mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
Merge pull request #231 from thinkbuf/feat/lots-alerts
feat(lots): holdings-reminder page — per-buy-lot auto stop/profit & expiry monitoring
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""批次登记 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}
|
||||
@@ -97,6 +97,9 @@ class RuleModel(BaseModel):
|
||||
conditions: list[ConditionModel] = []
|
||||
logic: str = "and" # and | or
|
||||
cooldown_seconds: int = 3600
|
||||
# date 类型 (日期提醒): 纯日历窗口, 无 conditions
|
||||
remind_date: str | None = None # YYYY-MM-DD
|
||||
lead_days: int = 0 # 提前 N 天进入提醒窗口
|
||||
severity: str = "info" # info | warn | critical
|
||||
webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 待定)
|
||||
webhook_enabled: bool = False # 兼容老规则 (已由 webhook_channels 取代, 仅做向后兼容读)
|
||||
@@ -166,6 +169,7 @@ def get_options(request: Request):
|
||||
{"key": "abnormal", "label": "异动监控"},
|
||||
{"key": "sector", "label": "板块监控"},
|
||||
{"key": "volume_delta", "label": "轮询放量"},
|
||||
{"key": "date", "label": "日期提醒"},
|
||||
],
|
||||
"scopes": [
|
||||
{"key": "symbols", "label": "指定标的"},
|
||||
@@ -288,6 +292,9 @@ def save_rule(req: RuleModel, request: Request):
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
# 编辑现有规则时, 保留原 created_at (避免按时间排序时位置跳动)
|
||||
existing = monitor_rules.load_one(_data_dir(request), rule["id"])
|
||||
# 批次派生规则由「持仓提醒」页托管, 监控中心只读 (启停/改/删均回持仓页)
|
||||
if existing and existing.get("lot_id"):
|
||||
raise HTTPException(status_code=409, detail="该规则由「持仓提醒」页托管, 请在持仓提醒页修改")
|
||||
if existing and existing.get("created_at"):
|
||||
rule["created_at"] = existing["created_at"]
|
||||
try:
|
||||
@@ -344,6 +351,10 @@ def save_rule(req: RuleModel, request: Request):
|
||||
def delete_rule(rule_id: str, request: Request):
|
||||
if not monitor_rules.ID_RE.match(rule_id):
|
||||
raise HTTPException(status_code=400, detail="规则 id 非法")
|
||||
# 批次派生规则由「持仓提醒」页托管, 删除需在持仓页操作 (级联清理派生规则)
|
||||
existing = monitor_rules.load_one(_data_dir(request), rule_id)
|
||||
if existing and existing.get("lot_id"):
|
||||
raise HTTPException(status_code=409, detail="该规则由「持仓提醒」页托管, 请在持仓提醒页删除批次")
|
||||
deleted = monitor_rules.delete_one(_data_dir(request), rule_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="规则不存在")
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.api import (
|
||||
indices,
|
||||
intraday,
|
||||
kline,
|
||||
lots,
|
||||
market_recap,
|
||||
mining,
|
||||
monitor_rules,
|
||||
@@ -471,6 +472,7 @@ app.include_router(settings_api.router)
|
||||
app.include_router(strategy.router)
|
||||
app.include_router(signals.router)
|
||||
app.include_router(monitor_rules.router)
|
||||
app.include_router(lots.router)
|
||||
app.include_router(alerts.router)
|
||||
app.include_router(rps.router)
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""文件系统小工具 — 原子写等。
|
||||
|
||||
历史遗留: json_report_store / strategy_cache / kline_sync 等模块里各有一份内联的
|
||||
同款原子写。新代码统一用本模块的 atomic_write_text, 一处实现一处维护。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def atomic_write_text(path: Path, text: str) -> None:
|
||||
"""临时文件 + os.replace 原子替换, 避免读侧读到半截 JSON。"""
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(text, encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
@@ -37,6 +37,26 @@ from app.market_time import cn_now, cn_today
|
||||
from app.parquet import scan_daily_parquet
|
||||
from app.services.index_const import CORE_INDEX_SYMBOLS
|
||||
from app.strategy.intraday_signals import IntradaySignalEvaluator
|
||||
from app.strategy.monitor import format_alert_quote
|
||||
|
||||
# 告警来源 → 中文标签 (webhook 标题 / 系统通知标题共用)
|
||||
SOURCE_LABELS = {
|
||||
"strategy": "策略", "signal": "信号", "price": "价格",
|
||||
"market": "异动", "ladder": "连板梯队", "sector": "板块",
|
||||
"volume_delta": "放量", "abnormal": "异动", "date": "日期提醒",
|
||||
}
|
||||
|
||||
|
||||
def _body_with_quote(body: str, ev: dict) -> str:
|
||||
"""推送正文尾部补上触发时的现价/涨跌幅 (日期提醒无行情, 自然为空)。
|
||||
|
||||
默认告警的 message 已由引擎拼过引语 (monitor._default_message), 这里仅在正文
|
||||
尚未带引语时追加, 避免「现价」出现两遍 (自定义 message 的规则则补上这一句)。
|
||||
"""
|
||||
quote_tail = format_alert_quote(ev.get("price"), ev.get("change_pct"))
|
||||
if not quote_tail or body.endswith(quote_tail):
|
||||
return body
|
||||
return f"{body} · {quote_tail}"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1063,6 +1083,12 @@ class QuoteService:
|
||||
rule_events += engine.evaluate_abnormal(_overview.get("rows") or [])
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("异动监控规则评估失败 (不影响其他告警): %s", e)
|
||||
# 日期提醒轮: 纯日历、无行情, 已在盘中; 引擎内按天 cooldown 保证每天一次
|
||||
if engine.has_rule_type("date"):
|
||||
try:
|
||||
rule_events = rule_events + engine.evaluate_date_rules()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("日期提醒评估失败 (不影响其他告警): %s", e)
|
||||
# ETF 规则轮: 股票快照不含 ETF, 用 ETF enriched 快照单独评估。
|
||||
# 独立 try —— ETF 轮任何异常都不得丢弃本轮已算出的股票告警。
|
||||
# refresh=False —— 不在轮询线程上触发 ETF 冷缓存的同步重算 (缓存由 ETF 实时
|
||||
@@ -1427,11 +1453,6 @@ class QuoteService:
|
||||
return
|
||||
|
||||
# 反查规则, 过滤出启用推送的事件
|
||||
source_labels = {
|
||||
"strategy": "策略", "signal": "信号",
|
||||
"price": "价格", "market": "异动", "ladder": "连板梯队",
|
||||
"sector": "板块", "volume_delta": "放量",
|
||||
}
|
||||
rules = engine.rules if engine is not None else {}
|
||||
enqueued = 0
|
||||
for ev in rule_events:
|
||||
@@ -1442,12 +1463,14 @@ class QuoteService:
|
||||
if not channels:
|
||||
continue
|
||||
source = ev.get("source", "")
|
||||
source_label = source_labels.get(source, source or "通知")
|
||||
source_label = SOURCE_LABELS.get(source, source or "通知")
|
||||
symbol = ev.get("symbol") or ""
|
||||
name = ev.get("name") or ""
|
||||
message = ev.get("message") or ""
|
||||
title = source_label
|
||||
body = f"{symbol} {name} {message}".strip() if symbol else (message or name)
|
||||
# 补上触发时的现价/涨跌幅, 让推送可执行 (止损到底触发在哪个价位)
|
||||
body = _body_with_quote(body, ev)
|
||||
# 提交到独立线程池, 不阻塞行情轮询线程 (webhook 慢/重试不拖累实时行情+告警)。
|
||||
# 按渠道独立投递: 飞书 / 企业微信谁被勾选且已配置就推谁。
|
||||
# 应用内 alerts.jsonl 记录与 SSE 已在前面完成, 不依赖 webhook 成败,
|
||||
@@ -1481,10 +1504,7 @@ class QuoteService:
|
||||
for ev in all_alerts:
|
||||
# 通知标题: 用 source 分类 (策略/信号/价格/异动)
|
||||
source = ev.get("source", "")
|
||||
source_label = {
|
||||
"strategy": "策略", "signal": "信号",
|
||||
"price": "价格", "market": "异动", "sector": "板块",
|
||||
}.get(source, source or "通知")
|
||||
source_label = SOURCE_LABELS.get(source, source or "通知")
|
||||
|
||||
name = ev.get("name") or ""
|
||||
symbol = ev.get("symbol") or ""
|
||||
@@ -1495,6 +1515,8 @@ class QuoteService:
|
||||
body = f"{symbol} {name} {message}".strip()
|
||||
else:
|
||||
body = message or name
|
||||
# 补上触发时的现价/涨跌幅 (日期提醒无行情, 自然为空)
|
||||
body = _body_with_quote(body, ev)
|
||||
|
||||
title = f"TickFlow · {source_label}"
|
||||
notify_adapter.notify(title, body)
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""批次登记域 — 薄"批次"页 (持仓提醒): 只生成监控规则, 不做会计。
|
||||
|
||||
每行一个买入批次 → 派生两条规则: 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
|
||||
@@ -25,6 +25,7 @@ from app.market_time import cn_today
|
||||
from app.strategy import config as _strategy_config
|
||||
from app.strategy.custom_signals import _OP_BUILDERS # type: ignore # 复用运算符构造器
|
||||
from app.strategy.intraday_signals import INTRADAY_SIGNAL_LABELS, uses_intraday_signals
|
||||
from app.strategy.monitor_rules import date_rule_in_window
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -71,6 +72,17 @@ def _signal_cn_name(name: str) -> str:
|
||||
return _SIGNAL_CN.get(name, name)
|
||||
|
||||
|
||||
def format_alert_quote(price, change_pct) -> str:
|
||||
"""告警正文尾部: '现价 1650.0 · +10.0%'。price/pct 均可缺; pct 为小数制。"""
|
||||
parts = []
|
||||
if price is not None:
|
||||
parts.append(f"现价 {price}")
|
||||
if change_pct is not None:
|
||||
sign = "+" if change_pct >= 0 else ""
|
||||
parts.append(f"{sign}{change_pct * 100:.1f}%")
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyAlert:
|
||||
"""策略告警"""
|
||||
@@ -323,6 +335,10 @@ class MonitorRuleEngine:
|
||||
self._rules: dict[str, dict] = {} # rule_id → rule
|
||||
# (rule_id, symbol, event_type) → 上次触发时间戳(秒)。用于 cooldown 去重。
|
||||
self._last_fire: dict[tuple[str, str, str], float] = {}
|
||||
# date 规则每个交易日只在首个轮询评估一次; 规则集变更时失效重评
|
||||
self._date_eval_day: str | None = None
|
||||
self._date_eval_rules_version = -1
|
||||
self._rules_version = 0 # set/add/remove/clear 递增, 供 date 缓存失效
|
||||
self._strategy_engine = None # 延迟注入, type=strategy 规则用它跑选股
|
||||
# symbol → 股票名 (enriched DataFrame 已 drop name 列, 触发时从此映射回填)
|
||||
self._name_map: dict[str, str] = {}
|
||||
@@ -428,6 +444,8 @@ class MonitorRuleEngine:
|
||||
rule.get("threshold_pct"),
|
||||
rule.get("window_minutes"),
|
||||
rule.get("abnormal_window"),
|
||||
rule.get("remind_date"),
|
||||
rule.get("lead_days"),
|
||||
)
|
||||
|
||||
def set_rules(self, rules: list[dict]) -> None:
|
||||
@@ -476,12 +494,14 @@ class MonitorRuleEngine:
|
||||
if key[0] in active_ids
|
||||
}
|
||||
logger.info("MonitorRuleEngine: 装载 %d 条规则", len(self._rules))
|
||||
self._rules_version += 1
|
||||
|
||||
def add_rule(self, rule: dict) -> None:
|
||||
if rule.get("enabled") is not False:
|
||||
self._rules[rule["id"]] = rule
|
||||
else:
|
||||
self._rules.pop(rule["id"], None)
|
||||
self._rules_version += 1
|
||||
|
||||
def remove_rule(self, rule_id: str) -> None:
|
||||
self._rules.pop(rule_id, None)
|
||||
@@ -498,6 +518,7 @@ class MonitorRuleEngine:
|
||||
self._sector_condition_state = {
|
||||
k: v for k, v in self._sector_condition_state.items() if k[0] != rule_id
|
||||
}
|
||||
self._rules_version += 1
|
||||
|
||||
def clear(self) -> None:
|
||||
self._rules.clear()
|
||||
@@ -506,6 +527,7 @@ class MonitorRuleEngine:
|
||||
self._strategy_signal_state.clear()
|
||||
self._strategy_signal_seen.clear()
|
||||
self._sector_condition_state.clear()
|
||||
self._rules_version += 1
|
||||
|
||||
@property
|
||||
def rules(self) -> dict[str, dict]:
|
||||
@@ -669,7 +691,9 @@ class MonitorRuleEngine:
|
||||
for rule_id, rule in list(self._rules.items()):
|
||||
if rule.get("asset_type", "stock") != asset_type:
|
||||
continue
|
||||
if rule.get("type") in ("sector", "abnormal"):
|
||||
if rule.get("type") in ("sector", "abnormal", "date"):
|
||||
# 三者不走行情 DataFrame 评估, 各走 evaluate_sectors / evaluate_abnormal /
|
||||
# evaluate_date_rules 专用路径
|
||||
continue
|
||||
try:
|
||||
events.extend(self._evaluate_rule(df, rule, now))
|
||||
@@ -683,6 +707,80 @@ class MonitorRuleEngine:
|
||||
|
||||
return events
|
||||
|
||||
def evaluate_date_rules(self, now: float | None = None) -> list[dict]:
|
||||
"""纯日历评估 date 规则: 窗口命中 + 每天最多一次, 无行情条件。
|
||||
|
||||
由行情轮询在盘中调用 (quote_service._evaluate_monitors), 事件与 _evaluate_rule 同构。
|
||||
窗口按自然日; 到期落在休市/节假日时需 lead_days 覆盖 (交易日历口径待 issue 定夺)。
|
||||
每个交易日只在首个轮询完整评估一次, 其余轮次命中缓存直接跳过。
|
||||
"""
|
||||
now = now if now is not None else time.time()
|
||||
today_iso = cn_today().isoformat()
|
||||
if self._date_eval_day == today_iso and self._date_eval_rules_version == self._rules_version:
|
||||
return []
|
||||
# 跨天首轮清掉已过期日期的按天 cooldown 键, 避免 _last_fire 无限累积
|
||||
self._last_fire = {
|
||||
key: value
|
||||
for key, value in self._last_fire.items()
|
||||
if not (key[1].startswith("_date_") and key[1] != f"_date_{today_iso}")
|
||||
}
|
||||
|
||||
today_d = _dt.date.fromisoformat(today_iso)
|
||||
events: list[dict] = []
|
||||
for rule in list(self._rules.values()):
|
||||
if rule.get("type") != "date" or rule.get("enabled") is False:
|
||||
continue
|
||||
remind = rule.get("remind_date") or ""
|
||||
if not date_rule_in_window(remind, int(rule.get("lead_days", 0)), today_iso):
|
||||
continue
|
||||
# 按天隔离: 窗口内每天最多触发一次
|
||||
key = (rule["id"], f"_date_{today_iso}", "date")
|
||||
cooldown = int(rule.get("cooldown_seconds") or 86400)
|
||||
last = self._last_fire.get(key)
|
||||
if last is not None and (now - last) < cooldown:
|
||||
continue
|
||||
self._last_fire[key] = now
|
||||
|
||||
symbols = [s for s in rule.get("symbols", []) if s]
|
||||
single_symbol = symbols[0] if len(symbols) == 1 else None
|
||||
msg = rule.get("message") or f"日期提醒 · {today_iso}"
|
||||
try:
|
||||
remain = (_dt.date.fromisoformat(remind) - today_d).days
|
||||
except ValueError:
|
||||
remain = 0
|
||||
msg += " · 今日到期" if remain <= 0 else f" · {remain}天后到期"
|
||||
# 单标的由 ev.symbol 携带; 仅多标的时拼列表
|
||||
if len(symbols) > 1:
|
||||
shown = "、".join(symbols[:3]) + ("等" if len(symbols) > 3 else "")
|
||||
msg = f"{msg} · {shown}"
|
||||
|
||||
ev = {
|
||||
"ts": int(now * 1000),
|
||||
"rule_id": rule["id"],
|
||||
"rule_name": rule.get("name", ""),
|
||||
"strategy_id": None,
|
||||
"source": "date",
|
||||
"type": "date_reminder",
|
||||
"symbol": single_symbol or "",
|
||||
"name": (self._name_map.get(single_symbol) or single_symbol) if single_symbol else None,
|
||||
"message": msg,
|
||||
"price": None,
|
||||
"change_pct": None,
|
||||
"signals": [],
|
||||
"severity": rule.get("severity", "info"),
|
||||
"conditions": [],
|
||||
"logic": "and",
|
||||
}
|
||||
events.append(ev)
|
||||
if self._alert_handler:
|
||||
try:
|
||||
self._alert_handler(ev)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("alert handler failed: %s", e)
|
||||
self._date_eval_day = today_iso
|
||||
self._date_eval_rules_version = self._rules_version
|
||||
return events
|
||||
|
||||
def evaluate_sectors(
|
||||
self,
|
||||
stock_df: pl.DataFrame,
|
||||
@@ -1623,12 +1721,7 @@ class MonitorRuleEngine:
|
||||
# signal / price / market: 条件摘要 + 现价 + 涨跌幅
|
||||
# 条件摘要: 把 conditions (truth/比较) 拼成可读串, 如 "MA20金叉 且 量比>2"
|
||||
cond_text = self._format_conditions_text(rule, conditions)
|
||||
price_text = f"现价 {price}" if price is not None else ""
|
||||
pct_text = ""
|
||||
if pct is not None:
|
||||
sign = "+" if pct >= 0 else ""
|
||||
pct_text = f"{sign}{pct * 100:.1f}%"
|
||||
tail = " · ".join(s for s in (price_text, pct_text) if s)
|
||||
tail = format_alert_quote(price, pct)
|
||||
if cond_text and tail:
|
||||
return f"{cond_text} · {tail}"
|
||||
return cond_text or tail or "监控触发"
|
||||
|
||||
@@ -18,9 +18,10 @@ import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.fs_utils import atomic_write_text
|
||||
from app.strategy.custom_signals import ALLOWED_FIELDS
|
||||
from app.strategy.intraday_signals import uses_intraday_signals
|
||||
|
||||
@@ -28,7 +29,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 常量 ────────────────────────────────────────────────
|
||||
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
||||
RULE_TYPES = {"strategy", "signal", "price", "market", "ladder", "sector", "abnormal", "volume_delta"}
|
||||
RULE_TYPES = {"strategy", "signal", "price", "market", "ladder", "sector", "abnormal", "volume_delta", "date"}
|
||||
SCOPES = {"symbols", "all", "sector", "watchlist_group"}
|
||||
LOGICS = {"and", "or"}
|
||||
DIRECTIONS = {"entry", "exit", "both"}
|
||||
@@ -100,7 +101,7 @@ def load_one(data_dir: Path, rule_id: str) -> dict | None:
|
||||
def save_one(data_dir: Path, rule: dict) -> None:
|
||||
p = _path(data_dir, rule["id"])
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(rule, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
atomic_write_text(p, json.dumps(rule, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def delete_one(data_dir: Path, rule_id: str) -> bool:
|
||||
@@ -117,6 +118,22 @@ def _is_signal_field(field: str) -> bool:
|
||||
return any(field.startswith(p) for p in _SIGNAL_PREFIXES)
|
||||
|
||||
|
||||
def date_rule_in_window(remind_date: str, lead_days: int, today: str) -> bool:
|
||||
"""提醒窗口 [remind_date - lead_days, remind_date] 是否包含 today (均 YYYY-MM-DD)。
|
||||
|
||||
只判自然日历窗口; 是否在交易时段由调用方决定。到期落在休市/节假日不会顺延,
|
||||
需 lead_days 覆盖 (交易日历口径待 issue 定夺)。非法输入一律返回 False (fail-safe)。
|
||||
"""
|
||||
try:
|
||||
remind = date.fromisoformat(remind_date)
|
||||
today_d = date.fromisoformat(today)
|
||||
lead = max(0, int(lead_days or 0))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
start = remind - timedelta(days=lead)
|
||||
return start <= today_d <= remind
|
||||
|
||||
|
||||
def validate(rule: dict) -> None:
|
||||
"""校验一条监控规则,非法则抛 ValueError (含中文信息)。"""
|
||||
rid = rule.get("id", "")
|
||||
@@ -236,6 +253,20 @@ def validate(rule: dict) -> None:
|
||||
raise ValueError(f"basic_filter.{key} 必须是正数字或 null")
|
||||
else:
|
||||
raise ValueError(f"basic_filter 不支持字段: {key}")
|
||||
elif rule.get("type") == "date":
|
||||
# 日期提醒: 纯日历, 锚定标的 (scope=symbols) 避免无对象的空提醒
|
||||
remind = rule.get("remind_date")
|
||||
if not isinstance(remind, str) or not remind.strip():
|
||||
raise ValueError("日期提醒规则必须指定 remind_date")
|
||||
try:
|
||||
date.fromisoformat(remind.strip())
|
||||
except ValueError:
|
||||
raise ValueError(f"remind_date 必须是 YYYY-MM-DD 日期: {remind!r}")
|
||||
lead = rule.get("lead_days", 0)
|
||||
if isinstance(lead, bool) or not isinstance(lead, int) or lead < 0:
|
||||
raise ValueError("lead_days 必须是非负整数 (提前提醒天数)")
|
||||
if rule.get("conditions"):
|
||||
raise ValueError("日期提醒规则不支持行情 conditions")
|
||||
else:
|
||||
# 信号/价格/市场类型: 需要 conditions
|
||||
conds = rule.get("conditions")
|
||||
@@ -349,6 +380,12 @@ def normalize(rule: dict) -> dict:
|
||||
r["scope"] = "all"
|
||||
r["symbols"] = []
|
||||
r["group_id"] = None
|
||||
# date 专属默认字段 (日期提醒): 纯日历窗口, 无行情条件, 每天至多一次
|
||||
if r.get("type") == "date":
|
||||
r["conditions"] = []
|
||||
r.setdefault("remind_date", None)
|
||||
r["lead_days"] = int(r.get("lead_days") or 0)
|
||||
r["cooldown_seconds"] = 86400
|
||||
# abnormal 专属默认字段 (异动边缘监控)
|
||||
r.setdefault("abnormal_window", "any")
|
||||
r.setdefault("logic", "and")
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""日期提醒 (date) 规则测试: 窗口判定 + 引擎按天去重 + 校验/归一化。
|
||||
|
||||
date 规则是纯日历、无行情条件, 由 quote_service 盘中轮询调用
|
||||
MonitorRuleEngine.evaluate_date_rules()。这里只测引擎与规则存储层。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from app.strategy import monitor_rules
|
||||
from app.strategy.monitor import MonitorRuleEngine, format_alert_quote
|
||||
|
||||
|
||||
def _rule(**kw):
|
||||
r = {
|
||||
"id": "date1", "name": "批次到期", "type": "date",
|
||||
"asset_type": "stock", "scope": "symbols",
|
||||
"symbols": ["600519.SH"],
|
||||
"remind_date": "2026-08-30", "lead_days": 3,
|
||||
"cooldown_seconds": 86400, "severity": "info", "enabled": True,
|
||||
"message": "批次到期提醒 · 2026-08-30",
|
||||
}
|
||||
r.update(kw)
|
||||
return r
|
||||
|
||||
|
||||
# ── 窗口纯函数 ──────────────────────────────────────────
|
||||
def test_window_pure_unit_cases():
|
||||
w = monitor_rules.date_rule_in_window
|
||||
assert w("2026-08-30", 3, "2026-08-28") is True # 窗口左界内
|
||||
assert w("2026-08-30", 3, "2026-08-27") is True # 恰在左界 (含)
|
||||
assert w("2026-08-30", 3, "2026-08-26") is False # 早于左界
|
||||
assert w("2026-08-30", 3, "2026-08-30") is True # 到期当天
|
||||
assert w("2026-08-30", 3, "2026-08-31") is False # 过期
|
||||
assert w("2026-08-30", 0, "2026-08-30") is True # 不提前
|
||||
assert w("2026-08-30", 0, "2026-08-29") is False
|
||||
assert w("not-a-date", 3, "2026-08-28") is False # 非法输入 fail-safe
|
||||
assert w("2026-08-30", "x", "2026-08-28") is False
|
||||
|
||||
|
||||
def test_validate_and_normalize_date():
|
||||
valid = {"id": "date2", "name": "n", "type": "date", "scope": "symbols",
|
||||
"symbols": ["600519.SH"], "remind_date": "2026-09-01", "lead_days": 2}
|
||||
monitor_rules.validate(valid)
|
||||
n = monitor_rules.normalize(valid)
|
||||
assert n["cooldown_seconds"] == 86400 # 每天最多一次
|
||||
assert n["conditions"] == []
|
||||
assert n["scope"] == "symbols"
|
||||
|
||||
# 归一化后为空 symbols 仍合法 (手动建), 但校验拒绝空 symbols (锚定标的)
|
||||
with pytest.raises(ValueError):
|
||||
monitor_rules.validate({**valid, "symbols": []})
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
monitor_rules.validate({**valid, "remind_date": None}) # 必填
|
||||
with pytest.raises(ValueError):
|
||||
monitor_rules.validate({**valid, "remind_date": "2026/09/01"}) # 格式
|
||||
with pytest.raises(ValueError):
|
||||
monitor_rules.validate({**valid, "lead_days": -1}) # 非负
|
||||
with pytest.raises(ValueError):
|
||||
monitor_rules.validate({**valid, "conditions": [{"field": "close", "op": ">=", "value": 10.0}]})
|
||||
assert "date" in monitor_rules.RULE_TYPES
|
||||
|
||||
|
||||
# ── 引擎评估 ────────────────────────────────────────────
|
||||
def _monkey_today(monkeypatch, iso: str):
|
||||
from datetime import date
|
||||
|
||||
monkeypatch.setattr("app.strategy.monitor.cn_today", lambda: date.fromisoformat(iso))
|
||||
|
||||
|
||||
def test_date_engine_fires_in_window(monkeypatch):
|
||||
_monkey_today(monkeypatch, "2026-08-28")
|
||||
eng = MonitorRuleEngine()
|
||||
eng.set_rules([_rule()])
|
||||
evs = eng.evaluate_date_rules()
|
||||
assert len(evs) == 1
|
||||
ev = evs[0]
|
||||
assert ev["source"] == "date"
|
||||
assert ev["type"] == "date_reminder"
|
||||
assert ev["symbol"] == "600519.SH"
|
||||
assert "2天后到期" in ev["message"] # 2026-08-30 - 今天(08-28)
|
||||
assert ev["message"] # 单标的: 不再把 symbol 拼进 message
|
||||
assert "600519.SH" not in ev["message"]
|
||||
assert ev["price"] is None and ev["change_pct"] is None # 日期提醒无行情
|
||||
assert ev["conditions"] == [] and ev["logic"] == "and"
|
||||
|
||||
|
||||
def test_date_engine_today_expiry_wording(monkeypatch):
|
||||
_monkey_today(monkeypatch, "2026-08-30")
|
||||
eng = MonitorRuleEngine()
|
||||
eng.set_rules([_rule(lead_days=0)])
|
||||
evs = eng.evaluate_date_rules()
|
||||
assert len(evs) == 1
|
||||
assert "今日到期" in evs[0]["message"]
|
||||
|
||||
|
||||
def test_date_engine_skips_outside_window(monkeypatch):
|
||||
_monkey_today(monkeypatch, "2026-08-31") # 过期
|
||||
eng = MonitorRuleEngine()
|
||||
eng.set_rules([_rule()])
|
||||
assert eng.evaluate_date_rules() == []
|
||||
|
||||
|
||||
def test_date_engine_ignores_disabled_and_non_date(monkeypatch):
|
||||
_monkey_today(monkeypatch, "2026-08-28")
|
||||
eng = MonitorRuleEngine()
|
||||
eng.set_rules([_rule(enabled=False), _rule(id="price1", type="price", conditions=[
|
||||
{"field": "close", "op": ">=", "value": 100.0},
|
||||
], message="")])
|
||||
assert eng.evaluate_date_rules() == []
|
||||
|
||||
|
||||
def test_date_engine_once_per_day(monkeypatch):
|
||||
_monkey_today(monkeypatch, "2026-08-28")
|
||||
eng = MonitorRuleEngine()
|
||||
eng.set_rules([_rule()])
|
||||
assert len(eng.evaluate_date_rules()) == 1
|
||||
assert eng.evaluate_date_rules() == [] # 同日 cooldown 去重
|
||||
# 跨天: 窗口内仍命中 → 再提醒一次
|
||||
_monkey_today(monkeypatch, "2026-08-29")
|
||||
assert len(eng.evaluate_date_rules()) == 1
|
||||
|
||||
|
||||
def test_date_engine_alerts_handler_called(monkeypatch):
|
||||
_monkey_today(monkeypatch, "2026-08-28")
|
||||
seen: list[dict] = []
|
||||
eng = MonitorRuleEngine(alert_handler=seen.append)
|
||||
eng.set_rules([_rule()])
|
||||
eng.evaluate_date_rules()
|
||||
assert len(seen) == 1 and seen[0]["source"] == "date"
|
||||
|
||||
|
||||
def test_date_not_evaluated_by_quote_evaluate(monkeypatch):
|
||||
"""date 规则不走 evaluate(df) 主循环, 避免与 date_rule 专用路径双触发。"""
|
||||
_monkey_today(monkeypatch, "2026-08-28")
|
||||
eng = MonitorRuleEngine()
|
||||
eng.set_rules([_rule()])
|
||||
df = pl.DataFrame({"symbol": ["600519.SH"], "close": [1500.0]})
|
||||
assert eng.evaluate(df) == []
|
||||
assert len(eng.evaluate_date_rules()) == 1
|
||||
|
||||
|
||||
# ── 告警引语 ────────────────────────────────────────────
|
||||
def test_format_alert_quote():
|
||||
assert format_alert_quote(1500.0, 0.1) == "现价 1500.0 · +10.0%"
|
||||
assert format_alert_quote(1425.0, -0.05) == "现价 1425.0 · -5.0%"
|
||||
assert format_alert_quote(None, None) == "" # 日期提醒等无行情
|
||||
assert format_alert_quote(None, 0.03) == "+3.0%"
|
||||
assert format_alert_quote(10.0, None) == "现价 10.0"
|
||||
@@ -0,0 +1,198 @@
|
||||
"""批次 (持仓提醒) 测试: 批次→规则映射 + 校验 + 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)
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
import { fmtDate } from '@/lib/format'
|
||||
|
||||
export interface DateShortcutOption {
|
||||
label: string
|
||||
/** 相对基准日期 base 的天数偏移 (0=base, 缺省今天) */
|
||||
days: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 当前值 (YYYY-MM-DD), 用于高亮命中的快捷项 */
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
options: DateShortcutOption[]
|
||||
/** 基准日期 (YYYY-MM-DD), 缺省今天; 如「到期日 = 买入日期 + N 天」 */
|
||||
base?: string
|
||||
}
|
||||
|
||||
function addDaysISO(days: number, base?: string): string {
|
||||
const d = base ? new Date(`${base}T00:00:00`) : new Date()
|
||||
d.setDate(d.getDate() + days)
|
||||
return fmtDate(d)
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷日期 chips — 与 DatePicker 并存: 快捷走 chips, 精确日期走日历。
|
||||
*/
|
||||
export function DateShortcuts({ value, onChange, options, base }: Props) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{options.map(opt => {
|
||||
const target = addDaysISO(opt.days, base)
|
||||
const active = value === target
|
||||
return (
|
||||
<button
|
||||
key={opt.label}
|
||||
type="button"
|
||||
title={target}
|
||||
onClick={() => onChange(active ? '' : target)}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 text-[10px] leading-none transition-colors cursor-pointer',
|
||||
active
|
||||
? 'bg-accent/15 text-accent border border-accent/30'
|
||||
: 'bg-elevated text-muted border border-transparent hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
BarChart3,
|
||||
Gauge,
|
||||
Sparkles,
|
||||
Layers2,
|
||||
Layers3,
|
||||
Landmark,
|
||||
RadioTower,
|
||||
@@ -85,6 +86,7 @@ const nav = [
|
||||
{ to: '/screener', label: '策略', icon: ScanSearch },
|
||||
{ to: '/backtest', label: '回测', icon: History },
|
||||
{ to: '/mining', label: '挖掘', icon: Pickaxe },
|
||||
{ to: '/lots', label: '持仓提醒', icon: Layers2 },
|
||||
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
|
||||
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
|
||||
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
|
||||
|
||||
@@ -409,8 +409,9 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
? '分时穿越信号需逐股订阅, 暂不支持自选分组作用域'
|
||||
: intradaySupport?.reason
|
||||
// 指数: 监控类型仅 signal/price (无涨跌停/策略/封单语义)
|
||||
// date: 由「持仓提醒」页 (批次派生) 生成, 监控中心不手工创建
|
||||
const visibleTypes = (options.data?.types ?? []).filter(
|
||||
t => assetType !== 'index' || t.key === 'signal' || t.key === 'price',
|
||||
t => t.key !== 'date' && (assetType !== 'index' || t.key === 'signal' || t.key === 'price'),
|
||||
)
|
||||
// 指数: 作用范围仅 symbols (无全市场/板块语义); ETF: 不支持自选分组 (分组为个股)
|
||||
const visibleScopes = (options.data?.scopes ?? []).filter(
|
||||
|
||||
+32
-1
@@ -868,7 +868,7 @@ export interface MonitorRule {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
type: 'strategy' | 'signal' | 'price' | 'market' | 'ladder' | 'sector' | 'abnormal' | 'volume_delta'
|
||||
type: 'strategy' | 'signal' | 'price' | 'market' | 'ladder' | 'sector' | 'abnormal' | 'volume_delta' | 'date'
|
||||
asset_type?: 'stock' | 'etf' | 'index'
|
||||
scope: 'symbols' | 'all' | 'sector' | 'watchlist_group'
|
||||
symbols: string[]
|
||||
@@ -904,6 +904,24 @@ export interface MonitorRule {
|
||||
threshold_volume?: number // 单轮增量 >= 此值时报警
|
||||
threshold_amount?: number // metric=amount 时: 单轮增量 >= 此值(元)时报警
|
||||
basic_filter?: VDBasicFilter // 基础过滤 (与策略 basic_filter 语义对齐)
|
||||
// date 类型 (日期提醒): 纯日历窗口, 无行情 conditions
|
||||
remind_date?: string | null // YYYY-MM-DD
|
||||
lead_days?: number // 提前 N 天进入提醒窗口
|
||||
lot_id?: string // 由「持仓提醒」页生成的规则, 托管在批次页 (监控中心只读)
|
||||
}
|
||||
|
||||
// 批次登记 (薄批次, 页面名"持仓提醒") — 只作监控规则生成的载体, 不做任何会计
|
||||
export interface Lot {
|
||||
id: string
|
||||
symbol: string
|
||||
qty: number
|
||||
cost_price: number
|
||||
buy_date?: string | null
|
||||
target_pct: number
|
||||
stop_pct: number
|
||||
remind_date?: string | null
|
||||
lead_days: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface VDBasicFilter {
|
||||
@@ -3113,6 +3131,19 @@ export const api = {
|
||||
monitorRuleDelete: (id: string) =>
|
||||
request<{ ok: boolean }>(`/api/monitor-rules/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
// ===== Lots (批次登记, 页面名"持仓提醒"; 保存/删除自动同步监控规则) =====
|
||||
lotsList: () =>
|
||||
request<{ lots: Lot[] }>('/api/lots'),
|
||||
|
||||
lotSave: (lot: Lot) =>
|
||||
request<{ ok: boolean; lot: Lot }>('/api/lots', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(lot),
|
||||
}),
|
||||
|
||||
lotDelete: (id: string) =>
|
||||
request<{ ok: boolean }>(`/api/lots/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
/** 模拟触发 ladder 封单监控 (Dev 调试, 不落盘不推送) */
|
||||
monitorRuleTestLadder: () =>
|
||||
request<{
|
||||
|
||||
@@ -100,6 +100,8 @@ export const QK = {
|
||||
// Monitor (监控规则 + 触发记录)
|
||||
monitorRules: ['monitor-rules'] as const,
|
||||
monitorRuleOptions: ['monitor-rule-options'] as const,
|
||||
lots: ['lots'] as const,
|
||||
lotsKline: (symbols: string) => ['lots-kline', symbols] as const,
|
||||
alerts: (source?: string) => ['alerts', source ?? ''] as const,
|
||||
|
||||
// AI 大盘复盘
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ArrowUpRight, CalendarClock, Pencil, Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { api, type Lot } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { Modal } from '@/components/Modal'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
import { DateShortcuts } from '@/components/DateShortcuts'
|
||||
import { StockPreviewDialog, toNavItems } from '@/components/StockPreviewDialog'
|
||||
import { boardTag } from '@/components/stock-table/primitives'
|
||||
|
||||
const emptyDraft = (): Lot => ({
|
||||
id: '',
|
||||
symbol: '',
|
||||
qty: 0,
|
||||
cost_price: 0,
|
||||
buy_date: '',
|
||||
target_pct: 0,
|
||||
stop_pct: 0,
|
||||
remind_date: '',
|
||||
lead_days: 1,
|
||||
})
|
||||
|
||||
/** 剩余天数单元格: 到期日 − 今天, 可为负 = 已超期; 无到期日 → — */
|
||||
function RemainingDays({ remind }: { remind?: string | null }) {
|
||||
if (!remind) return <span className="text-muted/60">—</span>
|
||||
const remindMs = new Date(`${remind}T00:00:00`).getTime()
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const n = Math.floor((remindMs - today.getTime()) / 86400000)
|
||||
if (n < 0) return <span className="font-mono text-warning">已超期{-n}天</span>
|
||||
return <span className="font-mono text-secondary">{n}天</span>
|
||||
}
|
||||
|
||||
/** 成本 vs 现价的盈亏% (纯价格比例, 无数量参与) */
|
||||
function CostPnL({ close, cost }: { close?: number; cost: number }) {
|
||||
if (close == null || !(cost > 0)) return <span className="text-muted/60">—</span>
|
||||
const pnl = (close - cost) / cost
|
||||
return <span className={cn('font-mono', priceColorClass(pnl))}>{fmtPct(pnl)}</span>
|
||||
}
|
||||
|
||||
export function Lots() {
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const [editing, setEditing] = useState<Lot | null>(null) // null=关闭
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null)
|
||||
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const lotsQuery = useQuery({ queryKey: QK.lots, queryFn: api.lotsList })
|
||||
const lots = lotsQuery.data?.lots ?? []
|
||||
|
||||
const allSymbols = useMemo(() => Array.from(new Set(lots.map(l => l.symbol))), [lots])
|
||||
const namesQuery = useQuery({
|
||||
queryKey: ['instrument-names', allSymbols.join(',')],
|
||||
queryFn: () => api.instrumentNames(allSymbols),
|
||||
enabled: allSymbols.length > 0,
|
||||
staleTime: 300000,
|
||||
})
|
||||
const symbolNames = namesQuery.data?.names ?? {}
|
||||
|
||||
// 9999 哨兵: 未记买入日期的排最后
|
||||
const sortedLots = useMemo(() => {
|
||||
return [...lots].sort((a, b) => (a.buy_date ?? '9999-12-31').localeCompare(b.buy_date ?? '9999-12-31'))
|
||||
}, [lots])
|
||||
const lotsNavItems = useMemo(
|
||||
() => toNavItems(sortedLots.map(l => ({ symbol: l.symbol, name: symbolNames[l.symbol] }))),
|
||||
[sortedLots, symbolNames],
|
||||
)
|
||||
|
||||
const dailyQuery = useQuery({
|
||||
queryKey: QK.lotsKline(allSymbols.join(',')),
|
||||
queryFn: () => api.klineDailyBatch(allSymbols, 5),
|
||||
enabled: allSymbols.length > 0,
|
||||
staleTime: 60000,
|
||||
})
|
||||
const lastPrices = useMemo(() => {
|
||||
const m: Record<string, number> = {}
|
||||
for (const [sym, rows] of Object.entries(dailyQuery.data?.data ?? {})) {
|
||||
const last = rows[rows.length - 1]
|
||||
if (last?.close != null) m[sym] = Number(last.close)
|
||||
}
|
||||
return m
|
||||
}, [dailyQuery.data])
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: api.lotDelete,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.lots })
|
||||
qc.invalidateQueries({ queryKey: QK.monitorRules })
|
||||
setConfirmId(null)
|
||||
},
|
||||
})
|
||||
|
||||
// 删除: 第一次进确认态, 第二次真删, 3 秒后自动复位 (与监控中心一致)
|
||||
const handleClickDelete = (id: string) => {
|
||||
if (confirmId === id) {
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current)
|
||||
setConfirmId(null)
|
||||
del.mutate(id)
|
||||
} else {
|
||||
setConfirmId(id)
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current)
|
||||
resetTimer.current = setTimeout(() => setConfirmId(null), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader title="持仓提醒" subtitle="记录个股 / ETF 买入批次, 自动生成止盈止损 / 到期监控规则" />
|
||||
<div className="flex-1 min-h-0 px-5 py-4">
|
||||
<div className="mx-auto max-w-5xl space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-secondary">{lots.length} 个批次</div>
|
||||
<button
|
||||
onClick={() => setEditing(emptyDraft())}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-btn border border-accent/30 bg-accent/10 px-3 text-xs font-medium text-accent transition-colors hover:bg-accent/15 cursor-pointer"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />新增批次
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lotsQuery.isLoading ? (
|
||||
<div className="rounded-xl border border-dashed border-border px-6 py-12 text-center text-xs text-muted">加载中…</div>
|
||||
) : lotsQuery.isError ? (
|
||||
<div className="rounded-xl border border-dashed border-border px-6 py-12 text-center">
|
||||
<div className="text-xs text-danger">批次加载失败</div>
|
||||
<button
|
||||
onClick={() => lotsQuery.refetch()}
|
||||
className="mt-2 rounded-btn border border-border px-3 py-1 text-[11px] text-secondary hover:bg-elevated cursor-pointer"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : lots.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border px-6 py-12 text-center">
|
||||
<div className="text-sm text-muted">还没有批次</div>
|
||||
<div className="mt-1 text-[11px] text-muted/70">记录一笔买入后, 系统会按成本价 ± 止盈/止损% 生成价格监控; 填了到期日则自动生成到期提醒。这里只用于生成提醒, 不是持仓记账。</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-surface/40 shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-surface/60 text-[10px] uppercase tracking-wide text-muted">
|
||||
<th className="px-4 py-2 font-medium">标的</th>
|
||||
<th className="px-2 py-2 font-medium text-right">数量(参考)</th>
|
||||
<th className="px-2 py-2 font-medium text-right">成本价</th>
|
||||
<th className="px-2 py-2 font-medium text-right">现价</th>
|
||||
<th className="px-2 py-2 font-medium text-right">盈亏%</th>
|
||||
<th className="px-2 py-2 font-medium text-right">止盈%</th>
|
||||
<th className="px-2 py-2 font-medium text-right">止损%</th>
|
||||
<th className="px-2 py-2 font-medium">买入日期</th>
|
||||
<th className="px-2 py-2 font-medium text-right">剩余天数</th>
|
||||
<th className="px-2 py-2 font-medium">到期提醒</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedLots.map(lot => (
|
||||
<tr key={lot.id} className="border-b border-border/40 last:border-0 hover:bg-elevated/40">
|
||||
<td className="px-4 py-2.5">
|
||||
<button
|
||||
onClick={() => setPreviewSymbol(lot.symbol)}
|
||||
title={`查看 ${lot.symbol} 日K`}
|
||||
className="inline-flex items-center gap-1.5 min-w-0 hover:bg-elevated/50 rounded px-0.5 py-0.5 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="font-mono font-medium text-foreground">{lot.symbol}</span>
|
||||
{(() => { const b = boardTag(lot.symbol); return b && <span className={`inline-flex items-center justify-center rounded px-1 text-[9px] font-bold leading-tight border ${b.color}`}>{b.label}</span> })()}
|
||||
{symbolNames[lot.symbol] && <span className="text-secondary truncate max-w-28">{symbolNames[lot.symbol]}</span>}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-right font-mono text-secondary">{lot.qty}</td>
|
||||
<td className="px-2 py-2.5 text-right font-mono text-foreground">{lot.cost_price}</td>
|
||||
<td className="px-2 py-2.5 text-right font-mono text-secondary">{lastPrices[lot.symbol] != null ? fmtPrice(lastPrices[lot.symbol]) : '—'}</td>
|
||||
<td className="px-2 py-2.5 text-right"><CostPnL close={lastPrices[lot.symbol]} cost={lot.cost_price} /></td>
|
||||
<td className="px-2 py-2.5 text-right font-mono text-bull">{lot.target_pct > 0 ? `${lot.target_pct}%` : '—'}</td>
|
||||
<td className="px-2 py-2.5 text-right font-mono text-bear">{lot.stop_pct > 0 ? `${lot.stop_pct}%` : '—'}</td>
|
||||
<td className="px-2 py-2.5 text-muted">{lot.buy_date || '—'}</td>
|
||||
<td className="px-2 py-2.5 text-right"><RemainingDays remind={lot.remind_date} /></td>
|
||||
<td className="px-2 py-2.5">
|
||||
{lot.remind_date ? (
|
||||
<span className="inline-flex items-center gap-1 text-rose-400">
|
||||
<CalendarClock className="h-3 w-3" />
|
||||
{lot.remind_date}
|
||||
{lot.lead_days > 0 && <span className="text-muted">· 提前{lot.lead_days}天</span>}
|
||||
</span>
|
||||
) : <span className="text-muted/60">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<button
|
||||
onClick={() => setEditing(lot)}
|
||||
title="编辑"
|
||||
className="p-1.5 rounded-md text-secondary transition-all hover:bg-accent/10 hover:text-accent cursor-pointer"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{confirmId === lot.id ? (
|
||||
<button
|
||||
onClick={() => handleClickDelete(lot.id)}
|
||||
title="再次点击确认删除"
|
||||
className="inline-flex items-center gap-1 rounded-md bg-danger/15 px-1.5 py-0.5 text-[9px] font-medium text-danger border border-danger/30 animate-pulse cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />确认
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleClickDelete(lot.id)}
|
||||
title="删除 (同步删除生成的监控规则)"
|
||||
className="p-1.5 rounded-md text-secondary transition-all hover:bg-danger/10 hover:text-danger cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center gap-1 text-[11px] text-muted">
|
||||
生成的止盈止损 / 到期提醒规则已同步至监控中心
|
||||
<button onClick={() => navigate('/monitor')} className="inline-flex items-center gap-0.5 text-accent hover:text-accent/80 cursor-pointer">
|
||||
去查看 <ArrowUpRight className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && <LotDialog lot={editing} onClose={() => setEditing(null)} />}
|
||||
|
||||
<StockPreviewDialog
|
||||
symbol={previewSymbol}
|
||||
name={previewSymbol ? symbolNames[previewSymbol] : undefined}
|
||||
navList={lotsNavItems}
|
||||
onNavigate={(sym) => setPreviewSymbol(sym)}
|
||||
onClose={() => setPreviewSymbol(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LotDialog({ lot, onClose }: { lot: Lot; onClose: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const [draft, setDraft] = useState<Lot>(() => ({ ...lot }))
|
||||
const [symbolQuery, setSymbolQuery] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// 数字字段用本地字符串承载 (可先清空再输入), 提交时才解析成数值;
|
||||
// 否则受控 number + parseFloat 会在清空瞬间把值塞回 0, 导致「0 去不掉」。
|
||||
const [nums, setNums] = useState<Record<string, string>>(() => {
|
||||
const f = (v: number | undefined | null) => (v == null || v === 0 ? '' : String(v))
|
||||
return {
|
||||
qty: f(lot.qty), cost_price: f(lot.cost_price),
|
||||
target_pct: f(lot.target_pct), stop_pct: f(lot.stop_pct),
|
||||
lead_days: f(lot.lead_days),
|
||||
}
|
||||
})
|
||||
const numField = (key: string) => ({
|
||||
value: nums[key] ?? '',
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setNums(s => ({ ...s, [key]: e.target.value })),
|
||||
})
|
||||
|
||||
const symbolSearch = useQuery({
|
||||
queryKey: QK.instrumentSearch(symbolQuery, 'stock,etf'),
|
||||
queryFn: () => api.instrumentSearch(symbolQuery, 20, 'stock,etf'),
|
||||
enabled: symbolQuery.trim().length > 0,
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (vals: Partial<Lot>) => api.lotSave({ ...draft, ...vals, symbol: draft.symbol.trim() }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.lots })
|
||||
qc.invalidateQueries({ queryKey: QK.monitorRules })
|
||||
onClose()
|
||||
},
|
||||
onError: err => setError(String((err as any)?.message ?? err)),
|
||||
})
|
||||
|
||||
const parseNum = (s: string | undefined) => {
|
||||
const n = parseFloat(s ?? '')
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
setError('')
|
||||
if (!draft.symbol.trim()) return setError('请选择标的')
|
||||
const vals: Partial<Lot> = {
|
||||
qty: parseNum(nums.qty),
|
||||
cost_price: parseNum(nums.cost_price),
|
||||
target_pct: parseNum(nums.target_pct),
|
||||
stop_pct: parseNum(nums.stop_pct),
|
||||
lead_days: Math.floor(parseNum(nums.lead_days)),
|
||||
}
|
||||
if (!((vals.cost_price ?? 0) > 0)) return setError('成本价必须为正数')
|
||||
if ((vals.qty ?? 0) < 0 || (vals.target_pct ?? 0) < 0 || (vals.stop_pct ?? 0) < 0 || (vals.lead_days ?? 0) < 0) {
|
||||
return setError('数量 / 百分比 / 提前天数不能为负数')
|
||||
}
|
||||
if (!((vals.target_pct ?? 0) > 0 || (vals.stop_pct ?? 0) > 0 || draft.remind_date)) {
|
||||
return setError('止盈% / 止损% / 到期日 至少设置一项')
|
||||
}
|
||||
save.mutate(vals)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} ariaLabel={lot.id ? '编辑批次' : '新增批次'} panelClassName="w-[92vw] max-w-md bg-surface border border-border rounded-card shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border/60 px-4 py-3">
|
||||
<span className="text-sm font-medium text-foreground">{lot.id ? '编辑批次' : '新增批次'}</span>
|
||||
<span className="text-[10px] text-muted">保存后自动同步监控规则</span>
|
||||
</div>
|
||||
<div className="space-y-3 px-4 py-4">
|
||||
{/* 标的 */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">标的</span>
|
||||
{draft.symbol ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 rounded bg-elevated px-2 py-1 font-mono text-[11px] text-secondary">
|
||||
{draft.symbol}
|
||||
<button onClick={() => setDraft(d => ({ ...d, symbol: '' }))} className="text-muted hover:text-danger cursor-pointer"><span className="text-[10px]">✕</span></button>
|
||||
</span>
|
||||
<span className="text-[10px] text-muted">点 ✕ 可重选</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<input
|
||||
value={symbolQuery}
|
||||
onChange={e => setSymbolQuery(e.target.value)}
|
||||
placeholder="搜索代码或名称..."
|
||||
autoFocus
|
||||
className="h-9 w-full rounded-btn border border-border bg-base pl-8 pr-3 text-xs text-foreground focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted" />
|
||||
{symbolSearch.data && symbolSearch.data.results.length > 0 && (
|
||||
<div className="absolute z-10 mt-1 max-h-48 w-full overflow-auto rounded border border-border bg-surface shadow-lg">
|
||||
{symbolSearch.data.results.map(r => (
|
||||
<button
|
||||
key={r.symbol}
|
||||
onClick={() => { setDraft(d => ({ ...d, symbol: r.symbol })); setSymbolQuery('') }}
|
||||
className="block w-full px-2.5 py-1.5 text-left text-[11px] hover:bg-elevated cursor-pointer"
|
||||
>
|
||||
<span className="font-mono text-foreground/80">{r.symbol}</span>
|
||||
<span className="ml-1.5 text-muted">{r.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">数量 (参考)</span>
|
||||
<input type="number" min={0} placeholder="0" {...numField('qty')} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">成本价</span>
|
||||
<input type="number" min={0} step="any" placeholder="0" {...numField('cost_price')} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">止盈%</span>
|
||||
<input type="number" min={0} step="any" placeholder="0" {...numField('target_pct')} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">止损%</span>
|
||||
<input type="number" min={0} step="any" placeholder="0" {...numField('stop_pct')} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">买入日期 (可选)</span>
|
||||
<DateShortcuts value={draft.buy_date ?? ''} onChange={v => setDraft(d => ({ ...d, buy_date: v || null }))} options={[{ label: '今天', days: 0 }]} />
|
||||
<DatePicker value={draft.buy_date ?? ''} onChange={v => setDraft(d => ({ ...d, buy_date: v || null }))} placeholder="不记录" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">到期日 (可选)</span>
|
||||
<DateShortcuts value={draft.remind_date ?? ''} onChange={v => setDraft(d => ({ ...d, remind_date: v || null }))} options={[{ label: '5天', days: 5 }, { label: '10天', days: 10 }, { label: '15天', days: 15 }]} base={draft.buy_date || undefined} />
|
||||
<DatePicker value={draft.remind_date ?? ''} onChange={v => setDraft(d => ({ ...d, remind_date: v || null }))} placeholder="不提醒" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.remind_date && (
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">提前提醒天数</span>
|
||||
<input type="number" min={0} placeholder="1" {...numField('lead_days')} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
|
||||
<span className="block text-[10px] text-muted">提醒仅在交易时段评估; 到期日若逢周末或长假, 请把提前天数调大些 (建议 ≥ 2, 长假更大)</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-[11px] text-danger">{error}</div>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border/60 px-4 py-3">
|
||||
<button onClick={onClose} className="h-9 rounded-btn border border-border px-3 text-xs text-secondary hover:bg-elevated cursor-pointer">取消</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={save.isPending}
|
||||
className={cn('h-9 rounded-btn px-4 text-xs font-medium bg-accent/90 text-white hover:bg-accent cursor-pointer disabled:opacity-50')}
|
||||
>
|
||||
{save.isPending ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries'
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
signal: '信号', price: '价格/涨跌', market: '市场异动', strategy: '策略监控', sector: '板块监控',
|
||||
abnormal: '异动监控', volume_delta: '轮询放量',
|
||||
abnormal: '异动监控', volume_delta: '轮询放量', date: '日期提醒',
|
||||
}
|
||||
|
||||
/** 严重级别 → 左侧色条 + 图标 */
|
||||
@@ -40,6 +40,7 @@ const SOURCE_BADGE_STYLE: Record<string, string> = {
|
||||
sector: 'bg-cyan-500/10 text-cyan-700 border-cyan-500/20 dark:text-cyan-300',
|
||||
abnormal: 'bg-orange-500/10 text-orange-500 border-orange-500/20 dark:text-orange-400',
|
||||
volume_delta: 'bg-rose-500/10 text-rose-400 border-rose-500/20 dark:text-rose-300',
|
||||
date: 'bg-violet-500/10 text-violet-500 border-violet-500/20 dark:text-violet-300',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,7 +134,7 @@ export function Monitor() {
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
// 触发记录: 过滤 + 统计 (提升到主组件, 供 header 行使用)
|
||||
const [filter, setFilter] = useState<'all' | 'strategy' | 'signal' | 'price' | 'market' | 'sector' | 'abnormal' | 'volume_delta'>('all')
|
||||
const [filter, setFilter] = useState<'all' | 'strategy' | 'signal' | 'price' | 'market' | 'sector' | 'abnormal' | 'volume_delta' | 'date'>('all')
|
||||
const [confirmClear, setConfirmClear] = useState(false)
|
||||
const [confirmClearRules, setConfirmClearRules] = useState(false)
|
||||
|
||||
@@ -214,7 +215,7 @@ export function Monitor() {
|
||||
<SectionHeader icon={BellRing} title="触发记录" />
|
||||
{/* 过滤标签 */}
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
{(['all', 'strategy', 'signal', 'price', 'market', 'sector', 'abnormal', 'volume_delta'] as const).map(f => (
|
||||
{(['all', 'strategy', 'signal', 'price', 'market', 'sector', 'abnormal', 'volume_delta', 'date'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
@@ -788,6 +789,9 @@ function RulesList({ rulesQuery, onEdit }: {
|
||||
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[9px] font-semibold', SOURCE_BADGE_STYLE[r.type] ?? 'bg-elevated text-muted')}>
|
||||
{TYPE_LABEL[r.type]}
|
||||
</span>
|
||||
{r.lot_id && (
|
||||
<span className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-semibold bg-emerald-400/10 text-emerald-500" title="由「持仓提醒」页托管, 请在持仓提醒页修改或删除">批次</span>
|
||||
)}
|
||||
{r.asset_type === 'index' && (
|
||||
<span className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-semibold bg-sky-500/10 text-sky-400">指数</span>
|
||||
)}
|
||||
@@ -826,6 +830,15 @@ function RulesList({ rulesQuery, onEdit }: {
|
||||
{!r.enabled && <span className="shrink-0 text-[9px] text-secondary">· 停用</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{r.lot_id ? (
|
||||
<span
|
||||
className="inline-flex items-center rounded-md border border-border/60 bg-elevated/60 px-1.5 py-0.5 text-[9px] text-secondary"
|
||||
title="由「持仓提醒」页生成的规则, 该页托管; 启停/修改/删除请到持仓提醒页"
|
||||
>
|
||||
批次托管
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => toggleEnabled(r)}
|
||||
title={r.enabled ? '停用' : '启用'}
|
||||
@@ -861,6 +874,8 @@ function RulesList({ rulesQuery, onEdit }: {
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -900,6 +915,12 @@ function RulesList({ rulesQuery, onEdit }: {
|
||||
{r.direction === 'up' ? '涨势偏离' : r.direction === 'down' ? '跌势偏离' : '涨跌双向'}
|
||||
</span>
|
||||
</div>
|
||||
) : r.type === 'date' ? (
|
||||
<div className="mt-1 flex items-center gap-1 pl-0.5 text-[9px] text-secondary">
|
||||
<span>提醒 {r.remind_date ?? ''}</span>
|
||||
{(r.lead_days ?? 0) > 0 && <span>· 提前{r.lead_days}天</span>}
|
||||
<span>· 仅交易日盘中评估</span>
|
||||
</div>
|
||||
) : r.type === 'volume_delta' ? (
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1 pl-0.5">
|
||||
<span className="rounded bg-rose-500/8 px-1.5 py-0.5 text-[9px] font-mono text-rose-500 dark:text-rose-300">
|
||||
|
||||
@@ -22,6 +22,7 @@ const Mining = lazy(() => import('./pages/Mining').then(m => ({ default: m.Minin
|
||||
const Financials = lazy(() => import('./pages/Financials').then(m => ({ default: m.Financials })))
|
||||
const Data = lazy(() => import('./pages/Data').then(m => ({ default: m.Data })))
|
||||
const Monitor = lazy(() => import('./pages/Monitor').then(m => ({ default: m.Monitor })))
|
||||
const Lots = lazy(() => import('./pages/Lots').then(m => ({ default: m.Lots })))
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard').then(m => ({ default: m.Dashboard })))
|
||||
const AnalysisDetail = lazy(() => import('./pages/AnalysisDetail').then(m => ({ default: m.AnalysisDetail })))
|
||||
const ConceptAnalysis = lazy(() => import('./pages/ConceptAnalysis').then(m => ({ default: m.ConceptAnalysis })))
|
||||
@@ -127,6 +128,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'financials', element: <Financials /> },
|
||||
{ path: 'data', element: <Data /> },
|
||||
{ path: 'monitor', element: <Monitor /> },
|
||||
{ path: 'lots', element: <Lots /> },
|
||||
{ path: 'limit-ladder', element: <LimitUpLadder /> },
|
||||
{ path: 'indices', element: <Indices /> },
|
||||
{ path: 'regime', element: <Regime /> },
|
||||
|
||||
Reference in New Issue
Block a user