feat: risk
This commit is contained in:
@@ -1681,6 +1681,10 @@ def add_position(pos: Dict[str, Any]) -> str:
|
|||||||
legs = pos.get("legs", [])
|
legs = pos.get("legs", [])
|
||||||
num_contracts = sum(abs(leg.get("quantity", 1)) for leg in legs)
|
num_contracts = sum(abs(leg.get("quantity", 1)) for leg in legs)
|
||||||
ib_fees = compute_ib_fees(num_contracts)
|
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 = get_conn()
|
||||||
conn.execute("""INSERT INTO portfolio (
|
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("title", pos.get("underlying", "")),
|
||||||
pos.get("underlying", ""),
|
pos.get("underlying", ""),
|
||||||
pos.get("strategy", ""),
|
pos.get("strategy", ""),
|
||||||
pos.get("asset_class", ""),
|
asset_class,
|
||||||
pos.get("entry_date", datetime.utcnow().isoformat()[:10]),
|
pos.get("entry_date", datetime.utcnow().isoformat()[:10]),
|
||||||
pos.get("expiry_date", ""),
|
pos.get("expiry_date", ""),
|
||||||
pos.get("expiry_days", 90),
|
pos.get("expiry_days", 90),
|
||||||
@@ -2176,6 +2180,7 @@ _TICKER_ASSET_CLASS: dict = {
|
|||||||
# Energy
|
# Energy
|
||||||
"CL=F": "energy", "BZ=F": "energy", "NG=F": "energy", "RB=F": "energy", "HO=F": "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",
|
"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
|
# Metals
|
||||||
"GC=F": "metals", "SI=F": "metals", "HG=F": "metals", "PA=F": "metals", "PL=F": "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",
|
"GLD": "metals", "IAU": "metals", "SLV": "metals", "GDX": "metals", "GDXJ": "metals",
|
||||||
@@ -2197,6 +2202,15 @@ _TICKER_ASSET_CLASS: dict = {
|
|||||||
# Forex
|
# Forex
|
||||||
"DX-Y.NYB": "forex", "UUP": "forex", "FXE": "forex", "FXY": "forex",
|
"DX-Y.NYB": "forex", "UUP": "forex", "FXE": "forex", "FXY": "forex",
|
||||||
"EUO": "forex", "YCS": "forex", "FXA": "forex", "FXB": "forex", "FXF": "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"
|
return "metals"
|
||||||
if stem[:2] in ("ZC", "ZS", "ZW", "CC", "KC", "CT"):
|
if stem[:2] in ("ZC", "ZS", "ZW", "CC", "KC", "CT"):
|
||||||
return "agriculture"
|
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:
|
if "=X" in t or "/" in t:
|
||||||
return "forex"
|
return "forex"
|
||||||
|
# Crypto tickers use the yfinance dash convention instead (BTC-USD, ETH-USD)
|
||||||
|
if t.endswith("-USD"):
|
||||||
|
return "crypto"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -2238,9 +2257,14 @@ def _normalize_asset_class(cls: str, ticker: str = "") -> str:
|
|||||||
return "equities"
|
return "equities"
|
||||||
if any(k in c for k in ("forex", "currency", "fx", "devise", "change", "eur", "usd", "jpy", "dxy")):
|
if any(k in c for k in ("forex", "currency", "fx", "devise", "change", "eur", "usd", "jpy", "dxy")):
|
||||||
return "forex"
|
return "forex"
|
||||||
# Already a canonical value
|
if any(k in c for k in ("rate", "bond", "treasury", "yield", "duration", "taux", "obligation")):
|
||||||
if c in ("energy", "metals", "agriculture", "indices", "equities", "forex"):
|
return "rates"
|
||||||
return c
|
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
|
# Fallback: infer from ticker
|
||||||
return _asset_class_from_ticker(ticker)
|
return _asset_class_from_ticker(ticker)
|
||||||
|
|
||||||
@@ -4396,7 +4420,9 @@ _RISK_FACTOR_MAP = {
|
|||||||
"triggers": {"financial_crisis", "elections"},
|
"triggers": {"financial_crisis", "elections"},
|
||||||
},
|
},
|
||||||
"liquidité": {
|
"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"},
|
"triggers": {"financial_crisis", "health_crisis"},
|
||||||
},
|
},
|
||||||
"dollar": {
|
"dollar": {
|
||||||
@@ -4406,9 +4432,14 @@ _RISK_FACTOR_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _classify_risk_factors(asset_class: str, triggers: List[str]) -> List[str]:
|
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."""
|
"""Return list of risk factors for a trade given its asset_class and pattern triggers.
|
||||||
ac = (asset_class or "").lower()
|
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 [])}
|
trg_set = {t.lower() for t in (triggers or [])}
|
||||||
factors = []
|
factors = []
|
||||||
for factor, cfg in _RISK_FACTOR_MAP.items():
|
for factor, cfg in _RISK_FACTOR_MAP.items():
|
||||||
@@ -4436,7 +4467,10 @@ def get_portfolio_exposure() -> Dict:
|
|||||||
|
|
||||||
for row in trades:
|
for row in trades:
|
||||||
t = dict(row)
|
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)
|
cap = float(t.get("capital_invested") or 0)
|
||||||
total_capital += cap
|
total_capital += cap
|
||||||
|
|
||||||
@@ -4460,7 +4494,7 @@ def get_portfolio_exposure() -> Dict:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
factors = _classify_risk_factors(ac, triggers)
|
factors = _classify_risk_factors(ac, triggers, t.get("underlying") or "")
|
||||||
for f in factors:
|
for f in factors:
|
||||||
if f not in by_factor:
|
if f not in by_factor:
|
||||||
by_factor[f] = {"capital": 0.0, "trade_count": 0, "trades": []}
|
by_factor[f] = {"capital": 0.0, "trade_count": 0, "trades": []}
|
||||||
@@ -4702,7 +4736,7 @@ def compute_kelly_sizing(
|
|||||||
kelly_frac = kelly_full * fractional
|
kelly_frac = kelly_full * fractional
|
||||||
|
|
||||||
# Risk cluster adjustment: halve if saturated
|
# 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 "[]"
|
triggers_raw = p.get("triggers") or "[]"
|
||||||
try:
|
try:
|
||||||
triggers_list = json.loads(triggers_raw) if isinstance(triggers_raw, str) else triggers_raw
|
triggers_list = json.loads(triggers_raw) if isinstance(triggers_raw, str) else triggers_raw
|
||||||
|
|||||||
Reference in New Issue
Block a user