v1.2 strategy upgrade: small-capital fee-aware signals, two-model pipeline, auto deep-research escalation
This commit is contained in:
12
.env.example
12
.env.example
@@ -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
35
bot.py
@@ -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:
|
||||||
|
|||||||
17
config.py
17
config.py
@@ -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"))
|
||||||
|
|||||||
131
services.py
131
services.py
@@ -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,
|
|
||||||
"stream": False,
|
|
||||||
"prompt": json.dumps({
|
|
||||||
"task": "Summarize market-moving info into a concise, neutral brief.",
|
"task": "Summarize market-moving info into a concise, neutral brief.",
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"news": context_items,
|
"news": context_items,
|
||||||
"format": {"summary": "<=140 words", "bullish_points": ["..."], "bearish_points": ["..."]}
|
"format": {
|
||||||
}),
|
"summary": "<=140 words",
|
||||||
"format": "json",
|
"bullish_points": ["..."],
|
||||||
|
"bearish_points": ["..."],
|
||||||
|
"uncertainties": ["..."]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user