feat: correlation-aware portfolio diversification
Browse files- 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 |
-
|
| 1362 |
-
|
| 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] = "
|
| 1368 |
|
| 1369 |
-
for _, row in buy_signals.
|
| 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
|