"""Slipstream — AI event-risk overlay (second-stage exclusion on the deterministic book). The 20 holdings are chosen by the systematic quant rule. This overlay then scans the candidate names for RECENT, CONCRETE, MATERIALLY-ADVERSE EVENTS the price/fundamentals may not have caught yet — legal, regulatory/FDA, geopolitical/trade, operational, competitive — and HARD-EXCLUDES any with a CRITICAL event only (since Oct 2026; "high" events are shown as watch flags and held). It is deliberately NOT a subjective "risk score": it only fires on a discrete event backed by a real headline, so ongoing/priced risks (general competition, valuation) can never trip it. Excluded names are replaced deterministically by the next-ranked candidate. Disciplined by design: capped exclusions, every exclusion cites a headline, cached once per UTC day, and it degrades to NO exclusions if news or the LLM is unavailable (the fund must always trade). """ from __future__ import annotations import json import os import re from datetime import datetime, timezone import config MAX_EXCLUSIONS = int(os.getenv("SLIPSTREAM_MAX_EXCLUSIONS", "3")) ENABLED = os.getenv("SLIPSTREAM_RISK_SCREEN", "1") == "1" CACHE = config.DATA_DIR / "risk_screen_cache.json" _SEV_RANK = {"none": 0, "medium": 1, "high": 2, "critical": 3} # Only events at or above this severity are excluded. "critical" = very high risk only: the backtested # rule has no overlay, so the live fund should deviate from it only for events that threaten the company. EXCLUDE_AT = os.getenv("SLIPSTREAM_EXCLUDE_SEVERITY", "critical").lower() # Deterministic backstop for the prompt's "analyst actions are never events" rule — the model # still returned `NFLX / medium / "Analyst target price cut and downgrade…"` (2026-09), citing a # Wells Fargo note. A flag whose triggering headline is analyst commentary is dropped unless the # same text also names a hard event. _ANALYST = re.compile( r"\b(analyst|downgrad\w*|upgrad\w*|price target|target price|rating|slash\w* (its )?target|" r"cuts? (its )?target|initiat\w* coverage|overweight|underweight|outperform|underperform)\b", re.I) _HARD_EVENT = re.compile( r"\b(FDA|SEC|DOJ|FTC|EU|indict\w*|verdict|injunction|consent decree|recall|sanction\w*|entity list|" r"export (ban|control)|tariff|bankrupt\w*|going.concern|restat\w*|resign\w*|steps? down|breach|" r"withdr\w* guidance|clinical hold|complete response|delist\w*|nationali[sz]\w*)\b", re.I) _PROMPT = """You are a risk officer screening a concentrated equity portfolio. For EACH ticker below, review its recent news headlines and decide whether a DISCRETE, RECENT, MATERIALLY-ADVERSE EVENT has occurred — something the market may not have fully priced. Only these event types count: • legal: SEC/DOJ action, indictment, adverse verdict/settlement, major patent loss • regulatory/FDA: complete response letter, clinical hold, adcom rejection, recall, license loss • geopolitical/trade: sanctions, entity-list addition, export ban, nationalization, tariff naming the company • operational: guidance withdrawal, going-concern, bankruptcy, major customer/contract loss, cyber-breach, accounting restatement, abrupt CEO/CFO exit • competitive: a SPECIFIC rival action (approval/launch) that directly displaces this company's product Do NOT flag ongoing or already-known risks (general competition, high valuation, cyclicality, macro, "analysts are cautious"). Analyst actions of ANY kind — rating changes, downgrades, price-target cuts, "worrying trends" commentary — are NEVER events: severity "none", even if the concern sounds operational. If there is no concrete adverse event in the headlines, severity is "none". Severity: none | medium | high | critical. Use high/critical only for events that plausibly impair value materially. MATERIALITY MUST BE JUDGED RELATIVE TO COMPANY SIZE (use your knowledge of each company's approximate market capitalization): • "high" requires plausible exposure of roughly >5% of the company's market value, OR a threat to the core business model / license to operate. • A civil lawsuit or regulatory complaint at the FILING stage against a large company, where the claimed amount is under ~2% of its market value, is at most "medium" (monitor, do not exclude) — a $20B claim is existential for a $30B company but a rounding error for a $2T one, and mega-cap antitrust filings historically resolve over years without impairing 12-month returns. • Adverse VERDICTS, injunctions, consent decrees, criminal indictments, or forced structural changes CAN be "high" regardless of company size. • "critical" is reserved for VERY HIGH risk — an event that threatens the company's survival or a large part of its value: bankruptcy / going-concern warning, fraud or accounting restatement, criminal indictment of the company or its CEO/CFO, loss of the license to operate (e.g. a key drug's approval withdrawn, delisting), sanctions/export bans cutting off a major share of revenue, or plausible exposure above ~20% of market value. When in doubt between "high" and "critical", choose "high". • If headlines show the stock already fell only modestly (under ~5%) on the news, the market has likely priced it — lean "medium" unless the event meets the tests above. Return ONLY a JSON object mapping each ticker to: {{"severity": "...", "category": "...", "event": "", "headline": ""}} HEADLINES: {blocks}""" def _load_cache(): try: return json.loads(CACHE.read_text()) except Exception: return {} def _classify(pool: list[str], news_by_tkr: dict) -> dict: """One batched Gemini call → {ticker: {severity, category, event, headline}}.""" from services.llm import _call_gemini blocks = [] for t in pool: hls = news_by_tkr.get(t, []) if not hls: continue lines = "\n".join(f" - {h['headline']}" for h in hls[:8]) blocks.append(f"{t}:\n{lines}") if not blocks: return {} raw = _call_gemini(_PROMPT.format(blocks="\n\n".join(blocks))) m = raw[raw.find("{"): raw.rfind("}") + 1] data = json.loads(m) out = {} for t, v in data.items(): if isinstance(v, dict) and str(v.get("severity", "none")).lower() != "none": text = f"{v.get('headline', '')} {v.get('event', '')}" if _ANALYST.search(text) and not _HARD_EVENT.search(text): continue # analyst commentary ≠ event out[t.upper()] = {"severity": str(v.get("severity", "none")).lower(), "category": str(v.get("category", ""))[:40], "event": str(v.get("event", ""))[:200], "headline": str(v.get("headline", ""))[:200]} return out def screen(pool: list[str]) -> dict: """Return {excluded: set, meta: {...}} for a candidate pool. Cached per UTC day; safe on failure.""" today = datetime.now(timezone.utc).strftime("%Y-%m-%d") pool = [t for t in pool if t] key = today + "|" + EXCLUDE_AT + "|" + ",".join(sorted(pool)) if not (ENABLED and config.GEMINI_API_KEY): return {"excluded": set(), "meta": {"enabled": False, "scanned": 0, "cleared": 0, "excluded": [], "as_of": today}} cache = _load_cache() if cache.get("key") == key: c = cache["meta"] return {"excluded": set(x["ticker"] for x in c["excluded"]), "meta": c} excluded_list, flags = [], {} try: import alpaca_client as alpaca news_by_tkr = alpaca.news(pool) flags = _classify(pool, news_by_tkr) ranked = sorted(flags.items(), key=lambda kv: -_SEV_RANK.get(kv[1]["severity"], 0)) for t, f in ranked: if _SEV_RANK.get(f["severity"], 0) >= _SEV_RANK.get(EXCLUDE_AT, 3) and len(excluded_list) < MAX_EXCLUSIONS: excluded_list.append({"ticker": t, **f}) except Exception as e: # any failure (no news, LLM error, bad JSON) → no exclusions, fund still trades meta = {"enabled": True, "scanned": len(pool), "cleared": len(pool), "excluded": [], "as_of": today, "note": f"screen unavailable ({type(e).__name__}) — no exclusions applied"} return {"excluded": set(), "meta": meta} meta = {"enabled": True, "scanned": len(pool), "cleared": len(pool) - len(excluded_list), "excluded": excluded_list, "exclude_at": EXCLUDE_AT, "flags_medium": [{"ticker": t, **f} for t, f in sorted(flags.items(), key=lambda kv: -_SEV_RANK.get(kv[1]["severity"], 0)) if 0 < _SEV_RANK.get(f["severity"], 0) < _SEV_RANK.get(EXCLUDE_AT, 3)][:5], "as_of": today} try: CACHE.write_text(json.dumps({"key": key, "meta": meta})) except Exception: pass return {"excluded": set(x["ticker"] for x in excluded_list), "meta": meta}