From cb5edf694dfeed5b868814bf7ffb0ead2fcbb42f Mon Sep 17 00:00:00 2001 From: drjones Date: Thu, 26 Feb 2026 01:38:55 -0800 Subject: [PATCH] v1.2 strategy upgrade: small-capital fee-aware signals, two-model pipeline, auto deep-research escalation --- .env.example | 12 +++-- bot.py | 35 ++++++++++--- config.py | 17 +++++-- services.py | 137 ++++++++++++++++++++++++++++++++++++++------------- 4 files changed, 153 insertions(+), 48 deletions(-) diff --git a/.env.example b/.env.example index fd5697c..149989e 100644 --- a/.env.example +++ b/.env.example @@ -5,17 +5,21 @@ ALPACA_BASE_URL=https://paper-api.alpaca.markets # Runtime PAPER_MODE=true +STARTING_CAPITAL_USD=100 MAX_ORDER_USD=5 -MAX_DAILY_NOTIONAL=50 -MAX_OPEN_POSITIONS=6 -MIN_CONFIDENCE=0.60 +MAX_DAILY_NOTIONAL=40 +MAX_OPEN_POSITIONS=8 +MIN_CONFIDENCE=0.62 +FEE_PER_TRADE_USD=0.00 +SLIPPAGE_BPS=5 TRADE_INTERVAL_HOURS=2 CURATE_INTERVAL_MINUTES=30 TIMEZONE=America/Los_Angeles # Ollama 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 SEARX_URL=http://10.30.20.35:6969/search diff --git a/bot.py b/bot.py index 7c47798..e1f4007 100644 --- a/bot.py +++ b/bot.py @@ -4,7 +4,16 @@ import json from sqlalchemy import func from config import settings 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) @@ -45,13 +54,21 @@ def run_cycle(): break 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( symbol=symbol, action=decision["action"], confidence=decision["confidence"], - reason=decision["reason"], + reason=f"{decision['reason']} | strat={strat['strategy']} score={strat['score']}", market_context=json.dumps(news)[:60000], order_usd=decision["order_usd"], status="planned", @@ -66,8 +83,14 @@ def run_cycle(): ) if should_trade: - notional = min(settings.max_order_usd, decision["order_usd"], settings.max_daily_notional - spent) - if notional <= 0: + # Fee/slippage-aware cap for tiny bankroll + 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" db.add(drow) db.commit() @@ -83,7 +106,7 @@ def run_cycle(): qty=float((res or {}).get("json", {}).get("qty", 0) or 0), notional=notional, 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() if ok: diff --git a/config.py b/config.py index 61b0c87..1f6c5b8 100644 --- a/config.py +++ b/config.py @@ -9,21 +9,32 @@ class Settings: alpaca_base = os.getenv("ALPACA_BASE_URL", "https://paper-api.alpaca.markets") 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_daily_notional = float(os.getenv("MAX_DAILY_NOTIONAL", "50")) - max_open_positions = int(os.getenv("MAX_OPEN_POSITIONS", "6")) + max_daily_notional = float(os.getenv("MAX_DAILY_NOTIONAL", "40")) + max_open_positions = int(os.getenv("MAX_OPEN_POSITIONS", "8")) 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")) curate_interval_minutes = int(os.getenv("CURATE_INTERVAL_MINUTES", "30")) 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_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") scraper_api = os.getenv("SCRAPER_API_URL", "http://10.30.20.115:24125") + # App db_path = os.getenv("DB_PATH", "sqlite:///./bot.db") host = os.getenv("APP_HOST", "0.0.0.0") port = int(os.getenv("APP_PORT", "8089")) diff --git a/services.py b/services.py index 5c4c291..5bbfa8e 100644 --- a/services.py +++ b/services.py @@ -4,7 +4,20 @@ import requests 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" params = {"q": q, "format": "json", "language": "en"} try: @@ -13,75 +26,129 @@ def searx_news(symbol: str, limit: int = 10): 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", "")[: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 except Exception: return [] def summarize_news_with_ollama(symbol: str, context_items: list): - payload = { - "model": settings.ollama_model, - "stream": False, - "prompt": json.dumps({ - "task": "Summarize market-moving info into a concise, neutral brief.", - "symbol": symbol, - "news": context_items, - "format": {"summary": "<=140 words", "bullish_points": ["..."], "bearish_points": ["..."]} - }), - "format": "json", + prompt = { + "task": "Summarize market-moving info into a concise, neutral brief.", + "symbol": symbol, + "news": context_items, + "format": { + "summary": "<=140 words", + "bullish_points": ["..."], + "bearish_points": ["..."], + "uncertainties": ["..."] + } } try: - r = requests.post(f"{settings.ollama_url}/api/generate", json=payload, timeout=45) - r.raise_for_status() - resp = r.json().get("response", "{}") - parsed = json.loads(resp) + parsed = _ollama_generate(settings.ollama_curator_model, prompt) return parsed.get("summary", "no-summary") except Exception: 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 = { - "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": { "actions": ["buy", "sell", "hold"], "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, "output_schema": { "action": "buy|sell|hold", "confidence": "0-1", - "reason": "short rationale", "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: - r = requests.post(f"{settings.ollama_url}/api/generate", json=payload, timeout=45) - r.raise_for_status() - resp = r.json().get("response", "{}") - d = json.loads(resp) + d = _ollama_generate(settings.ollama_decision_model, prompt, timeout=60) action = str(d.get("action", "hold")).lower() if action not in {"buy", "sell", "hold"}: action = "hold" 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) 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: return { - "action": random.choice(["hold", "hold", "buy"]), - "confidence": 0.3, + "action": strategy.get("action", "hold"), + "confidence": min(strategy.get("confidence", 0.5), 0.55), "order_usd": min(1.0, settings.max_order_usd), - "reason": "fallback-mode", + "reason": "decision-fallback-strategy", + "needs_more_research": False, + "research_topics": [], }