feat: 新增企业微信推送、个股分析页重构、价位算法优化及多处交互改进 (v0.1.82)

- 推送: 新增企业微信群机器人通道(与飞书并列), 监控告警/复盘报告多渠道分发
- 个股分析: 历史报告改为右侧常驻栏(展示全部股票报告), 进页面自动恢复上次选股
- 价位算法: 成交密集区改用换手率衰减模型(国内主流筹码分布); 缺口回补判定修正(必须完全穿越缺口)
- 回测: 回撤止盈改纯峰值口径(与 trailing_stop 一致); 佣金/印花税/滑点合并一行; 建仓口径加问号气泡说明
- 分时图: 自选列表迷你分时图加渐变填充(对齐个股对话框风格)
- 图表: 个股分析日K图右侧标签适配浅色主题; 价位标签与下方文字行双向 hover 高亮联动
This commit is contained in:
shy3130
2026-07-07 17:56:25 +08:00
parent 03e99bd05f
commit f8914c9140
17 changed files with 726 additions and 160 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
import sys
__version__ = "0.1.81"
__version__ = "0.1.82"
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
+26
View File
@@ -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
+5 -2
View File
@@ -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"))
# 止损/移损/回撤止盈: 价格跌破风控线触发
+101 -52
View File
@@ -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
+11
View File
@@ -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)
+21 -2
View File
@@ -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:
"""新建监控规则时是否默认勾选「飞书推送」。
+16 -9
View File
@@ -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)
+125
View File
@@ -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)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tickflow-stock-panel-frontend",
"private": true,
"version": "0.1.81",
"version": "0.1.82",
"type": "module",
"scripts": {
"dev": "vite",
@@ -125,11 +125,15 @@ export function AnalysisKChart({
}: Props) {
const chartRef = useRef<HTMLDivElement>(null)
const chartInstRef = useRef<ECharts | null>(null)
/** seriesIndex → levelKey 映射, buildOption 填充, ECharts hover 事件反查 */
const seriesKeyMapRef = useRef<Map<number, string>>(new Map())
// 主题: buildOption 内部用 CT() 动态取色, 这里只负责切换时触发重建
const theme = useTheme()
const [activeTypes, setActiveTypes] = useState<Set<LevelType>>(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<string | null>(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<number, string>()
// 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}
/>
)}
</div>
@@ -432,12 +486,14 @@ export function AnalysisKChart({
// ===== 价位统计面板(图表下方,结构化文本展示) =====
function LevelOverview({
levels, activeTypes, pivotRank, close,
levels, activeTypes, pivotRank, close, hoveredKey, onHover,
}: {
levels: Record<LevelType, PriceLevel[]>
activeTypes: Set<LevelType>
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 (
<div className="flex items-center gap-2 py-0.5">
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="text-[11px] text-secondary w-24 shrink-0 truncate">{p.label}</span>
<span className="text-[11px] font-mono text-foreground">{p.value.toFixed(2)}</span>
<div
onMouseEnter={() => 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}
>
<span className="h-1.5 w-1.5 rounded-full shrink-0 transition-transform" style={{ backgroundColor: color, transform: hit ? 'scale(1.5)' : 'scale(1)' }} />
<span className={`text-[11px] w-24 shrink-0 truncate ${hit ? 'text-foreground font-medium' : 'text-secondary'}`}>{p.label}</span>
<span className={`text-[11px] font-mono ${hit ? 'text-foreground font-bold' : 'text-foreground'}`}>{p.value.toFixed(2)}</span>
<span className="text-[9px] font-mono text-muted">{fmtPct(p.value)}</span>
</div>
)
@@ -518,9 +584,9 @@ function collectPriceLines(
levels: Record<LevelType, PriceLevel[]> | undefined,
active: Set<LevelType>,
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) + '亿'
@@ -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 (
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="block">
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={color} stopOpacity={0.4} />
<stop offset="1" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
{/* 昨收基准线 (深灰实线, 比虚线更明显) */}
<line
x1={0} y1={prevCloseY} x2={W} y2={prevCloseY}
stroke={LINE_PREV_CLOSE} strokeWidth={0.6} opacity={0.7}
/>
{/* 价格折线下方渐变填充 */}
<polygon points={areaPoints} fill={`url(#${gradId})`} stroke="none" />
{/* 分时均线 (暖黄细线) */}
<polyline
points={avgPoints}
+32
View File
@@ -789,6 +789,7 @@ export interface Preferences {
system_notify_enabled: boolean
feishu_webhook_url?: string
feishu_webhook_secret?: string
wecom_webhook_url?: string
webhook_enabled_default?: boolean
sidebar_index_symbols: string[]
nav_order: string[]
@@ -986,6 +987,11 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ url, secret }),
}),
updateWecomWebhook: (url: string) =>
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<ExtDataDetectUrlResult>('/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<string, string>
body?: string
response_path?: string
field_map?: Record<string, string>
}
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<string, unknown>[]
}
export interface ExtDataConfig {
id: string
label: string
+25 -10
View File
@@ -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 ? '已配置' : '未配置'}
</span>
</button>
{/* 微信(开发中, 占位不可选) */}
<div className="flex items-center gap-2 rounded-btn border border-border/40 bg-base/20 px-2.5 py-1.5 opacity-60">
<span className="flex h-3 w-3 shrink-0 items-center justify-center rounded border border-border" />
<span className="text-[11px] text-secondary"></span>
<span className="text-[9px] text-muted">/</span>
<span className="ml-auto rounded bg-muted/10 px-1 py-px text-[9px] text-muted"></span>
</div>
{/* 企业微信(可用, 多选) */}
<button
type="button"
disabled={pushMut.isPending}
onClick={() => togglePushChannel('wecom')}
className={cn(
'flex w-full items-center gap-2 rounded-btn border px-2.5 py-1.5 text-left transition-colors disabled:opacity-50',
reviewPushChannels.includes('wecom')
? 'border-accent/40 bg-accent/10'
: 'border-border/60 bg-base/40 hover:bg-base/60',
)}
>
<span className={cn('flex h-3 w-3 shrink-0 items-center justify-center rounded border', reviewPushChannels.includes('wecom') ? 'border-accent bg-accent text-white' : 'border-border')}>
{reviewPushChannels.includes('wecom') && <Check className="h-2.5 w-2.5" />}
</span>
<span className="text-[11px] text-foreground"></span>
<span className="text-[9px] text-muted"></span>
<span className={cn('ml-auto text-[9px]', wecomConfigured ? 'text-emerald-500' : 'text-warning')}>
{wecomConfigured ? '已配置' : '未配置'}
</span>
</button>
</div>
<p className="mt-1.5 text-[10px] leading-relaxed text-muted/70">
Webhook
{reviewPushChannels.includes('feishu') && !feishuConfigured && (
Webhook
{((reviewPushChannels.includes('feishu') && !feishuConfigured) || (reviewPushChannels.includes('wecom') && !wecomConfigured)) && (
<Link to="/settings?tab=monitoring" className="ml-1 text-accent hover:underline" onClick={() => setShowSchedule(false)}>
</Link>
+88 -56
View File
@@ -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<string>('')
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<string | null>(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={
<div className="flex items-center gap-2">
<LastStockChip stock={lastStock} onSelect={onSelect} />
{symbol && (
<button
onClick={() => setShowHistory(v => !v)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn border border-border text-secondary text-xs hover:text-foreground hover:bg-elevated transition-colors"
>
<HistoryIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
}
/>
@@ -130,18 +131,21 @@ export function StockAnalysis() {
)}
</div>
{/* 主体 */}
{!symbol ? (
<EmptyState
icon={LineChart}
title="选择一只股票开始分析"
hint="搜索代码或名称,查看日 K 与关键价位,并可让 AI 进行技术面 / 基本面 / 财务面 / 消息面四维综合分析。"
/>
) : showHistory ? (
<HistoryList symbol={symbol} />
) : (
<StockAnalysisBoard symbol={symbol} />
)}
{/* 主体:左侧当前个股看板 + 右侧常驻历史报告 */}
<div className="grid grid-cols-[1fr_288px] gap-6 items-start">
<div className="min-w-0">
{!symbol ? (
<EmptyState
icon={LineChart}
title="选择一只股票开始分析"
hint="搜索代码或名称,查看日 K 与关键价位,并可让 AI 进行技术面 / 基本面 / 财务面 / 消息面四维综合分析。"
/>
) : (
<StockAnalysisBoard symbol={symbol} />
)}
</div>
<HistorySidebar />
</div>
</div>
{/* 二次确认:已有历史报告 */}
@@ -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 <div className="flex items-center justify-center py-20"><Loader2 className="h-5 w-5 animate-spin text-muted" /></div>
}
if (mine.length === 0) {
return <EmptyState icon={HistoryIcon} title="暂无历史报告" hint={`还没有 ${symbol} 的个股分析报告,点击「AI 个股分析」生成第一份。`} />
}
return (
<div className="space-y-2">
{mine.map(r => (
<div key={r.id} className="rounded-card border border-border/60 bg-surface/40 p-3 hover:border-border transition-colors">
<div className="flex items-center justify-between gap-3">
<button onClick={() => openHistoryReport(r.id)} className="flex-1 text-left min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-secondary">{fmtRelative(r.created_at)}</span>
{r.close && <span className="text-[10px] font-mono text-muted"> {r.close.toFixed(2)}</span>}
{r.focus && <span className="text-[10px] text-sky-300/70 truncate">: {r.focus}</span>}
</div>
<div className="mt-1 text-xs text-muted truncate">{r.summary || '点击查看完整报告'}</div>
</button>
<button
onClick={() => { deleteReport(r.id); toast('已删除', 'success') }}
className="shrink-0 text-[10px] text-muted hover:text-danger transition-colors px-2 py-1"
>
</button>
</div>
<aside className="self-start sticky top-0">
<div className="rounded-card border border-border/60 bg-surface/40 overflow-hidden">
<div className="px-3 py-2.5 border-b border-border/40 flex items-center gap-2">
<HistoryIcon className="h-3.5 w-3.5 text-sky-400 shrink-0" />
<span className="text-xs font-medium text-foreground"></span>
{loaded && reports.length > 0 && (
<span className="ml-auto text-[10px] text-muted">{reports.length}</span>
)}
</div>
))}
</div>
{!loaded ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-4 w-4 animate-spin text-muted" />
</div>
) : reports.length === 0 ? (
<div className="px-3 py-10 text-center">
<p className="text-xs text-muted"></p>
<p className="text-[10px] text-muted/60 mt-1">,AI </p>
</div>
) : (
<div className="max-h-[calc(100vh-220px)] overflow-y-auto p-2 space-y-1.5">
{reports.map(r => (
<div
key={r.id}
className="group rounded-lg border border-border/40 bg-elevated/20 p-2.5 hover:border-border hover:bg-elevated/40 transition-colors"
>
<div className="flex items-center justify-between gap-2">
<button
onClick={() => openHistoryReport(r.id)}
className="flex-1 text-left min-w-0"
>
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-xs font-medium text-foreground truncate">{r.name || r.symbol}</span>
<span className="text-[10px] font-mono text-muted shrink-0">{r.symbol}</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted">
<span>{fmtRelative(r.created_at)}</span>
{r.close != null && <span className="font-mono"> {r.close.toFixed(2)}</span>}
{r.focus && <span className="text-sky-300/70 truncate">: {r.focus}</span>}
</div>
{r.summary && (
<div className="mt-1 text-[11px] text-muted truncate">{r.summary}</div>
)}
</button>
<button
onClick={() => { deleteReport(r.id); toast('已删除', 'success') }}
className="shrink-0 text-[10px] text-muted/60 hover:text-danger transition-colors px-1 py-0.5 opacity-0 group-hover:opacity-100"
title="删除"
>
</button>
</div>
</div>
))}
</div>
)}
</div>
</aside>
)
}
@@ -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<HTMLDivElement>(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 (
<div ref={iconRef} className="relative inline-flex items-center">
<HelpCircle
className="h-3.5 w-3.5 text-yellow-500/80 hover:text-yellow-500 cursor-help transition-colors"
onClick={handleOpen}
/>
<AnimatePresence>
{open && pos && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.95 }}
transition={{ duration: 0.15 }}
style={{ top: pos.top, left: bubbleLeft }}
className="fixed z-50 w-64 bg-surface border border-border rounded-md shadow-xl p-3 text-[11px] text-secondary leading-relaxed"
onClick={e => e.stopPropagation()}
>
<div className="font-medium text-foreground mb-1.5"></div>
<div className="space-y-1">
<div><b className="text-foreground"></b><b className="text-foreground"></b>()</div>
<div><b className="text-foreground"></b><b className="text-foreground"></b>(/)</div>
<div><b className="text-foreground"></b>,</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
)
}
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI' }
const TRADE_PAGE_SIZE_OPTIONS = [10, 20, 30, 50, 100]
const BADGE_CLS_MAP: Record<string, string> = {
@@ -1263,7 +1314,10 @@ export function StrategyBacktest() {
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-secondary block mb-1.5"></label>
<div className="flex items-center gap-1 mb-1.5">
<label className="text-xs font-medium text-secondary"></label>
<FillRuleHint />
</div>
<select value={entryFill} onChange={e => setEntryFill(e.target.value as any)} className={INPUT_CLS}>
<option value="open_t+1"></option>
<option value="close_t"></option>
@@ -1277,7 +1331,6 @@ export function StrategyBacktest() {
</select>
</div>
</div>
<div className="mt-1 text-[10px] leading-4 text-muted">/</div>
{simMode === 'position' && (
<div className="grid grid-cols-2 gap-2">
@@ -1303,16 +1356,20 @@ export function StrategyBacktest() {
<input type="number" min={0} max={100} value={maxExposure} onChange={e => setMaxExposure(e.target.value)}
className={INPUT_CLS} />
</div>
</div>
)}
{simMode === 'position' && (
<div className="grid grid-cols-3 gap-2">
<div>
<label className="text-xs font-medium text-secondary block mb-1.5">()</label>
<label className="text-[10px] font-medium text-secondary block mb-1"> </label>
<input type="number" min={0} value={fees} onChange={e => setFees(e.target.value)} className={INPUT_CLS} />
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5">()</label>
<label className="text-[10px] font-medium text-secondary block mb-1"> </label>
<input type="number" min={0} value={stampTax} onChange={e => setStampTax(e.target.value)} className={INPUT_CLS} />
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5">()</label>
<label className="text-[10px] font-medium text-secondary block mb-1"> </label>
<input type="number" min={0} value={slippage} onChange={e => setSlippage(e.target.value)} className={INPUT_CLS} />
</div>
</div>
+104 -2
View File
@@ -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 } =
</div>
{/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。
飞书已实现; 微信开发中, QMT/ptrade 待定。
飞书 / 企业微信已实现; QMT/ptrade 待定。
每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */}
<Card icon={Webhook} title="推送通知">
<p className="text-xs text-secondary mb-3">
@@ -486,9 +515,82 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
)}
</div>
{/* 企业微信群机器人 (可用): 与飞书并列, 勾选默认 + 展开地址配置 */}
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
<div
onClick={() => setWecomOpen(o => !o)}
className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60"
>
<input
type="checkbox"
checked={webhookDefault}
onChange={e => { e.stopPropagation(); toggleWebhookDefault(e.target.checked) }}
onClick={e => e.stopPropagation()}
title="作为新建规则的默认推送渠道"
className="h-3 w-3 accent-accent cursor-pointer"
/>
<span className="text-[11px] font-medium text-foreground"></span>
<span className="text-[9px] text-muted"></span>
{webhookDefault && (
<span className="rounded bg-accent/15 px-1 py-px text-[9px] text-accent"></span>
)}
<span className={`ml-auto text-[9px] ${wecomWebhookUrl ? 'text-emerald-500' : 'text-warning'}`}>
{wecomWebhookUrl ? '已配置' : '未配置'}
</span>
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${wecomOpen ? 'rotate-180' : ''}`} />
</div>
{wecomOpen && (
<div className="border-t border-border/60 bg-base/30 p-3">
<label className="block space-y-1.5">
<span className="text-[11px] text-muted">Webhook Key</span>
<input
value={wecomDraft}
onChange={e => setWecomDraft(e.target.value)}
placeholder={WECOM_PREFIX + '?key=xxxxxxxx' + ' 或直接填 key'}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
/>
</label>
{wecomError && (
<div className="mt-2 text-[11px] text-danger">{wecomError}</div>
)}
<div className="mt-2 flex items-center gap-2">
<button
onClick={submitWecom}
disabled={saveWecomWebhook.isPending || wecomDraft.trim() === wecomWebhookUrl}
className="px-3 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer hover:bg-accent/90 transition-colors"
>
{saveWecomWebhook.isPending ? '保存中…' : '保存'}
</button>
{wecomWebhookUrl && (
<span className="text-[10px] text-emerald-500"> </span>
)}
</div>
<details className="mt-3 text-[10px] text-muted">
<summary className="cursor-pointer hover:text-secondary"> Webhook ?</summary>
<ol className="mt-1.5 space-y-1 pl-4 list-decimal leading-relaxed">
<li>, ... <b></b></li>
<li> <b></b> </li>
<li> <b>Webhook </b>( key ),</li>
<li> key (= )</li>
<li>,"微信推送"</li>
</ol>
<p className="mt-1.5 pl-4 text-muted/70">
📖 :
<a href="https://developer.work.weixin.qq.com/document/path/91770" target="_blank" rel="noreferrer" className="text-accent hover:text-accent/80">
使
</a>
</p>
</details>
</div>
)}
</div>
{/* 占位渠道 — 不可点 */}
{[
{ name: '微信', hint: '公众号/企业微信', status: '开发中' },
{ name: 'QMT', hint: '量化交易终端', status: '待定' },
{ name: 'ptrade', hint: '量化交易终端', status: '待定' },
].map(ch => (