mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 19:14:16 +08:00
fix(backtest): strategy position not tracked during signal generation
Root cause: _generate_signals() iterated all bars calling strategy.next() but never updated _position_size or _cash on the strategy. Strategies that check self.position['size'] before buy/sell always saw 0, producing only BUY signals with no SELL — exhausting cash and producing drawdowns exceeding 100%. Fix: add _update_strategy_position() that estimates position changes after each bar's signals using close price. This gives the strategy an accurate view of its holdings so it can correctly alternate buy/sell. Regression tests added: - test_position_aware_buy_sell_alternation: verifies BUY/SELL alternation - test_position_aware_no_duplicate_buys: no suspicious tiny duplicate buys Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f7e1abd873
commit
6a6d75f5d5
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user