325 lines
12 KiB
Python
325 lines
12 KiB
Python
import json
|
|
import random
|
|
import time
|
|
import uuid
|
|
import requests
|
|
import redis
|
|
from config import settings
|
|
|
|
_redis = None
|
|
|
|
def redis_client():
|
|
global _redis
|
|
if _redis is None:
|
|
try:
|
|
_redis = redis.Redis.from_url(settings.redis_url, decode_responses=True, socket_timeout=2)
|
|
_redis.ping()
|
|
except Exception:
|
|
_redis = None
|
|
return _redis
|
|
|
|
def redis_set_json(key: str, value: dict, ttl: int = 3600):
|
|
r = redis_client()
|
|
if not r:
|
|
return False
|
|
try:
|
|
r.setex(key, ttl, json.dumps(value))
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def redis_get_json(key: str):
|
|
r = redis_client()
|
|
if not r:
|
|
return None
|
|
try:
|
|
v = r.get(key)
|
|
return json.loads(v) if v else None
|
|
except Exception:
|
|
return None
|
|
|
|
def redis_lock(key: str, ttl: int = 180):
|
|
r = redis_client()
|
|
if not r:
|
|
return True
|
|
try:
|
|
return bool(r.set(key, str(int(time.time())), ex=ttl, nx=True))
|
|
except Exception:
|
|
return True
|
|
|
|
|
|
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()
|
|
return json.loads(r.json().get("response", "{}"))
|
|
|
|
|
|
def _embed(text: str):
|
|
try:
|
|
r = requests.post(f"{settings.ollama_url}/api/embeddings", json={"model": settings.ollama_embed_model, "prompt": text[:8000]}, timeout=30)
|
|
r.raise_for_status()
|
|
return r.json().get("embedding", [])
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def qdrant_ensure_collection(vector_size=768):
|
|
try:
|
|
requests.put(f"{settings.qdrant_url}/collections/{settings.qdrant_collection}", json={"vectors": {"size": vector_size, "distance": "Cosine"}}, timeout=10)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def qdrant_add_memory(symbol: str, text: str, payload: dict):
|
|
vec = _embed(text)
|
|
if not vec:
|
|
return False
|
|
qdrant_ensure_collection(len(vec))
|
|
enriched = {
|
|
"symbol": symbol,
|
|
"text": text[:2000],
|
|
"memory_type": payload.get("memory_type", "decision"),
|
|
"created_at": int(time.time()),
|
|
**payload,
|
|
}
|
|
body = {
|
|
"points": [{"id": str(uuid.uuid4()), "vector": vec, "payload": enriched}]
|
|
}
|
|
try:
|
|
r = requests.put(f"{settings.qdrant_url}/collections/{settings.qdrant_collection}/points", json=body, timeout=15)
|
|
return r.ok
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def qdrant_similar(symbol: str, query_text: str, limit: int = 8):
|
|
vec = _embed(query_text)
|
|
if not vec:
|
|
return []
|
|
body = {
|
|
"vector": vec,
|
|
"limit": limit,
|
|
"with_payload": True,
|
|
"filter": {
|
|
"should": [
|
|
{"key": "symbol", "match": {"value": symbol}},
|
|
{"key": "memory_type", "match": {"value": "macro"}}
|
|
]
|
|
}
|
|
}
|
|
try:
|
|
r = requests.post(f"{settings.qdrant_url}/collections/{settings.qdrant_collection}/points/search", json=body, timeout=15)
|
|
if not r.ok:
|
|
return []
|
|
out = []
|
|
for p in r.json().get("result", []):
|
|
pl = p.get("payload", {})
|
|
out.append({
|
|
"score": p.get("score", 0),
|
|
"text": pl.get("text", ""),
|
|
"status": pl.get("status", ""),
|
|
"action": pl.get("action", ""),
|
|
"confidence": pl.get("confidence", None),
|
|
"outcome_score": pl.get("outcome_score", None),
|
|
})
|
|
return out
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
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:
|
|
r = requests.get(settings.searx_url, params=params, timeout=20)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
return [{"title": it.get("title", ""), "url": it.get("url", ""), "content": (it.get("content", "") or "")[:700]} for it in data.get("results", [])[:limit]]
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def extra_research(symbol: str, weak_points: list, limit: int = 6):
|
|
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()
|
|
return [{"title": it.get("title", ""), "url": it.get("url", ""), "content": (it.get("content", "") or "")[:700]} for it in data.get("results", [])[:limit]]
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def summarize_news_with_ollama(symbol: str, context_items: list):
|
|
prompt = {"task": "Summarize market-moving info into a concise brief", "symbol": symbol, "news": context_items}
|
|
fallback = " | ".join([(x.get("title") or "")[:90] for x in context_items[:3] if x.get("title")]) or f"No strong headlines for {symbol}"
|
|
try:
|
|
parsed = _ollama_generate(settings.ollama_curator_model, prompt)
|
|
s = parsed.get("summary")
|
|
return s if s else fallback
|
|
except Exception:
|
|
return fallback
|
|
|
|
|
|
def strategy_signals(symbol: str, context_items: list):
|
|
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"])
|
|
score = bullish - bearish
|
|
action = "buy" if score >= 2 else ("sell" if score <= -2 else "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 _normalize_decision(d: dict, strategy: dict):
|
|
action = str(d.get("action", strategy.get("action", "hold"))).lower()
|
|
if action not in {"buy", "sell", "hold"}:
|
|
action = strategy.get("action", "hold")
|
|
try:
|
|
confidence = max(0.0, min(1.0, float(d.get("confidence", strategy.get("confidence", 0.5)))))
|
|
except Exception:
|
|
confidence = min(strategy.get("confidence", 0.5), 0.55)
|
|
try:
|
|
order_usd = float(d.get("order_usd", settings.max_order_usd))
|
|
except Exception:
|
|
order_usd = settings.max_order_usd
|
|
order_usd = max(1.0, min(order_usd, settings.max_order_usd))
|
|
reason = str(d.get("reason", "normalized-decision"))[:500]
|
|
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 [],
|
|
}
|
|
|
|
|
|
def llm_final_decision(symbol: str, context_items: list, strategy: dict, memory_hits: list):
|
|
prompt = {
|
|
"task": "Final trading decision. Return strict JSON.",
|
|
"symbol": symbol,
|
|
"constraints": {"actions": ["buy", "sell", "hold"], "max_order_usd": settings.max_order_usd, "fee_per_trade_usd": settings.fee_per_trade_usd, "slippage_bps": settings.slippage_bps, "avoid_overtrading": True},
|
|
"strategy_prior": strategy,
|
|
"memory_hits": memory_hits,
|
|
"news": context_items,
|
|
"output_schema": {"action": "buy|sell|hold", "confidence": "0-1", "order_usd": f"<= {settings.max_order_usd}", "reason": "short rationale", "needs_more_research": True, "research_topics": ["..."]},
|
|
}
|
|
# pass 1: strict json mode
|
|
try:
|
|
d = _ollama_generate(settings.ollama_decision_model, prompt, timeout=60)
|
|
return _normalize_decision(d, strategy)
|
|
except Exception:
|
|
pass
|
|
|
|
# pass 2: non-json constrained output, then parse heuristically
|
|
try:
|
|
text_prompt = (
|
|
f"Symbol: {symbol}\n"
|
|
f"Strategy prior: {strategy}\n"
|
|
f"Return 4 lines only:\n"
|
|
f"action: buy|sell|hold\nconfidence: 0-1\norder_usd: <= {settings.max_order_usd}\nreason: <short>\n"
|
|
)
|
|
r = requests.post(f"{settings.ollama_url}/api/generate", json={"model": settings.ollama_decision_model, "prompt": text_prompt, "stream": False}, timeout=45)
|
|
if r.ok:
|
|
raw = (r.json().get("response", "") or "").lower()
|
|
action = "buy" if "buy" in raw else ("sell" if "sell" in raw else "hold")
|
|
conf = 0.6 if "confidence" not in raw else strategy.get("confidence", 0.55)
|
|
parsed = {"action": action, "confidence": conf, "order_usd": settings.max_order_usd, "reason": raw[:300]}
|
|
return _normalize_decision(parsed, strategy)
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"action": strategy.get("action", "hold"),
|
|
"confidence": min(strategy.get("confidence", 0.5), 0.55),
|
|
"order_usd": min(5.0, settings.max_order_usd),
|
|
"reason": "decision-fallback-strategy",
|
|
"needs_more_research": False,
|
|
"research_topics": [],
|
|
}
|
|
|
|
|
|
def alpaca_headers():
|
|
return {"APCA-API-KEY-ID": settings.alpaca_key, "APCA-API-SECRET-KEY": settings.alpaca_secret, "Content-Type": "application/json"}
|
|
|
|
|
|
def place_order(symbol: str, action: str, order_usd: float):
|
|
if action not in {"buy", "sell"}:
|
|
return None
|
|
payload = {"symbol": symbol, "side": action, "type": "market", "time_in_force": "day", "notional": round(order_usd, 2)}
|
|
try:
|
|
r = requests.post(f"{settings.alpaca_base}/v2/orders", headers=alpaca_headers(), json=payload, timeout=20)
|
|
return {"ok": r.ok, "status": r.status_code, "json": r.json() if r.text else {}}
|
|
except Exception as e:
|
|
return {"ok": False, "status": 0, "json": {"error": str(e)}}
|
|
|
|
|
|
def account_snapshot():
|
|
try:
|
|
r = requests.get(f"{settings.alpaca_base}/v2/account", headers=alpaca_headers(), timeout=20)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def positions_snapshot():
|
|
try:
|
|
r = requests.get(f"{settings.alpaca_base}/v2/positions", headers=alpaca_headers(), timeout=20)
|
|
return r.json() if r.ok else []
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def market_open():
|
|
try:
|
|
r = requests.get(f"{settings.alpaca_base}/v2/clock", headers=alpaca_headers(), timeout=20)
|
|
return bool(r.json().get("is_open", False)) if r.ok else False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def trilium_log(title: str, body: str):
|
|
if not settings.trilium_token:
|
|
return False
|
|
headers = {"Authorization": settings.trilium_token, "Content-Type": "application/json"}
|
|
payload = {"title": title[:120], "type": "text", "mime": "text/markdown", "content": body}
|
|
try:
|
|
# best-effort endpoints across Trilium variants
|
|
for ep in ["/etapi/create-note", "/etapi/notes"]:
|
|
r = requests.post(settings.trilium_url.rstrip("/") + ep, headers=headers, json=payload, timeout=15)
|
|
if r.ok:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def qdrant_memory_health():
|
|
try:
|
|
r = requests.get(f"{settings.qdrant_url}/collections/{settings.qdrant_collection}", timeout=10)
|
|
if not r.ok:
|
|
return {"ok": False, "status": r.status_code}
|
|
j = r.json().get("result", {})
|
|
return {
|
|
"ok": True,
|
|
"collection": settings.qdrant_collection,
|
|
"points_count": j.get("points_count"),
|
|
"indexed_vectors_count": j.get("indexed_vectors_count"),
|
|
}
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e)[:160]}
|
|
|
|
|
|
def n8n_emit(event: dict):
|
|
if not settings.n8n_webhook:
|
|
return False
|
|
try:
|
|
r = requests.post(settings.n8n_webhook, json=event, timeout=10)
|
|
return r.ok
|
|
except Exception:
|
|
return False
|