mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
Merge pull request #31 from shy3130/feat/v0.1.65-monitor-backtest-webhook
release: v0.1.65 监控增强 + 回测止盈 + 飞书推送
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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。"""
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -110,6 +110,9 @@ def run_now(
|
||||
today = _date.today()
|
||||
today_exists = latest_daily and latest_daily >= today
|
||||
new_daily_days = 0
|
||||
# 日K范围拉取的起点(分支3补缺口/分支4首次); 实时增量/跳过时为 None。
|
||||
# 供 Step 1.5 除权因子回溯范围对齐: 范围拉取→用日K范围, 非范围→最近N天兜底。
|
||||
daily_range_start: _date | None = None
|
||||
|
||||
# A 股日K拉取开关(默认开);关闭时跳过日K同步,保留已有数据
|
||||
pull_a_share = _prefs.get_pipeline_pull_a_share()
|
||||
@@ -130,6 +133,7 @@ def run_now(
|
||||
# 也覆盖"今天已有数据但无实时行情权限(free/none)"的降级场景:
|
||||
# 此时 start_date = latest_daily = today,batch 刷新当天日K。
|
||||
start_date = latest_daily
|
||||
daily_range_start = start_date
|
||||
emit("sync_daily", 12, f"获取日K [{start_date} ~ {today}]…")
|
||||
logger.info("sync_daily: [%s ~ %s] %s", start_date, today,
|
||||
"refresh today" if today_exists else "gap fill")
|
||||
@@ -150,6 +154,7 @@ def run_now(
|
||||
else:
|
||||
# 首次:无任何数据 → batch 拉 1 年
|
||||
start_date = today - _td(days=365)
|
||||
daily_range_start = start_date
|
||||
emit("sync_daily", 12, f"获取日K [{start_date} ~ {today}]…")
|
||||
logger.info("sync_daily: [%s ~ %s] initial fetch", start_date, today)
|
||||
|
||||
@@ -167,36 +172,22 @@ def run_now(
|
||||
logger.info("sync_daily: [%s ~ %s] done", start_date, today)
|
||||
_invalidate("daily")
|
||||
|
||||
# Step 1.5: 增量同步除权因子 — 从已有数据最新日期的下一天开始获取
|
||||
# Step 1.5: 同步除权因子 — 范围与日K拉取方式对齐
|
||||
# 日K范围拉取(补缺口/首次) → 除权用日K范围 [daily_range_start, now]
|
||||
# 首次会覆盖整个日K区间内的历史除权事件; 补缺口天然只增量(起点=latest_daily≈昨天)
|
||||
# 日K实时增量/跳过(分支2/分支1) → 除权兜底拉最近 30 天, 补可能遗漏的新除权
|
||||
# (这两类分支不拉历史日K, 除权不能用日K范围, 只能兜底最近几日)
|
||||
written_adj = 0
|
||||
affected_symbols: list[str] = []
|
||||
if capset.has(Cap.ADJ_FACTOR):
|
||||
from datetime import datetime, timedelta
|
||||
adj_end = datetime.now()
|
||||
# 从已有除权因子数据的最新日期开始获取,避免重复拉取
|
||||
adj_factor_path = repo.store.data_dir / "adj_factor" / "all.parquet"
|
||||
fallback_start = adj_end - timedelta(days=30)
|
||||
if adj_factor_path.exists():
|
||||
try:
|
||||
from datetime import date as date_cls
|
||||
max_date = pl.scan_parquet(adj_factor_path).select(
|
||||
pl.col("trade_date").max()
|
||||
).collect().item()
|
||||
if max_date is not None:
|
||||
# trade_date 可能是 date / datetime / string 类型
|
||||
if isinstance(max_date, str):
|
||||
td = date_cls.fromisoformat(max_date)
|
||||
elif isinstance(max_date, datetime):
|
||||
td = max_date.date()
|
||||
else:
|
||||
td = max_date
|
||||
adj_start = datetime.combine(td, datetime.min.time())
|
||||
else:
|
||||
adj_start = fallback_start
|
||||
except Exception:
|
||||
adj_start = fallback_start
|
||||
if daily_range_start is not None:
|
||||
adj_start = datetime.combine(daily_range_start, datetime.min.time())
|
||||
else:
|
||||
adj_start = fallback_start
|
||||
# 日K实时增量/跳过时, 除权兜底拉最近 N 天, 覆盖周末/长假/停机期间的新除权事件。
|
||||
# 15 天: 覆盖春节/国庆最长约10天长假 + 故障恢复缓冲; sync_adj_factor 内部 merge+unique 幂等, 多拉无副作用。
|
||||
adj_start = adj_end - timedelta(days=15)
|
||||
adj_start_str = adj_start.strftime("%Y-%m-%d")
|
||||
adj_end_str = adj_end.strftime("%Y-%m-%d")
|
||||
emit("sync_adj", 50, f"获取除权因子 [{adj_start_str} ~ {adj_end_str}]…")
|
||||
@@ -312,33 +303,46 @@ def run_now(
|
||||
if pull_etf:
|
||||
_types.append("ETF")
|
||||
emit("sync_index", 88, f"同步{'+'.join(_types)}日K…")
|
||||
# 子阶段进度分配: 88.0(开始) → 89.0(完成), 指数占前半, ETF 占后半
|
||||
try:
|
||||
if pull_index:
|
||||
emit("sync_index", 88, "同步指数维表…")
|
||||
index_count = index_sync.sync_index_instruments(repo, pull_index=True, pull_etf=False)
|
||||
emit("sync_index", 88, f"指数维表完成,{index_count} 只")
|
||||
index_dir = repo.store.data_dir / "kline_index_enriched"
|
||||
index_dates = sorted(
|
||||
d.name[5:] for d in index_dir.glob("date=*")
|
||||
if d.is_dir() and d.name.startswith("date=")
|
||||
) if index_dir.exists() else []
|
||||
index_start = _date.fromisoformat(index_dates[-1]) if index_dates else today - _td(days=365)
|
||||
|
||||
def _index_chunk(cur: int, tot: int) -> None:
|
||||
emit("sync_index", 88, f"指数日K批次 {cur}/{tot}",
|
||||
stage_pct=int(100 * cur / tot) if tot else 100, skip_log=cur < tot)
|
||||
|
||||
written_index_daily = index_sync.sync_and_persist_index_daily(
|
||||
repo,
|
||||
capset,
|
||||
start_date=_dt.combine(index_start, _dt.min.time()),
|
||||
end_date=_dt.combine(today, _dt.min.time()),
|
||||
on_chunk_done=_index_chunk,
|
||||
)
|
||||
emit("sync_index", 88, f"指数日K完成,{written_index_daily} 行")
|
||||
_invalidate("index_instruments")
|
||||
_invalidate("index_daily")
|
||||
_invalidate("index_enriched")
|
||||
|
||||
if pull_etf:
|
||||
emit("sync_index", 88, "同步 ETF 维表…")
|
||||
etf_count = index_sync.sync_etf_instruments(repo)
|
||||
emit("sync_index", 88, f"ETF 维表完成,{etf_count} 只")
|
||||
etf_symbols: list[str] = []
|
||||
etf_inst = repo.get_etf_instruments()
|
||||
if not etf_inst.is_empty() and "symbol" in etf_inst.columns:
|
||||
etf_symbols = sorted(set(etf_inst["symbol"].to_list()))
|
||||
if etf_symbols and capset.has(Cap.ADJ_FACTOR):
|
||||
try:
|
||||
emit("sync_index", 88, "同步 ETF 除权因子…")
|
||||
from datetime import datetime, timedelta
|
||||
adj_end = datetime.now()
|
||||
adj_path = repo.store.data_dir / "adj_factor_etf" / "all.parquet"
|
||||
@@ -361,6 +365,7 @@ def run_now(
|
||||
end_time=adj_end,
|
||||
)
|
||||
etf_adj_symbols = len(affected_etfs)
|
||||
emit("sync_index", 88, f"ETF 除权因子完成,{etf_adj_symbols} 只")
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("ETF adj_factor skipped: %s", e)
|
||||
etf_dir = repo.store.data_dir / "kline_etf_enriched"
|
||||
@@ -369,12 +374,19 @@ def run_now(
|
||||
if d.is_dir() and d.name.startswith("date=")
|
||||
) if etf_dir.exists() else []
|
||||
etf_start = _date.fromisoformat(etf_dates[-1]) if etf_dates else today - _td(days=365)
|
||||
|
||||
def _etf_chunk(cur: int, tot: int) -> None:
|
||||
emit("sync_index", 88, f"ETF 日K批次 {cur}/{tot}",
|
||||
stage_pct=int(100 * cur / tot) if tot else 100, skip_log=cur < tot)
|
||||
|
||||
written_etf_daily = index_sync.sync_and_persist_etf_daily(
|
||||
repo,
|
||||
capset,
|
||||
start_date=_dt.combine(etf_start, _dt.min.time()),
|
||||
end_date=_dt.combine(today, _dt.min.time()),
|
||||
on_chunk_done=_etf_chunk,
|
||||
)
|
||||
emit("sync_index", 88, f"ETF 日K完成,{written_etf_daily} 行")
|
||||
_invalidate("etf_instruments")
|
||||
_invalidate("etf_daily")
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 开关控制)。
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -410,6 +410,10 @@ class MonitorRuleEngine:
|
||||
"change_pct": pct,
|
||||
"signals": hit_sigs,
|
||||
"severity": severity,
|
||||
# 触发条件快照 (signal/price/market 类型): 用于触发记录展示
|
||||
# 「命中了什么条件」。strategy 类型靠策略选股池 diff, 不写条件。
|
||||
"conditions": list(rule.get("conditions", [])) if rtype != "strategy" else [],
|
||||
"logic": rule.get("logic", "and") if rtype != "strategy" else "and",
|
||||
}
|
||||
events.append(ev)
|
||||
if self._alert_handler:
|
||||
|
||||
@@ -1,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"
|
||||
|
||||
Generated
+1
-1
@@ -2491,7 +2491,7 @@ all = [
|
||||
|
||||
[[package]]
|
||||
name = "tickflow-stock-panel-backend"
|
||||
version = "0.1.63"
|
||||
version = "0.1.64"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tickflow-stock-panel-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.64",
|
||||
"version": "0.1.65",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import { useState } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Check, GripVertical } from 'lucide-react'
|
||||
import { storage } from '@/lib/storage'
|
||||
|
||||
export type CardKey =
|
||||
@@ -12,20 +29,26 @@ interface CardDef {
|
||||
desc: string
|
||||
/** 档位能力不足时该卡片是否默认隐藏(减少干扰) */
|
||||
defaultHiddenIfNoCap: boolean
|
||||
/** 无条件默认隐藏(用户可在设置里手动开启) */
|
||||
defaultHidden?: boolean
|
||||
}
|
||||
|
||||
/** 数据画像卡片定义 —— 顺序即弹窗展示顺序 */
|
||||
/** 数据画像卡片定义 —— 默认顺序即此数组顺序 */
|
||||
export const DATA_CARD_DEFS: CardDef[] = [
|
||||
{ key: 'instruments', label: '个股维表', desc: 'A 股股票元数据', defaultHiddenIfNoCap: false },
|
||||
{ key: 'daily', label: '日 K', desc: 'A 股日K线数据', defaultHiddenIfNoCap: false },
|
||||
{ key: 'adj_factor', label: '除权因子', desc: '复权计算因子', defaultHiddenIfNoCap: true },
|
||||
{ key: 'enriched', label: 'Enriched', desc: '技术指标计算结果', defaultHiddenIfNoCap: false },
|
||||
{ key: 'index', label: '指数', desc: '主要市场指数日K', defaultHiddenIfNoCap: false },
|
||||
{ key: 'etf', label: 'ETF', desc: '场内交易基金日K', defaultHiddenIfNoCap: false },
|
||||
{ key: 'adj_factor', label: '除权因子', desc: '复权计算因子', defaultHiddenIfNoCap: true },
|
||||
{ key: 'etf', label: 'ETF', desc: '场内交易基金日K', defaultHiddenIfNoCap: false, defaultHidden: true },
|
||||
{ key: 'minute', label: '分钟 K', desc: '分钟级K线(需 Pro+)', defaultHiddenIfNoCap: true },
|
||||
{ key: 'financials', label: '财务数据', desc: '财报数据(需 Expert)', defaultHiddenIfNoCap: true },
|
||||
]
|
||||
|
||||
const DEFAULT_ORDER = DATA_CARD_DEFS.map(d => d.key)
|
||||
/** 恢复默认时显示的卡片数量(按默认顺序取前 N 张) */
|
||||
const DEFAULT_VISIBLE_COUNT = 5
|
||||
|
||||
const CAP_KEY_MAP: Partial<Record<CardKey, string>> = {
|
||||
adj_factor: 'adj_factor',
|
||||
minute: 'kline.minute.batch',
|
||||
@@ -35,6 +58,7 @@ const CAP_KEY_MAP: Partial<Record<CardKey, string>> = {
|
||||
/**
|
||||
* 读取卡片显隐状态。结合档位能力决定默认值:
|
||||
* - 用户显式设置过 → 用设置值
|
||||
* - 未设置 + defaultHidden → 隐藏(无条件默认隐藏)
|
||||
* - 未设置 + defaultHiddenIfNoCap + 当前无能力 → 隐藏
|
||||
* - 其他 → 显示
|
||||
*/
|
||||
@@ -47,6 +71,8 @@ export function getCardVisibility(
|
||||
for (const def of DATA_CARD_DEFS) {
|
||||
if (def.key in override) {
|
||||
result[def.key] = override[def.key]
|
||||
} else if (def.defaultHidden) {
|
||||
result[def.key] = false
|
||||
} else {
|
||||
result[def.key] = def.defaultHiddenIfNoCap ? has(CAP_KEY_MAP[def.key] ?? '') : true
|
||||
}
|
||||
@@ -54,60 +80,102 @@ export function getCardVisibility(
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取卡片显示顺序。
|
||||
* - 用户拖拽设置过 → 用设置值(过滤掉已不存在的 key, 补齐新增的 key)
|
||||
* - 未设置 → 用 DATA_CARD_DEFS 默认顺序
|
||||
*/
|
||||
export function getCardOrder(): CardKey[] {
|
||||
const saved = storage.dataCardOrder.get([])
|
||||
if (!saved.length) return [...DEFAULT_ORDER]
|
||||
const known = new Set<CardKey>(DEFAULT_ORDER)
|
||||
const ordered = saved.filter(k => known.has(k as CardKey)) as CardKey[]
|
||||
// 补齐新增的 key(默认顺序里新增的卡片追加到末尾)
|
||||
for (const k of DEFAULT_ORDER) {
|
||||
if (!ordered.includes(k)) ordered.push(k)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
export function PageSettingsModal({
|
||||
caps,
|
||||
}: {
|
||||
caps: Record<string, unknown> | undefined
|
||||
}) {
|
||||
const [visible, setVisible] = useState<Record<string, boolean>>(() => getCardVisibility(caps))
|
||||
const [order, setOrder] = useState<CardKey[]>(() => getCardOrder())
|
||||
|
||||
const toggle = (key: CardKey) => {
|
||||
const next = { ...visible, [key]: !visible[key] }
|
||||
const persistVisible = (next: Record<string, boolean>) => {
|
||||
setVisible(next)
|
||||
storage.dataCardVisible.set(next)
|
||||
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
storage.dataCardVisible.set({})
|
||||
setVisible(getCardVisibility(caps))
|
||||
const persistOrder = (next: CardKey[]) => {
|
||||
setOrder(next)
|
||||
storage.dataCardOrder.set(next)
|
||||
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
|
||||
}
|
||||
|
||||
const toggle = (key: CardKey) => persistVisible({ ...visible, [key]: !(visible[key] ?? true) })
|
||||
|
||||
const reset = () => {
|
||||
// 恢复默认: 默认顺序 + 仅勾选前 5 张卡片, 其余隐藏
|
||||
const defaultOrder = [...DEFAULT_ORDER]
|
||||
const defaultVisible: Record<string, boolean> = {}
|
||||
defaultOrder.forEach((k, i) => { defaultVisible[k] = i < DEFAULT_VISIBLE_COUNT })
|
||||
storage.dataCardVisible.set(defaultVisible)
|
||||
storage.dataCardOrder.set(defaultOrder)
|
||||
setVisible(defaultVisible)
|
||||
setOrder(defaultOrder)
|
||||
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
const oldIdx = order.indexOf(active.id as CardKey)
|
||||
const newIdx = order.indexOf(over.id as CardKey)
|
||||
if (oldIdx < 0 || newIdx < 0) return
|
||||
persistOrder(arrayMove(order, oldIdx, newIdx))
|
||||
}
|
||||
|
||||
// 按 order 排序卡片定义
|
||||
const defByKey = new Map(DATA_CARD_DEFS.map(d => [d.key, d]))
|
||||
const orderedDefs = order.map(k => defByKey.get(k)!).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-xs text-secondary leading-relaxed">
|
||||
勾选要在数据画像区显示的卡片。未勾选的卡片将隐藏,不影响数据本身。
|
||||
拖动手柄调整卡片顺序,勾选控制显隐。未勾选的卡片将隐藏,不影响数据本身。
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{DATA_CARD_DEFS.map((def) => {
|
||||
const on = visible[def.key] ?? true
|
||||
return (
|
||||
<label
|
||||
key={def.key}
|
||||
className={`flex items-center gap-2.5 rounded-card border px-3 py-2 cursor-pointer transition-colors ${
|
||||
on ? 'border-accent/40 bg-accent/[0.05]' : 'border-border bg-base/30 hover:border-border/70'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(def.key)}
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
|
||||
on ? 'bg-accent border-accent' : 'bg-base border-border'
|
||||
}`}
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
>
|
||||
{on && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-foreground">{def.label}</div>
|
||||
<div className="text-[10px] text-muted leading-snug">{def.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={order} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-1.5">
|
||||
{orderedDefs.map((def) => {
|
||||
const on = visible[def.key] ?? true
|
||||
return (
|
||||
<SortableCardRow
|
||||
key={def.key}
|
||||
id={def.key}
|
||||
label={def.label}
|
||||
desc={def.desc}
|
||||
on={on}
|
||||
onToggle={() => toggle(def.key)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<div className="flex items-center justify-end pt-1">
|
||||
<button
|
||||
onClick={reset}
|
||||
@@ -119,3 +187,67 @@ export function PageSettingsModal({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 可拖拽的卡片行 ──
|
||||
function SortableCardRow({
|
||||
id, label, desc, on, onToggle,
|
||||
}: {
|
||||
id: CardKey
|
||||
label: string
|
||||
desc: string
|
||||
on: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center gap-2 rounded-card border px-3 py-2 transition-colors ${
|
||||
isDragging ? 'bg-elevated shadow-lg' : ''
|
||||
} ${on ? 'border-accent/40 bg-accent/[0.05]' : 'border-border bg-base/30'}`}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing text-muted hover:text-foreground transition-colors shrink-0"
|
||||
title="拖动排序"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
{/* 显隐勾选 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
|
||||
on ? 'bg-accent border-accent' : 'bg-base border-border'
|
||||
}`}
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
>
|
||||
{on && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-foreground">{label}</div>
|
||||
<div className="text-[10px] text-muted leading-snug">{desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Save, X, Plus, Search } from 'lucide-react'
|
||||
import { api, genRuleId, type MonitorRule, type MonitorCondition } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { SignalPicker } from '@/components/screener/SignalPicker'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
|
||||
interface Props {
|
||||
/** 编辑现有规则;null=新建 */
|
||||
@@ -42,9 +44,15 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions })
|
||||
const strategies = useQuery({ queryKey: QK.screenerStrategies, queryFn: api.screenerStrategies })
|
||||
const { data: prefs } = usePreferences()
|
||||
const feishuConfigured = !!(prefs?.feishu_webhook_url)
|
||||
const [editing] = useState(!!rule)
|
||||
// 新建规则: 预填全局「默认推送渠道」(飞书), preset 显式指定时以 preset 为准。
|
||||
// 编辑规则: 完全沿用规则自身配置, 不受默认值影响。
|
||||
const [draft, setDraft] = useState<MonitorRule>(
|
||||
rule ? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) } : emptyRule(preset),
|
||||
rule
|
||||
? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) }
|
||||
: { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!(prefs?.webhook_enabled_default) },
|
||||
)
|
||||
const [error, setError] = useState('')
|
||||
const [symbolQuery, setSymbolQuery] = useState('')
|
||||
@@ -368,21 +376,59 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Webhook 推送 (占位, 后续开发) */}
|
||||
{/* Webhook 推送 — 飞书可用, QMT/ptrade 待定 */}
|
||||
<div className="rounded-btn border border-border/40 bg-base/40 p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[11px] font-medium text-foreground">Webhook 推送</span>
|
||||
<span className="ml-1.5 rounded bg-muted/10 px-1 py-px text-[9px] text-muted">开发中</span>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-not-allowed opacity-50">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-medium text-foreground">Webhook 推送</span>
|
||||
<span className="text-[9px] text-muted">触发时推送告警到外部</span>
|
||||
</div>
|
||||
|
||||
{/* 渠道列表 */}
|
||||
<div className="space-y-1.5">
|
||||
{/* 飞书 (可用) */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!draft.webhook_enabled}
|
||||
onChange={e => setDraft(d => ({ ...d, webhook_enabled: e.target.checked }))}
|
||||
className="h-3 w-3 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-[11px] text-foreground">飞书</span>
|
||||
<span className="text-[9px] text-muted">群机器人</span>
|
||||
{draft.webhook_enabled && (
|
||||
<span className={`ml-auto text-[9px] ${feishuConfigured ? 'text-emerald-500' : 'text-warning'}`}>
|
||||
{feishuConfigured ? '已配置' : '未配置'}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* QMT (待定) */}
|
||||
<label className="flex items-center gap-2 cursor-not-allowed opacity-50">
|
||||
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
|
||||
<span className="text-[10px] text-muted">启用</span>
|
||||
<span className="text-[11px] text-secondary">QMT</span>
|
||||
<span className="rounded bg-muted/10 px-1 py-px text-[9px] text-muted">待定</span>
|
||||
</label>
|
||||
|
||||
{/* ptrade (待定) */}
|
||||
<label className="flex items-center gap-2 cursor-not-allowed opacity-50">
|
||||
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
|
||||
<span className="text-[11px] text-secondary">ptrade</span>
|
||||
<span className="rounded bg-muted/10 px-1 py-px text-[9px] text-muted">待定</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[10px] leading-relaxed text-muted">
|
||||
触发时把信号推送到外部软件 (如 QMT、掘金量化),实现自动下单。后续版本支持。
|
||||
</p>
|
||||
|
||||
{/* 飞书勾选但全局未配置 → 提示前往设置 */}
|
||||
{draft.webhook_enabled && !feishuConfigured && (
|
||||
<p className="text-[10px] leading-relaxed text-warning/80">
|
||||
飞书 Webhook 地址尚未配置,
|
||||
<Link to="/settings?tab=monitoring" className="text-accent hover:text-accent/80">前往设置页配置 →</Link>
|
||||
</p>
|
||||
)}
|
||||
{draft.webhook_enabled && feishuConfigured && (
|
||||
<p className="text-[10px] leading-relaxed text-muted">
|
||||
命中本规则时,告警将推送到设置页配置的飞书群。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -110,4 +110,6 @@ export const storage = {
|
||||
|
||||
/** 数据页画像卡片显隐 (卡片key → 是否显示) */
|
||||
dataCardVisible: kv<Record<string, boolean>>('data-card-visible'),
|
||||
/** 数据页画像卡片顺序 (卡片key 数组, 长度=卡片总数) */
|
||||
dataCardOrder: kv<string[]>('data-card-order'),
|
||||
} as const
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Database, Flame, Gauge, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
|
||||
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Database, Flame, Gauge, Info, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
@@ -583,6 +583,9 @@ export function Dashboard() {
|
||||
const latestDate = dataStatus.data?.enriched?.latest_date ?? null
|
||||
const currentDate = selectedDate ?? data.as_of ?? ''
|
||||
const quoteRunning = (!selectedDate || selectedDate === latestDate) && data.quote_status?.running
|
||||
// 实时模式: none / watchlist / full_market。
|
||||
// watchlist (Free 档) 仅自选 ≤5 只实时, 看板呈现的大盘数据实为盘后快照, 需提示避免误读。
|
||||
const quoteMode = data.quote_status?.mode as ('none' | 'watchlist' | 'full_market') | undefined
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-base p-3">
|
||||
@@ -650,6 +653,18 @@ export function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Free 档提示: 大盘看板为盘后数据, 仅自选股实时。避免用户误读为全市场实时。 */}
|
||||
{quoteMode === 'watchlist' && (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-card border border-amber-500/30 bg-amber-500/8 px-3 py-2 text-[11px] leading-relaxed">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<div className="min-w-0 flex-1 text-secondary">
|
||||
当前为「自选实时」模式,看板展示的大盘数据为<strong className="text-foreground">盘后快照</strong>(最新有数据日),并非盘中实时;
|
||||
仅自选股({data.quote_status?.watchlist_symbol_count ?? 0} 只)支持实时监控。
|
||||
<span className="ml-1 text-accent">全市场实时需 Starter+</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 grid grid-cols-4 gap-2">
|
||||
{data.indices.map(item => <IndexTicker key={item.symbol} item={item} />)}
|
||||
</div>
|
||||
|
||||
+170
-151
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
@@ -42,7 +42,7 @@ import { ExtendHistoryPanel } from '@/components/data/ExtendHistoryPanel'
|
||||
import { EnrichedRebuildPanel } from '@/components/data/EnrichedRebuildPanel'
|
||||
import { MinuteSyncConfig } from '@/components/data/MinuteSyncConfig'
|
||||
import { PipelineScopeConfig } from '@/components/data/PipelineScopeConfig'
|
||||
import { PageSettingsModal, getCardVisibility } from '@/components/data/PageSettingsModal'
|
||||
import { PageSettingsModal, getCardVisibility, getCardOrder, type CardKey } from '@/components/data/PageSettingsModal'
|
||||
import { QuoteConfigCard } from '@/components/data/QuoteConfigCard'
|
||||
import { EnrichedSchemaModal } from '@/components/data/SchemaModal'
|
||||
import { Skeleton } from '@/components/data/Skeleton'
|
||||
@@ -317,6 +317,171 @@ export function Data() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 按卡片 key 渲染对应的 StatCard (顺序由 getCardOrder 控制, 显隐由 cardVisible 控制)
|
||||
const renderStatCard = (k: CardKey): React.ReactNode => {
|
||||
switch (k) {
|
||||
case 'instruments':
|
||||
return (
|
||||
<StatCard
|
||||
title="个股维表"
|
||||
hint="盘前同步 · 元数据快照"
|
||||
stats={s?.instruments}
|
||||
isInstrument
|
||||
loading={isLoading}
|
||||
active={activeCard === 'instruments'}
|
||||
done={doneStages.has('instruments')}
|
||||
skipped={skippedCards.has('instruments')}
|
||||
stagePct={activeCard === 'instruments' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="instruments"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
auto
|
||||
onShowFields={() => setSchemaTable('instruments')}
|
||||
/>
|
||||
)
|
||||
case 'daily':
|
||||
return (
|
||||
<StatCard
|
||||
title="日 K"
|
||||
hint="增量同步 · 全市场"
|
||||
stats={s?.daily}
|
||||
loading={isLoading}
|
||||
active={activeCard === 'daily'}
|
||||
done={doneStages.has('daily')}
|
||||
skipped={skippedCards.has('daily')}
|
||||
stagePct={activeCard === 'daily' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="daily"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
auto
|
||||
onShowFields={() => setSchemaTable('daily')}
|
||||
onSettings={hasData ? () => setOpenSettings(v => v === 'daily' ? null : 'daily') : undefined}
|
||||
settingsOpen={openSettings === 'daily'}
|
||||
/>
|
||||
)
|
||||
case 'adj_factor':
|
||||
return (
|
||||
<StatCard
|
||||
title="除权因子"
|
||||
hint="增量同步 · 全市场"
|
||||
stats={s?.adj_factor}
|
||||
loading={isLoading}
|
||||
active={activeCard === 'adj_factor'}
|
||||
done={doneStages.has('adj_factor')}
|
||||
skipped={skippedCards.has('adj_factor')}
|
||||
stagePct={activeCard === 'adj_factor' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="adj_factor"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
auto
|
||||
onShowFields={() => setSchemaTable('adj_factor')}
|
||||
/>
|
||||
)
|
||||
case 'enriched':
|
||||
return (
|
||||
<StatCard
|
||||
title="Enriched"
|
||||
hint="复权 OHLCV + 技术指标"
|
||||
stats={s?.enriched}
|
||||
loading={isLoading}
|
||||
active={activeCard === 'enriched'}
|
||||
done={doneStages.has('enriched')}
|
||||
skipped={skippedCards.has('enriched')}
|
||||
stagePct={activeCard === 'enriched' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="enriched"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
auto
|
||||
subLabel="字段 · 指标 · 信号"
|
||||
localBadgeSuffix={`${prefs.data?.enriched_batch_size ?? 1000}只/批`}
|
||||
onShowFields={() => setSchemaTable('enriched')}
|
||||
onSettings={hasData ? () => setOpenSettings(v => v === 'enriched' ? null : 'enriched') : undefined}
|
||||
settingsOpen={openSettings === 'enriched'}
|
||||
/>
|
||||
)
|
||||
case 'index':
|
||||
return (
|
||||
<StatCard
|
||||
title="指数"
|
||||
hint="CN_Index · 独立存储"
|
||||
stats={indexOverviewStats}
|
||||
loading={isLoading}
|
||||
active={activeCard === 'index_daily'}
|
||||
done={doneStages.has('index_daily')}
|
||||
skipped={skippedCards.has('index_daily')}
|
||||
stagePct={activeCard === 'index_daily' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="daily"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
auto={indexAuto}
|
||||
subLabel={indexOverviewLabel}
|
||||
fieldTabs={[
|
||||
{ label: '维表', table: 'index_instruments' },
|
||||
{ label: '日K', table: 'index_daily' },
|
||||
{ label: '指标', table: 'index_enriched' },
|
||||
] as FieldTab[]}
|
||||
onShowFields={(t) => setSchemaTable(t ?? 'index_daily')}
|
||||
onSettings={hasData ? () => setOpenSettings(v => v === 'index' ? null : 'index') : undefined}
|
||||
settingsOpen={openSettings === 'index'}
|
||||
/>
|
||||
)
|
||||
case 'etf':
|
||||
return (
|
||||
<StatCard
|
||||
title="ETF"
|
||||
hint="场内基金 · 独立存储"
|
||||
stats={etfOverviewStats}
|
||||
loading={isLoading}
|
||||
tierKey="etf"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
auto={etfAuto}
|
||||
subLabel="维表 · 日K · 指标"
|
||||
fieldTabs={[
|
||||
{ label: '维表', table: 'etf_instruments' },
|
||||
{ label: '日K', table: 'etf_daily' },
|
||||
{ label: '指标', table: 'etf_enriched' },
|
||||
] as FieldTab[]}
|
||||
onShowFields={(t) => setSchemaTable(t ?? 'etf_daily')}
|
||||
/>
|
||||
)
|
||||
case 'minute':
|
||||
return (
|
||||
<StatCard
|
||||
title="分钟 K"
|
||||
hint="全市场同步"
|
||||
stats={s?.minute}
|
||||
loading={isLoading}
|
||||
active={activeCard === 'minute'}
|
||||
done={doneStages.has('minute')}
|
||||
skipped={skippedCards.has('minute')}
|
||||
stagePct={activeCard === 'minute' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="minute"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
auto={minuteAuto}
|
||||
onShowFields={() => setSchemaTable('minute')}
|
||||
onSettings={hasData ? () => setOpenSettings(v => v === 'minute' ? null : 'minute') : undefined}
|
||||
settingsOpen={openSettings === 'minute'}
|
||||
/>
|
||||
)
|
||||
case 'financials':
|
||||
return (
|
||||
<StatCard
|
||||
title="财务数据"
|
||||
hint="利润表 / 资负表 / 现金流 / 指标"
|
||||
stats={s?.financials ? { rows: s.financials.rows } : null}
|
||||
loading={isLoading}
|
||||
tierKey="financials"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={topRef} />
|
||||
@@ -620,155 +785,9 @@ export function Data() {
|
||||
<div>
|
||||
<SectionTitle icon={Database}>数据画像</SectionTitle>
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 items-stretch">
|
||||
{cardVisible.instruments && (
|
||||
<StatCard
|
||||
title="个股维表"
|
||||
hint="盘前同步 · 元数据快照"
|
||||
stats={s?.instruments}
|
||||
isInstrument
|
||||
loading={isLoading}
|
||||
active={activeCard === 'instruments'}
|
||||
done={doneStages.has('instruments')}
|
||||
skipped={skippedCards.has('instruments')}
|
||||
stagePct={activeCard === 'instruments' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="instruments"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
auto
|
||||
onShowFields={() => setSchemaTable('instruments')}
|
||||
/>
|
||||
)}
|
||||
{cardVisible.daily && (
|
||||
<StatCard
|
||||
title="日 K"
|
||||
hint="增量同步 · 全市场"
|
||||
stats={s?.daily}
|
||||
loading={isLoading}
|
||||
active={activeCard === 'daily'}
|
||||
done={doneStages.has('daily')}
|
||||
skipped={skippedCards.has('daily')}
|
||||
stagePct={activeCard === 'daily' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="daily"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
auto
|
||||
onShowFields={() => setSchemaTable('daily')}
|
||||
onSettings={hasData ? () => setOpenSettings(v => v === 'daily' ? null : 'daily') : undefined}
|
||||
settingsOpen={openSettings === 'daily'}
|
||||
/>
|
||||
)}
|
||||
{cardVisible.enriched && (
|
||||
<StatCard
|
||||
title="Enriched"
|
||||
hint="复权 OHLCV + 技术指标"
|
||||
stats={s?.enriched}
|
||||
loading={isLoading}
|
||||
active={activeCard === 'enriched'}
|
||||
done={doneStages.has('enriched')}
|
||||
skipped={skippedCards.has('enriched')}
|
||||
stagePct={activeCard === 'enriched' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="enriched"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
auto
|
||||
subLabel="字段 · 指标 · 信号"
|
||||
localBadgeSuffix={`${prefs.data?.enriched_batch_size ?? 1000}只/批`}
|
||||
onShowFields={() => setSchemaTable('enriched')}
|
||||
onSettings={hasData ? () => setOpenSettings(v => v === 'enriched' ? null : 'enriched') : undefined}
|
||||
settingsOpen={openSettings === 'enriched'}
|
||||
/>
|
||||
)}
|
||||
{cardVisible.index && (
|
||||
<StatCard
|
||||
title="指数"
|
||||
hint="CN_Index · 独立存储"
|
||||
stats={indexOverviewStats}
|
||||
loading={isLoading}
|
||||
active={activeCard === 'index_daily'}
|
||||
done={doneStages.has('index_daily')}
|
||||
skipped={skippedCards.has('index_daily')}
|
||||
stagePct={activeCard === 'index_daily' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="daily"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
auto={indexAuto}
|
||||
subLabel={indexOverviewLabel}
|
||||
fieldTabs={[
|
||||
{ label: '维表', table: 'index_instruments' },
|
||||
{ label: '日K', table: 'index_daily' },
|
||||
{ label: '指标', table: 'index_enriched' },
|
||||
] as FieldTab[]}
|
||||
onShowFields={(t) => setSchemaTable(t ?? 'index_daily')}
|
||||
onSettings={hasData ? () => setOpenSettings(v => v === 'index' ? null : 'index') : undefined}
|
||||
settingsOpen={openSettings === 'index'}
|
||||
/>
|
||||
)}
|
||||
{cardVisible.etf && (
|
||||
<StatCard
|
||||
title="ETF"
|
||||
hint="场内基金 · 独立存储"
|
||||
stats={etfOverviewStats}
|
||||
loading={isLoading}
|
||||
tierKey="etf"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
auto={etfAuto}
|
||||
subLabel="维表 · 日K · 指标"
|
||||
fieldTabs={[
|
||||
{ label: '维表', table: 'etf_instruments' },
|
||||
{ label: '日K', table: 'etf_daily' },
|
||||
{ label: '指标', table: 'etf_enriched' },
|
||||
] as FieldTab[]}
|
||||
onShowFields={(t) => setSchemaTable(t ?? 'etf_daily')}
|
||||
/>
|
||||
)}
|
||||
{cardVisible.adj_factor && (
|
||||
<StatCard
|
||||
title="除权因子"
|
||||
hint="增量同步 · 全市场"
|
||||
stats={s?.adj_factor}
|
||||
loading={isLoading}
|
||||
active={activeCard === 'adj_factor'}
|
||||
done={doneStages.has('adj_factor')}
|
||||
skipped={skippedCards.has('adj_factor')}
|
||||
stagePct={activeCard === 'adj_factor' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="adj_factor"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
auto
|
||||
onShowFields={() => setSchemaTable('adj_factor')}
|
||||
/>
|
||||
)}
|
||||
{cardVisible.minute && (
|
||||
<StatCard
|
||||
title="分钟 K"
|
||||
hint="全市场同步"
|
||||
stats={s?.minute}
|
||||
loading={isLoading}
|
||||
active={activeCard === 'minute'}
|
||||
done={doneStages.has('minute')}
|
||||
skipped={skippedCards.has('minute')}
|
||||
stagePct={activeCard === 'minute' ? (job.data?.stage_pct ?? 0) : 0}
|
||||
tierKey="minute"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
auto={minuteAuto}
|
||||
onShowFields={() => setSchemaTable('minute')}
|
||||
onSettings={hasData ? () => setOpenSettings(v => v === 'minute' ? null : 'minute') : undefined}
|
||||
settingsOpen={openSettings === 'minute'}
|
||||
/>
|
||||
)}
|
||||
{cardVisible.financials && (
|
||||
<StatCard
|
||||
title="财务数据"
|
||||
hint="利润表 / 资负表 / 现金流 / 指标"
|
||||
stats={s?.financials ? { rows: s.financials.rows } : null}
|
||||
loading={isLoading}
|
||||
tierKey="financials"
|
||||
capLimits={caps.data?.capabilities}
|
||||
tierLabel={caps.data?.label}
|
||||
/>
|
||||
)}
|
||||
{getCardOrder().filter(k => cardVisible[k]).map((k: CardKey) => (
|
||||
<Fragment key={k}>{renderStatCard(k)}</Fragment>
|
||||
))}
|
||||
{(extConfigs.data?.items ?? []).map((ext) => (
|
||||
<ExtDataStatCard
|
||||
key={ext.id}
|
||||
|
||||
@@ -181,7 +181,7 @@ export function Financials() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="w-full max-w-none px-8 py-6 space-y-6">
|
||||
<div className="px-8 py-6 space-y-6 max-w-7xl">
|
||||
{syncing && (
|
||||
<div className="flex items-center gap-2 rounded-card border border-accent/30 bg-accent/[0.06] px-3 py-2 text-xs text-accent">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { RadioTower, Plus, Trash2, Settings2, Zap, Bell, ListChecks, BellRing, TrendingUp, TrendingDown, Flame } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { api, type MonitorRule, type AlertEvent } from '@/lib/api'
|
||||
import { api, type MonitorRule, type AlertEvent, type MonitorCondition } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { fmtPrice, fmtPct } from '@/lib/format'
|
||||
import { cn } from '@/lib/cn'
|
||||
@@ -367,9 +367,33 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px]">{renderMessage(ev.source, ev.message)}</span>
|
||||
</div>
|
||||
{/* 详情行: 命中条件 (signal/price/market) + 当前价 / 或默认消息 */}
|
||||
{(ev.conditions && ev.conditions.length > 0) ? (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px]">
|
||||
<span className="text-muted">命中</span>
|
||||
{ev.conditions.map((c: MonitorCondition, ci: number) => (
|
||||
<span key={ci} className="inline-flex items-center gap-0.5">
|
||||
{ci > 0 && <span className="text-secondary">{ev.logic === 'or' ? '或' : '且'}</span>}
|
||||
{c.op === 'truth' ? (
|
||||
<span className="text-accent/80">{cnSignal(c.field)}</span>
|
||||
) : (
|
||||
<span className="text-foreground/80 font-mono">{cnSignal(c.field)}{c.op}{c.value}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{ev.price != null && (
|
||||
<>
|
||||
<span className="text-muted">·</span>
|
||||
<span className="text-muted">现价</span>
|
||||
<span className="font-mono text-foreground/90">{fmtPrice(ev.price)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px]">{renderMessage(ev.source, ev.message)}</span>
|
||||
</div>
|
||||
)}
|
||||
{ev.signals && ev.signals.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{ev.signals.map((s: string, j: number) => (
|
||||
|
||||
@@ -90,7 +90,7 @@ export function StockAnalysis() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="w-full max-w-none px-8 py-6 space-y-6">
|
||||
<div className="px-8 py-6 space-y-6 max-w-7xl">
|
||||
{/* 搜索栏 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-72">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap } from 'lucide-react'
|
||||
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus } from 'lucide-react'
|
||||
import {
|
||||
api,
|
||||
type StrategyBacktestResult,
|
||||
@@ -154,6 +154,7 @@ const buildDefaultOverrides = (detail: StrategyDetail) => ({
|
||||
exit_signals: detail.exit_signals.map(toSignalId),
|
||||
scoring: { ...detail.scoring },
|
||||
stop_loss: detail.stop_loss,
|
||||
take_profit: detail.take_profit,
|
||||
trailing_stop: detail.trailing_stop,
|
||||
trailing_take_profit_activate: detail.trailing_take_profit_activate,
|
||||
trailing_take_profit_drawdown: detail.trailing_take_profit_drawdown,
|
||||
@@ -192,6 +193,7 @@ function ExitReasonBadge({ reason }: { reason: string }) {
|
||||
const config: Record<string, { label: string; cls: string }> = {
|
||||
signal: { label: '信号', cls: 'bg-accent/10 text-accent border-accent/30' },
|
||||
stop_loss: { label: '止损', cls: 'bg-red-500/10 text-red-400 border-red-500/30' },
|
||||
take_profit: { label: '止盈', cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
|
||||
trailing_stop: { label: '移损', cls: 'bg-orange-500/10 text-orange-400 border-orange-500/30' },
|
||||
trailing_take_profit: { label: '回撤止盈', cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
|
||||
max_hold: { label: '超期', cls: 'bg-amber-400/10 text-amber-400 border-amber-400/30' },
|
||||
@@ -517,6 +519,12 @@ function StockPoolPicker({ value, onChange }: { value: string; onChange: (value:
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const results = search.data?.results ?? []
|
||||
// 自选列表 — 供「从自选导入」一键填入回测范围
|
||||
const watchlist = useQuery({
|
||||
queryKey: QK.watchlist,
|
||||
queryFn: () => api.watchlistList(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (results.length === 0) return
|
||||
@@ -545,43 +553,84 @@ function StockPoolPicker({ value, onChange }: { value: string; onChange: (value:
|
||||
setOpen(false)
|
||||
}
|
||||
const removeSymbol = (symbol: string) => setSymbols(symbols.filter(s => s !== symbol))
|
||||
// 一键导入自选: 合并去重, 顺带回填股票名
|
||||
const importFromWatchlist = () => {
|
||||
const entries = watchlist.data?.symbols ?? []
|
||||
if (entries.length === 0) return
|
||||
setSymbolNames(prev => {
|
||||
const next = { ...prev }
|
||||
entries.forEach(e => { if (e.name) next[e.symbol] = e.name })
|
||||
return next
|
||||
})
|
||||
setSymbols([...symbols, ...entries.map(e => e.symbol)])
|
||||
}
|
||||
const watchlistCount = watchlist.data?.symbols?.length ?? 0
|
||||
|
||||
return (
|
||||
<div className="space-y-2" ref={ref}>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setOpen(true) }}
|
||||
onFocus={() => { if (query.trim()) setOpen(true) }}
|
||||
placeholder="搜索股票名称/代码添加股票池"
|
||||
className="w-full rounded-input border border-border bg-surface py-1.5 pl-8 pr-2.5 text-xs focus:border-accent focus:outline-none"
|
||||
/>
|
||||
{open && results.length > 0 && (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-card border border-border bg-base shadow-xl">
|
||||
{results.map(r => {
|
||||
const added = symbols.includes(r.symbol)
|
||||
return (
|
||||
<button
|
||||
key={r.symbol}
|
||||
type="button"
|
||||
disabled={added}
|
||||
onClick={() => addSymbol(r.symbol, r.name)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${added ? 'cursor-default text-muted' : 'text-foreground hover:bg-elevated'}`}
|
||||
>
|
||||
<span className="w-[78px] shrink-0 font-mono">{r.symbol}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-secondary">{r.name}</span>
|
||||
<Plus className={`h-3.5 w-3.5 ${added ? 'opacity-30' : 'text-accent'}`} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setOpen(true) }}
|
||||
onFocus={() => { if (query.trim()) setOpen(true) }}
|
||||
placeholder="搜索股票名称/代码添加股票池"
|
||||
className="w-full rounded-input border border-border bg-surface py-1.5 pl-8 pr-2.5 text-xs focus:border-accent focus:outline-none"
|
||||
/>
|
||||
{open && results.length > 0 && (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-card border border-border bg-base shadow-xl">
|
||||
{results.map(r => {
|
||||
const added = symbols.includes(r.symbol)
|
||||
return (
|
||||
<button
|
||||
key={r.symbol}
|
||||
type="button"
|
||||
disabled={added}
|
||||
onClick={() => addSymbol(r.symbol, r.name)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${added ? 'cursor-default text-muted' : 'text-foreground hover:bg-elevated'}`}
|
||||
>
|
||||
<span className="w-[78px] shrink-0 font-mono">{r.symbol}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-secondary">{r.name}</span>
|
||||
<Plus className={`h-3.5 w-3.5 ${added ? 'opacity-30' : 'text-accent'}`} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 操作按钮 — 紧贴输入框右侧 */}
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{/* 当前范围 — 有范围显示个数, 无范围显示全市场 */}
|
||||
<span className={`whitespace-nowrap text-[11px] font-medium ${symbols.length === 0 ? 'text-amber-400' : 'text-accent'}`}>
|
||||
{symbols.length === 0 ? '全市场' : `共 ${symbols.length} 只`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={importFromWatchlist}
|
||||
disabled={watchlist.isLoading || watchlistCount === 0}
|
||||
className="inline-flex items-center gap-1 whitespace-nowrap rounded-input border border-border bg-surface px-2 py-1.5 text-[11px] text-secondary transition-colors hover:border-accent/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="把自选列表的个股加入回测范围"
|
||||
>
|
||||
<ListPlus className="h-3 w-3" />
|
||||
{watchlist.isLoading ? '加载…' : watchlistCount === 0 ? '自选空' : `导入自选(${watchlistCount})`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSymbols([])}
|
||||
disabled={symbols.length === 0}
|
||||
className="inline-flex items-center gap-1 whitespace-nowrap rounded-input border border-border bg-surface px-2 py-1.5 text-[11px] text-secondary transition-colors hover:border-danger/50 hover:text-danger disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="清空回测范围"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{symbols.length === 0 ? (
|
||||
<span className="text-[11px] font-medium text-amber-400">留空 = 全市场,由基础过滤和策略条件筛选。</span>
|
||||
<span className="text-[11px] text-muted">默认全市场回测,由基础过滤和策略条件筛选。</span>
|
||||
) : symbols.map(symbol => {
|
||||
const name = symbolNames[symbol]
|
||||
return (
|
||||
@@ -899,6 +948,7 @@ export function StrategyBacktest() {
|
||||
const scoreMinValue = overrides.score_min == null ? '' : String(overrides.score_min)
|
||||
const scoreMaxValue = overrides.score_max == null ? '' : String(overrides.score_max)
|
||||
const stopLossPct = overrides.stop_loss == null ? '' : String(Math.abs(Number(overrides.stop_loss)) * 100)
|
||||
const takeProfitPct = overrides.take_profit == null ? '' : String(Math.abs(Number(overrides.take_profit)) * 100)
|
||||
const trailingStopPct = overrides.trailing_stop == null ? '' : String(Math.abs(Number(overrides.trailing_stop)) * 100)
|
||||
const trailingTakeProfitActivatePct = overrides.trailing_take_profit_activate == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_activate)) * 100)
|
||||
const trailingTakeProfitDrawdownPct = overrides.trailing_take_profit_drawdown == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_drawdown)) * 100)
|
||||
@@ -942,6 +992,7 @@ export function StrategyBacktest() {
|
||||
`卖点 ${exitSignals.length}`,
|
||||
scoreFilterSummary,
|
||||
stopLossPct !== '' ? `止损 ${stopLossPct}%` : '止损未设',
|
||||
takeProfitPct !== '' ? `止盈 ${takeProfitPct}%` : '止盈未设',
|
||||
trailingStopPct !== '' ? `移损 ${trailingStopPct}%` : '移损未设',
|
||||
trailingTakeProfitActivatePct !== '' && trailingTakeProfitDrawdownPct !== '' ? `回撤 ${trailingTakeProfitActivatePct}-${trailingTakeProfitDrawdownPct}点` : '回撤未设',
|
||||
maxHoldDaysValue !== '' ? `最长 ${maxHoldDaysValue}天` : '不限持仓',
|
||||
@@ -1481,6 +1532,9 @@ export function StrategyBacktest() {
|
||||
{result.strategy_info.stop_loss != null && (
|
||||
<span className="text-[10px] text-secondary">止损 {fmtPct(result.strategy_info.stop_loss)}</span>
|
||||
)}
|
||||
{result.strategy_info.take_profit != null && (
|
||||
<span className="text-[10px] text-secondary">止盈 {fmtPct(result.strategy_info.take_profit)}</span>
|
||||
)}
|
||||
{result.strategy_info.trailing_stop != null && (
|
||||
<span className="text-[10px] text-secondary">移损 {fmtPct(result.strategy_info.trailing_stop)}</span>
|
||||
)}
|
||||
@@ -1869,7 +1923,7 @@ export function StrategyBacktest() {
|
||||
</div>
|
||||
|
||||
{settingsTab === 'range' && (
|
||||
<ConfigSection title="回测范围" hint={<span className="font-medium text-amber-400">留空 = 全市场</span>}>
|
||||
<ConfigSection title="回测范围">
|
||||
<StockPoolPicker value={symbols} onChange={setSymbols} />
|
||||
<div className="text-[11px] leading-5 text-muted">默认全市场回测,由基础过滤、策略条件和买卖触发器筛选;需要单票调试或自选池回测时再限定股票池。</div>
|
||||
</ConfigSection>
|
||||
@@ -2091,6 +2145,21 @@ export function StrategyBacktest() {
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[11px] text-secondary">止盈(%)</span>
|
||||
<input
|
||||
type="number"
|
||||
value={takeProfitPct}
|
||||
min={1}
|
||||
max={500}
|
||||
step={0.5}
|
||||
onChange={e => {
|
||||
const n = numOrNull(e.target.value)
|
||||
updateOverride('take_profit', n == null ? null : clamp(Math.abs(n), 1, 500) / 100)
|
||||
}}
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[11px] text-secondary">移动止损(%)</span>
|
||||
<input
|
||||
|
||||
@@ -39,6 +39,7 @@ const BUILTIN_PAGES: NavEntry[] = [
|
||||
{ id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true },
|
||||
{ id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
|
||||
{ id: '/stock-analysis', label: '个股分析', type: 'builtin', visible: true },
|
||||
{ id: '/review', label: '复盘', type: 'builtin', visible: true },
|
||||
{ id: '/financials', label: '财务分析', type: 'builtin', visible: true },
|
||||
{ id: '/indices', label: '指数', type: 'builtin', visible: true },
|
||||
{ id: '/trading', label: '交易', type: 'builtin', visible: true },
|
||||
|
||||
@@ -3,12 +3,12 @@ import { Link } from 'react-router-dom'
|
||||
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Activity,
|
||||
Shield,
|
||||
Wifi,
|
||||
BarChart3,
|
||||
Flame,
|
||||
Zap,
|
||||
Bell,
|
||||
Webhook,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
usePreferences,
|
||||
@@ -53,8 +53,9 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
|
||||
const refreshPages = prefs?.sse_refresh_pages ?? {}
|
||||
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
|
||||
const systemNotify = prefs?.system_notify_enabled ?? false
|
||||
const hasDepth = !!caps?.capabilities?.['depth5.batch']
|
||||
// 新建监控规则时是否默认勾选飞书推送 (全局默认值, 单条规则可独立修改)
|
||||
const webhookDefault = prefs?.webhook_enabled_default ?? false
|
||||
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol)
|
||||
const indicesPinned = prefs?.indices_nav_pinned ?? true
|
||||
const isRunning = quoteStatus?.running ?? false
|
||||
@@ -63,6 +64,17 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
const minInterval = intervalData?.min_interval ?? 5
|
||||
const maxInterval = intervalData?.max_interval ?? 60
|
||||
const [intervalDraft, setIntervalDraft] = useState(interval)
|
||||
const feishuWebhookUrl = prefs?.feishu_webhook_url ?? ''
|
||||
const feishuWebhookSecret = prefs?.feishu_webhook_secret ?? ''
|
||||
const [feishuDraft, setFeishuDraft] = useState(feishuWebhookUrl)
|
||||
const [feishuSecretDraft, setFeishuSecretDraft] = useState(feishuWebhookSecret)
|
||||
const [feishuError, setFeishuError] = useState('')
|
||||
// 飞书渠道配置区展开态 (推送通知卡片内)
|
||||
const [channelOpen, setChannelOpen] = useState(false)
|
||||
useEffect(() => {
|
||||
setFeishuDraft(feishuWebhookUrl)
|
||||
setFeishuSecretDraft(feishuWebhookSecret)
|
||||
}, [feishuWebhookUrl, feishuWebhookSecret])
|
||||
const watchlistSymbols = prefs?.realtime_watchlist_symbols ?? []
|
||||
const watchlist = useQuery({
|
||||
queryKey: QK.watchlist,
|
||||
@@ -107,11 +119,31 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
}, [qc])
|
||||
|
||||
const toggleSystemNotify = useCallback(async (enabled: boolean) => {
|
||||
await api.updateSystemNotify(enabled)
|
||||
const toggleWebhookDefault = useCallback(async (enabled: boolean) => {
|
||||
await api.updateWebhookDefault(enabled)
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
}, [qc])
|
||||
|
||||
const saveFeishuWebhook = useMutation({
|
||||
mutationFn: ({ url, secret }: { url: string; secret: string }) => api.updateFeishuWebhook(url, secret),
|
||||
onSuccess: () => {
|
||||
setFeishuError('')
|
||||
toast('飞书 Webhook 已保存', 'success')
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
},
|
||||
onError: (err: any) => setFeishuError(String(err?.message ?? '保存失败')),
|
||||
})
|
||||
const FEISHU_PREFIX = 'https://open.feishu.cn/open-apis/bot/v2/hook/'
|
||||
const submitFeishu = useCallback(() => {
|
||||
const url = feishuDraft.trim()
|
||||
const secret = feishuSecretDraft.trim()
|
||||
if (url && !url.startsWith(FEISHU_PREFIX)) {
|
||||
setFeishuError('地址需以 ' + FEISHU_PREFIX + ' 开头')
|
||||
return
|
||||
}
|
||||
saveFeishuWebhook.mutate({ url, secret })
|
||||
}, [feishuDraft, feishuSecretDraft, saveFeishuWebhook])
|
||||
|
||||
const runFix = useMutation({
|
||||
mutationFn: () => api.runLimitLadderFix(),
|
||||
onSuccess: (data) => {
|
||||
@@ -300,29 +332,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
|
||||
{/* ========== 右列 ========== */}
|
||||
<div className="space-y-6">
|
||||
{/* 策略监控已迁移至监控中心 */}
|
||||
<Card icon={Shield} title="策略监控">
|
||||
<p className="text-xs text-secondary mb-3">
|
||||
策略监控、个股信号监控、价格监控已统一到「监控中心」页面,支持灵活配置触发条件、冷却期和作用范围。
|
||||
</p>
|
||||
<a
|
||||
href="#/monitor"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/15 text-accent text-xs font-medium hover:bg-accent/25 transition-colors"
|
||||
>
|
||||
前往监控中心配置 →
|
||||
</a>
|
||||
<div className="mt-3 pt-3 border-t border-border">
|
||||
<ToggleRow
|
||||
icon={Bell}
|
||||
label="系统通知"
|
||||
desc="监控告警同时推送到操作系统通知中心(窗口最小化或后台也能收到)"
|
||||
checked={systemNotify}
|
||||
onChange={toggleSystemNotify}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 连板梯队降级修正 */}
|
||||
{/* 连板梯队降级修正 (移至右列顶部) */}
|
||||
<div
|
||||
id="depth-fix"
|
||||
className={`rounded-card transition-all duration-500 ${flash ? 'ring-2 ring-accent/60 ring-offset-2 ring-offset-base scale-[1.01]' : 'ring-0 ring-transparent'}`}
|
||||
@@ -368,6 +378,122 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。
|
||||
飞书已实现; 微信开发中, QMT/ptrade 待定。
|
||||
每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */}
|
||||
<Card icon={Webhook} title="推送通知">
|
||||
<p className="text-xs text-secondary mb-3">
|
||||
监控规则命中后,可把告警推送到外部。勾选渠道作为<b className="text-foreground/80">新建规则的默认推送</b>,
|
||||
单条规则仍可在编辑页独立修改。
|
||||
</p>
|
||||
|
||||
{/* 渠道列表 — 每行一个渠道, 勾选默认 + 点行展开地址配置 */}
|
||||
<div className="space-y-2">
|
||||
{/* 飞书 (可用): 勾选默认 + 展开地址配置 */}
|
||||
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
|
||||
<div
|
||||
onClick={() => setChannelOpen(o => !o)}
|
||||
className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={webhookDefault}
|
||||
onChange={e => { e.stopPropagation(); toggleWebhookDefault(e.target.checked) }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
title="作为新建规则的默认推送渠道"
|
||||
className="h-3 w-3 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-foreground">飞书</span>
|
||||
<span className="text-[9px] text-muted">群机器人</span>
|
||||
{webhookDefault && (
|
||||
<span className="rounded bg-accent/15 px-1 py-px text-[9px] text-accent">默认</span>
|
||||
)}
|
||||
<span className={`ml-auto text-[9px] ${feishuWebhookUrl ? 'text-emerald-500' : 'text-warning'}`}>
|
||||
{feishuWebhookUrl ? '已配置' : '未配置'}
|
||||
</span>
|
||||
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${channelOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
|
||||
{/* 飞书地址配置 — 行内展开 */}
|
||||
{channelOpen && (
|
||||
<div className="border-t border-border/60 bg-base/30 p-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[11px] text-muted">Webhook 地址</span>
|
||||
<input
|
||||
value={feishuDraft}
|
||||
onChange={e => setFeishuDraft(e.target.value)}
|
||||
placeholder={FEISHU_PREFIX + 'xxxxxxxx'}
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block mt-2 space-y-1.5">
|
||||
<span className="text-[11px] text-muted">签名密钥 (可选 · 启用签名校验时填)</span>
|
||||
<input
|
||||
type="password"
|
||||
value={feishuSecretDraft}
|
||||
onChange={e => setFeishuSecretDraft(e.target.value)}
|
||||
placeholder="机器人未启用签名校验则留空"
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{feishuError && (
|
||||
<div className="mt-2 text-[11px] text-danger">{feishuError}</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={submitFeishu}
|
||||
disabled={saveFeishuWebhook.isPending || (feishuDraft.trim() === feishuWebhookUrl && feishuSecretDraft.trim() === feishuWebhookSecret)}
|
||||
className="px-3 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
{saveFeishuWebhook.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
{feishuWebhookUrl && (
|
||||
<span className="text-[10px] text-emerald-500">● 已配置</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details className="mt-3 text-[10px] text-muted">
|
||||
<summary className="cursor-pointer hover:text-secondary">如何获取飞书 Webhook 地址?</summary>
|
||||
<ol className="mt-1.5 space-y-1 pl-4 list-decimal leading-relaxed">
|
||||
<li>打开飞书,进入目标群聊 → 群设置 → <b>群机器人</b></li>
|
||||
<li>点击「添加机器人」→ 选择「<b>自定义机器人</b>」</li>
|
||||
<li>填写机器人名称后添加,复制生成的 Webhook 地址</li>
|
||||
<li>安全设置若启用了「<b>签名校验</b>」,把密钥一并复制填到「签名密钥」框</li>
|
||||
<li>粘贴到上方输入框并保存</li>
|
||||
</ol>
|
||||
<p className="mt-1.5 pl-4 text-muted/70">
|
||||
📖 官方文档:
|
||||
<a href="https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN" target="_blank" rel="noreferrer" className="text-accent hover:text-accent/80">
|
||||
自定义机器人使用指南 ↗
|
||||
</a>
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 占位渠道 — 不可点 */}
|
||||
{[
|
||||
{ name: '微信', hint: '公众号/企业微信', status: '开发中' },
|
||||
{ name: 'QMT', hint: '量化交易终端', status: '待定' },
|
||||
{ name: 'ptrade', hint: '量化交易终端', status: '待定' },
|
||||
].map(ch => (
|
||||
<div
|
||||
key={ch.name}
|
||||
className="flex items-center gap-2 rounded-btn border border-border/40 bg-base/20 px-2.5 py-2 opacity-60"
|
||||
>
|
||||
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
|
||||
<span className="text-[11px] text-secondary">{ch.name}</span>
|
||||
<span className="text-[9px] text-muted">{ch.hint}</span>
|
||||
<span className="ml-auto rounded bg-muted/10 px-1 py-px text-[9px] text-muted">{ch.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 581 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 727 KiB |
Reference in New Issue
Block a user