feat: risk
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user