Files
tick-stock-panel/backend/tests/test_lots.py
T
richardandClaude adc7ab52a9 feat(lots): holdings-reminder registry with auto stop/profit & expiry monitoring
新增「持仓提醒」页(/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>
2026-09-02 20:20:38 +08:00

199 lines
7.5 KiB
Python

"""批次 (持仓提醒) 测试: 批次→规则映射 + 校验 + sync 一致性 (锁/校验先行/级联删除)。"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from app.api import lots as lots_api
from app.strategy import lots as lots_domain
from app.strategy import monitor_rules
def _lot(**overrides):
lot = {
"id": "lot_test1",
"symbol": "600519.SH",
"qty": 100,
"cost_price": 1500.0,
"buy_date": "2026-08-01",
"target_pct": 10,
"stop_pct": 5,
"remind_date": "2026-09-01",
"lead_days": 2,
}
lot.update(overrides)
return lot
class _EngineStub:
def __init__(self) -> None:
self.set_calls = 0
self.rules = []
def set_rules(self, rules) -> None:
self.set_calls += 1
self.rules = rules
def _make_request(tmp_path: Path, engine: _EngineStub):
repo = SimpleNamespace(
store=SimpleNamespace(data_dir=tmp_path),
resolve_asset_type=lambda _symbol: "stock",
)
state = SimpleNamespace(repo=repo, monitor_engine=engine)
return SimpleNamespace(app=SimpleNamespace(state=state))
def _lot_path(tmp_path: Path, lot_id: str) -> Path:
return tmp_path / "user_data" / "lots" / f"{lot_id}.json"
def _patch_channels(monkeypatch) -> None:
monkeypatch.setattr("app.services.preferences.get_webhook_default_channels", lambda: [])
# ── 纯映射: 批次 → 规则 ────────────────────────────────
def test_lot_to_rules_price_and_date():
price, date_rule = lots_domain.lot_to_rules(_lot())
assert price is not None and date_rule is not None
assert price["id"] == "lot_test1_p" and price["type"] == "price"
assert price["scope"] == "symbols" and price["symbols"] == ["600519.SH"]
assert price["conditions"] == [
{"field": "close", "op": ">=", "value": 1650.0}, # 1500*1.10
{"field": "close", "op": "<=", "value": 1425.0}, # 1500*0.95
]
assert price["logic"] == "or"
assert price["cooldown_seconds"] == 86400
assert price["lot_id"] == "lot_test1"
assert "止盈10%" in price["message"] and "止损5%" in price["message"]
assert date_rule["id"] == "lot_test1_d" and date_rule["type"] == "date"
assert date_rule["remind_date"] == "2026-09-01" and date_rule["lead_days"] == 2
def test_lot_to_rules_optional_parts():
# 只有止盈 (无止损/无到期) → 无 date 规则
price, date_rule = lots_domain.lot_to_rules(_lot(stop_pct=0, remind_date=None))
assert price is not None and date_rule is None
assert len(price["conditions"]) == 1
# 只有到期 (无止盈/止损) → 无 price 规则
price, date_rule = lots_domain.lot_to_rules(_lot(target_pct=0, stop_pct=0))
assert price is None and date_rule is not None
def test_validate_lot_rules_and_errors():
lots_domain.validate_lot(lots_domain.normalize_lot(_lot()))
for overrides in (
{"symbol": " "},
{"cost_price": 0},
{"qty": -1},
{"target_pct": -1},
{"stop_pct": -1},
{"lead_days": -1},
{"remind_date": "2026/09/01"},
{"buy_date": "bad"},
{"target_pct": 0, "stop_pct": 0, "remind_date": None},
):
with pytest.raises(ValueError):
lots_domain.validate_lot({**_lot(), **overrides})
def test_normalize_lot_defaults():
n = lots_domain.normalize_lot({"id": "lot_x", "symbol": "600519.SH", "cost_price": 10})
assert n["qty"] == 0 and n["lead_days"] == 1
assert n["created_at"]
assert n["symbol"] == "600519.SH"
# ── sync_lot 一致性 (校验先行 / 级联 / 单次重载) ─────────
def test_sync_lot_validates_rules_before_write(tmp_path, monkeypatch):
engine = _EngineStub()
request = _make_request(tmp_path, engine)
_patch_channels(monkeypatch)
reloaded = []
monkeypatch.setattr(lots_api, "_reload_engine", lambda r: reloaded.append(1))
def boom(_rule) -> None:
raise ValueError("bad rule")
monkeypatch.setattr("app.strategy.monitor_rules.validate", boom)
with pytest.raises(HTTPException) as ei:
lots_api.sync_lot(request, _lot())
assert ei.value.status_code == 400
# 批次文件、规则文件、引擎重载 都不该发生 (避免半成品)
assert not _lot_path(tmp_path, "lot_test1").exists()
assert reloaded == []
rules_dir = tmp_path / "user_data" / "monitor_rules"
assert not rules_dir.exists() or list(rules_dir.glob("*.json")) == []
def test_sync_lot_writes_lot_rules_and_reloads_once(tmp_path, monkeypatch):
engine = _EngineStub()
request = _make_request(tmp_path, engine)
_patch_channels(monkeypatch)
reloaded = []
monkeypatch.setattr(lots_api, "_reload_engine", lambda r: reloaded.append(1))
lots_api.sync_lot(request, _lot())
assert _lot_path(tmp_path, "lot_test1").exists()
price = monitor_rules.load_one(tmp_path, "lot_test1_p")
date_rule = monitor_rules.load_one(tmp_path, "lot_test1_d")
assert price is not None and price["lot_id"] == "lot_test1"
assert price["conditions"][0] == {"field": "close", "op": ">=", "value": 1650.0}
assert date_rule is not None and date_rule["remind_date"] == "2026-09-01"
assert len(reloaded) == 1
def test_sync_lot_removes_rules_when_monitor_point_removed(tmp_path, monkeypatch):
engine = _EngineStub()
request = _make_request(tmp_path, engine)
_patch_channels(monkeypatch)
monkeypatch.setattr(lots_api, "_reload_engine", lambda r: None)
lots_api.sync_lot(request, _lot())
# 编辑后只剩止盈, 无到期 → date 规则应被级联删除
lots_api.sync_lot(request, _lot(remind_date=None))
assert monitor_rules.load_one(tmp_path, "lot_test1_d") is None
assert monitor_rules.load_one(tmp_path, "lot_test1_p") is not None
def test_delete_lot_removes_lot_and_both_rules(tmp_path, monkeypatch):
engine = _EngineStub()
request = _make_request(tmp_path, engine)
_patch_channels(monkeypatch)
monkeypatch.setattr(lots_api, "_reload_engine", lambda r: None)
lots_api.sync_lot(request, _lot())
lots_api.delete_lot("lot_test1", request)
assert not _lot_path(tmp_path, "lot_test1").exists()
assert monitor_rules.load_one(tmp_path, "lot_test1_p") is None
assert monitor_rules.load_one(tmp_path, "lot_test1_d") is None
def test_sync_lot_etf_resolves_asset_type(tmp_path, monkeypatch):
engine = _EngineStub()
repo = SimpleNamespace(
store=SimpleNamespace(data_dir=tmp_path),
resolve_asset_type=lambda _symbol: "etf",
)
state = SimpleNamespace(repo=repo, monitor_engine=engine)
request = SimpleNamespace(app=SimpleNamespace(state=state))
_patch_channels(monkeypatch)
monkeypatch.setattr(lots_api, "_reload_engine", lambda r: None)
lots_api.sync_lot(request, _lot(symbol="510300.SH"))
# 止盈止损价格规则须走 ETF 监控轮才会触发, 故 asset_type 必须为 etf
price = monitor_rules.load_one(tmp_path, "lot_test1_p")
date_rule = monitor_rules.load_one(tmp_path, "lot_test1_d")
assert price is not None and price["asset_type"] == "etf"
assert date_rule is not None and date_rule["asset_type"] == "etf"
def test_upsert_lot_invalid_returns_400(tmp_path, monkeypatch):
engine = _EngineStub()
request = _make_request(tmp_path, engine)
_patch_channels(monkeypatch)
with pytest.raises(HTTPException) as ei:
lots_api.upsert_lot(lots_api.LotModel(**_lot(cost_price=0)), request)
assert ei.value.status_code == 400
assert "cost_price" in str(ei.value.detail)