diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index 860ece8..8e0261f 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -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) diff --git a/backend/app/api/monitor_rules.py b/backend/app/api/monitor_rules.py index e0856af..475d685 100644 --- a/backend/app/api/monitor_rules.py +++ b/backend/app/api/monitor_rules.py @@ -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 = "" diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 9af897a..8612846 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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。""" diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py index 205784b..09f9f80 100644 --- a/backend/app/api/strategy.py +++ b/backend/app/api/strategy.py @@ -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), diff --git a/backend/app/backtest/engine.py b/backend/app/backtest/engine.py index 8d43975..943490c 100644 --- a/backend/app/backtest/engine.py +++ b/backend/app/backtest/engine.py @@ -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, diff --git a/backend/app/backtest/strategy.py b/backend/app/backtest/strategy.py index 125169e..0518fb3 100644 --- a/backend/app/backtest/strategy.py +++ b/backend/app/backtest/strategy.py @@ -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, diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py index 59a829a..bf02072 100644 --- a/backend/app/jobs/daily_pipeline.py +++ b/backend/app/jobs/daily_pipeline.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index 4b15d2e..dcc7366 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/services/financial_sync.py b/backend/app/services/financial_sync.py index 22dee64..983b67f 100644 --- a/backend/app/services/financial_sync.py +++ b/backend/app/services/financial_sync.py @@ -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。 diff --git a/backend/app/services/index_sync.py b/backend/app/services/index_sync.py index 4f689b9..42f3b1b 100644 --- a/backend/app/services/index_sync.py +++ b/backend/app/services/index_sync.py @@ -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() diff --git a/backend/app/services/kline_sync.py b/backend/app/services/kline_sync.py index cff2f70..7a4d5f5 100644 --- a/backend/app/services/kline_sync.py +++ b/backend/app/services/kline_sync.py @@ -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: diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 7853dfe..9f06a2e 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -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) diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 676b079..9a8000d 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -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 开关控制)。 diff --git a/backend/app/services/webhook_adapter.py b/backend/app/services/webhook_adapter.py new file mode 100644 index 0000000..28c356f --- /dev/null +++ b/backend/app/services/webhook_adapter.py @@ -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 diff --git a/backend/app/strategy/monitor.py b/backend/app/strategy/monitor.py index 41cf219..8215815 100644 --- a/backend/app/strategy/monitor.py +++ b/backend/app/strategy/monitor.py @@ -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: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6f07f15..ebce2c4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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" diff --git a/backend/uv.lock b/backend/uv.lock index 233de5f..7ec7052 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2491,7 +2491,7 @@ all = [ [[package]] name = "tickflow-stock-panel-backend" -version = "0.1.63" +version = "0.1.64" source = { editable = "." } dependencies = [ { name = "apscheduler" }, diff --git a/frontend/package.json b/frontend/package.json index 07720ae..50abd9e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "tickflow-stock-panel-frontend", "private": true, - "version": "0.1.64", + "version": "0.1.65", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/data/PageSettingsModal.tsx b/frontend/src/components/data/PageSettingsModal.tsx index 121cce3..86abaae 100644 --- a/frontend/src/components/data/PageSettingsModal.tsx +++ b/frontend/src/components/data/PageSettingsModal.tsx @@ -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> = { adj_factor: 'adj_factor', minute: 'kline.minute.batch', @@ -35,6 +58,7 @@ const CAP_KEY_MAP: Partial> = { /** * 读取卡片显隐状态。结合档位能力决定默认值: * - 用户显式设置过 → 用设置值 + * - 未设置 + 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(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 | undefined }) { const [visible, setVisible] = useState>(() => getCardVisibility(caps)) + const [order, setOrder] = useState(() => getCardOrder()) - const toggle = (key: CardKey) => { - const next = { ...visible, [key]: !visible[key] } + const persistVisible = (next: Record) => { 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 = {} + 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 (

- 勾选要在数据画像区显示的卡片。未勾选的卡片将隐藏,不影响数据本身。 + 拖动手柄调整卡片顺序,勾选控制显隐。未勾选的卡片将隐藏,不影响数据本身。

-
- {DATA_CARD_DEFS.map((def) => { - const on = visible[def.key] ?? true - return ( - - ) - })} -
+ + +
+ {orderedDefs.map((def) => { + const on = visible[def.key] ?? true + return ( + toggle(def.key)} + /> + ) + })} +
+
+
+ {/* 显隐勾选 */} + +
+
{label}
+
{desc}
+
+
+ ) +} diff --git a/frontend/src/components/monitor/RuleEditor.tsx b/frontend/src/components/monitor/RuleEditor.tsx index 59c5782..0c19864 100644 --- a/frontend/src/components/monitor/RuleEditor.tsx +++ b/frontend/src/components/monitor/RuleEditor.tsx @@ -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( - 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) {
- {/* Webhook 推送 (占位, 后续开发) */} + {/* Webhook 推送 — 飞书可用, QMT/ptrade 待定 */}
-
-
- Webhook 推送 - 开发中 -
-
{error &&
{error}
} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d0feaf7..dc41613 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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', diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index ad134e7..8f368ef 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -110,4 +110,6 @@ export const storage = { /** 数据页画像卡片显隐 (卡片key → 是否显示) */ dataCardVisible: kv>('data-card-visible'), + /** 数据页画像卡片顺序 (卡片key 数组, 长度=卡片总数) */ + dataCardOrder: kv('data-card-order'), } as const diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 3903e7d..c5e8ee4 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 (
@@ -650,6 +653,18 @@ export function Dashboard() {
+ {/* Free 档提示: 大盘看板为盘后数据, 仅自选股实时。避免用户误读为全市场实时。 */} + {quoteMode === 'watchlist' && ( +
+ +
+ 当前为「自选实时」模式,看板展示的大盘数据为盘后快照(最新有数据日),并非盘中实时; + 仅自选股({data.quote_status?.watchlist_symbol_count ?? 0} 只)支持实时监控。 + 全市场实时需 Starter+ +
+
+ )} +
{data.indices.map(item => )}
diff --git a/frontend/src/pages/Data.tsx b/frontend/src/pages/Data.tsx index 54b1d92..ffc47cc 100644 --- a/frontend/src/pages/Data.tsx +++ b/frontend/src/pages/Data.tsx @@ -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 ( + setSchemaTable('instruments')} + /> + ) + case 'daily': + return ( + setSchemaTable('daily')} + onSettings={hasData ? () => setOpenSettings(v => v === 'daily' ? null : 'daily') : undefined} + settingsOpen={openSettings === 'daily'} + /> + ) + case 'adj_factor': + return ( + setSchemaTable('adj_factor')} + /> + ) + case 'enriched': + return ( + setSchemaTable('enriched')} + onSettings={hasData ? () => setOpenSettings(v => v === 'enriched' ? null : 'enriched') : undefined} + settingsOpen={openSettings === 'enriched'} + /> + ) + case 'index': + return ( + setSchemaTable(t ?? 'index_daily')} + onSettings={hasData ? () => setOpenSettings(v => v === 'index' ? null : 'index') : undefined} + settingsOpen={openSettings === 'index'} + /> + ) + case 'etf': + return ( + setSchemaTable(t ?? 'etf_daily')} + /> + ) + case 'minute': + return ( + setSchemaTable('minute')} + onSettings={hasData ? () => setOpenSettings(v => v === 'minute' ? null : 'minute') : undefined} + settingsOpen={openSettings === 'minute'} + /> + ) + case 'financials': + return ( + + ) + default: + return null + } + } + return ( <>
@@ -620,155 +785,9 @@ export function Data() {
数据画像
- {cardVisible.instruments && ( - setSchemaTable('instruments')} - /> - )} - {cardVisible.daily && ( - setSchemaTable('daily')} - onSettings={hasData ? () => setOpenSettings(v => v === 'daily' ? null : 'daily') : undefined} - settingsOpen={openSettings === 'daily'} - /> - )} - {cardVisible.enriched && ( - setSchemaTable('enriched')} - onSettings={hasData ? () => setOpenSettings(v => v === 'enriched' ? null : 'enriched') : undefined} - settingsOpen={openSettings === 'enriched'} - /> - )} - {cardVisible.index && ( - setSchemaTable(t ?? 'index_daily')} - onSettings={hasData ? () => setOpenSettings(v => v === 'index' ? null : 'index') : undefined} - settingsOpen={openSettings === 'index'} - /> - )} - {cardVisible.etf && ( - setSchemaTable(t ?? 'etf_daily')} - /> - )} - {cardVisible.adj_factor && ( - setSchemaTable('adj_factor')} - /> - )} - {cardVisible.minute && ( - setSchemaTable('minute')} - onSettings={hasData ? () => setOpenSettings(v => v === 'minute' ? null : 'minute') : undefined} - settingsOpen={openSettings === 'minute'} - /> - )} - {cardVisible.financials && ( - - )} + {getCardOrder().filter(k => cardVisible[k]).map((k: CardKey) => ( + {renderStatCard(k)} + ))} {(extConfigs.data?.items ?? []).map((ext) => ( -
+
{syncing && (
diff --git a/frontend/src/pages/Monitor.tsx b/frontend/src/pages/Monitor.tsx index 5d4d6e1..82b4ef2 100644 --- a/frontend/src/pages/Monitor.tsx +++ b/frontend/src/pages/Monitor.tsx @@ -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 })()}
-
- {renderMessage(ev.source, ev.message)} -
+ {/* 详情行: 命中条件 (signal/price/market) + 当前价 / 或默认消息 */} + {(ev.conditions && ev.conditions.length > 0) ? ( +
+ 命中 + {ev.conditions.map((c: MonitorCondition, ci: number) => ( + + {ci > 0 && {ev.logic === 'or' ? '或' : '且'}} + {c.op === 'truth' ? ( + {cnSignal(c.field)} + ) : ( + {cnSignal(c.field)}{c.op}{c.value} + )} + + ))} + {ev.price != null && ( + <> + · + 现价 + {fmtPrice(ev.price)} + + )} +
+ ) : ( +
+ {renderMessage(ev.source, ev.message)} +
+ )} {ev.signals && ev.signals.length > 0 && (
{ev.signals.map((s: string, j: number) => ( diff --git a/frontend/src/pages/StockAnalysis.tsx b/frontend/src/pages/StockAnalysis.tsx index 959a284..905ed65 100644 --- a/frontend/src/pages/StockAnalysis.tsx +++ b/frontend/src/pages/StockAnalysis.tsx @@ -90,7 +90,7 @@ export function StockAnalysis() { } /> -
+
{/* 搜索栏 */}
diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index 2003d0a..7eca937 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -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 = { 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 (
-
- - { 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 && ( -
- {results.map(r => { - const added = symbols.includes(r.symbol) - return ( - - ) - })} -
- )} +
+
+ + { 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 && ( +
+ {results.map(r => { + const added = symbols.includes(r.symbol) + return ( + + ) + })} +
+ )} +
+ {/* 操作按钮 — 紧贴输入框右侧 */} +
+ {/* 当前范围 — 有范围显示个数, 无范围显示全市场 */} + + {symbols.length === 0 ? '全市场' : `共 ${symbols.length} 只`} + + + +
{symbols.length === 0 ? ( - 留空 = 全市场,由基础过滤和策略条件筛选。 + 默认全市场回测,由基础过滤和策略条件筛选。 ) : 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 && ( 止损 {fmtPct(result.strategy_info.stop_loss)} )} + {result.strategy_info.take_profit != null && ( + 止盈 {fmtPct(result.strategy_info.take_profit)} + )} {result.strategy_info.trailing_stop != null && ( 移损 {fmtPct(result.strategy_info.trailing_stop)} )} @@ -1869,7 +1923,7 @@ export function StrategyBacktest() {
{settingsTab === 'range' && ( - 留空 = 全市场}> +
默认全市场回测,由基础过滤、策略条件和买卖触发器筛选;需要单票调试或自选池回测时再限定股票池。
@@ -2091,6 +2145,21 @@ export function StrategyBacktest() { className={INPUT_CLS} /> +
) diff --git a/screenshots/financials-ultrawide-fix.png b/screenshots/financials-ultrawide-fix.png deleted file mode 100644 index 1ee13d8..0000000 Binary files a/screenshots/financials-ultrawide-fix.png and /dev/null differ diff --git a/screenshots/stock-analysis-ultrawide-fix.png b/screenshots/stock-analysis-ultrawide-fix.png deleted file mode 100644 index 8285006..0000000 Binary files a/screenshots/stock-analysis-ultrawide-fix.png and /dev/null differ