mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 20:14:16 +08:00
新增「持仓提醒」页(/lots),登记买入批次(个股/ETF),每批自动生成并同步两条
监控规则: lot_{id}_p(type=price 止盈止损)与 lot_{id}_d(type=date 到期提醒)。
- 核心监控引擎新增 date 规则类型: 纯日历窗口、每个交易日仅在首个轮询评估一次、
按天 cooldown、跨天清理过期键; 消息按触发当天显示"N天后到期/今日到期"。
- 批次派生规则继承默认 webhook 渠道; 监控中心只读展示(批次/批次托管徽标),
通用 monitor-rules 接口对带 lot_id 的规则写/删返回 409, 避免与批次页脱节。
- 通知正文统一在缺省时追加触发现价与涨跌幅, 并避免与引擎自带引语重复。
- 后端分层: strategy/lots.py 域(校验/存储/批次→规则纯映射) + api/lots.py 薄路由
(写锁/校验先行/级联删规则/复用 _sync_engine 重载); services/fs_utils.py 原子写。
- 前端: api/queryKeys 契约、DateShortcuts、Lots 页(数值输入可留空、加载/出错态、
文案打磨)、Monitor 日期提醒/批次徽标; RuleEditor 不手工建 date(由持仓页生成)。
- 记账/加仓减仓属"交易口径", 不在本改动(issue #230); 到期提醒按自然日窗口,
休市/节假日顺延的交易日历口径待 issue 定夺。
验证: 新增 tests/test_date_rule.py + tests/test_lots.py 全绿(20), 监控族回归通过,
ruff 新文件干净, pnpm build 通过。
Co-Authored-By: Claude <noreply@anthropic.com>
152 lines
6.2 KiB
Python
152 lines
6.2 KiB
Python
"""日期提醒 (date) 规则测试: 窗口判定 + 引擎按天去重 + 校验/归一化。
|
|
|
|
date 规则是纯日历、无行情条件, 由 quote_service 盘中轮询调用
|
|
MonitorRuleEngine.evaluate_date_rules()。这里只测引擎与规则存储层。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.strategy import monitor_rules
|
|
from app.strategy.monitor import MonitorRuleEngine, format_alert_quote
|
|
|
|
|
|
def _rule(**kw):
|
|
r = {
|
|
"id": "date1", "name": "批次到期", "type": "date",
|
|
"asset_type": "stock", "scope": "symbols",
|
|
"symbols": ["600519.SH"],
|
|
"remind_date": "2026-08-30", "lead_days": 3,
|
|
"cooldown_seconds": 86400, "severity": "info", "enabled": True,
|
|
"message": "批次到期提醒 · 2026-08-30",
|
|
}
|
|
r.update(kw)
|
|
return r
|
|
|
|
|
|
# ── 窗口纯函数 ──────────────────────────────────────────
|
|
def test_window_pure_unit_cases():
|
|
w = monitor_rules.date_rule_in_window
|
|
assert w("2026-08-30", 3, "2026-08-28") is True # 窗口左界内
|
|
assert w("2026-08-30", 3, "2026-08-27") is True # 恰在左界 (含)
|
|
assert w("2026-08-30", 3, "2026-08-26") is False # 早于左界
|
|
assert w("2026-08-30", 3, "2026-08-30") is True # 到期当天
|
|
assert w("2026-08-30", 3, "2026-08-31") is False # 过期
|
|
assert w("2026-08-30", 0, "2026-08-30") is True # 不提前
|
|
assert w("2026-08-30", 0, "2026-08-29") is False
|
|
assert w("not-a-date", 3, "2026-08-28") is False # 非法输入 fail-safe
|
|
assert w("2026-08-30", "x", "2026-08-28") is False
|
|
|
|
|
|
def test_validate_and_normalize_date():
|
|
valid = {"id": "date2", "name": "n", "type": "date", "scope": "symbols",
|
|
"symbols": ["600519.SH"], "remind_date": "2026-09-01", "lead_days": 2}
|
|
monitor_rules.validate(valid)
|
|
n = monitor_rules.normalize(valid)
|
|
assert n["cooldown_seconds"] == 86400 # 每天最多一次
|
|
assert n["conditions"] == []
|
|
assert n["scope"] == "symbols"
|
|
|
|
# 归一化后为空 symbols 仍合法 (手动建), 但校验拒绝空 symbols (锚定标的)
|
|
with pytest.raises(ValueError):
|
|
monitor_rules.validate({**valid, "symbols": []})
|
|
|
|
with pytest.raises(ValueError):
|
|
monitor_rules.validate({**valid, "remind_date": None}) # 必填
|
|
with pytest.raises(ValueError):
|
|
monitor_rules.validate({**valid, "remind_date": "2026/09/01"}) # 格式
|
|
with pytest.raises(ValueError):
|
|
monitor_rules.validate({**valid, "lead_days": -1}) # 非负
|
|
with pytest.raises(ValueError):
|
|
monitor_rules.validate({**valid, "conditions": [{"field": "close", "op": ">=", "value": 10.0}]})
|
|
assert "date" in monitor_rules.RULE_TYPES
|
|
|
|
|
|
# ── 引擎评估 ────────────────────────────────────────────
|
|
def _monkey_today(monkeypatch, iso: str):
|
|
from datetime import date
|
|
|
|
monkeypatch.setattr("app.strategy.monitor.cn_today", lambda: date.fromisoformat(iso))
|
|
|
|
|
|
def test_date_engine_fires_in_window(monkeypatch):
|
|
_monkey_today(monkeypatch, "2026-08-28")
|
|
eng = MonitorRuleEngine()
|
|
eng.set_rules([_rule()])
|
|
evs = eng.evaluate_date_rules()
|
|
assert len(evs) == 1
|
|
ev = evs[0]
|
|
assert ev["source"] == "date"
|
|
assert ev["type"] == "date_reminder"
|
|
assert ev["symbol"] == "600519.SH"
|
|
assert "2天后到期" in ev["message"] # 2026-08-30 - 今天(08-28)
|
|
assert ev["message"] # 单标的: 不再把 symbol 拼进 message
|
|
assert "600519.SH" not in ev["message"]
|
|
assert ev["price"] is None and ev["change_pct"] is None # 日期提醒无行情
|
|
assert ev["conditions"] == [] and ev["logic"] == "and"
|
|
|
|
|
|
def test_date_engine_today_expiry_wording(monkeypatch):
|
|
_monkey_today(monkeypatch, "2026-08-30")
|
|
eng = MonitorRuleEngine()
|
|
eng.set_rules([_rule(lead_days=0)])
|
|
evs = eng.evaluate_date_rules()
|
|
assert len(evs) == 1
|
|
assert "今日到期" in evs[0]["message"]
|
|
|
|
|
|
def test_date_engine_skips_outside_window(monkeypatch):
|
|
_monkey_today(monkeypatch, "2026-08-31") # 过期
|
|
eng = MonitorRuleEngine()
|
|
eng.set_rules([_rule()])
|
|
assert eng.evaluate_date_rules() == []
|
|
|
|
|
|
def test_date_engine_ignores_disabled_and_non_date(monkeypatch):
|
|
_monkey_today(monkeypatch, "2026-08-28")
|
|
eng = MonitorRuleEngine()
|
|
eng.set_rules([_rule(enabled=False), _rule(id="price1", type="price", conditions=[
|
|
{"field": "close", "op": ">=", "value": 100.0},
|
|
], message="")])
|
|
assert eng.evaluate_date_rules() == []
|
|
|
|
|
|
def test_date_engine_once_per_day(monkeypatch):
|
|
_monkey_today(monkeypatch, "2026-08-28")
|
|
eng = MonitorRuleEngine()
|
|
eng.set_rules([_rule()])
|
|
assert len(eng.evaluate_date_rules()) == 1
|
|
assert eng.evaluate_date_rules() == [] # 同日 cooldown 去重
|
|
# 跨天: 窗口内仍命中 → 再提醒一次
|
|
_monkey_today(monkeypatch, "2026-08-29")
|
|
assert len(eng.evaluate_date_rules()) == 1
|
|
|
|
|
|
def test_date_engine_alerts_handler_called(monkeypatch):
|
|
_monkey_today(monkeypatch, "2026-08-28")
|
|
seen: list[dict] = []
|
|
eng = MonitorRuleEngine(alert_handler=seen.append)
|
|
eng.set_rules([_rule()])
|
|
eng.evaluate_date_rules()
|
|
assert len(seen) == 1 and seen[0]["source"] == "date"
|
|
|
|
|
|
def test_date_not_evaluated_by_quote_evaluate(monkeypatch):
|
|
"""date 规则不走 evaluate(df) 主循环, 避免与 date_rule 专用路径双触发。"""
|
|
_monkey_today(monkeypatch, "2026-08-28")
|
|
eng = MonitorRuleEngine()
|
|
eng.set_rules([_rule()])
|
|
df = pl.DataFrame({"symbol": ["600519.SH"], "close": [1500.0]})
|
|
assert eng.evaluate(df) == []
|
|
assert len(eng.evaluate_date_rules()) == 1
|
|
|
|
|
|
# ── 告警引语 ────────────────────────────────────────────
|
|
def test_format_alert_quote():
|
|
assert format_alert_quote(1500.0, 0.1) == "现价 1500.0 · +10.0%"
|
|
assert format_alert_quote(1425.0, -0.05) == "现价 1425.0 · -5.0%"
|
|
assert format_alert_quote(None, None) == "" # 日期提醒等无行情
|
|
assert format_alert_quote(None, 0.03) == "+3.0%"
|
|
assert format_alert_quote(10.0, None) == "现价 10.0"
|