v1.2 strategy upgrade: small-capital fee-aware signals, two-model pipeline, auto deep-research escalation

This commit is contained in:
2026-02-26 01:38:55 -08:00
parent 080e66980f
commit cb5edf694d
4 changed files with 153 additions and 48 deletions

View File

@@ -5,17 +5,21 @@ ALPACA_BASE_URL=https://paper-api.alpaca.markets
# Runtime # Runtime
PAPER_MODE=true PAPER_MODE=true
STARTING_CAPITAL_USD=100
MAX_ORDER_USD=5 MAX_ORDER_USD=5
MAX_DAILY_NOTIONAL=50 MAX_DAILY_NOTIONAL=40
MAX_OPEN_POSITIONS=6 MAX_OPEN_POSITIONS=8
MIN_CONFIDENCE=0.60 MIN_CONFIDENCE=0.62
FEE_PER_TRADE_USD=0.00
SLIPPAGE_BPS=5
TRADE_INTERVAL_HOURS=2 TRADE_INTERVAL_HOURS=2
CURATE_INTERVAL_MINUTES=30 CURATE_INTERVAL_MINUTES=30
TIMEZONE=America/Los_Angeles TIMEZONE=America/Los_Angeles
# Ollama # Ollama
OLLAMA_URL=http://10.30.20.110:11434 OLLAMA_URL=http://10.30.20.110:11434
OLLAMA_MODEL=gemma3:latest OLLAMA_CURATOR_MODEL=gemma3:latest
OLLAMA_DECISION_MODEL=agent-oss:latest
# Data sources # Data sources
SEARX_URL=http://10.30.20.35:6969/search SEARX_URL=http://10.30.20.35:6969/search

35
bot.py
View File

@@ -4,7 +4,16 @@ import json
from sqlalchemy import func from sqlalchemy import func
from config import settings from config import settings
from db import SessionLocal, BotDecision, TradeExecution, CuratedInsight from db import SessionLocal, BotDecision, TradeExecution, CuratedInsight
from services import searx_news, ollama_decide, place_order, market_open, positions_snapshot, summarize_news_with_ollama from services import (
searx_news,
extra_research,
summarize_news_with_ollama,
strategy_signals,
llm_final_decision,
place_order,
market_open,
positions_snapshot,
)
scheduler = BackgroundScheduler(timezone=settings.timezone) scheduler = BackgroundScheduler(timezone=settings.timezone)
@@ -45,13 +54,21 @@ def run_cycle():
break break
news = searx_news(symbol) news = searx_news(symbol)
decision = ollama_decide(symbol, news) strat = strategy_signals(symbol, news)
decision = llm_final_decision(symbol, news, strat)
# Escalate to deeper research when model asks or confidence weak
if decision.get("needs_more_research") or decision.get("confidence", 0) < settings.min_confidence:
more = extra_research(symbol, decision.get("research_topics", []))
if more:
news = news + more
decision = llm_final_decision(symbol, news, strat)
drow = BotDecision( drow = BotDecision(
symbol=symbol, symbol=symbol,
action=decision["action"], action=decision["action"],
confidence=decision["confidence"], confidence=decision["confidence"],
reason=decision["reason"], reason=f"{decision['reason']} | strat={strat['strategy']} score={strat['score']}",
market_context=json.dumps(news)[:60000], market_context=json.dumps(news)[:60000],
order_usd=decision["order_usd"], order_usd=decision["order_usd"],
status="planned", status="planned",
@@ -66,8 +83,14 @@ def run_cycle():
) )
if should_trade: if should_trade:
notional = min(settings.max_order_usd, decision["order_usd"], settings.max_daily_notional - spent) # Fee/slippage-aware cap for tiny bankroll
if notional <= 0: effective_cost = settings.fee_per_trade_usd + (settings.slippage_bps / 10000.0) * decision["order_usd"]
notional = min(
settings.max_order_usd,
decision["order_usd"],
settings.max_daily_notional - spent,
)
if notional <= effective_cost:
drow.status = "risk_blocked" drow.status = "risk_blocked"
db.add(drow) db.add(drow)
db.commit() db.commit()
@@ -83,7 +106,7 @@ def run_cycle():
qty=float((res or {}).get("json", {}).get("qty", 0) or 0), qty=float((res or {}).get("json", {}).get("qty", 0) or 0),
notional=notional, notional=notional,
alpaca_order_id=(res or {}).get("json", {}).get("id", ""), alpaca_order_id=(res or {}).get("json", {}).get("id", ""),
raw=json.dumps(res)[:60000], raw=json.dumps({"decision": decision, "strategy": strat, "broker": res})[:60000],
)) ))
db.commit() db.commit()
if ok: if ok:

View File

