Files
tick-stock-panel/backend/app/strategy/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

178 lines
6.7 KiB
Python

"""批次登记域 — 薄"批次"页 (持仓提醒): 只生成监控规则, 不做会计。
每行一个买入批次 → 派生两条规则: lot_{id}_p (price 止盈止损) / lot_{id}_d (date 到期提醒)。
记账/加减仓属"交易口径", 不在本模块 (issue #230)。纯函数 + 文件存储, 镜像 monitor_rules.py,
不做 API、不做引擎重载。
"""
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime
from datetime import date as _date
from pathlib import Path
from app.services.fs_utils import atomic_write_text
from app.strategy import monitor_rules
from app.strategy.monitor import MonitorRuleEngine # 复用条件文本拼装 (静态方法)
logger = logging.getLogger(__name__)
# id 需满足规则 id 同款正则, 且为后缀留位: 派生规则 {id}_p/_d 不得超过 40 字符
_ID = monitor_rules.ID_RE
_MAX_ID_LEN = 40 - 2 # 派生规则 id 后缀 "_p" / "_d"
def _dir(data_dir: Path) -> Path:
d = data_dir / "user_data" / "lots"
d.mkdir(parents=True, exist_ok=True)
return d
def _path(data_dir: Path, lot_id: str) -> Path:
return _dir(data_dir) / f"{lot_id}.json"
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
# ── 校验与归一化 ────────────────────────────────────────
def validate_lot(lot: dict) -> None:
"""校验批次字段, 非法抛 ValueError (中文信息)。"""
lot_id = lot.get("id")
if lot_id is not None and (
not isinstance(lot_id, str) or not _ID.match(lot_id) or len(lot_id) > _MAX_ID_LEN
):
raise ValueError(f"批次 id 非法 (仅小写字母数字下划线, 且需为派生规则 id 留位): {lot_id!r}")
if not (lot.get("symbol") or "").strip():
raise ValueError("symbol 不能为空")
cost = lot.get("cost_price")
if isinstance(cost, bool) or not isinstance(cost, (int, float)) or cost <= 0:
raise ValueError("cost_price 必须是正数")
for key, label in (("qty", "数量"), ("target_pct", "止盈%"), ("stop_pct", "止损%")):
v = lot.get(key, 0)
if isinstance(v, bool) or not isinstance(v, (int, float)) or v < 0:
raise ValueError(f"{label} 不能为负数")
lead = lot.get("lead_days", 0)
if isinstance(lead, bool) or not isinstance(lead, int) or lead < 0:
raise ValueError("lead_days 必须是非负整数")
for key, label in (("buy_date", "买入日期"), ("remind_date", "到期日")):
raw = lot.get(key)
if raw not in (None, ""):
try:
_date.fromisoformat(raw)
except ValueError:
raise ValueError(f"{label} 必须是 YYYY-MM-DD: {raw!r}") from None
if not (lot.get("target_pct", 0) > 0 or lot.get("stop_pct", 0) > 0 or lot.get("remind_date")):
raise ValueError("止盈% / 止损% / 到期日 至少设置一项 (否则无监控点)")
def normalize_lot(lot: dict) -> dict:
"""补全默认字段, 返回规范化后的批次 (不校验)。"""
d = dict(lot)
d["symbol"] = (d.get("symbol") or "").strip()
d.setdefault("qty", 0)
d.setdefault("cost_price", 0)
d.setdefault("buy_date", None)
d.setdefault("target_pct", 0)
d.setdefault("stop_pct", 0)
d.setdefault("remind_date", None)
d.setdefault("lead_days", 1)
d.setdefault("created_at", _now_iso())
return d
# ── 持久化 ─────────────────────────────────────────────
def load_all(data_dir: Path) -> list[dict]:
"""读取全部批次。损坏的文件被跳过。"""
out: list[dict] = []
for f in sorted(_dir(data_dir).glob("lot_*.json")):
try:
out.append(normalize_lot(json.loads(f.read_text(encoding="utf-8"))))
except Exception as e:
logger.warning("lot load failed %s: %s", f.name, e)
return out
def save_one(data_dir: Path, lot: dict) -> None:
p = _path(data_dir, lot["id"])
p.parent.mkdir(parents=True, exist_ok=True)
atomic_write_text(p, json.dumps(lot, ensure_ascii=False, indent=2))
def delete_one(data_dir: Path, lot_id: str) -> bool:
p = _path(data_dir, lot_id)
if p.exists():
p.unlink()
return True
return False
# ── 批次 → 监控规则 (纯映射) ───────────────────────────
def lot_to_rules(lot: dict) -> tuple[dict | None, dict | None]:
"""批次 → (price 止盈止损规则, date 到期规则); 无对应监控点时返回 None。
纯映射不做 I/O; 规则 id 派生自批次 id ({lot_id}_p/_d), 保证稳定可级联。
"""
symbol = lot["symbol"]
lot_id = lot["id"]
cost = float(lot["cost_price"])
target = float(lot.get("target_pct", 0))
stop = float(lot.get("stop_pct", 0))
qty = float(lot.get("qty", 0) or 0)
qty_text = f" · {qty:g}股" if qty > 0 else ""
conds: list[dict] = []
if target > 0:
conds.append({"field": "close", "op": ">=", "value": round(cost * (1 + target / 100), 4)})
if stop > 0:
conds.append({"field": "close", "op": "<=", "value": round(cost * (1 - stop / 100), 4)})
price_rule = None
if conds:
msg = f"批次止盈止损 · 成本{cost:g}"
if target > 0:
msg += f" · 止盈{target:g}%"
if stop > 0:
msg += f" · 止损{stop:g}%"
msg += qty_text
cond_text = MonitorRuleEngine._format_conditions_text({"logic": "or"}, conds)
if cond_text:
msg += f" · {cond_text}"
price_rule = {
"id": f"{lot_id}_p",
"name": f"批次止盈止损 · {symbol}",
"type": "price",
"asset_type": "stock",
"scope": "symbols",
"symbols": [symbol],
"conditions": conds,
"logic": "or",
"cooldown_seconds": 86400,
"severity": "warn",
"message": msg,
"enabled": True,
"lot_id": lot_id,
}
date_rule = None
if lot.get("remind_date"):
lead = int(lot.get("lead_days", 1))
date_rule = {
"id": f"{lot_id}_d",
"name": f"批次到期 · {symbol}",
"type": "date",
"asset_type": "stock",
"scope": "symbols",
"symbols": [symbol],
"remind_date": lot["remind_date"],
"lead_days": lead,
"cooldown_seconds": 86400,
"severity": "info",
# 提前天数由引擎 evaluate_date_rules 统一追加, 这里只放静态部分
"message": f"批次到期提醒 · {lot['remind_date']}{qty_text}",
"enabled": True,
"lot_id": lot_id,
}
return price_rule, date_rule