From f8914c9140b729290034979636c5888ec5f16e2f Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Tue, 7 Jul 2026 17:56:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E6=8E=A8=E9=80=81=E3=80=81=E4=B8=AA=E8=82=A1?= =?UTF-8?q?=E5=88=86=E6=9E=90=E9=A1=B5=E9=87=8D=E6=9E=84=E3=80=81=E4=BB=B7?= =?UTF-8?q?=E4=BD=8D=E7=AE=97=E6=B3=95=E4=BC=98=E5=8C=96=E5=8F=8A=E5=A4=9A?= =?UTF-8?q?=E5=A4=84=E4=BA=A4=E4=BA=92=E6=94=B9=E8=BF=9B=20(v0.1.82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 推送: 新增企业微信群机器人通道(与飞书并列), 监控告警/复盘报告多渠道分发 - 个股分析: 历史报告改为右侧常驻栏(展示全部股票报告), 进页面自动恢复上次选股 - 价位算法: 成交密集区改用换手率衰减模型(国内主流筹码分布); 缺口回补判定修正(必须完全穿越缺口) - 回测: 回撤止盈改纯峰值口径(与 trailing_stop 一致); 佣金/印花税/滑点合并一行; 建仓口径加问号气泡说明 - 分时图: 自选列表迷你分时图加渐变填充(对齐个股对话框风格) - 图表: 个股分析日K图右侧标签适配浅色主题; 价位标签与下方文字行双向 hover 高亮联动 --- backend/app/__init__.py | 2 +- backend/app/api/settings.py | 26 +++ backend/app/backtest/engine.py | 7 +- backend/app/indicators/levels.py | 153 ++++++++++++------ backend/app/jobs/daily_pipeline.py | 11 ++ backend/app/services/preferences.py | 23 ++- backend/app/services/quote_service.py | 25 +-- backend/app/services/webhook_adapter.py | 125 ++++++++++++++ backend/pyproject.toml | 2 +- frontend/package.json | 2 +- .../stock-analysis/AnalysisKChart.tsx | 105 ++++++++++-- .../components/stock-table/MiniIntraday.tsx | 17 ++ frontend/src/lib/api.ts | 32 ++++ frontend/src/pages/Review.tsx | 35 ++-- frontend/src/pages/StockAnalysis.tsx | 144 ++++++++++------- .../src/pages/backtest/StrategyBacktest.tsx | 71 +++++++- frontend/src/pages/settings/Monitoring.tsx | 106 +++++++++++- 17 files changed, 726 insertions(+), 160 deletions(-) diff --git a/backend/app/__init__.py b/backend/app/__init__.py index d2f47a3..efad5f0 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -2,7 +2,7 @@ import sys -__version__ = "0.1.81" +__version__ = "0.1.82" # Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的 # 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。 diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index fa02f8f..6f36c4e 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -398,6 +398,7 @@ def get_preferences() -> dict: "system_notify_enabled": preferences.get_system_notify_enabled(), "feishu_webhook_url": preferences.get_feishu_webhook_url(), "feishu_webhook_secret": preferences.get_feishu_webhook_secret(), + "wecom_webhook_url": preferences.get_wecom_webhook_url(), "webhook_enabled_default": preferences.get_webhook_enabled_default(), "sidebar_index_symbols": preferences.get_sidebar_index_symbols(), "nav_order": preferences.get_nav_order(), @@ -821,6 +822,31 @@ def update_feishu_webhook(req: FeishuWebhookPrefsIn) -> dict: return {"feishu_webhook_url": saved_url, "feishu_webhook_secret": saved_secret} +class WecomWebhookPrefsIn(BaseModel): + url: str + + +@router.put("/preferences/wecom-webhook") +def update_wecom_webhook(req: WecomWebhookPrefsIn) -> dict: + """企业微信群机器人 Webhook 地址 — 与飞书并列的第二推送通道。 + + - url: 传入空串表示清空配置; 非空需为合法企业微信群机器人地址, 或纯 key。 + - 用户可只填 key (webhook/send?key=xxx 的 xxx 部分), 后端自动补全为完整 URL。 + """ + from app.services import preferences + from app.services import webhook_adapter + + url = (req.url or "").strip() + if url and not webhook_adapter.is_valid_wecom_url(url): + raise HTTPException( + status_code=400, + detail="Webhook 地址非法, 需为企业微信群机器人地址 " + "(https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=... 或纯 key)", + ) + saved_url = preferences.set_wecom_webhook_url(url) + return {"wecom_webhook_url": saved_url} + + class WebhookEnabledDefaultIn(BaseModel): enabled: bool diff --git a/backend/app/backtest/engine.py b/backend/app/backtest/engine.py index 66a223a..f030dde 100644 --- a/backend/app/backtest/engine.py +++ b/backend/app/backtest/engine.py @@ -576,7 +576,8 @@ class BacktestEngine: if activate_pct is not None and drawdown_pct is not None and peak_price > entry_price: peak_profit = peak_price / entry_price - 1 if peak_profit >= abs(float(activate_pct)): - risk_lines.append((entry_price * (1 + peak_profit - abs(float(drawdown_pct))), "trailing_take_profit")) + # 回撤止盈触发线: 相对峰值价回撤 drawdown 个点 (纯峰值口径) + risk_lines.append((peak_price * (1 - abs(float(drawdown_pct))), "trailing_take_profit")) risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)] # 止损/移损/回撤止盈: 价格跌破风控线触发 (取最高优先级线) @@ -1039,7 +1040,9 @@ class BacktestEngine: if activate_pct is not None and drawdown_pct is not None and peak_price > entry_price: peak_profit = peak_price / entry_price - 1 if peak_profit >= abs(float(activate_pct)): - take_profit_line = entry_price * (1 + peak_profit - abs(float(drawdown_pct))) + # 回撤止盈触发线: 相对峰值价回撤 drawdown 个点 (纯峰值口径) + # 启动门槛用成本基准的浮盈率, 触发线用峰值基准, 与 trailing_stop 同口径 + take_profit_line = peak_price * (1 - abs(float(drawdown_pct))) risk_lines.append((take_profit_line, "trailing_take_profit")) # 止损/移损/回撤止盈: 价格跌破风控线触发 diff --git a/backend/app/indicators/levels.py b/backend/app/indicators/levels.py index ad3f204..054e782 100644 --- a/backend/app/indicators/levels.py +++ b/backend/app/indicators/levels.py @@ -60,15 +60,31 @@ LEVEL_TYPES = { # ================================================================ def _support_resistance(df: pl.DataFrame, bins: int = 40) -> list[dict]: - """成交量分布 (Volume Profile) —— 真正基于价+量的支撑/压力位。 + """筹码分布(换手率衰减模型) —— 国内主流 A 股支撑/压力位算法。 - 把每个价位层按价格分桶,统计落在该桶的累计成交量,取高成交密集区作为关键 - 价位带。与 BOLL/Keltner 等"波动通道"不同,成交密集区反映的是真实换手堆积, - 是经典意义的支撑/压力。 + 算法依据:大智慧/同花顺/通达信"筹码分布"专利模型(CN109711994A),物理含义是 + "当前各价位还剩多少筹码(持仓成本分布)",而非海外 Volume Profile 的"历史成交 + 堆积"。两者关键区别在于衰减方式: + - 海外 VP: 无衰减, 历史成交永远累加(反映"曾经在哪换过手") + - 国内筹码分布: 按换手率衰减, 反映"现在谁还拿着"(物理正确的持仓成本) - 密集区 = 成交量高于均值的桶,按成交量降序取前 3 个作为关键价位带: - - POC(控制点):成交量最大的桶,标记为 strong - - 其他高成交区:高于均值,标记为 medium + 核心迭代公式(逐日, 从老到新): + 当日收盘后各价位筹码 = 前一日各价位筹码 × (1 - 当日换手率) + + 当日新增成交(按 high~low 区间分摊到对应价位桶) + + 物理解释: 今天市场换了 turnover_rate 比例的手, 意味着昨天所有价位的筹码中有 + 这么比例被换走了(被卖掉), 同时今天新成交的筹码按当天价格区间分布在各价位。 + 这样: + - 低换手股票的老筹码衰减慢(长期横盘低换手 → 历史套牢盘长期保留, 符合真实) + - 高换手股票的老筹码快速消失(近期高换手 → 老筹码被消化, 近期成本占主导) + 比固定时间衰减更聪明: 停牌期间换手率为 0, 筹码不动; 固定时间衰减会错误地衰减。 + + 成交量按 high~low 价格区间分摊到桶(而非全归 (high+low)/2 中点), 振幅大的 K 线 + 不再污染中点价, 更接近真实成交分布。 + + 密集区输出: + - POC(控制点):当前筹码量最大的桶(=最多人持仓的成本区), strong + - 其他高筹码区:高于均值, medium, 最多 2 个 """ if df.is_empty() or "volume" not in df.columns or df.height < 20: return [] @@ -78,33 +94,54 @@ def _support_resistance(df: pl.DataFrame, bins: int = 40) -> list[dict]: if not (hi > lo > 0): return [] - # 每根 K 的价格区间中点 × 成交量 ≈ 该价位层贡献的成交量(简化模型) - df2 = df.select([ - ((pl.col("high") + pl.col("low")) / 2).alias("mid"), - pl.col("volume").alias("vol"), - ]).drop_nulls() + n = df.height - # 桶边界:bins 个桶需要 bins-1 个内部 break,cut 据此切成 bins 段 + # 桶边界:bins 个桶需要 bins-1 个内部 break step = (hi - lo) / bins - edges = [lo + i * step for i in range(bins + 1)] # 含首尾,共 bins+1 个边界值 - breaks = edges[1:-1] # 内部 break,bins-1 个 - bin_labels = [f"{i}" for i in range(bins)] # 桶序号 0..bins-1 - # 至少要有 1 个不同的内部 break - if len(set(f"{b:.6f}" for b in breaks)) < 1: - return [] + edges = [lo + i * step for i in range(bins + 1)] - df2 = df2.with_columns( - pl.col("mid").cut(breaks, labels=bin_labels).alias("bin") - ) - prof = df2.group_by("bin").agg(pl.col("vol").sum()) - if prof.is_empty(): - return [] + # 取换手率序列(百分数, 如 1.23 表示 1.23%)。无该字段则退化为纯累加(无衰减)。 + has_turnover = "turnover_rate" in df.columns + turnovers = df["turnover_rate"].to_list() if has_turnover else [0.0] * n - # 把桶序号字符串还原为 int,以便回查 edges;并按序号排序保证可索引 - prof = prof.with_columns(pl.col("bin").cast(pl.Int64).alias("bi")).sort("bi") - bin_ids = prof["bi"].to_list() - vols = prof["vol"].to_list() - mean_vol = sum(vols) / len(vols) if vols else 0 + highs = df["high"].to_list() + lows = df["low"].to_list() + vols = df["volume"].to_list() + + # 逐日迭代: 保留各桶的"当前筹码量"。 + # 顺序: 从老(i=0)到新(i=n-1)。每日先把存量按 (1 - turnover) 衰减, 再叠加当日新增。 + chips = [0.0] * bins + for i in range(n): + t = float(turnovers[i] or 0) + # 换手率正常 0~100, 异常值(负/超大)钳制到 [0, 1] 区间比例 + decay_ratio = 1.0 - max(0.0, min(t, 100.0)) / 100.0 + + # 1) 存量筹码按今日换手率衰减(被卖掉的部分移除) + if decay_ratio < 1.0: + for k in range(bins): + chips[k] *= decay_ratio + + # 2) 当日新增成交按 high~low 区间分摊到对应价位桶 + v = vols[i] or 0 + if v > 0: + k_low = min(int((lows[i] - lo) / step), bins - 1) + k_high = min(int((highs[i] - lo) / step), bins - 1) + if k_low > k_high: + k_low, k_high = k_high, k_low + if k_high < 0 or k_low >= bins: + continue + k_low = max(k_low, 0) + k_high = min(k_high, bins - 1) + share = v / (k_high - k_low + 1) + for k in range(k_low, k_high + 1): + chips[k] += share + + # 只保留有筹码的桶 + bin_ids = [k for k in range(bins) if chips[k] > 0] + if not bin_ids: + return [] + vals = [chips[k] for k in bin_ids] + mean_val = sum(vals) / len(vals) if vals else 0 def bin_mid(bin_id: int) -> float: return (edges[bin_id] + edges[bin_id + 1]) / 2 @@ -112,14 +149,14 @@ def _support_resistance(df: pl.DataFrame, bins: int = 40) -> list[dict]: close = float(df.tail(1)["close"][0]) out: list[dict] = [] - # POC:成交量最大的桶 - poc_pos = max(range(len(vols)), key=lambda i: vols[i]) + # POC: 当前筹码量最大的桶(最多人持仓的成本区) + poc_pos = max(range(len(vals)), key=lambda i: vals[i]) poc_mid = bin_mid(bin_ids[poc_pos]) out.append({"value": round(poc_mid, 2), "label": "成交密集区(POC)", "type": "sr", "side": _side(poc_mid, close), "strength": "strong"}) - # 其他高成交区(高于均值,排除 POC),按成交量降序取 2 个 - candidates = [(i, v) for i, v in enumerate(vols) if v > mean_vol and i != poc_pos] + # 其他高筹码区(高于均值, 排除 POC), 按筹码量降序取 2 个 + candidates = [(i, v) for i, v in enumerate(vals) if v > mean_val and i != poc_pos] candidates.sort(key=lambda x: x[1], reverse=True) for i, _v in candidates[:2]: mid = bin_mid(bin_ids[i]) @@ -373,8 +410,10 @@ def _gap_levels(df: pl.DataFrame, lookback: int = 120) -> list[dict]: 向上缺口:当日 low > 前日 high(开盘跳空高开,全天未回补) 向下缺口:当日 high < 前日 low(开盘跳空低开,全天未回补) - 缺口是天然的支撑/阻力位。只保留"未回补"的(后续价格未回到缺口区间内), - 并按价格聚合相近缺口(±0.5%),每方向只取距当前价最近的 2~3 个。 + 缺口是天然的支撑/阻力位。只保留"未回补"的:缺口形成后,后续任何一根 K 线的 + 价格(low/high)只要回到缺口区间内,就算已回补(支撑/阻力已被测试消化),过滤掉。 + 这与"当前价是否在缺口内"无关 —— 即使价格后来远离,只要曾经回补过就不算有效缺口。 + 最后按价格聚合相近缺口(±0.5%),每方向只取距当前价最近的 2~3 个。 """ if df.is_empty() or df.height < 5: return [] @@ -383,34 +422,44 @@ def _gap_levels(df: pl.DataFrame, lookback: int = 120) -> list[dict]: highs = sub["high"].to_list() lows = sub["low"].to_list() - up_gaps: list[tuple[float, float]] = [] # (缺口低点, 缺口高点) - dn_gaps: list[tuple[float, float]] = [] + # 收集缺口: (形成位置 i, 缺口下沿, 缺口上沿) + # 向上缺口: 第 i 日 low > 第 i-1 日 high, 缺口区间 = (highs[i-1], lows[i]) + # 向下缺口: 第 i 日 high < 第 i-1 日 low, 缺口区间 = (highs[i], lows[i-1]) + up_gaps: list[tuple[int, float, float]] = [] + dn_gaps: list[tuple[int, float, float]] = [] for i in range(1, len(highs)): if _ok(highs[i]) and _ok(lows[i]) and _ok(highs[i - 1]) and _ok(lows[i - 1]): - if lows[i] > highs[i - 1]: # 向上缺口 - up_gaps.append((highs[i - 1], lows[i])) - elif highs[i] < lows[i - 1]: # 向下缺口 - dn_gaps.append((highs[i], lows[i - 1])) + if lows[i] > highs[i - 1]: + up_gaps.append((i, float(highs[i - 1]), float(lows[i]))) + elif highs[i] < lows[i - 1]: + dn_gaps.append((i, float(highs[i]), float(lows[i - 1]))) - def _filter_unfilled(gaps: list[tuple[float, float]], is_up: bool) -> list[float]: - """过滤掉已被后续价格回补的缺口,取缺口价位中点。""" + def _filter_unfilled(gaps: list[tuple[int, float, float]]) -> list[float]: + """过滤掉已被回补的缺口。 + + 回补判定: 缺口在位置 i 形成, 向后扫描 i+1..end, 只要任意一根 K 线的价格区间 + 完全覆盖缺口真空带(low <= g_hi 且 high >= g_lo), 即价格真正穿越了缺口 → 已回补。 + 仅"触及缺口边缘"(如 low 跌到上沿)不算回补, 因为缺口是价格真空带, 必须整个 + 被某根 K 线的高低区间覆盖才算填补了真空。 + """ mids: list[float] = [] - for g_lo, g_hi in gaps: - # 未回补判定:当前价不在缺口区间内 - if is_up and close >= g_hi: # 向上缺口:价格已超过缺口上沿 = 未回补(站在缺口上方) + for i, g_lo, g_hi in gaps: + filled = False + for j in range(i + 1, len(highs)): + if lows[j] <= g_hi and highs[j] >= g_lo: + filled = True + break + if not filled: mids.append((g_lo + g_hi) / 2) - elif not is_up and close <= g_lo: # 向下缺口:价格已低于缺口下沿 = 未回补 - mids.append((g_lo + g_hi) / 2) - # 聚合相近缺口 + 按距当前价排序取最近 3 个 agg = _aggregate_levels(mids, 0.005) agg.sort(key=lambda v: abs(v - close)) return agg[:3] out: list[dict] = [] - for mid in _filter_unfilled(up_gaps, True): + for mid in _filter_unfilled(up_gaps): out.append({"value": round(mid, 2), "label": "向上缺口", "type": "gap", "side": _side(mid, close), "strength": "medium"}) - for mid in _filter_unfilled(dn_gaps, False): + for mid in _filter_unfilled(dn_gaps): out.append({"value": round(mid, 2), "label": "向下缺口", "type": "gap", "side": _side(mid, close), "strength": "medium"}) return out diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py index 817dae4..af029c6 100644 --- a/backend/app/jobs/daily_pipeline.py +++ b/backend/app/jobs/daily_pipeline.py @@ -726,6 +726,17 @@ def _maybe_push_review(content: str, meta: dict) -> None: url, "TickFlow · 每日复盘", subtitle, content, secret ) logger.info("review push(feishu) %s", "sent" if ok else "failed") + elif ch == "wecom": + url = preferences.get_wecom_webhook_url() + if not url: + logger.info("review push(wecom) skipped: webhook not configured") + continue + # 企业微信 markdown 标题已含一级标题, subtitle 拼到正文首行 + full_body = (f"**{subtitle}**\n\n{content}" if subtitle else content) + ok = webhook_adapter.send_wecom_markdown( + url, "TickFlow · 每日复盘", full_body + ) + logger.info("review push(wecom) %s", "sent" if ok else "failed") # 未来更多渠道在此追加分支 except Exception as e: # noqa: BLE001 logger.warning("review push error: %s", e) diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 9fcd22f..adb9c46 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -283,9 +283,9 @@ def set_depth_finalize_time(hour: int, minute: int) -> dict: return {"hour": h, "minute": m} -# 复盘推送可选渠道白名单 (微信等暂未实现, 不在白名单内, 前端仅作占位) +# 复盘推送可选渠道白名单 (企业微信已实现, 与飞书并列) # 多选: 不推送 = 空数组, 而非 'none' -REVIEW_PUSH_CHANNELS = {"feishu"} +REVIEW_PUSH_CHANNELS = {"feishu", "wecom"} def get_review_schedule() -> dict: @@ -476,6 +476,25 @@ def set_feishu_webhook_secret(secret: str) -> str: return get_feishu_webhook_secret() +def get_wecom_webhook_url() -> str: + """企业微信群机器人 Webhook 地址 — 与飞书并列的第二推送通道。 + + 存储完整 URL (https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx); + 用户也可只填 key, 由 webhook_adapter.normalize_wecom_url 自动补全。 + """ + return load().get("wecom_webhook_url", "") + + +def set_wecom_webhook_url(url: str) -> str: + """保存企业微信 Webhook 地址。传入空串表示清空配置。 + + 存储时统一补全为完整 URL, 避免后续每次推送都要再判一次。 + """ + from app.services.webhook_adapter import normalize_wecom_url + save({"wecom_webhook_url": normalize_wecom_url(url)}) + return get_wecom_webhook_url() + + def get_webhook_enabled_default() -> bool: """新建监控规则时是否默认勾选「飞书推送」。 diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index da32c6c..0b92560 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -886,7 +886,7 @@ class QuoteService: def _maybe_send_webhook(self, rule_events: list[dict], engine) -> None: """把告警通过 Webhook 推送到外部 IM (由规则 webhook_enabled 开关控制)。 - - 全局飞书 URL 未配置: 直接返回 + - 飞书 / 企业微信任一已配置即生效 (两个都没配才跳过) - 仅推送 webhook_enabled=True 的规则触发的告警 - 失败静默, 不阻断主流程 - 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重 @@ -898,10 +898,12 @@ class QuoteService: from app.services import preferences from app.services import webhook_adapter - url = preferences.get_feishu_webhook_url() - if not url: + feishu_url = preferences.get_feishu_webhook_url() + feishu_secret = preferences.get_feishu_webhook_secret() + wecom_url = preferences.get_wecom_webhook_url() + # 两个通道都没配置才跳过 + if not feishu_url and not wecom_url: return - secret = preferences.get_feishu_webhook_secret() # 反查规则, 过滤出启用推送的事件 source_labels = { @@ -909,7 +911,8 @@ class QuoteService: "price": "价格", "market": "异动", } rules = engine.rules if engine is not None else {} - pushed = 0 + pushed_feishu = 0 + pushed_wecom = 0 for ev in rule_events: rule = rules.get(ev.get("rule_id")) if not rule or not rule.get("webhook_enabled"): @@ -921,10 +924,14 @@ class QuoteService: 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) + if feishu_url and webhook_adapter.send_feishu(feishu_url, title, body, feishu_secret): + pushed_feishu += 1 + if wecom_url and webhook_adapter.send_wecom(wecom_url, title, body): + pushed_wecom += 1 + if pushed_feishu: + logger.info("飞书 Webhook 推送: %d 条", pushed_feishu) + if pushed_wecom: + logger.info("企业微信 Webhook 推送: %d 条", pushed_wecom) except Exception as e: # noqa: BLE001 logger.debug("Webhook 推送异常 (不影响告警主流程): %s", e) diff --git a/backend/app/services/webhook_adapter.py b/backend/app/services/webhook_adapter.py index afb76f5..484bcc0 100644 --- a/backend/app/services/webhook_adapter.py +++ b/backend/app/services/webhook_adapter.py @@ -168,3 +168,128 @@ def send_feishu_card(webhook_url: str, title: str, subtitle: str, body_md: str, }, } return _post_feishu(webhook_url, payload, secret) + + +# ================================================================ +# 企业微信群机器人 +# ================================================================ +# +# 与飞书自定义机器人几乎同构: 同样是"群机器人 Webhook + POST JSON"。 +# 关键差异: +# 1. Webhook 形态: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx +# 2. 无需签名校验 (key 本身即凭证; 企业微信群机器人可选"签名校验"但极少用) +# 3. Markdown 原生支持 (msg_type=markdown), 不必像飞书那样包进 interactive 卡片 +# 4. 成功响应: {"errcode":0,"errmsg":"ok"} +# +# 限制: 每个机器人每分钟最多 20 条消息 (超出会被限流 460min 内不可用), +# 依赖 MonitorRuleEngine 的 cooldown 去重即可应对告警场景。 +# 企业微信群的消息可在绑定的个人微信接收, 实现"微信推送"体验。 + +WECOM_HOOK_PREFIX = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send" + + +def is_valid_wecom_url(url: str) -> bool: + """校验是否为合法的企业微信群机器人 Webhook 地址。 + + 允许两种写法: + - 完整: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx + - 仅 key: xxx (企业微信群机器人 key 为 36 位 UUID 样式, 保存时自动补全) + """ + if not url: + return False + if url.startswith(WECOM_HOOK_PREFIX): + return True + # 纯 key: 企业微信 key 形如 12345678-1234-1234-1234-1234567890ab (36 位), + # 但用户可能截断, 放宽到 >= 20 位的无空格无斜杠字符串。 + url = url.strip() + if " " in url or "/" in url or "?" in url: + return False + return len(url) >= 20 + + +def normalize_wecom_url(url: str) -> str: + """把纯 key 补全为完整 Webhook URL。已是完整 URL 则原样返回。""" + url = (url or "").strip() + if not url: + return "" + if url.startswith(WECOM_HOOK_PREFIX): + return url + return f"{WECOM_HOOK_PREFIX}?key={url}" + + +def _post_wecom(webhook_url: str, payload: dict) -> bool: + """发送一次企业微信 webhook 请求并判定成败。 + + 成功响应: HTTP 200 且 errcode=0。失败静默返回 False。 + """ + try: + import httpx + + resp = httpx.post(webhook_url, json=payload, timeout=5.0) + if resp.status_code == 200: + try: + data = resp.json() + if isinstance(data, dict): + # errcode=0 表示成功; 45009=频率限制, 其它非零=业务失败 + if data.get("errcode") == 0: + return True + logger.debug("企业微信推送业务失败: %s", data) + return False + except ValueError: + 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 + + +def send_wecom(webhook_url: str, title: str, body: str) -> bool: + """推送一条文本消息到企业微信群机器人。 + + Args: + webhook_url: 企业微信群机器人 Webhook 地址 (或纯 key, 会自动补全) + title: 消息标题 (与正文拼接为一条文本) + body: 消息正文 + + Returns: + True=成功送达, False=失败或 URL 非法。 + 失败静默, 不抛异常 (与 send_feishu 一致)。 + """ + webhook_url = normalize_wecom_url(webhook_url) + if not is_valid_wecom_url(webhook_url): + return False + + text = _truncate(f"{title}\n{body}".strip()) + if not text: + return False + + payload: dict = {"msg_type": "text", "text": {"content": text}} + return _post_wecom(webhook_url, payload) + + +def send_wecom_markdown(webhook_url: str, title: str, body_md: str) -> bool: + """推送一条 Markdown 消息到企业微信群机器人 —— 承载完整复盘报告。 + + 企业微信群机器人原生支持 markdown 类型 (比飞书 interactive 卡片简单), + 支持 # ## **粗体** >引用 - 列表 等基础语法, 单条上限 4096 字节。 + + Args: + webhook_url: 企业微信群机器人 Webhook 地址 (或纯 key) + title: 标题 (作为一级标题 ## 拼到正文前) + body_md: markdown 正文 + + Returns: + True=成功送达, False=失败或 URL 非法。 + """ + webhook_url = normalize_wecom_url(webhook_url) + if not is_valid_wecom_url(webhook_url): + return False + + content = f"## {title}\n\n{_truncate_card(body_md)}" + if not content.strip(): + return False + + payload: dict = {"msg_type": "markdown", "markdown": {"content": content}} + return _post_wecom(webhook_url, payload) + diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0b955e5..e2204f6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tickflow-stock-panel-backend" -version = "0.1.81" +version = "0.1.82" description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配" readme = "../README.md" requires-python = ">=3.11" diff --git a/frontend/package.json b/frontend/package.json index bf2f6fd..28549ca 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "tickflow-stock-panel-frontend", "private": true, - "version": "0.1.81", + "version": "0.1.82", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/stock-analysis/AnalysisKChart.tsx b/frontend/src/components/stock-analysis/AnalysisKChart.tsx index 0759757..5851ae8 100644 --- a/frontend/src/components/stock-analysis/AnalysisKChart.tsx +++ b/frontend/src/components/stock-analysis/AnalysisKChart.tsx @@ -125,11 +125,15 @@ export function AnalysisKChart({ }: Props) { const chartRef = useRef(null) const chartInstRef = useRef(null) + /** seriesIndex → levelKey 映射, buildOption 填充, ECharts hover 事件反查 */ + const seriesKeyMapRef = useRef>(new Map()) // 主题: buildOption 内部用 CT() 动态取色, 这里只负责切换时触发重建 const theme = useTheme() const [activeTypes, setActiveTypes] = useState>(new Set(defaultLevelTypes)) /** 枢轴点显示到第几档:1=只P+R1/S1, 2=到R2/S2, 3=全档(R3/S3) */ const [pivotRank, setPivotRank] = useState<1 | 2 | 3>(1) + /** 双向联动高亮: hover 价位标签 ↔ hover 下方文字行。值为 levelKey, null=无高亮 */ + const [hoveredKey, setHoveredKey] = useState(null) // 数据预处理 + 带状曲线序列对齐(后端 series 的日期范围可能与 rows 不同,需映射) const { dates, candle, vols, dateIndex, zoomStart, alignedSeries } = useMemo(() => { @@ -221,6 +225,8 @@ export function AnalysisKChart({ const series: any[] = [ { name: 'K', type: 'candlestick', data: candle, animation: false, + // z=2 让蜡烛始终在价位线(z=1)之上, hover 高亮价位线时不会被遮挡/变淡 + z: 2, itemStyle: { color: THEME.bull, color0: THEME.bear, borderColor: THEME.bull, borderColor0: THEME.bear, @@ -236,18 +242,31 @@ export function AnalysisKChart({ // 价位水平线 —— 用 line series(恒定值)画水平线,endLabel 显示标签文字; // 与通道曲线一致,标签落在右侧 grid.right 预留带(外侧),不压蜡烛。 + // hoveredKey 非空时:命中线加粗高亮,其它线淡化(opacity 0.15),形成聚焦效果。 + const dimming = hoveredKey != null for (const p of priceLines) { + const k = levelKey(p.type, p.value) + const hit = hoveredKey === k + const opacity = dimming ? (hit ? 1 : 0.12) : 0.7 + const width = hit ? 2 : 1 series.push({ - name: p.label, type: 'line', silent: true, animation: false, + name: p.label, type: 'line', silent: false, animation: false, symbol: 'none', data: dates.map(() => p.value), - lineStyle: { width: 1, color: p.color, type: 'dashed', opacity: 0.7 }, + // 默认 z=1 在蜡烛(z=2)之下; 命中时 zlevel=10 提到独立顶层, 标签不再被遮挡 + z: 1, + zlevel: hit ? 10 : 0, + lineStyle: { width, color: p.color, type: 'dashed', opacity }, itemStyle: { color: p.color }, endLabel: { show: true, formatter: () => `${p.label} ${p.value.toFixed(2)}`, - color: p.color, fontSize: 9, fontFamily: 'JetBrains Mono, monospace', - backgroundColor: 'rgba(15,23,42,0.85)', padding: [1, 4], borderRadius: 2, + color: p.color, fontSize: hit ? 10 : 9, fontFamily: 'JetBrains Mono, monospace', + fontWeight: hit ? 'bold' : 'normal', + backgroundColor: hit ? CT().tooltipBg : CT().infoBarBg, + borderColor: hit ? p.color : 'transparent', + borderWidth: hit ? 1 : 0, + padding: [2, 5], borderRadius: 2, distance: 6, }, }) @@ -264,22 +283,47 @@ export function AnalysisKChart({ for (let i = data.length - 1; i >= 0; i--) { if (data[i] != null) { lastVal = data[i]; break } } + // 曲线 key 用 group(同组上下轨联动),hover 命中时高亮 + const hit = hoveredKey === def.group + const opacity = dimming ? (hit ? 1 : 0.12) : 0.8 + const width = hit ? 1.8 : 1 series.push({ name: def.endLabel, type: 'line', data: data.map(v => v ?? '-'), - smooth: true, symbol: 'none', silent: true, animation: false, - lineStyle: { width: 1, color: def.color, type: def.dashed === false ? 'solid' : 'dashed', opacity: 0.8 }, + smooth: true, symbol: 'none', silent: false, animation: false, + z: 1, + zlevel: hit ? 10 : 0, + lineStyle: { width, color: def.color, type: def.dashed === false ? 'solid' : 'dashed', opacity }, itemStyle: { color: def.color }, // 右侧端点标签:显示该通道的最新数值,距绘图区右缘留 6px 间距 endLabel: lastVal != null ? { show: true, formatter: () => `${lastVal!.toFixed(2)}`, - color: def.color, fontSize: 9, fontFamily: 'JetBrains Mono, monospace', - backgroundColor: 'rgba(15,23,42,0.85)', padding: [1, 4], borderRadius: 2, + color: def.color, fontSize: hit ? 10 : 9, fontFamily: 'JetBrains Mono, monospace', + fontWeight: hit ? 'bold' : 'normal', + backgroundColor: hit ? CT().tooltipBg : CT().infoBarBg, + borderColor: hit ? def.color : 'transparent', + borderWidth: hit ? 1 : 0, + padding: [2, 5], borderRadius: 2, distance: 6, } : undefined, }) } + // 填充 seriesIndex → levelKey 映射(K/成交量索引 0/1 不参与联动) + const keyMap = new Map() + // series[0]=K线, series[1]=成交量, 之后是按 priceLines + CURVE_DEFS 顺序 push 的 + let si = 2 + for (const p of priceLines) { + keyMap.set(si++, levelKey(p.type, p.value)) + } + for (const def of CURVE_DEFS) { + if (!activeTypes.has(def.group)) continue + const data = alignedSeries[def.alignedKey] + if (!data || !data.some(v => v != null)) continue + keyMap.set(si++, def.group) + } + seriesKeyMapRef.current = keyMap + return { animation: false, backgroundColor: 'transparent', @@ -336,10 +380,18 @@ export function AnalysisKChart({ onDateClick(dates[params.dataIndex]) } }) + // hover 价位线/曲线 endLabel → 联动高亮(与下方文字行双向联动) + chartInstRef.current.on('mouseover', (params: any) => { + if (params.componentType === 'series') { + const k = seriesKeyMapRef.current.get(params.seriesIndex as number) + if (k) setHoveredKey(k) + } + }) + chartInstRef.current.on('globalout', () => setHoveredKey(null)) } chartInstRef.current.setOption(buildOption(), true) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rows, levels, series, seriesDates, activeTypes, pivotRank, markers, ranges, height, theme]) + }, [rows, levels, series, seriesDates, activeTypes, pivotRank, markers, ranges, height, theme, hoveredKey]) // resize useEffect(() => { @@ -424,6 +476,8 @@ export function AnalysisKChart({ activeTypes={activeTypes} pivotRank={pivotRank} close={rows.length ? rows[rows.length - 1].close : undefined} + hoveredKey={hoveredKey} + onHover={setHoveredKey} /> )} @@ -432,12 +486,14 @@ export function AnalysisKChart({ // ===== 价位统计面板(图表下方,结构化文本展示) ===== function LevelOverview({ - levels, activeTypes, pivotRank, close, + levels, activeTypes, pivotRank, close, hoveredKey, onHover, }: { levels: Record activeTypes: Set pivotRank: 1 | 2 | 3 close?: number + hoveredKey: string | null + onHover: (k: string | null) => void }) { // 收集当前显示的点位(同 collectPriceLines 的过滤逻辑) const visible: PriceLevel[] = [] @@ -469,11 +525,21 @@ function LevelOverview({ const Row = ({ p }: { p: PriceLevel }) => { const color = LEVEL_GROUPS.find(g => g.key === p.type)?.color ?? CT().text + const k = levelKey(p.type, p.value) + const hit = hoveredKey === k + const dim = hoveredKey != null && !hit return ( -
- - {p.label} - {p.value.toFixed(2)} +
onHover(k)} + onMouseLeave={() => onHover(null)} + className={`flex items-center gap-2 py-0.5 px-1.5 -mx-1.5 rounded transition-colors cursor-default ${ + hit ? 'bg-elevated/60' : '' + }`} + style={dim ? { opacity: 0.35 } : undefined} + > + + {p.label} + {p.value.toFixed(2)} {fmtPct(p.value)}
) @@ -518,9 +584,9 @@ function collectPriceLines( levels: Record | undefined, active: Set, pivotRank: 1 | 2 | 3, -): { value: number; label: string; color: string }[] { +): { value: number; label: string; color: string; type: string }[] { if (!levels) return [] - const out: { value: number; label: string; color: string }[] = [] + const out: { value: number; label: string; color: string; type: string }[] = [] for (const g of LEVEL_GROUPS) { if (!active.has(g.key)) continue for (const p of levels[g.key] ?? []) { @@ -530,7 +596,7 @@ function collectPriceLines( // sr 组现为成交密集区水平点,直接画线即可,无需特判。 if (p.type === 'boll' || p.type === 'keltner_s' || p.type === 'keltner_m' || p.type === 'keltner_l' || p.type === 'atr_stop') continue - out.push({ value: p.value, label: p.label, color: strengthColor(p.strength, g.color) }) + out.push({ value: p.value, label: p.label, color: strengthColor(p.strength, g.color), type: p.type }) } } return out @@ -543,6 +609,11 @@ function strengthColor(strength: string | undefined, base: string): string { return base } +/** 价位唯一标识: 同类型同价格视为同一点位(用于联动高亮)。 */ +function levelKey(type: string, value: number): string { + return `${type}-${value.toFixed(2)}` +} + function fmtVol(v: number): string { if (!v) return '0' if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿' diff --git a/frontend/src/components/stock-table/MiniIntraday.tsx b/frontend/src/components/stock-table/MiniIntraday.tsx index 9849f59..f57a771 100644 --- a/frontend/src/components/stock-table/MiniIntraday.tsx +++ b/frontend/src/components/stock-table/MiniIntraday.tsx @@ -5,8 +5,10 @@ - 价格折线:涨(收盘 ≥ 昨收)红色,跌绿色 — 以昨收价(prevClose)为基准, 不是当日开盘价 - 昨收基准线:浅灰实线(从开到右),比虚线更明显 - 分时均线:黄色细线(成交均价,这里用 close 的累计均值近似) +- 价格线下方渐变填充: 顶部半透明实色 → 底部全透明(与个股对话框 EChartsIntraday 一致) 空数据返回等尺寸占位 SVG,保证加载前后尺寸一致(同 MiniCandlestick 模式)。 */ +import { useId } from 'react' import type { MinuteKlineRow } from '@/lib/api' export function MiniIntraday({ rows, prevClose, changePct, width = 100, height = 56 }: { @@ -80,16 +82,31 @@ export function MiniIntraday({ rows, prevClose, changePct, width = 100, height = // 均线 points const avgPoints = avgLine.map((v, i) => `${xScale(i).toFixed(1)},${yScale(v).toFixed(1)}`).join(' ') + // 渐变填充多边形 points: 价格折线 + 底部右下角 + 左下角, 闭合到画布底边 + const bottomY = H - padY + const areaPoints = `${pricePoints} ${xScale(n - 1).toFixed(1)},${bottomY.toFixed(1)} ${xScale(0).toFixed(1)},${bottomY.toFixed(1)}` + // 昨收参考线 y 坐标 const prevCloseY = yScale(baseline) + // 渐变 id 唯一化(自选列表同屏多张图, 避免互相覆盖) + const gradId = useId().replace(/:/g, '') + return ( + + + + + + {/* 昨收基准线 (深灰实线, 比虚线更明显) */} + {/* 价格折线下方渐变填充 */} + {/* 分时均线 (暖黄细线) */} + request<{ wecom_webhook_url: string }>('/api/settings/preferences/wecom-webhook', { + method: 'PUT', + body: JSON.stringify({ url }), + }), updateWebhookDefault: (enabled: boolean) => request<{ webhook_enabled_default: boolean }>('/api/settings/preferences/webhook-enabled-default', { method: 'PUT', @@ -1456,6 +1462,12 @@ export const api = { ) }, + extDataDetectUrl: (body: ExtDataDetectUrlRequest) => + request('/api/ext-data/detect-url', { + method: 'POST', + body: JSON.stringify(body), + }), + extDataFixSymbol: (id: string) => request<{ status: string; fixed_files: number }>( `/api/ext-data/${id}/fix-symbol`, @@ -2007,6 +2019,26 @@ export interface PullConfig { next_run?: string | null } +export interface ExtDataDetectUrlRequest { + url: string + method?: string + headers?: Record + body?: string + response_path?: string + field_map?: Record +} + +export interface ExtDataDetectUrlResult { + status: string + total_rows: number + response_path: string + response_path_candidates: string[] + fields: ExtDataField[] + symbol_candidates: string[] + code_candidates: string[] + preview: Record[] +} + export interface ExtDataConfig { id: string label: string diff --git a/frontend/src/pages/Review.tsx b/frontend/src/pages/Review.tsx index dbe2f46..ae0e2bb 100644 --- a/frontend/src/pages/Review.tsx +++ b/frontend/src/pages/Review.tsx @@ -104,8 +104,9 @@ export function Review() { const prefs = usePreferences() const reviewSched = prefs.data?.review_schedule ?? { enabled: false, hour: 15, minute: 10 } const feishuConfigured = !!(prefs.data?.feishu_webhook_url) + const wecomConfigured = !!(prefs.data?.wecom_webhook_url) // 推送渠道是独立的顶层偏好(多选), 与定时 / 实时行情无关, 常驻可单独设置 - // []=不推送, ['feishu']=飞书(微信开发中, 仅占位) + // []=不推送, ['feishu']=飞书, ['wecom']=企业微信 const reviewPushChannels = prefs.data?.review_push_channels ?? [] // 弹窗内的本地草稿: 开关和时间都在本地改, 点「保存」才真正提交(避免开关一拨就关弹窗) const [draft, setDraft] = useState(reviewSched) @@ -443,17 +444,31 @@ export function Review() { {feishuConfigured ? '已配置' : '未配置'}
- {/* 微信(开发中, 占位不可选) */} -
- - 微信 - 公众号/企业微信 - 开发中 -
+ {/* 企业微信(可用, 多选) */} +

- 手动或定时生成的复盘都会以卡片消息推送完整报告。复用「设置 → 实时监控」的飞书 Webhook。 - {reviewPushChannels.includes('feishu') && !feishuConfigured && ( + 手动或定时生成的复盘都会推送完整报告。复用「设置 → 实时监控」的 Webhook 配置。 + {((reviewPushChannels.includes('feishu') && !feishuConfigured) || (reviewPushChannels.includes('wecom') && !wecomConfigured)) && ( setShowSchedule(false)}> 前往配置 → diff --git a/frontend/src/pages/StockAnalysis.tsx b/frontend/src/pages/StockAnalysis.tsx index ba6af0f..8cc2fe0 100644 --- a/frontend/src/pages/StockAnalysis.tsx +++ b/frontend/src/pages/StockAnalysis.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useQuery } from '@tanstack/react-query' import { Sparkles, LineChart, History as HistoryIcon, Loader2, ExternalLink, Bell } from 'lucide-react' import { PageHeader } from '@/components/PageHeader' @@ -13,7 +13,7 @@ import { QK } from '@/lib/queryKeys' import { toast } from '@/components/Toast' import { startAnalysis, findTodayReport, useHistoryReports, - deleteReport, openHistoryReport, + deleteReport, openHistoryReport, loadHistory, } from '@/lib/stockAnalysisStore' /** @@ -29,14 +29,24 @@ export function StockAnalysis() { const [name, setName] = useState('') const [checking, setChecking] = useState(false) const [confirmReport, setConfirmReport] = useState<{ id: string; created_at: string; focus: string } | null>(null) - const [showHistory, setShowHistory] = useState(false) const [previewSymbol, setPreviewSymbol] = useState(null) const { last: lastStock, remember: rememberStock } = useLastStock('stock-analysis') + // 进入页面立即加载历史报告(供右侧常驻列表)。store 内部有 historyLoaded 去重, 重复调用安全。 + useEffect(() => { loadHistory() }, []) + + // 自动恢复上次选中的股票(切走再回来不丢)。useLastStock 的 last 来自 localStorage, 同步可用。 + useEffect(() => { + if (!symbol && lastStock) { + setSymbol(lastStock.symbol) + setName(lastStock.name) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + const onSelect = (sym: string, nm: string) => { setSymbol(sym) setName(nm) - setShowHistory(false) setConfirmReport(null) rememberStock(sym, nm) } @@ -77,15 +87,6 @@ export function StockAnalysis() { right={

- {symbol && ( - - )}
} /> @@ -130,18 +131,21 @@ export function StockAnalysis() { )} - {/* 主体 */} - {!symbol ? ( - - ) : showHistory ? ( - - ) : ( - - )} + {/* 主体:左侧当前个股看板 + 右侧常驻历史报告 */} +
+
+ {!symbol ? ( + + ) : ( + + )} +
+ +
{/* 二次确认:已有历史报告 */} @@ -230,41 +234,69 @@ function StockAnalysisBoard({ symbol }: { symbol: string }) { ) } -// ===== 历史报告列表 ===== -function HistoryList({ symbol }: { symbol: string }) { +// ===== 左侧常驻:历史报告侧栏(所有股票,按时间倒序平铺) ===== +function HistorySidebar() { const { reports, loaded } = useHistoryReports() - const mine = reports.filter(r => r.symbol === symbol) - - if (!loaded) { - return
- } - if (mine.length === 0) { - return - } return ( -
- {mine.map(r => ( -
-
- - -
+
+ ) } diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index 1b2b8bb..1d18f01 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, ListPlus } from 'lucide-react' +import { motion, AnimatePresence } from 'framer-motion' +import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus, HelpCircle } from 'lucide-react' import { api, type StrategyBacktestResult, @@ -89,6 +89,57 @@ const quickRangeTitle = (range: QuickRangeConfig) => range.unit === 'all' const INPUT_CLS = `w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth` +/** 建仓/清仓口径说明 — 黄色问号图标, 点击弹出气泡。 + * 用 fixed 定位脱离父容器 overflow 裁剪(左侧表单是 overflow-y-auto, absolute 气泡会被裁)。 */ +function FillRuleHint() { + const [open, setOpen] = useState(false) + const [pos, setPos] = useState<{ top: number; left: number } | null>(null) + const iconRef = useRef(null) + + const handleOpen = () => { + if (!open && iconRef.current) { + const r = iconRef.current.getBoundingClientRect() + setPos({ top: r.bottom + 4, left: r.left }) + } + setOpen(v => !v) + } + + // 气泡宽度 256px(w-64), 若右侧超出视口则向左对齐 + const bubbleLeft = pos ? Math.min(pos.left, window.innerWidth - 256 - 8) : 0 + + return ( +
+ + + {open && pos && ( + <> +
setOpen(false)} /> + e.stopPropagation()} + > +
成交口径说明
+
+
建仓默认次日开盘(避免未来函数)
+
清仓默认当日收盘(持仓中可盘中/收盘卖)
+
买卖点由策略触发器决定,这里只决定成交价。
+
+
+ + )} + +
+ ) +} + const SRC_MAP: Record = { builtin: '内置', custom: '自定义', ai: 'AI' } const TRADE_PAGE_SIZE_OPTIONS = [10, 20, 30, 50, 100] const BADGE_CLS_MAP: Record = { @@ -1263,7 +1314,10 @@ export function StrategyBacktest() {
- +
+ + +
-
建仓默认次日开盘(避免未来函数),清仓默认当日收盘(持仓中可盘中/收盘卖);买卖点由策略触发器决定,这里只决定成交价。
{simMode === 'position' && (
@@ -1303,16 +1356,20 @@ export function StrategyBacktest() { setMaxExposure(e.target.value)} className={INPUT_CLS} />
+
+ )} + {simMode === 'position' && ( +
- + setFees(e.target.value)} className={INPUT_CLS} />
- + setStampTax(e.target.value)} className={INPUT_CLS} />
- + setSlippage(e.target.value)} className={INPUT_CLS} />
diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx index 628d3d9..779e5e2 100644 --- a/frontend/src/pages/settings/Monitoring.tsx +++ b/frontend/src/pages/settings/Monitoring.tsx @@ -69,12 +69,21 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = const [feishuDraft, setFeishuDraft] = useState(feishuWebhookUrl) const [feishuSecretDraft, setFeishuSecretDraft] = useState(feishuWebhookSecret) const [feishuError, setFeishuError] = useState('') + // 企业微信 webhook + const wecomWebhookUrl = prefs?.wecom_webhook_url ?? '' + const [wecomDraft, setWecomDraft] = useState(wecomWebhookUrl) + const [wecomError, setWecomError] = useState('') // 飞书渠道配置区展开态 (推送通知卡片内) const [channelOpen, setChannelOpen] = useState(false) + // 企业微信渠道配置区展开态 + const [wecomOpen, setWecomOpen] = useState(false) useEffect(() => { setFeishuDraft(feishuWebhookUrl) setFeishuSecretDraft(feishuWebhookSecret) }, [feishuWebhookUrl, feishuWebhookSecret]) + useEffect(() => { + setWecomDraft(wecomWebhookUrl) + }, [wecomWebhookUrl]) const watchlistSymbols = prefs?.realtime_watchlist_symbols ?? [] const watchlist = useQuery({ queryKey: QK.watchlist, @@ -144,6 +153,26 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = saveFeishuWebhook.mutate({ url, secret }) }, [feishuDraft, feishuSecretDraft, saveFeishuWebhook]) + const saveWecomWebhook = useMutation({ + mutationFn: (url: string) => api.updateWecomWebhook(url), + onSuccess: () => { + setWecomError('') + toast('企业微信 Webhook 已保存', 'success') + qc.invalidateQueries({ queryKey: QK.preferences }) + }, + onError: (err: any) => setWecomError(String(err?.message ?? '保存失败')), + }) + const WECOM_PREFIX = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send' + const submitWecom = useCallback(() => { + const url = wecomDraft.trim() + // 允许完整 URL 或纯 key (36位UUID样式) + if (url && !url.startsWith(WECOM_PREFIX) && url.length < 20) { + setWecomError('请输入完整 Webhook 地址或纯 key (至少 20 位)') + return + } + saveWecomWebhook.mutate(url) + }, [wecomDraft, saveWecomWebhook]) + const runFix = useMutation({ mutationFn: () => api.runLimitLadderFix(), onSuccess: (data) => { @@ -390,7 +419,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
{/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。 - 飞书已实现; 微信开发中, QMT/ptrade 待定。 + 飞书 / 企业微信已实现; QMT/ptrade 待定。 每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */}

@@ -486,9 +515,82 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = )} + {/* 企业微信群机器人 (可用): 与飞书并列, 勾选默认 + 展开地址配置 */} +

