复制 local 代码到 serve

This commit is contained in:
2026-07-04 16:59:25 +08:00
parent cee04c1b46
commit 0474e5fb46
302 changed files with 78290 additions and 0 deletions
@@ -0,0 +1,422 @@
from __future__ import annotations
from datetime import date, timedelta
import polars as pl
from app.backtest.engine import BacktestEngine, MatcherConfig
def _panel(symbols: list[str], days: int = 4, price: float = 10.0, overrides: dict[tuple[str, int], dict] | None = None) -> pl.DataFrame:
overrides = overrides or {}
start = date(2024, 1, 1)
rows = []
for sym in symbols:
for i in range(days):
patch = overrides.get((sym, i), {})
rows.append({
"symbol": sym,
"name": sym,
"date": start + timedelta(days=i),
"open": patch.get("open", price),
"high": patch.get("high", price),
"low": patch.get("low", price),
"close": patch.get("close", price),
"volume": patch.get("volume", 100_000),
"score": patch.get("score", {"A": 4, "B": 3, "C": 2, "D": 1}.get(sym, 0)),
"signal_limit_up": patch.get("signal_limit_up", False),
"signal_limit_down": patch.get("signal_limit_down", False),
})
return pl.DataFrame(rows).sort(["symbol", "date"])
def _mask(panel: pl.DataFrame, marks: set[tuple[str, int]]) -> pl.Series:
values = []
base = date(2024, 1, 1)
for row in panel.select(["symbol", "date"]).iter_rows(named=True):
day = (row["date"] - base).days
values.append((row["symbol"], day) in marks)
return pl.Series(values, dtype=pl.Boolean)
def _engine() -> BacktestEngine:
return BacktestEngine(repo=None) # simulate_portfolio 不访问 repo
def test_max_exposure_sets_target_position_and_caps_count():
panel = _panel(["A", "B", "C", "D"], days=3)
entries = _mask(panel, {("A", 0), ("B", 0), ("C", 0), ("D", 0)})
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=3,
max_exposure_pct=0.6,
initial_capital=100_000,
),
)
assert len(result.trades) == 3
assert {t.symbol for t in result.trades} == {"A", "B", "C"}
assert all(abs(t.position_pct - 0.2) < 0.001 for t in result.trades)
assert result.stats["max_exposure"] <= 0.61
def test_one_price_limit_up_blocks_buy():
panel = _panel(
["A"],
days=3,
overrides={
("A", 1): {"open": 11, "high": 11, "low": 11, "close": 11, "signal_limit_up": True},
},
)
entries = _mask(panel, {("A", 0)})
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(matching="open_t+1", fees_pct=0, slippage_bps=0, max_positions=1, initial_capital=100_000),
)
assert result.trades == []
assert result.stats["execution"]["buy_limit_up"] == 1
def test_failed_open_exit_keeps_slot_and_blocks_replacement_buy():
panel = _panel(
["A", "B", "C", "D"],
days=4,
overrides={
("A", 2): {"open": 9, "high": 9, "low": 9, "close": 9, "signal_limit_down": True},
},
)
entries = _mask(panel, {
("A", 0), ("B", 0), ("C", 0),
("D", 1),
})
exits = _mask(panel, {("A", 1)})
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=3,
max_exposure_pct=0.6,
initial_capital=100_000,
),
)
assert "D" not in {t.symbol for t in result.trades}
assert result.stats["execution"]["sell_limit_down"] == 1
assert result.stats["execution"]["pending_exit"] == 1
assert result.stats["execution"]["buy_no_slot"] >= 1
a_trade = next(t for t in result.trades if t.symbol == "A")
assert a_trade.blocked_exit_days == 1
assert a_trade.exit_reason == "signal"
def test_trailing_stop_uses_high_water_mark():
panel = _panel(
["A"],
days=5,
overrides={
("A", 2): {"open": 10, "high": 12, "low": 11.8, "close": 12},
("A", 3): {"open": 12, "high": 12, "low": 11.3, "close": 11.3},
},
)
entries = _mask(panel, {("A", 0)})
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=1,
initial_capital=100_000,
trailing_stop_pct=0.05,
),
)
assert len(result.trades) == 1
trade = result.trades[0]
assert trade.exit_reason == "trailing_stop"
assert trade.exit_price == 11.4
def test_trailing_take_profit_requires_activation():
panel = _panel(
["A"],
days=5,
overrides={
("A", 2): {"open": 10, "high": 10.8, "low": 10.4, "close": 10.8},
("A", 3): {"open": 10.8, "high": 10.8, "low": 10.4, "close": 10.4},
},
)
entries = _mask(panel, {("A", 0)})
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=1,
initial_capital=100_000,
trailing_take_profit_activate_pct=0.10,
trailing_take_profit_drawdown_pct=0.03,
),
)
assert result.trades[0].exit_reason == "end"
def test_trailing_take_profit_exits_after_activation():
panel = _panel(
["A"],
days=5,
overrides={
("A", 2): {"open": 10, "high": 12, "low": 11.8, "close": 12},
("A", 3): {"open": 12, "high": 12, "low": 11.5, "close": 11.5},
},
)
entries = _mask(panel, {("A", 0)})
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=1,
initial_capital=100_000,
trailing_take_profit_activate_pct=0.10,
trailing_take_profit_drawdown_pct=0.03,
),
)
assert len(result.trades) == 1
trade = result.trades[0]
assert trade.exit_reason == "trailing_take_profit"
assert trade.exit_price == 11.7
def test_score_filter_uses_signal_day_score_range():
panel = _panel(
["A", "B", "C"],
days=3,
overrides={
("A", 0): {"score": 70},
("B", 0): {"score": 80},
("C", 0): {"score": 90},
("A", 1): {"score": 100},
("B", 1): {"score": 1},
("C", 1): {"score": 1},
},
)
entries = _mask(panel, {("A", 0), ("B", 0), ("C", 0)})
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=3,
initial_capital=100_000,
score_min=71,
score_max=85,
),
)
assert {t.symbol for t in result.trades} == {"B"}
assert result.trades[0].entry_score == 80
assert result.stats["execution"]["buy_score_filter"] == 2
def test_independent_candidates_allow_overlapping_same_symbol_trades():
panel = _panel(
["A"],
days=5,
overrides={
("A", 0): {"close": 10},
("A", 1): {"close": 11},
("A", 2): {"close": 12},
("A", 3): {"close": 13},
("A", 4): {"close": 14},
},
)
entries = _mask(panel, {("A", 0), ("A", 1)})
exits = _mask(panel, set())
result = _engine().simulate_independent_candidates(
panel,
entries,
exits,
MatcherConfig(matching="close_t", fees_pct=0, slippage_bps=0, max_hold_days=2),
)
assert result.stats["full_kind"] == "candidate_execution"
assert result.stats["n_candidates"] == 2
assert len(result.trades) == 2
assert [t.entry_date for t in result.trades] == ["2024-01-01", "2024-01-02"]
assert [t.exit_date for t in result.trades] == ["2024-01-03", "2024-01-04"]
assert all(t.exit_reason == "max_hold" for t in result.trades)
def test_independent_candidates_apply_stop_loss():
panel = _panel(
["A"],
days=4,
overrides={
("A", 0): {"close": 10, "low": 10},
("A", 1): {"open": 10, "high": 10, "low": 8.9, "close": 9},
},
)
entries = _mask(panel, {("A", 0)})
exits = _mask(panel, set())
result = _engine().simulate_independent_candidates(
panel,
entries,
exits,
MatcherConfig(matching="close_t", fees_pct=0, slippage_bps=0, stop_loss_pct=0.1),
)
assert len(result.trades) == 1
assert result.trades[0].exit_reason == "stop_loss"
assert result.trades[0].exit_price == 9.0
def test_signal_exit_takes_priority_over_max_hold():
"""同一日既有卖点信号又到期 → 应按 signal 平仓 (卖点优先于 max_hold 兜底)。"""
panel = _panel(
["A"],
days=4,
overrides={
# day1 次日开盘买入 (open_t+1), 价 10
("A", 1): {"open": 10, "high": 10, "low": 10, "close": 10},
# day2 持有 (hold_days 计到 1)
("A", 2): {"open": 11, "high": 11, "low": 11, "close": 11},
# day3: 既到期 (hold_days=2 >= max_hold_days=2) 又有卖点信号 → signal 优先
("A", 3): {"open": 12, "high": 12, "low": 12, "close": 12},
},
)
entries = _mask(panel, {("A", 0)}) # day0 收盘确认 → day1 开盘买
exits = _mask(panel, {("A", 2)}) # day2 收盘确认卖点 → day3 开盘卖
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=1,
max_hold_days=2,
initial_capital=100_000,
),
)
assert len(result.trades) == 1
trade = result.trades[0]
assert trade.exit_reason == "signal"
assert trade.exit_price == 12.0 # 卖点用 day3 开盘 (exit_fill 跟随 matching=open_t+1)
def test_stop_loss_triggers_even_when_expired_in_open_mode():
"""open_t+1 模式下仓位到期且当日破止损 → 应按 stop_loss 平仓 (风控优先于 max_hold)。"""
panel = _panel(
["A"],
days=4,
overrides={
("A", 1): {"open": 10, "high": 10, "low": 10, "close": 10},
# day3 开盘跳空跌破止损 (-10%): open=8.9 < 9.0 止损线, low=8.5
("A", 3): {"open": 8.9, "high": 8.9, "low": 8.5, "close": 8.7},
},
)
entries = _mask(panel, {("A", 0)})
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=1,
max_hold_days=2,
stop_loss_pct=0.1,
initial_capital=100_000,
),
)
assert len(result.trades) == 1
trade = result.trades[0]
assert trade.exit_reason == "stop_loss"
# 风控盘中触发: 开盘价 8.9 <= 止损线 9.0 → 按开盘价 8.9 成交
assert trade.exit_price == 8.9
def test_default_fill_is_buy_open_sell_close():
"""拆分口径: 建仓=次日开盘, 清仓=收盘。entry_price 用次日 open, exit_price 用收盘价。"""
panel = _panel(
["A"],
days=4,
overrides={
# day1: 次日开盘买入, 开盘 10
("A", 1): {"open": 10, "high": 10.5, "low": 9.5, "close": 10.2},
# day2: 到期 (max_hold_days=1), 收盘卖
("A", 2): {"open": 11, "high": 11, "low": 10, "close": 10.8},
},
)
entries = _mask(panel, {("A", 0)}) # day0 收盘确认
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
entry_fill="open_t+1",
exit_fill="close_t",
fees_pct=0,
slippage_bps=0,
max_positions=1,
max_hold_days=1,
initial_capital=100_000,
),
)
assert len(result.trades) == 1
trade = result.trades[0]
assert trade.entry_price == 10.0 # 次日开盘
assert trade.exit_price == 10.8 # 到期日收盘
assert trade.exit_reason == "max_hold"
@@ -0,0 +1,59 @@
"""全量模拟 (full mode) 尾部执行回归测试。"""
from __future__ import annotations
from datetime import date, timedelta
import polars as pl
from app.backtest.engine import BacktestEngine, MatcherConfig
def _panel_with_tail(symbols: list[str], n_data_days: int) -> pl.DataFrame:
start = date(2024, 1, 1)
rows = []
for sym in symbols:
for i in range(n_data_days):
px = 10.0 + i
rows.append({
"symbol": sym,
"date": start + timedelta(days=i),
"open": px,
"high": px,
"low": px,
"close": px,
"volume": 100_000,
"signal_limit_up": False,
"signal_limit_down": False,
})
return pl.DataFrame(rows).sort(["symbol", "date"])
def test_full_simulation_executes_signal_at_tail():
"""信号集中在正式区间最后一天时, tail 数据应允许次日开盘买入并按策略退出。"""
n_days = 6
panel = _panel_with_tail(["A"], n_days + 3)
start = date(2024, 1, 1)
end = start + timedelta(days=n_days - 1)
entry_vals = []
for row in panel.select(["symbol", "date"]).iter_rows(named=True):
entry_vals.append(row["date"] == end)
entry_mask = pl.Series(entry_vals, dtype=pl.Boolean)
exit_mask = pl.Series([False] * len(panel), dtype=pl.Boolean)
result = BacktestEngine(repo=None).simulate_independent_candidates( # type: ignore[arg-type]
panel,
entry_mask,
exit_mask,
MatcherConfig(matching="open_t+1", fees_pct=0, slippage_bps=0, max_hold_days=2),
)
assert not result.stats.get("error"), f"unexpected error: {result.stats.get('error')}"
assert result.stats.get("full_kind") == "candidate_execution"
assert result.stats.get("n_candidates") == 1
assert result.stats.get("n_trades") == 1
assert len(result.trades) == 1
trade = result.trades[0]
assert trade.entry_signal_date == str(end)
assert trade.entry_date == str(end + timedelta(days=1))
assert trade.exit_reason == "max_hold"
@@ -0,0 +1,163 @@
from __future__ import annotations
from datetime import date, timedelta
from types import SimpleNamespace
import polars as pl
from app.backtest.engine import BacktestEngine, SimResult
from app.backtest.strategy import StrategyBacktestConfig, StrategyBacktestService
from app.strategy.engine import StrategyDef
def _strategy(**kwargs) -> StrategyDef:
defaults = dict(
meta={"id": "test", "name": "test", "scoring": {}, "params": [], "limit": 100},
basic_filter={"enabled": True, "amount_min": 100.0},
entry_signals=[],
exit_signals=[],
stop_loss=None,
trailing_stop=None,
trailing_take_profit_activate=None,
trailing_take_profit_drawdown=None,
max_hold_days=None,
alerts=[],
filter_fn=lambda df, params: pl.lit(True),
filter_history_fn=None,
lookback_days=1,
source="custom",
file_path=None,
)
defaults.update(kwargs)
return StrategyDef(**defaults)
class _StrategyEngineStub:
def __init__(self, strategy: StrategyDef) -> None:
self.strategy = strategy
def get(self, strategy_id: str) -> StrategyDef:
return self.strategy
class _RepoStub:
def get_index_daily(self, *args, **kwargs) -> pl.DataFrame:
return pl.DataFrame()
class _EngineStub:
def __init__(self, panel: pl.DataFrame) -> None:
self.panel = panel
self.repo = _RepoStub()
self.load_args = None
self.sim_panel: pl.DataFrame | None = None
self.sim_entries: pl.Series | None = None
def load_panel(self, symbols, start: date, end: date) -> pl.DataFrame:
self.load_args = (symbols, start, end)
return self.panel
def simulate_portfolio(self, panel, entries, exits, config, progress_cb=None, cancel_event=None) -> SimResult:
self.sim_panel = panel
self.sim_entries = entries
return SimResult(
equity_curve=[{"date": "2024-01-01", "value": config.initial_capital}],
drawdown_curve=[{"date": "2024-01-01", "value": 0.0}],
trades=[],
per_symbol_stats=[],
stats={"total_return": 0.0, "n_trades": 0},
)
def test_basic_filter_only_limits_entries_not_panel_rows():
start = date(2024, 1, 1)
rows = []
for i, amount in enumerate([1000.0, 0.0, 1000.0]):
rows.append({
"symbol": "A",
"name": "A",
"date": start + timedelta(days=i),
"open": 10.0 + i,
"high": 10.0 + i,
"low": 10.0 + i,
"close": 10.0 + i,
"volume": 100_000,
"amount": amount,
"signal_limit_up": False,
"signal_limit_down": False,
})
panel = pl.DataFrame(rows).sort(["symbol", "date"])
engine = _EngineStub(panel)
service = StrategyBacktestService(engine=engine, strategy_engine=_StrategyEngineStub(_strategy()))
result = service.run(StrategyBacktestConfig(
strategy_id="test",
symbols=None,
start=start,
end=start + timedelta(days=2),
matching="close_t",
mode="position",
))
assert result.error is None
assert engine.sim_panel is not None
assert engine.sim_panel.height == 3
assert engine.sim_panel.filter(pl.col("amount") == 0.0).height == 1
assert engine.sim_entries is not None
assert engine.sim_entries.to_list() == [True, False, True]
assert engine.load_args is not None
assert engine.load_args[1] < start # warmup 只用于计算, 不参与正式交易
def test_score_normalizes_inside_strategy_candidate_universe():
panel = pl.DataFrame({
"symbol": ["A", "B", "C"],
"date": [date(2024, 1, 1)] * 3,
"factor": [10.0, 20.0, 1000.0],
})
universe = pl.Series([True, True, False], dtype=pl.Boolean)
strategy = SimpleNamespace(meta={"scoring": {"factor": 1.0}, "order_by": "score", "descending": True})
scored = StrategyBacktestService._apply_score(panel, strategy, None, universe_mask=universe)
scores = dict(zip(scored["symbol"].to_list(), scored["score"].to_list()))
assert scores["A"] == 0.0
assert scores["B"] == 100.0
assert scores["C"] == 0.0
def test_full_mode_executes_every_candidate_with_strategy_rules():
start = date(2024, 1, 1)
panel = pl.DataFrame([
{"symbol": "A", "name": "A", "date": start, "open": 10.0, "high": 10.0, "low": 10.0, "close": 10.0, "volume": 1, "amount": 1000.0, "signal_limit_up": False, "signal_limit_down": False},
{"symbol": "A", "name": "A", "date": start + timedelta(days=1), "open": 11.0, "high": 11.0, "low": 11.0, "close": 11.0, "volume": 1, "amount": 0.0, "signal_limit_up": False, "signal_limit_down": False},
{"symbol": "A", "name": "A", "date": start + timedelta(days=2), "open": 20.0, "high": 20.0, "low": 20.0, "close": 20.0, "volume": 1, "amount": 1000.0, "signal_limit_up": False, "signal_limit_down": False},
]).sort(["symbol", "date"])
engine = BacktestEngine(repo=None) # type: ignore[arg-type]
engine.load_panel = lambda symbols, s, e: panel # type: ignore[method-assign]
strategy = _strategy(
filter_fn=lambda df, params: pl.col("date") == start,
max_hold_days=1,
)
service = StrategyBacktestService(engine=engine, strategy_engine=_StrategyEngineStub(strategy))
result = service.run(StrategyBacktestConfig(
strategy_id="test",
symbols=None,
start=start,
end=start,
mode="full",
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
holding_days=1,
))
assert result.error is None
assert result.stats["full_kind"] == "candidate_execution"
assert result.stats["n_candidates"] == 1
assert result.stats["n_trades"] == 1
assert result.trades[0]["entry_date"] == str(start + timedelta(days=1))
assert result.trades[0]["exit_reason"] == "max_hold"
assert result.stats["avg_return"] == round(20 / 11 - 1, 4)