@@ -9,21 +9,32 @@ class Settings:
alpaca_base = os.getenv("ALPACA_BASE_URL", "https://paper-api.alpaca.markets") alpaca_base = os.getenv("ALPACA_BASE_URL", "https://paper-api.alpaca.markets")
paper_mode = os.getenv("PAPER_MODE", "true").lower() == "true" paper_mode = os.getenv("PAPER_MODE", "true").lower() == "true"
# Capital/risk profile
starting_capital_usd = float(os.getenv("STARTING_CAPITAL_USD", "100"))
max_order_usd = float(os.getenv("MAX_ORDER_USD", "5")) max_order_usd = float(os.getenv("MAX_ORDER_USD", "5"))
max_daily_notional = float(os.getenv("MAX_DAILY_NOTIONAL", "50")) max_daily_notional = float(os.getenv("MAX_DAILY_NOTIONAL", "40"))
max_open_positions = int(os.getenv("MAX_OPEN_POSITIONS", "6")) max_open_positions = int(os.getenv("MAX_OPEN_POSITIONS", "8"))
min_confidence = float(os.getenv("MIN_CONFIDENCE", "0.60")) min_confidence = float(os.getenv("MIN_CONFIDENCE", "0.60"))
# Approx fee model for small-size optimization
fee_per_trade_usd = float(os.getenv("FEE_PER_TRADE_USD", "0.00"))
slippage_bps = float(os.getenv("SLIPPAGE_BPS", "5"))
# Scheduling
trade_interval_hours = int(os.getenv("TRADE_INTERVAL_HOURS", "2")) trade_interval_hours = int(os.getenv("TRADE_INTERVAL_HOURS", "2"))
curate_interval_minutes = int(os.getenv("CURATE_INTERVAL_MINUTES", "30")) curate_interval_minutes = int(os.getenv("CURATE_INTERVAL_MINUTES", "30"))
timezone = os.getenv("TIMEZONE", "America/Los_Angeles") timezone = os.getenv("TIMEZONE", "America/Los_Angeles")
# LLM stack (small for curation, larger for final decision)
ollama_url = os.getenv("OLLAMA_URL", "http://10.30.20.110:11434") ollama_url = os.getenv("OLLAMA_URL", "http://10.30.20.110:11434")
ollama_model = os.getenv("OLLAMA_MODEL", "gemma3:latest") ollama_curator_model = os.getenv("OLLAMA_CURATOR_MODEL", "gemma3:latest")
ollama_decision_model = os.getenv("OLLAMA_DECISION_MODEL", "agent-oss:latest")
# Data sources
searx_url = os.getenv("SEARX_URL", "http://10.30.20.35:6969/search") searx_url = os.getenv("SEARX_URL", "http://10.30.20.35:6969/search")
scraper_api = os.getenv("SCRAPER_API_URL", "http://10.30.20.115:24125") scraper_api = os.getenv("SCRAPER_API_URL", "http://10.30.20.115:24125")
# App
db_path = os.getenv("DB_PATH", "sqlite:///./bot.db") db_path = os.getenv("DB_PATH", "sqlite:///./bot.db")
host = os.getenv("APP_HOST", "0.0.0.0") host = os.getenv("APP_HOST", "0.0.0.0")
port = int(os.getenv("APP_PORT", "8089")) port = int(os.getenv("APP_PORT", "8089"))

View File

