Merge pull request #31 from shy3130/feat/v0.1.65-monitor-backtest-webhook

release: v0.1.65 监控增强 + 回测止盈 + 飞书推送
This commit is contained in:
wshy
2026-06-29 17:23:05 +08:00
committed by GitHub
32 changed files with 1123 additions and 326 deletions
+11 -2
View File
@@ -7,7 +7,7 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from app.indicators.pipeline import compute_enriched_single
from app.indicators.pipeline import compute_enriched, compute_enriched_single
from app.services import kline_sync
logger = logging.getLogger(__name__)
@@ -126,7 +126,16 @@ def get_daily(
raise HTTPException(status_code=502, detail=f"TickFlow fetch failed: {e}") from e
if raw.is_empty():
return {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": []}
enriched = compute_enriched_single(raw)
# 拉除权因子做前复权 (Starter+ 有权限), 否则空 df → compute_enriched 退回未复权
factors = pl.DataFrame()
capset = getattr(request.app.state, "capabilities", None)
try:
from app.tickflow.capabilities import Cap
if capset and capset.has(Cap.ADJ_FACTOR):
factors = kline_sync.fetch_adj_factor_single(symbol)
except Exception as e: # noqa: BLE001
logger.debug("单股除权因子拉取失败 %s: %s", symbol, e)
enriched = compute_enriched(raw, factors=factors)
rows = enriched.tail(days).to_dicts()
# 即使 live 模式也尝试追加实时蜡烛
rows = _maybe_inject_live_candle(request, symbol, rows)
+1 -1
View File
@@ -47,7 +47,7 @@ class RuleModel(BaseModel):
logic: str = "and" # and | or
cooldown_seconds: int = 3600
severity: str = "info" # info | warn | critical
webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 开发中)
webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 待定)
webhook_enabled: bool = False
message: str = ""
+47
View File
@@ -307,6 +307,9 @@ def get_preferences() -> dict:
"strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(),
"strategy_monitor_ids": preferences.get_strategy_monitor_ids(),
"system_notify_enabled": preferences.get_system_notify_enabled(),
"feishu_webhook_url": preferences.get_feishu_webhook_url(),
"feishu_webhook_secret": preferences.get_feishu_webhook_secret(),
"webhook_enabled_default": preferences.get_webhook_enabled_default(),
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
"nav_order": preferences.get_nav_order(),
"nav_hidden": preferences.get_nav_hidden(),
@@ -554,6 +557,50 @@ def update_system_notify(req: SystemNotifyPrefsIn) -> dict:
return {"system_notify_enabled": saved}
class FeishuWebhookPrefsIn(BaseModel):
url: str
secret: str = ""
@router.put("/preferences/feishu-webhook")
def update_feishu_webhook(req: FeishuWebhookPrefsIn) -> dict:
"""飞书 Webhook 地址 + 签名密钥 — 全局一处配置, 所有启用推送的监控规则共用。
- url: 传入空串表示清空配置; 非空则需为合法的飞书自定义机器人地址。
- secret: 机器人启用了「签名校验」时填密钥, 留空表示不验签。
"""
from app.services import preferences
from app.services import webhook_adapter
url = (req.url or "").strip()
if url and not webhook_adapter.is_valid_feishu_url(url):
raise HTTPException(
status_code=400,
detail="Webhook 地址非法, 需为飞书自定义机器人地址 "
"(https://open.feishu.cn/open-apis/bot/v2/hook/...)",
)
saved_url = preferences.set_feishu_webhook_url(url)
saved_secret = preferences.set_feishu_webhook_secret((req.secret or "").strip())
return {"feishu_webhook_url": saved_url, "feishu_webhook_secret": saved_secret}
class WebhookEnabledDefaultIn(BaseModel):
enabled: bool
@router.put("/preferences/webhook-enabled-default")
def update_webhook_enabled_default(req: WebhookEnabledDefaultIn) -> dict:
"""新建监控规则时是否默认勾选「飞书推送」。
数据模型当前只有飞书一个可用渠道 (QMT/ptrade 待定),故此处仅一个布尔。
单条规则仍可在规则编辑页独立修改此项。
"""
from app.services import preferences
saved = preferences.set_webhook_enabled_default(req.enabled)
return {"webhook_enabled_default": saved}
@router.put("/preferences/quote-interval")
def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict:
"""更新行情轮询间隔。按档位自动 clamp。"""
+1
View File
@@ -84,6 +84,7 @@ def _strategy_detail(s: StrategyDef, overrides: dict | None = None) -> dict:
"entry_signals": s.entry_signals,
"exit_signals": s.exit_signals,
"stop_loss": overrides.get("stop_loss", s.stop_loss) if overrides else s.stop_loss,
"take_profit": getattr(s, "take_profit", None),
"trailing_stop": getattr(s, "trailing_stop", None),
"trailing_take_profit_activate": getattr(s, "trailing_take_profit_activate", None),
"trailing_take_profit_drawdown": getattr(s, "trailing_take_profit_drawdown", None),
+43 -18
View File
@@ -37,6 +37,7 @@ class MatcherConfig:
fees_pct: float = 0.0002
slippage_bps: float = 5.0
stop_loss_pct: float | None = None
take_profit_pct: float | None = None
trailing_stop_pct: float | None = None
trailing_take_profit_activate_pct: float | None = None
trailing_take_profit_drawdown_pct: float | None = None
@@ -65,7 +66,7 @@ class TradeRecord:
exit_price: float
pnl_pct: float
duration: int
exit_reason: str # "signal" | "stop_loss" | "trailing_stop" | "trailing_take_profit" | "max_hold" | "end"
exit_reason: str # "signal" | "stop_loss" | "take_profit" | "trailing_stop" | "trailing_take_profit" | "max_hold" | "end"
# 退出优先级 (高→低): pending_exit(历史挂单) > 风控(止损/移动止损/移动止盈) > signal(卖点) > max_hold(到期) > end
name: str = ""
shares: float = 0.0
@@ -544,6 +545,7 @@ class BacktestEngine:
return None, None
open_price = float(open_prices[idx])
low_price = float(low_prices[idx])
high_price = float(high_prices[idx])
peak_price = float(pos.get("max_high", entry_price))
risk_lines: list[tuple[float, str]] = []
@@ -560,13 +562,24 @@ class BacktestEngine:
risk_lines.append((entry_price * (1 + peak_profit - abs(float(drawdown_pct))), "trailing_take_profit"))
risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)]
if not risk_lines:
return None, None
stop_price, reason = max(risk_lines, key=lambda item: item[0])
if _valid_price(open_price) and open_price <= stop_price:
return reason, open_price
if _valid_price(low_price) and low_price <= stop_price:
return reason, stop_price
# 止损/移损/回撤止盈: 价格跌破风控线触发 (取最高优先级线)
if risk_lines:
stop_price, reason = max(risk_lines, key=lambda item: item[0])
if _valid_price(open_price) and open_price <= stop_price:
return reason, open_price
if _valid_price(low_price) and low_price <= stop_price:
return reason, stop_price
# 固定止盈: 价格涨破止盈线触发
tp_pct = getattr(config, "take_profit_pct", None)
if tp_pct is not None:
tp_line = entry_price * (1 + abs(float(tp_pct)))
if _valid_price(tp_line):
# 开盘即超过止盈线 → 以开盘价成交; 否则当日触及高点止盈
if _valid_price(open_price) and open_price >= tp_line:
return "take_profit", open_price
if _valid_price(high_price) and high_price >= tp_line:
return "take_profit", tp_line
return None, None
def _try_close(pos: dict, idx: int, reason: str, signal_date: str, exit_price_override: float | None = None) -> bool:
@@ -993,6 +1006,7 @@ class BacktestEngine:
continue
open_price = float(open_prices[idx])
low_price = float(low_prices[idx])
high_price = float(high_prices[idx])
entry_price = float(pos["entry_price"])
peak_price = float(pos.get("max_high", entry_price))
risk_lines: list[tuple[float, str]] = []
@@ -1011,17 +1025,28 @@ class BacktestEngine:
take_profit_line = entry_price * (1 + peak_profit - abs(float(drawdown_pct)))
risk_lines.append((take_profit_line, "trailing_take_profit"))
# 止损/移损/回撤止盈: 价格跌破风控线触发
risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)]
if not risk_lines:
continue
stop_price, reason = max(risk_lines, key=lambda item: item[0])
exit_price_override = None
if _valid_price(open_price) and open_price <= stop_price:
exit_price_override = open_price
elif _valid_price(low_price) and low_price <= stop_price:
exit_price_override = stop_price
if exit_price_override is not None:
_try_sell(sym, idx, reason, d_str, sold_today, exit_price_override)
if risk_lines:
stop_price, reason = max(risk_lines, key=lambda item: item[0])
exit_price_override = None
if _valid_price(open_price) and open_price <= stop_price:
exit_price_override = open_price
elif _valid_price(low_price) and low_price <= stop_price:
exit_price_override = stop_price
if exit_price_override is not None:
_try_sell(sym, idx, reason, d_str, sold_today, exit_price_override)
continue
# 固定止盈: 价格涨破止盈线触发
tp_pct = getattr(config, "take_profit_pct", None)
if tp_pct is not None:
tp_line = entry_price * (1 + abs(float(tp_pct)))
if _valid_price(tp_line):
if _valid_price(open_price) and open_price >= tp_line:
_try_sell(sym, idx, "take_profit", d_str, sold_today, open_price)
elif _valid_price(high_price) and high_price >= tp_line:
_try_sell(sym, idx, "take_profit", d_str, sold_today, tp_line)
def _process_entries(
d_str: str,
+7
View File
@@ -103,6 +103,11 @@ class StrategyBacktestService:
entry_signals = self._effective_signals(overrides, "entry_signals", s.entry_signals)
exit_signals = self._effective_signals(overrides, "exit_signals", s.exit_signals)
stop_loss = self._override_value(overrides, "stop_loss", s.stop_loss)
take_profit = self._normalize_pct(
self._override_value(overrides, "take_profit", getattr(s, "take_profit", None)),
0.01,
5.0,
)
trailing_stop = self._normalize_pct(
self._override_value(overrides, "trailing_stop", getattr(s, "trailing_stop", None)),
0.005,
@@ -195,6 +200,7 @@ class StrategyBacktestService:
fees_pct=config.fees_pct,
slippage_bps=config.slippage_bps,
stop_loss_pct=stop_loss,
take_profit_pct=take_profit,
trailing_stop_pct=trailing_stop,
trailing_take_profit_activate_pct=trailing_take_profit_activate,
trailing_take_profit_drawdown_pct=trailing_take_profit_drawdown,
@@ -246,6 +252,7 @@ class StrategyBacktestService:
"entry_signals": entry_signals,
"exit_signals": exit_signals,
"stop_loss": stop_loss,
"take_profit": take_profit,
"trailing_stop": trailing_stop,
"trailing_take_profit_activate": trailing_take_profit_activate,
"trailing_take_profit_drawdown": trailing_take_profit_drawdown,
+36 -24
View File
@@ -110,6 +110,9 @@ def run_now(
today = _date.today()
today_exists = latest_daily and latest_daily >= today
new_daily_days = 0
# 日K范围拉取的起点(分支3补缺口/分支4首次); 实时增量/跳过时为 None。
# 供 Step 1.5 除权因子回溯范围对齐: 范围拉取→用日K范围, 非范围→最近N天兜底。
daily_range_start: _date | None = None
# A 股日K拉取开关(默认开);关闭时跳过日K同步,保留已有数据
pull_a_share = _prefs.get_pipeline_pull_a_share()
@@ -130,6 +133,7 @@ def run_now(
# 也覆盖"今天已有数据但无实时行情权限(free/none)"的降级场景:
# 此时 start_date = latest_daily = today,batch 刷新当天日K。
start_date = latest_daily
daily_range_start = start_date
emit("sync_daily", 12, f"获取日K [{start_date} ~ {today}]…")
logger.info("sync_daily: [%s ~ %s] %s", start_date, today,
"refresh today" if today_exists else "gap fill")
@@ -150,6 +154,7 @@ def run_now(
else:
# 首次:无任何数据 → batch 拉 1 年
start_date = today - _td(days=365)
daily_range_start = start_date
emit("sync_daily", 12, f"获取日K [{start_date} ~ {today}]…")
logger.info("sync_daily: [%s ~ %s] initial fetch", start_date, today)
@@ -167,36 +172,22 @@ def run_now(
logger.info("sync_daily: [%s ~ %s] done", start_date, today)
_invalidate("daily")
# Step 1.5: 增量同步除权因子 — 从已有数据最新日期的下一天开始获取
# Step 1.5: 同步除权因子 — 范围与日K拉取方式对齐
# 日K范围拉取(补缺口/首次) → 除权用日K范围 [daily_range_start, now]
# 首次会覆盖整个日K区间内的历史除权事件; 补缺口天然只增量(起点=latest_daily≈昨天)
# 日K实时增量/跳过(分支2/分支1) → 除权兜底拉最近 30 天, 补可能遗漏的新除权
# (这两类分支不拉历史日K, 除权不能用日K范围, 只能兜底最近几日)
written_adj = 0
affected_symbols: list[str] = []
if capset.has(Cap.ADJ_FACTOR):
from datetime import datetime, timedelta
adj_end = datetime.now()
# 从已有除权因子数据的最新日期开始获取,避免重复拉取
adj_factor_path = repo.store.data_dir / "adj_factor" / "all.parquet"
fallback_start = adj_end - timedelta(days=30)
if adj_factor_path.exists():
try:
from datetime import date as date_cls
max_date = pl.scan_parquet(adj_factor_path).select(
pl.col("trade_date").max()
).collect().item()
if max_date is not None:
# trade_date 可能是 date / datetime / string 类型
if isinstance(max_date, str):
td = date_cls.fromisoformat(max_date)
elif isinstance(max_date, datetime):
td = max_date.date()
else:
td = max_date
adj_start = datetime.combine(td, datetime.min.time())
else:
adj_start = fallback_start
except Exception:
adj_start = fallback_start
if daily_range_start is not None:
adj_start = datetime.combine(daily_range_start, datetime.min.time())
else:
adj_start = fallback_start
# 日K实时增量/跳过时, 除权兜底拉最近 N 天, 覆盖周末/长假/停机期间的新除权事件。
# 15 天: 覆盖春节/国庆最长约10天长假 + 故障恢复缓冲; sync_adj_factor 内部 merge+unique 幂等, 多拉无副作用。
adj_start = adj_end - timedelta(days=15)
adj_start_str = adj_start.strftime("%Y-%m-%d")
adj_end_str = adj_end.strftime("%Y-%m-%d")
emit("sync_adj", 50, f"获取除权因子 [{adj_start_str} ~ {adj_end_str}]…")
@@ -312,33 +303,46 @@ def run_now(
if pull_etf:
_types.append("ETF")
emit("sync_index", 88, f"同步{'+'.join(_types)}日K…")
# 子阶段进度分配: 88.0(开始) → 89.0(完成), 指数占前半, ETF 占后半
try:
if pull_index:
emit("sync_index", 88, "同步指数维表…")
index_count = index_sync.sync_index_instruments(repo, pull_index=True, pull_etf=False)
emit("sync_index", 88, f"指数维表完成,{index_count}")
index_dir = repo.store.data_dir / "kline_index_enriched"
index_dates = sorted(
d.name[5:] for d in index_dir.glob("date=*")
if d.is_dir() and d.name.startswith("date=")
) if index_dir.exists() else []
index_start = _date.fromisoformat(index_dates[-1]) if index_dates else today - _td(days=365)
def _index_chunk(cur: int, tot: int) -> None:
emit("sync_index", 88, f"指数日K批次 {cur}/{tot}",
stage_pct=int(100 * cur / tot) if tot else 100, skip_log=cur < tot)
written_index_daily = index_sync.sync_and_persist_index_daily(
repo,
capset,
start_date=_dt.combine(index_start, _dt.min.time()),
end_date=_dt.combine(today, _dt.min.time()),
on_chunk_done=_index_chunk,
)
emit("sync_index", 88, f"指数日K完成,{written_index_daily}")
_invalidate("index_instruments")
_invalidate("index_daily")
_invalidate("index_enriched")
if pull_etf:
emit("sync_index", 88, "同步 ETF 维表…")
etf_count = index_sync.sync_etf_instruments(repo)
emit("sync_index", 88, f"ETF 维表完成,{etf_count}")
etf_symbols: list[str] = []
etf_inst = repo.get_etf_instruments()
if not etf_inst.is_empty() and "symbol" in etf_inst.columns:
etf_symbols = sorted(set(etf_inst["symbol"].to_list()))
if etf_symbols and capset.has(Cap.ADJ_FACTOR):
try:
emit("sync_index", 88, "同步 ETF 除权因子…")
from datetime import datetime, timedelta
adj_end = datetime.now()
adj_path = repo.store.data_dir / "adj_factor_etf" / "all.parquet"
@@ -361,6 +365,7 @@ def run_now(
end_time=adj_end,
)
etf_adj_symbols = len(affected_etfs)
emit("sync_index", 88, f"ETF 除权因子完成,{etf_adj_symbols}")
except Exception as e: # noqa: BLE001
logger.warning("ETF adj_factor skipped: %s", e)
etf_dir = repo.store.data_dir / "kline_etf_enriched"
@@ -369,12 +374,19 @@ def run_now(
if d.is_dir() and d.name.startswith("date=")
) if etf_dir.exists() else []
etf_start = _date.fromisoformat(etf_dates[-1]) if etf_dates else today - _td(days=365)
def _etf_chunk(cur: int, tot: int) -> None:
emit("sync_index", 88, f"ETF 日K批次 {cur}/{tot}",
stage_pct=int(100 * cur / tot) if tot else 100, skip_log=cur < tot)
written_etf_daily = index_sync.sync_and_persist_etf_daily(
repo,
capset,
start_date=_dt.combine(etf_start, _dt.min.time()),
end_date=_dt.combine(today, _dt.min.time()),
on_chunk_done=_etf_chunk,
)
emit("sync_index", 88, f"ETF 日K完成,{written_etf_daily}")
_invalidate("etf_instruments")
_invalidate("etf_daily")
+2 -1
View File
@@ -98,7 +98,8 @@ async def lifespan(app: FastAPI):
except Exception as e: # noqa: BLE001
logger.warning("内置扩展表初始化失败 (不影响启动): %s", e)
# 财务数据独立调度 (需 Expert 套餐)
# 财务数据 (需 Expert 套餐): 仅初始化调度器供 /api/financials/sync/* 手动同步,
# 不启动自动调度——用户在「财务分析」页点「同步」手动拉取。
from app.services.financial_sync import financial_scheduler
financial_scheduler.start(store.data_dir, capset)
app.state.financial_scheduler = financial_scheduler
+15 -3
View File
@@ -200,13 +200,18 @@ class FinancialScheduler:
# 手动同步(run_now)是否正在进行。前端据此显示"同步中"并防重复点击。
self._is_syncing = False
def start(self, data_dir: Path, capset: CapabilitySet) -> None:
def start(self, data_dir: Path, capset: CapabilitySet, *, auto_schedule: bool = False) -> None:
"""初始化调度器,并按需启动周期同步后台任务。
auto_schedule=False (默认): 仅初始化 (设置数据目录/能力 + 恢复 last_sync),
供 /api/financials/sync/* 手动同步使用, 不启动自动调度。
auto_schedule=True: 额外启动每周一次的 metrics 自动同步 (启动后 60s 首跑)。
"""
if not capset.has(Cap.FINANCIAL):
logger.info("FinancialScheduler skipped: no FINANCIAL capability")
return
self._data_dir = data_dir
self._capset = capset
self._running = True
# 从持久化恢复上次同步时间: 重启后前端仍能显示真实最后同步时间,而非"尚未同步"
try:
from app.services import preferences
@@ -227,8 +232,15 @@ class FinancialScheduler:
logger.info("FinancialScheduler restored last_sync: %s", list(self._last_sync.keys()))
except Exception as e: # noqa: BLE001
logger.warning("restore financial_sync_times failed: %s", e)
if not auto_schedule:
# 仅初始化 (手动同步用), 不启动周期任务。
logger.info("FinancialScheduler initialized (auto-schedule disabled; manual sync only)")
return
self._running = True
self._task = asyncio.create_task(self._run_loop())
logger.info("FinancialScheduler started")
logger.info("FinancialScheduler started (auto-schedule enabled)")
def _record_sync(self, table: str) -> None:
"""记录一张表的同步完成时间: 更新内存 + 持久化到 preferences.json。
+11 -1
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import logging
import gc
from collections.abc import Callable
from datetime import datetime, timedelta
import polars as pl
@@ -194,11 +195,13 @@ def sync_and_persist_index_daily(
start_date: datetime | None = None,
end_date: datetime | None = None,
symbols_override: list[str] | None = None,
on_chunk_done: Callable[[int, int], None] | None = None,
) -> int:
"""同步指数/ETF 日K到独立 parquet,并计算 enriched。
symbols_override 非空时,只拉这些代码(跳过 instruments 表),用于自定义范围。
否则取 index_instruments 表全量(指数+ETF 合并存储)。
on_chunk_done(current, total) 每个批次完成后回调。
"""
if not capset.has(Cap.KLINE_DAILY_BATCH):
return 0
@@ -248,6 +251,8 @@ def sync_and_persist_index_daily(
repo.append_index_enriched(enriched)
total_rows += raw.height
logger.info("index/etf daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height)
if on_chunk_done:
on_chunk_done(i + 1, len(chunks))
del raw, enriched
gc.collect()
repo.refresh_index_views()
@@ -292,8 +297,11 @@ def sync_and_persist_etf_daily(
start_date: datetime | None = None,
end_date: datetime | None = None,
symbols_override: list[str] | None = None,
on_chunk_done: Callable[[int, int], None] | None = None,
) -> int:
"""同步 ETF 日K到独立 kline_etf_* parquet,并计算 ETF enriched。"""
"""同步 ETF 日K到独立 kline_etf_* parquet,并计算 ETF enriched。
on_chunk_done(current, total) 每个批次完成后回调。
"""
if not capset.has(Cap.KLINE_DAILY_BATCH):
return 0
@@ -344,6 +352,8 @@ def sync_and_persist_etf_daily(
repo.append_etf_enriched(enriched)
total_rows += raw.height
logger.info("etf daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height)
if on_chunk_done:
on_chunk_done(i + 1, len(chunks))
del raw, enriched
gc.collect()
repo.refresh_index_views()
+23 -2
View File
@@ -241,8 +241,14 @@ def _normalize_adj_factor(raw) -> pl.DataFrame:
df = pl.from_pandas(raw.reset_index() if hasattr(raw, "reset_index") else raw)
if df.is_empty():
return df
rename_map = {"timestamp": "trade_date", "date": "trade_date", "adj_factor": "ex_factor"}
df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
# rename: timestamp/date → trade_date, adj_factor → ex_factor
# 注意: 新版 SDK 可能同时返回 timestamp 和 trade_date (或 adj_factor 和 ex_factor),
# 直接 rename 会产生重复列报错。仅当目标列不存在时才 rename。
rename_map: dict[str, str] = {}
for src, dst in (("timestamp", "trade_date"), ("date", "trade_date"), ("adj_factor", "ex_factor")):
if src in df.columns and dst not in df.columns:
rename_map[src] = dst
df = df.rename(rename_map)
if "trade_date" in df.columns:
if df.schema["trade_date"] in {pl.Int64, pl.Int32, pl.UInt64, pl.UInt32, pl.Float64, pl.Float32}:
df = df.with_columns(
@@ -481,6 +487,21 @@ def fetch_minute_single(symbol: str, trade_date: date) -> pl.DataFrame:
return pl.DataFrame()
def fetch_adj_factor_single(symbol: str) -> pl.DataFrame:
"""从 TickFlow 实时拉取单股除权因子(不写入本地), 用于单股 K 线即时前复权。
返回结构: symbol, trade_date, ex_factor (空 DataFrame 表示无除权事件或拉取失败)。
与 _apply_adj_factor / compute_enriched 的 factors 参数格式一致。
"""
tf = get_client()
try:
raw = tf.klines.ex_factors([symbol], as_dataframe=True, show_progress=False)
except Exception as e: # noqa: BLE001
logger.warning("fetch_adj_factor_single(%s) failed: %s", symbol, e)
return pl.DataFrame()
return _normalize_adj_factor(raw)
def _latest_minute_datetime(repo: KlineRepository) -> datetime | None:
"""本地分钟 K 数据的最新时间。"""
try:
+37
View File
@@ -397,6 +397,43 @@ def set_system_notify_enabled(enabled: bool) -> bool:
return bool(enabled)
def get_feishu_webhook_url() -> str:
"""飞书自定义机器人 Webhook 地址 — 全局共用一处, 所有启用推送的规则都推到这一个群。"""
return load().get("feishu_webhook_url", "")
def get_feishu_webhook_secret() -> str:
"""飞书自定义机器人签名密钥 — 机器人启用「签名校验」时必填, 留空表示不验签。"""
return load().get("feishu_webhook_secret", "")
def set_feishu_webhook_url(url: str) -> str:
"""保存飞书 Webhook 地址。传入空串表示清空配置。"""
save({"feishu_webhook_url": str(url or "").strip()})
return get_feishu_webhook_url()
def set_feishu_webhook_secret(secret: str) -> str:
"""保存飞书签名密钥。传入空串表示不验签。"""
save({"feishu_webhook_secret": str(secret or "").strip()})
return get_feishu_webhook_secret()
def get_webhook_enabled_default() -> bool:
"""新建监控规则时是否默认勾选「飞书推送」。
数据模型当前只有一个 webhook_enabled 布尔 (即飞书), QMT/ptrade 待定。
此默认值供规则编辑器新建规则时预填, 单条规则仍可独立修改。
"""
return load().get("webhook_enabled_default", False)
def set_webhook_enabled_default(enabled: bool) -> bool:
"""保存飞书推送默认勾选态。"""
save({"webhook_enabled_default": bool(enabled)})
return get_webhook_enabled_default()
def get_screener_auto_run() -> bool:
"""选股页进入时是否自动运行所有策略 (获取命中数)。默认开。"""
return load().get("screener_auto_run", True)
+54
View File
@@ -635,6 +635,8 @@ class QuoteService:
return
all_alerts: list[dict] = []
rule_events: list[dict] = []
engine = None
# 通用监控规则评估 (统一引擎: signal/price/market/strategy)
if self._app_state:
@@ -675,6 +677,8 @@ class QuoteService:
"change_pct": ev["change_pct"],
"signals": ev["signals"],
"severity": ev.get("severity", "info"),
"conditions": ev.get("conditions") or [],
"logic": ev.get("logic") or "and",
})
# Free 自选实时只刷新少量标的, 不写全市场策略缓存。
@@ -696,9 +700,59 @@ class QuoteService:
# cooldown 去重已在 MonitorRuleEngine 做过, 这里只负责转发。
self._maybe_send_system_notifications(all_alerts)
# Webhook 推送 (飞书等外部 IM, 由规则 webhook_enabled 开关控制)。
# 紧随系统通知, 同样静默降级不阻断主流程。
if rule_events:
self._maybe_send_webhook(rule_events, engine)
except Exception as e: # noqa: BLE001
logger.warning("监控评估失败: %s", e)
def _maybe_send_webhook(self, rule_events: list[dict], engine) -> None:
"""把告警通过 Webhook 推送到外部 IM (由规则 webhook_enabled 开关控制)。
- 全局飞书 URL 未配置: 直接返回
- 仅推送 webhook_enabled=True 的规则触发的告警
- 失败静默, 不阻断主流程
- 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重
注意: 用 rule_events (含 rule_id) 而非重建后的 all_alerts,
以便反查引擎规则判断是否启用推送。
"""
try:
from app.services import preferences
from app.services import webhook_adapter
url = preferences.get_feishu_webhook_url()
if not url:
return
secret = preferences.get_feishu_webhook_secret()
# 反查规则, 过滤出启用推送的事件
source_labels = {
"strategy": "策略", "signal": "信号",
"price": "价格", "market": "异动",
}
rules = engine.rules if engine is not None else {}
pushed = 0
for ev in rule_events:
rule = rules.get(ev.get("rule_id"))
if not rule or not rule.get("webhook_enabled"):
continue
source = ev.get("source", "")
source_label = source_labels.get(source, source or "通知")
symbol = ev.get("symbol") or ""
name = ev.get("name") or ""
message = ev.get("message") or ""
title = f"TickFlow · {source_label}"
body = f"{symbol} {name} {message}".strip() if symbol else (message or name)
if webhook_adapter.send_feishu(url, title, body, secret):
pushed += 1
if pushed:
logger.info("飞书 Webhook 推送: %d", pushed)
except Exception as e: # noqa: BLE001
logger.debug("Webhook 推送异常 (不影响告警主流程): %s", e)
def _maybe_send_system_notifications(self, all_alerts: list[dict]) -> None:
"""把告警转发到操作系统通知中心 (由 preferences 开关控制)。
+106
View File
@@ -0,0 +1,106 @@
"""Webhook 推送适配器 — 把告警事件推送到外部 IM / 量化软件。
职责: 把后端产生的告警事件, 通过用户配置的 Webhook 地址推送到外部。
目前支持飞书群机器人; QMT / ptrade 等量化通道为待定。
飞书自定义机器人接入:
1. 飞书群 → 群设置 → 群机器人 → 添加「自定义机器人」
2. 复制生成的 Webhook 地址 (形如 https://open.feishu.cn/open-apis/bot/v2/hook/xxx)
3. (可选) 安全设置 → 启用「签名校验」, 记录签名密钥(secret)
4. 填入设置页「飞书 Webhook」配置
设计: 失败静默降级, 绝不因推送失败阻断告警主流程 (落盘 / SSE 推送)。
去重不在本层做, 复用 MonitorRuleEngine 的 cooldown。
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import logging
import time
logger = logging.getLogger(__name__)
# 单次推送最长字符 (飞书单条文本消息上限 30KB, 这里保守截断避免刷屏)
_MAX_LEN = 500
# 飞书自定义机器人 Webhook 前缀 (用于 URL 合法性校验)
FEISHU_HOOK_PREFIX = "https://open.feishu.cn/open-apis/bot/v2/hook/"
def _truncate(text: str) -> str:
"""截断超长文本。"""
text = (text or "").strip()
return text[:_MAX_LEN] + ("" if len(text) > _MAX_LEN else "")
def is_valid_feishu_url(url: str) -> bool:
"""校验是否为合法的飞书自定义机器人 Webhook 地址。"""
return bool(url) and url.startswith(FEISHU_HOOK_PREFIX)
def _gen_sign(timestamp: str, secret: str) -> str:
"""计算飞书自定义机器人签名。
算法 (官方): 把 `timestamp + "\\n" + secret` 作为签名字符串 (key),
用 HmacSHA256 计算空字符串的签名结果, 再 Base64 编码。
"""
string_to_sign = f"{timestamp}\n{secret}"
hmac_code = hmac.new(
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
return base64.b64encode(hmac_code).decode("utf-8")
def send_feishu(webhook_url: str, title: str, body: str, secret: str = "") -> bool:
"""推送一条文本消息到飞书群机器人。
Args:
webhook_url: 飞书自定义机器人 Webhook 地址
title: 消息标题 (与正文拼接为一条文本)
body: 消息正文
secret: 签名密钥 (机器人启用了「签名校验」时必填; 留空则不带签名)
Returns:
True=成功送达, False=失败或 URL 非法。
失败静默, 不抛异常 (Webhook 是辅助通道, 不能阻断告警主流程)。
"""
if not is_valid_feishu_url(webhook_url):
return False
text = _truncate(f"{title}\n{body}".strip())
if not text:
return False
try:
import httpx
payload: dict = {"msg_type": "text", "content": {"text": text}}
# 启用签名校验时, 请求体须带 timestamp + sign (秒级时间戳)
if secret:
timestamp = str(int(time.time()))
payload["timestamp"] = timestamp
payload["sign"] = _gen_sign(timestamp, secret)
resp = httpx.post(webhook_url, json=payload, timeout=5.0)
# 飞书成功响应: {"code":0,"msg":"success"} (或 StatusCode 200 + Extra)
if resp.status_code == 200:
try:
data = resp.json()
# code=0 表示飞书业务侧成功; 部分版本无 code 字段则按 msg 判断
if isinstance(data, dict):
code = data.get("code", data.get("StatusCode", 0))
if code == 0:
return True
logger.debug("飞书推送业务失败: %s", data)
return False
except ValueError:
# 非 JSON 响应但 HTTP 200, 视为成功
return True
logger.debug("飞书推送 HTTP %s: %s", resp.status_code, resp.text[:200])
return False
except Exception as e: # noqa: BLE001
logger.debug("飞书 Webhook 推送失败: %s", e)
return False
+4
View File
@@ -410,6 +410,10 @@ class MonitorRuleEngine:
"change_pct": pct,
"signals": hit_sigs,
"severity": severity,
# 触发条件快照 (signal/price/market 类型): 用于触发记录展示
# 「命中了什么条件」。strategy 类型靠策略选股池 diff, 不写条件。
"conditions": list(rule.get("conditions", [])) if rtype != "strategy" else [],
"logic": rule.get("logic", "and") if rtype != "strategy" else "and",
}
events.append(ev)
if self._alert_handler:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "tickflow-stock-panel-backend"
version = "0.1.64"
version = "0.1.65"
description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配"
readme = "../README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -2491,7 +2491,7 @@ all = [
[[package]]
name = "tickflow-stock-panel-backend"
version = "0.1.63"
version = "0.1.64"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tickflow-stock-panel-frontend",
"private": true,
"version": "0.1.64",
"version": "0.1.65",
"type": "module",
"scripts": {
"dev": "vite",
@@ -1,5 +1,22 @@
import { useState } from 'react'
import { Check } from 'lucide-react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Check, GripVertical } from 'lucide-react'
import { storage } from '@/lib/storage'
export type CardKey =
@@ -12,20 +29,26 @@ interface CardDef {
desc: string
/** 档位能力不足时该卡片是否默认隐藏(减少干扰) */
defaultHiddenIfNoCap: boolean
/** 无条件默认隐藏(用户可在设置里手动开启) */
defaultHidden?: boolean
}
/** 数据画像卡片定义 —— 顺序即弹窗展示顺序 */
/** 数据画像卡片定义 —— 默认顺序即此数组顺序 */
export const DATA_CARD_DEFS: CardDef[] = [
{ key: 'instruments', label: '个股维表', desc: 'A 股股票元数据', defaultHiddenIfNoCap: false },
{ key: 'daily', label: '日 K', desc: 'A 股日K线数据', defaultHiddenIfNoCap: false },
{ key: 'adj_factor', label: '除权因子', desc: '复权计算因子', defaultHiddenIfNoCap: true },
{ key: 'enriched', label: 'Enriched', desc: '技术指标计算结果', defaultHiddenIfNoCap: false },
{ key: 'index', label: '指数', desc: '主要市场指数日K', defaultHiddenIfNoCap: false },
{ key: 'etf', label: 'ETF', desc: '场内交易基金日K', defaultHiddenIfNoCap: false },
{ key: 'adj_factor', label: '除权因子', desc: '复权计算因子', defaultHiddenIfNoCap: true },
{ key: 'etf', label: 'ETF', desc: '场内交易基金日K', defaultHiddenIfNoCap: false, defaultHidden: true },
{ key: 'minute', label: '分钟 K', desc: '分钟级K线(需 Pro+)', defaultHiddenIfNoCap: true },
{ key: 'financials', label: '财务数据', desc: '财报数据(需 Expert)', defaultHiddenIfNoCap: true },
]
const DEFAULT_ORDER = DATA_CARD_DEFS.map(d => d.key)
/** 恢复默认时显示的卡片数量(按默认顺序取前 N 张) */
const DEFAULT_VISIBLE_COUNT = 5
const CAP_KEY_MAP: Partial<Record<CardKey, string>> = {
adj_factor: 'adj_factor',
minute: 'kline.minute.batch',
@@ -35,6 +58,7 @@ const CAP_KEY_MAP: Partial<Record<CardKey, string>> = {
/**
* 读取卡片显隐状态。结合档位能力决定默认值:
* - 用户显式设置过 → 用设置值
* - 未设置 + defaultHidden → 隐藏(无条件默认隐藏)
* - 未设置 + defaultHiddenIfNoCap + 当前无能力 → 隐藏
* - 其他 → 显示
*/
@@ -47,6 +71,8 @@ export function getCardVisibility(
for (const def of DATA_CARD_DEFS) {
if (def.key in override) {
result[def.key] = override[def.key]
} else if (def.defaultHidden) {
result[def.key] = false
} else {
result[def.key] = def.defaultHiddenIfNoCap ? has(CAP_KEY_MAP[def.key] ?? '') : true
}
@@ -54,60 +80,102 @@ export function getCardVisibility(
return result
}
/**
* 读取卡片显示顺序。
* - 用户拖拽设置过 → 用设置值(过滤掉已不存在的 key, 补齐新增的 key)
* - 未设置 → 用 DATA_CARD_DEFS 默认顺序
*/
export function getCardOrder(): CardKey[] {
const saved = storage.dataCardOrder.get([])
if (!saved.length) return [...DEFAULT_ORDER]
const known = new Set<CardKey>(DEFAULT_ORDER)
const ordered = saved.filter(k => known.has(k as CardKey)) as CardKey[]
// 补齐新增的 key(默认顺序里新增的卡片追加到末尾)
for (const k of DEFAULT_ORDER) {
if (!ordered.includes(k)) ordered.push(k)
}
return ordered
}
export function PageSettingsModal({
caps,
}: {
caps: Record<string, unknown> | undefined
}) {
const [visible, setVisible] = useState<Record<string, boolean>>(() => getCardVisibility(caps))
const [order, setOrder] = useState<CardKey[]>(() => getCardOrder())
const toggle = (key: CardKey) => {
const next = { ...visible, [key]: !visible[key] }
const persistVisible = (next: Record<string, boolean>) => {
setVisible(next)
storage.dataCardVisible.set(next)
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
}
const reset = () => {
storage.dataCardVisible.set({})
setVisible(getCardVisibility(caps))
const persistOrder = (next: CardKey[]) => {
setOrder(next)
storage.dataCardOrder.set(next)
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
}
const toggle = (key: CardKey) => persistVisible({ ...visible, [key]: !(visible[key] ?? true) })
const reset = () => {
// 恢复默认: 默认顺序 + 仅勾选前 5 张卡片, 其余隐藏
const defaultOrder = [...DEFAULT_ORDER]
const defaultVisible: Record<string, boolean> = {}
defaultOrder.forEach((k, i) => { defaultVisible[k] = i < DEFAULT_VISIBLE_COUNT })
storage.dataCardVisible.set(defaultVisible)
storage.dataCardOrder.set(defaultOrder)
setVisible(defaultVisible)
setOrder(defaultOrder)
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
}
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIdx = order.indexOf(active.id as CardKey)
const newIdx = order.indexOf(over.id as CardKey)
if (oldIdx < 0 || newIdx < 0) return
persistOrder(arrayMove(order, oldIdx, newIdx))
}
// 按 order 排序卡片定义
const defByKey = new Map(DATA_CARD_DEFS.map(d => [d.key, d]))
const orderedDefs = order.map(k => defByKey.get(k)!).filter(Boolean)
return (
<div className="space-y-2.5">
<p className="text-xs text-secondary leading-relaxed">
,
,,
</p>
<div className="space-y-1.5">
{DATA_CARD_DEFS.map((def) => {
const on = visible[def.key] ?? true
return (
<label
key={def.key}
className={`flex items-center gap-2.5 rounded-card border px-3 py-2 cursor-pointer transition-colors ${
on ? 'border-accent/40 bg-accent/[0.05]' : 'border-border bg-base/30 hover:border-border/70'
}`}
>
<button
type="button"
onClick={() => toggle(def.key)}
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
on ? 'bg-accent border-accent' : 'bg-base border-border'
}`}
role="checkbox"
aria-checked={on}
>
{on && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
</button>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-foreground">{def.label}</div>
<div className="text-[10px] text-muted leading-snug">{def.desc}</div>
</div>
</label>
)
})}
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={order} strategy={verticalListSortingStrategy}>
<div className="space-y-1.5">
{orderedDefs.map((def) => {
const on = visible[def.key] ?? true
return (
<SortableCardRow
key={def.key}
id={def.key}
label={def.label}
desc={def.desc}
on={on}
onToggle={() => toggle(def.key)}
/>
)
})}
</div>
</SortableContext>
</DndContext>
<div className="flex items-center justify-end pt-1">
<button
onClick={reset}
@@ -119,3 +187,67 @@ export function PageSettingsModal({
</div>
)
}
// ── 可拖拽的卡片行 ──
function SortableCardRow({
id, label, desc, on, onToggle,
}: {
id: CardKey
label: string
desc: string
on: boolean
onToggle: () => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
zIndex: isDragging ? 10 : undefined,
}
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-2 rounded-card border px-3 py-2 transition-colors ${
isDragging ? 'bg-elevated shadow-lg' : ''
} ${on ? 'border-accent/40 bg-accent/[0.05]' : 'border-border bg-base/30'}`}
>
{/* 拖拽手柄 */}
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-muted hover:text-foreground transition-colors shrink-0"
title="拖动排序"
>
<GripVertical className="h-4 w-4" />
</button>
{/* 显隐勾选 */}
<button
type="button"
onClick={onToggle}
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
on ? 'bg-accent border-accent' : 'bg-base border-border'
}`}
role="checkbox"
aria-checked={on}
>
{on && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
</button>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-foreground">{label}</div>
<div className="text-[10px] text-muted leading-snug">{desc}</div>
</div>
</div>
)
}
+58 -12
View File
@@ -1,9 +1,11 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Save, X, Plus, Search } from 'lucide-react'
import { api, genRuleId, type MonitorRule, type MonitorCondition } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { SignalPicker } from '@/components/screener/SignalPicker'
import { usePreferences } from '@/lib/useSharedQueries'
interface Props {
/** 编辑现有规则;null=新建 */
@@ -42,9 +44,15 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const qc = useQueryClient()
const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions })
const strategies = useQuery({ queryKey: QK.screenerStrategies, queryFn: api.screenerStrategies })
const { data: prefs } = usePreferences()
const feishuConfigured = !!(prefs?.feishu_webhook_url)
const [editing] = useState(!!rule)
// 新建规则: 预填全局「默认推送渠道」(飞书), preset 显式指定时以 preset 为准。
// 编辑规则: 完全沿用规则自身配置, 不受默认值影响。
const [draft, setDraft] = useState<MonitorRule>(
rule ? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) } : emptyRule(preset),
rule
? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) }
: { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!(prefs?.webhook_enabled_default) },
)
const [error, setError] = useState('')
const [symbolQuery, setSymbolQuery] = useState('')
@@ -368,21 +376,59 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
</label>
</div>
{/* Webhook 推送 (占位, 后续开发) */}
{/* Webhook 推送 — 飞书可用, QMT/ptrade 待定 */}
<div className="rounded-btn border border-border/40 bg-base/40 p-3 space-y-2">
<div className="flex items-center justify-between">
<div>
<span className="text-[11px] font-medium text-foreground">Webhook </span>
<span className="ml-1.5 rounded bg-muted/10 px-1 py-px text-[9px] text-muted"></span>
</div>
<label className="flex items-center gap-1.5 cursor-not-allowed opacity-50">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-foreground">Webhook </span>
<span className="text-[9px] text-muted"></span>
</div>
{/* 渠道列表 */}
<div className="space-y-1.5">
{/* 飞书 (可用) */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={!!draft.webhook_enabled}
onChange={e => setDraft(d => ({ ...d, webhook_enabled: e.target.checked }))}
className="h-3 w-3 accent-accent cursor-pointer"
/>
<span className="text-[11px] text-foreground"></span>
<span className="text-[9px] text-muted"></span>
{draft.webhook_enabled && (
<span className={`ml-auto text-[9px] ${feishuConfigured ? 'text-emerald-500' : 'text-warning'}`}>
{feishuConfigured ? '已配置' : '未配置'}
</span>
)}
</label>
{/* QMT (待定) */}
<label className="flex items-center gap-2 cursor-not-allowed opacity-50">
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
<span className="text-[10px] text-muted"></span>
<span className="text-[11px] text-secondary">QMT</span>
<span className="rounded bg-muted/10 px-1 py-px text-[9px] text-muted"></span>
</label>
{/* ptrade (待定) */}
<label className="flex items-center gap-2 cursor-not-allowed opacity-50">
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
<span className="text-[11px] text-secondary">ptrade</span>
<span className="rounded bg-muted/10 px-1 py-px text-[9px] text-muted"></span>
</label>
</div>
<p className="text-[10px] leading-relaxed text-muted">
( QMT),
</p>
{/* 飞书勾选但全局未配置 → 提示前往设置 */}
{draft.webhook_enabled && !feishuConfigured && (
<p className="text-[10px] leading-relaxed text-warning/80">
Webhook ,
<Link to="/settings?tab=monitoring" className="text-accent hover:text-accent/80"> </Link>
</p>
)}
{draft.webhook_enabled && feishuConfigured && (
<p className="text-[10px] leading-relaxed text-muted">
,
</p>
)}
</div>
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
+17
View File
@@ -357,6 +357,7 @@ export interface StrategyDetail {
entry_signals: string[]
exit_signals: string[]
stop_loss: number | null
take_profit: number | null
trailing_stop: number | null
trailing_take_profit_activate: number | null
trailing_take_profit_drawdown: number | null
@@ -442,6 +443,8 @@ export interface AlertEvent {
signals?: string[]
severity?: string
strategy_id?: string
conditions?: MonitorCondition[]
logic?: 'and' | 'or'
}
/** 生成监控规则 id (时间戳 + 随机后缀), 用户无需手动填写。 */
@@ -583,6 +586,7 @@ export interface StrategyBacktestResult {
entry_signals: string[]
exit_signals: string[]
stop_loss: number | null
take_profit: number | null
trailing_stop: number | null
trailing_take_profit_activate: number | null
trailing_take_profit_drawdown: number | null
@@ -682,6 +686,9 @@ export interface Preferences {
strategy_monitor_enabled: boolean
strategy_monitor_ids: string[]
system_notify_enabled: boolean
feishu_webhook_url?: string
feishu_webhook_secret?: string
webhook_enabled_default?: boolean
sidebar_index_symbols: string[]
nav_order: string[]
nav_hidden: string[]
@@ -838,6 +845,16 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ enabled }),
}),
updateFeishuWebhook: (url: string, secret: string = '') =>
request<{ feishu_webhook_url: string; feishu_webhook_secret: string }>('/api/settings/preferences/feishu-webhook', {
method: 'PUT',
body: JSON.stringify({ url, secret }),
}),
updateWebhookDefault: (enabled: boolean) =>
request<{ webhook_enabled_default: boolean }>('/api/settings/preferences/webhook-enabled-default', {
method: 'PUT',
body: JSON.stringify({ enabled }),
}),
updatePipelineSchedule: (hour: number, minute: number) =>
request<{ hour: number; minute: number }>('/api/settings/preferences/pipeline-schedule', {
method: 'PUT',
+2
View File
@@ -110,4 +110,6 @@ export const storage = {
/** 数据页画像卡片显隐 (卡片key → 是否显示) */
dataCardVisible: kv<Record<string, boolean>>('data-card-visible'),
/** 数据页画像卡片顺序 (卡片key 数组, 长度=卡片总数) */
dataCardOrder: kv<string[]>('data-card-order'),
} as const
+16 -1
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Database, Flame, Gauge, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Database, Flame, Gauge, Info, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
import { DatePicker } from '@/components/DatePicker'
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -583,6 +583,9 @@ export function Dashboard() {
const latestDate = dataStatus.data?.enriched?.latest_date ?? null
const currentDate = selectedDate ?? data.as_of ?? ''
const quoteRunning = (!selectedDate || selectedDate === latestDate) && data.quote_status?.running
// 实时模式: none / watchlist / full_market。
// watchlist (Free 档) 仅自选 ≤5 只实时, 看板呈现的大盘数据实为盘后快照, 需提示避免误读。
const quoteMode = data.quote_status?.mode as ('none' | 'watchlist' | 'full_market') | undefined
return (
<div className="min-h-full bg-base p-3">
@@ -650,6 +653,18 @@ export function Dashboard() {
</div>
</div>
{/* Free 档提示: 大盘看板为盘后数据, 仅自选股实时。避免用户误读为全市场实时。 */}
{quoteMode === 'watchlist' && (
<div className="mb-3 flex items-start gap-2 rounded-card border border-amber-500/30 bg-amber-500/8 px-3 py-2 text-[11px] leading-relaxed">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<div className="min-w-0 flex-1 text-secondary">
,<strong className="text-foreground"></strong>(),;
({data.quote_status?.watchlist_symbol_count ?? 0} )
<span className="ml-1 text-accent"> Starter+</span>
</div>
</div>
)}
<div className="mb-3 grid grid-cols-4 gap-2">
{data.indices.map(item => <IndexTicker key={item.symbol} item={item} />)}
</div>
+170 -151
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import {
@@ -42,7 +42,7 @@ import { ExtendHistoryPanel } from '@/components/data/ExtendHistoryPanel'
import { EnrichedRebuildPanel } from '@/components/data/EnrichedRebuildPanel'
import { MinuteSyncConfig } from '@/components/data/MinuteSyncConfig'
import { PipelineScopeConfig } from '@/components/data/PipelineScopeConfig'
import { PageSettingsModal, getCardVisibility } from '@/components/data/PageSettingsModal'
import { PageSettingsModal, getCardVisibility, getCardOrder, type CardKey } from '@/components/data/PageSettingsModal'
import { QuoteConfigCard } from '@/components/data/QuoteConfigCard'
import { EnrichedSchemaModal } from '@/components/data/SchemaModal'
import { Skeleton } from '@/components/data/Skeleton'
@@ -317,6 +317,171 @@ export function Data() {
})
}, [])
// 按卡片 key 渲染对应的 StatCard (顺序由 getCardOrder 控制, 显隐由 cardVisible 控制)
const renderStatCard = (k: CardKey): React.ReactNode => {
switch (k) {
case 'instruments':
return (
<StatCard
title="个股维表"
hint="盘前同步 · 元数据快照"
stats={s?.instruments}
isInstrument
loading={isLoading}
active={activeCard === 'instruments'}
done={doneStages.has('instruments')}
skipped={skippedCards.has('instruments')}
stagePct={activeCard === 'instruments' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="instruments"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('instruments')}
/>
)
case 'daily':
return (
<StatCard
title="日 K"
hint="增量同步 · 全市场"
stats={s?.daily}
loading={isLoading}
active={activeCard === 'daily'}
done={doneStages.has('daily')}
skipped={skippedCards.has('daily')}
stagePct={activeCard === 'daily' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="daily"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('daily')}
onSettings={hasData ? () => setOpenSettings(v => v === 'daily' ? null : 'daily') : undefined}
settingsOpen={openSettings === 'daily'}
/>
)
case 'adj_factor':
return (
<StatCard
title="除权因子"
hint="增量同步 · 全市场"
stats={s?.adj_factor}
loading={isLoading}
active={activeCard === 'adj_factor'}
done={doneStages.has('adj_factor')}
skipped={skippedCards.has('adj_factor')}
stagePct={activeCard === 'adj_factor' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="adj_factor"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('adj_factor')}
/>
)
case 'enriched':
return (
<StatCard
title="Enriched"
hint="复权 OHLCV + 技术指标"
stats={s?.enriched}
loading={isLoading}
active={activeCard === 'enriched'}
done={doneStages.has('enriched')}
skipped={skippedCards.has('enriched')}
stagePct={activeCard === 'enriched' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="enriched"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
subLabel="字段 · 指标 · 信号"
localBadgeSuffix={`${prefs.data?.enriched_batch_size ?? 1000}只/批`}
onShowFields={() => setSchemaTable('enriched')}
onSettings={hasData ? () => setOpenSettings(v => v === 'enriched' ? null : 'enriched') : undefined}
settingsOpen={openSettings === 'enriched'}
/>
)
case 'index':
return (
<StatCard
title="指数"
hint="CN_Index · 独立存储"
stats={indexOverviewStats}
loading={isLoading}
active={activeCard === 'index_daily'}
done={doneStages.has('index_daily')}
skipped={skippedCards.has('index_daily')}
stagePct={activeCard === 'index_daily' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="daily"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={indexAuto}
subLabel={indexOverviewLabel}
fieldTabs={[
{ label: '维表', table: 'index_instruments' },
{ label: '日K', table: 'index_daily' },
{ label: '指标', table: 'index_enriched' },
] as FieldTab[]}
onShowFields={(t) => setSchemaTable(t ?? 'index_daily')}
onSettings={hasData ? () => setOpenSettings(v => v === 'index' ? null : 'index') : undefined}
settingsOpen={openSettings === 'index'}
/>
)
case 'etf':
return (
<StatCard
title="ETF"
hint="场内基金 · 独立存储"
stats={etfOverviewStats}
loading={isLoading}
tierKey="etf"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={etfAuto}
subLabel="维表 · 日K · 指标"
fieldTabs={[
{ label: '维表', table: 'etf_instruments' },
{ label: '日K', table: 'etf_daily' },
{ label: '指标', table: 'etf_enriched' },
] as FieldTab[]}
onShowFields={(t) => setSchemaTable(t ?? 'etf_daily')}
/>
)
case 'minute':
return (
<StatCard
title="分钟 K"
hint="全市场同步"
stats={s?.minute}
loading={isLoading}
active={activeCard === 'minute'}
done={doneStages.has('minute')}
skipped={skippedCards.has('minute')}
stagePct={activeCard === 'minute' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="minute"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={minuteAuto}
onShowFields={() => setSchemaTable('minute')}
onSettings={hasData ? () => setOpenSettings(v => v === 'minute' ? null : 'minute') : undefined}
settingsOpen={openSettings === 'minute'}
/>
)
case 'financials':
return (
<StatCard
title="财务数据"
hint="利润表 / 资负表 / 现金流 / 指标"
stats={s?.financials ? { rows: s.financials.rows } : null}
loading={isLoading}
tierKey="financials"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
/>
)
default:
return null
}
}
return (
<>
<div ref={topRef} />
@@ -620,155 +785,9 @@ export function Data() {
<div>
<SectionTitle icon={Database}></SectionTitle>
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 items-stretch">
{cardVisible.instruments && (
<StatCard
title="个股维表"
hint="盘前同步 · 元数据快照"
stats={s?.instruments}
isInstrument
loading={isLoading}
active={activeCard === 'instruments'}
done={doneStages.has('instruments')}
skipped={skippedCards.has('instruments')}
stagePct={activeCard === 'instruments' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="instruments"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('instruments')}
/>
)}
{cardVisible.daily && (
<StatCard
title="日 K"
hint="增量同步 · 全市场"
stats={s?.daily}
loading={isLoading}
active={activeCard === 'daily'}
done={doneStages.has('daily')}
skipped={skippedCards.has('daily')}
stagePct={activeCard === 'daily' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="daily"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('daily')}
onSettings={hasData ? () => setOpenSettings(v => v === 'daily' ? null : 'daily') : undefined}
settingsOpen={openSettings === 'daily'}
/>
)}
{cardVisible.enriched && (
<StatCard
title="Enriched"
hint="复权 OHLCV + 技术指标"
stats={s?.enriched}
loading={isLoading}
active={activeCard === 'enriched'}
done={doneStages.has('enriched')}
skipped={skippedCards.has('enriched')}
stagePct={activeCard === 'enriched' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="enriched"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
subLabel="字段 · 指标 · 信号"
localBadgeSuffix={`${prefs.data?.enriched_batch_size ?? 1000}只/批`}
onShowFields={() => setSchemaTable('enriched')}
onSettings={hasData ? () => setOpenSettings(v => v === 'enriched' ? null : 'enriched') : undefined}
settingsOpen={openSettings === 'enriched'}
/>
)}
{cardVisible.index && (
<StatCard
title="指数"
hint="CN_Index · 独立存储"
stats={indexOverviewStats}
loading={isLoading}
active={activeCard === 'index_daily'}
done={doneStages.has('index_daily')}
skipped={skippedCards.has('index_daily')}
stagePct={activeCard === 'index_daily' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="daily"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={indexAuto}
subLabel={indexOverviewLabel}
fieldTabs={[
{ label: '维表', table: 'index_instruments' },
{ label: '日K', table: 'index_daily' },
{ label: '指标', table: 'index_enriched' },
] as FieldTab[]}
onShowFields={(t) => setSchemaTable(t ?? 'index_daily')}
onSettings={hasData ? () => setOpenSettings(v => v === 'index' ? null : 'index') : undefined}
settingsOpen={openSettings === 'index'}
/>
)}
{cardVisible.etf && (
<StatCard
title="ETF"
hint="场内基金 · 独立存储"
stats={etfOverviewStats}
loading={isLoading}
tierKey="etf"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={etfAuto}
subLabel="维表 · 日K · 指标"
fieldTabs={[
{ label: '维表', table: 'etf_instruments' },
{ label: '日K', table: 'etf_daily' },
{ label: '指标', table: 'etf_enriched' },
] as FieldTab[]}
onShowFields={(t) => setSchemaTable(t ?? 'etf_daily')}
/>
)}
{cardVisible.adj_factor && (
<StatCard
title="除权因子"
hint="增量同步 · 全市场"
stats={s?.adj_factor}
loading={isLoading}
active={activeCard === 'adj_factor'}
done={doneStages.has('adj_factor')}
skipped={skippedCards.has('adj_factor')}
stagePct={activeCard === 'adj_factor' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="adj_factor"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('adj_factor')}
/>
)}
{cardVisible.minute && (
<StatCard
title="分钟 K"
hint="全市场同步"
stats={s?.minute}
loading={isLoading}
active={activeCard === 'minute'}
done={doneStages.has('minute')}
skipped={skippedCards.has('minute')}
stagePct={activeCard === 'minute' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="minute"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={minuteAuto}
onShowFields={() => setSchemaTable('minute')}
onSettings={hasData ? () => setOpenSettings(v => v === 'minute' ? null : 'minute') : undefined}
settingsOpen={openSettings === 'minute'}
/>
)}
{cardVisible.financials && (
<StatCard
title="财务数据"
hint="利润表 / 资负表 / 现金流 / 指标"
stats={s?.financials ? { rows: s.financials.rows } : null}
loading={isLoading}
tierKey="financials"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
/>
)}
{getCardOrder().filter(k => cardVisible[k]).map((k: CardKey) => (
<Fragment key={k}>{renderStatCard(k)}</Fragment>
))}
{(extConfigs.data?.items ?? []).map((ext) => (
<ExtDataStatCard
key={ext.id}
+1 -1
View File
@@ -181,7 +181,7 @@ export function Financials() {
}
/>
<div className="w-full max-w-none px-8 py-6 space-y-6">
<div className="px-8 py-6 space-y-6 max-w-7xl">
{syncing && (
<div className="flex items-center gap-2 rounded-card border border-accent/30 bg-accent/[0.06] px-3 py-2 text-xs text-accent">
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
+28 -4
View File
@@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion'
import { RadioTower, Plus, Trash2, Settings2, Zap, Bell, ListChecks, BellRing, TrendingUp, TrendingDown, Flame } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { api, type MonitorRule, type AlertEvent } from '@/lib/api'
import { api, type MonitorRule, type AlertEvent, type MonitorCondition } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtPrice, fmtPct } from '@/lib/format'
import { cn } from '@/lib/cn'
@@ -367,9 +367,33 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
})()}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px]">{renderMessage(ev.source, ev.message)}</span>
</div>
{/* 详情行: 命中条件 (signal/price/market) + 当前价 / 或默认消息 */}
{(ev.conditions && ev.conditions.length > 0) ? (
<div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px]">
<span className="text-muted"></span>
{ev.conditions.map((c: MonitorCondition, ci: number) => (
<span key={ci} className="inline-flex items-center gap-0.5">
{ci > 0 && <span className="text-secondary">{ev.logic === 'or' ? '或' : '且'}</span>}
{c.op === 'truth' ? (
<span className="text-accent/80">{cnSignal(c.field)}</span>
) : (
<span className="text-foreground/80 font-mono">{cnSignal(c.field)}{c.op}{c.value}</span>
)}
</span>
))}
{ev.price != null && (
<>
<span className="text-muted">·</span>
<span className="text-muted"></span>
<span className="font-mono text-foreground/90">{fmtPrice(ev.price)}</span>
</>
)}
</div>
) : (
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px]">{renderMessage(ev.source, ev.message)}</span>
</div>
)}
{ev.signals && ev.signals.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{ev.signals.map((s: string, j: number) => (
+1 -1
View File
@@ -90,7 +90,7 @@ export function StockAnalysis() {
}
/>
<div className="w-full max-w-none px-8 py-6 space-y-6">
<div className="px-8 py-6 space-y-6 max-w-7xl">
{/* 搜索栏 */}
<div className="flex items-center gap-3">
<div className="w-72">
+102 -33
View File
@@ -1,7 +1,7 @@
import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap } from 'lucide-react'
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus } from 'lucide-react'
import {
api,
type StrategyBacktestResult,
@@ -154,6 +154,7 @@ const buildDefaultOverrides = (detail: StrategyDetail) => ({
exit_signals: detail.exit_signals.map(toSignalId),
scoring: { ...detail.scoring },
stop_loss: detail.stop_loss,
take_profit: detail.take_profit,
trailing_stop: detail.trailing_stop,
trailing_take_profit_activate: detail.trailing_take_profit_activate,
trailing_take_profit_drawdown: detail.trailing_take_profit_drawdown,
@@ -192,6 +193,7 @@ function ExitReasonBadge({ reason }: { reason: string }) {
const config: Record<string, { label: string; cls: string }> = {
signal: { label: '信号', cls: 'bg-accent/10 text-accent border-accent/30' },
stop_loss: { label: '止损', cls: 'bg-red-500/10 text-red-400 border-red-500/30' },
take_profit: { label: '止盈', cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
trailing_stop: { label: '移损', cls: 'bg-orange-500/10 text-orange-400 border-orange-500/30' },
trailing_take_profit: { label: '回撤止盈', cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
max_hold: { label: '超期', cls: 'bg-amber-400/10 text-amber-400 border-amber-400/30' },
@@ -517,6 +519,12 @@ function StockPoolPicker({ value, onChange }: { value: string; onChange: (value:
staleTime: 30_000,
})
const results = search.data?.results ?? []
// 自选列表 — 供「从自选导入」一键填入回测范围
const watchlist = useQuery({
queryKey: QK.watchlist,
queryFn: () => api.watchlistList(),
staleTime: 30_000,
})
useEffect(() => {
if (results.length === 0) return
@@ -545,43 +553,84 @@ function StockPoolPicker({ value, onChange }: { value: string; onChange: (value:
setOpen(false)
}
const removeSymbol = (symbol: string) => setSymbols(symbols.filter(s => s !== symbol))
// 一键导入自选: 合并去重, 顺带回填股票名
const importFromWatchlist = () => {
const entries = watchlist.data?.symbols ?? []
if (entries.length === 0) return
setSymbolNames(prev => {
const next = { ...prev }
entries.forEach(e => { if (e.name) next[e.symbol] = e.name })
return next
})
setSymbols([...symbols, ...entries.map(e => e.symbol)])
}
const watchlistCount = watchlist.data?.symbols?.length ?? 0
return (
<div className="space-y-2" ref={ref}>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input
type="text"
value={query}
onChange={e => { setQuery(e.target.value); setOpen(true) }}
onFocus={() => { if (query.trim()) setOpen(true) }}
placeholder="搜索股票名称/代码添加股票池"
className="w-full rounded-input border border-border bg-surface py-1.5 pl-8 pr-2.5 text-xs focus:border-accent focus:outline-none"
/>
{open && results.length > 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-card border border-border bg-base shadow-xl">
{results.map(r => {
const added = symbols.includes(r.symbol)
return (
<button
key={r.symbol}
type="button"
disabled={added}
onClick={() => addSymbol(r.symbol, r.name)}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${added ? 'cursor-default text-muted' : 'text-foreground hover:bg-elevated'}`}
>
<span className="w-[78px] shrink-0 font-mono">{r.symbol}</span>
<span className="min-w-0 flex-1 truncate text-secondary">{r.name}</span>
<Plus className={`h-3.5 w-3.5 ${added ? 'opacity-30' : 'text-accent'}`} />
</button>
)
})}
</div>
)}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input
type="text"
value={query}
onChange={e => { setQuery(e.target.value); setOpen(true) }}
onFocus={() => { if (query.trim()) setOpen(true) }}
placeholder="搜索股票名称/代码添加股票池"
className="w-full rounded-input border border-border bg-surface py-1.5 pl-8 pr-2.5 text-xs focus:border-accent focus:outline-none"
/>
{open && results.length > 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-card border border-border bg-base shadow-xl">
{results.map(r => {
const added = symbols.includes(r.symbol)
return (
<button
key={r.symbol}
type="button"
disabled={added}
onClick={() => addSymbol(r.symbol, r.name)}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${added ? 'cursor-default text-muted' : 'text-foreground hover:bg-elevated'}`}
>
<span className="w-[78px] shrink-0 font-mono">{r.symbol}</span>
<span className="min-w-0 flex-1 truncate text-secondary">{r.name}</span>
<Plus className={`h-3.5 w-3.5 ${added ? 'opacity-30' : 'text-accent'}`} />
</button>
)
})}
</div>
)}
</div>
{/* 操作按钮 — 紧贴输入框右侧 */}
<div className="flex shrink-0 items-center gap-1.5">
{/* 当前范围 — 有范围显示个数, 无范围显示全市场 */}
<span className={`whitespace-nowrap text-[11px] font-medium ${symbols.length === 0 ? 'text-amber-400' : 'text-accent'}`}>
{symbols.length === 0 ? '全市场' : `${symbols.length}`}
</span>
<button
type="button"
onClick={importFromWatchlist}
disabled={watchlist.isLoading || watchlistCount === 0}
className="inline-flex items-center gap-1 whitespace-nowrap rounded-input border border-border bg-surface px-2 py-1.5 text-[11px] text-secondary transition-colors hover:border-accent/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
title="把自选列表的个股加入回测范围"
>
<ListPlus className="h-3 w-3" />
{watchlist.isLoading ? '加载…' : watchlistCount === 0 ? '自选空' : `导入自选(${watchlistCount})`}
</button>
<button
type="button"
onClick={() => setSymbols([])}
disabled={symbols.length === 0}
className="inline-flex items-center gap-1 whitespace-nowrap rounded-input border border-border bg-surface px-2 py-1.5 text-[11px] text-secondary transition-colors hover:border-danger/50 hover:text-danger disabled:cursor-not-allowed disabled:opacity-50"
title="清空回测范围"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{symbols.length === 0 ? (
<span className="text-[11px] font-medium text-amber-400"> = </span>
<span className="text-[11px] text-muted"></span>
) : symbols.map(symbol => {
const name = symbolNames[symbol]
return (
@@ -899,6 +948,7 @@ export function StrategyBacktest() {
const scoreMinValue = overrides.score_min == null ? '' : String(overrides.score_min)
const scoreMaxValue = overrides.score_max == null ? '' : String(overrides.score_max)
const stopLossPct = overrides.stop_loss == null ? '' : String(Math.abs(Number(overrides.stop_loss)) * 100)
const takeProfitPct = overrides.take_profit == null ? '' : String(Math.abs(Number(overrides.take_profit)) * 100)
const trailingStopPct = overrides.trailing_stop == null ? '' : String(Math.abs(Number(overrides.trailing_stop)) * 100)
const trailingTakeProfitActivatePct = overrides.trailing_take_profit_activate == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_activate)) * 100)
const trailingTakeProfitDrawdownPct = overrides.trailing_take_profit_drawdown == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_drawdown)) * 100)
@@ -942,6 +992,7 @@ export function StrategyBacktest() {
`卖点 ${exitSignals.length}`,
scoreFilterSummary,
stopLossPct !== '' ? `止损 ${stopLossPct}%` : '止损未设',
takeProfitPct !== '' ? `止盈 ${takeProfitPct}%` : '止盈未设',
trailingStopPct !== '' ? `移损 ${trailingStopPct}%` : '移损未设',
trailingTakeProfitActivatePct !== '' && trailingTakeProfitDrawdownPct !== '' ? `回撤 ${trailingTakeProfitActivatePct}-${trailingTakeProfitDrawdownPct}` : '回撤未设',
maxHoldDaysValue !== '' ? `最长 ${maxHoldDaysValue}` : '不限持仓',
@@ -1481,6 +1532,9 @@ export function StrategyBacktest() {
{result.strategy_info.stop_loss != null && (
<span className="text-[10px] text-secondary"> {fmtPct(result.strategy_info.stop_loss)}</span>
)}
{result.strategy_info.take_profit != null && (
<span className="text-[10px] text-secondary"> {fmtPct(result.strategy_info.take_profit)}</span>
)}
{result.strategy_info.trailing_stop != null && (
<span className="text-[10px] text-secondary"> {fmtPct(result.strategy_info.trailing_stop)}</span>
)}
@@ -1869,7 +1923,7 @@ export function StrategyBacktest() {
</div>
{settingsTab === 'range' && (
<ConfigSection title="回测范围" hint={<span className="font-medium text-amber-400"> = </span>}>
<ConfigSection title="回测范围">
<StockPoolPicker value={symbols} onChange={setSymbols} />
<div className="text-[11px] leading-5 text-muted"></div>
</ConfigSection>
@@ -2091,6 +2145,21 @@ export function StrategyBacktest() {
className={INPUT_CLS}
/>
</label>
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">(%)</span>
<input
type="number"
value={takeProfitPct}
min={1}
max={500}
step={0.5}
onChange={e => {
const n = numOrNull(e.target.value)
updateOverride('take_profit', n == null ? null : clamp(Math.abs(n), 1, 500) / 100)
}}
className={INPUT_CLS}
/>
</label>
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">(%)</span>
<input
@@ -39,6 +39,7 @@ const BUILTIN_PAGES: NavEntry[] = [
{ id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true },
{ id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
{ id: '/stock-analysis', label: '个股分析', type: 'builtin', visible: true },
{ id: '/review', label: '复盘', type: 'builtin', visible: true },
{ id: '/financials', label: '财务分析', type: 'builtin', visible: true },
{ id: '/indices', label: '指数', type: 'builtin', visible: true },
{ id: '/trading', label: '交易', type: 'builtin', visible: true },
+154 -28
View File
@@ -3,12 +3,12 @@ import { Link } from 'react-router-dom'
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query'
import {
Activity,
Shield,
Wifi,
BarChart3,
Flame,
Zap,
Bell,
Webhook,
ChevronDown,
} from 'lucide-react'
import {
usePreferences,
@@ -53,8 +53,9 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
const refreshPages = prefs?.sse_refresh_pages ?? {}
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
const systemNotify = prefs?.system_notify_enabled ?? false
const hasDepth = !!caps?.capabilities?.['depth5.batch']
// 新建监控规则时是否默认勾选飞书推送 (全局默认值, 单条规则可独立修改)
const webhookDefault = prefs?.webhook_enabled_default ?? false
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol)
const indicesPinned = prefs?.indices_nav_pinned ?? true
const isRunning = quoteStatus?.running ?? false
@@ -63,6 +64,17 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
const minInterval = intervalData?.min_interval ?? 5
const maxInterval = intervalData?.max_interval ?? 60
const [intervalDraft, setIntervalDraft] = useState(interval)
const feishuWebhookUrl = prefs?.feishu_webhook_url ?? ''
const feishuWebhookSecret = prefs?.feishu_webhook_secret ?? ''
const [feishuDraft, setFeishuDraft] = useState(feishuWebhookUrl)
const [feishuSecretDraft, setFeishuSecretDraft] = useState(feishuWebhookSecret)
const [feishuError, setFeishuError] = useState('')
// 飞书渠道配置区展开态 (推送通知卡片内)
const [channelOpen, setChannelOpen] = useState(false)
useEffect(() => {
setFeishuDraft(feishuWebhookUrl)
setFeishuSecretDraft(feishuWebhookSecret)
}, [feishuWebhookUrl, feishuWebhookSecret])
const watchlistSymbols = prefs?.realtime_watchlist_symbols ?? []
const watchlist = useQuery({
queryKey: QK.watchlist,
@@ -107,11 +119,31 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
qc.invalidateQueries({ queryKey: QK.preferences })
}, [qc])
const toggleSystemNotify = useCallback(async (enabled: boolean) => {
await api.updateSystemNotify(enabled)
const toggleWebhookDefault = useCallback(async (enabled: boolean) => {
await api.updateWebhookDefault(enabled)
qc.invalidateQueries({ queryKey: QK.preferences })
}, [qc])
const saveFeishuWebhook = useMutation({
mutationFn: ({ url, secret }: { url: string; secret: string }) => api.updateFeishuWebhook(url, secret),
onSuccess: () => {
setFeishuError('')
toast('飞书 Webhook 已保存', 'success')
qc.invalidateQueries({ queryKey: QK.preferences })
},
onError: (err: any) => setFeishuError(String(err?.message ?? '保存失败')),
})
const FEISHU_PREFIX = 'https://open.feishu.cn/open-apis/bot/v2/hook/'
const submitFeishu = useCallback(() => {
const url = feishuDraft.trim()
const secret = feishuSecretDraft.trim()
if (url && !url.startsWith(FEISHU_PREFIX)) {
setFeishuError('地址需以 ' + FEISHU_PREFIX + ' 开头')
return
}
saveFeishuWebhook.mutate({ url, secret })
}, [feishuDraft, feishuSecretDraft, saveFeishuWebhook])
const runFix = useMutation({
mutationFn: () => api.runLimitLadderFix(),
onSuccess: (data) => {
@@ -300,29 +332,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
{/* ========== 右列 ========== */}
<div className="space-y-6">
{/* 策略监控已迁移至监控中心 */}
<Card icon={Shield} title="策略监控">
<p className="text-xs text-secondary mb-3">
,
</p>
<a
href="#/monitor"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/15 text-accent text-xs font-medium hover:bg-accent/25 transition-colors"
>
</a>
<div className="mt-3 pt-3 border-t border-border">
<ToggleRow
icon={Bell}
label="系统通知"
desc="监控告警同时推送到操作系统通知中心(窗口最小化或后台也能收到)"
checked={systemNotify}
onChange={toggleSystemNotify}
/>
</div>
</Card>
{/* 连板梯队降级修正 */}
{/* 连板梯队降级修正 (移至右列顶部) */}
<div
id="depth-fix"
className={`rounded-card transition-all duration-500 ${flash ? 'ring-2 ring-accent/60 ring-offset-2 ring-offset-base scale-[1.01]' : 'ring-0 ring-transparent'}`}
@@ -368,6 +378,122 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
)}
</Card>
</div>
{/* ()
; , QMT/ptrade
每个渠道合并成一行: 勾选=, */}
<Card icon={Webhook} title="推送通知">
<p className="text-xs text-secondary mb-3">
,<b className="text-foreground/80"></b>,
</p>
{/* 渠道列表 — 每行一个渠道, 勾选默认 + 点行展开地址配置 */}
<div className="space-y-2">
{/* 飞书 (可用): 勾选默认 + 展开地址配置 */}
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
<div
onClick={() => setChannelOpen(o => !o)}
className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60"
>
<input
type="checkbox"
checked={webhookDefault}
onChange={e => { e.stopPropagation(); toggleWebhookDefault(e.target.checked) }}
onClick={e => e.stopPropagation()}
title="作为新建规则的默认推送渠道"
className="h-3 w-3 accent-accent cursor-pointer"
/>
<span className="text-[11px] font-medium text-foreground"></span>
<span className="text-[9px] text-muted"></span>
{webhookDefault && (
<span className="rounded bg-accent/15 px-1 py-px text-[9px] text-accent"></span>
)}
<span className={`ml-auto text-[9px] ${feishuWebhookUrl ? 'text-emerald-500' : 'text-warning'}`}>
{feishuWebhookUrl ? '已配置' : '未配置'}
</span>
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${channelOpen ? 'rotate-180' : ''}`} />
</div>
{/* 飞书地址配置 — 行内展开 */}
{channelOpen && (
<div className="border-t border-border/60 bg-base/30 p-3">
<label className="block space-y-1.5">
<span className="text-[11px] text-muted">Webhook </span>
<input
value={feishuDraft}
onChange={e => setFeishuDraft(e.target.value)}
placeholder={FEISHU_PREFIX + 'xxxxxxxx'}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
/>
</label>
<label className="block mt-2 space-y-1.5">
<span className="text-[11px] text-muted"> ( · )</span>
<input
type="password"
value={feishuSecretDraft}
onChange={e => setFeishuSecretDraft(e.target.value)}
placeholder="机器人未启用签名校验则留空"
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
/>
</label>
{feishuError && (
<div className="mt-2 text-[11px] text-danger">{feishuError}</div>
)}
<div className="mt-2 flex items-center gap-2">
<button
onClick={submitFeishu}
disabled={saveFeishuWebhook.isPending || (feishuDraft.trim() === feishuWebhookUrl && feishuSecretDraft.trim() === feishuWebhookSecret)}
className="px-3 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer hover:bg-accent/90 transition-colors"
>
{saveFeishuWebhook.isPending ? '保存中…' : '保存'}
</button>
{feishuWebhookUrl && (
<span className="text-[10px] text-emerald-500"> </span>
)}
</div>
<details className="mt-3 text-[10px] text-muted">
<summary className="cursor-pointer hover:text-secondary"> Webhook ?</summary>
<ol className="mt-1.5 space-y-1 pl-4 list-decimal leading-relaxed">
<li>, <b></b></li>
<li> <b></b></li>
<li>, Webhook </li>
<li><b></b>,</li>
<li></li>
</ol>
<p className="mt-1.5 pl-4 text-muted/70">
📖 :
<a href="https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN" target="_blank" rel="noreferrer" className="text-accent hover:text-accent/80">
使
</a>
</p>
</details>
</div>
)}
</div>
{/* 占位渠道 — 不可点 */}
{[
{ name: '微信', hint: '公众号/企业微信', status: '开发中' },
{ name: 'QMT', hint: '量化交易终端', status: '待定' },
{ name: 'ptrade', hint: '量化交易终端', status: '待定' },
].map(ch => (
<div
key={ch.name}
className="flex items-center gap-2 rounded-btn border border-border/40 bg-base/20 px-2.5 py-2 opacity-60"
>
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
<span className="text-[11px] text-secondary">{ch.name}</span>
<span className="text-[9px] text-muted">{ch.hint}</span>
<span className="ml-auto rounded bg-muted/10 px-1 py-px text-[9px] text-muted">{ch.status}</span>
</div>
))}
</div>
</Card>
</div>
</div>
)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 727 KiB