feat: risk

This commit is contained in:
OpenSquared
2026-07-26 16:41:13 +02:00
parent 09a0203494
commit 67793608e4

View File

@@ -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