+
setWecomOpen(o => !o)} + className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60" + > + { e.stopPropagation(); toggleWebhookDefault(e.target.checked) }} + onClick={e => e.stopPropagation()} + title="作为新建规则的默认推送渠道" + className="h-3 w-3 accent-accent cursor-pointer" + /> + 企业微信 + 群机器人 + {webhookDefault && ( + 默认 + )} + + {wecomWebhookUrl ? '已配置' : '未配置'} + + +
+ + {wecomOpen && ( +
+ + + {wecomError && ( +
{wecomError}
+ )} + +
+ + {wecomWebhookUrl && ( + ● 已配置 + )} +
+ +
+ 如何获取企业微信 Webhook 地址? +
    +
  1. 打开企业微信,进入目标群聊 → 右上角「...」→ 群机器人
  2. +
  3. 点击「添加」→ 选择「自定义机器人」→ 填写名字
  4. +
  5. 复制生成的 Webhook 地址(含 key 参数),粘贴到上方输入框
  6. +
  7. 也可只复制 key 参数部分(= 后面的内容)填入
  8. +
  9. 企业微信群的消息可同步到绑定的个人微信,实现"微信推送"
  10. +
+

+ 📖 官方文档: + + 群机器人使用指南 ↗ + +

+
+
+ )} +
+ {/* 占位渠道 — 不可点 */} {[ - { name: '微信', hint: '公众号/企业微信', status: '开发中' }, { name: 'QMT', hint: '量化交易终端', status: '待定' }, { name: 'ptrade', hint: '量化交易终端', status: '待定' }, ].map(ch => (