diff --git a/src/easy_tdx/backtest/orders.py b/src/easy_tdx/backtest/orders.py index dfd2712..6333283 100644 --- a/src/easy_tdx/backtest/orders.py +++ b/src/easy_tdx/backtest/orders.py @@ -144,19 +144,22 @@ class OrderSimulator: dt_col = self.df["datetime"] # 尝试直接比较(如果是 int 类型) + # 注意:用 to_numpy().argmax() 取位置索引,而非 idxmax()(返回 label), + # 因为后续 self.df.iloc[...] 按位置取行;若 df.index 非默认 RangeIndex, + # label != position 会导致撮合取错 bar。 try: - idx = (dt_col == datetime_val).idxmax() if (dt_col == datetime_val).any() else None - if idx is not None: - return int(idx) + mask = (dt_col == datetime_val).to_numpy() + if mask.any(): + return int(mask.argmax()) except (TypeError, ValueError): pass # 如果是 datetime 对象,转为 int 比较 if pd.api.types.is_datetime64_any_dtype(dt_col): dt_ints = dt_col.dt.strftime("%Y%m%d").astype(int) - mask = dt_ints == datetime_val - if mask.any(): - return int(mask.idxmax()) + mask_arr = (dt_ints == datetime_val).to_numpy() + if mask_arr.any(): + return int(mask_arr.argmax()) return None return None diff --git a/tests/unit/test_backtest_orders.py b/tests/unit/test_backtest_orders.py index 72879da..64aea20 100644 --- a/tests/unit/test_backtest_orders.py +++ b/tests/unit/test_backtest_orders.py @@ -464,3 +464,41 @@ class TestSlippageModelIntegration: trades = sim.simulate(signals, cash=20000, position=0) assert len(trades) == 1 assert trades[0].slippage == pytest.approx(5.0) + + +# ── Test Non-Continuous Index ───────────────────────────────────────────────── + + +class TestNonContinuousIndex: + """df.index 非默认 RangeIndex 时,撮合应按位置(iloc)而非 label 取 bar。 + + 回归 _find_bar_index 旧实现在非连续 index 下用 idxmax() 返回 label 当位置用, + 导致 iloc 取错 bar / 越界。 + """ + + def test_next_open_with_non_continuous_index(self) -> None: + """信号在 bar 0(label=10),应在 bar 1(position)open 成交。""" + df = _make_df(10) + df.index = [10 * (i + 1) for i in range(len(df))] # [10,20,...,100] + sim = OrderSimulator(df, execution="next_open") + + signals = [_buy_signal(0, size=100)] + trades = sim.simulate(signals, cash=20000, position=0) + + assert len(trades) == 1 + # position 1 的 open = 101.0;旧代码会用 label 10 当位置 → iloc[10] 越界 + assert trades[0].price == 101.0 + assert trades[0].rejected is False + + def test_this_close_with_non_continuous_index(self) -> None: + """this_close 模式下信号在 bar 2(label=30),应在同根 close 成交。""" + df = _make_df(10) + df.index = [10 * (i + 1) for i in range(len(df))] + sim = OrderSimulator(df, execution="this_close") + + signals = [_buy_signal(2, size=100)] + trades = sim.simulate(signals, cash=20000, position=0) + + assert len(trades) == 1 + # position 2 的 close = 103.0 + assert trades[0].price == 103.0