slipstream / risk_screen.py
s-ttp's picture
Upload folder using huggingface_hub
261cef9 verified
Raw History Blame Contribute Delete
9.03 kB
"""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": "<one factual line>", "headline": "<the triggering headline, verbatim>"}}
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}