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

127 lines
4.7 KiB
Python

"""批次登记 API — 薄"批次"页 (持仓提醒), 只做胶水, 不含会计语义。
映射/校验/持久化在 strategy.lots 域; 写完派生规则后复用 monitor_rules 的 _sync_engine 同步引擎。
"""
from __future__ import annotations
import secrets
import threading
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from app.strategy import lots as lots_domain
from app.strategy import monitor_rules
router = APIRouter(prefix="/api/lots", tags=["lots"])
# 批次 + 派生规则 + 引擎重载的跨请求互斥; 规则全部校验通过才落盘, 避免半成品 (镜像 watchlist 服务层)。
_write_lock = threading.Lock()
def _data_dir(request: Request) -> Path:
return request.app.state.repo.store.data_dir
def _resolve_asset_type(request: Request, symbol: str) -> str:
"""按 symbol 解析资产类型 (stock/etf); 解析失败默认 stock (fail-safe)。"""
repo = getattr(request.app.state, "repo", None)
try:
return repo.resolve_asset_type(symbol) if repo is not None else "stock"
except Exception:
return "stock"
class LotModel(BaseModel):
id: str | None = None
symbol: str
qty: float = 0
cost_price: float = 0
buy_date: str | None = None
target_pct: float = 0
stop_pct: float = 0
remind_date: str | None = None
lead_days: int = 1
def _reload_engine(request: Request) -> None:
"""批次规则保存/删除后重载引擎 — 复用监控规则 API 的共享重载 (含指数纠正)。"""
from app.api.monitor_rules import _sync_engine
_sync_engine(request)
def sync_lot(request: Request, lot: dict) -> None:
"""写批次文件 + 同步其两条派生监控规则 + 重载引擎。
派生规则继承用户默认推送渠道 (webhook_default_channels), 否则批次告警会静默只走应用内。
"""
from app.services import preferences
data_dir = _data_dir(request)
with _write_lock:
default_channels = preferences.get_webhook_default_channels()
# ETF/指数等资产类型解析 (止盈止损价格规则须走对应资产监控轮才会触发)
asset_type = _resolve_asset_type(request, lot["symbol"])
price_rule, date_rule = lots_domain.lot_to_rules(lot)
rules_to_write: list[dict] = []
rules_to_delete: list[str] = []
for rid, rule in ((f"{lot['id']}_p", price_rule), (f"{lot['id']}_d", date_rule)):
if rule is None:
rules_to_delete.append(rid)
continue
rule["asset_type"] = asset_type
rule.setdefault("webhook_channels", list(default_channels))
# 保留旧 created_at, 避免编辑批次后派生规则在监控中心列表跳位
existing = monitor_rules.load_one(data_dir, rid)
if existing and existing.get("created_at"):
rule["created_at"] = existing["created_at"]
try:
monitor_rules.validate(rule)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
rules_to_write.append(monitor_rules.normalize(rule))
lots_domain.save_one(data_dir, lot)
for rid in rules_to_delete:
monitor_rules.delete_one(data_dir, rid)
for rule in rules_to_write:
monitor_rules.save_one(data_dir, rule)
_reload_engine(request)
@router.get("")
def list_lots(request: Request):
return {"lots": lots_domain.load_all(_data_dir(request))}
@router.post("")
def upsert_lot(lot_in: LotModel, request: Request):
"""新建/更新一个批次。id 缺省时服务端生成 (紧凑, 保证 {id}_p/_d 规则 id ≤ 40 字符)。"""
lot = lot_in.model_dump()
if not lot.get("id"):
lot["id"] = f"lot_{int(time.time() * 1000):x}_{secrets.token_hex(2)}"
try:
lots_domain.validate_lot(lot)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
lot = lots_domain.normalize_lot(lot)
sync_lot(request, lot)
return {"ok": True, "lot": lot}
@router.delete("/{lot_id}")
def delete_lot(lot_id: str, request: Request):
if not monitor_rules.ID_RE.match(lot_id):
raise HTTPException(status_code=400, detail="批次 id 非法")
data_dir = _data_dir(request)
with _write_lock:
deleted = lots_domain.delete_one(data_dir, lot_id)
# 两条派生规则都要删 (用 or 会短路跳过第二条)
deleted_p = monitor_rules.delete_one(data_dir, f"{lot_id}_p")
deleted_d = monitor_rules.delete_one(data_dir, f"{lot_id}_d")
if deleted or deleted_p or deleted_d:
_reload_engine(request)
return {"ok": True}