mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 14:24:15 +08:00
- 全部 K 线取数点改走 CompactKlineData 列式直转 polars (无 pandas 中转, 全市场单轮本地转换 ~1.1s → ~0.1s), 时区口径统一 _timestamp_to_beijing_datetime - 除权因子 trade_date 原取 UTC 日期, 北京零点事件整体早一天 → 转北京墙钟后取日期 - 盘中分钟增量间隔上限 300s → 120s (universe 仅回最新 3 根, 超限必留缺口)
193 lines
7.5 KiB
Python
193 lines
7.5 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.market_time import CN_TZ
|
|
from app.services.kline_sync import fetch_intraday_monitor_batch, intraday_monitor_support
|
|
from app.strategy import monitor_rules
|
|
from app.strategy.intraday_signals import IntradaySignalEvaluator
|
|
from app.strategy.monitor import MonitorRuleEngine
|
|
from app.tickflow.capabilities import Cap, CapabilityLimits, CapabilitySet
|
|
|
|
|
|
def _minute_rows(prices: list[float]) -> pl.DataFrame:
|
|
return pl.DataFrame({
|
|
"symbol": ["600000.SH"] * len(prices),
|
|
"datetime": [datetime(2026, 7, 17, 9, 30 + i) for i in range(len(prices))],
|
|
"close": prices,
|
|
"volume": [1.0] * len(prices),
|
|
"amount": [price * 100.0 for price in prices],
|
|
})
|
|
|
|
|
|
def test_intraday_crosses_are_edge_triggered_and_not_replayed():
|
|
evaluator = IntradaySignalEvaluator()
|
|
kwargs = {
|
|
"symbols": {"600000.SH"},
|
|
"prev_close": {"600000.SH": 10.0},
|
|
"asset_type": "stock",
|
|
}
|
|
|
|
# 首次只建立基线, 不补发当前已有的穿越。
|
|
assert evaluator.evaluate(_minute_rows([9.0]), now=datetime(2026, 7, 17, 9, 32), **kwargs) == []
|
|
|
|
up = evaluator.evaluate(_minute_rows([9.0, 11.0]), now=datetime(2026, 7, 17, 9, 33), **kwargs)
|
|
assert len(up) == 1
|
|
assert up[0]["signal_intraday_avg_cross_up"] is True
|
|
assert up[0]["signal_intraday_zero_cross_up"] is True
|
|
|
|
# 同一根已完成分钟线不得重复触发。
|
|
assert evaluator.evaluate(_minute_rows([9.0, 11.0]), now=datetime(2026, 7, 17, 9, 33, 30), **kwargs) == []
|
|
|
|
down = evaluator.evaluate(_minute_rows([9.0, 11.0, 9.0]), now=datetime(2026, 7, 17, 9, 34), **kwargs)
|
|
assert len(down) == 1
|
|
assert down[0]["signal_intraday_avg_cross_down"] is True
|
|
assert down[0]["signal_intraday_zero_cross_down"] is True
|
|
|
|
|
|
def test_intraday_signals_flow_through_monitor_engine():
|
|
evaluator = IntradaySignalEvaluator()
|
|
kwargs = {
|
|
"symbols": {"600000.SH"},
|
|
"prev_close": {"600000.SH": 10.0},
|
|
"asset_type": "stock",
|
|
}
|
|
evaluator.evaluate(_minute_rows([9.0]), now=datetime(2026, 7, 17, 9, 32), **kwargs)
|
|
signals = evaluator.evaluate(_minute_rows([9.0, 11.0]), now=datetime(2026, 7, 17, 9, 33), **kwargs)
|
|
enriched = pl.DataFrame({
|
|
"symbol": ["600000.SH"], "close": [11.0], "change_pct": [0.1],
|
|
})
|
|
engine = MonitorRuleEngine()
|
|
engine.set_rules([{**_intraday_rule(), "cooldown_seconds": 0}])
|
|
events = engine.evaluate(evaluator.inject(enriched, signals))
|
|
assert len(events) == 1
|
|
assert events[0]["rule_id"] == "intraday_rule"
|
|
assert events[0]["signals"] == ["signal_intraday_avg_cross_up"]
|
|
|
|
|
|
def test_intraday_signal_state_resets_between_trading_days():
|
|
evaluator = IntradaySignalEvaluator()
|
|
evaluator.evaluate(
|
|
_minute_rows([9.0]), symbols={"600000.SH"},
|
|
prev_close={"600000.SH": 10.0}, asset_type="stock",
|
|
now=datetime(2026, 7, 17, 9, 32),
|
|
)
|
|
next_day = pl.DataFrame({
|
|
"symbol": ["600000.SH"],
|
|
"datetime": [datetime(2026, 7, 18, 9, 30)],
|
|
"close": [11.0], "volume": [1.0], "amount": [1100.0],
|
|
})
|
|
assert evaluator.evaluate(
|
|
next_day, symbols={"600000.SH"},
|
|
prev_close={"600000.SH": 10.0}, asset_type="stock",
|
|
now=datetime(2026, 7, 18, 9, 32),
|
|
) == []
|
|
|
|
|
|
def test_intraday_average_does_not_accumulate_previous_day_bars():
|
|
evaluator = IntradaySignalEvaluator()
|
|
previous_day = pl.DataFrame({
|
|
"symbol": ["600000.SH"],
|
|
"datetime": [datetime(2026, 7, 16, 15, 0)],
|
|
"close": [100.0], "volume": [1000.0], "amount": [10_000_000.0],
|
|
})
|
|
first = pl.concat([previous_day, _minute_rows([9.0])])
|
|
evaluator.evaluate(
|
|
first, symbols={"600000.SH"}, prev_close={"600000.SH": 10.0},
|
|
asset_type="stock", now=datetime(2026, 7, 17, 9, 32),
|
|
)
|
|
second = pl.concat([previous_day, _minute_rows([9.0, 11.0])])
|
|
signals = evaluator.evaluate(
|
|
second, symbols={"600000.SH"}, prev_close={"600000.SH": 10.0},
|
|
asset_type="stock", now=datetime(2026, 7, 17, 9, 33),
|
|
)
|
|
assert signals[0]["signal_intraday_avg_cross_up"] is True
|
|
|
|
|
|
def test_intraday_cutoff_keeps_beijing_time_in_utc_runtime():
|
|
evaluator = IntradaySignalEvaluator()
|
|
kwargs = {
|
|
"symbols": {"600000.SH"},
|
|
"prev_close": {"600000.SH": 10.0},
|
|
"asset_type": "stock",
|
|
}
|
|
assert evaluator.evaluate(
|
|
_minute_rows([9.0]), symbols={"600000.SH"},
|
|
prev_close={"600000.SH": 10.0}, asset_type="stock",
|
|
now=datetime(2026, 7, 17, 9, 32, tzinfo=CN_TZ),
|
|
) == []
|
|
signals = evaluator.evaluate(
|
|
_minute_rows([9.0, 11.0]), now=datetime(2026, 7, 17, 9, 33, tzinfo=CN_TZ),
|
|
**kwargs,
|
|
)
|
|
assert signals[0]["signal_intraday_zero_cross_up"] is True
|
|
|
|
|
|
def _intraday_rule(scope: str = "symbols") -> dict:
|
|
return {
|
|
"id": "intraday_rule", "name": "分时监控", "enabled": True,
|
|
"type": "signal", "asset_type": "stock", "scope": scope,
|
|
"symbols": ["600000.SH"], "logic": "and",
|
|
"conditions": [{"field": "signal_intraday_avg_cross_up", "op": "truth"}],
|
|
}
|
|
|
|
|
|
def test_intraday_rule_pool_is_derived_from_enabled_rules():
|
|
engine = MonitorRuleEngine()
|
|
disabled = {**_intraday_rule(), "id": "disabled", "enabled": False, "symbols": ["000001.SZ"]}
|
|
engine.set_rules([_intraday_rule(), disabled])
|
|
assert engine.intraday_signal_symbols("stock") == {"600000.SH"}
|
|
assert engine.intraday_signal_symbols("etf") == set()
|
|
|
|
|
|
def test_intraday_rule_rejects_non_symbol_scope():
|
|
with pytest.raises(ValueError, match="仅支持指定标的"):
|
|
monitor_rules.validate(_intraday_rule("all"))
|
|
|
|
|
|
def test_intraday_support_uses_capability_limits(monkeypatch):
|
|
monkeypatch.setattr("app.services.preferences.get_minute_data_provider", lambda: "tickflow")
|
|
capset = CapabilitySet({Cap.KLINE_MINUTE_BATCH: CapabilityLimits(batch=25, rpm=30)})
|
|
support = intraday_monitor_support(capset)
|
|
assert support["available"] is True
|
|
assert support["source"] == "minute_batch"
|
|
assert support["max_symbols"] == 25
|
|
|
|
denied = intraday_monitor_support(CapabilitySet())
|
|
assert denied["available"] is False
|
|
|
|
|
|
def test_intraday_batch_provider_is_normalized_without_network(monkeypatch):
|
|
monkeypatch.setattr("app.services.preferences.get_minute_data_provider", lambda: "tickflow")
|
|
|
|
class FakeKlines:
|
|
def intraday_batch(self, symbols, count, as_dataframe, show_progress, batch_size):
|
|
assert symbols == ["600000.SH"]
|
|
assert count == 300
|
|
assert as_dataframe is False
|
|
assert show_progress is False
|
|
assert batch_size == 20
|
|
# as_dataframe=False: dict[symbol → CompactKlineData (字段→列数组)]
|
|
ts = int(datetime(2026, 7, 17, 9, 30, tzinfo=CN_TZ).timestamp() * 1000)
|
|
return {
|
|
"600000.SH": {
|
|
"timestamp": [ts],
|
|
"open": [10.0], "high": [10.1], "low": [9.9], "close": [10.0],
|
|
"volume": [1.0], "amount": [1000.0],
|
|
},
|
|
}
|
|
|
|
class FakeClient:
|
|
klines = FakeKlines()
|
|
|
|
monkeypatch.setattr("app.services.kline_sync.get_client", lambda: FakeClient())
|
|
capset = CapabilitySet({Cap.INTRADAY_BATCH: CapabilityLimits(batch=20, rpm=30)})
|
|
result = fetch_intraday_monitor_batch(
|
|
["600000.SH"], capset, now=datetime(2026, 7, 17, 10, 0, tzinfo=CN_TZ),
|
|
)
|
|
assert result.columns == ["symbol", "datetime", "open", "high", "low", "close", "volume", "amount"]
|
|
assert result["symbol"].to_list() == ["600000.SH"]
|