Skip to content

Commit 5240684

Browse files
committed
fix: pre-deploy hardening — remove broken ElasticNet from ensemble, fix risk manager weight clamping, add dynamic sector lookup
Three fixes identified during quant assessment before paper trading launch: 1. ensemble.py: Removed ElasticNet (scale-sensitive model receiving unscaled features spanning 12 orders of magnitude). Ensemble is now LightGBM 60% + Random Forest 40%. IC calibration still active. 2. risk_manager.py: Removed max(0.0, ...) clamp in record_trade() that was silently zeroing short-position weights, corrupting leverage/sector/trade-size checks. 3. sectors.py: Added dynamic yfinance sector lookup with session cache for the ~300 S&P 500 tickers not in the static map. Fixed DARDEN->DRI typo and ISRG misclassification (Technology->Health Care). Also includes: Round 3 MEDIUM/LOW audit fixes (22 findings), dashboard rewrite with dark cockpit theme + JSON API, paper trading tracker CLI, and all test updates. 510/510 tests passing.
1 parent b02467f commit 5240684

29 files changed

Lines changed: 3649 additions & 376 deletions

.env.example

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,32 @@ ALPACA_API_SECRET=your_paper_api_secret_here
1010
# Get webhook URL from your messaging app
1111
ALERT_WEBHOOK_URL=
1212

13-
# Bot Configuration
14-
MAX_POSITION_WEIGHT=0.30
15-
STOP_LOSS_PCT=0.05
16-
TAKE_PROFIT_PCT=0.15
17-
MAX_DRAWDOWN_LIMIT=0.15
18-
19-
# Logging
13+
# --- Risk & Position Sizing ---
14+
MAX_POSITION_WEIGHT=0.30 # Max weight per position (30%)
15+
MAX_PORTFOLIO_VAR_95=0.06 # Max daily VaR at 95% confidence (6%)
16+
MAX_DRAWDOWN_LIMIT=0.15 # Kill switch: liquidate if drawdown exceeds this (15%)
17+
STOP_LOSS_PCT=0.05 # Fixed stop-loss fallback when ATR unavailable (5%)
18+
TAKE_PROFIT_PCT=0.15 # Fixed take-profit fallback when ATR unavailable (15%)
19+
ATR_SL_MULTIPLIER=2.0 # Stop-loss at N x ATR below fill price
20+
ATR_TP_MULTIPLIER=3.0 # Take-profit at N x ATR above fill price
21+
22+
# --- Trading Strategy ---
23+
TOP_N_STOCKS=10 # Number of top-ranked stocks to hold
24+
OPTIMIZER_METHOD=hrp # Portfolio optimizer: hrp, min_cvar, risk_parity
25+
REBALANCE_FREQUENCY=weekly # daily or weekly
26+
REBALANCE_DAY=2 # 0=Mon, 1=Tue, 2=Wed, 3=Thu, 4=Fri
27+
28+
# --- Order Execution ---
29+
ORDER_POLL_INTERVAL_SECS=2 # How often to poll for fill status
30+
ORDER_POLL_TIMEOUT_SECS=60 # Max time to wait for a fill
31+
32+
# --- Logging ---
2033
LOG_DIR=/var/log/signum
2134
LOG_LEVEL=INFO
2235

23-
# Bot Behavior
24-
PAPER_TRADING=true
25-
SLEEP_AFTER_TRADE_HOURS=16
26-
SLEEP_MARKET_CLOSED_HOURS=4
36+
# --- Bot Behavior ---
37+
SLEEP_AFTER_TRADE_HOURS=12 # Sleep after successful trade cycle
38+
SLEEP_MARKET_CLOSED_HOURS=1 # Sleep when market is closed
2739

2840
# Restart Configuration
2941
MAX_RETRIES=10

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,12 @@ skills-lock.json
2929

3030
# Documentation plans (keep local)
3131
docs/plans/
32+
33+
# SSH keys (never commit private keys)
34+
deploy/signum_ed25519
35+
deploy/signum_ed25519.pub
36+
37+
# Session artifacts
38+
session-*.md
39+
signum
40+
signum.pub

examples/live_bot.py

Lines changed: 143 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -53,23 +53,48 @@
5353
)
5454
logger = logging.getLogger("LiveBot")
5555

