mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34: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>
127 lines
4.7 KiB
Python
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}
|