mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
一期 · 分钟策略执行链路: - 引擎新增 minute_filter 执行后端: 策略声明 filter_minute_history(df, params), timeframes 必须且仅为 ["1m"]; 输入为当日分钟K窗口, 命中行事后联表 enriched 快照补基础过滤列 (name/total_shares/change_pct), close 用最新分钟价 - 内置策略「分钟红7」(minute_red_streak): 最近 N 根(默认7)分钟K至少 5 根 close>open, 且按最高价排序的最高的 2 根全红; 全向量化, 671k 行约 230ms; 参数 bars/min_red/top_red/rank_by_close, 不足 N 根不触发, 同值取更晚K线 - ScreenerService 1m context: 优先读 as_of 当日 kline_minute 分区, 缺失回退 全市场最近分区; 单分区直读与全量 glob 解耦 - run_preset/run_all 分钟周期结果不写日线盘后缓存 (语义隔离) 二期 · 盘中分钟增量落盘 (Expert 专有): - kline_sync.fetch_intraday_full_market_burst: intraday.batch 独立限流池, 全市场 5546/200=28 块线程池一次打出, 轮内不重试 - MinuteRefreshService: 后台线程, 门控链 = 开关→自定义分钟源让位→INTRADAY_BATCH 能力→连续竞价时段(9:30-11:30/13:00-15:00); 固定节奏 next=max(起点+间隔,完成), 不补跑; 每轮单次合并落盘 (_write_minute_partition unique 幂等) - 偏好 minute_refresh_enabled(默认关)/minute_refresh_interval([60,300]s 默认60); GET /api/settings/minute-refresh/status 状态端点 前端: - 策略页日线/分钟周期切换 (1m 下 ETF 置灰、不触发盘后 runAll、prune 仅日线), 策略卡片「分钟」徽章, 策略池对话框按周期拉取 - 数据页分钟K设置弹窗新增盘中增量区块: 开关/间隔(60-300s)/能力缺失置灰/ 服务状态行(运行中·时段暂停·最近一轮) 测试: 26 项新增 (形态7/引擎4/context4/服务11), 更新 3 个 matrix 不变量测试; 全量 1112 passed; 前端 build 通过; 浏览器端到端实测通过
199 lines
7.2 KiB
Python
199 lines
7.2 KiB
Python
"""盘中分钟增量刷新服务 (minute_refresh) 测试。
|
|
|
|
覆盖:
|
|
- 连续竞价时段判定 (含边界)
|
|
- 门控链: 开关关闭 / 自定义分钟源让位 / 能力缺失 / 时段外 / 放行
|
|
- 单轮: mock 边界层脉冲 + 落盘, 校验状态字段与 universe 来源
|
|
- 偏好读写: 默认关闭、间隔 clamp [60, 300]
|
|
- API: /minute-refresh/status 无服务时 available=false
|
|
|
|
不发起真实网络请求: fetch_intraday_full_market_burst 与 _write_minute_partition
|
|
均 monkeypatch 替换。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
import polars as pl
|
|
|
|
from app.services import minute_refresh, preferences
|
|
from app.services.minute_refresh import MinuteRefreshService, _in_continuous_session
|
|
|
|
|
|
def _isolated_prefs(tmp_path, monkeypatch):
|
|
path = tmp_path / "preferences.json"
|
|
monkeypatch.setattr(preferences, "_path", lambda: path)
|
|
preferences._invalidate_cache()
|
|
return path
|
|
|
|
|
|
class _FakeCapSet:
|
|
def __init__(self, has_intraday_batch: bool):
|
|
self._has = has_intraday_batch
|
|
|
|
def has(self, cap) -> bool:
|
|
from app.tickflow.capabilities import Cap
|
|
|
|
return self._has and cap == Cap.INTRADAY_BATCH
|
|
|
|
|
|
class _FakeAppState:
|
|
def __init__(self, has_intraday_batch: bool):
|
|
self.capabilities = _FakeCapSet(has_intraday_batch)
|
|
|
|
|
|
class _FakeRepo:
|
|
def __init__(self, symbols: list[str]):
|
|
from pathlib import Path
|
|
self._inst = pl.DataFrame({"symbol": symbols})
|
|
self.store = type("S", (), {"data_dir": Path(".")})()
|
|
|
|
def get_instruments(self) -> pl.DataFrame:
|
|
return self._inst
|
|
|
|
|
|
# ── 时段判定 ────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_continuous_session_boundaries():
|
|
wk = datetime(2026, 8, 25, 10, 0) # 周二
|
|
assert _in_continuous_session(wk)
|
|
assert not _in_continuous_session(datetime(2026, 8, 25, 9, 29))
|
|
assert not _in_continuous_session(datetime(2026, 8, 25, 11, 31)) # 午休
|
|
assert _in_continuous_session(datetime(2026, 8, 25, 13, 0)) # 午后恢复
|
|
assert _in_continuous_session(datetime(2026, 8, 25, 15, 0)) # 收盘瞬时
|
|
assert not _in_continuous_session(datetime(2026, 8, 25, 15, 1))
|
|
assert not _in_continuous_session(datetime(2026, 8, 22, 10, 0)) # 周六
|
|
|
|
|
|
# ── 门控链 ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _svc(tmp_path, monkeypatch, *, enabled=True, custom_provider=False, capability=True, in_hours=True):
|
|
_isolated_prefs(tmp_path, monkeypatch)
|
|
preferences.save({"minute_refresh_enabled": enabled})
|
|
if custom_provider:
|
|
# 模拟已注册的自定义分钟源 (真实注册表在测试环境未加载)
|
|
monkeypatch.setattr(preferences, "get_minute_data_provider", lambda: "a-stock-data")
|
|
svc = MinuteRefreshService(_FakeRepo(["600000.SH"]))
|
|
svc.set_app_state(_FakeAppState(capability))
|
|
monkeypatch.setattr(minute_refresh, "_in_continuous_session", lambda now=None: in_hours)
|
|
return svc
|
|
|
|
|
|
def test_gate_disabled(tmp_path, monkeypatch):
|
|
assert _svc(tmp_path, monkeypatch, enabled=False)._gate_reason() == "disabled"
|
|
|
|
|
|
def test_gate_custom_provider_yields(tmp_path, monkeypatch):
|
|
svc = _svc(tmp_path, monkeypatch, custom_provider=True)
|
|
assert svc._gate_reason() == "custom_minute_provider"
|
|
|
|
|
|
def test_gate_capability_missing(tmp_path, monkeypatch):
|
|
svc = _svc(tmp_path, monkeypatch, capability=False)
|
|
assert svc._gate_reason() == "capability"
|
|
|
|
|
|
def test_gate_outside_trading_hours(tmp_path, monkeypatch):
|
|
svc = _svc(tmp_path, monkeypatch, in_hours=False)
|
|
assert svc._gate_reason() == "outside_trading_hours"
|
|
|
|
|
|
def test_gate_pass(tmp_path, monkeypatch):
|
|
svc = _svc(tmp_path, monkeypatch)
|
|
assert svc._gate_reason() is None
|
|
assert svc.capability_ok() and not svc.custom_provider_active()
|
|
|
|
|
|
# ── 单轮 ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_run_round_writes_partition_and_updates_status(tmp_path, monkeypatch):
|
|
svc = _svc(tmp_path, monkeypatch)
|
|
minute_df = pl.DataFrame({
|
|
"symbol": ["600000.SH"],
|
|
"datetime": [datetime(2026, 8, 25, 1, 30)],
|
|
"open": [10.0], "high": [10.5], "low": [9.9], "close": [10.2],
|
|
"volume": [1000.0], "amount": [10200.0],
|
|
})
|
|
calls: dict = {}
|
|
|
|
def fake_burst(symbols, capset, *, count=300):
|
|
calls["symbols"] = list(symbols)
|
|
return (minute_df, 1)
|
|
|
|
def fake_write(df, minute_dir):
|
|
calls["dir"] = minute_dir
|
|
calls["rows"] = df.height
|
|
return df.height
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.kline_sync.fetch_intraday_full_market_burst", fake_burst
|
|
)
|
|
monkeypatch.setattr("app.services.kline_sync._write_minute_partition", fake_write)
|
|
|
|
svc._run_round()
|
|
|
|
assert calls["symbols"] == ["600000.SH"]
|
|
assert calls["rows"] == 1
|
|
st = svc.status()
|
|
assert st["rounds"] == 1
|
|
assert st["last_rows"] == 1
|
|
assert st["last_symbols"] == 1
|
|
assert st["last_requests"] == 1
|
|
assert st["last_round_at"] is not None
|
|
assert st["last_error"] is None
|
|
assert st["capability_ok"] is True
|
|
|
|
|
|
def test_run_round_records_error_when_burst_empty(tmp_path, monkeypatch):
|
|
svc = _svc(tmp_path, monkeypatch)
|
|
monkeypatch.setattr(
|
|
"app.services.kline_sync.fetch_intraday_full_market_burst",
|
|
lambda symbols, capset, *, count=300: (pl.DataFrame(), 3),
|
|
)
|
|
svc._run_round()
|
|
st = svc.status()
|
|
assert st["rounds"] == 0
|
|
assert "no data" in st["last_error"]
|
|
assert st["last_requests"] == 3
|
|
|
|
|
|
def test_status_reports_gate_reason_when_stopped(tmp_path, monkeypatch):
|
|
svc = _svc(tmp_path, monkeypatch, enabled=False)
|
|
st = svc.status()
|
|
assert st["enabled"] is False
|
|
assert st["running"] is False
|
|
assert st["gate_reason"] == "disabled"
|
|
assert st["interval_seconds"] == 60
|
|
|
|
|
|
# ── 偏好 ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_refresh_preferences_defaults_and_clamp(tmp_path, monkeypatch):
|
|
_isolated_prefs(tmp_path, monkeypatch)
|
|
assert preferences.get_minute_refresh_enabled() is False
|
|
assert preferences.get_minute_refresh_interval() == 60
|
|
preferences.save({"minute_refresh_interval": 5})
|
|
assert preferences.get_minute_refresh_interval() == 60 # 下限
|
|
preferences.save({"minute_refresh_interval": 999})
|
|
assert preferences.get_minute_refresh_interval() == 300 # 上限
|
|
preferences.save({"minute_refresh_interval": 90})
|
|
assert preferences.get_minute_refresh_interval() == 90
|
|
|
|
|
|
def test_status_endpoint_without_service():
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.api.settings import router
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
resp = client.get("/api/settings/minute-refresh/status")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"available": False}
|