56-
# --- Configuration ---
57-
TOP_N_STOCKS = 10
58-
OPTIMIZER_METHOD = "hrp"
59-
MAX_POSITION_WEIGHT = 0.30
60-
MAX_PORTFOLIO_VAR_95 = 0.06
61-
MAX_DRAWDOWN_LIMIT = 0.15
62-
STOP_LOSS_PCT = 0.05 # 5% trailing stop-loss from entry price (fallback if ATR unavailable)
63-
TAKE_PROFIT_PCT = 0.15 # 15% take-profit from entry price (fallback if ATR unavailable)
64-
ATR_SL_MULTIPLIER = 2.0 # Stop-loss at 2x ATR below fill price
65-
ATR_TP_MULTIPLIER = 3.0 # Take-profit at 3x ATR above fill price (1.5:1 R:R ratio)
66-
SLEEP_AFTER_TRADE_HOURS = 12
67-
SLEEP_MARKET_CLOSED_HOURS = 1
68-
ORDER_POLL_INTERVAL_SECS = 2 # How often to poll for fill status
69-
ORDER_POLL_TIMEOUT_SECS = 60 # Max time to wait for a fill
56+
# --- Configuration (all overridable via environment variables) ---
57+
58+
59+
def _env_float(key: str, default: float) -> float:
60+
"""Read a float from env, falling back to default."""
61+
val = os.getenv(key)
62+
if val is None:
63+
return default
64+
try:
65+
return float(val)
66+
except ValueError:
67+
logger.warning(f"Invalid float for {key}={val!r}, using default {default}")
68+
return default
69+
70+
71+
def _env_int(key: str, default: int) -> int:
72+
"""Read an int from env, falling back to default."""
73+
val = os.getenv(key)
74+
if val is None:
75+
return default
76+
try:
77+
return int(val)
78+
except ValueError:
79+
logger.warning(f"Invalid int for {key}={val!r}, using default {default}")
80+
return default
81+
82+
83+
TOP_N_STOCKS = _env_int("TOP_N_STOCKS", 10)
84+
OPTIMIZER_METHOD = os.getenv("OPTIMIZER_METHOD", "hrp")
85+
MAX_POSITION_WEIGHT = _env_float("MAX_POSITION_WEIGHT", 0.30)
86+
MAX_PORTFOLIO_VAR_95 = _env_float("MAX_PORTFOLIO_VAR_95", 0.06)
87+
MAX_DRAWDOWN_LIMIT = _env_float("MAX_DRAWDOWN_LIMIT", 0.15)
88+
STOP_LOSS_PCT = _env_float("STOP_LOSS_PCT", 0.05)
89+
TAKE_PROFIT_PCT = _env_float("TAKE_PROFIT_PCT", 0.15)
90+
ATR_SL_MULTIPLIER = _env_float("ATR_SL_MULTIPLIER", 2.0)
91+
ATR_TP_MULTIPLIER = _env_float("ATR_TP_MULTIPLIER", 3.0)
92+
SLEEP_AFTER_TRADE_HOURS = _env_int("SLEEP_AFTER_TRADE_HOURS", 12)
93+
SLEEP_MARKET_CLOSED_HOURS = _env_int("SLEEP_MARKET_CLOSED_HOURS", 1)
94+
ORDER_POLL_INTERVAL_SECS = _env_int("ORDER_POLL_INTERVAL_SECS", 2)
95+
ORDER_POLL_TIMEOUT_SECS = _env_int("ORDER_POLL_TIMEOUT_SECS", 60)
7096
TERMINAL_ORDER_STATES = {
7197
"filled",
72-
"partially_filled",
7398
"canceled",
7499
"cancelled",
75100
"expired",
@@ -79,8 +104,8 @@
79104
# --- Rebalancing schedule (Phase 1: Cost Reduction) ---
80105
# Weekly rebalancing reduces transaction costs by ~74% vs daily.
81106
# Wednesday chosen: avoids Monday/Friday effects, mid-week liquidity is higher.
82-
REBALANCE_DAY = 2 # 0=Mon, 1=Tue, 2=Wed, 3=Thu, 4=Fri
83-
REBALANCE_FREQUENCY = "weekly" # "daily" or "weekly"
107+
REBALANCE_DAY = _env_int("REBALANCE_DAY", 2) # 0=Mon, 1=Tue, 2=Wed, 3=Thu, 4=Fri
108+
REBALANCE_FREQUENCY = os.getenv("REBALANCE_FREQUENCY", "weekly") # "daily" or "weekly"
84109

85110
# --- Alerting (Fix #37) ---
86111
ALERT_WEBHOOK_URL = os.getenv("ALERT_WEBHOOK_URL") # Slack/Discord/generic webhook
@@ -398,6 +423,37 @@ def _save_bot_state(state: dict) -> None:
398423
logger.warning(f"Could not save bot state: {e}")
399424

400425

426+
EQUITY_HISTORY_FILE = Path("data/equity_history.json")
427+
428+
429+
def _append_equity_history(equity: float) -> None:
430+
"""Append an equity snapshot to the equity history file (R3-E-12 fix).
431+
432+
The dashboard reads this file to display the equity curve.
433+
Each entry is {date, equity}.
434+
"""
435+
try:
436+
EQUITY_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
437+
records = []
438+
if EQUITY_HISTORY_FILE.exists():
439+
with open(EQUITY_HISTORY_FILE, "r") as f:
440+
records = json.load(f)
441+
records.append(
442+
{
443+
"date": datetime.now(tz=ZoneInfo("America/New_York")).isoformat(),
444+
"equity": round(equity, 2),
445+
}
446+
)
447+
# Keep last 2000 entries to avoid unbounded growth
448+
records = records[-2000:]
449+
tmp = EQUITY_HISTORY_FILE.with_suffix(".tmp")
450+
with open(tmp, "w") as f:
451+
json.dump(records, f)
452+
tmp.replace(EQUITY_HISTORY_FILE)
453+
except Exception as e:
454+
logger.warning(f"Could not append equity history: {e}")
455+
456+
401457
def _has_traded_today(broker: AlpacaBroker) -> bool:
402458
"""Check if the bot has already submitted orders today.
403459
@@ -406,23 +462,19 @@ def _has_traded_today(broker: AlpacaBroker) -> bool:
406462
407463
C2 fix: created_at may be a datetime or string — normalize to string.
408464
M1 fix: use UTC date to match Alpaca's UTC timestamps.
465+
R3-E-13 fix: use ``after`` param to scope query to today's orders only,
466+
avoiding the 500-order pagination limit.
409467
"""
410468
try:
411469
from datetime import timezone
412470

413-
# Get all orders (including closed)
414-
orders = broker.list_orders(status="all")
415-
416-
# M1 fix: use UTC date to match Alpaca order timestamps
471+
# R3-E-13 fix: scope query to today's orders using ``after`` parameter
417472
today_utc = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
473+
after_ts = f"{today_utc}T00:00:00Z"
474+
orders = broker.list_orders(status="all", after=after_ts)
475+
418476
executed_today = [
419-
o
420-
for o in orders
421-
if o.status not in ("canceled", "cancelled", "expired")
422-
and o.order_id
423-
and hasattr(o, "created_at")
424-
and o.created_at
425-
and str(o.created_at).startswith(today_utc) # C2 fix: str() handles datetime
477+
o for o in orders if o.status not in ("canceled", "cancelled", "expired") and o.order_id
426478
]
427479

428480
if executed_today:
@@ -460,7 +512,7 @@ def _initialize_risk_engine(broker: AlpacaBroker, risk_manager: RiskManager) ->
460512
try:
461513
import yfinance as yf
462514

463-
data = yf.download(symbols, period="1y", interval="1d", progress=False)
515+
data = yf.download(symbols, period="1y", interval="1d", progress=False, auto_adjust=True)
464516
if data is not None and len(data) > 0:
465517
close_raw = data["Close"]
466518
close: pd.DataFrame = (
@@ -573,6 +625,32 @@ def run_trading_cycle(
573625
for ticker, w in sorted(target_weights.items(), key=lambda x: -x[1]):
574626
logger.info(f" {ticker}: {w:.2%}")
575627

628+
# Feature drift detection — compare inference features to training reference.
629+
# Informational only during paper trading: log and alert but don't block.
630+
import python.alpha.predict as _predict_mod
631+
632+
drift_report = _predict_mod._last_drift_report
633+
if drift_report is not None:
634+
drifted_features = [k for k, v in drift_report.items() if v["drifted"]]
635+
total_features = len(drift_report)
636+
if drifted_features:
637+
drift_pct = len(drifted_features) / total_features * 100
638+
logger.warning(
639+
f"DRIFT ALERT: {len(drifted_features)}/{total_features} features "
640+
f"({drift_pct:.0f}%) show distribution drift: {drifted_features}"
641+
)
642+
# High-severity PSI (>0.2 indicates major shift)
643+
severe = [f for f in drifted_features if drift_report[f].get("psi", 0) > 0.2]
644+
if severe:
645+
logger.warning(f" High PSI (>0.2) features: {severe}")
646+
_send_alert(
647+
f"[LiveBot] Feature drift detected: {len(drifted_features)}/{total_features} "
648+
f"features drifted. High-PSI: {severe or 'none'}. "
649+
f"Model predictions may be degraded."
650+
)
651+
else:
652+
logger.info(f"Drift check passed: 0/{total_features} features drifted")
653+
576654
# Apply regime-based exposure adjustment (Phase 2: Risk Management)
577655
# H-CAUTION fix: track whether caution mode is active to prevent renorm undoing it
578656
in_caution_mode = False
@@ -635,6 +713,13 @@ def run_trading_cycle(
635713
bridge.equity = account.equity
636714
bridge.cash = account.cash
637715

716+
# R3-E-1 fix: reset ALL bridge positions before syncing from broker.
717+
# Without this, positions closed via SL/TP between cycles remain as
718+
# ghost entries in the bridge, causing spurious sell orders.
719+
for ticker in list(bridge.positions.keys()):
720+
bridge.positions[ticker].quantity = 0.0
721+
bridge.positions[ticker].avg_cost = 0.0
722+
638723
# Sync current positions from broker into the bridge
639724
current_positions = broker.list_positions()
640725
for pos in current_positions:
@@ -791,12 +876,13 @@ def run_trading_cycle(
791876
# C1/C-OCO-1 fix: submit SL+TP as OCO pair (one-cancels-other).
792877
# When SL fills, TP is automatically cancelled (and vice versa),
793878
# preventing orphaned orders from creating naked short positions.
879+
# R3-E-8 fix: remove redundant limit_price on parent —
880+
# OCO legs define their own prices via take_profit/stop_loss.
794881
oco_order = BrokerOrder(
795882
symbol=entry["symbol"],
796883
side="sell",
797884
qty=sl_tp_qty,
798885
order_type="limit",
799-
limit_price=tp_price,
800886
time_in_force="gtc",
801887
order_class="oco",
802888
take_profit_limit_price=tp_price,
@@ -992,7 +1078,9 @@ def _handle_sigterm(signum, frame):
9921078
# Create execution bridge once — persists equity curve, P&L, and weight
9931079
# history across cycles. Each cycle syncs positions/equity from broker.
9941080
account = broker.get_account()
995-
bridge = ExecutionBridge(risk_manager=risk_manager, initial_capital=account.equity)
1081+
bridge = ExecutionBridge(
1082+
risk_manager=risk_manager, initial_capital=account.equity, commission_rate=0.0
1083+
)
9961084
_bridge_ref[0] = bridge # C3 fix: make bridge accessible to SIGTERM handler
9971085

9981086
try:
@@ -1026,10 +1114,17 @@ def _handle_sigterm(signum, frame):
10261114
# Re-initialize risk engine each cycle to capture new positions
10271115
_initialize_risk_engine(broker, risk_manager)
10281116

1029-
# Check portfolio-level risk before trading
1030-
# Get current position weights from broker
1117+
# R3-E-3 fix: sync risk_manager.current_weights from broker
1118+
# so trade-size, leverage, and sector checks use actual positions
10311119
positions = broker.list_positions()
10321120
account = broker.get_account()
1121+
if positions and account.equity > 0:
1122+
risk_manager.current_weights = pd.Series(
1123+
{p.symbol: p.market_value / account.equity for p in positions}
1124+
)
1125+
1126+
# Check portfolio-level risk before trading
1127+
# Get current position weights from broker
10331128
if positions and account.equity > 0:
10341129
weights = pd.Series(
10351130
{p.symbol: p.market_value / account.equity for p in positions}
@@ -1105,21 +1200,31 @@ def _handle_sigterm(signum, frame):
11051200
)
11061201

11071202
# Persist state after trading cycle (P1-6)
1203+
# R3-E-7 fix: persist equity_peak for drawdown tracking across restarts
1204+
current_equity = bridge.equity
1205+
prev_state = _load_bot_state()
1206+
equity_peak = max(
1207+
current_equity,
1208+
prev_state.get("equity_peak", current_equity),
1209+
)
11081210
_save_bot_state(
11091211
{
11101212
"last_trade_date": datetime.now(
11111213
tz=ZoneInfo("America/New_York")
11121214
).isoformat(),
1113-
"last_equity": bridge.equity,
1215+
"last_equity": current_equity,
1216+
"equity_peak": equity_peak,
11141217
"positions_count": len(bridge.positions),
11151218
}
11161219
)
11171220

1221+
# R3-E-12 fix: append to equity_history.json for dashboard visibility
1222+
_append_equity_history(current_equity)
1223+
11181224
if traded:
1119-
# Sleep until next market open (dynamic, Fix #34)
1120-
next_open = clock.get("next_open")
1121-
# After trading, sleep until market re-opens next session
1122-
# If next_open is available and in the future, sleep until then
1225+
# R3-E-2 fix: re-fetch clock after trading to get fresh next_open
1226+
fresh_clock = broker.get_clock()
1227+
next_open = fresh_clock.get("next_open")
11231228
if next_open:
11241229
sleep_secs = _seconds_until(next_open)
11251230
logger.info(

0 commit comments

Comments
 (0)