mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
- monitor_rules: raise from None in remind_date validation (B904) - lots: log warning on asset-type fallback to stock (fail-open traceability) - matrix: demote turnover NaN-column notice to debug
132 lines
4.9 KiB
Python
132 lines
4.9 KiB
Python
"""批次登记 API — 薄"批次"页 (持仓提醒), 只做胶水, 不含会计语义。
|
|
|
|
映射/校验/持久化在 strategy.lots 域; 写完派生规则后复用 monitor_rules 的 _sync_engine 同步引擎。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
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:
|
|
# 回退为 stock 会让 etf 批次的止盈止损规则落入错误的监控轮, 必须留痕排查
|
|
logging.getLogger(__name__).warning(
|
|
"resolve_asset_type failed for %s, falling back to stock", symbol, exc_info=True
|
|
)
|
|
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}
|