mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
回测成交口径: - close_t 按收盘价成交的建仓日: 当日 high 发生在建仓之前, 不得进入移动止损 峰值 (峰值虚高会把风控线上移、退出提前) — 矩阵独立/矩阵组合/legacy 三条 路径同口径; 新增回归测试锁定 open_t+1 对照组不被误伤 - 矩阵路径补收盘封死跌停拦截 (含一字), 经 pending_exit 递延到下一可卖日, 与独立/legacy 路径同口径 (此前尾盘封板股按跌停价"卖出"低估亏损) - #388 当日指标纪律同口径: 盘中成交不可知当日收盘, 代数剔除当日 enriched - 分钟回放: 入场价用前复权口径 (与日线出场同尺度); 除权日涨停参考价按 ex_factor 还原 (直接用 raw_prev 会把涨停价算高, 除权日封板拒买漏检) - 指标管线: 涨停参考价除权口径修正 + 夏普用样本标准差 (ddof=1, 与 engine 一致) - 回测任务: _acquire_or_evict_job 收口同 key 复用/强制重建 (参数不同先取消 旧任务, 避免旧任务占着重任务槽跑完) 服务与并发: - task_registry 读-改-写加锁: 两个前端 fire-and-forget 并发上报时后写者 覆盖先写者 (丢终态翻转或整条记录); 文件写入本身原子, 锁只护 RMW 段 - quote_service 生命周期锁串行化 start/stop/enable 的 check-then-act (双标签页 并发开关会起两条轮询线程 → 双倍拉取配额 + 重复 SSE 广播) - 系统通知投递移入专用单线程池: notify_adapter 底层 subprocess 逐条最长 5s, 在 _poll_loop 同步执行会让告警风暴阻塞一轮监控评估, 拖慢行情拉取与推送 - sector_monitor / minute_refresh / wecom_bot: 同类单实例与停止时序加固 - ai_provider: 401 识别加词边界 (避免模型正文含 401 数字串误判鉴权失败并 附出不相关指引); 自定义可执行路径校验可执行位 - api/data: 清空数据期间暂停全量分钟增量线程 (否则"清完又被写回部分今日 数据"); 状态接口偏好/能力探测异常降级而不是 500 - api/kline: 失效内存 enriched 快照 (非空缓存不校验 generation, 只刷 DuckDB 视图会让重建结果对自选/筛选/监控不可见直到进程重启) - api/position_policies: 非法 ID 纵深防御按不存在处理 前端: - assistant store: 生成终态 (中断提示/流式收敛) 钉在发起会话, 切换或删除会话 不再把"已中断"写进无关新会话 - backtestTask: 键上挂着旧任务且新参数不同 (不同 job_key) 时先取消旧任务, 避免其占着重任务槽跑到自然结束; 同参重跑跳过取消 (后端按同 key 复用 / force 重建已完成任务) - 查询键集中与正确性: QK.instrumentNames (排序去重, 同一集合不同来源顺序 复用缓存)、QK.kline 滚动窗口空串边界 (useQuoteStream 焦点股增量合并可 修补)、regimeHistory 用实际请求三元组 + 裸前缀失效全部变体 - Data 页 / Regime / SectorRotationCard / RuleEditor / monitor / lots / stock-analysis 接入集中键, useQuoteStream 对齐 验证: 后端全量 3546 passed (含 close_t 峰值/分钟回测/跌停递延/并发上报等 新增用例); 前端 49 文件 348 用例 + pnpm build 通过; ruff 改动文件零新增 (141 vs 基线 142)。 Signed-off-by: shy3130 <shy3130@qq.com>
472 lines
15 KiB
Python
472 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import numpy as np
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.backtest.matrix import build_market_data_matrix, make_signal_matrix
|
|
from app.services import alert_store, preferences, quote_service
|
|
from app.services.quote_service import QuoteService
|
|
from app.strategy import monitor_rules
|
|
from app.strategy.engine import StrategyDataContext, StrategyDef, StrategyEngine, StrategyResult
|
|
from app.strategy.monitor import MonitorRuleEngine
|
|
|
|
|
|
def _rule(*events: str, **overrides) -> dict:
|
|
return monitor_rules.normalize({
|
|
"id": "strategy_rule",
|
|
"name": "策略监控",
|
|
"type": "strategy",
|
|
"asset_type": "stock",
|
|
"scope": "all",
|
|
"symbols": [],
|
|
"strategy_id": "demo",
|
|
"notify_events": list(events),
|
|
"conditions": [],
|
|
"cooldown_seconds": 3600,
|
|
**overrides,
|
|
})
|
|
|
|
|
|
class _SequenceStrategyEngine:
|
|
def __init__(self, results: list[StrategyResult]):
|
|
self.results = list(results)
|
|
self.strategy = SimpleNamespace(
|
|
meta={"id": "demo", "name": "示例策略"},
|
|
execution_backend="polars_expr",
|
|
filter_history_fn=None,
|
|
)
|
|
|
|
def get(self, strategy_id: str):
|
|
assert strategy_id == "demo"
|
|
return self.strategy
|
|
|
|
def run(self, strategy_id: str, context, **kwargs):
|
|
assert strategy_id == "demo"
|
|
return self.results.pop(0)
|
|
|
|
|
|
def _result(
|
|
as_of: date,
|
|
pool: tuple[str, ...] = (),
|
|
buys: tuple[str, ...] = (),
|
|
sells: tuple[str, ...] = (),
|
|
scores: dict[str, float] | None = None,
|
|
) -> StrategyResult:
|
|
scores = scores or {}
|
|
return StrategyResult(
|
|
as_of=as_of,
|
|
strategy_id="demo",
|
|
rows=[
|
|
{
|
|
"symbol": symbol,
|
|
"close": 10.0,
|
|
"change_pct": 0.01,
|
|
**({"score": scores[symbol]} if symbol in scores else {}),
|
|
}
|
|
for symbol in pool
|
|
],
|
|
total=len(pool),
|
|
scores=scores,
|
|
entry_signal_hits=[{"symbol": symbol, "signals": ["signal_buy"]} for symbol in buys],
|
|
exit_signal_hits=[{"symbol": symbol, "signals": ["signal_sell"]} for symbol in sells],
|
|
)
|
|
|
|
|
|
def _quotes() -> pl.DataFrame:
|
|
return pl.DataFrame({
|
|
"symbol": ["A", "B"],
|
|
"close": [10.0, 20.0],
|
|
"change_pct": [0.01, -0.02],
|
|
})
|
|
|
|
|
|
def test_strategy_rule_compatibility_and_validation(tmp_path):
|
|
legacy = {
|
|
"id": "legacy", "name": "旧规则", "type": "strategy",
|
|
"scope": "all", "strategy_id": "demo",
|
|
}
|
|
monitor_rules.save_one(tmp_path, legacy)
|
|
|
|
loaded = monitor_rules.load_one(tmp_path, "legacy")
|
|
assert loaded is not None
|
|
assert loaded["notify_events"] == ["pool_entry", "pool_exit"]
|
|
assert loaded["score_min"] is None
|
|
assert loaded["score_max"] is None
|
|
assert monitor_rules.load_all(tmp_path)[0]["notify_events"] == ["pool_entry", "pool_exit"]
|
|
|
|
with pytest.raises(ValueError, match="至少选择一个通知事件"):
|
|
monitor_rules.validate(_rule())
|
|
with pytest.raises(ValueError, match="非法事件"):
|
|
monitor_rules.validate(_rule("unknown"))
|
|
with pytest.raises(ValueError, match="0 到 100"):
|
|
monitor_rules.validate(_rule("pool_entry", score_min=-1))
|
|
with pytest.raises(ValueError, match="不能大于"):
|
|
monitor_rules.validate(_rule("pool_entry", score_min=90, score_max=70))
|
|
monitor_rules.validate(_rule("buy_signal", "pool_exit"))
|
|
|
|
normalized = monitor_rules.normalize(_rule(
|
|
"pool_entry",
|
|
webhook_channels=["feishu", "custom", "email", "unsupported"],
|
|
))
|
|
assert normalized["webhook_channels"] == ["feishu", "custom", "email"]
|
|
|
|
|
|
def test_strategy_score_range_filters_pool_and_buy_signals_but_not_sell_signals():
|
|
day = date(2026, 7, 24)
|
|
engine = MonitorRuleEngine()
|
|
engine.set_strategy_engine(_SequenceStrategyEngine([
|
|
_result(
|
|
day,
|
|
pool=("A", "B", "D"),
|
|
buys=("A", "B", "D"),
|
|
scores={"A": 69, "B": 80},
|
|
),
|
|
_result(
|
|
day,
|
|
pool=("A", "B", "C", "D"),
|
|
buys=("A", "B", "C", "D"),
|
|
sells=("B",),
|
|
scores={"A": 70, "B": 91, "C": 90},
|
|
),
|
|
]))
|
|
engine.set_rules([_rule(
|
|
"buy_signal", "sell_signal", "pool_entry", "pool_exit",
|
|
score_min=70,
|
|
score_max=90,
|
|
)])
|
|
|
|
with patch("app.strategy.monitor.time.time", side_effect=[100, 101]):
|
|
assert engine.evaluate(_quotes()) == []
|
|
events = engine.evaluate(_quotes())
|
|
|
|
assert {(event["type"], event["symbol"]) for event in events} == {
|
|
("buy_signal", "A"),
|
|
("buy_signal", "C"),
|
|
("sell_signal", "B"),
|
|
("pool_entry", "A"),
|
|
("pool_entry", "C"),
|
|
("pool_exit", "B"),
|
|
}
|
|
|
|
|
|
def test_strategy_score_range_edit_resets_pool_baseline():
|
|
day = date(2026, 7, 24)
|
|
engine = MonitorRuleEngine()
|
|
engine.set_strategy_engine(_SequenceStrategyEngine([
|
|
_result(day, pool=("A",), scores={"A": 80}),
|
|
_result(day, pool=("A",), scores={"A": 80}),
|
|
]))
|
|
rule = _rule("pool_exit", score_min=70)
|
|
engine.set_rules([rule])
|
|
assert engine.evaluate(_quotes()) == []
|
|
|
|
engine.set_rules([{**rule, "score_min": 90}])
|
|
|
|
assert engine.evaluate(_quotes()) == []
|
|
|
|
|
|
def test_strategy_events_baseline_dedupe_and_next_day_replay():
|
|
day1 = date(2026, 7, 24)
|
|
day2 = date(2026, 7, 25)
|
|
engine = MonitorRuleEngine()
|
|
engine.set_strategy_engine(_SequenceStrategyEngine([
|
|
_result(day1, pool=("A",), buys=("A",)),
|
|
_result(day1, pool=("A", "B"), buys=("A", "B")),
|
|
_result(day1, pool=("A", "B")),
|
|
_result(day1, pool=("A", "B"), buys=("B",)),
|
|
_result(day2, pool=("A", "B"), buys=("B",)),
|
|
]))
|
|
engine.set_rules([_rule("buy_signal", "pool_entry")])
|
|
|
|
with patch("app.strategy.monitor.time.time", side_effect=[100, 101, 102, 103, 4000]):
|
|
assert engine.evaluate(_quotes()) == []
|
|
events = engine.evaluate(_quotes())
|
|
assert {(event["type"], event["symbol"]) for event in events} == {
|
|
("buy_signal", "B"),
|
|
("pool_entry", "B"),
|
|
}
|
|
assert all(event["strategy_id"] == "demo" for event in events)
|
|
assert engine.evaluate(_quotes()) == []
|
|
assert engine.evaluate(_quotes()) == []
|
|
next_day = engine.evaluate(_quotes())
|
|
assert [(event["type"], event["symbol"]) for event in next_day] == [("buy_signal", "B")]
|
|
|
|
|
|
def test_strategy_sell_and_pool_exit_are_independent_events():
|
|
day = date(2026, 7, 24)
|
|
engine = MonitorRuleEngine()
|
|
engine.set_strategy_engine(_SequenceStrategyEngine([
|
|
_result(day, pool=("A", "B")),
|
|
_result(day, pool=("A",), sells=("B",)),
|
|
]))
|
|
engine.set_rules([_rule("sell_signal", "pool_exit")])
|
|
|
|
with patch("app.strategy.monitor.time.time", side_effect=[100, 101]):
|
|
assert engine.evaluate(_quotes()) == []
|
|
events = engine.evaluate(_quotes())
|
|
|
|
assert {(event["type"], event["symbol"]) for event in events} == {
|
|
("sell_signal", "B"),
|
|
("pool_exit", "B"),
|
|
}
|
|
|
|
|
|
def test_strategy_rule_reload_preserves_state_and_semantic_edit_resets_it():
|
|
day = date(2026, 7, 24)
|
|
engine = MonitorRuleEngine()
|
|
engine.set_strategy_engine(_SequenceStrategyEngine([
|
|
_result(day, buys=("A",)),
|
|
_result(day, buys=("A",)),
|
|
_result(day, buys=("A",)),
|
|
_result(day, buys=("A",)),
|
|
]))
|
|
rule = _rule("buy_signal")
|
|
engine.set_rules([rule])
|
|
assert engine.evaluate(_quotes()) == []
|
|
|
|
engine.set_rules([{**rule, "message": "新文案"}])
|
|
assert engine.evaluate(_quotes()) == []
|
|
|
|
engine.set_rules([{**rule, "scope": "symbols", "symbols": ["A"]}])
|
|
assert engine.evaluate(_quotes()) == []
|
|
|
|
engine.set_rules([])
|
|
assert not engine._strategy_signal_state
|
|
engine.set_rules([rule])
|
|
assert engine.evaluate(_quotes()) == []
|
|
|
|
|
|
def test_matrix_signal_hits_map_codes_and_keep_unlabelled_hits():
|
|
mapped = StrategyEngine._matrix_signal_hits(
|
|
np.array([1, 1, 0], dtype=np.uint8),
|
|
np.array([1, -1, -1], dtype=np.int16),
|
|
("signal_a", "signal_b"),
|
|
("A", "B", "C"),
|
|
)
|
|
assert mapped == [
|
|
{"symbol": "A", "signals": ["signal_b"]},
|
|
{"symbol": "B", "signals": []},
|
|
]
|
|
|
|
|
|
def test_matrix_strategy_pool_masks_rows_and_both_signal_directions():
|
|
day = date(2026, 7, 24)
|
|
panel = pl.DataFrame({
|
|
"symbol": ["A", "B"],
|
|
"date": [day, day],
|
|
"open": [10.0, 20.0],
|
|
"high": [10.0, 20.0],
|
|
"low": [10.0, 20.0],
|
|
"close": [10.0, 20.0],
|
|
"volume": [100.0, 100.0],
|
|
"amount": [1000.0, 2000.0],
|
|
})
|
|
calls = 0
|
|
|
|
class _AllSignals:
|
|
def required_fields(self):
|
|
return frozenset({"close"})
|
|
|
|
def required_warmup_bars(self, params):
|
|
return 1
|
|
|
|
def compute_signals(self, market, params):
|
|
nonlocal calls
|
|
calls += 1
|
|
active = np.ones(market.shape, dtype=np.uint8)
|
|
codes = np.zeros(market.shape, dtype=np.int16)
|
|
return make_signal_matrix(
|
|
market.shape,
|
|
entry=active,
|
|
exit=active,
|
|
entry_signal_code=codes,
|
|
exit_signal_code=codes,
|
|
entry_signal_ids=("signal_buy",),
|
|
exit_signal_ids=("signal_sell",),
|
|
)
|
|
|
|
strategy = StrategyDef(
|
|
meta={"id": "matrix", "scoring": {}, "limit": 100},
|
|
basic_filter={"enabled": False},
|
|
entry_signals=[],
|
|
exit_signals=[],
|
|
stop_loss=None,
|
|
trailing_stop=None,
|
|
trailing_take_profit_activate=None,
|
|
trailing_take_profit_drawdown=None,
|
|
max_hold_days=None,
|
|
filter_fn=None,
|
|
filter_history_fn=None,
|
|
lookback_days=1,
|
|
source="custom",
|
|
execution_backend="matrix_native",
|
|
matrix_strategy=_AllSignals(),
|
|
)
|
|
engine = StrategyEngine(strategy_dirs=[])
|
|
engine._strategies["matrix"] = strategy
|
|
result = engine.run(
|
|
"matrix",
|
|
StrategyDataContext(
|
|
"stock",
|
|
"1d",
|
|
day,
|
|
current=panel,
|
|
market=build_market_data_matrix(panel),
|
|
),
|
|
pool=["A"],
|
|
)
|
|
|
|
assert calls == 1
|
|
assert [row["symbol"] for row in result.rows] == ["A"]
|
|
assert result.entry_signal_hits == [{"symbol": "A", "signals": ["signal_buy"]}]
|
|
assert result.exit_signal_hits == [{"symbol": "A", "signals": ["signal_sell"]}]
|
|
|
|
|
|
def test_ordinary_strategy_uses_signal_overrides_and_ignores_malformed_values():
|
|
day = date(2026, 7, 24)
|
|
quotes = pl.DataFrame({
|
|
"symbol": ["A", "B"],
|
|
"signal_default_buy": [True, False],
|
|
"signal_override_buy": [False, True],
|
|
"signal_default_sell": [False, True],
|
|
"signal_override_sell": [True, False],
|
|
})
|
|
strategy = StrategyDef(
|
|
meta={"id": "ordinary", "scoring": {}, "limit": 100},
|
|
basic_filter={"enabled": False},
|
|
entry_signals=["signal_default_buy"],
|
|
exit_signals=["signal_default_sell"],
|
|
stop_loss=None,
|
|
trailing_stop=None,
|
|
trailing_take_profit_activate=None,
|
|
trailing_take_profit_drawdown=None,
|
|
max_hold_days=None,
|
|
filter_fn=None,
|
|
filter_history_fn=None,
|
|
lookback_days=1,
|
|
source="custom",
|
|
)
|
|
engine = StrategyEngine(strategy_dirs=[])
|
|
engine._strategies["ordinary"] = strategy
|
|
context = StrategyDataContext("stock", "1d", day, current=quotes)
|
|
|
|
result = engine.run(
|
|
"ordinary",
|
|
context,
|
|
overrides={
|
|
"entry_signals": ["signal_override_buy"],
|
|
"exit_signals": ["signal_override_sell"],
|
|
},
|
|
)
|
|
assert result.entry_signal_hits == [{"symbol": "B", "signals": ["signal_override_buy"]}]
|
|
assert result.exit_signal_hits == [{"symbol": "A", "signals": ["signal_override_sell"]}]
|
|
|
|
fallback = engine.run(
|
|
"ordinary",
|
|
context,
|
|
overrides={"entry_signals": None, "exit_signals": "signal_override_sell"},
|
|
)
|
|
assert fallback.entry_signal_hits == [{"symbol": "A", "signals": ["signal_default_buy"]}]
|
|
assert fallback.exit_signal_hits == [{"symbol": "B", "signals": ["signal_default_sell"]}]
|
|
|
|
|
|
def test_quote_service_forwards_real_strategy_id(monkeypatch, tmp_path):
|
|
event = {
|
|
"ts": 1,
|
|
"rule_id": "strategy_rule",
|
|
"strategy_id": "demo",
|
|
"source": "strategy",
|
|
"type": "buy_signal",
|
|
"symbol": "A",
|
|
"name": "测试股票",
|
|
"message": "策略买入信号",
|
|
"price": 10.0,
|
|
"change_pct": 0.01,
|
|
"signals": ["signal_buy"],
|
|
"severity": "info",
|
|
}
|
|
|
|
class _Engine:
|
|
rule_count = 1
|
|
|
|
def __init__(self):
|
|
self.rules = {"strategy_rule": {"webhook_channels": []}}
|
|
|
|
def set_name_map(self, name_map):
|
|
pass
|
|
|
|
def has_rule_type(self, rtype: str) -> bool:
|
|
return False
|
|
|
|
def has_asset_rules(self, asset_type: str) -> bool:
|
|
return False
|
|
|
|
def evaluate(self, df, asset_type: str):
|
|
return [event]
|
|
|
|
def consume_strategy_result_updates(self) -> bool:
|
|
return False
|
|
|
|
class _Repo:
|
|
store = SimpleNamespace(data_dir=tmp_path)
|
|
|
|
@staticmethod
|
|
def get_instruments():
|
|
return pl.DataFrame({"symbol": ["A"], "name": ["测试股票"]})
|
|
|
|
monkeypatch.setattr(alert_store, "append_many", lambda *args: None)
|
|
monkeypatch.setattr(preferences, "get_system_notify_enabled", lambda: False)
|
|
service = QuoteService()
|
|
subscriber = service.subscribe()
|
|
service.set_app_state(SimpleNamespace(monitor_engine=_Engine(), repo=_Repo()))
|
|
service._repo = _Repo()
|
|
service.get_enriched_today = lambda: (_quotes(), quote_service.cn_today())
|
|
|
|
with patch.object(QuoteService, "_is_continuous_trading", return_value=True):
|
|
service._evaluate_monitors(pl.DataFrame(), None)
|
|
|
|
assert subscriber.pop()["alerts"][0]["strategy_id"] == "demo"
|
|
|
|
|
|
def test_system_notifications_delivered_off_calling_thread(monkeypatch):
|
|
"""系统通知投递必须离开行情轮询线程 (专用线程池)。
|
|
|
|
notify_adapter 底层是 subprocess (每条最长 5s 超时), 若在轮询线程上同步
|
|
逐条执行, 告警风暴会让一轮监控评估阻塞数十秒, 拖慢行情拉取与 SSE 推送。
|
|
"""
|
|
import threading
|
|
|
|
from app.services import preferences, quote_service as qs_mod
|
|
|
|
monkeypatch.setattr(preferences, "get_system_notify_enabled", lambda: True)
|
|
|
|
delivered = threading.Event()
|
|
records: list[tuple[str, str, str]] = []
|
|
|
|
def fake_notify(title: str, body: str) -> bool:
|
|
records.append((threading.current_thread().name, title, body))
|
|
delivered.set()
|
|
return True
|
|
|
|
from app.services import notify_adapter
|
|
|
|
monkeypatch.setattr(notify_adapter, "notify", fake_notify)
|
|
|
|
svc = object.__new__(qs_mod.QuoteService) # 只测该方法, 不跑完整 __init__
|
|
svc._maybe_send_system_notifications([{
|
|
"source": "price", "symbol": "000001.SZ", "name": "平安银行",
|
|
"message": "涨幅突破阈值", "close": 10.0, "change_pct": 0.0366,
|
|
}])
|
|
|
|
assert delivered.wait(2), "系统通知应在专用线程池内被投递"
|
|
thread_name, title, body = records[0]
|
|
assert thread_name.startswith("sys-notify")
|
|
assert thread_name != threading.current_thread().name
|
|
assert title == "TickFlow · 价格"
|
|
assert "000001.SZ" in body
|