veteroner commited on
Commit
918f6f8
·
verified ·
1 Parent(s): 86378f6

feat: correlation-aware portfolio diversification

Browse files
Files changed (1) hide show
  1. trading/worker.py +99 -5
trading/worker.py CHANGED
@@ -589,6 +589,86 @@ def _sync_to_supabase() -> None:
589
  logger.warning("Supabase sync failed: %s", e)
590
 
591
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  class TradingWorker:
593
  """Production trading worker with full safety stack.
594
 
@@ -1358,16 +1438,30 @@ class TradingWorker:
1358
  for _, row in buy_signals.iterrows():
1359
  signal_actions[str(row["symbol"])] = "BUY_SKIPPED_NO_SLOT"
1360
 
1361
- selected_buy_symbols = {
1362
- str(row["symbol"]) for _, row in buy_signals.head(slots).iterrows()
1363
- }
 
 
 
 
 
 
 
 
 
 
 
 
1364
  for _, row in buy_signals.iterrows():
1365
  sym = str(row["symbol"])
1366
  if sym not in selected_buy_symbols and signal_actions.get(sym) == "BUY_SIGNAL":
1367
- signal_actions[sym] = "BUY_SKIPPED_LOW_PRIORITY"
1368
 
1369
- for _, row in buy_signals.head(slots).iterrows():
1370
  sym = row["symbol"]
 
 
1371
  if sym in broker_positions:
1372
  signal_actions[sym] = "BUY_SKIPPED_ALREADY_HELD"
1373
  continue
 
589
  logger.warning("Supabase sync failed: %s", e)
590
 
591
 
592
+ def _diversified_selection(
593
+ candidates: List[Dict[str, Any]],
594
+ held_symbols: List[str],
595
+ slots: int,
596
+ market_id: str,
597
+ *,
598
+ max_corr: float = 0.80,
599
+ lookback: int = 60,
600
+ ) -> List[str]:
601
+ """Pick up to `slots` symbols (input already sorted best-first) while keeping
602
+ pairwise return-correlation with already-picked AND currently-held positions
603
+ below `max_corr`.
604
+
605
+ Diversification is the one mathematically-guaranteed 'free lunch': a basket of
606
+ less-correlated names has lower variance (and shallower drawdowns) for the same
607
+ expected return. The worker previously just took the top-N by confidence, which
608
+ can pile into correlated names (e.g. three banks) and amplify drawdowns — the
609
+ kind that trip the kill switch.
610
+
611
+ FAIL-OPEN: on any data/compute error this returns the plain top-`slots` list, so
612
+ it can never block trading or behave worse than the previous logic.
613
+ """
614
+ syms = [str(c["symbol"]) for c in candidates]
615
+ if slots <= 0 or not syms:
616
+ return []
617
+ top_fallback = syms[:slots]
618
+ try:
619
+ import pandas as pd
620
+ from data.stock_data_api import get_stock_data_for_api
621
+
622
+ all_syms = list(dict.fromkeys(syms + [str(s) for s in held_symbols]))
623
+ rets: Dict[str, Any] = {}
624
+ for s in all_syms:
625
+ try:
626
+ df = get_stock_data_for_api(
627
+ to_provider_symbol(s, market_id=market_id),
628
+ period="3mo", interval="1d", market_id=market_id,
629
+ )
630
+ if df is not None and not df.empty and len(df) > 25:
631
+ rets[s] = df["Close"].pct_change().dropna().tail(lookback)
632
+ except Exception:
633
+ continue
634
+ if len(rets) < 2:
635
+ return top_fallback
636
+
637
+ corr = pd.DataFrame(rets).dropna(how="all").corr()
638
+ held = set(str(s) for s in held_symbols)
639
+
640
+ def _too_correlated(sym: str, chosen: List[str]) -> bool:
641
+ for other in list(chosen) + list(held):
642
+ if sym in corr.index and other in corr.columns:
643
+ c = corr.loc[sym, other]
644
+ if pd.notna(c) and abs(float(c)) > max_corr:
645
+ return True
646
+ return False
647
+
648
+ picked: List[str] = []
649
+ for s in syms: # already sorted best-first
650
+ if len(picked) >= slots:
651
+ break
652
+ if s in held:
653
+ continue
654
+ if _too_correlated(s, picked):
655
+ continue
656
+ picked.append(s)
657
+
658
+ # If strict diversification underfilled the slots, top up with the
659
+ # next-best remaining names (relax the constraint rather than waste slots).
660
+ if len(picked) < slots:
661
+ for s in syms:
662
+ if len(picked) >= slots:
663
+ break
664
+ if s not in picked and s not in held:
665
+ picked.append(s)
666
+ return picked[:slots]
667
+ except Exception as e:
668
+ logger.warning("Diversified selection failed (%s) — using top-N fallback", e)
669
+ return top_fallback
670
+
671
+
672
  class TradingWorker:
673
  """Production trading worker with full safety stack.
674
 
 
1438
  for _, row in buy_signals.iterrows():
1439
  signal_actions[str(row["symbol"])] = "BUY_SKIPPED_NO_SLOT"
1440
 
1441
+ # Correlation-aware diversification: pick the best names that aren't too
1442
+ # correlated with each other or with what we already hold. Reduces
1443
+ # concentrated drawdowns vs. naive top-N-by-confidence. Fail-open.
1444
+ _candidates = [
1445
+ {"symbol": str(row["symbol"]), "confidence": float(row.get("confidence", 0))}
1446
+ for _, row in buy_signals.iterrows()
1447
+ ]
1448
+ selected_buy_symbols = set(
1449
+ _diversified_selection(
1450
+ _candidates, list(broker_positions.keys()), slots, self.market_id,
1451
+ )
1452
+ )
1453
+ if selected_buy_symbols:
1454
+ logger.info("Diversified BUY selection (%d/%d slots): %s",
1455
+ len(selected_buy_symbols), slots, ", ".join(sorted(selected_buy_symbols)))
1456
  for _, row in buy_signals.iterrows():
1457
  sym = str(row["symbol"])
1458
  if sym not in selected_buy_symbols and signal_actions.get(sym) == "BUY_SIGNAL":
1459
+ signal_actions[sym] = "BUY_SKIPPED_DIVERSIFICATION"
1460
 
1461
+ for _, row in buy_signals.iterrows():
1462
  sym = row["symbol"]
1463
+ if str(sym) not in selected_buy_symbols:
1464
+ continue
1465
  if sym in broker_positions:
1466
  signal_actions[sym] = "BUY_SKIPPED_ALREADY_HELD"
1467
  continue