Skip to content

Commit 6e171ac

Browse files
yingwangclaude
andcommitted
Fix 5 bugs: safety sell-check, Alpaca timeout cancel, LightGBM label contamination, backtest fees & look-ahead bias
- safety.py: sell orders now subtract from position value instead of adding - alpaca_broker.py: cancel unfilled orders on timeout to prevent position drift - lgbm_strategy.py, strategy_ensemble.py: remove nan_to_num(0.5), let _flatten() filter NaN targets - engine.py: reserve fee buffer before computing target shares to avoid negative cash - engine.py: execute trades at T+1 close (signal day T, execution day T+1) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e7f16d6 commit 6e171ac

5 files changed

Lines changed: 39 additions & 20 deletions

File tree

quant/backtest/engine.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,32 +85,35 @@ def run(self, prices: pd.DataFrame, target_weights_by_date: dict[str, pd.Series]
8585
if rd in dates:
8686
rebalance_dates.add(rd)
8787

88+
pending_target = None # Target weights waiting for T+1 execution
89+
8890
for date in dates:
8991
px = prices_ffilled.loc[date, symbols]
9092
portfolio_value = cash + (holdings * px).sum()
9193

92-
# Rebalance if this is a rebalance date
93-
if date in rebalance_dates:
94-
target = target_weights_by_date.get(str(date.date()),
95-
target_weights_by_date.get(date, pd.Series(dtype=float)))
96-
target = target.reindex(symbols).fillna(0)
94+
# Execute pending rebalance at T+1 close (signal was computed at T)
95+
if pending_target is not None:
96+
target = pending_target
97+
pending_target = None
98+
99+
# Reserve a fee buffer so target allocation doesn't push cash negative
100+
fee_reserve_bps = self.txn_cost_bps + self.slippage_bps + self.impact_coeff
101+
allocable = portfolio_value * (1 - fee_reserve_bps / 10000)
97102

98-
target_shares = (portfolio_value * target / px).fillna(0).apply(np.floor)
103+
target_shares = (allocable * target / px).fillna(0).apply(np.floor)
99104
trades = target_shares - holdings
100105

101106
# Apply transaction costs with market impact model
102-
# cost = fixed_bps * trade_value + impact_coeff * sqrt(trade_value / portfolio) * trade_value
103107
trade_value = (trades.abs() * px).sum()
104108
fixed_cost = trade_value * (self.txn_cost_bps + self.slippage_bps) / 10000
105109
participation = trade_value / portfolio_value if portfolio_value > 0 else 0
106110
impact_cost = trade_value * self.impact_coeff * np.sqrt(participation) / 10000
107111
cost = fixed_cost + impact_cost
108112
cash -= cost
109113

110-
# Execute trades
114+
# Execute trades at T+1 close price
111115
trade_cash = (trades * px).sum()
112116
cash -= trade_cash
113-
# Update entry prices: set for new/increased positions, clear for sold
114117
new_positions = (holdings == 0) & (target_shares > 0)
115118
entry_prices[new_positions] = px[new_positions]
116119
closed_positions = target_shares == 0
@@ -124,6 +127,12 @@ def run(self, prices: pd.DataFrame, target_weights_by_date: dict[str, pd.Series]
124127
"cost": cost,
125128
})
126129

130+
# On signal day, capture target weights for next-day execution
131+
if date in rebalance_dates:
132+
target = target_weights_by_date.get(str(date.date()),
133+
target_weights_by_date.get(date, pd.Series(dtype=float)))
134+
pending_target = target.reindex(symbols).fillna(0)
135+
127136
portfolio_value = cash + (holdings * px).sum()
128137

129138
# Daily stop-loss check: sell positions that dropped below threshold

quant/execution/alpaca_broker.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -238,12 +238,18 @@ def _execute_single(self, order: Order, signal_price: float) -> Order:
238238
self.exec_log.log_order_filled(order, signal_price)
239239
self.safety.record_fill(order.quantity * order.filled_price)
240240
else:
241-
order.status = "submitted"
242241
order.order_id = alpaca_order.id
243-
logger.warning(
244-
"Order for %s not filled within timeout, status=%s",
245-
order.symbol, "submitted",
246-
)
242+
# Cancel unfilled/partially-filled order to prevent position drift
243+
try:
244+
self.api.cancel_order(alpaca_order.id)
245+
logger.info("Cancelled timed-out order %s for %s", alpaca_order.id, order.symbol)
246+
order.status = "cancelled"
247+
except Exception as cancel_err:
248+
logger.warning(
249+
"Failed to cancel timed-out order %s for %s: %s",
250+
alpaca_order.id, order.symbol, cancel_err,
251+
)
252+
order.status = "submitted"
247253

248254
return order
249255

quant/execution/safety.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,11 @@ def validate(
143143

144144
# 5. Position concentration check
145145
if portfolio_value > 0:
146-
position_pct = (current_position_value + order_value) / portfolio_value
146+
if order.side == "sell":
147+
projected_position = max(0, current_position_value - order_value)
148+
else:
149+
projected_position = current_position_value + order_value
150+
position_pct = projected_position / portfolio_value
147151
if position_pct > self.config.max_position_pct_of_portfolio:
148152
reason = (
149153
f"Position in {order.symbol} would be {position_pct:.1%} of portfolio, "

quant/signals/lgbm_strategy.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ def run_backtest(self, start: str = None, end: str = None) -> BacktestResult:
300300
returns, self.pred_horizon
301301
)
302302
y = cs_targets.reindex(index=dates, columns=symbols).values
303-
y = np.nan_to_num(y, nan=0.5)
303+
# Keep NaN — _flatten() filters them via np.isfinite()
304304

305305
if not ML_BACKEND_AVAILABLE:
306306
logger.error(
@@ -436,7 +436,7 @@ def get_current_signal(self) -> pd.Series:
436436
returns, self.pred_horizon
437437
)
438438
y = cs_targets.reindex(index=dates, columns=symbols).values
439-
y = np.nan_to_num(y, nan=0.5)
439+
# Keep NaN — _flatten() filters them via np.isfinite()
440440

441441
# Train on all available data
442442
date_idx = len(dates) - 1
@@ -494,7 +494,7 @@ def get_current_portfolio(self, capital: float = None) -> pd.DataFrame:
494494
returns, self.pred_horizon
495495
)
496496
y = cs_targets.reindex(index=dates, columns=symbols).values
497-
y = np.nan_to_num(y, nan=0.5)
497+
# Keep NaN — _flatten() filters them via np.isfinite()
498498

499499
date_idx = len(dates) - 1
500500
self._train_model(X, y, date_idx, feature_names)

quant/strategy_ensemble.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ def run_backtest(self, start: str = None, end: str = None) -> BacktestResult:
233233
returns, 21 # pred_horizon
234234
)
235235
y_ml = cs_targets.reindex(index=ml_dates, columns=ml_symbols).values
236-
y_ml = np.nan_to_num(y_ml, nan=0.5)
236+
# Keep NaN — _flatten() filters them via np.isfinite()
237237

238238
lgbm_model = LGBMRankingModel(
239239
num_leaves=31,
@@ -412,7 +412,7 @@ def get_current_signal(self) -> pd.Series:
412412
)
413413
cs_targets = self.feature_engine.get_cross_sectional_target(returns, 21)
414414
y = cs_targets.reindex(index=dates, columns=syms).values
415-
y = np.nan_to_num(y, nan=0.5)
415+
# Keep NaN — _flatten() filters them via np.isfinite()
416416

417417
lgbm_model = LGBMRankingModel(
418418
num_leaves=31, learning_rate=0.05,

0 commit comments

Comments
 (0)