From 67793608e4357786b8e6b9fdce28e5684063a026 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sun, 26 Jul 2026 16:41:13 +0200 Subject: [PATCH] feat: risk --- backend/services/database.py | 58 ++++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/backend/services/database.py b/backend/services/database.py index 6a5836f..49c5596 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -1681,6 +1681,10 @@ def add_position(pos: Dict[str, Any]) -> str: legs = pos.get("legs", []) num_contracts = sum(abs(leg.get("quantity", 1)) for leg in legs) ib_fees = compute_ib_fees(num_contracts) + # Normalize/infer at write time too (defense in depth) so the stored value is already + # canonical for any future reader that doesn't go through get_portfolio_exposure()'s + # own normalization — an empty/malformed asset_class no longer needs a later backfill. + asset_class = _normalize_asset_class(pos.get("asset_class") or "", pos.get("underlying") or "") or "autre" conn = get_conn() conn.execute("""INSERT INTO portfolio ( @@ -1692,7 +1696,7 @@ def add_position(pos: Dict[str, Any]) -> str: pos.get("title", pos.get("underlying", "")), pos.get("underlying", ""), pos.get("strategy", ""), - pos.get("asset_class", ""), + asset_class, pos.get("entry_date", datetime.utcnow().isoformat()[:10]), pos.get("expiry_date", ""), pos.get("expiry_days", 90), @@ -2176,6 +2180,7 @@ _TICKER_ASSET_CLASS: dict = { # Energy "CL=F": "energy", "BZ=F": "energy", "NG=F": "energy", "RB=F": "energy", "HO=F": "energy", "XLE": "energy", "XOP": "energy", "USO": "energy", "UCO": "energy", "BOIL": "energy", "UNG": "energy", + "BNO": "energy", # Brent ETF, used as the Options Lab proxy for the BRENT Watchlist entry # Metals "GC=F": "metals", "SI=F": "metals", "HG=F": "metals", "PA=F": "metals", "PL=F": "metals", "GLD": "metals", "IAU": "metals", "SLV": "metals", "GDX": "metals", "GDXJ": "metals", @@ -2197,6 +2202,15 @@ _TICKER_ASSET_CLASS: dict = { # Forex "DX-Y.NYB": "forex", "UUP": "forex", "FXE": "forex", "FXY": "forex", "EUO": "forex", "YCS": "forex", "FXA": "forex", "FXB": "forex", "FXF": "forex", + # Rates & bonds — previously unmapped, so a position on any of these silently fell + # through to "autre" even though _RISK_FACTOR_MAP already has a "rates" bucket + # (récession/liquidité) that nothing could ever reach. + "ZB=F": "rates", "ZN=F": "rates", "ZF=F": "rates", "ZT=F": "rates", + "TLT": "rates", "IEF": "rates", "SHY": "rates", "HYG": "rates", "LQD": "rates", "EMB": "rates", + # Crypto — also previously unmapped; folded into "liquidité" in _RISK_FACTOR_MAP since + # crypto behaves as a risk-appetite/liquidity barometer rather than its own macro factor. + "BTC-USD": "crypto", "ETH-USD": "crypto", "BTC=F": "crypto", "ETH=F": "crypto", + "GBTC": "crypto", "IBIT": "crypto", "COIN": "crypto", } @@ -2216,9 +2230,14 @@ def _asset_class_from_ticker(ticker: str) -> str: return "metals" if stem[:2] in ("ZC", "ZS", "ZW", "CC", "KC", "CT"): return "agriculture" - # Currency pairs + if stem[:2] in ("ZB", "ZN", "ZF", "ZT"): + return "rates" + # Currency pairs (this app's forex tickers always use the =X suffix, e.g. EURUSD=X) if "=X" in t or "/" in t: return "forex" + # Crypto tickers use the yfinance dash convention instead (BTC-USD, ETH-USD) + if t.endswith("-USD"): + return "crypto" return "" @@ -2238,9 +2257,14 @@ def _normalize_asset_class(cls: str, ticker: str = "") -> str: return "equities" if any(k in c for k in ("forex", "currency", "fx", "devise", "change", "eur", "usd", "jpy", "dxy")): return "forex" - # Already a canonical value - if c in ("energy", "metals", "agriculture", "indices", "equities", "forex"): - return c + if any(k in c for k in ("rate", "bond", "treasury", "yield", "duration", "taux", "obligation")): + return "rates" + if any(k in c for k in ("crypto", "bitcoin", "btc", "ethereum", "eth", "token")): + return "crypto" + # Already a canonical value — includes asset_class_configs' "agri"/"bonds" desk keys + # as synonyms, since that registry uses a slightly different vocabulary than this one. + if c in ("energy", "metals", "agriculture", "agri", "indices", "equities", "forex", "rates", "bonds", "crypto"): + return {"agri": "agriculture", "bonds": "rates"}.get(c, c) # Fallback: infer from ticker return _asset_class_from_ticker(ticker) @@ -4396,7 +4420,9 @@ _RISK_FACTOR_MAP = { "triggers": {"financial_crisis", "elections"}, }, "liquidité": { - "asset_classes": {"indices", "equities", "rates"}, + # Crypto folded in here rather than given its own factor — it behaves as a + # risk-appetite/liquidity barometer more than a distinct macro theme. + "asset_classes": {"indices", "equities", "rates", "crypto"}, "triggers": {"financial_crisis", "health_crisis"}, }, "dollar": { @@ -4406,9 +4432,14 @@ _RISK_FACTOR_MAP = { } -def _classify_risk_factors(asset_class: str, triggers: List[str]) -> List[str]: - """Return list of risk factors for a trade given its asset_class and pattern triggers.""" - ac = (asset_class or "").lower() +def _classify_risk_factors(asset_class: str, triggers: List[str], ticker: str = "") -> List[str]: + """Return list of risk factors for a trade given its asset_class and pattern triggers. + Routes asset_class through _normalize_asset_class() first (with a ticker fallback) so a + missing/malformed/differently-spelled value (e.g. an empty field, or a desk-registry + "agri"/"bonds" key instead of this map's "agriculture"/"rates") still resolves to a real + factor instead of silently falling through to "autre" — see _RISK_FACTOR_MAP's asset + classes above for the exhaustive set every recognized asset_class should now map into.""" + ac = _normalize_asset_class(asset_class, ticker) or (asset_class or "").lower() trg_set = {t.lower() for t in (triggers or [])} factors = [] for factor, cfg in _RISK_FACTOR_MAP.items(): @@ -4436,7 +4467,10 @@ def get_portfolio_exposure() -> Dict: for row in trades: t = dict(row) - ac = (t.get("asset_class") or "autre").lower() + # Normalized first (handles empty/malformed/differently-spelled values via the + # position's own ticker) — only a genuinely uncategorizable position falls to + # "autre" now, instead of any position whose asset_class field happened to be blank. + ac = _normalize_asset_class(t.get("asset_class") or "", t.get("underlying") or "") or "autre" cap = float(t.get("capital_invested") or 0) total_capital += cap @@ -4460,7 +4494,7 @@ def get_portfolio_exposure() -> Dict: except Exception: pass - factors = _classify_risk_factors(ac, triggers) + factors = _classify_risk_factors(ac, triggers, t.get("underlying") or "") for f in factors: if f not in by_factor: by_factor[f] = {"capital": 0.0, "trade_count": 0, "trades": []} @@ -4702,7 +4736,7 @@ def compute_kelly_sizing( kelly_frac = kelly_full * fractional # Risk cluster adjustment: halve if saturated - ac = (p.get("asset_class") or "").lower() + ac = _normalize_asset_class(p.get("asset_class") or "") or "" triggers_raw = p.get("triggers") or "[]" try: triggers_list = json.loads(triggers_raw) if isinstance(triggers_raw, str) else triggers_raw