diff --git a/src/easy_tdx/backtest/engine.py b/src/easy_tdx/backtest/engine.py index 46c5790..8700321 100644 --- a/src/easy_tdx/backtest/engine.py +++ b/src/easy_tdx/backtest/engine.py @@ -137,6 +137,11 @@ class BacktestEngine: def _generate_signals(self, df: pd.DataFrame, chanlun_result: Any | None) -> list[Signal]: """Generate signals from strategy. + After each bar's signals, update strategy's internal position state so + subsequent bars can make informed decisions (e.g., "don't buy if already + holding"). Uses close price as estimate; actual execution price is + determined later by OrderSimulator. + Args: df: Price data chanlun_result: Optional chanlun analysis result @@ -156,6 +161,11 @@ class BacktestEngine: if chanlun_result is not None: strat._chanlun_result = chanlun_result + # Initialize position tracking + strat._cash = self._cash + strat._position_size = 0.0 + close_arr = df["close"].to_numpy() + # Call init strat._call_init() @@ -165,10 +175,48 @@ class BacktestEngine: strat._set_bar_index(i) strat._call_next() bar_signals = strat._clear_signals() + + # Update strategy position state so next bar sees current holdings + self._update_strategy_position(strat, bar_signals, close_arr[i]) + all_signals.extend(bar_signals) return all_signals + def _update_strategy_position( + self, strat: Strategy, signals: list[Signal], est_price: float + ) -> None: + """Update strategy's internal position estimate after each bar. + + Uses close price as estimate for full-position calculations. + The actual execution price is determined by OrderSimulator later. + + Args: + strat: Strategy instance + signals: Signals generated on this bar + est_price: Estimated price (close of current bar) + """ + for sig in signals: + price = sig.price or est_price + if sig.direction == "BUY": + if sig.size == 0: + # Full position: estimate shares (100-lot rounding) + shares = int(strat._cash / (price * (1 + self._commission)) / 100) * 100 + if shares > 0: + strat._position_size += shares + strat._cash -= shares * price + else: + strat._position_size += sig.size + strat._cash -= sig.size * price + elif sig.direction == "SELL": + if sig.size == 0: + # Full sell + strat._cash += strat._position_size * price + strat._position_size = 0.0 + else: + strat._cash += sig.size * price + strat._position_size = max(0.0, strat._position_size - sig.size) + def _compute_pnls(self, trades: list[Trade]) -> list[Trade]: """Compute realized PnL for sell trades. @@ -191,10 +239,10 @@ class BacktestEngine: if position_size > 0: avg_cost = position_cost / position_size trade.pnl = (trade.price - avg_cost) * trade.size - trade.commission + position_cost -= avg_cost * trade.size + position_size -= trade.size else: trade.pnl = 0.0 - position_cost -= avg_cost * trade.size - position_size -= trade.size return trades @@ -240,10 +288,15 @@ class BacktestEngine: Returns: BacktestResult with empty DataFrames """ + perf = PerformanceAnalyzer( + pd.DataFrame(columns=["total", "drawdown"]), + pd.DataFrame(columns=["direction", "pnl", "rejected"]), + ).compute() + return BacktestResult( - performance={}, + performance=perf, equity_curve=pd.DataFrame( - columns=["datetime", "cash", "position_value", "total", "drawdown"] + columns=["datetime", "cash", "position_value", "total", "drawdown", "drawdown_pct"] ), trades=pd.DataFrame( columns=[ diff --git a/tests/unit/test_backtest_engine.py b/tests/unit/test_backtest_engine.py index eea2c0e..06bc7a5 100644 --- a/tests/unit/test_backtest_engine.py +++ b/tests/unit/test_backtest_engine.py @@ -286,3 +286,76 @@ def test_pnl_calculation(): # For buy trades, PnL should be 0 assert (buy_trades["pnl"] == 0).all() + + +class PositionAwareStrategy(Strategy): + """Strategy that checks position before trading (the common pattern).""" + + def init(self): + self.ma5 = self.I(MyTT.MA, self.data.close, 5) + self.ma20 = self.I(MyTT.MA, self.data.close, 20) + self.cross_up = False + self.cross_down = False + + def next(self): + if self._bar_index > 0: + prev_ma5 = self.ma5[self._bar_index - 1] + prev_ma20 = self.ma20[self._bar_index - 1] + curr_ma5 = self.ma5[self._bar_index] + curr_ma20 = self.ma20[self._bar_index] + + if prev_ma5 <= prev_ma20 and curr_ma5 > curr_ma20: + if self.position["size"] == 0: + self.buy(size=0) + elif prev_ma5 >= prev_ma20 and curr_ma5 < curr_ma20: + if self.position["size"] > 0: + self.sell(size=0) + + +def test_position_aware_buy_sell_alternation(): + """Regression: strategy that checks position must produce alternating BUY/SELL.""" + df = _make_df(n=300, seed=42) + engine = BacktestEngine(PositionAwareStrategy, cash=100000) + result = engine.run(df) + + trades = result.trades[result.trades["rejected"] == False] + directions = trades["direction"].tolist() + + # Must have both BUYs and SELLs + assert "BUY" in directions, "No BUY trades generated" + assert "SELL" in directions, "No SELL trades generated — position feedback broken" + + # Trades must alternate: no two consecutive BUYs or SELLs + for i in range(1, len(directions)): + assert directions[i] != directions[i - 1], ( + f"Consecutive same-direction trades at index {i}: " + f"{directions[i - 1]} -> {directions[i]}" + ) + + +def test_position_aware_no_duplicate_buys(): + """After a BUY, position['size'] > 0 so strategy should not buy again.""" + df = _make_df(n=300, seed=42) + engine = BacktestEngine(PositionAwareStrategy, cash=100000) + result = engine.run(df) + + buy_trades = result.trades[ + (result.trades["direction"] == "BUY") & (result.trades["rejected"] == False) + ] + + # Each BUY's size should be reasonable (not tiny leftover from exhausted cash) + if len(buy_trades) > 1: + # No consecutive buys where the second is tiny (cash leftover artifact) + sizes = buy_trades["size"].tolist() + for i in range(1, len(sizes)): + # Second buy in a pair should not be tiny compared to first + # (would indicate position wasn't tracked between bars) + if i >= 1: + prev_size = sizes[i - 1] + cur_size = sizes[i] + # Allow some variance but not orders-of-magnitude difference + if prev_size > 0: + assert cur_size > prev_size * 0.1, ( + f"Suspicious tiny buy {cur_size} after {prev_size} — " + f"position feedback may be broken" + )