import json import random import time import uuid import requests import redis import meilisearch_python_sdk from config import settings _redis = None _meili = None def meili_client(): global _meili if _meili is None: try: _meili = meilisearch_python_sdk.Client(settings.meili_url, settings.meili_api_key or None) except Exception: _meili = None return _meili def meili_index_doc(index_uid: str, doc: dict): c = meili_client() if not c: return False try: idx = c.index(index_uid) idx.add_documents([doc], primary_key='id') return True except Exception: return False def meili_search(index_uid: str, q: str, limit: int = 20): c = meili_client() if not c: return {"hits": []} try: idx = c.index(index_uid) res = idx.search(q, {'limit': limit}) if isinstance(res, dict): return res # sdk object fallback return { 'hits': getattr(res, 'hits', []), 'estimatedTotalHits': getattr(res, 'estimated_total_hits', None), 'processingTimeMs': getattr(res, 'processing_time_ms', None), 'query': q, } except Exception: return {"hits": []} def meili_health(): c = meili_client() if not c: return {"ok": False, "error": "client_unavailable"} try: h = c.health() status = getattr(h, 'status', None) if status is None and isinstance(h, dict): status = h.get('status') return {"ok": True, "status": status or 'available'} except Exception as e: return {"ok": False, "error": str(e)[:160]} 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: \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: # collection-specific r = requests.get(f"{settings.qdrant_url}/collections/{settings.qdrant_collection}", timeout=10) if r.ok: 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"), } # fallback global health/list r2 = requests.get(f"{settings.qdrant_url}/collections", timeout=10) if r2.ok: names=[c.get('name') for c in r2.json().get('result',{}).get('collections',[])] return {"ok": True, "collection": settings.qdrant_collection, "exists": settings.qdrant_collection in names, "collections": names[:20]} return {"ok": False, "status": r.status_code} 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