@@ -4,7 +4,20 @@ import requests
from config import settings from config import settings
def searx_news(symbol: str, limit: int = 10): def _ollama_generate(model: str, payload_obj: dict, timeout: int = 45):
payload = {
"model": model,
"stream": False,
"prompt": json.dumps(payload_obj),
"format": "json",
}
r = requests.post(f"{settings.ollama_url}/api/generate", json=payload, timeout=timeout)
r.raise_for_status()
resp = r.json().get("response", "{}")
return json.loads(resp)
def searx_news(symbol: str, limit: int = 12):
q = f"{symbol} stock news earnings guidance analyst macro risk" q = f"{symbol} stock news earnings guidance analyst macro risk"
params = {"q": q, "format": "json", "language": "en"} params = {"q": q, "format": "json", "language": "en"}
try: try:
@@ -13,75 +26,129 @@ def searx_news(symbol: str, limit: int = 10):
data = r.json() data = r.json()
out = [] out = []
for it in data.get("results", [])[:limit]: for it in data.get("results", [])[:limit]:
out.append({"title": it.get("title", ""), "url": it.get("url", ""), "content": it.get("content", "")[:500]}) out.append({
"title": it.get("title", ""),
"url": it.get("url", ""),
"content": (it.get("content", "") or "")[:700],
})
return out
except Exception:
return []
def extra_research(symbol: str, weak_points: list, limit: int = 6):
"""Second-pass targeted research when confidence/coverage is weak."""
q = f"{symbol} {' '.join(weak_points[:3])} SEC filing guidance risks competition"
params = {"q": q, "format": "json", "language": "en"}
try:
r = requests.get(settings.searx_url, params=params, timeout=20)
r.raise_for_status()
data = r.json()
out = []
for it in data.get("results", [])[:limit]:
out.append({
"title": it.get("title", ""),
"url": it.get("url", ""),
"content": (it.get("content", "") or "")[:700],
})
return out return out
except Exception: except Exception:
return [] return []
def summarize_news_with_ollama(symbol: str, context_items: list): def summarize_news_with_ollama(symbol: str, context_items: list):
payload = { prompt = {
"model": settings.ollama_model, "task": "Summarize market-moving info into a concise, neutral brief.",
"stream": False, "symbol": symbol,
"prompt": json.dumps({ "news": context_items,
"task": "Summarize market-moving info into a concise, neutral brief.", "format": {
"symbol": symbol, "summary": "<=140 words",
"news": context_items, "bullish_points": ["..."],
"format": {"summary": "<=140 words", "bullish_points": ["..."], "bearish_points": ["..."]} "bearish_points": ["..."],
}), "uncertainties": ["..."]
"format": "json", }
} }
try: try:
r = requests.post(f"{settings.ollama_url}/api/generate", json=payload, timeout=45) parsed = _ollama_generate(settings.ollama_curator_model, prompt)
r.raise_for_status()
resp = r.json().get("response", "{}")
parsed = json.loads(resp)
return parsed.get("summary", "no-summary") return parsed.get("summary", "no-summary")
except Exception: except Exception:
return "summary-unavailable" return "summary-unavailable"
def ollama_decide(symbol: str, context_items: list): def strategy_signals(symbol: str, context_items: list):
"""Proven-ish small-capital rules encoded as interpretable signals."""
text_blob = " ".join((x.get("title", "") + " " + x.get("content", "")) for x in context_items).lower()
bullish = sum(k in text_blob for k in ["beat", "raise guidance", "upgrade", "buyback", "record revenue"])
bearish = sum(k in text_blob for k in ["miss", "downgrade", "lawsuit", "probe", "cut guidance", "recall"])
# simple event momentum score
score = bullish - bearish
# conservative policy for tiny capital: only trade stronger score edges
if score >= 2:
action = "buy"
elif score <= -2:
action = "sell"
else:
action = "hold"
conf = min(0.85, 0.50 + abs(score) * 0.08)
return {
"strategy": "event-momentum-v1",
"score": score,
"action": action,
"confidence": conf,
"signals": {"bullish": bullish, "bearish": bearish},
}
def llm_final_decision(symbol: str, context_items: list, strategy: dict):
prompt = { prompt = {
"task": "You are a conservative autonomous trading policy engine. Return strict JSON only.", "task": "Final trading decision using all context and a conservative small-capital profile. Return strict JSON.",
"symbol": symbol,
"constraints": { "constraints": {
"actions": ["buy", "sell", "hold"], "actions": ["buy", "sell", "hold"],
"max_order_usd": settings.max_order_usd, "max_order_usd": settings.max_order_usd,
"risk": "do not overtrade; prefer hold on weak signal", "min_expected_edge_after_fees": "positive",
"fee_per_trade_usd": settings.fee_per_trade_usd,
"slippage_bps": settings.slippage_bps,
"avoid_overtrading": True,
}, },
"symbol": symbol, "strategy_prior": strategy,
"news": context_items, "news": context_items,
"output_schema": { "output_schema": {
"action": "buy|sell|hold", "action": "buy|sell|hold",
"confidence": "0-1", "confidence": "0-1",
"reason": "short rationale",
"order_usd": f"<= {settings.max_order_usd}", "order_usd": f"<= {settings.max_order_usd}",
"reason": "short rationale",
"needs_more_research": True,
"research_topics": ["..."]
}, },
} }
payload = {
"model": settings.ollama_model,
"prompt": json.dumps(prompt),
"stream": False,
"format": "json",
}
try: try:
r = requests.post(f"{settings.ollama_url}/api/generate", json=payload, timeout=45) d = _ollama_generate(settings.ollama_decision_model, prompt, timeout=60)
r.raise_for_status()
resp = r.json().get("response", "{}")
d = json.loads(resp)
action = str(d.get("action", "hold")).lower() action = str(d.get("action", "hold")).lower()
if action not in {"buy", "sell", "hold"}: if action not in {"buy", "sell", "hold"}:
action = "hold" action = "hold"
confidence = max(0.0, min(1.0, float(d.get("confidence", 0.5)))) confidence = max(0.0, min(1.0, float(d.get("confidence", 0.5))))
order_usd = min(float(d.get("order_usd", settings.max_order_usd)), settings.max_order_usd) order_usd = min(float(d.get("order_usd", settings.max_order_usd)), settings.max_order_usd)
reason = d.get("reason", "fallback") reason = d.get("reason", "fallback")
return {"action": action, "confidence": confidence, "order_usd": order_usd, "reason": reason} return {
"action": action,
"confidence": confidence,
"order_usd": order_usd,
"reason": reason,
"needs_more_research": bool(d.get("needs_more_research", False)),
"research_topics": d.get("research_topics", []) or [],
}
except Exception: except Exception:
return { return {
"action": random.choice(["hold", "hold", "buy"]), "action": strategy.get("action", "hold"),
"confidence": 0.3, "confidence": min(strategy.get("confidence", 0.5), 0.55),
"order_usd": min(1.0, settings.max_order_usd), "order_usd": min(1.0, settings.max_order_usd),
"reason": "fallback-mode", "reason": "decision-fallback-strategy",
"needs_more_research": False,
"research_topics": [],
} }