From 2acaa38a2b44edf0d20a5e23ace27570a6b01fcb Mon Sep 17 00:00:00 2001 From: shy3130 Date: Sat, 27 Jun 2026 23:23:34 +0800 Subject: [PATCH] feat: release v0.1.60 --- README.md | 29 +- backend/app/__init__.py | 2 +- backend/app/api/analysis.py | 47 +- backend/app/api/data.py | 128 +++- backend/app/api/market_recap.py | 108 +++ backend/app/api/overview.py | 220 +----- backend/app/api/settings.py | 71 +- backend/app/api/watchlist.py | 36 +- backend/app/data_providers/__init__.py | 8 + backend/app/data_providers/base.py | 68 ++ backend/app/data_providers/normalizer.py | 99 +++ backend/app/data_providers/registry.py | 15 + backend/app/data_providers/schemas.py | 18 + .../app/data_providers/tickflow_provider.py | 120 ++++ backend/app/jobs/daily_pipeline.py | 148 +++- backend/app/main.py | 3 +- backend/app/services/index_sync.py | 266 ++++++- backend/app/services/kline_sync.py | 48 +- .../app/services/market_overview_builder.py | 576 +++++++++++++++ backend/app/services/market_recap.py | 356 ++++++++++ backend/app/services/market_recap_reports.py | 92 +++ backend/app/services/preferences.py | 153 ++++ backend/app/services/quote_service.py | 208 +++++- backend/app/services/watchlist.py | 14 + backend/app/tickflow/client.py | 19 +- backend/app/tickflow/policy.py | 7 +- backend/app/tickflow/repository.py | 428 ++++++++++- backend/pyproject.toml | 2 +- backend/uv.lock | 2 +- frontend/package.json | 2 +- frontend/src/components/Layout.tsx | 66 +- .../src/components/data/ActiveJobCard.tsx | 2 +- .../src/components/data/PageSettingsModal.tsx | 121 ++++ .../components/data/PipelineScopeConfig.tsx | 86 +++ frontend/src/components/data/SchemaModal.tsx | 5 +- frontend/src/components/data/StatCard.tsx | 4 +- frontend/src/lib/api.ts | 130 +++- frontend/src/lib/capability-labels.tsx | 14 +- frontend/src/lib/queryKeys.ts | 3 + frontend/src/lib/storage.ts | 3 + frontend/src/pages/Dashboard.tsx | 277 ++++++-- frontend/src/pages/Data.tsx | 192 +++-- frontend/src/pages/IndustryAnalysis.tsx | 6 +- frontend/src/pages/Onboarding.tsx | 182 +++-- frontend/src/pages/Review.tsx | 662 ++++++++++++++++++ frontend/src/pages/Watchlist.tsx | 40 +- frontend/src/pages/settings/Keys.tsx | 39 +- frontend/src/pages/settings/Monitoring.tsx | 89 ++- frontend/src/router.tsx | 2 + tiers.yaml | 13 +- 50 files changed, 4574 insertions(+), 655 deletions(-) create mode 100644 backend/app/api/market_recap.py create mode 100644 backend/app/data_providers/__init__.py create mode 100644 backend/app/data_providers/base.py create mode 100644 backend/app/data_providers/normalizer.py create mode 100644 backend/app/data_providers/registry.py create mode 100644 backend/app/data_providers/schemas.py create mode 100644 backend/app/data_providers/tickflow_provider.py create mode 100644 backend/app/services/market_overview_builder.py create mode 100644 backend/app/services/market_recap.py create mode 100644 backend/app/services/market_recap_reports.py create mode 100644 frontend/src/components/data/PageSettingsModal.tsx create mode 100644 frontend/src/components/data/PipelineScopeConfig.tsx create mode 100644 frontend/src/pages/Review.tsx diff --git a/README.md b/README.md index cac1cae..a5fb5c9 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ [![Deploy: Docker](https://img.shields.io/badge/Deploy-Docker-2496ed.svg)](./Dockerfile) [![GitHub stars](https://img.shields.io/github/stars/shy3130/tickflow-stock-panel?style=social)](https://github.com/shy3130/tickflow-stock-panel/stargazers) -基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据 · 🚀 **开箱即用**(单容器 / Free 模式) -能力驱动,适配 Free → Expert 全档位订阅 · 🔌 **自由接入第三方扩展数据**(例如 Tushare、自有量化项目数据) +基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据 · 🚀 **开箱即用**(单容器 / None 模式) +能力驱动,适配 None → Expert 全档位订阅 · 🔌 **自由接入第三方扩展数据**(例如 Tushare、自有量化项目数据) **[核心功能](#-核心功能)** · **[快速开始](#-快速开始)** · **[配置](#️-配置)** · **[路线图](#-路线图)** @@ -28,14 +28,14 @@ ## 🎯 项目定位 让任何**个人散户 / 量化爱好者**,**零运维**地拥有一套**与自己订阅档位严格匹配**的 A 股分析、选股、监控工作台。 -基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) Key **低成本**获取数据。**填写邀请码 `V3KDKGXPEA` 免费领取概念行业等扩展数据**。
-**任意接入第三方数据**(Tushare 等),页面可视化自定义配置扩展数据表。 +基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) Key **零成本**获取数据,并使用策略定制+监控+回测功能。 +**内置ths概念、ths行业数据**。可接入第三方自有个性化扩展数据(人气、资金流向等)。 **项目所需配置**: | 配置项 | 说明 | 是否必填 | | :--- | :--- | :--- | -| **TickFlow API Key** | 数据源凭证,留空启用 None 模式,获取免费key后开启free模式可定制策略+回测 | 可选 | +| **TickFlow API Key** | 数据源凭证,留空启用 None 模式;免费注册 Key 后进入 Free 模式,可使用历史日K与自选股实时监控 | 可选 | | **AI 大模型 API Key** | 用于 AI 生成策略、个股分析、财务分析等,任意 OpenAI 兼容接口,留空关闭 | 可选 | @@ -169,7 +169,8 @@ ### 🧰 数据与扩展 -- **多源数据**:TickFlow 日 K / 分钟 K / 指数 / 财务(利润 / 资产负债 / 现金流)/ 自选行情 +- **多源数据**:TickFlow 日 K / 分钟 K / 指数 / 财务(利润 / 资产负债 / 现金流)/ 实时行情 +- **实时行情分档**:None 仅历史日K(当日数据通常盘后 1-2 小时可用);Free 可监控自选页前 5 个标的(最低 6 秒刷新);Starter+ 使用全市场实时行情 - **🔌 第三方数据接入(重点)** —— TickFlow 之外的数据也能用: - 支持 **Tushare** 等第三方数据源,通过 **HTTP 定时拉取**自动入库 - 支持 **CSV / Excel 上传** · **JSON 写入**,自动 schema 发现与符号归一 @@ -196,7 +197,7 @@ ### 方式 A:Dev 模式(二次开发,最推荐) ```bash -cp .env.example .env # 填 TICKFLOW_API_KEY,留空则启用 Free 试用 +cp .env.example .env # 填 TICKFLOW_API_KEY,留空则启用 None 模式 ``` **一键启动**(推荐,自动检查\下载依赖 / 释放端口 / 同时起前后端,Ctrl-C 一并关闭): @@ -211,7 +212,7 @@ cp .env.example .env # 填 TICKFLOW_API_KEY,留空则启用 Free 试用 ### 方式 B:Docker(最省心,可部署) ```bash -cp .env.example .env # 按需填写 Key(留空即 Free 模式,可直接体验) +cp .env.example .env # 按需填写 Key(留空即 None 模式,可直接体验历史日K) docker compose up --build # 打开 http://localhost:3018 ``` @@ -269,9 +270,9 @@ pnpm dev # http://localhost:3011 1. 打开面板 → **设置 → 凭据与能力** → 点 **重新检测**,确认 Tier Label 2. 点 **立即跑盘后管道** —— 拉日 K + 计算 enriched 表 - - **Free 用户**:只同步内置 DEMO_SYMBOLS(浦发 / 招商 / 茅台等 10 只) + - **None / Free 用户**:历史日K走 free-api 通道;当日数据通常盘后 1-2 小时可用 - **Starter+**:同步全 A 或可获取的 instruments 列表 -3. **自选**页:添加跟踪标的;点代码进 **K 线**页看蜡烛图 + 买卖点 +3. **自选**页:添加跟踪标的;Free 档实时行情会自动监控自选页前 5 个标的,可用「移到顶部」调整优先级;点代码进 **K 线**页看蜡烛图 + 买卖点 4. **选股**页:点任一内置策略卡片即时扫描;或用自定义信号组合条件 5. **回测**页:选策略 / 信号 + 时间区间 → 跑回测 → 看净值 / 夏普 / 交易明细(SSE 实时进度) 6. **监控中心**页:配置监控规则(策略/个股信号/价格/市场异动),盘中 SSE 实时弹窗通知 + 持久化触发记录;或在个股详情页点「加监控」快速添加 @@ -284,13 +285,15 @@ pnpm dev # http://localhost:3011 ### 数据源:TickFlow -TickFlow 提供订阅制 A 股数据。**留空 `TICKFLOW_API_KEY` 即启用 Free 模式,无需注册即可体验**。 +TickFlow 提供订阅制 A 股数据。**留空 `TICKFLOW_API_KEY` 即启用 None 模式,可通过 free-api 使用历史日K;当日数据通常需盘后 1-2 小时可用**。免费注册并填写 Key 后进入 Free 模式,可开启自选股实时监控。 ```ini -TICKFLOW_API_KEY= # 留空 = Free 模式;填入 Key = 按订阅档位解锁 +TICKFLOW_API_KEY= # 留空 = None 模式;填入 Key = 按订阅档位解锁 ``` -> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/)。系统启动时会自动探测你的真实能力集,UI 显示「≈ Pro」等友好标签。 +> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/)。系统启动时会自动探测你的真实能力集,UI 显示「Free / Starter / Pro / Expert」等友好标签。高等档位包含较低档位的全部权益。 +> +> 当前面板使用的实时能力:Free = 自选页前 5 个标的实时监控(最低 6 秒刷新);Starter+ = 全市场实时行情;Pro = 分钟K + 盘口;Expert = WebSocket + 财务数据。 ### AI(可选):策略生成 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 72d001b..31c4976 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -2,7 +2,7 @@ import sys -__version__ = "0.1.53" +__version__ = "0.1.60" # Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的 # 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。 diff --git a/backend/app/api/analysis.py b/backend/app/api/analysis.py index 18b572e..2045b45 100644 --- a/backend/app/api/analysis.py +++ b/backend/app/api/analysis.py @@ -9,8 +9,6 @@ from typing import Literal from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field -from app.services.ext_data import ExtConfigStore - router = APIRouter(prefix="/api/analysis-menus", tags=["analysis-menus"]) @@ -113,44 +111,13 @@ def _save(request: Request, menu: AnalysisMenu) -> AnalysisMenu: def _default_menus(request: Request) -> list[AnalysisMenu]: - ext_store = ExtConfigStore(_data_dir(request)) - menus: list[AnalysisMenu] = [] - for cfg in ext_store.load_all(): - fields = cfg.fields - concept = next((f for f in fields if "概念" in f.name or "概念" in f.label or "concept" in f.name.lower()), None) - if concept: - detail_names = ["股票简称", "股票代码", concept.name, "人气排名", "资金流向", "PE", "PB"] - detail_columns = [] - for name in detail_names: - f = next((x for x in fields if x.name == name), None) - if not f: - continue - is_num = f.dtype in ("int", "float") - detail_columns.append(AnalysisColumn( - field=f.name, - label=f.label or f.name, - type="number" if is_num else "string", - sortable=is_num, - precision=2 if f.dtype == "float" else None, - )) - menus.append(AnalysisMenu( - id="concept_analysis", - label="概念分析", - icon="tags", - data_source=cfg.id, - template="dimension_rank", - dimension_field=concept.name, - group_columns=[ - AnalysisColumn(field="__dimension", label="概念"), - AnalysisColumn(field="__count", label="股票数", type="number", sortable=True), - ], - detail_columns=detail_columns, - default_sort=DefaultSort(field="人气排名", order="asc") if any(c.field == "人气排名" for c in detail_columns) else None, - order=100, - builtin=True, - )) - break - return menus + """自动生成的默认分析菜单。 + + 历史上会扫描扩展数据配置,对含「概念」字段的表自动生成一个「概念分析」菜单。 + 现已关闭自动生成 —— 内置的概念分析页(/concept-analysis)已覆盖该场景, + 自动菜单会造成导航重复。需要时用户可在「设置 → 扩展页面」手动创建。 + """ + return [] @router.get("") diff --git a/backend/app/api/data.py b/backend/app/api/data.py index d5a725d..2700d09 100644 --- a/backend/app/api/data.py +++ b/backend/app/api/data.py @@ -38,6 +38,9 @@ _table_cache: dict[str, dict | None] = { "index_daily": None, "index_enriched": None, "index_instruments": None, + "etf_daily": None, + "etf_enriched": None, + "etf_instruments": None, "minute": None, "adj_factor": None, "instruments": None, @@ -262,6 +265,85 @@ def _safe_aggregate_index_instruments(repo) -> dict | None: } +def _safe_aggregate_etf_instruments(repo) -> dict | None: + """ETF instruments 统计 — 优先独立 instruments_etf,兼容旧 instruments_index。""" + queries = [ + """SELECT count(*) AS rows, + count(DISTINCT symbol) AS symbols, + count_if(name IS NOT NULL AND name != '') AS named + FROM instruments_etf""", + """SELECT count(*) AS rows, + count(DISTINCT symbol) AS symbols, + count_if(name IS NOT NULL AND name != '') AS named + FROM instruments_index + WHERE asset_type = 'etf'""", + ] + for sql in queries: + try: + row = repo.execute_one(sql) + except Exception as e: # noqa: BLE001 + logger.debug("aggregate etf instruments fallback failed: %s", e) + continue + if row and row[0]: + return { + "rows": int(row[0]), + "symbols_covered": int(row[1] or 0), + "latest_as_of": None, + "named": int(row[2] or 0), + } + return None + + +def _safe_aggregate_etf_enriched(repo) -> dict | None: + """ETF enriched 统计 — 独立 kline_etf_enriched。""" + fields = 0 + try: + cols = repo.execute_all("DESCRIBE kline_etf_enriched") + fields = len(cols) + except Exception: # noqa: BLE001 + pass + stats = _safe_aggregate(repo, "kline_etf_enriched") + if not stats: + return None + return {**stats, "fields": fields} + + +def _safe_aggregate_etf_daily(repo) -> dict | None: + """ETF 日K统计 — 优先独立 kline_etf_daily,兼容旧 index 存储。""" + queries = [ + """SELECT count(*) AS rows, + min(date) AS earliest, + max(date) AS latest, + count(DISTINCT symbol) AS symbols, + count(DISTINCT date) AS trading_days + FROM kline_etf_daily""", + """SELECT count(*) AS rows, + min(date) AS earliest, + max(date) AS latest, + count(DISTINCT symbol) AS symbols, + count(DISTINCT date) AS trading_days + FROM kline_index_daily + WHERE symbol IN ( + SELECT DISTINCT symbol FROM instruments_index WHERE asset_type = 'etf' + )""", + ] + for sql in queries: + try: + row = repo.execute_one(sql) + except Exception as e: # noqa: BLE001 + logger.debug("aggregate etf daily fallback failed: %s", e) + continue + if row and row[0]: + return { + "rows": int(row[0]), + "earliest_date": str(row[1]) if row[1] else None, + "latest_date": str(row[2]) if row[2] else None, + "symbols_covered": int(row[3] or 0), + "trading_days": int(row[4] or 0), + } + return None + + def _safe_aggregate_adj_factor(repo) -> dict | None: """adj_factor 视图统计,日期范围对齐日 K 覆盖区间。""" try: @@ -405,6 +487,10 @@ def _compute_storage(data_dir: Path) -> dict: "index_daily": data_dir / "kline_index_daily", "index_enriched": data_dir / "kline_index_enriched", "index_instruments": data_dir / "instruments_index", + "etf_daily": data_dir / "kline_etf_daily", + "etf_enriched": data_dir / "kline_etf_enriched", + "etf_instruments": data_dir / "instruments_etf", + "etf_adj_factor": data_dir / "adj_factor_etf", "minute": data_dir / "kline_minute", "adj_factor": data_dir / "adj_factor", "instruments": data_dir / "instruments", @@ -507,10 +593,13 @@ def status(request: Request) -> dict: return { "daily": _get_table_stats("daily", lambda: _safe_aggregate_daily(repo)), "enriched": _get_table_stats("enriched", lambda: _safe_aggregate_enriched(repo)), - "index_daily": _get_table_stats("index_daily", lambda: _safe_aggregate_index_daily(repo)), - "index_enriched": _get_table_stats("index_enriched", lambda: _safe_aggregate_index_enriched(repo)), - "index_instruments": _get_table_stats("index_instruments", lambda: _safe_aggregate_index_instruments(repo)), - "minute": _get_table_stats("minute", lambda: _safe_aggregate_minute(repo)), + "index_daily": _get_table_stats("index_daily", lambda: _safe_aggregate_index_daily(repo)), + "index_enriched": _get_table_stats("index_enriched", lambda: _safe_aggregate_index_enriched(repo)), + "index_instruments": _get_table_stats("index_instruments", lambda: _safe_aggregate_index_instruments(repo)), + "etf_daily": _get_table_stats("etf_daily", lambda: _safe_aggregate_etf_daily(repo)), + "etf_enriched": _get_table_stats("etf_enriched", lambda: _safe_aggregate_etf_enriched(repo)), + "etf_instruments": _get_table_stats("etf_instruments", lambda: _safe_aggregate_etf_instruments(repo)), + "minute": _get_table_stats("minute", lambda: _safe_aggregate_minute(repo)), "adj_factor": _get_table_stats("adj_factor", lambda: _safe_aggregate_adj_factor(repo)), "instruments": _get_table_stats("instruments", lambda: _safe_aggregate_instruments(repo)), "financials": _get_table_stats("financials", lambda: _safe_aggregate_financials(repo)), @@ -537,8 +626,9 @@ def clear_data(request: Request): deleted = 0 for sub in ( - "kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched", "kline_minute", - "adj_factor", "instruments", "instruments_index", "pools", "financials", + "kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched", + "kline_etf_daily", "kline_etf_enriched", "kline_etf_minute", "kline_minute", + "adj_factor", "adj_factor_etf", "instruments", "instruments_index", "instruments_etf", "pools", "financials", "backtest_results", "screener_results", "ai_cache", ): d = data_dir / sub @@ -596,10 +686,15 @@ def clear_data(request: Request): "kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet", "kline_index_daily": f"{d}/kline_index_daily/**/*.parquet", "kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet", + "kline_etf_daily": f"{d}/kline_etf_daily/**/*.parquet", + "kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet", + "kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet", "kline_minute": f"{d}/kline_minute/**/*.parquet", "adj_factor": f"{d}/adj_factor/**/*.parquet", + "adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet", "instruments": f"{d}/instruments/**/*.parquet", "instruments_index": f"{d}/instruments_index/**/*.parquet", + "instruments_etf": f"{d}/instruments_etf/**/*.parquet", }.items(): try: repo.db.execute( @@ -638,6 +733,17 @@ _TABLE_FIELD_DESC: dict[str, dict[str, str]] = { "amount": "成交额", }, "kline_index_enriched": ENRICHED_COLUMNS, + "kline_etf_daily": { + "symbol": "ETF代码", + "date": "交易日期", + "open": "开盘价", + "high": "最高价", + "low": "最低价", + "close": "收盘价", + "volume": "成交量", + "amount": "成交额", + }, + "kline_etf_enriched": ENRICHED_COLUMNS, "kline_minute": { "symbol": "股票代码", "datetime": "分钟时间戳", @@ -675,6 +781,13 @@ _TABLE_FIELD_DESC: dict[str, dict[str, str]] = { "code": "指数编码(纯数字)", "asset_type": "资产类型(index)", }, + "instruments_etf": { + "symbol": "ETF代码", + "name": "ETF名称", + "code": "ETF编码(纯数字)", + "asset_type": "资产类型(etf)", + "source": "数据源", + }, } # view 名 → DuckDB 视图名 @@ -684,6 +797,9 @@ _SCHEMA_VIEWS: dict[str, str] = { "index_daily": "kline_index_daily", "index_enriched": "kline_index_enriched", "index_instruments": "instruments_index", + "etf_daily": "kline_etf_daily", + "etf_enriched": "kline_etf_enriched", + "etf_instruments": "instruments_etf", "minute": "kline_minute", "adj_factor": "adj_factor", "instruments": "instruments", diff --git a/backend/app/api/market_recap.py b/backend/app/api/market_recap.py new file mode 100644 index 0000000..8f7a337 --- /dev/null +++ b/backend/app/api/market_recap.py @@ -0,0 +1,108 @@ +"""AI 大盘复盘 API — 流式复盘 + 报告持久化。 + +路由前缀: /api/market-recap + +端点: + POST /analyze AI 流式大盘复盘(NDJSON) + GET /reports 历史复盘列表 + POST /reports 保存一条复盘报告 + DELETE /reports/{report_id} 删除一条复盘报告 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from app.services import market_recap_reports +from app.services.market_recap import recap_market_stream + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/market-recap", tags=["market-recap"]) + + +class AnalyzeRequest(BaseModel): + """AI 大盘复盘请求。""" + as_of: str | None = None # 可选:复盘日期(YYYY-MM-DD),缺省取最新有数据日 + focus: str = "" # 可选:用户追加的复盘关注点 + + +@router.post("/analyze") +async def analyze_market(request: Request, req: AnalyzeRequest): + """AI 大盘复盘 — NDJSON 流式返回。 + + 装配市场总览(指数/涨跌/连板/封板/板块/情绪雷达)→ 复盘提示词 → + 流式调用 LLM → 逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。 + + 协议: + {"type":"meta","as_of","emotion_score","emotion_label","summary"} + {"type":"delta","content":"..."} + {"type":"error","message":"..."} + {"type":"done"} + """ + from datetime import date as date_cls + + repo = request.app.state.repo + quote_service = getattr(request.app.state, "quote_service", None) + depth_service = getattr(request.app.state, "depth_service", None) + + as_of = None + if req.as_of: + try: + as_of = date_cls.fromisoformat(req.as_of) + except ValueError: + raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}") + + async def stream_gen(): + async for chunk in recap_market_stream(repo, quote_service, depth_service, as_of, req.focus): + yield chunk + "\n" + + return StreamingResponse( + stream_gen(), + media_type="application/x-ndjson", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +# ================================================================ +# 报告 CRUD(历史复盘持久化) +# ================================================================ + +class SaveReportRequest(BaseModel): + """保存一条 AI 大盘复盘报告。""" + as_of: str + focus: str = "" + content: str + summary: str = "" + emotion_score: int | None = None + emotion_label: str = "" + + +@router.get("/reports") +def list_reports(request: Request): + """获取全部历史复盘(按时间降序,后端已裁剪到上限)。""" + return {"reports": market_recap_reports.list_reports()} + + +@router.post("/reports") +def save_report(request: Request, req: SaveReportRequest): + """保存一条复盘报告。""" + report = market_recap_reports.save_report({ + "as_of": req.as_of, + "focus": req.focus, + "content": req.content, + "summary": req.summary, + "emotion_score": req.emotion_score, + "emotion_label": req.emotion_label, + }) + return {"ok": True, "report": report} + + +@router.delete("/reports/{report_id}") +def delete_report(request: Request, report_id: str): + """删除一条复盘报告。""" + ok = market_recap_reports.delete_report(report_id) + return {"ok": ok} diff --git a/backend/app/api/overview.py b/backend/app/api/overview.py index c6367c1..3bd7ede 100644 --- a/backend/app/api/overview.py +++ b/backend/app/api/overview.py @@ -343,216 +343,18 @@ def _pct_band_rows(values: list[float]) -> list[dict]: def _build_overview(request: Request, as_of: date | None = None) -> dict: - repo = request.app.state.repo - svc = ScreenerService(repo) - as_of = as_of or svc.latest_date() - status = _quote_status(request) - indices = _index_quotes(request, as_of) + """装配市场总览(委托给 services.market_overview_builder,保持行为一致)。 - if not as_of: - return { - "as_of": None, - "quote_status": status, - "indices": indices, - "breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 0}, - "amount": {"total": 0, "avg": 0}, - "boards": [], - "limit": {"limit_up": 0, "broken": 0, "failed": 0, "limit_down": 0, "max_boards": 0, "tiers": []}, - "distribution": [], - "trend": {"above_ma5": 0, "above_ma20": 0, "above_ma60": 0, "above_ma5_pct": 0, "above_ma20_pct": 0, "above_ma60_pct": 0, "new_high": 0, "new_low": 0}, - "activity": {"avg_turnover": 0, "high_turnover": 0, "high_vol_ratio": 0, "vol_ratio": 1}, - "radar": [], - "emotion": {"score": 50, "label": "暂无"}, - "top_gainers": [], - "top_losers": [], - "turnover_leaders": [], - "active_leaders": [], - "concept_rank": {"leading": [], "lagging": []}, - "industry_rank": {"leading": [], "lagging": []}, - } - - df = svc._load_enriched_for_date(as_of) - if df.is_empty(): - rows: list[dict] = [] - else: - cols = [ - "symbol", "name", "close", "change_pct", "amount", "turnover_rate", "volume", - "vol_ratio_5d", "consecutive_limit_ups", "signal_limit_up", "signal_broken_limit_up", "signal_limit_down", - "ma5", "ma20", "ma60", "high_60d", "low_60d", "signal_n_day_high", "signal_n_day_low", - ] - df = df.select([c for c in cols if c in df.columns]) - rows = df.to_dicts() - - # 过滤真停牌(volume=0 且 change_pct=0),保留有涨跌幅的浮点误差股以对齐同花顺口径 - if rows and "volume" in rows[0]: - rows = [r for r in rows - if (_finite(r.get("volume")) or 0) > 0 - or (_finite(r.get("change_pct")) or 0) != 0] - - total = len(rows) - up = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) > 0) - down = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) < 0) - flat = max(0, total - up - down) - up_pct = up / total * 100 if total else 0 - down_pct = down / total * 100 if total else 0 - - amounts = [_finite(r.get("amount")) or 0 for r in rows] - total_amount = sum(amounts) - avg_amount = total_amount / total if total else 0 - - pct_values = [_finite(r.get("change_pct")) for r in rows] - pct_values = [v for v in pct_values if v is not None] - avg_pct = sum(pct_values) / len(pct_values) if pct_values else 0 - median_pct = sorted(pct_values)[len(pct_values) // 2] if pct_values else 0 - strong_up = sum(1 for v in pct_values if v >= 0.03) - strong_down = sum(1 for v in pct_values if v <= -0.03) - - limit_up = sum(1 for r in rows if bool(r.get("signal_limit_up")) or (_finite(r.get("consecutive_limit_ups")) or 0) > 0) - broken = sum(1 for r in rows if bool(r.get("signal_broken_limit_up"))) - limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down"))) - max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0) - - # 五档 sealed 修正: 假涨停/假跌停不计入(需 Pro+ depth5.batch 能力) - depth_svc = getattr(request.app.state, "depth_service", None) - sealed_ready = False - fake_up = 0 - fake_down = 0 - if depth_svc: - up_map = depth_svc.get_sealed_map(as_of, is_down=False) - down_map = depth_svc.get_sealed_map(as_of, is_down=True) - sealed_ready = bool(up_map or down_map) and depth_svc.is_sealed_ready(as_of) - if up_map: - fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False) - if down_map: - fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False) - if sealed_ready: - limit_up = max(0, limit_up - fake_up) - limit_down = max(0, limit_down - fake_down) - - seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0 - - def above_ma_count(ma_key: str) -> int: - return sum(1 for r in rows if (_finite(r.get("close")) is not None and _finite(r.get(ma_key)) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get(ma_key)) or 0))) - - above_ma5 = above_ma_count("ma5") - above_ma20 = above_ma_count("ma20") - above_ma60 = above_ma_count("ma60") - new_high = sum(1 for r in rows if bool(r.get("signal_n_day_high")) or (_finite(r.get("close")) is not None and _finite(r.get("high_60d")) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get("high_60d")) or 0))) - new_low = sum(1 for r in rows if bool(r.get("signal_n_day_low")) or (_finite(r.get("close")) is not None and _finite(r.get("low_60d")) is not None and (_finite(r.get("close")) or 0) <= (_finite(r.get("low_60d")) or 0))) - - turnovers = [_finite(r.get("turnover_rate")) for r in rows] - turnovers = [v for v in turnovers if v is not None] - avg_turnover = sum(turnovers) / len(turnovers) if turnovers else 0 - high_turnover = sum(1 for v in turnovers if v >= 5) - - boards_map: dict[str, dict] = {} - for r in rows: - b = _board(str(r.get("symbol") or "")) - item = boards_map.setdefault(b, {"board": b, "count": 0, "up": 0, "down": 0, "amount": 0.0}) - item["count"] += 1 - change = _finite(r.get("change_pct")) or 0 - if change > 0: - item["up"] += 1 - elif change < 0: - item["down"] += 1 - item["amount"] += _finite(r.get("amount")) or 0 - boards = sorted(boards_map.values(), key=lambda x: x["amount"], reverse=True) - for b in boards: - count = b["count"] or 1 - b["up_pct"] = b["up"] / count * 100 - - tiers_map: dict[int, int] = {} - for r in rows: - n = int(_finite(r.get("consecutive_limit_ups")) or 0) - if n > 0: - tiers_map[n] = tiers_map.get(n, 0) + 1 - tiers = [{"boards": k, "count": v} for k, v in sorted(tiers_map.items(), key=lambda item: -item[0])] - - index_changes = [_finite(r.get("change_pct")) for r in indices] - index_changes = [v for v in index_changes if v is not None] - avg_index_pct = sum(index_changes) / len(index_changes) if index_changes else 0 - vol_ratios = [_finite(r.get("vol_ratio_5d")) for r in rows] - vol_ratios = [v for v in vol_ratios if v is not None] - avg_vol_ratio = sum(vol_ratios) / len(vol_ratios) if vol_ratios else 1 - high_vol_ratio = sum(1 for v in vol_ratios if v >= 1.5) - - concept_rank = _dimension_rank(rows, request, "concept") - industry_rank = _dimension_rank(rows, request, "industry", level=2) - - strong_diff_pct = (strong_up - strong_down) / total * 100 if total else 0 - high_vol_pct = high_vol_ratio / total * 100 if total else 0 - strong_down_pct = strong_down / total * 100 if total else 0 - tier2_count = sum(t["count"] for t in tiers if t["boards"] >= 2) - mainline_items = [*concept_rank["leading"][:3], *industry_rank["leading"][:3]] - mainline_avg = max([_finite(item.get("avg_pct")) or 0 for item in mainline_items], default=0) - mainline_cover_pct = max([(_finite(item.get("count")) or 0) / total * 100 for item in mainline_items], default=0) if total else 0 - mainline_score = round(_score(mainline_avg, -0.005, 0.03) * 0.65 + _score(mainline_cover_pct, 1, 12) * 0.35) if mainline_items else 50 - - radar = [ - {"key": "index", "label": "指数", "value": _score(avg_index_pct, -2.5, 2.5)}, - {"key": "profit", "label": "赚钱", "value": round(_score(up_pct, 20, 80) * 0.45 + _score(avg_pct, -0.02, 0.02) * 0.25 + _score(median_pct, -0.02, 0.02) * 0.20 + _score(strong_diff_pct, -8, 8) * 0.10)}, - {"key": "money", "label": "量能", "value": round(_score(avg_vol_ratio, 0.6, 1.8) * 0.70 + _score(high_vol_pct, 2, 12) * 0.30)}, - {"key": "speculation", "label": "投机", "value": round(_score(limit_up, 5, 90) * 0.25 + _score(seal_rate, 30, 85) * 0.35 + _score(max_boards, 1, 8) * 0.25 + _score(tier2_count, 0, 30) * 0.15)}, - {"key": "resilience", "label": "抗跌", "value": 100 - round(_score(down_pct, 20, 80) * 0.55 + _score(strong_down_pct, 1, 12) * 0.45)}, - {"key": "mainline", "label": "主线", "value": mainline_score}, - ] - emotion_score = round(sum(r["value"] for r in radar) / len(radar)) if radar else 50 - if emotion_score >= 70: - emotion_label = "强势" - elif emotion_score >= 55: - emotion_label = "偏暖" - elif emotion_score >= 45: - emotion_label = "震荡" - elif emotion_score >= 30: - emotion_label = "偏冷" - else: - emotion_label = "冰点" - - return _json_safe({ - "as_of": str(as_of), - "quote_status": status, - "indices": indices, - "breadth": { - "total": total, - "up": up, - "down": down, - "flat": flat, - "up_pct": up_pct, - "down_pct": down_pct, - "avg_pct": avg_pct, - "median_pct": median_pct, - "strong_up": strong_up, - "strong_down": strong_down, - }, - "amount": {"total": total_amount, "avg": avg_amount}, - "boards": boards, - "limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers, "sealed_ready": sealed_ready, "fake_up": fake_up, "fake_down": fake_down}, - "distribution": _pct_band_rows(pct_values), - "trend": { - "above_ma5": above_ma5, - "above_ma20": above_ma20, - "above_ma60": above_ma60, - "above_ma5_pct": above_ma5 / total * 100 if total else 0, - "above_ma20_pct": above_ma20 / total * 100 if total else 0, - "above_ma60_pct": above_ma60 / total * 100 if total else 0, - "new_high": new_high, - "new_low": new_low, - }, - "activity": { - "avg_turnover": avg_turnover, - "high_turnover": high_turnover, - "high_vol_ratio": high_vol_ratio, - "vol_ratio": avg_vol_ratio, - }, - "radar": radar, - "emotion": {"score": emotion_score, "label": emotion_label}, - "top_gainers": _top_rows(rows, "change_pct", True), - "top_losers": _top_rows(rows, "change_pct", False), - "turnover_leaders": _top_rows(rows, "amount", True), - "active_leaders": _top_rows(rows, "turnover_rate", True), - "concept_rank": concept_rank, - "industry_rank": industry_rank, - }) + 逻辑已抽离至 build_market_overview,以解耦对 Request 的依赖, + 使大盘复盘等无 Request 的调用方可复用同一装配逻辑。 + """ + from app.services.market_overview_builder import build_market_overview + return build_market_overview( + repo=request.app.state.repo, + quote_service=getattr(request.app.state, "quote_service", None), + depth_service=getattr(request.app.state, "depth_service", None), + as_of=as_of, + ) @router.get("/market") diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 4157fbe..1735940 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -287,6 +287,16 @@ def get_preferences() -> dict: "indices_nav_pinned": preferences.get_indices_nav_pinned(), "minute_sync_enabled": preferences.get_minute_sync_enabled(), "minute_sync_days": preferences.get_minute_sync_days(), + "daily_data_provider": preferences.get_daily_data_provider(), + "adj_factor_provider": preferences.get_adj_factor_provider(), + "minute_data_provider": preferences.get_minute_data_provider(), + "realtime_data_provider": preferences.get_realtime_data_provider(), + "realtime_watchlist_symbols": preferences.get_realtime_watchlist_symbols(), + **preferences.get_realtime_quote_scope(), + "pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(), + "pipeline_pull_etf": preferences.get_pipeline_pull_etf(), + "pipeline_pull_index": preferences.get_pipeline_pull_index(), + "pipeline_index_symbols": preferences.get_pipeline_index_symbols(), "pipeline_schedule": preferences.get_pipeline_schedule(), "instruments_schedule": preferences.get_instruments_schedule(), "enriched_batch_size": preferences.get_enriched_batch_size(), @@ -384,11 +394,19 @@ class RealtimeQuotesPrefs(BaseModel): realtime_quotes_enabled: bool +class RealtimeQuoteScopePrefs(BaseModel): + realtime_pull_stock: bool | None = None + realtime_pull_etf: bool | None = None + realtime_pull_index: bool | None = None + realtime_index_mode: str | None = None + realtime_index_symbols: list[str] | None = None + + @router.put("/preferences/realtime-quotes") def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict: """保存全局实时行情开关。 - none/free 档无实时行情权限:拒绝开启,persist 为关闭并返回 allowed=False, + none 档无实时行情权限;free 档开启自选股实时;starter+ 开启全市场实时。 前端据此把开关置灰 / 回弹。 """ from app.services import preferences @@ -401,6 +419,9 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict: if qs: qs.disable() return {"realtime_quotes_enabled": False, "realtime_allowed": False} + if req.realtime_quotes_enabled and qs and qs.realtime_mode() == "watchlist" and not preferences.get_realtime_watchlist_symbols(): + preferences.save({"realtime_quotes_enabled": False}) + return {"realtime_quotes_enabled": False, "realtime_allowed": True, "mode": "watchlist", "error": "watchlist_empty"} preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled}) if qs: @@ -412,6 +433,26 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict: return {"realtime_quotes_enabled": req.realtime_quotes_enabled, "realtime_allowed": allowed} +@router.put("/preferences/realtime-quote-scope") +def update_realtime_quote_scope(req: RealtimeQuoteScopePrefs) -> dict: + """保存盘中实时行情范围;独立于盘后管道范围。""" + from app.services import preferences + cfg = req.model_dump(exclude_none=True) + return preferences.set_realtime_quote_scope(cfg) + + +class RealtimeWatchlistPrefs(BaseModel): + symbols: list[str] = [] + + +@router.put("/preferences/realtime-watchlist") +def update_realtime_watchlist(req: RealtimeWatchlistPrefs) -> dict: + """兼容旧入口;Free 实时标的由自选页前 5 个决定。""" + from app.services import preferences + symbols = preferences.set_realtime_watchlist_symbols(req.symbols) + return {"realtime_watchlist_symbols": symbols} + + class IndicesNavPinnedPrefs(BaseModel): indices_nav_pinned: bool @@ -464,6 +505,34 @@ def update_realtime_monitor_config(req: RealtimeMonitorConfigIn, request: Reques return result +class PipelinePullTypesIn(BaseModel): + """盘后管道拉取内容开关(A股 / ETF / 指数 独立控制)。""" + pipeline_pull_a_share: bool | None = None + pipeline_pull_etf: bool | None = None + pipeline_pull_index: bool | None = None + + +@router.put("/preferences/pipeline-pull-types") +def update_pipeline_pull_types(req: PipelinePullTypesIn) -> dict: + """更新盘后管道拉取内容开关。""" + from app.services import preferences + cfg = req.model_dump(exclude_none=True) + return preferences.set_pipeline_pull_types(cfg) + + +class PipelineIndexSymbolsIn(BaseModel): + """指数自定义拉取代码(逗号/换行/空格分隔,空串表示全量)。""" + symbols: str = "" + + +@router.put("/preferences/pipeline-index-symbols") +def update_pipeline_index_symbols(req: PipelineIndexSymbolsIn) -> dict: + """保存指数自定义拉取代码。""" + from app.services import preferences + symbols = preferences.set_pipeline_index_symbols(req.symbols) + return {"pipeline_index_symbols": symbols} + + class QuoteIntervalIn(BaseModel): interval: float diff --git a/backend/app/api/watchlist.py b/backend/app/api/watchlist.py index 183b875..81fc34d 100644 --- a/backend/app/api/watchlist.py +++ b/backend/app/api/watchlist.py @@ -27,28 +27,48 @@ class BatchAddRequest(BaseModel): note: str = "" +def _with_names(rows: list[dict], request: Request) -> list[dict]: + if not rows: + return rows + try: + df_i = request.app.state.repo.get_instruments() + if df_i.is_empty() or "symbol" not in df_i.columns or "name" not in df_i.columns: + return rows + name_by_symbol = dict(df_i.select(["symbol", "name"]).iter_rows()) + return [{**row, "name": name_by_symbol.get(row.get("symbol"))} for row in rows] + except Exception as e: # noqa: BLE001 + logger.debug("attach watchlist names failed: %s", e) + return rows + + @router.get("") -def list_all(): - return {"symbols": watchlist.list_symbols()} +def list_all(request: Request): + return {"symbols": _with_names(watchlist.list_symbols(), request)} @router.post("") -def add_one(req: AddRequest): +def add_one(req: AddRequest, request: Request): rows = watchlist.add(req.symbol, req.note) - return {"symbols": rows} + return {"symbols": _with_names(rows, request)} @router.post("/batch") -def add_batch(req: BatchAddRequest): +def add_batch(req: BatchAddRequest, request: Request): for sym in req.symbols: watchlist.add(sym, req.note) - return {"symbols": watchlist.list_symbols(), "added": len(req.symbols)} + return {"symbols": _with_names(watchlist.list_symbols(), request), "added": len(req.symbols)} + + +@router.post("/{symbol}/top") +def move_one_to_top(symbol: str, request: Request): + rows = watchlist.move_to_top(symbol) + return {"symbols": _with_names(rows, request)} @router.delete("/{symbol}") -def remove_one(symbol: str): +def remove_one(symbol: str, request: Request): rows = watchlist.remove(symbol) - return {"symbols": rows} + return {"symbols": _with_names(rows, request)} @router.delete("") diff --git a/backend/app/data_providers/__init__.py b/backend/app/data_providers/__init__.py new file mode 100644 index 0000000..4d3e9fd --- /dev/null +++ b/backend/app/data_providers/__init__.py @@ -0,0 +1,8 @@ +"""Market data provider abstraction. + +Providers normalize external data sources into the internal parquet schema. +""" +from app.data_providers.base import AssetType, MarketDataProvider, ProviderCapabilities +from app.data_providers.registry import get_provider + +__all__ = ["AssetType", "MarketDataProvider", "ProviderCapabilities", "get_provider"] diff --git a/backend/app/data_providers/base.py b/backend/app/data_providers/base.py new file mode 100644 index 0000000..c17ca21 --- /dev/null +++ b/backend/app/data_providers/base.py @@ -0,0 +1,68 @@ +"""Provider contracts for external market data sources. + +The first implementation wraps TickFlow. Other providers (Tushare/AkShare/etc.) +should return the same normalized Polars schemas so storage, indicators and +backtests stay data-source agnostic. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Literal, Protocol + +import polars as pl + +AssetType = Literal["stock", "index", "etf"] + + +@dataclass(frozen=True) +class ProviderCapabilities: + instruments: bool = False + daily: bool = False + adj_factor: bool = False + minute: bool = False + realtime: bool = False + financial: bool = False + + +class MarketDataProvider(Protocol): + name: str + capabilities: ProviderCapabilities + + def get_instruments(self, asset_type: AssetType) -> pl.DataFrame: + """Return normalized instruments: symbol/name/code/exchange/asset_type/source.""" + + def get_daily( + self, + symbols: list[str], + start_time: datetime | None, + end_time: datetime | None, + asset_type: AssetType, + ) -> pl.DataFrame: + """Return normalized daily K rows.""" + + def get_adj_factors( + self, + symbols: list[str], + start_time: datetime | None, + end_time: datetime | None, + asset_type: AssetType, + ) -> pl.DataFrame: + """Return normalized adjustment factors: symbol/trade_date/ex_factor.""" + + def get_minute( + self, + symbols: list[str], + start_time: datetime | None, + end_time: datetime | None, + asset_type: AssetType, + freq: str = "1m", + ) -> pl.DataFrame: + """Return normalized minute K rows. Implementations may return empty.""" + + def get_realtime( + self, + universes: list[str] | None = None, + symbols: list[str] | None = None, + ) -> pl.DataFrame: + """Return normalized realtime quotes. Implementations may return empty.""" diff --git a/backend/app/data_providers/normalizer.py b/backend/app/data_providers/normalizer.py new file mode 100644 index 0000000..edf047e --- /dev/null +++ b/backend/app/data_providers/normalizer.py @@ -0,0 +1,99 @@ +"""Normalize provider responses into internal Polars schemas.""" +from __future__ import annotations + +import polars as pl + +from app.indicators.pipeline import filter_halt_days + +DAILY_COLS = ["symbol", "date", "open", "high", "low", "close", "volume", "amount"] +ADJ_FACTOR_COLS = ["symbol", "trade_date", "ex_factor"] +INSTRUMENT_COLS = ["symbol", "name", "code", "exchange", "asset_type", "source"] + + +def to_polars(data) -> pl.DataFrame: + if data is None: + return pl.DataFrame() + if isinstance(data, pl.DataFrame): + return data + if isinstance(data, dict): + rows: list[dict] = [] + for sym, values in data.items(): + for item in values or []: + row = dict(item or {}) + row.setdefault("symbol", sym) + rows.append(row) + return pl.DataFrame(rows) if rows else pl.DataFrame() + if hasattr(data, "reset_index"): + return pl.from_pandas(data.reset_index()) + try: + return pl.DataFrame(data) + except Exception: # noqa: BLE001 + return pl.DataFrame() + + +def normalize_daily(data, default_symbol: str | None = None, source: str = "tickflow") -> pl.DataFrame: # noqa: ARG001 + df = to_polars(data) + if df.is_empty(): + return df + rename_map = { + "ts_code": "symbol", + "trade_date": "date", + "datetime": "date", + "vol": "volume", + "amt": "amount", + } + df = df.rename({k: v for k, v in rename_map.items() if k in df.columns}) + if "symbol" not in df.columns and default_symbol: + df = df.with_columns(pl.lit(default_symbol).alias("symbol")) + if "date" in df.columns and df.schema["date"] != pl.Date: + df = df.with_columns(pl.col("date").cast(pl.Date, strict=False)) + for col in ("open", "high", "low", "close", "volume", "amount"): + if col in df.columns: + df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False)) + df = filter_halt_days(df) + keep = [c for c in DAILY_COLS if c in df.columns] + return df.select(keep) if keep else pl.DataFrame() + + +def normalize_adj_factors(data, source: str = "tickflow") -> pl.DataFrame: # noqa: ARG001 + df = to_polars(data) + 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}) + 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( + pl.from_epoch(pl.col("trade_date").cast(pl.Int64), time_unit="ms").dt.date().alias("trade_date") + ) + else: + df = df.with_columns(pl.col("trade_date").cast(pl.Date, strict=False)) + if "ex_factor" in df.columns: + df = df.with_columns(pl.col("ex_factor").cast(pl.Float64, strict=False)) + keep = [c for c in ADJ_FACTOR_COLS if c in df.columns] + return df.select(keep).drop_nulls() if len(keep) == len(ADJ_FACTOR_COLS) else pl.DataFrame() + + +def normalize_instruments(rows: list[dict], asset_type: str, source: str = "tickflow") -> pl.DataFrame: + if not rows: + return pl.DataFrame() + out: list[dict] = [] + for item in rows: + symbol = item.get("symbol") + if not symbol: + continue + out.append({ + "symbol": str(symbol), + "name": item.get("name") or str(symbol), + "code": item.get("code") or str(symbol).split(".")[0], + "exchange": item.get("exchange"), + "asset_type": asset_type, + "source": source, + }) + if not out: + return pl.DataFrame() + return pl.DataFrame(out).select(INSTRUMENT_COLS).unique(subset=["symbol"], keep="last").sort("symbol") diff --git a/backend/app/data_providers/registry.py b/backend/app/data_providers/registry.py new file mode 100644 index 0000000..e7a39e6 --- /dev/null +++ b/backend/app/data_providers/registry.py @@ -0,0 +1,15 @@ +"""Provider registry.""" +from __future__ import annotations + +from app.data_providers.tickflow_provider import TickFlowProvider + +_PROVIDERS = { + "tickflow": TickFlowProvider, +} + + +def get_provider(name: str = "tickflow"): + provider_cls = _PROVIDERS.get((name or "tickflow").lower()) + if provider_cls is None: + raise ValueError(f"Unsupported data provider: {name}") + return provider_cls() diff --git a/backend/app/data_providers/schemas.py b/backend/app/data_providers/schemas.py new file mode 100644 index 0000000..51e8dc3 --- /dev/null +++ b/backend/app/data_providers/schemas.py @@ -0,0 +1,18 @@ +"""Internal provider schema column lists.""" +from __future__ import annotations + +DAILY_COLUMNS = [ + "symbol", "asset_type", "source", "date", "open", "high", "low", "close", + "volume", "amount", "pre_close", "change_pct", +] + +ADJ_FACTOR_COLUMNS = ["symbol", "asset_type", "source", "trade_date", "ex_factor"] + +INSTRUMENT_COLUMNS = [ + "symbol", "name", "exchange", "asset_type", "source", "list_date", "status", +] + +MINUTE_COLUMNS = [ + "symbol", "asset_type", "source", "datetime", "open", "high", "low", "close", + "volume", "amount", "freq", +] diff --git a/backend/app/data_providers/tickflow_provider.py b/backend/app/data_providers/tickflow_provider.py new file mode 100644 index 0000000..a086949 --- /dev/null +++ b/backend/app/data_providers/tickflow_provider.py @@ -0,0 +1,120 @@ +"""TickFlow provider implementation.""" +from __future__ import annotations + +import logging +from datetime import datetime + +import polars as pl + +from app.data_providers.base import AssetType, ProviderCapabilities +from app.data_providers.normalizer import normalize_adj_factors, normalize_daily, normalize_instruments +from app.tickflow.client import get_client + +logger = logging.getLogger(__name__) + +_EXCHANGES = ["SH", "SZ", "BJ"] + + +class TickFlowProvider: + name = "tickflow" + capabilities = ProviderCapabilities( + instruments=True, + daily=True, + adj_factor=True, + minute=True, + realtime=True, + financial=True, + ) + + def get_instruments(self, asset_type: AssetType) -> pl.DataFrame: + tf = get_client() + instrument_type = "stock" if asset_type == "stock" else asset_type + rows: list[dict] = [] + for ex in _EXCHANGES: + try: + items = tf.exchanges.get_instruments(ex, instrument_type=instrument_type) + rows.extend([it for it in (items or []) if isinstance(it, dict)]) + except Exception as e: # noqa: BLE001 + logger.warning("TickFlow instruments %s/%s failed: %s", ex, instrument_type, e) + return normalize_instruments(rows, asset_type=asset_type, source=self.name) + + def get_daily( + self, + symbols: list[str], + start_time: datetime | None, + end_time: datetime | None, + asset_type: AssetType, # noqa: ARG002 + ) -> pl.DataFrame: + if not symbols: + return pl.DataFrame() + tf = get_client() + kwargs = { + "period": "1d", + "adjust": "none", + "count": 10000 if start_time and end_time else 250, + "as_dataframe": True, + "show_progress": False, + } + if start_time and end_time: + from app.services.kline_sync import _datetime_to_ms + kwargs["start_time"] = _datetime_to_ms(start_time) + kwargs["end_time"] = _datetime_to_ms(end_time) + raw = tf.klines.batch(symbols, **kwargs) + frames: list[pl.DataFrame] = [] + if isinstance(raw, dict): + for sym, sub in raw.items(): + normalized = normalize_daily(sub, default_symbol=sym, source=self.name) + if not normalized.is_empty(): + frames.append(normalized) + else: + normalized = normalize_daily(raw, source=self.name) + if not normalized.is_empty(): + frames.append(normalized) + return pl.concat(frames, how="diagonal_relaxed") if frames else pl.DataFrame() + + def get_adj_factors( + self, + symbols: list[str], + start_time: datetime | None, + end_time: datetime | None, + asset_type: AssetType, # noqa: ARG002 + ) -> pl.DataFrame: + if not symbols: + return pl.DataFrame() + tf = get_client() + kwargs = {"as_dataframe": False} + if start_time or end_time: + from app.services.kline_sync import _datetime_to_ms + if start_time: + kwargs["start_time"] = _datetime_to_ms(start_time) + if end_time: + kwargs["end_time"] = _datetime_to_ms(end_time) + raw = tf.klines.ex_factors(symbols, **kwargs) + return normalize_adj_factors(raw, source=self.name) + + def get_minute( + self, + symbols: list[str], + start_time: datetime | None, + end_time: datetime | None, + asset_type: AssetType, # noqa: ARG002 + freq: str = "1m", # noqa: ARG002 + ) -> pl.DataFrame: + # Existing minute sync remains in app.services.kline_sync for now. + return pl.DataFrame() + + def get_realtime( + self, + universes: list[str] | None = None, + symbols: list[str] | None = None, + ) -> pl.DataFrame: + tf = get_client() + if universes and symbols: + raise ValueError("TickFlow realtime accepts either universes or symbols, not both") + if universes: + resp = tf.quotes.get_by_universes(universes=universes) + elif symbols: + resp = tf.quotes.get(symbols=symbols) + else: + return pl.DataFrame() + return pl.DataFrame(resp or []) diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py index 0ffa0d2..d08a56e 100644 --- a/backend/app/jobs/daily_pipeline.py +++ b/backend/app/jobs/daily_pipeline.py @@ -1,7 +1,7 @@ """盘后管道 + 盘前维表同步。 调度: - 09:10 盘前 — 同步标的维表 instruments (全量覆盖) + 09:10 盘前 — 同步个股维表 instruments (全量覆盖) 15:30 盘后 — 日K同步 + 增量除权因子 + enriched 计算 + 刷新视图 盘后同步策略: @@ -20,7 +20,7 @@ from apscheduler.triggers.cron import CronTrigger from app.indicators.pipeline import run_pipeline from app.config import settings -from app.services import index_sync, instrument_sync, kline_sync +from app.services import index_sync, instrument_sync, kline_sync, preferences as _prefs from app.tickflow.capabilities import Cap, CapabilitySet from app.tickflow.pools import DEMO_SYMBOLS, get_pool from app.tickflow.repository import KlineRepository @@ -69,7 +69,7 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]: def run_instruments_sync(repo: KlineRepository) -> dict: - """盘前同步标的维表。""" + """盘前同步个股维表。""" rows = instrument_sync.sync_instruments(repo.store.data_dir) _refresh_instruments_view(repo) _invalidate("instruments") @@ -89,12 +89,12 @@ def run_now( emit = on_progress or _noop skipped: list[str] = [] - # Step 0: 先同步标的维表, 再解析标的池 — 确保标的池基于最新 instruments - emit("sync_instruments", 2, "同步标的维表…") + # Step 0: 先同步个股维表, 再解析标的池 — 确保标的池基于最新 instruments + emit("sync_instruments", 2, "同步个股维表…") inst_rows = instrument_sync.sync_instruments(repo.store.data_dir) if inst_rows > 0: _refresh_instruments_view(repo) - emit("sync_instruments", 8, f"标的维表同步完成,{inst_rows} 只标的") + emit("sync_instruments", 8, f"个股维表同步完成,{inst_rows} 只标的") _invalidate("instruments") emit("resolve_universe", 9, "解析标的池…") @@ -111,7 +111,12 @@ def run_now( today_exists = latest_daily and latest_daily >= today new_daily_days = 0 - if today_exists and capset.has(Cap.QUOTE_POOL): + # A 股日K拉取开关(默认开);关闭时跳过日K同步,保留已有数据 + pull_a_share = _prefs.get_pipeline_pull_a_share() + if not pull_a_share: + emit("sync_daily", 45, "已跳过 A 股日K同步(拉取内容未勾选)") + logger.info("sync_daily: skipped (pipeline_pull_a_share=False)") + elif today_exists and capset.has(Cap.QUOTE_POOL): # 付费档:今天有数据(QuoteService 已落盘)→ 实时行情覆写,确保最新。 # free/none 档无 quote.pool 能力,即便今天已有数据(如从 expert 降级), # 也降级到下方 batch 路径刷新,避免调用无权限的实时行情接口。 @@ -291,33 +296,98 @@ def run_now( _refresh_single_view(repo, "kline_enriched") _invalidate("enriched") - # Step 2.3: 指数同步 — 独立 kline_index_* 存储,不进入股票选股/策略链路。 + # Step 2.3: 指数 / ETF 同步 — 物理分开存储;ETF 可复权,指数不复权。 written_index_daily = 0 + written_etf_daily = 0 index_count = 0 - if capset.has(Cap.KLINE_DAILY_BATCH): - emit("sync_index", 88, "同步指数列表与日K…") + etf_count = 0 + etf_adj_symbols = 0 + pull_index = _prefs.get_pipeline_pull_index() + pull_etf = _prefs.get_pipeline_pull_etf() + + if capset.has(Cap.KLINE_DAILY_BATCH) and (pull_index or pull_etf): + _types = [] + if pull_index: + _types.append("指数") + if pull_etf: + _types.append("ETF") + emit("sync_index", 88, f"同步{'+'.join(_types)}日K…") try: - index_count = index_sync.sync_index_instruments(repo) - 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) - 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()), - ) + if pull_index: + index_count = index_sync.sync_index_instruments(repo, pull_index=True, pull_etf=False) + 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) + 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()), + ) + _invalidate("index_instruments") + _invalidate("index_daily") + _invalidate("index_enriched") + + if pull_etf: + etf_count = index_sync.sync_etf_instruments(repo) + 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: + from datetime import datetime, timedelta + adj_end = datetime.now() + adj_path = repo.store.data_dir / "adj_factor_etf" / "all.parquet" + fallback_start = adj_end - timedelta(days=30) + adj_start = fallback_start + if adj_path.exists(): + max_date = pl.scan_parquet(adj_path).select(pl.col("trade_date").max()).collect().item() + if max_date is not None: + if isinstance(max_date, str): + adj_start = datetime.combine(_date.fromisoformat(max_date), datetime.min.time()) + elif isinstance(max_date, datetime): + adj_start = datetime.combine(max_date.date(), datetime.min.time()) + else: + adj_start = datetime.combine(max_date, datetime.min.time()) + _, affected_etfs = index_sync.sync_etf_adj_factor( + etf_symbols, + repo, + capset, + start_time=adj_start, + end_time=adj_end, + ) + etf_adj_symbols = len(affected_etfs) + except Exception as e: # noqa: BLE001 + logger.warning("ETF adj_factor skipped: %s", e) + etf_dir = repo.store.data_dir / "kline_etf_enriched" + etf_dates = sorted( + d.name[5:] for d in etf_dir.glob("date=*") + 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) + 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()), + ) + _invalidate("etf_instruments") + _invalidate("etf_daily") + repo.refresh_index_views() - _invalidate("index_instruments") - _invalidate("index_daily") - _invalidate("index_enriched") - emit("sync_index", 89, f"指数完成,{index_count} 只指数,{written_index_daily} 行日K") + emit( + "sync_index", + 89, + f"同步完成,指数 {index_count} 只/{written_index_daily} 行, ETF {etf_count} 只/{written_etf_daily} 行" + + (f", ETF复权 {etf_adj_symbols} 只" if etf_adj_symbols else ""), + ) except Exception as e: # noqa: BLE001 - logger.warning("sync_index failed: %s", e) - emit("sync_index", 89, f"指数同步失败:{e}") + logger.warning("sync_index/etf failed: %s", e) + emit("sync_index", 89, f"指数/ETF同步失败:{e}") else: skipped.append("sync_index") @@ -364,6 +434,9 @@ def run_now( "enriched_days": written_enriched, "index_count": index_count, "index_daily_rows": written_index_daily, + "etf_count": etf_count, + "etf_daily_rows": written_etf_daily, + "etf_adj_factor_symbols": etf_adj_symbols, "minute_rows": written_minute, "skipped_stages": skipped, } @@ -377,10 +450,15 @@ def _refresh_views(repo: KlineRepository) -> None: "kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet", "kline_index_daily": f"{d}/kline_index_daily/**/*.parquet", "kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet", + "kline_etf_daily": f"{d}/kline_etf_daily/**/*.parquet", + "kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet", + "kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet", "kline_minute": f"{d}/kline_minute/**/*.parquet", "adj_factor": f"{d}/adj_factor/**/*.parquet", + "adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet", "instruments": f"{d}/instruments/**/*.parquet", "instruments_index": f"{d}/instruments_index/**/*.parquet", + "instruments_etf": f"{d}/instruments_etf/**/*.parquet", } for name, path in views.items(): try: @@ -390,6 +468,7 @@ def _refresh_views(repo: KlineRepository) -> None: ) except Exception as e: # noqa: BLE001 logger.warning("refresh view %s failed: %s", name, e) + repo.store._register_unified_views() def _refresh_single_view(repo: KlineRepository, name: str) -> None: @@ -400,10 +479,15 @@ def _refresh_single_view(repo: KlineRepository, name: str) -> None: "kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet", "kline_index_daily": f"{d}/kline_index_daily/**/*.parquet", "kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet", + "kline_etf_daily": f"{d}/kline_etf_daily/**/*.parquet", + "kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet", + "kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet", "kline_minute": f"{d}/kline_minute/**/*.parquet", "adj_factor": f"{d}/adj_factor/**/*.parquet", + "adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet", "instruments": f"{d}/instruments/**/*.parquet", "instruments_index": f"{d}/instruments_index/**/*.parquet", + "instruments_etf": f"{d}/instruments_etf/**/*.parquet", } path = paths.get(name) if not path: @@ -457,7 +541,7 @@ def _run_tracked(fn, job_label: str) -> None: def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOScheduler: """启动调度器。 - 工作日 09:10 — 同步标的维表 + 工作日 09:10 — 同步个股维表 工作日 HH:MM — 盘后管道(时间由用户偏好决定,默认 15:30) """ from app.services import preferences @@ -469,9 +553,9 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche # 盘前: 同步 instruments(时间由偏好决定) def _instruments_task(on_progress=None): emit = on_progress or _noop - emit("sync_instruments", 0, "同步标的维表…") + emit("sync_instruments", 0, "同步个股维表…") result = run_instruments_sync(repo) - emit("done", 100, f"标的维表同步完成,{result.get('instruments_rows', 0)} 只标的") + emit("done", 100, f"个股维表同步完成,{result.get('instruments_rows', 0)} 只标的") return result scheduler.add_job( diff --git a/backend/app/main.py b/backend/app/main.py index f8fc073..84a875f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,7 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from app import __version__ -from app.api import analysis, backtest, data, ext_data, financials, indices, intraday, kline, monitor_rules, alerts, overview, pipeline, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist +from app.api import analysis, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist from app.api.routes import router as core_router from app.config import settings from app.jobs import daily_pipeline @@ -193,6 +193,7 @@ app.include_router(data.router) app.include_router(ext_data.router) app.include_router(financials.router) app.include_router(stock_analysis.router) +app.include_router(market_recap.router) app.include_router(settings_api.router) app.include_router(strategy.router) app.include_router(signals.router) diff --git a/backend/app/services/index_sync.py b/backend/app/services/index_sync.py index b7ee11a..4f689b9 100644 --- a/backend/app/services/index_sync.py +++ b/backend/app/services/index_sync.py @@ -1,4 +1,9 @@ -"""指数数据同步服务。""" +"""指数 / ETF 数据同步服务。 + +标的列表优先用免费的 exchanges.get_instruments(type=index/etf) 拉取 +(None/Free 档均可用,无需 quote.pool 权限);付费档可额外用 +quotes.get_by_universes 作为补充来源。日K统一走 klines.batch。 +""" from __future__ import annotations import logging @@ -15,9 +20,15 @@ from app.tickflow.repository import KlineRepository logger = logging.getLogger(__name__) +# exchanges.get_instruments 查询的交易所(沪深京) +_EXCHANGES = ["SH", "SZ", "BJ"] + def _quotes_to_index_instruments(resp) -> pl.DataFrame: - """将 TickFlow quotes 响应规范为指数 instruments。""" + """将 TickFlow quotes 响应(get_by_universes)规范为指数 instruments。 + + 付费档(Starter+)的补充来源,免费档用不到。 + """ if resp is None: return pl.DataFrame() @@ -61,33 +72,119 @@ def _quotes_to_index_instruments(resp) -> pl.DataFrame: return result.unique(subset=["symbol"], keep="last").sort("symbol") -def sync_index_instruments(repo: KlineRepository) -> int: - """同步 CN_Index 指数标的维表,返回指数数量。""" +def _fetch_instruments_by_type(instrument_type: str, asset_type_label: str) -> pl.DataFrame: + """用免费的 exchanges.get_instruments 拉取指定类型的标的列表。 + + None/Free 档均可使用(标的信息查询免费开放)。 + instrument_type: 'index' / 'etf' + asset_type_label: 写入 instruments 表的 asset_type 标记('index' / 'etf') + """ tf = get_client() - resp = None - errors: list[str] = [] - for kwargs in ( - {"universes": ["CN_Index"]}, - {"universes": ["CN_Index"], "as_dataframe": False}, - ): + rows: list[dict] = [] + for ex in _EXCHANGES: try: - resp = tf.quotes.get_by_universes(**kwargs) - if resp is not None and len(resp) > 0: - break + items = tf.exchanges.get_instruments(ex, instrument_type=instrument_type) + for it in items or []: + item = it if isinstance(it, dict) else {} + symbol = item.get("symbol") + if not symbol: + continue + rows.append({ + "symbol": str(symbol), + "name": item.get("name") or str(symbol), + }) except Exception as e: # noqa: BLE001 - errors.append(str(e)) - resp = None + logger.warning("get_instruments(%s, type=%s) failed: %s", ex, instrument_type, e) - if resp is None or len(resp) == 0: - logger.warning("CN_Index universe returned empty: %s", "; ".join(errors)) - return 0 + if not rows: + return pl.DataFrame() - instruments = _quotes_to_index_instruments(resp) - if instruments.is_empty(): + return ( + pl.DataFrame(rows) + .with_columns([ + pl.col("symbol").str.split(".").list.first().alias("code"), + pl.lit(asset_type_label).alias("asset_type"), + ]) + .unique(subset=["symbol"], keep="last") + .sort("symbol") + ) + + +def sync_index_instruments( + repo: KlineRepository, + pull_index: bool = True, + pull_etf: bool = True, +) -> int: + """同步指数 / ETF 标的维表,返回标的总数。 + + 新版物理分开保存: 指数写 instruments_index, ETF 写 instruments_etf。 + 读取层仍兼容旧版 instruments_index 中 asset_type='etf' 的历史数据。 + """ + index_parts: list[pl.DataFrame] = [] + etf_parts: list[pl.DataFrame] = [] + + # 1) 免费通道:按开关分别拉 index / etf + if pull_index: + index_df = _fetch_instruments_by_type("index", "index") + if not index_df.is_empty(): + index_parts.append(index_df) + if pull_etf: + etf_df = _fetch_instruments_by_type("etf", "etf") + if not etf_df.is_empty(): + etf_parts.append(etf_df) + + # 2) 付费补充:Starter+ 用 get_by_universes 补指数(仅当开启指数拉取) + if pull_index: + capset = None + try: + from app.tickflow import policy + capset = policy.detect_capabilities(force=False) + except Exception: # noqa: BLE001 + pass + if capset is not None and capset.has(Cap.QUOTE_POOL): + tf = get_client() + for kwargs in ( + {"universes": ["CN_Index"]}, + {"universes": ["CN_Index"], "as_dataframe": False}, + ): + try: + resp = tf.quotes.get_by_universes(**kwargs) + if resp is not None and len(resp) > 0: + sup = _quotes_to_index_instruments(resp) + if not sup.is_empty(): + index_parts.append(sup) + break + except Exception as e: # noqa: BLE001 + logger.debug("CN_Index universe supplement failed: %s", e) + + total = 0 + if index_parts: + index_inst = pl.concat(index_parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol") + if not index_inst.is_empty(): + repo.save_index_instruments(index_inst) + total += index_inst.height + if etf_parts: + etf_inst = pl.concat(etf_parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol") + if not etf_inst.is_empty(): + repo.save_etf_instruments(etf_inst) + total += etf_inst.height + + if total == 0: + logger.warning("指数/ETF 标的列表为空(pull_index=%s, pull_etf=%s)", pull_index, pull_etf) return 0 - repo.save_index_instruments(instruments) repo.refresh_index_views() - return instruments.height + logger.info("指数/ETF 标的同步完成: %d 只", total) + return total + + +def sync_etf_instruments(repo: KlineRepository) -> int: + """单独同步 ETF 标的维表(返回 ETF 数量)。""" + etf_df = _fetch_instruments_by_type("etf", "etf") + if etf_df.is_empty(): + return 0 + repo.save_etf_instruments(etf_df) + repo.refresh_index_views() + return etf_df.height def sync_and_persist_index_daily( @@ -96,19 +193,30 @@ def sync_and_persist_index_daily( count: int | None = None, start_date: datetime | None = None, end_date: datetime | None = None, + symbols_override: list[str] | None = None, ) -> int: - """同步指数日K到独立 parquet,并计算指数 enriched。""" + """同步指数/ETF 日K到独立 parquet,并计算 enriched。 + + symbols_override 非空时,只拉这些代码(跳过 instruments 表),用于自定义范围。 + 否则取 index_instruments 表全量(指数+ETF 合并存储)。 + """ if not capset.has(Cap.KLINE_DAILY_BATCH): return 0 - instruments = repo.get_index_instruments() - if instruments.is_empty(): - sync_index_instruments(repo) + if symbols_override: + symbols = sorted(set(s for s in symbols_override if s)) + if not symbols: + return 0 + else: instruments = repo.get_index_instruments() - if instruments.is_empty() or "symbol" not in instruments.columns: - return 0 - - symbols = sorted(set(instruments["symbol"].to_list())) + if instruments.is_empty(): + sync_index_instruments(repo, pull_index=True, pull_etf=False) + instruments = repo.get_index_instruments() + if not instruments.is_empty() and "asset_type" in instruments.columns: + instruments = instruments.filter(pl.col("asset_type") != "etf") + if instruments.is_empty() or "symbol" not in instruments.columns: + return 0 + symbols = sorted(set(instruments["symbol"].to_list())) lim = capset.limits(Cap.KLINE_DAILY_BATCH) batch_size = preferences.get_index_daily_batch_size() if lim and lim.batch: @@ -139,7 +247,103 @@ def sync_and_persist_index_daily( enriched = compute_enriched(raw, factors=None, instruments=None) repo.append_index_enriched(enriched) total_rows += raw.height - logger.info("index daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height) + logger.info("index/etf daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height) + del raw, enriched + gc.collect() + repo.refresh_index_views() + return total_rows + + +def _load_etf_factors(repo: KlineRepository) -> pl.DataFrame: + factor_path = repo.store.data_dir / "adj_factor_etf" / "all.parquet" + if not factor_path.exists(): + return pl.DataFrame() + try: + return pl.read_parquet(factor_path) + except Exception as e: # noqa: BLE001 + logger.warning("ETF 复权因子读取失败: %s", e) + return pl.DataFrame() + + +def sync_etf_adj_factor( + symbols: list[str], + repo: KlineRepository, + capset: CapabilitySet, + start_time: datetime | None = None, + end_time: datetime | None = None, + on_chunk_done=None, +) -> tuple[int, list[str]]: + """同步 ETF 复权因子;失败由调用方降级为 warning。""" + return kline_sync.sync_adj_factor( + symbols, + repo, + capset, + start_time=start_time, + end_time=end_time, + on_chunk_done=on_chunk_done, + asset_type="etf", + ) + + +def sync_and_persist_etf_daily( + repo: KlineRepository, + capset: CapabilitySet, + count: int | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + symbols_override: list[str] | None = None, +) -> int: + """同步 ETF 日K到独立 kline_etf_* parquet,并计算 ETF enriched。""" + if not capset.has(Cap.KLINE_DAILY_BATCH): + return 0 + + if symbols_override: + symbols = sorted(set(s for s in symbols_override if s)) + else: + instruments = repo.get_etf_instruments() + if instruments.is_empty(): + sync_etf_instruments(repo) + instruments = repo.get_etf_instruments() + if instruments.is_empty() or "symbol" not in instruments.columns: + return 0 + symbols = sorted(set(instruments["symbol"].to_list())) + if not symbols: + return 0 + + lim = capset.limits(Cap.KLINE_DAILY_BATCH) + batch_size = preferences.get_index_daily_batch_size() + if lim and lim.batch: + batch_size = min(batch_size, lim.batch) + rpm = lim.rpm if lim else None + + end_time = end_date or datetime.now() + start_time = start_date or (end_time - timedelta(days=365)) + + total_rows = 0 + interval = (60.0 / rpm) if rpm else 0 + chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)] + factors = _load_etf_factors(repo) + for i, chunk in enumerate(chunks): + if i > 0 and interval > 0 and len(chunks) > rpm: + import time + time.sleep(interval) + raw = kline_sync.sync_daily_batch( + chunk, + count=count, + batch_size=None, + start_time=start_time, + end_time=end_time, + ) + if raw.is_empty(): + continue + + repo.append_etf_daily(raw) + batch_factors = factors.filter(pl.col("symbol").is_in(chunk)) if not factors.is_empty() else factors + # ETF 使用复权和通用技术指标;不传 instruments,避免套用 A股涨跌停/连板逻辑。 + enriched = compute_enriched(raw, factors=batch_factors, instruments=None) + 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) del raw, enriched gc.collect() repo.refresh_index_views() diff --git a/backend/app/services/kline_sync.py b/backend/app/services/kline_sync.py index e1ac71d..cff2f70 100644 --- a/backend/app/services/kline_sync.py +++ b/backend/app/services/kline_sync.py @@ -223,11 +223,47 @@ def sync_daily_by_quotes(repo: KlineRepository) -> int: return daily_df.height +def _normalize_adj_factor(raw) -> pl.DataFrame: + """Normalize SDK ex_factors response to symbol/trade_date/ex_factor.""" + if raw is None or len(raw) == 0: + return pl.DataFrame() + if isinstance(raw, dict): + rows: list[dict] = [] + for sym, values in raw.items(): + for item in values or []: + row = dict(item or {}) + row.setdefault("symbol", sym) + rows.append(row) + df = pl.DataFrame(rows) if rows else pl.DataFrame() + elif isinstance(raw, pl.DataFrame): + df = raw + else: + 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}) + 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( + pl.from_epoch(pl.col("trade_date").cast(pl.Int64), time_unit="ms").dt.date().alias("trade_date") + ) + else: + df = df.with_columns(pl.col("trade_date").cast(pl.Date, strict=False)) + if "ex_factor" in df.columns: + df = df.with_columns(pl.col("ex_factor").cast(pl.Float64, strict=False)) + cols = [c for c in ["symbol", "trade_date", "ex_factor"] if c in df.columns] + if len(cols) < 3: + return pl.DataFrame() + return df.select(cols).drop_nulls() + + def sync_adj_factor(symbols: list[str], repo: KlineRepository, capset: CapabilitySet, start_time: datetime | None = None, end_time: datetime | None = None, - on_chunk_done: Callable[[int, int], None] | None = None) -> tuple[int, list[str]]: + on_chunk_done: Callable[[int, int], None] | None = None, + asset_type: str = "stock") -> tuple[int, list[str]]: """同步除权因子(Starter+)。SDK 接口:`tf.klines.ex_factors(symbols=...)`。 支持增量: 传 start_time/end_time 只拉取该时间范围内的新除权事件。 @@ -257,10 +293,9 @@ def sync_adj_factor(symbols: list[str], repo: KlineRepository, time.sleep(interval) try: raw = tf.klines.ex_factors(chunk, **sdk_kwargs) - if raw is not None and len(raw) > 0: - all_dfs.append(pl.from_pandas( - raw.reset_index() if hasattr(raw, "reset_index") else raw - )) + normalized = _normalize_adj_factor(raw) + if not normalized.is_empty(): + all_dfs.append(normalized) logger.debug("adj_factor chunk %d/%d: %d symbols", i + 1, len(chunks), len(chunk)) except Exception as e: # noqa: BLE001 logger.warning("adj_factor chunk %d failed: %s", i + 1, e) @@ -276,7 +311,8 @@ def sync_adj_factor(symbols: list[str], repo: KlineRepository, # 提取受影响的 symbol 列表(合并前) affected = new_data["symbol"].unique().to_list() - out = repo.store.data_dir / "adj_factor" / "all.parquet" + factor_dir = "adj_factor_etf" if asset_type == "etf" else "adj_factor" + out = repo.store.data_dir / factor_dir / "all.parquet" out.parent.mkdir(parents=True, exist_ok=True) if out.exists(): diff --git a/backend/app/services/market_overview_builder.py b/backend/app/services/market_overview_builder.py new file mode 100644 index 0000000..8a1adfd --- /dev/null +++ b/backend/app/services/market_overview_builder.py @@ -0,0 +1,576 @@ +"""市场总览数据装配(与 HTTP Request 解耦)。 + +本模块由 `app.api.overview._build_overview` 抽离而来,目的是让「大盘复盘」 +等无 Request 的调用方(定时任务、复盘服务)也能复用同一套聚合逻辑。 + +行为与原 `_build_overview` 完全一致,仅把对 `request.app.state.{repo, +quote_service,depth_service}` 的依赖改为显式参数。 + +公共入口: + build_market_overview(repo, quote_service, depth_service, as_of) +""" +from __future__ import annotations + +import math +import re +from datetime import date +from typing import Any + +import polars as pl + +from app.services.ext_data import ExtConfig, ExtConfigStore +from app.services.screener import ScreenerService + +# ================================================================ +# 常量(与 overview.py 保持同步;复盘复盘仅 A 股核心指数) +# ================================================================ + +CORE_INDEX_NAMES = { + "000001.SH": "上证指数", + "399001.SZ": "深证成指", + "399006.SZ": "创业板指", + "000680.SH": "科创综指", +} +CORE_INDEX_SYMBOLS = tuple(CORE_INDEX_NAMES.keys()) + +_DIMENSION_SEP = re.compile(r"[、,,;;|/\s]+") + + +# ================================================================ +# 通用工具 +# ================================================================ + +def _finite(v: Any) -> float | None: + if v is None: + return None + try: + f = float(v) + except (TypeError, ValueError): + return None + return f if math.isfinite(f) else None + + +def _json_safe(value: Any) -> Any: + if isinstance(value, dict): + return {k: _json_safe(v) for k, v in value.items()} + if isinstance(value, list): + return [_json_safe(v) for v in value] + if isinstance(value, float) and not math.isfinite(value): + return None + return value + + +def _board(symbol: str) -> str: + if symbol.endswith(".BJ"): + return "北交所" + if symbol.startswith(("300", "301")): + return "创业板" + if symbol.startswith(("688", "689")): + return "科创板" + if symbol.endswith(".SH"): + return "沪主板" + if symbol.endswith(".SZ"): + return "深主板" + return "其他" + + +def _score(value: float, low: float, high: float) -> int: + if high <= low: + return 50 + return max(0, min(100, round((value - low) / (high - low) * 100))) + + +# ================================================================ +# 指数行情(实时 quote_service 优先,回退 kline_index_daily SQL) +# ================================================================ + +def _quote_status(quote_service) -> dict: + qs = quote_service + if not qs: + return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False} + return qs.status() + + +def _index_quotes(repo, quote_service, as_of: date | None = None) -> list[dict]: + rows: list[dict] = [] + if quote_service and as_of is None: + df = quote_service.get_index_quotes(list(CORE_INDEX_SYMBOLS)) + if not df.is_empty(): + rows = df.to_dicts() + + if not rows and repo: + placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS) + try: + db_rows = repo.execute_all( + f""" + WITH ranked AS ( + SELECT symbol, date, close, + row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn + FROM kline_index_daily + WHERE symbol IN ({placeholders}) + AND (? IS NULL OR date <= ?) + ), latest AS ( + SELECT symbol, + max(CASE WHEN rn = 1 THEN date END) AS date, + max(CASE WHEN rn = 1 THEN close END) AS last_price, + max(CASE WHEN rn = 2 THEN close END) AS prev_close + FROM ranked + WHERE rn <= 2 + GROUP BY symbol + ) + SELECT symbol, date, last_price, prev_close + FROM latest + """, + [*CORE_INDEX_SYMBOLS, as_of, as_of], + ) + except Exception: # noqa: BLE001 + db_rows = [] + for symbol, dt, last_price, prev_close in db_rows: + change_amount = None + change_pct = None + lp = _finite(last_price) + pc = _finite(prev_close) + if lp is not None and pc not in (None, 0): + change_amount = lp - pc + change_pct = change_amount / pc * 100 + rows.append({ + "symbol": symbol, + "name": CORE_INDEX_NAMES.get(symbol), + "date": str(dt) if dt else None, + "last_price": lp, + "close": lp, + "prev_close": pc, + "change_amount": change_amount, + "change_pct": change_pct, + }) + + by_symbol = {r.get("symbol"): r for r in rows} + out = [] + for symbol in CORE_INDEX_SYMBOLS: + r = by_symbol.get(symbol, {"symbol": symbol}) + out.append({ + "symbol": symbol, + "name": r.get("name") or CORE_INDEX_NAMES[symbol], + "last_price": _finite(r.get("last_price") if r.get("last_price") is not None else r.get("close")), + "change_pct": _finite(r.get("change_pct")), + "change_amount": _finite(r.get("change_amount")), + }) + return out + + +# ================================================================ +# 扩展数据(行业 / 概念)维度聚合 +# ================================================================ + +def _dimension_field(config: ExtConfig, kind: str) -> str | None: + candidates = ["概念", "concept", "theme"] if kind == "concept" else ["行业", "industry", "sector"] + for candidate in candidates: + needle = candidate.lower() + for field in config.fields: + haystack = f"{field.name} {field.label}".lower() + if needle in haystack: + return field.name + return None + + +def _ext_files(data_dir, config: ExtConfig) -> list[str]: + base = data_dir / "ext_data" / config.id + if config.mode == "timeseries": + root = base / "timeseries" + return [str(p) for p in sorted(root.rglob("*.parquet")) if p.is_file()] + return [str(p) for p in sorted(base.glob("*.parquet")) if p.is_file()] + + +def _read_ext_rows(data_dir, config: ExtConfig, dimension_field: str) -> list[dict]: + files = _ext_files(data_dir, config) + if not files: + return [] + try: + df = pl.read_parquet(files, hive_partitioning=True) + except TypeError: + try: + df = pl.read_parquet(files) + except Exception: # noqa: BLE001 + return [] + except Exception: # noqa: BLE001 + return [] + if df.is_empty() or dimension_field not in df.columns: + return [] + + if config.mode == "timeseries" and "date" in df.columns: + latest = df.get_column("date").max() + if latest is not None: + df = df.filter(pl.col("date") == latest) + + symbol_cols = ["symbol", "code", "股票代码", "代码"] + for mapping in (config.symbol_map, config.code_map): + if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"): + symbol_cols.append(str(mapping["col"])) + cols = [] + for col in [dimension_field, *symbol_cols]: + if col in df.columns and col not in cols: + cols.append(col) + return df.select(cols).to_dicts() + + +def _dimension_values(raw: Any) -> list[str]: + if raw is None: + return [] + values = [v.strip() for v in _DIMENSION_SEP.split(str(raw).strip()) if v.strip()] + return values + + +def _symbol_keys(row: dict, config: ExtConfig) -> list[str]: + fields = ["symbol", "code", "股票代码", "代码"] + for mapping in (config.symbol_map, config.code_map): + if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"): + fields.append(str(mapping["col"])) + + keys: list[str] = [] + for field in fields: + raw = row.get(field) + if raw is None: + continue + text = str(raw).strip().upper() + if not text: + continue + keys.append(text) + if "." in text: + keys.append(text.split(".", 1)[0]) + return keys + + +def _dimension_rank(rows: list[dict], repo, kind: str, limit: int = 5, level: int | None = None) -> dict: + if not rows: + return {"leading": [], "lagging": []} + + quote_map: dict[str, dict] = {} + for row in rows: + symbol = str(row.get("symbol") or "").strip().upper() + if not symbol: + continue + quote_map[symbol] = row + quote_map[symbol.split(".", 1)[0]] = row + + store = ExtConfigStore(repo.store.data_dir) + groups: dict[str, dict[str, dict]] = {} + for config in store.load_all(): + field = _dimension_field(config, kind) + if not field: + continue + for ext_row in _read_ext_rows(repo.store.data_dir, config, field): + quote = None + for key in _symbol_keys(ext_row, config): + quote = quote_map.get(key) + if quote: + break + if not quote: + continue + symbol = str(quote.get("symbol") or "") + for value in _dimension_values(ext_row.get(field)): + # 行业按 "-" 拆分级: "银行-银行-股份制银行" → level=2 取"银行"(二级) + if level is not None and "-" in value: + parts = value.split("-") + value = parts[level - 1] if level <= len(parts) else parts[-1] + groups.setdefault(value, {})[symbol] = quote + + items = [] + for name, by_symbol in groups.items(): + stocks = list(by_symbol.values()) + changes = [_finite(s.get("change_pct")) for s in stocks] + changes = [v for v in changes if v is not None] + if not changes: + continue + leader = max(stocks, key=lambda s: _finite(s.get("change_pct")) or -999) + items.append({ + "name": name, + "count": len(stocks), + "avg_pct": sum(changes) / len(changes), + "up_count": sum(1 for v in changes if v > 0), + "down_count": sum(1 for v in changes if v < 0), + "amount": sum(_finite(s.get("amount")) or 0 for s in stocks), + "leader": { + "symbol": leader.get("symbol"), + "name": leader.get("name"), + "change_pct": _finite(leader.get("change_pct")), + }, + }) + + leading = sorted(items, key=lambda x: x["avg_pct"], reverse=True)[:limit] + lagging = sorted(items, key=lambda x: x["avg_pct"])[:limit] + return {"leading": leading, "lagging": lagging} + + +# ================================================================ +# Top 行 / 涨跌幅分桶 +# ================================================================ + +def _top_rows(rows: list[dict], key: str, descending: bool, limit: int = 8) -> list[dict]: + filtered = [r for r in rows if _finite(r.get(key)) is not None] + filtered.sort(key=lambda r: _finite(r.get(key)) or 0, reverse=descending) + return [ + { + "symbol": r.get("symbol"), + "name": r.get("name"), + "close": _finite(r.get("close")), + "change_pct": _finite(r.get("change_pct")), + "amount": _finite(r.get("amount")), + "turnover_rate": _finite(r.get("turnover_rate")), + "board": _board(str(r.get("symbol") or "")), + } + for r in filtered[:limit] + ] + + +def _pct_band_rows(values: list[float]) -> list[dict]: + bands = [ + ("<-5%", None, -0.05), + ("-5~-3%", -0.05, -0.03), + ("-3~-1%", -0.03, -0.01), + ("-1~0%", -0.01, 0), + ("0~1%", 0, 0.01), + ("1~3%", 0.01, 0.03), + ("3~5%", 0.03, 0.05), + (">5%", 0.05, None), + ] + total = len(values) or 1 + out = [] + for label, low, high in bands: + count = 0 + for v in values: + if low is None and v < high: + count += 1 + elif high is None and v >= low: + count += 1 + elif low is not None and high is not None and low <= v < high: + count += 1 + out.append({"label": label, "count": count, "pct": count / total * 100}) + return out + + +# ================================================================ +# 主装配入口 +# ================================================================ + +def build_market_overview( + repo, + quote_service=None, + depth_service=None, + as_of: date | None = None, +) -> dict: + """装配市场总览(与原 overview._build_overview 行为一致)。 + + Args: + repo: KlineRepository(必填)。 + quote_service: QuoteService(可选;实时指数行情来源)。 + depth_service: DepthService(可选;五档封板修正)。 + as_of: 指定日期,None 则取最新有数据日。 + """ + svc = ScreenerService(repo) + as_of = as_of or svc.latest_date() + status = _quote_status(quote_service) + indices = _index_quotes(repo, quote_service, as_of) + + if not as_of: + return { + "as_of": None, + "quote_status": status, + "indices": indices, + "breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 0}, + "amount": {"total": 0, "avg": 0}, + "boards": [], + "limit": {"limit_up": 0, "broken": 0, "failed": 0, "limit_down": 0, "max_boards": 0, "tiers": []}, + "distribution": [], + "trend": {"above_ma5": 0, "above_ma20": 0, "above_ma60": 0, "above_ma5_pct": 0, "above_ma20_pct": 0, "above_ma60_pct": 0, "new_high": 0, "new_low": 0}, + "activity": {"avg_turnover": 0, "high_turnover": 0, "high_vol_ratio": 0, "vol_ratio": 1}, + "radar": [], + "emotion": {"score": 50, "label": "暂无"}, + "top_gainers": [], + "top_losers": [], + "turnover_leaders": [], + "active_leaders": [], + "concept_rank": {"leading": [], "lagging": []}, + "industry_rank": {"leading": [], "lagging": []}, + } + + df = svc._load_enriched_for_date(as_of) + if df.is_empty(): + rows: list[dict] = [] + else: + cols = [ + "symbol", "name", "close", "change_pct", "amount", "turnover_rate", "volume", + "vol_ratio_5d", "consecutive_limit_ups", "signal_limit_up", "signal_broken_limit_up", "signal_limit_down", + "ma5", "ma20", "ma60", "high_60d", "low_60d", "signal_n_day_high", "signal_n_day_low", + ] + df = df.select([c for c in cols if c in df.columns]) + rows = df.to_dicts() + + # 过滤真停牌(volume=0 且 change_pct=0),保留有涨跌幅的浮点误差股以对齐同花顺口径 + if rows and "volume" in rows[0]: + rows = [r for r in rows + if (_finite(r.get("volume")) or 0) > 0 + or (_finite(r.get("change_pct")) or 0) != 0] + + total = len(rows) + up = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) > 0) + down = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) < 0) + flat = max(0, total - up - down) + up_pct = up / total * 100 if total else 0 + down_pct = down / total * 100 if total else 0 + + amounts = [_finite(r.get("amount")) or 0 for r in rows] + total_amount = sum(amounts) + avg_amount = total_amount / total if total else 0 + + pct_values = [_finite(r.get("change_pct")) for r in rows] + pct_values = [v for v in pct_values if v is not None] + avg_pct = sum(pct_values) / len(pct_values) if pct_values else 0 + median_pct = sorted(pct_values)[len(pct_values) // 2] if pct_values else 0 + strong_up = sum(1 for v in pct_values if v >= 0.03) + strong_down = sum(1 for v in pct_values if v <= -0.03) + + limit_up = sum(1 for r in rows if bool(r.get("signal_limit_up")) or (_finite(r.get("consecutive_limit_ups")) or 0) > 0) + broken = sum(1 for r in rows if bool(r.get("signal_broken_limit_up"))) + limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down"))) + max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0) + + # 五档 sealed 修正: 假涨停/假跌停不计入(需 Pro+ depth5.batch 能力) + sealed_ready = False + fake_up = 0 + fake_down = 0 + if depth_service: + up_map = depth_service.get_sealed_map(as_of, is_down=False) + down_map = depth_service.get_sealed_map(as_of, is_down=True) + sealed_ready = bool(up_map or down_map) and depth_service.is_sealed_ready(as_of) + if up_map: + fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False) + if down_map: + fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False) + if sealed_ready: + limit_up = max(0, limit_up - fake_up) + limit_down = max(0, limit_down - fake_down) + + seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0 + + def above_ma_count(ma_key: str) -> int: + return sum(1 for r in rows if (_finite(r.get("close")) is not None and _finite(r.get(ma_key)) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get(ma_key)) or 0))) + + above_ma5 = above_ma_count("ma5") + above_ma20 = above_ma_count("ma20") + above_ma60 = above_ma_count("ma60") + new_high = sum(1 for r in rows if bool(r.get("signal_n_day_high")) or (_finite(r.get("close")) is not None and _finite(r.get("high_60d")) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get("high_60d")) or 0))) + new_low = sum(1 for r in rows if bool(r.get("signal_n_day_low")) or (_finite(r.get("close")) is not None and _finite(r.get("low_60d")) is not None and (_finite(r.get("close")) or 0) <= (_finite(r.get("low_60d")) or 0))) + + turnovers = [_finite(r.get("turnover_rate")) for r in rows] + turnovers = [v for v in turnovers if v is not None] + avg_turnover = sum(turnovers) / len(turnovers) if turnovers else 0 + high_turnover = sum(1 for v in turnovers if v >= 5) + + boards_map: dict[str, dict] = {} + for r in rows: + b = _board(str(r.get("symbol") or "")) + item = boards_map.setdefault(b, {"board": b, "count": 0, "up": 0, "down": 0, "amount": 0.0}) + item["count"] += 1 + change = _finite(r.get("change_pct")) or 0 + if change > 0: + item["up"] += 1 + elif change < 0: + item["down"] += 1 + item["amount"] += _finite(r.get("amount")) or 0 + boards = sorted(boards_map.values(), key=lambda x: x["amount"], reverse=True) + for b in boards: + count = b["count"] or 1 + b["up_pct"] = b["up"] / count * 100 + + tiers_map: dict[int, int] = {} + for r in rows: + n = int(_finite(r.get("consecutive_limit_ups")) or 0) + if n > 0: + tiers_map[n] = tiers_map.get(n, 0) + 1 + tiers = [{"boards": k, "count": v} for k, v in sorted(tiers_map.items(), key=lambda item: -item[0])] + + index_changes = [_finite(r.get("change_pct")) for r in indices] + index_changes = [v for v in index_changes if v is not None] + avg_index_pct = sum(index_changes) / len(index_changes) if index_changes else 0 + vol_ratios = [_finite(r.get("vol_ratio_5d")) for r in rows] + vol_ratios = [v for v in vol_ratios if v is not None] + avg_vol_ratio = sum(vol_ratios) / len(vol_ratios) if vol_ratios else 1 + high_vol_ratio = sum(1 for v in vol_ratios if v >= 1.5) + + concept_rank = _dimension_rank(rows, repo, "concept") + industry_rank = _dimension_rank(rows, repo, "industry", level=2) + + strong_diff_pct = (strong_up - strong_down) / total * 100 if total else 0 + high_vol_pct = high_vol_ratio / total * 100 if total else 0 + strong_down_pct = strong_down / total * 100 if total else 0 + tier2_count = sum(t["count"] for t in tiers if t["boards"] >= 2) + mainline_items = [*concept_rank["leading"][:3], *industry_rank["leading"][:3]] + mainline_avg = max([_finite(item.get("avg_pct")) or 0 for item in mainline_items], default=0) + mainline_cover_pct = max([(_finite(item.get("count")) or 0) / total * 100 for item in mainline_items], default=0) if total else 0 + mainline_score = round(_score(mainline_avg, -0.005, 0.03) * 0.65 + _score(mainline_cover_pct, 1, 12) * 0.35) if mainline_items else 50 + + radar = [ + {"key": "index", "label": "指数", "value": _score(avg_index_pct, -2.5, 2.5)}, + {"key": "profit", "label": "赚钱", "value": round(_score(up_pct, 20, 80) * 0.45 + _score(avg_pct, -0.02, 0.02) * 0.25 + _score(median_pct, -0.02, 0.02) * 0.20 + _score(strong_diff_pct, -8, 8) * 0.10)}, + {"key": "money", "label": "量能", "value": round(_score(avg_vol_ratio, 0.6, 1.8) * 0.70 + _score(high_vol_pct, 2, 12) * 0.30)}, + {"key": "speculation", "label": "投机", "value": round(_score(limit_up, 5, 90) * 0.25 + _score(seal_rate, 30, 85) * 0.35 + _score(max_boards, 1, 8) * 0.25 + _score(tier2_count, 0, 30) * 0.15)}, + {"key": "resilience", "label": "抗跌", "value": 100 - round(_score(down_pct, 20, 80) * 0.55 + _score(strong_down_pct, 1, 12) * 0.45)}, + {"key": "mainline", "label": "主线", "value": mainline_score}, + ] + emotion_score = round(sum(r["value"] for r in radar) / len(radar)) if radar else 50 + if emotion_score >= 70: + emotion_label = "强势" + elif emotion_score >= 55: + emotion_label = "偏暖" + elif emotion_score >= 45: + emotion_label = "震荡" + elif emotion_score >= 30: + emotion_label = "偏冷" + else: + emotion_label = "冰点" + + return _json_safe({ + "as_of": str(as_of), + "quote_status": status, + "indices": indices, + "breadth": { + "total": total, + "up": up, + "down": down, + "flat": flat, + "up_pct": up_pct, + "down_pct": down_pct, + "avg_pct": avg_pct, + "median_pct": median_pct, + "strong_up": strong_up, + "strong_down": strong_down, + }, + "amount": {"total": total_amount, "avg": avg_amount}, + "boards": boards, + "limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers, "sealed_ready": sealed_ready, "fake_up": fake_up, "fake_down": fake_down}, + "distribution": _pct_band_rows(pct_values), + "trend": { + "above_ma5": above_ma5, + "above_ma20": above_ma20, + "above_ma60": above_ma60, + "above_ma5_pct": above_ma5 / total * 100 if total else 0, + "above_ma20_pct": above_ma20 / total * 100 if total else 0, + "above_ma60_pct": above_ma60 / total * 100 if total else 0, + "new_high": new_high, + "new_low": new_low, + }, + "activity": { + "avg_turnover": avg_turnover, + "high_turnover": high_turnover, + "high_vol_ratio": high_vol_pct, + "vol_ratio": avg_vol_ratio, + }, + "radar": radar, + "emotion": {"score": emotion_score, "label": emotion_label}, + "top_gainers": _top_rows(rows, "change_pct", True), + "top_losers": _top_rows(rows, "change_pct", False), + "turnover_leaders": _top_rows(rows, "amount", True), + "active_leaders": _top_rows(rows, "turnover_rate", True), + "concept_rank": concept_rank, + "industry_rank": industry_rank, + }) diff --git a/backend/app/services/market_recap.py b/backend/app/services/market_recap.py new file mode 100644 index 0000000..6c5ef3d --- /dev/null +++ b/backend/app/services/market_recap.py @@ -0,0 +1,356 @@ +"""AI 大盘复盘 —— 流式 LLM 复盘生成。 + +复刻 stock_analyzer.py 的 NDJSON 流式协议(meta/delta/error/done), +将「市场总览」聚合数据交给 LLM 生成结构化复盘报告。 + +数据来源:services.market_overview_builder.build_market_overview +(与 GET /api/overview/market 同源,保证复盘与看板数据口径一致)。 + +流式协议(与 stock_analyzer / financial_analyzer 一致,前端解析无差异): + {"type":"meta", "as_of", "emotion_score", "emotion_label", "summary"} + {"type":"delta","content":"..."} 逐 chunk 文本 + {"type":"error","message":"..."} + {"type":"done"} +""" +from __future__ import annotations + +import json +import logging +from datetime import date +from typing import AsyncIterator + +from app.services.market_overview_builder import build_market_overview + +logger = logging.getLogger(__name__) + + +# ================================================================ +# 系统提示词(市场策略师人格 + 固定七节模板) +# ================================================================ + +_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深市场策略师,擅长从指数结构、涨跌家数、连板梯队、板块轮动与资金情绪中提炼交易主线,产出可直接指导次日仓位与节奏的盘后复盘报告。 + +## 输出规范 + +用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。 + +### 1. 🎯 一句话定调(1-2 句) +用一句话概括今日市场的**核心矛盾与状态**(如"放量普涨、情绪修复,主线围绕科技扩散"/"指数虚高、个股杀跌,赚钱效应冰点")。结尾用【明日基调:进攻 / 均衡 / 防守】给出明确倾向。 + +### 2. 📊 盘面总览 +- 三大指数(上证/深证/创业板)表现:谁强谁弱、量能配合 +- 涨跌家数、涨停/跌停/炸板结构、两市成交额(放量/缩量判断) +- 情绪温度(强势/偏暖/震荡/偏冷/冰点)及一句话依据 + +### 3. 📈 指数结构 +谁在护盘、谁在拖累;指数是否同步;关键支撑/压力位(基于当日点位推断);是否存在量价背离。 + +### 4. 🔥 板块主线 +- 领涨板块:背后的逻辑(消息/业绩/资金/技术)、持续性判断、是否形成可交易主线 +- 领跌板块:风险信号、是否扩散 +- 连板梯队与投机情绪:最高连板、封板率、炸板率反映的资金激进程度 + +### 5. 💰 资金与情绪 +成交额结构(增量/存量)、市场宽度(上涨占比、站上均线占比)、量能指标(量比)解读;风险偏好是修复还是转弱。 + +### 6. 📰 消息催化 +结合提供的近期新闻,提炼真正影响明日交易节奏的催化或扰动。明确区分"已兑现"与"待发酵"。**若提供了"无新闻数据"的说明,则本节基于量价异动进行[推断],并如实标注,不要编造具体消息。** + +### 7. 🎯 明日交易计划 +- 进攻 / 均衡 / 防守:基于今日盘面给出次日基调 +- 仓位区间建议(轻仓/半仓/重仓的粗略指引) +- 关注方向(领涨延续 / 低吸 / 反包)与回避方向(高位滞涨 / 杀跌扩散) +- 一个明确的触发失效条件(如"若上证跌破 X 点则转为防守") + +### 8. ⚠️ 风险提示 +列出需要重点盯的风险点(如量能跟不上、外资流出、连板断层等)。末尾附一行: +"> ⚠️ 本报告由 AI 基于公开行情数据生成,仅供参考,不构成任何投资建议。交易有风险,入市需谨慎。" + +## 分析准则(务必遵守) + +1. **数据说话**:每个判断引用具体数值,严禁空泛套话("情绪回暖"必须改成"涨停 68 家较前日 +22,封板率 75%") +2. **诚实中立**:看多就写多,看空就写空,不要骑墙;数据不支持时直言无法判断 +3. **结构优先**:先看指数同步性与量能结构,再看板块与情绪,最后才是消息 +4. **不重复数字**:正文负责解读表格数据背后的含义,不要照抄罗列已提供的大段原始数字 +5. **风险前置**:任何进攻建议都要配触发失效条件 +6. **简明实战**:用交易员能扫读的密度输出,总字数 1200-2000 字,重在可执行 + +现在请基于下方数据进行复盘。""" + + +# ================================================================ +# 用户消息构建(精简切片,控制 token) +# ================================================================ + +def _fmt_pct(v, suffix="%") -> str: + if v is None: + return "—" + return f"{v:+.2f}{suffix}" if suffix else f"{v:.2f}" + + +def _build_indices_block(overview: dict) -> str: + """指数行情精简块。""" + indices = overview.get("indices") or [] + if not indices: + return "(暂无指数)" + lines = [] + for idx in indices: + name = idx.get("name") or idx.get("symbol") + price = idx.get("last_price") + chg = idx.get("change_pct") + price_s = f"{price:.2f}" if price is not None else "—" + lines.append(f"- {name}: {price_s} {_fmt_pct(chg)}") + return "\n".join(lines) + + +def _build_breadth_block(overview: dict) -> str: + b = overview.get("breadth") or {} + amt = overview.get("amount") or {} + lim = overview.get("limit") or {} + tr = overview.get("trend") or {} + act = overview.get("activity") or {} + + total_amount = amt.get("total") or 0 + # 成交额单位换算为亿元(原始为元) + amount_yi = total_amount / 1e8 if total_amount else 0 + + lines = [ + f"- 上涨/下跌/平盘: {b.get('up',0)} / {b.get('down',0)} / {b.get('flat',0)}" + f" (上涨占比 {b.get('up_pct',0):.1f}%)", + f"- 涨停/炸板/跌停: {lim.get('limit_up',0)} / {lim.get('broken',0)} / {lim.get('limit_down',0)}" + f" (封板率 {lim.get('seal_rate',0):.0f}%, 最高连板 {lim.get('max_boards',0)})", + ] + if lim.get("tiers"): + tiers_str = "、".join(f"{t['boards']}板×{t['count']}" for t in lim["tiers"][:5]) + lines.append(f"- 连板梯队: {tiers_str}") + lines.append(f"- 两市成交额: {amount_yi:.0f} 亿元") + lines.append( + f"- 均线站位: MA5 {tr.get('above_ma5_pct',0):.0f}% / " + f"MA20 {tr.get('above_ma20_pct',0):.0f}% / MA60 {tr.get('above_ma60_pct',0):.0f}%" + ) + lines.append( + f"- 量能: 平均换手 {act.get('avg_turnover',0):.2f}%, " + f"量比5日均 {act.get('vol_ratio',1):.2f}" + ) + return "\n".join(lines) + + +def _build_sector_block(rank: dict, label: str) -> str: + """板块排名精简块(领涨/领跌 top5)。""" + if not rank: + return f"### {label}\n(暂无数据)" + def _fmt(items): + if not items: + return "—" + return "、".join( + f"{it.get('name')}({(it.get('avg_pct') or 0)*100:+.2f}%,领涨:{it.get('leader',{}).get('name','—')})" + for it in items[:5] + ) + return ( + f"- 领涨{label}: {_fmt(rank.get('leading'))}\n" + f"- 领跌{label}: {_fmt(rank.get('lagging'))}" + ) + + +def _build_emotion_block(overview: dict) -> str: + emo = overview.get("emotion") or {} + radar = overview.get("radar") or [] + score = emo.get("score", 50) + label = emo.get("label", "—") + lines = [f"- 情绪温度: {score} ({label})"] + if radar: + dims = "、".join(f"{r.get('label')}{r.get('value',0)}" for r in radar) + lines.append(f"- 六维雷达: {dims}") + return "\n".join(lines) + + +def _build_user_prompt(overview: dict, news: list[dict], focus: str) -> str: + """构建用户消息:复盘日期 + 市场数据精简切片 + 新闻 + 关注点。""" + as_of = overview.get("as_of") or "今日" + + parts: list[str] = [ + f"复盘日期: {as_of}", + "", + "## 主要指数", + _build_indices_block(overview), + "", + "## 盘面数据", + _build_breadth_block(overview), + "", + "## 市场情绪", + _build_emotion_block(overview), + "", + "## 概念板块排名", + _build_sector_block(overview.get("concept_rank"), "概念"), + "", + "## 行业板块排名", + _build_sector_block(overview.get("industry_rank"), "行业"), + ] + + if news: + news_lines = [] + for i, n in enumerate(news[:8], 1): + title = (n.get("title") or "").strip() + snippet = (n.get("snippet") or "").strip() + source = (n.get("source") or "").strip() + pub = (n.get("published_date") or "").strip() + meta = " / ".join(p for p in (source, pub) if p) + news_lines.append(f"{i}. {title} ({meta})\n {snippet}" if meta else f"{i}. {title}\n {snippet}") + parts.extend(["", "## 近期市场新闻", "\n".join(news_lines)]) + else: + parts.extend([ + "", + "## 近期市场新闻", + "(暂无新闻数据:本功能新闻检索能力将在后续版本接入。" + "请按系统提示词第 6 节的说明,基于量价异动进行[推断],并如实标注,不要编造具体消息。)", + ]) + + if focus.strip(): + parts.extend(["", f"本次复盘请特别关注: {focus.strip()}"]) + + return "\n".join(parts) + + +# ================================================================ +# 摘要生成(供 meta 事件 / 历史报告 summary) +# ================================================================ + +def _recap_summary(overview: dict) -> str: + """一句话摘要(供 meta 事件与历史列表展示)。""" + indices = overview.get("indices") or [] + emo = overview.get("emotion") or {} + lim = overview.get("limit") or {} + amt = overview.get("amount") or {} + total_amount = (amt.get("total") or 0) / 1e8 + + idx_str = "、".join( + f"{(i.get('name') or '')}{(i.get('change_pct') or 0):+.2f}%" + for i in indices[:3] + ) or "指数缺失" + return ( + f"{idx_str} | 情绪{emo.get('score',50)}({emo.get('label','—')}) | " + f"涨停{lim.get('limit_up',0)} | 成交{total_amount:.0f}亿" + ) + + +# ================================================================ +# 流式主入口 +# ================================================================ + +async def recap_market_stream( + repo, + quote_service=None, + depth_service=None, + as_of: date | None = None, + focus: str = "", + news: list[dict] | None = None, +) -> AsyncIterator[str]: + """流式大盘复盘:yield 出每个 NDJSON 事件。 + + Args: + repo: KlineRepository(必填)。 + quote_service / depth_service: 可选,数据装配依赖。 + as_of: 复盘日期,None 取最新有数据日。 + focus: 用户追加的复盘关注点。 + news: 预检索的新闻列表(P1 不传,留 None 走降级说明;P3 由 news_search 注入)。 + """ + # 1. 装配市场总览 + overview = build_market_overview(repo, quote_service, depth_service, as_of) + as_of_str = overview.get("as_of") + + if not as_of_str: + yield json.dumps({ + "type": "error", + "message": "暂无市场数据,请先在「数据」页同步日 K 与指数后再复盘", + }, ensure_ascii=False) + return + + emo = overview.get("emotion") or {} + + # 2. meta 事件(前端据此先渲染信号灯/看板) + yield json.dumps({ + "type": "meta", + "as_of": as_of_str, + "emotion_score": emo.get("score", 50), + "emotion_label": emo.get("label", "—"), + "summary": _recap_summary(overview), + }, ensure_ascii=False) + + # 3+4. 构建 prompt + 流式调用 LLM(整体 try-except,任何异常 yield error,避免前端卡死) + try: + from openai import AsyncOpenAI + from app import secrets_store + from app.config import settings + + ai_key = secrets_store.get_ai_key() + if not ai_key: + yield json.dumps({ + "type": "error", + "message": "AI API Key 未配置,请在「设置 → AI」中配置", + }, ensure_ascii=False) + return + + user_prompt = _build_user_prompt(overview, news or [], focus) + + user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent + client = AsyncOpenAI( + api_key=ai_key, + base_url=secrets_store.get_ai_config("ai_base_url", "https://api.alysc.top"), + timeout=180.0, + max_retries=2, + default_headers={"User-Agent": user_agent}, + ) + + stream = await client.chat.completions.create( + model=secrets_store.get_ai_config("ai_model", "gpt-5.5"), + messages=[ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + temperature=0.5, + max_tokens=4500, + stream=True, + ) + + async for chunk in stream: + delta = chunk.choices[0].delta if chunk.choices else None + if delta and delta.content: + yield json.dumps({"type": "delta", "content": delta.content}, ensure_ascii=False) + + except Exception as e: # noqa: BLE001 + logger.exception("AI market recap failed for %s: %s", as_of_str, e) + yield json.dumps({"type": "error", "message": f"AI 复盘失败: {e}"}, ensure_ascii=False) + return + + yield json.dumps({"type": "done"}, ensure_ascii=False) + + +async def recap_market_once( + repo, + quote_service=None, + depth_service=None, + as_of: date | None = None, + focus: str = "", + news: list[dict] | None = None, +) -> tuple[str | None, dict]: + """非流式版本(供定时任务调用):累积全部 delta,返回 (content, meta)。 + + content 为完整 Markdown 文本;失败时为 None。 + meta 含 as_of / emotion_score / emotion_label / summary(即使失败也尽量回填)。 + """ + content_parts: list[str] = [] + meta: dict = {"as_of": as_of.isoformat() if as_of else None} + async for evt in recap_market_stream(repo, quote_service, depth_service, as_of, focus, news): + try: + obj = json.loads(evt) + except Exception: # noqa: BLE001 + continue + t = obj.get("type") + if t == "meta": + meta = obj + elif t == "delta": + content_parts.append(obj.get("content", "")) + elif t == "error": + logger.warning("market recap error event: %s", obj.get("message")) + return None, meta + return "".join(content_parts), meta diff --git a/backend/app/services/market_recap_reports.py b/backend/app/services/market_recap_reports.py new file mode 100644 index 0000000..f3ad6c4 --- /dev/null +++ b/backend/app/services/market_recap_reports.py @@ -0,0 +1,92 @@ +"""AI 大盘复盘报告持久化存储。 + +与 stock_reports.py(个股分析报告)/ ai_reports.py(财务分析报告)完全独立 —— +单独的文件、字段、上限,互不影响。刻意不复用,避免引入 kind 判别字段与分支 +(解耦 > 抽象)。 + +存储位置: data/user_data/ai_market_recaps.json (数组,按 created_at 降序) +保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。 + +每条报告结构: +{ + "id": "mkr_xxx", # 唯一 id(market-recap-report) + "as_of": "2026-06-27", # 复盘日期 + "focus": "", # 用户追加的关心点(可为空) + "content": "# ...markdown", # 报告正文 + "summary": "三大指数齐涨...", # 一句话摘要 + "emotion_score": 68, # 情绪分(0-100, 复盘生成时的市场情绪雷达均分) + "emotion_label": "偏暖", # 情绪标签(强势/偏暖/震荡/偏冷/冰点) + "created_at": "2026-06-27T15:35:00" +} +""" +from __future__ import annotations + +import json +import logging +import time +from pathlib import Path + +logger = logging.getLogger(__name__) + +MAX_REPORTS = 30 + + +def _path() -> Path: + from app.config import settings + p = settings.data_dir / "user_data" / "ai_market_recaps.json" + p.parent.mkdir(parents=True, exist_ok=True) + return p + + +def list_reports() -> list[dict]: + """返回全部报告(按 created_at 降序)。""" + p = _path() + if not p.exists(): + return [] + try: + data = json.loads(p.read_text(encoding="utf-8")) + if isinstance(data, list): + return sorted(data, key=lambda r: r.get("created_at", ""), reverse=True) + except Exception as e: # noqa: BLE001 + logger.warning("ai_market_recaps.json malformed: %s", e) + return [] + + +def _save_all(reports: list[dict]) -> None: + """全量写入(裁剪到 MAX_REPORTS)。""" + reports.sort(key=lambda r: r.get("created_at", ""), reverse=True) + if len(reports) > MAX_REPORTS: + reports = reports[:MAX_REPORTS] + _path().write_text( + json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8", + ) + + +def save_report(report: dict) -> dict: + """新增一条报告并持久化。返回保存后的报告(含 id / created_at)。""" + reports = list_reports() + if not report.get("id"): + report["id"] = f"mkr_{int(time.time() * 1000)}" + if not report.get("created_at"): + report["created_at"] = _now_iso() + reports.append(report) + _save_all(reports) + logger.info("Market recap saved: %s (as_of=%s), total %d", + report.get("id"), report.get("as_of"), len(reports)) + return report + + +def delete_report(report_id: str) -> bool: + """删除指定报告。返回是否删除成功。""" + reports = list_reports() + before = len(reports) + reports = [r for r in reports if r.get("id") != report_id] + if len(reports) < before: + _save_all(reports) + return True + return False + + +def _now_iso() -> str: + from datetime import datetime + return datetime.now().isoformat(timespec="seconds") diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index f462b01..8fe2890 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -53,6 +53,29 @@ def get_realtime_quote_interval() -> float: return load().get("realtime_quote_interval", 10.0) +def get_realtime_watchlist_symbols() -> list[str]: + """Free 档自选实时监控标的:直接取自选页前 5 个。""" + try: + from app.services import watchlist + rows = watchlist.list_symbols() + except Exception as e: # noqa: BLE001 + logger.warning("load watchlist for realtime failed: %s", e) + return [] + out: list[str] = [] + for row in rows: + symbol = str((row or {}).get("symbol") or "").strip().upper() + if symbol and symbol not in out: + out.append(symbol) + if len(out) >= 5: + break + return out + + +def set_realtime_watchlist_symbols(symbols: list[str]) -> list[str]: # noqa: ARG001 + """兼容旧接口: Free 实时标的现在由自选页前 5 个决定。""" + return get_realtime_watchlist_symbols() + + def set_realtime_quote_interval(interval: float) -> float: """保存行情轮询间隔(不在此做 min/max 校验,由调用方按档位限制)。""" current = load() @@ -71,6 +94,83 @@ def get_minute_sync_days() -> int: return max(1, min(30, load().get("minute_sync_days", 5))) +# ===== 数据源选择 (默认 TickFlow;第一阶段仅日K切换入口) ===== + +_ALLOWED_DATA_PROVIDERS = {"tickflow"} + + +def get_daily_data_provider() -> str: + provider = str(load().get("daily_data_provider", "tickflow") or "tickflow").lower() + return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow" + + +def get_adj_factor_provider() -> str: + provider = str(load().get("adj_factor_provider", "same_as_daily") or "same_as_daily").lower() + if provider == "same_as_daily": + return provider + return provider if provider in _ALLOWED_DATA_PROVIDERS else "same_as_daily" + + +def get_minute_data_provider() -> str: + provider = str(load().get("minute_data_provider", "tickflow") or "tickflow").lower() + return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow" + + +def get_realtime_data_provider() -> str: + # 盘中实时现阶段仅支持 TickFlow。 + return "tickflow" + + +# ===== 盘后管道拉取内容开关 (A股 / ETF / 指数 独立控制) ===== + +def get_pipeline_pull_a_share() -> bool: + """A 股日K固定拉取。""" + return True + + +def get_pipeline_pull_etf() -> bool: + """是否拉取 ETF 日K。默认 False(标的多,首次较慢)。""" + return load().get("pipeline_pull_etf", False) + + +def get_pipeline_pull_index() -> bool: + """是否拉取指数日K。默认 True。""" + return load().get("pipeline_pull_index", True) + + +_PIPELINE_PULL_KEYS = ("pipeline_pull_etf", "pipeline_pull_index") + + +def get_pipeline_pull_types() -> dict: + """返回三个拉取开关的当前值。""" + return { + "pipeline_pull_a_share": get_pipeline_pull_a_share(), + "pipeline_pull_etf": get_pipeline_pull_etf(), + "pipeline_pull_index": get_pipeline_pull_index(), + } + + +def set_pipeline_pull_types(cfg: dict) -> dict: + """批量保存拉取开关。只接受白名单内的布尔字段。""" + updates = { + k: bool(v) for k, v in cfg.items() + if k in _PIPELINE_PULL_KEYS and v is not None + } + save(updates) + return get_pipeline_pull_types() + + +def get_pipeline_index_symbols() -> str: + """指数自定义拉取代码(逗号/换行/空格分隔)。空串表示全量。""" + return str(load().get("pipeline_index_symbols", "") or "").strip() + + +def set_pipeline_index_symbols(symbols: str) -> str: + """保存指数自定义代码,返回规范化后的字符串。""" + save({"pipeline_index_symbols": symbols}) + return get_pipeline_index_symbols() + + def get_pipeline_schedule() -> dict: """返回盘后管道调度时间 {"hour": 15, "minute": 30}。""" d = load().get("pipeline_schedule", {"hour": 15, "minute": 30}) @@ -178,6 +278,59 @@ SSE_REFRESH_PAGES_DEFAULT = { SIDEBAR_INDEX_SYMBOLS_DEFAULT = ["000001.SH", "399001.SZ", "399006.SZ", "000680.SH"] +# ===== 盘中实时行情范围 (独立于盘后管道范围) ===== + + +def get_realtime_pull_stock() -> bool: + return load().get("realtime_pull_stock", True) + + +def get_realtime_pull_etf() -> bool: + # 老用户兼容: ETF 实时默认关闭,避免升级后请求量/写盘量突然增加。 + return load().get("realtime_pull_etf", False) + + +def get_realtime_pull_index() -> bool: + return load().get("realtime_pull_index", True) + + +def get_realtime_index_mode() -> str: + mode = str(load().get("realtime_index_mode", "core") or "core").lower() + return mode if mode in {"core", "all"} else "core" + + +def get_realtime_index_symbols() -> list[str]: + stored = load().get("realtime_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT) + if isinstance(stored, str): + import re + stored = [s.strip() for s in re.split(r"[,\s]+", stored) if s.strip()] + return [str(s) for s in stored if str(s).strip()] + + +def set_realtime_quote_scope(cfg: dict) -> dict: + updates = {} + for key in ("realtime_pull_stock", "realtime_pull_etf", "realtime_pull_index"): + if key in cfg and cfg[key] is not None: + updates[key] = bool(cfg[key]) + if "realtime_index_mode" in cfg and cfg["realtime_index_mode"] in {"core", "all"}: + updates["realtime_index_mode"] = cfg["realtime_index_mode"] + if "realtime_index_symbols" in cfg and cfg["realtime_index_symbols"] is not None: + updates["realtime_index_symbols"] = cfg["realtime_index_symbols"] + if updates: + save(updates) + return get_realtime_quote_scope() + + +def get_realtime_quote_scope() -> dict: + return { + "realtime_pull_stock": get_realtime_pull_stock(), + "realtime_pull_etf": get_realtime_pull_etf(), + "realtime_pull_index": get_realtime_pull_index(), + "realtime_index_mode": get_realtime_index_mode(), + "realtime_index_symbols": get_realtime_index_symbols(), + } + + def get_sse_refresh_pages() -> dict[str, bool]: """返回每个页面的 SSE 刷新开关。""" stored = load().get("sse_refresh_pages", {}) diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index aa39c7a..676b079 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -43,6 +43,7 @@ class QuoteService: "expert": 1.0, "pro": 2.0, "starter": 3.0, + "free": 6.0, } DEFAULT_INTERVAL = 10.0 MAX_INTERVAL = 60.0 @@ -68,6 +69,7 @@ class QuoteService: self._fetched_at: float = 0.0 # 拉取完成的 Unix 时间戳 (毫秒) self._symbol_count: int = 0 self._index_symbol_count: int = 0 + self._etf_symbol_count: int = 0 self._index_quotes_cache: pl.DataFrame | None = None # ================================================================ @@ -102,11 +104,11 @@ class QuoteService: def enable(self) -> bool: """开启自动行情 (不立即启动线程,等下一个交易时段)。 - none/free 档无实时行情权限,拒绝开启并返回 False; - starter+ 正常启动。返回值表示是否真正开启。 + none 档无实时行情权限,拒绝开启并返回 False; + free 档开启自选股实时,starter+ 开启全市场实时。返回值表示是否真正开启。 """ if not self.is_realtime_allowed(): - logger.warning("实时行情开启被拒:当前档位(none/free)无实时行情权限") + logger.warning("实时行情开启被拒:当前档位(none)无实时行情权限") return False self._enabled = True self._save_enabled(True) @@ -126,14 +128,14 @@ class QuoteService: def boot_check(self) -> None: """启动时检查 preferences,若 enabled 则自动启动。 - none/free 档无实时行情权限:即使 preferences 标记为 enabled, + none 档无实时行情权限:即使 preferences 标记为 enabled, 也不启动,并同步 preferences 为关闭(避免 UI 误显示已开启)。 """ from app.services import preferences if not self.is_realtime_allowed(): if preferences.get_realtime_quotes_enabled(): self._save_enabled(False) - logger.info("实时行情未启动:当前档位(none/free)无实时行情权限") + logger.info("实时行情未启动:当前档位(none)无实时行情权限") return if preferences.get_realtime_quotes_enabled(): self.start() @@ -199,13 +201,19 @@ class QuoteService: return tier_label().split()[0].split("+")[0].strip().lower() @classmethod - def is_realtime_allowed(cls) -> bool: - """当前档位是否允许使用实时行情。 + def realtime_mode(cls) -> str: + """当前实时行情模式: none / watchlist / full_market。""" + tier = cls._current_tier() + if tier == "none": + return "none" + if tier == "free": + return "watchlist" + return "full_market" - none/free 档走 free-api 服务器,无实时行情权限 → 不允许; - starter+ 付费档走付费端点,有实时行情 → 允许。 - """ - return cls._current_tier() not in ("none", "free") + @classmethod + def is_realtime_allowed(cls) -> bool: + """当前档位是否允许使用实时行情。""" + return cls.realtime_mode() != "none" @classmethod def _tier_min_interval(cls) -> float: @@ -262,13 +270,19 @@ class QuoteService: def status(self) -> dict: """返回行情服务状态。""" + from app.services import preferences age = (time.perf_counter() - self._fetch_time) * 1000 if self._fetch_time else -1 + mode = self.realtime_mode() return { "enabled": self._enabled, "running": self._running, + "mode": mode, + "realtime_allowed": mode != "none", + "watchlist_symbol_count": len(preferences.get_realtime_watchlist_symbols()), "interval_s": self._interval, "symbol_count": self._symbol_count, "index_symbol_count": self._index_symbol_count, + "etf_symbol_count": self._etf_symbol_count, "quote_age_ms": round(age, 0) if age >= 0 else None, "is_trading_hours": self._is_trading_hours(), "last_fetch_ms": round(self._fetched_at, 0) if self._fetched_at else None, @@ -299,17 +313,47 @@ class QuoteService: waited += 0.5 def _fetch_quotes(self) -> None: - """拉取全市场行情 → 写 daily + 计算 enriched + 更新缓存。""" - from app.tickflow.client import get_client + """按当前档位拉取行情。""" + if self.realtime_mode() == "watchlist": + self._fetch_watchlist_quotes() + return + self._fetch_full_market_quotes() - tf = get_client() + def _fetch_full_market_quotes(self) -> None: + """拉取全市场行情 → 写 daily + 计算 enriched + 更新缓存。""" + from app.tickflow.client import get_paid_realtime_client + + tf = get_paid_realtime_client() + if tf is None: + logger.warning("实时行情拉取失败:未配置付费服务器 API Key") + return t0 = time.perf_counter() now_ts = time.perf_counter() try: + from app.services import preferences all_index_symbols = set(self._repo.get_index_symbol_set()) if self._repo else set() - all_index_symbols.update(self.CORE_INDEX_SYMBOLS) - resp = tf.quotes.get_by_universes(universes=["CN_Equity_A", "CN_Index"]) + core_index_symbols = set(preferences.get_realtime_index_symbols() or self.CORE_INDEX_SYMBOLS) + all_index_symbols.update(core_index_symbols) + all_etf_symbols = set() + if self._repo: + etf_inst = self._repo.get_etf_instruments() + if not etf_inst.is_empty() and "symbol" in etf_inst.columns: + all_etf_symbols = set(etf_inst["symbol"].cast(pl.Utf8).to_list()) + + universes: list[str] = [] + if preferences.get_realtime_pull_stock(): + universes.append("CN_Equity_A") + if preferences.get_realtime_pull_etf() and all_etf_symbols: + universes.append("CN_ETF") + if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "all": + universes.append("CN_Index") + + resp = [] + if universes: + resp.extend(tf.quotes.get_by_universes(universes=universes) or []) + if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "core": + resp.extend(tf.quotes.get(symbols=sorted(core_index_symbols)) or []) except Exception as e: # noqa: BLE001 logger.warning("行情拉取失败: %s", e) return @@ -349,7 +393,11 @@ class QuoteService: }) index_records = [r for r in records if r.get("symbol") in all_index_symbols] - stock_records = [r for r in records if r.get("symbol") not in all_index_symbols] + etf_records = [r for r in records if r.get("symbol") in all_etf_symbols] + stock_records = [ + r for r in records + if r.get("symbol") not in all_index_symbols and r.get("symbol") not in all_etf_symbols + ] fetch_ms = (time.perf_counter() - t0) * 1000 fetched_at = time.time() * 1000 @@ -361,9 +409,10 @@ class QuoteService: self._fetched_at = fetched_at self._symbol_count = len(stock_records) self._index_symbol_count = len(index_records) + self._etf_symbol_count = len(etf_records) self._index_quotes_cache = self._build_index_quotes(index_records) - logger.info("行情刷新: %d 只股票, %d 只指数, 耗时 %.0fms", len(stock_records), len(index_records), fetch_ms) + logger.info("行情刷新: %d 只股票, %d 只ETF, %d 只指数, 耗时 %.0fms", len(stock_records), len(etf_records), len(index_records), fetch_ms) # ---- 写 kline_daily (不复权原始价格, 只有 OHLCV) ---- daily_df = self._build_daily(stock_records) @@ -373,12 +422,22 @@ class QuoteService: except Exception as e: # noqa: BLE001 logger.warning("日K写盘失败: %s", e) + etf_daily_df = self._build_daily(etf_records) + if not etf_daily_df.is_empty() and self._repo: + try: + self._repo.flush_live_daily_asset("etf", etf_daily_df) + except Exception as e: # noqa: BLE001 + logger.warning("ETF 日K写盘失败: %s", e) + # ---- 构建 API 直接值的补充表 (不写 daily, 只用于 enriched 计算) ---- quote_extra = self._build_quote_extra(stock_records) + etf_quote_extra = self._build_quote_extra(etf_records) # ---- 增量计算 enriched + 写盘 + 更新缓存 ---- if not daily_df.is_empty() and self._repo: - self._flush_live_enriched(daily_df, quote_extra) + self._flush_live_enriched(daily_df, quote_extra, asset_type="stock") + if not etf_daily_df.is_empty() and self._repo: + self._flush_live_enriched(etf_daily_df, etf_quote_extra, asset_type="etf") # ---- 通知 SSE ---- self._update_event.set() @@ -386,6 +445,87 @@ class QuoteService: # ---- 策略监控 + 告警评估 ---- self._evaluate_monitors(daily_df, quote_extra) + def _fetch_watchlist_quotes(self) -> None: + """Free 档自选股实时: 只拉取最多 5 个 symbols。""" + from app.services import preferences + from app.tickflow.client import get_paid_realtime_client + + symbols = preferences.get_realtime_watchlist_symbols() + if not symbols: + logger.info("自选实时未配置标的, 跳过行情拉取") + return + + tf = get_paid_realtime_client() + if tf is None: + logger.warning("自选实时拉取失败:未配置付费服务器 API Key") + return + + t0 = time.perf_counter() + now_ts = time.perf_counter() + try: + resp = tf.quotes.get(symbols=symbols) or [] + except Exception as e: # noqa: BLE001 + logger.warning("自选实时拉取失败: %s", e) + return + + if not resp: + logger.warning("自选实时行情数据为空") + return + + records = [] + for q in resp: + ext = q.get("ext") or {} + last_price = q.get("last_price") + prev_close = q.get("prev_close") + change_amount = ext.get("change_amount") + change_pct = ext.get("change_pct") + if change_amount is None and last_price is not None and prev_close is not None: + change_amount = float(last_price) - float(prev_close) + if change_pct is None and change_amount is not None and prev_close not in (None, 0): + change_pct = float(change_amount) / float(prev_close) * 100 + records.append({ + "symbol": q.get("symbol"), + "name": q.get("name") or ext.get("name"), + "last_price": last_price, + "prev_close": prev_close, + "open": q.get("open"), + "high": q.get("high"), + "low": q.get("low"), + "volume": q.get("volume"), + "amount": q.get("amount"), + "change_pct": change_pct, + "change_amount": change_amount, + "amplitude": ext.get("amplitude"), + "turnover_rate": ext.get("turnover_rate"), + "timestamp": q.get("timestamp"), + "session": q.get("session"), + }) + + fetch_ms = (time.perf_counter() - t0) * 1000 + fetched_at = time.time() * 1000 + with self._lock: + self._fetch_time = now_ts + self._fetch_ms = fetch_ms + self._fetched_at = fetched_at + self._symbol_count = len(records) + self._index_symbol_count = 0 + self._etf_symbol_count = 0 + self._index_quotes_cache = None + + logger.info("自选实时刷新: %d 只股票, 耗时 %.0fms", len(records), fetch_ms) + + daily_df = self._build_daily(records) + quote_extra = self._build_quote_extra(records) + if not daily_df.is_empty() and self._repo: + try: + self._repo.merge_live_daily_asset("stock", daily_df) + except Exception as e: # noqa: BLE001 + logger.warning("自选实时日K写盘失败: %s", e) + self._flush_live_enriched(daily_df, quote_extra, asset_type="stock", merge=True) + + self._update_event.set() + self._evaluate_monitors(daily_df, quote_extra) + # ================================================================ # 工具 # ================================================================ @@ -537,8 +677,8 @@ class QuoteService: "severity": ev.get("severity", "info"), }) - # 刷新策略结果缓存 (实时行情开启时,每轮行情更新后自动重算) - if self._enabled and self._app_state: + # Free 自选实时只刷新少量标的, 不写全市场策略缓存。 + if self._enabled and self._app_state and self.realtime_mode() == "full_market": self._refresh_strategy_cache(enriched_today, enriched_date) # 推入待推送队列 + 通知 SSE (含背压保护) @@ -690,7 +830,7 @@ class QuoteService: # enriched 增量计算 # ================================================================ - def _flush_live_enriched(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame = None) -> None: + def _flush_live_enriched(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame = None, asset_type: str = "stock", merge: bool = False) -> None: """增量计算今天的 enriched: 用昨天的递推状态 + 今天 OHLCV → 只算今天 5500 行。 quote_extra: API 直接提供的补充字段 (prev_close, change_pct 等), @@ -701,11 +841,16 @@ class QuoteService: t0 = time.perf_counter() # ---- 尝试增量路径 ---- - live_agg = self._repo.get_live_agg() - prev_enriched, prev_date = self._repo.get_enriched_latest() + live_agg = self._repo.get_live_agg() if asset_type == "stock" else pl.DataFrame() + prev_enriched, prev_date = ( + self._repo.get_enriched_latest() + if asset_type == "stock" + else self._repo.get_enriched_latest_asset(asset_type) + ) use_incremental = ( - not live_agg.is_empty() + asset_type == "stock" + and not live_agg.is_empty() and not prev_enriched.is_empty() and prev_date is not None ) @@ -736,7 +881,8 @@ class QuoteService: "ok" if not live_agg.is_empty() else "空", prev_date) cutoff = today - timedelta(days=90) - daily_glob = str(self._repo.store.data_dir / "kline_daily" / "**" / "*.parquet") + table = "kline_etf_daily" if asset_type == "etf" else "kline_daily" + daily_glob = str(self._repo.store.data_dir / table / "**" / "*.parquet") ohlcv_cols = ["symbol", "date", "open", "high", "low", "close", "volume", "amount"] hist_df = ( pl.scan_parquet(daily_glob) @@ -753,14 +899,15 @@ class QuoteService: full_df = pl.concat([hist_df, daily_ohlcv], how="diagonal_relaxed") full_df = full_df.sort(["symbol", "date"]) - factor_path = self._repo.store.data_dir / "adj_factor" / "all.parquet" + factor_dir = "adj_factor_etf" if asset_type == "etf" else "adj_factor" + factor_path = self._repo.store.data_dir / factor_dir / "all.parquet" factors = pl.DataFrame() if factor_path.exists(): try: factors = pl.read_parquet(factor_path) except Exception: pass - instruments = self._repo.get_instruments() + instruments = self._repo.get_instruments() if asset_type == "stock" else None enriched_full = compute_enriched(full_df, factors=factors, instruments=instruments) enriched_today = enriched_full.filter(pl.col("date") == today) @@ -769,7 +916,10 @@ class QuoteService: return # ---- 写盘 + 更新缓存 ---- - self._repo.flush_live_enriched(enriched_today) + if merge: + self._repo.merge_live_enriched_asset(asset_type, enriched_today) + else: + self._repo.flush_live_enriched_asset(asset_type, enriched_today) elapsed = time.perf_counter() - t0 mode_label = "增量" if use_incremental else "全量" diff --git a/backend/app/services/watchlist.py b/backend/app/services/watchlist.py index 879e6bd..7515b72 100644 --- a/backend/app/services/watchlist.py +++ b/backend/app/services/watchlist.py @@ -63,6 +63,20 @@ def remove(symbol: str) -> list[dict]: return df.to_dicts() +def move_to_top(symbol: str) -> list[dict]: + p = _path() + if not p.exists(): + return [] + df = pl.read_parquet(p) + if df.is_empty() or symbol not in df["symbol"].to_list(): + return df.to_dicts() + target = df.filter(pl.col("symbol") == symbol) + rest = df.filter(pl.col("symbol") != symbol) + out = pl.concat([target, rest], how="diagonal_relaxed") + out.write_parquet(p) + return out.to_dicts() + + def clear() -> int: """清空自选列表。返回移除的数量。""" p = _path() diff --git a/backend/app/tickflow/client.py b/backend/app/tickflow/client.py index 5a0c087..d02b608 100644 --- a/backend/app/tickflow/client.py +++ b/backend/app/tickflow/client.py @@ -18,6 +18,7 @@ from app import secrets_store _sync_client: TickFlow | None = None _async_client: AsyncTickFlow | None = None +_paid_realtime_client: TickFlow | None = None # ===== 服务器归属判定 ===== @@ -71,11 +72,27 @@ def get_async_client() -> AsyncTickFlow: return _async_client +def get_paid_realtime_client() -> TickFlow | None: + """实时行情专用付费服务器客户端。 + + none/free 的历史日K仍走 get_client() 的 free-api;实时行情全部走付费服务器。 + Free 档如果有有效 key,也使用这里的 paid endpoint 调按标的实时接口。 + """ + global _paid_realtime_client + key = secrets_store.get_tickflow_key() + if not key: + return None + if _paid_realtime_client is None: + _paid_realtime_client = TickFlow(api_key=key, base_url=_base_url()) + return _paid_realtime_client + + def reset_clients() -> None: """Key 变化后调用 — 让下一次 get_client() 拿新实例。""" - global _sync_client, _async_client + global _sync_client, _async_client, _paid_realtime_client _sync_client = None _async_client = None + _paid_realtime_client = None def current_mode() -> str: diff --git a/backend/app/tickflow/policy.py b/backend/app/tickflow/policy.py index 5e38b16..0442f48 100644 --- a/backend/app/tickflow/policy.py +++ b/backend/app/tickflow/policy.py @@ -31,9 +31,8 @@ _CAPSET_CACHE_FILE = "capabilities.json" # 旧缓存(无此字段或版本更低)会被判定过期,触发重新探测。 # v2: 拆分 depth5 → depth5(单只) + depth5.batch(批量) # v3: 探测补全 quote.batch(此前 tiers.yaml 声明了但 _probe_real 漏探测) -# v4: 5 档重构 —— 新增 none 档(无key/无效key),free 档重定义(走 free-api 服务器, -# 仅历史日K)。判定改为复权因子分水岭:_classify_tier 接管档位判定。 -_CACHE_SCHEMA_VERSION = 4 +# v5: Free 档补充付费服务器 quote.by_symbol(10rpm/5标的),用于自选股实时监控。 +_CACHE_SCHEMA_VERSION = 5 # 探测用最小代价请求:挑流通性最好的 1 只标的试 _PROBE_SYMBOL = "600000.SH" # 浦发银行,长期不会退市 @@ -278,7 +277,7 @@ def detect_capabilities(force: bool = False) -> CapabilitySet: _persist(capset, "None", log=probe_log, missing=[], extras=[], invalid_key=True) return capset if classified.is_free: - # 免费有效 key:能力按 free 档(= none 档能力,走 free-api 服务器) + # 免费有效 key:按 free 档能力持久化(日K free-api + 按标的实时)。 capset = _tier_to_capset(tiers["free"]) _persist(capset, "Free", log=probe_log + ["✓ 免费有效 key(运行时走 free-api 服务器)"], missing=[], extras=[]) return capset diff --git a/backend/app/tickflow/repository.py b/backend/app/tickflow/repository.py index a247316..953b291 100644 --- a/backend/app/tickflow/repository.py +++ b/backend/app/tickflow/repository.py @@ -38,11 +38,16 @@ class DataStore: "kline_daily_enriched", "kline_index_daily", "kline_index_enriched", + "kline_etf_daily", + "kline_etf_enriched", + "kline_etf_minute", "kline_minute", "adj_factor", + "adj_factor_etf", "financials", "instruments", "instruments_index", + "instruments_etf", "instruments_ext", "kline_ext", "pools", @@ -74,14 +79,24 @@ class DataStore: SELECT * FROM read_parquet('{d}/kline_index_daily/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW kline_index_enriched AS SELECT * FROM read_parquet('{d}/kline_index_enriched/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW kline_etf_daily AS + SELECT * FROM read_parquet('{d}/kline_etf_daily/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW kline_etf_enriched AS + SELECT * FROM read_parquet('{d}/kline_etf_enriched/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW kline_etf_minute AS + SELECT * FROM read_parquet('{d}/kline_etf_minute/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW kline_minute AS SELECT * FROM read_parquet('{d}/kline_minute/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW adj_factor AS SELECT * FROM read_parquet('{d}/adj_factor/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW adj_factor_etf AS + SELECT * FROM read_parquet('{d}/adj_factor_etf/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW instruments AS SELECT * FROM read_parquet('{d}/instruments/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW instruments_index AS SELECT * FROM read_parquet('{d}/instruments_index/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW instruments_etf AS + SELECT * FROM read_parquet('{d}/instruments_etf/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW instruments_ext AS SELECT * FROM read_parquet('{d}/instruments_ext/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW kline_ext AS @@ -104,6 +119,91 @@ class DataStore: self.db.execute(sql) except duckdb.IOException: logger.debug("view registration skipped (no parquet yet): %s", sql[:60]) + self._register_unified_views() + + def _has_parquet(self, subdir: str) -> bool: + return any((self.data_dir / subdir).rglob("*.parquet")) + + def _register_unified_views(self) -> None: + """Register optional all-asset views when their backing parquet exists. + + Physical storage remains split for performance and compatibility. These + views are convenience read models for new APIs/features. + """ + daily_parts: list[str] = [] + enriched_parts: list[str] = [] + minute_parts: list[str] = [] + inst_parts: list[str] = [] + + if self._has_parquet("kline_daily"): + daily_parts.append(""" + SELECT symbol, date, open, high, low, close, volume, amount, + 'stock' AS asset_type, 'tickflow' AS source + FROM kline_daily + """) + if self._has_parquet("kline_index_daily"): + daily_parts.append(""" + SELECT symbol, date, open, high, low, close, volume, amount, + 'index' AS asset_type, 'tickflow' AS source + FROM kline_index_daily + """) + if self._has_parquet("kline_etf_daily"): + daily_parts.append(""" + SELECT symbol, date, open, high, low, close, volume, amount, + 'etf' AS asset_type, 'tickflow' AS source + FROM kline_etf_daily + """) + + if self._has_parquet("kline_daily_enriched"): + enriched_parts.append("SELECT *, 'stock' AS asset_type, 'tickflow' AS source FROM kline_enriched") + if self._has_parquet("kline_index_enriched"): + enriched_parts.append("SELECT *, 'index' AS asset_type, 'tickflow' AS source FROM kline_index_enriched") + if self._has_parquet("kline_etf_enriched"): + enriched_parts.append("SELECT *, 'etf' AS asset_type, 'tickflow' AS source FROM kline_etf_enriched") + + if self._has_parquet("kline_minute"): + minute_parts.append(""" + SELECT symbol, datetime, open, high, low, close, volume, amount, + 'stock' AS asset_type, 'tickflow' AS source + FROM kline_minute + """) + if self._has_parquet("kline_etf_minute"): + minute_parts.append(""" + SELECT symbol, datetime, open, high, low, close, volume, amount, + 'etf' AS asset_type, 'tickflow' AS source + FROM kline_etf_minute + """) + + if self._has_parquet("instruments"): + inst_parts.append(""" + SELECT symbol, name, code, exchange, 'stock' AS asset_type, 'tickflow' AS source + FROM instruments + """) + if self._has_parquet("instruments_index"): + inst_parts.append(""" + SELECT symbol, name, code, NULL AS exchange, 'index' AS asset_type, 'tickflow' AS source + FROM instruments_index + WHERE coalesce(asset_type, 'index') != 'etf' + """) + if self._has_parquet("instruments_etf"): + inst_parts.append(""" + SELECT symbol, name, code, NULL AS exchange, 'etf' AS asset_type, 'tickflow' AS source + FROM instruments_etf + """) + + unions = { + "kline_daily_all": daily_parts, + "kline_enriched_all": enriched_parts, + "kline_minute_all": minute_parts, + "instruments_all": inst_parts, + } + for name, parts in unions.items(): + if not parts: + continue + try: + self.db.execute(f"CREATE OR REPLACE VIEW {name} AS " + " UNION ALL BY NAME ".join(parts)) + except Exception as e: # noqa: BLE001 + logger.debug("unified view %s skipped: %s", name, e) class KlineRepository: @@ -124,13 +224,21 @@ class KlineRepository: self._enriched_history_cache: pl.DataFrame | None = None # ~100万行 self._enriched_history_start: date | None = None self._index_instruments_cache: pl.DataFrame | None = None + self._etf_enriched_cache: pl.DataFrame | None = None + self._etf_enriched_cache_date: date | None = None + self._etf_live_agg_cache: pl.DataFrame | None = None + self._etf_live_agg_cache_date: date | None = None + self._etf_instruments_cache: pl.DataFrame | None = None # parquet glob 路径 self._enriched_glob = str(store.data_dir / "kline_daily_enriched" / "**" / "*.parquet") self._index_enriched_glob = str(store.data_dir / "kline_index_enriched" / "**" / "*.parquet") + self._etf_enriched_glob = str(store.data_dir / "kline_etf_enriched" / "**" / "*.parquet") self._minute_glob = str(store.data_dir / "kline_minute" / "**" / "*.parquet") + self._etf_minute_glob = str(store.data_dir / "kline_etf_minute" / "**" / "*.parquet") self._inst_glob = str(store.data_dir / "instruments" / "**" / "*.parquet") self._index_inst_glob = str(store.data_dir / "instruments_index" / "**" / "*.parquet") + self._etf_inst_glob = str(store.data_dir / "instruments_etf" / "**" / "*.parquet") def execute_all(self, sql: str, params: list | None = None) -> list[tuple]: """线程安全的 SELECT → fetchall。DuckDB 单 connection 非线程安全,所有读路径须走此方法。""" @@ -150,6 +258,7 @@ class KlineRepository: """刷新 Polars 缓存。在 pipeline 完成后、服务启动时调用。""" self._refresh_instruments() self._refresh_index_instruments() + self._refresh_etf_instruments() self._refresh_enriched() def clear_cache(self) -> None: @@ -167,6 +276,11 @@ class KlineRepository: self._live_agg_cache_date = None self._instruments_cache = None self._index_instruments_cache = None + self._etf_enriched_cache = None + self._etf_enriched_cache_date = None + self._etf_live_agg_cache = None + self._etf_live_agg_cache_date = None + self._etf_instruments_cache = None def _refresh_enriched(self) -> None: """从 parquet 加载 enriched 最新日到内存 + 构建聚合表。 @@ -462,6 +576,47 @@ class KlineRepository: return df_hist, agg_a + def _refresh_etf_enriched(self) -> None: + """从 ETF enriched parquet 加载最新日到内存缓存。""" + try: + enriched_dir = self.store.data_dir / "kline_etf_enriched" + dates = sorted( + p.name[5:] for p in enriched_dir.glob("date=*") + if p.is_dir() and p.name.startswith("date=") + ) if enriched_dir.exists() else [] + if not dates: + self._etf_enriched_cache = None + self._etf_enriched_cache_date = None + return + latest = date.fromisoformat(dates[-1]) + target_parquet = enriched_dir / f"date={dates[-1]}" / "part.parquet" + df_latest = pl.read_parquet(target_parquet) + if df_latest.is_empty(): + return + + from datetime import timedelta + from app.indicators.pipeline import compute_indicators, compute_signals + start_full = latest - timedelta(days=300) + read_cols = [c for c in ["symbol", "date", "open", "high", "low", "close", + "volume", "amount", "raw_close", "raw_high", "raw_low"] + if c in df_latest.columns] + df_hist = ( + pl.scan_parquet(self._etf_enriched_glob, + cast_options=pl.ScanCastOptions(integer_cast="allow-float")) + .filter(pl.col("date") >= start_full) + .select(read_cols) + .sort(["symbol", "date"]) + .collect() + ) + if df_hist.is_empty(): + self._etf_enriched_cache = df_latest.sort(["symbol"]) + else: + df_full = compute_signals(compute_indicators(df_hist)) + self._etf_enriched_cache = df_full.filter(pl.col("date") == latest).sort(["symbol"]) + self._etf_enriched_cache_date = latest + except Exception as e: # noqa: BLE001 + logger.debug("ETF enriched 缓存刷新跳过: %s", e) + def _refresh_instruments(self) -> None: """加载 instruments 到内存。""" try: @@ -482,6 +637,28 @@ class KlineRepository: except Exception as e: # noqa: BLE001 logger.debug("index instruments 缓存刷新跳过: %s", e) + def _refresh_etf_instruments(self) -> None: + """加载 ETF instruments 到内存;兼容旧版 instruments_index 中的 ETF。""" + parts: list[pl.DataFrame] = [] + try: + df = pl.scan_parquet(self._etf_inst_glob).collect() + if not df.is_empty(): + parts.append(df) + except Exception as e: # noqa: BLE001 + logger.debug("etf instruments 缓存刷新跳过(new): %s", e) + try: + legacy = self.get_index_instruments() + if not legacy.is_empty() and "asset_type" in legacy.columns: + legacy = legacy.filter(pl.col("asset_type") == "etf") + if not legacy.is_empty(): + parts.append(legacy) + except Exception as e: # noqa: BLE001 + logger.debug("etf instruments legacy fallback skipped: %s", e) + if parts: + df_all = pl.concat(parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol") + self._etf_instruments_cache = df_all + logger.info("ETF instruments 缓存已加载: %d 只", len(df_all)) + def get_enriched_latest(self) -> tuple[pl.DataFrame, date | None]: """返回缓存的 enriched 最新日 DataFrame + 日期。如无缓存则懒加载。""" if self._enriched_cache is None: @@ -490,6 +667,18 @@ class KlineRepository: return pl.DataFrame(), self._enriched_cache_date return self._enriched_cache, self._enriched_cache_date + def get_enriched_latest_asset(self, asset_type: str) -> tuple[pl.DataFrame, date | None]: + """按资产类型返回最新 enriched 缓存。stock 保持旧缓存语义。""" + if asset_type == "stock": + return self.get_enriched_latest() + if asset_type == "etf": + if self._etf_enriched_cache is None: + self._refresh_etf_enriched() + if self._etf_enriched_cache is None: + return pl.DataFrame(), self._etf_enriched_cache_date + return self._etf_enriched_cache, self._etf_enriched_cache_date + return pl.DataFrame(), None + def get_enriched_history(self, target_date: date, lookback_days: int) -> pl.DataFrame | None: """返回预计算的 enriched 历史数据 (仅 lookback 范围, 不含 warmup)。 @@ -567,6 +756,27 @@ class KlineRepository: return pl.DataFrame() return self._index_instruments_cache + def get_etf_instruments(self) -> pl.DataFrame: + """返回缓存的 ETF instruments DataFrame;兼容旧版 instruments_index 中的 ETF。""" + if self._etf_instruments_cache is None: + self._refresh_etf_instruments() + if self._etf_instruments_cache is None: + return pl.DataFrame() + return self._etf_instruments_cache + + def get_instruments_asset(self, asset_type: str) -> pl.DataFrame: + """按资产类型返回 instruments;老 stock 路径保持原样。""" + if asset_type == "stock": + return self.get_instruments() + if asset_type == "index": + df = self.get_index_instruments() + if not df.is_empty() and "asset_type" in df.columns: + return df.filter(pl.col("asset_type") != "etf") + return df + if asset_type == "etf": + return self.get_etf_instruments() + return pl.DataFrame() + def get_index_symbol_set(self) -> set[str]: """返回已缓存指数 symbol 集合。""" df = self.get_index_instruments() @@ -656,6 +866,45 @@ class KlineRepository: df = df.select(existing) return df + def get_etf_daily( + self, + symbol: str, + start: date, + end: date, + columns: list[str] | None = None, + ) -> pl.DataFrame: + """ETF 日K查询 — 优先读独立 ETF enriched,兼容旧版 index enriched 中的 ETF。""" + from datetime import timedelta + + warmup_start = start - timedelta(days=150) + df = self._scan_etf_daily_symbol(symbol, warmup_start, end, None) + if df.is_empty(): + # 旧版 ETF 曾存入 kline_index_enriched;没有独立数据时回退读取。 + df = self._scan_index_daily_symbol(symbol, warmup_start, end, None) + if not df.is_empty(): + df = self._compute_index_enriched_range(df) + df = df.filter((pl.col("date") >= start) & (pl.col("date") <= end)) + if columns and not df.is_empty(): + existing = [c for c in columns if c in df.columns] + df = df.select(existing) + return df + + def get_daily_asset( + self, + asset_type: str, + symbol: str, + start: date, + end: date, + columns: list[str] | None = None, + ) -> pl.DataFrame: + if asset_type == "stock": + return self.get_daily(symbol, start, end, columns) + if asset_type == "index": + return self.get_index_daily(symbol, start, end, columns) + if asset_type == "etf": + return self.get_etf_daily(symbol, start, end, columns) + return pl.DataFrame() + def get_minute( self, symbol: str, @@ -770,6 +1019,23 @@ class KlineRepository: logger.warning("指数日K查询失败: %s", e) return pl.DataFrame() + def _scan_etf_daily_symbol(self, symbol: str, start: date, end: date, columns: list[str] | None) -> pl.DataFrame: + try: + lf = pl.scan_parquet(self._etf_enriched_glob, + cast_options=pl.ScanCastOptions(integer_cast="allow-float")).filter( + (pl.col("symbol") == symbol) + & (pl.col("date") >= start) + & (pl.col("date") <= end) + ).sort("date") + if columns: + schema_names = lf.collect_schema().names() + existing = [c for c in columns if c in schema_names] + lf = lf.select(existing) + return lf.collect() + except Exception as e: # noqa: BLE001 + logger.debug("ETF 日K查询跳过: %s", e) + return pl.DataFrame() + def _merge_cached_and_scan( self, cached: pl.DataFrame, @@ -911,6 +1177,39 @@ class KlineRepository: df_storage = df.select(storage_cols) self._write_daily_partition(df_storage, "kline_index_enriched") + def append_etf_daily(self, df: pl.DataFrame) -> None: + """按日分区写入 ETF 日K数据 (merge-upsert)。""" + if df.is_empty(): + return + self._write_daily_partition(df, "kline_etf_daily") + + def append_etf_enriched(self, df: pl.DataFrame) -> None: + """按日分区写入 ETF enriched 数据。磁盘仅写入基础行情窄表。""" + if df.is_empty(): + return + from app.indicators.pipeline import ENRICHED_STORAGE_COLS + storage_cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns] + df_storage = df.select(storage_cols) + self._write_daily_partition(df_storage, "kline_etf_enriched") + + def append_daily_asset(self, asset_type: str, df: pl.DataFrame) -> None: + """按资产类型写入日K;stock/index 保持旧目录兼容。""" + if asset_type == "stock": + self.append_daily(df) + elif asset_type == "index": + self.append_index_daily(df) + elif asset_type == "etf": + self.append_etf_daily(df) + + def append_enriched_asset(self, asset_type: str, df: pl.DataFrame) -> None: + """按资产类型写入 enriched;stock/index 保持旧目录兼容。""" + if asset_type == "stock": + self.append_enriched(df) + elif asset_type == "index": + self.append_index_enriched(df) + elif asset_type == "etf": + self.append_etf_enriched(df) + def save_index_instruments(self, df: pl.DataFrame) -> None: """保存指数标的维表。""" if df.is_empty() or "symbol" not in df.columns: @@ -919,8 +1218,21 @@ class KlineRepository: out.parent.mkdir(parents=True, exist_ok=True) df.unique(subset=["symbol"], keep="last").sort("symbol").write_parquet(out) self._index_instruments_cache = None + self._etf_instruments_cache = None self._refresh_index_instruments() + def save_etf_instruments(self, df: pl.DataFrame) -> None: + """保存 ETF 标的维表到独立目录。""" + if df.is_empty() or "symbol" not in df.columns: + return + if "asset_type" not in df.columns: + df = df.with_columns(pl.lit("etf").alias("asset_type")) + out = self.store.data_dir / "instruments_etf" / "instruments_etf.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + df.unique(subset=["symbol"], keep="last").sort("symbol").write_parquet(out) + self._etf_instruments_cache = None + self._refresh_etf_instruments() + def refresh_index_views(self) -> None: """刷新指数相关 DuckDB 视图。""" d = self.store.data_dir.as_posix() @@ -929,15 +1241,23 @@ class KlineRepository: SELECT * FROM read_parquet('{d}/kline_index_daily/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW kline_index_enriched AS SELECT * FROM read_parquet('{d}/kline_index_enriched/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW kline_etf_daily AS + SELECT * FROM read_parquet('{d}/kline_etf_daily/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW kline_etf_enriched AS + SELECT * FROM read_parquet('{d}/kline_etf_enriched/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW instruments_index AS SELECT * FROM read_parquet('{d}/instruments_index/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW instruments_etf AS + SELECT * FROM read_parquet('{d}/instruments_etf/**/*.parquet', union_by_name=true)""", ] for sql in statements: try: with self._lock: self.db.execute(sql) except Exception as e: # noqa: BLE001 - logger.debug("index view refresh skipped: %s", e) + logger.debug("index/etf view refresh skipped: %s", e) + with self._lock: + self.store._register_unified_views() def _write_daily_partition(self, df: pl.DataFrame, table: str) -> None: """按 date 分区写入 parquet,每个日期一个文件,支持 merge-upsert。""" @@ -955,11 +1275,92 @@ class KlineRepository: date_df = date_df.sort(["symbol", "date"]) date_df.write_parquet(out) + def merge_live_daily_asset(self, asset_type: str, df: pl.DataFrame) -> None: + """按 symbol 合并当天指定资产日K分区。用于少量自选实时,不覆盖全市场。""" + if df.is_empty() or "date" not in df.columns: + return + table = { + "stock": "kline_daily", + "index": "kline_index_daily", + "etf": "kline_etf_daily", + }.get(asset_type) + if not table: + return + base = self.store.data_dir / table + dt = df["date"][0] + ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt) + out = base / f"date={ds}" / "part.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + date_df = df.sort(["symbol", "date"]) + if out.exists(): + existing = pl.read_parquet(out) + date_df = pl.concat([existing, date_df], how="diagonal_relaxed").unique( + subset=["symbol", "date"], keep="last" + ) + date_df.sort(["symbol", "date"]).write_parquet(out) + + def merge_live_enriched_asset(self, asset_type: str, df: pl.DataFrame) -> None: + """按 symbol 合并当天 enriched 分区和内存缓存。用于少量自选实时。""" + if df.is_empty() or "date" not in df.columns: + return + dt = df["date"][0] + if asset_type == "stock": + table = "kline_daily_enriched" + existing_cache = self._enriched_cache if self._enriched_cache_date == dt else pl.DataFrame() + elif asset_type == "etf": + table = "kline_etf_enriched" + existing_cache = self._etf_enriched_cache if self._etf_enriched_cache_date == dt else pl.DataFrame() + elif asset_type == "index": + table = "kline_index_enriched" + existing_cache = pl.DataFrame() + else: + return + + merged_cache = df + if existing_cache is not None and not existing_cache.is_empty(): + merged_cache = pl.concat([existing_cache, df], how="diagonal_relaxed").unique( + subset=["symbol", "date"], keep="last" + ) + merged_cache = merged_cache.sort(["symbol"]) + if asset_type == "stock": + self._enriched_cache = merged_cache + self._enriched_cache_date = dt + elif asset_type == "etf": + self._etf_enriched_cache = merged_cache + self._etf_enriched_cache_date = dt + + from app.indicators.pipeline import ENRICHED_STORAGE_COLS + storage_cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns] + df_storage = df.select(storage_cols).sort(["symbol"]) + base = self.store.data_dir / table + ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt) + out = base / f"date={ds}" / "part.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + if out.exists(): + existing = pl.read_parquet(out) + df_storage = pl.concat([existing, df_storage], how="diagonal_relaxed").unique( + subset=["symbol", "date"], keep="last" + ) + df_storage.sort(["symbol"]).write_parquet(out) + def flush_live_daily(self, df: pl.DataFrame) -> None: """覆写当天 kline_daily 分区 (实时行情落盘, 非merge)。""" if df.is_empty() or "date" not in df.columns: return - base = self.store.data_dir / "kline_daily" + self.flush_live_daily_asset("stock", df) + + def flush_live_daily_asset(self, asset_type: str, df: pl.DataFrame) -> None: + """覆写当天指定资产日K分区 (实时行情落盘, 非merge)。""" + if df.is_empty() or "date" not in df.columns: + return + table = { + "stock": "kline_daily", + "index": "kline_index_daily", + "etf": "kline_etf_daily", + }.get(asset_type) + if not table: + return + base = self.store.data_dir / table dt = df["date"][0] ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt) out = base / f"date={ds}" / "part.parquet" @@ -971,17 +1372,30 @@ class KlineRepository: 内存缓存保留完整指标列供各服务使用,磁盘仅写入 14 列存储列。 """ + self.flush_live_enriched_asset("stock", df) + + def flush_live_enriched_asset(self, asset_type: str, df: pl.DataFrame) -> None: + """覆写当天指定资产 enriched 分区 (实时 enriched 落盘, 非merge)。""" if df.is_empty() or "date" not in df.columns: return - # 内存缓存: 保留完整 66 列 - self._enriched_cache = df.sort(["symbol"]) dt = df["date"][0] - self._enriched_cache_date = dt - # 磁盘写入: 仅 14 列存储列 + if asset_type == "stock": + self._enriched_cache = df.sort(["symbol"]) + self._enriched_cache_date = dt + table = "kline_daily_enriched" + elif asset_type == "etf": + self._etf_enriched_cache = df.sort(["symbol"]) + self._etf_enriched_cache_date = dt + table = "kline_etf_enriched" + elif asset_type == "index": + table = "kline_index_enriched" + else: + return + from app.indicators.pipeline import ENRICHED_STORAGE_COLS storage_cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns] df_storage = df.select(storage_cols).sort(["symbol"]) - base = self.store.data_dir / "kline_daily_enriched" + base = self.store.data_dir / table ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt) out = base / f"date={ds}" / "part.parquet" out.parent.mkdir(parents=True, exist_ok=True) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 92e3c79..2d7b567 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tickflow-stock-panel-backend" -version = "0.1.53" +version = "0.1.60" description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配" readme = "../README.md" requires-python = ">=3.11" diff --git a/backend/uv.lock b/backend/uv.lock index 9c7818b..3b33532 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2491,7 +2491,7 @@ all = [ [[package]] name = "tickflow-stock-panel-backend" -version = "0.1.45" +version = "0.1.60" source = { editable = "." } dependencies = [ { name = "apscheduler" }, diff --git a/frontend/package.json b/frontend/package.json index 13dca1e..d7d3f5f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "tickflow-stock-panel-frontend", "private": true, - "version": "0.1.53", + "version": "0.1.60", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index cd87948..819ac15 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -29,7 +29,6 @@ import { Settings, Key, Database, - Timer, Loader2, LayoutDashboard, Tags, @@ -42,6 +41,8 @@ import { Cable, RadioTower, CheckCircle2, + BookOpenCheck, + ExternalLink, } from 'lucide-react' import { Logo } from './Logo' import { api, type IndexQuote } from '@/lib/api' @@ -50,6 +51,7 @@ import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitor // 品牌色 — 只用于 logo / brand 区域,不影响功能语义色 const BRAND = '#8B5CF6' +const TICKFLOW_REGISTER_URL = 'https://tickflow.org/auth/register?ref=V3KDKGXPEA' const CORE_INDEXES = [ { symbol: '000001.SH', name: '上证指数' }, @@ -65,14 +67,15 @@ const nav = [ { to: '/watchlist', label: '自选', icon: Star }, { to: '/screener', label: '策略', icon: ScanSearch }, { to: '/backtest', label: '回测', icon: History }, + { to: '/stock-analysis', label: '个股分析', icon: TrendingUp }, { to: '/limit-ladder', label: '连板梯队', icon: Flame }, { to: '/concept-analysis', label: '概念分析', icon: Layers3 }, { to: '/industry-analysis', label: '行业分析', icon: Landmark }, - { to: '/stock-analysis', label: '个股分析', icon: TrendingUp }, { to: '/financials', label: '财务分析', icon: FileText }, + { to: '/monitor', label: '监控中心', icon: RadioTower }, + { to: '/review', label: '复盘', icon: BookOpenCheck }, { to: '/indices', label: '指数', icon: BarChart3 }, { to: '/trading', label: '交易', icon: Cable }, - { to: '/monitor', label: '监控中心', icon: RadioTower }, { to: '/data', label: '数据', icon: Database }, ] as const @@ -156,7 +159,7 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) { labelTextStyle: { color: '#71717a' }, }, free: { - desc: '基础日K · 单股查询', + desc: '基础日K · 自选实时', tagBg: { background: 'rgba(113,113,122,0.3)' }, dotStyle: { background: '#71717a' }, labelTextStyle: { color: '#a1a1aa' }, @@ -182,8 +185,8 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) { } const t = tierConfig[base] || tierConfig.none - // none 档显示中文「无」,无 label 时显示「无档」 - const displayLabel = isNone ? '无' : (label || '无') + // none 档显示英文「None」,无 label 时也显示「None」 + const displayLabel = isNone ? 'None' : (label || 'None') return ( {label} {/* 个股分析 Beta 标识 */} - {to === '/stock-analysis' && ( + {(to === '/stock-analysis' || to === '/review') && ( Beta @@ -445,13 +455,27 @@ export function Layout() { {/* 全局行情开关 */}
- {isFreeTier ? ( - /* Free 档位 — 显示升级提示 */ -
- 实时行情 - - 需 Starter+ - + {isNoneTier ? ( +
+
+ 实时行情 + + Free+ + +
+
+ 免费注册 + + TickFlow + + + 开启个股监控 +
) : ( /* Starter+ — 开关 + 跳转设置 */ @@ -465,14 +489,14 @@ export function Layout() { : 'bg-muted' }`} /> - 实时行情 + 实时行情 · {realtimeModeLabel}
)} - {showSidebarQuotes && !isFreeTier && ( + {showSidebarQuotes && !isWatchlistMode && !isNoneTier && ( )} diff --git a/frontend/src/components/data/ActiveJobCard.tsx b/frontend/src/components/data/ActiveJobCard.tsx index 025227a..69cd447 100644 --- a/frontend/src/components/data/ActiveJobCard.tsx +++ b/frontend/src/components/data/ActiveJobCard.tsx @@ -8,7 +8,7 @@ import type { PipelineJob } from '@/lib/api' export const STAGE_LABELS: Record = { init: '初始化', resolve_universe: '解析标的池', - sync_instruments: '同步标的维表', + sync_instruments: '同步个股维表', sync_daily: '同步日 K', sync_adj: '同步除权因子', compute_enriched: '计算技术指标', diff --git a/frontend/src/components/data/PageSettingsModal.tsx b/frontend/src/components/data/PageSettingsModal.tsx new file mode 100644 index 0000000..121cce3 --- /dev/null +++ b/frontend/src/components/data/PageSettingsModal.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react' +import { Check } from 'lucide-react' +import { storage } from '@/lib/storage' + +export type CardKey = + | 'instruments' | 'daily' | 'adj_factor' | 'enriched' + | 'index' | 'etf' | 'minute' | 'financials' + +interface CardDef { + key: CardKey + label: string + desc: string + /** 档位能力不足时该卡片是否默认隐藏(减少干扰) */ + defaultHiddenIfNoCap: boolean +} + +/** 数据画像卡片定义 —— 顺序即弹窗展示顺序 */ +export const DATA_CARD_DEFS: CardDef[] = [ + { key: 'instruments', label: '个股维表', desc: 'A 股股票元数据', defaultHiddenIfNoCap: false }, + { key: 'daily', label: '日 K', desc: 'A 股日K线数据', defaultHiddenIfNoCap: false }, + { 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: 'minute', label: '分钟 K', desc: '分钟级K线(需 Pro+)', defaultHiddenIfNoCap: true }, + { key: 'financials', label: '财务数据', desc: '财报数据(需 Expert)', defaultHiddenIfNoCap: true }, +] + +const CAP_KEY_MAP: Partial> = { + adj_factor: 'adj_factor', + minute: 'kline.minute.batch', + financials: 'financial', +} + +/** + * 读取卡片显隐状态。结合档位能力决定默认值: + * - 用户显式设置过 → 用设置值 + * - 未设置 + defaultHiddenIfNoCap + 当前无能力 → 隐藏 + * - 其他 → 显示 + */ +export function getCardVisibility( + caps: Record | undefined, +): Record { + const has = (capKey: string) => !capKey || !!caps?.[capKey] + const override = storage.dataCardVisible.get({}) + const result: Record = {} + for (const def of DATA_CARD_DEFS) { + if (def.key in override) { + result[def.key] = override[def.key] + } else { + result[def.key] = def.defaultHiddenIfNoCap ? has(CAP_KEY_MAP[def.key] ?? '') : true + } + } + return result +} + +export function PageSettingsModal({ + caps, +}: { + caps: Record | undefined +}) { + const [visible, setVisible] = useState>(() => getCardVisibility(caps)) + + const toggle = (key: CardKey) => { + const next = { ...visible, [key]: !visible[key] } + setVisible(next) + storage.dataCardVisible.set(next) + window.dispatchEvent(new CustomEvent('data-card-visible-change')) + } + + const reset = () => { + storage.dataCardVisible.set({}) + setVisible(getCardVisibility(caps)) + window.dispatchEvent(new CustomEvent('data-card-visible-change')) + } + + return ( +
+

+ 勾选要在数据画像区显示的卡片。未勾选的卡片将隐藏,不影响数据本身。 +

+
+ {DATA_CARD_DEFS.map((def) => { + const on = visible[def.key] ?? true + return ( + + ) + })} +
+
+ +
+
+ ) +} diff --git a/frontend/src/components/data/PipelineScopeConfig.tsx b/frontend/src/components/data/PipelineScopeConfig.tsx new file mode 100644 index 0000000..fa54297 --- /dev/null +++ b/frontend/src/components/data/PipelineScopeConfig.tsx @@ -0,0 +1,86 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Check, Loader2 } from 'lucide-react' +import { api } from '@/lib/api' +import { QK } from '@/lib/queryKeys' + +type PullKey = 'pipeline_pull_a_share' | 'pipeline_pull_etf' | 'pipeline_pull_index' + +interface ScopeItem { + key: PullKey + label: string + desc: string + defaultOn: boolean +} + +const ITEMS: ScopeItem[] = [ + { key: 'pipeline_pull_a_share', label: 'A股', desc: '沪深京 A 股日K(约 5500 只)', defaultOn: true }, + { key: 'pipeline_pull_index', label: '指数', desc: '主要市场指数(默认全量约 600 只)', defaultOn: true }, + { key: 'pipeline_pull_etf', label: 'ETF', desc: '场内交易基金(约 1500 只,首次较慢)', defaultOn: false }, +] + +export function PipelineScopeConfig() { + const qc = useQueryClient() + const prefs = useQuery({ queryKey: QK.preferences, queryFn: api.preferences }) + + const updateToggle = useMutation({ + mutationFn: (cfg: Partial>) => api.updatePipelinePullTypes(cfg), + onSuccess: () => { + qc.invalidateQueries({ queryKey: QK.preferences }) + qc.invalidateQueries({ queryKey: QK.dataStatus }) + }, + }) + + const getValue = (key: PullKey, def: boolean) => prefs.data?.[key] ?? def + + return ( +
+

+ 勾选盘后管道每次自动拉取的数据类型。仅影响后续同步,已存储的历史数据不受影响。 +

+
+ {ITEMS.map((item) => { + const locked = item.key === 'pipeline_pull_a_share' + const on = locked || getValue(item.key, item.defaultOn) + return ( +
+ +
+ ) + })} +
+ {updateToggle.isPending && ( +
+ 保存中… +
+ )} +
+ 数据通道基于免费接口,所有档位均可拉取。 +
+
+ ) +} diff --git a/frontend/src/components/data/SchemaModal.tsx b/frontend/src/components/data/SchemaModal.tsx index 0a42867..8f9d05e 100644 --- a/frontend/src/components/data/SchemaModal.tsx +++ b/frontend/src/components/data/SchemaModal.tsx @@ -4,7 +4,7 @@ import { api, type EnrichedField } from '@/lib/api' import { QK } from '@/lib/queryKeys' const TABLE_TITLES: Record = { - instruments: '标的维表', + instruments: '个股维表', daily: '日 K', adj_factor: '除权因子', enriched: 'Enriched', @@ -12,6 +12,9 @@ const TABLE_TITLES: Record = { index_instruments: '指数维表', index_daily: '指数日 K', index_enriched: '指数 Enriched', + etf_instruments: 'ETF 维表', + etf_daily: 'ETF 日 K', + etf_enriched: 'ETF Enriched', } function categorize(name: string): string { diff --git a/frontend/src/components/data/StatCard.tsx b/frontend/src/components/data/StatCard.tsx index 3b95777..3e51498 100644 --- a/frontend/src/components/data/StatCard.tsx +++ b/frontend/src/components/data/StatCard.tsx @@ -5,7 +5,7 @@ import { fmtDate } from '@/lib/format' import { Skeleton } from './Skeleton' // 卡片能力定义:capKey → 查 capability limits;tierReq → 无权限时显示的档位要求 -// capKey 为空串表示该数据在 free-api 服务器(无档/免费档)即可获取,无需付费能力门控。 +// capKey 为空串表示该数据在 free-api 服务器(None 档/Free 档)即可获取,无需付费能力门控。 export const CARD_META: Record>) => + request<{ + pipeline_pull_a_share: boolean + pipeline_pull_etf: boolean + pipeline_pull_index: boolean + }>('/api/settings/preferences/pipeline-pull-types', { + method: 'PUT', + body: JSON.stringify(cfg), + }), + updatePipelineIndexSymbols: (symbols: string) => + request<{ pipeline_index_symbols: string }>('/api/settings/preferences/pipeline-index-symbols', { + method: 'PUT', + body: JSON.stringify({ symbols }), + }), updateRealtimeQuotes: (enabled: boolean) => - request<{ realtime_quotes_enabled: boolean }>('/api/settings/preferences/realtime-quotes', { + request<{ realtime_quotes_enabled: boolean; realtime_allowed?: boolean; mode?: string; error?: string }>('/api/settings/preferences/realtime-quotes', { method: 'PUT', body: JSON.stringify({ realtime_quotes_enabled: enabled }), }), + updateRealtimeQuoteScope: (cfg: Partial>) => + request>('/api/settings/preferences/realtime-quote-scope', { + method: 'PUT', + body: JSON.stringify(cfg), + }), updateIndicesNavPinned: (pinned: boolean) => request<{ indices_nav_pinned: boolean }>('/api/settings/preferences/indices-nav-pinned', { method: 'PUT', @@ -722,9 +768,13 @@ export const api = { request<{ enabled: boolean running: boolean + mode?: 'none' | 'watchlist' | 'full_market' + realtime_allowed?: boolean interval_s: number symbol_count: number + watchlist_symbol_count?: number index_symbol_count?: number + etf_symbol_count?: number quote_age_ms: number | null is_trading_hours: boolean last_fetch_ms: number | null @@ -951,6 +1001,11 @@ export const api = { `/api/watchlist/${encodeURIComponent(symbol)}`, { method: 'DELETE' }, ), + watchlistMoveToTop: (symbol: string) => + request<{ symbols: WatchlistEntry[] }>( + `/api/watchlist/${encodeURIComponent(symbol)}/top`, + { method: 'POST' }, + ), watchlistClear: () => request<{ removed: number }>('/api/watchlist', { method: 'DELETE' }), watchlistQuotes: () => request<{ quotes: Quote[] }>('/api/watchlist/quotes'), @@ -1372,6 +1427,68 @@ export const api = { } }, + // ===== 大盘复盘 ===== + reviewReportsList: () => + request<{ reports: AiReviewReport[] }>('/api/market-recap/reports'), + + reviewReportSave: (r: { + as_of: string; focus?: string; content: string + summary?: string; emotion_score?: number | null; emotion_label?: string + }) => + request<{ ok: boolean; report: AiReviewReport }>('/api/market-recap/reports', { + method: 'POST', body: JSON.stringify(r), + }), + + reviewReportDelete: (reportId: string) => + request<{ ok: boolean }>(`/api/market-recap/reports/${encodeURIComponent(reportId)}`, { method: 'DELETE' }), + + /** + * AI 大盘复盘 — 流式调用(NDJSON,与个股/财务分析同协议)。 + * meta 里带 as_of / emotion_score / emotion_label / summary,供前端先渲染信号灯。 + */ + async *reviewStream(asOf?: string, focus?: string): AsyncGenerator<{ + type: 'meta' | 'delta' | 'error' | 'done' + as_of?: string + emotion_score?: number + emotion_label?: string + summary?: string + content?: string + message?: string + }> { + const res = await fetch('/api/market-recap/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ as_of: asOf ?? null, focus: focus ?? '' }), + }) + if (!res.ok) { + let detail = '' + try { const j = JSON.parse(await res.text()); detail = j.detail ?? j.message ?? '' } catch { /* ignore */ } + const msg = detail || `${res.status} ${res.statusText}` + toast(msg, 'error') + throw new Error(msg) + } + if (!res.body) throw new Error('响应无 body') + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buf = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + const lines = buf.split('\n') + buf = lines.pop() ?? '' + for (const line of lines) { + const s = line.trim() + if (!s) continue + try { yield JSON.parse(s) } catch { /* ignore */ } + } + } + if (buf.trim()) { + try { yield JSON.parse(buf.trim()) } catch { /* ignore */ } + } + }, + // ===== Strategy Engine ===== strategyList: () => request<{ strategies: StrategyDetail[] }>('/api/strategies'), @@ -1540,6 +1657,9 @@ export interface DataStatus { index_daily: TableStats | null index_enriched: TableStats | null index_instruments: InstrumentsStats | null + etf_daily: TableStats | null + etf_enriched: TableStats | null + etf_instruments: InstrumentsStats | null minute: TableStats | null adj_factor: TableStats | null instruments: InstrumentsStats | null @@ -1555,6 +1675,14 @@ export interface DataStatus { index_enriched_size_mb?: number index_instruments_files?: number index_instruments_size_mb?: number + etf_daily_files?: number + etf_daily_size_mb?: number + etf_enriched_files?: number + etf_enriched_size_mb?: number + etf_instruments_files?: number + etf_instruments_size_mb?: number + etf_adj_factor_files?: number + etf_adj_factor_size_mb?: number minute_files: number minute_size_mb: number adj_factor_files: number diff --git a/frontend/src/lib/capability-labels.tsx b/frontend/src/lib/capability-labels.tsx index 0e4087e..bdac3fe 100644 --- a/frontend/src/lib/capability-labels.tsx +++ b/frontend/src/lib/capability-labels.tsx @@ -1,6 +1,6 @@ // capability 内部名 → 用户能理解的中文标签 export const CAP_LABELS: Record = { - 'quote.by_symbol': { name: '实时行情(按标的)', hint: '查询单只股票当前价' }, + 'quote.by_symbol': { name: '自选股实时监控', hint: 'Free 可按标的查询实时行情,用于少量自选股监控' }, 'quote.batch': { name: '实时行情(批量)', hint: '一次拿多只股票的价' }, 'quote.pool': { name: '标的池查询', hint: '按沪深300等池子拿行情' }, 'kline.daily.by_symbol': { name: '日 K(按标的)', hint: '单只股票历史日 K' }, @@ -16,7 +16,7 @@ export const CAP_LABELS: Record = { // 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。 // 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。 -// none = 无档(无 key / 无效 key),低于 free,仅历史日K无实时行情。 +// none = None 档(无 key / 无效 key),低于 free,仅历史日K无实时行情。 export const TIER_RANK: Record = { none: -1, free: 0, starter: 1, pro: 2, expert: 3 } export const EXPERT_RANK = TIER_RANK.expert @@ -45,19 +45,19 @@ const TIER_STYLE: Record = { labelTextStyle: { color: '#71717a' }, }, free: { - desc: '基础日K · 单股查询', + desc: '历史日K · 自选实时', tagBg: { background: 'rgba(113,113,122,0.3)' }, dotStyle: { background: '#71717a' }, labelTextStyle: { color: '#a1a1aa' }, }, starter: { - desc: '批量同步 · 行情池', + desc: '除权因子 · 全市场实时', tagBg: { background: 'rgba(59,130,246,0.2)' }, dotStyle: { background: '#3b82f6' }, labelTextStyle: { color: '#60a5fa' }, }, pro: { - desc: '分钟K · 实时行情 · 盘口', + desc: '分钟K · 盘口', tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' }, dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' }, labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' }, @@ -92,8 +92,8 @@ export function tierTextStyle(label: string): { color?: string; background?: str export function TierTag({ label, className = '' }: { label: string; className?: string }) { const t = tierStyle(label) const base = tierBaseName(label) - // none 档显示中文「无」,其余档显示英文档名 - const display = base === 'none' ? '无' : base + // none 档显示英文「None」,其余档显示英文档名 + const display = base === 'none' ? 'None' : base return ( ['alerts', source ?? ''] as const, + + // AI 大盘复盘 + reviewReports: ['review-reports'] as const, } as const // ===== SSE 应该 invalidate 的 key 前缀列表 ===== diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index d44468d..ad134e7 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -107,4 +107,7 @@ export const storage = { /** 行业分析页面字段配置 */ industryAnalysisConfig: kv>('industry-analysis-config'), + + /** 数据页画像卡片显隐 (卡片key → 是否显示) */ + dataCardVisible: kv>('data-card-visible'), } as const diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 44fb5df..b749bcf 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,8 +1,8 @@ -import { useState, type ReactNode } from 'react' +import { useState, useEffect, useRef, type ReactNode } from 'react' import { Link } from 'react-router-dom' -import { useQuery } from '@tanstack/react-query' -import { motion } from 'framer-motion' -import { Activity, AlertTriangle, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Check, Copy, Flame, Gauge, LineChart, Loader2, RefreshCw, Sparkles, Target, Timer, ExternalLink } from 'lucide-react' +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 { DatePicker } from '@/components/DatePicker' import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api' import { QK } from '@/lib/queryKeys' @@ -10,6 +10,8 @@ import { fmtBigNum, fmtPct } from '@/lib/format' import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries' import { SealedBadge } from '@/components/SealedBadge' import { StockPreviewDialog } from '@/components/StockPreviewDialog' +import { SettingsModal } from '@/components/data/SettingsModal' +import { STAGE_LABELS } from '@/components/data/ActiveJobCard' import { cn } from '@/lib/cn' import { cnSignal } from '@/lib/signals' import { boardTag } from '@/components/stock-table/primitives' @@ -469,9 +471,11 @@ function HotRankCard({ title, rank, configUrl }: { title: string; rank?: Overvie } export function Dashboard() { + const qc = useQueryClient() const [selectedDate, setSelectedDate] = useState() const [manualFetching, setManualFetching] = useState(false) - const [copiedCode, setCopiedCode] = useState(false) + // 首次使用(无数据 + 未完成引导)自动弹窗: 同一会话只弹一次 + const [showWelcomeModal, setShowWelcomeModal] = useState(false) const dataStatus = useDataStatus({ staleTime: 60_000 }) const overview = useQuery({ queryKey: QK.overviewMarket(selectedDate), @@ -485,15 +489,67 @@ export function Dashboard() { const hasDepth = !!caps.data?.capabilities?.['depth5.batch'] const sealedReady = !!data?.limit?.sealed_ready const isSealedDegrade = !hasDepth || !sealedReady - // none 档(无 key / 无效 key)→ 显示升级提示横幅 + // none 档(无 key / 无效 key): 不再阻断功能, 仅实时行情等扩展能力受限 const isNoKey = settings.data?.mode === 'none' - // 无本地数据(enriched/daily 都没有)→ 提示去数据页同步 + // 无本地数据(enriched/daily 都没有)→ 常驻引导卡片 // 注: 后端 status 的 rows 为性能刻意返回 0, 用 trading_days 判断是否有数据 const ds = dataStatus.data const hasNoData = !!ds && (ds.enriched?.trading_days ?? 0) === 0 && (ds.daily?.trading_days ?? 0) === 0 + // ===== 盘后管道触发(看板内一键获取数据) ===== + const [fetchJobId, setFetchJobId] = useState(null) + const fetchStatus = useQuery({ + queryKey: QK.pipelineJob(fetchJobId ?? ''), + queryFn: () => api.pipelineJob(fetchJobId!), + enabled: !!fetchJobId, + refetchInterval: (q: any) => { + const j = q.state.data + return j && (j.status === 'succeeded' || j.status === 'failed') ? false : 1_000 + }, + }) + const startFetch = useMutation({ + mutationFn: api.pipelineRun, + onSuccess: ({ job_id }) => setFetchJobId(job_id), + }) + const isFetching = startFetch.isPending + || fetchStatus.data?.status === 'running' + || fetchStatus.data?.status === 'pending' + const fetchFailed = fetchStatus.data?.status === 'failed' + const fetchSucceeded = fetchStatus.data?.status === 'succeeded' + + // 首次使用且无数据 → 自动弹一次引导弹窗(同会话只弹一次) + useEffect(() => { + if (!hasNoData) return + if (settings.data?.onboarding_completed === false) return // 还在引导流程中,不重复弹 + if (sessionStorage.getItem('tf_welcome_shown')) return + sessionStorage.setItem('tf_welcome_shown', '1') + setShowWelcomeModal(true) + }, [hasNoData, settings.data?.onboarding_completed]) + + // 同步完成后刷新看板数据 + useEffect(() => { + if (fetchSucceeded) { + qc.invalidateQueries({ queryKey: QK.dataStatus }) + qc.invalidateQueries({ queryKey: QK.overviewMarket(undefined) }) + } + }, [fetchSucceeded, qc]) + + // 组件重新挂载时(从其他页面切回)恢复正在运行的同步任务进度。 + // 原因: fetchJobId 是组件内状态, 切走页面时组件卸载、状态丢失, 切回后进度卡片消失。 + // 修复: 挂载时若无本地数据且未跟踪任何 job, 查一次后端是否有 active job, 有则接管。 + const resumeTriedRef = useRef(false) + useEffect(() => { + if (resumeTriedRef.current) return + if (!hasNoData) return + if (fetchJobId) return + resumeTriedRef.current = true + api.pipelineJobs(1).then(({ active_id }) => { + if (active_id) setFetchJobId(active_id) + }).catch(() => { /* 查询失败不阻塞, 用户仍可手动点击获取 */ }) + }, [hasNoData, fetchJobId]) + // 手动刷新: 显示旋转动画; SSE 自动刷新: 静默, 无体感 const handleRefresh = () => { setManualFetching(true) @@ -530,59 +586,31 @@ export function Dashboard() { return (
- {/* none 档(无 key)提示横幅 —— 引导用户领取免费 Key 解锁完整能力 */} - {isNoKey && ( -
- - - 当前未配置 API Key,批量同步、实时行情等能力不可用。 - - 前往 TickFlow 官网 - - - 免费注册(或填邀请码{' '} - - V3KDKGXPEA - - - )即可领取免费 API Key,无需付费即可体验。 - -
- )} - {/* 无本地数据提示 —— 引导用户去数据页同步 (仅当已配置 Key 时显示, 无 Key 时优先提示配置 Key) */} - {hasNoData && !isNoKey && ( -
- - - 当前暂无数据,请前往数据页面同步行情数据完成后查看。 - - 前往同步数据 - - - -
+ {/* 无本地数据常驻引导卡片 —— 一键触发盘后管道获取数据(无 Key 也可) */} + {hasNoData && ( + startFetch.mutate()} + isNoKey={isNoKey} + /> )} + {/* 首次使用自动弹窗(同会话仅一次) */} + + {showWelcomeModal && ( + setShowWelcomeModal(false)} + onStart={() => { + startFetch.mutate() + setShowWelcomeModal(false) + }} + /> + )} +
@@ -720,3 +748,134 @@ export function Dashboard() {
) } + +// ===== 无数据常驻引导卡片: 一键触发盘后管道获取行情数据(无 Key 也可) ===== +function FetchDataCard({ + isFetching, isStarting, fetchFailed, stage, fetchPct, onStart, isNoKey, +}: { + isFetching: boolean + isStarting: boolean + fetchFailed: boolean + stage?: string + fetchPct?: number + onStart: () => void + isNoKey: boolean +}) { + const stageText = stage ? (STAGE_LABELS[stage] ?? stage) : '正在同步行情数据…' + return ( +
+
+
+ +
+
+
当前暂无数据
+

+ 首次使用需获取行情数据后才能查看看板。系统将从免费数据源拉取近 1 年全 A 股日K(约 5500 只),预计 1-3 分钟,期间可继续浏览其他页面。 +

+ {isNoKey && ( +

+ ⓘ 无需 API Key,当前为 None 档即可获取历史日K,可制定策略+回测。配置免费 Key 可解锁实时行情监控能力。 +

+ )} + + {isFetching ? ( +
+
+ + + {isStarting ? '正在启动同步任务…' : stageText} + + + {typeof fetchPct === 'number' ? `${Math.round(fetchPct)}%` : ''} + +
+
+ +
+
+ ) : fetchFailed ? ( +
+ 同步失败,请重试 + +
+ ) : ( +
+ + + 前往数据页 + + +
+ )} +
+
+
+ ) +} + +// ===== 首次使用自动弹窗: 询问用户后触发盘后管道 ===== +function WelcomeFetchModal({ + isNoKey, onClose, onStart, +}: { + isNoKey: boolean + onClose: () => void + onStart: () => void +}) { + return ( + +
+ + + +

首次使用,需先获取行情数据

+

+ 系统将从免费数据源拉取近 1 年全 A 股日K(约 5500 只),预计 1-3 分钟。 + 同步期间可继续浏览其他页面,完成后看板自动刷新。 +

+ {isNoKey && ( +
+ ⓘ 当前无需 API Key,None 档即可获取历史日K数据。 +
+ )} +
+ + +
+
+
+ ) +} diff --git a/frontend/src/pages/Data.tsx b/frontend/src/pages/Data.tsx index 35b4fe1..54b1d92 100644 --- a/frontend/src/pages/Data.tsx +++ b/frontend/src/pages/Data.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { Link } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' import { @@ -9,12 +8,15 @@ import { HardDrive, Clock, Calendar, + CheckSquare, Trash2, Plus, Wifi, + SlidersHorizontal, AlertTriangle, Info, } from 'lucide-react' +import { Link } from 'react-router-dom' import { EndpointTestDialog } from '@/components/EndpointTestDialog' import { api, type ExtDataConfig } from '@/lib/api' import { @@ -39,6 +41,8 @@ import { ScheduleEditor } from '@/components/data/ScheduleEditor' 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 { QuoteConfigCard } from '@/components/data/QuoteConfigCard' import { EnrichedSchemaModal } from '@/components/data/SchemaModal' import { Skeleton } from '@/components/data/Skeleton' @@ -187,7 +191,27 @@ export function Data() { const hasAdjCap = !!caps.data?.capabilities?.['adj_factor'] const hasDailyBatchCap = !!caps.data?.capabilities?.['kline.daily.batch'] const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch'] - const pipelineSteps = ['日K', ...(hasAdjCap ? ['复权'] : []), '指标', '指数', ...((hasMinuteCap && minuteAuto) ? ['分钟K'] : [])] + const indexAuto = prefs.data?.pipeline_pull_index ?? true + const etfAuto = prefs.data?.pipeline_pull_etf ?? false + const pipelineSteps = [ + '日K', + ...(hasAdjCap ? ['复权'] : []), + '指标', + ...(indexAuto ? ['指数'] : []), + ...(etfAuto ? ['ETF'] : []), + ...((hasMinuteCap && minuteAuto) ? ['分钟K'] : []), + ] + + // 数据画像卡片显隐(由页面设置弹窗控制,存 localStorage) + const [cardVisibleTick, setCardVisibleTick] = useState(0) + useEffect(() => { + const handler = () => setCardVisibleTick(t => t + 1) + window.addEventListener('data-card-visible-change', handler) + return () => window.removeEventListener('data-card-visible-change', handler) + }, []) + const cardVisible = getCardVisibility(caps.data?.capabilities) + // 引用 cardVisibleTick 触发重渲染(避免 lint 警告) + void cardVisibleTick useEffect(() => { if (job.data && (job.data.status === 'succeeded' || job.data.status === 'failed')) { @@ -224,6 +248,14 @@ export function Data() { symbols_covered: s.index_daily?.symbols_covered ?? s.index_instruments?.rows ?? 0, trading_days: s.index_daily?.trading_days ?? s.index_enriched?.trading_days ?? 0, } : null + // ETF 统计(后端已按 asset_type='etf' 从 index 存储中拆分) + const etfOverviewStats = s ? { + rows: 0, + earliest_date: s.etf_daily?.earliest_date ?? s.etf_enriched?.earliest_date ?? null, + latest_date: s.etf_daily?.latest_date ?? s.etf_enriched?.latest_date ?? null, + symbols_covered: s.etf_daily?.symbols_covered ?? s.etf_instruments?.rows ?? 0, + trading_days: s.etf_daily?.trading_days ?? s.etf_enriched?.trading_days ?? 0, + } : null const indexOverviewLabel = s ? '日 · 维表 · 日K · 指标' : undefined const indexEarliestDate = s?.index_daily?.earliest_date ?? s?.index_enriched?.earliest_date ?? null const indexOffsetDays = indexExtendUnit === 'month' ? indexExtendValue * 30 : indexExtendValue * 365 @@ -293,31 +325,28 @@ export function Data() { subtitle="本地数据画像 · 同步状态 · 历史记录" right={
- {!hasData && !isLoading && !isNoKey && ( + {!hasData && !isLoading && ( 首次使用请点击右侧按钮同步数据 )} - {isNoKey ? ( - - ) : ( - - )} + )} + {isStarting ? '启动中…' : isRunning ? '同步中…' : '立即同步'} + +
+ - - ,即可免费领取扩展数据。 + {/* 档位对比说明 —— None 档 vs Free 档 */} +
+ {/* None 档 —— 不配置时默认 */} +
+
+ None + 不配置(默认) +
+
    +
  • · 仅历史日K数据,无实时行情
  • +
  • · 数据有延迟,盘后约 1-2 小时更新当天
  • +
  • · 可用于策略回测、盘后分析
  • +
+
+ {/* Free 档 —— 免费注册即可获取 */} +
+
+ Free + 注册免费获取 + 推荐 +
+
    +
  • · 无需付费,注册即享
  • +
  • · 历史日K + 限定范围内的实时数据
  • +
  • · 可指定个股进行实时监控
  • +
+
{/* Key 已配置提示 */} @@ -302,6 +314,27 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
)} + {/* 获取 Key 的说明 —— 黄框卡片 */} +
+ + + Key 可在{' '} + + tickflow.org + + + 获取。 + + 当前数据源基于 TickFlow 基座,其他第三方数据源正在开发适配中。 + + +
+ {/* 输入 */}
{ @@ -399,7 +432,7 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void const settings = useSettings() const caps = useCapabilities() - // 是否配置成功 —— 免费档(free)或付费档(api_key)都算;无档(none)算未配置 + // 是否配置成功 —— 免费档(free)或付费档(api_key)都算;None 档算未配置 const hasKey = settings.data?.mode === 'free' || settings.data?.mode === 'api_key' const capList = caps.data ? Object.entries(caps.data.capabilities) : [] @@ -458,10 +491,10 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void
-
将以基础模式继续
+
将以 None 档继续

- 当前未配置有效 Key,仅可使用历史日K数据。配置 Key 后可解锁实时行情、批量同步等能力, - 随时在 设置 → 账户 填写。 + 当前未配置有效 Key,仍可使用看板、选股、回测等功能 —— 进入看板后可直接获取近 1 年历史日K数据。配置 Key 后可解锁实时行情监控等能力,随时在 + 设置 → 账户 填写。

)} @@ -490,10 +523,16 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void // ===== Step 3: 完成 ===== function FinishStep({ onNext, onBack, pending }: { onNext: () => void; onBack: () => void; pending: boolean }) { + const settings = useSettings() + // 是否已配置 Key(free 或 api_key 都算,None 档算未配置) + const hasKey = settings.data?.mode === 'free' || settings.data?.mode === 'api_key' + + // 首要行动:获取数据(不管配没配 Key, 新用户都需要先拉数据) + // 快速上手入口(精简为核心功能) const tips = [ - { icon: ScanSearch, text: '在「选股」页用内置策略一键扫描全市场' }, - { icon: BellRing, text: '在「监控」页设置条件或策略告警,盘中实时推送' }, - { icon: ShieldCheck, text: '在「回测」页用历史数据验证策略表现' }, + { icon: TrendingUp, text: '「个股分析」:输入代码,AI 四维分析 + 关键价位' }, + { icon: ScanSearch, text: '「选股」页:内置多套策略,一键扫描全市场' }, + { icon: ShieldCheck, text: '「回测」页:用历史数据验证策略表现,用数据说话' }, ] return ( @@ -520,18 +559,37 @@ function FinishStep({ onNext, onBack, pending }: { onNext: () => void; onBack: (

一切就绪!

- 配置已完成。下面几个入口帮你快速上手,有任何问题随时在 - 设置 里调整。 + {hasKey + ? 'Key 已生效,进入面板后系统会自动引导你获取行情数据,完成后即可使用全部功能。' + : '当前为 None 档,进入面板后系统会自动引导你获取历史日K数据(无需 Key),即可开始体验。'}

- {/* 快速上手提示 */} -
+ {/* 首要行动:获取数据 */} + +
+ +
+
+
下一步:获取行情数据
+

+ 进入面板后,看板会自动引导你拉取近 1 年全 A 股日K(约 5500 只,预计 1-3 分钟)。同步期间可浏览其他页面。 +

+
+
+ + {/* 快速上手入口 */} +
{tips.map((t, i) => (
diff --git a/frontend/src/pages/Review.tsx b/frontend/src/pages/Review.tsx new file mode 100644 index 0000000..36c7674 --- /dev/null +++ b/frontend/src/pages/Review.tsx @@ -0,0 +1,662 @@ +/** + * AI 大盘复盘页 —— 盘后复盘看板 + 流式 LLM 复盘报告 + 历史归档。 + * + * 数据分工: + * - 顶部看板(指数/涨跌/连板/封板/情绪雷达)来自 GET /api/overview/market + * - 复盘报告(markdown)由 POST /api/market-recap/analyze 流式生成 + * 视觉语言对齐 Dashboard:A 股红涨绿跌、rounded-card 卡片、SectionTitle 层级。 + */ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Link } from 'react-router-dom' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { motion } from 'framer-motion' +import { + BookOpenCheck, RefreshCw, Sparkles, Trash2, History, ChevronRight, AlertTriangle, + BarChart3, Activity, Layers, ArrowUpRight, ArrowDownRight, Database, Wand2, +} from 'lucide-react' + +import { api, type OverviewMarket, type AiReviewReport } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { cn } from '@/lib/cn' +import { fmtPrice } from '@/lib/format' +import { PageHeader } from '@/components/PageHeader' +import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer' +import { toast } from '@/components/Toast' + +// ================================================================ +// 涨跌幅格式化(注意单位差异) +// overview 的 indices.change_pct / breadth.up_pct / seal_rate / *_pct / emotion.score +// 都是【已是百分比值】(如 1.2 表示 1.2%),直接 toFixed 即可,不要 *100。 +// ================================================================ +function fmtPctAlready(v: number | null | undefined, digits = 2, withSign = false): string { + if (v == null || Number.isNaN(v)) return '—' + const sign = withSign && v > 0 ? '+' : '' + return `${sign}${v.toFixed(digits)}%` +} +function pctClass(v: number | null | undefined): string { + if (v == null || Number.isNaN(v) || v === 0) return 'text-muted' + return v > 0 ? 'text-bull' : 'text-bear' +} +// A 股惯例: 强势=红, 弱式=绿(对齐 Dashboard scoreColor) +function scoreColor(v: number | null | undefined): string { + if (v == null || Number.isNaN(v)) return '#71717A' + if (v >= 70) return '#F04438' + if (v >= 55) return '#FB923C' + if (v >= 45) return '#F59E0B' + if (v >= 30) return '#84CC16' + return '#12B76A' +} + +type Phase = 'idle' | 'loading' | 'streaming' | 'done' | 'error' + +export function Review() { + const qc = useQueryClient() + // 复盘日期:当前固定取最新交易日(后续如需日期选择可改回 useState) + const asOf: string | undefined = undefined + const [focus, setFocus] = useState('') + const [phase, setPhase] = useState('idle') + const [content, setContent] = useState('') + const [error, setError] = useState('') + const [meta, setMeta] = useState<{ as_of?: string; emotion_score?: number; emotion_label?: string; summary?: string } | null>(null) + const [viewing, setViewing] = useState(null) // 查看历史报告 + const abortRef = useRef(null) + const reportEndRef = useRef(null) + + // 看板数据(与总览页同源) + const marketQuery = useQuery({ + queryKey: QK.overviewMarket(asOf), + queryFn: () => api.overviewMarket(asOf), + staleTime: 5_000, + placeholderData: (prev) => prev, + }) + + // 历史报告 + const historyQuery = useQuery<{ reports: AiReviewReport[] }>({ + queryKey: QK.reviewReports, + queryFn: () => api.reviewReportsList(), + }) + + const deleteMut = useMutation({ + mutationFn: (id: string) => api.reviewReportDelete(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: QK.reviewReports }) + toast('已删除', 'success') + }, + onError: () => { /* request() 已 toast */ }, + }) + + // 自动滚动到报告底部(streaming 时) + useEffect(() => { + if (phase === 'streaming') { + reportEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) + } + }, [content, phase]) + + // 主流程:生成复盘 + const generate = useCallback(async () => { + if (phase === 'loading' || phase === 'streaming') return + setViewing(null) + setPhase('loading') + setContent('') + setError('') + setMeta(null) + + const ctrl = new AbortController() + abortRef.current = ctrl + let buf = '' + let failed = false + try { + for await (const evt of api.reviewStream(asOf, focus)) { + if (ctrl.signal.aborted) break + if (evt.type === 'meta') { + setMeta(evt) + } else if (evt.type === 'delta' && evt.content) { + buf += evt.content + setContent(buf) + setPhase('streaming') + } else if (evt.type === 'error') { + failed = true + setError(evt.message ?? '复盘失败') + setPhase('error') + return + } else if (evt.type === 'done') { + setPhase('done') + } + } + // 流正常结束但无 done 事件,按 done 处理 + if (buf && !failed) setPhase('done') + } catch (e: any) { + if (!ctrl.signal.aborted) { + setError(e?.message ?? '复盘失败') + setPhase('error') + } + } finally { + abortRef.current = null + } + }, [asOf, focus, phase]) + + // 保存当前报告 + const saveCurrent = useCallback(async () => { + if (!content) return + const reportAsOf = meta?.as_of ?? marketQuery.data?.as_of ?? asOf ?? new Date().toISOString().slice(0, 10) + try { + await api.reviewReportSave({ + as_of: reportAsOf, + focus, + content, + summary: meta?.summary, + emotion_score: meta?.emotion_score ?? null, + emotion_label: meta?.emotion_label ?? '', + }) + qc.invalidateQueries({ queryKey: QK.reviewReports }) + toast('复盘已归档', 'success') + } catch { /* request() 已 toast */ } + }, [content, meta, asOf, focus, marketQuery.data, qc]) + + // 查看历史报告 + const viewReport = useCallback((r: AiReviewReport) => { + abortRef.current?.abort() + setViewing(r) + setContent(r.content) + setMeta({ as_of: r.as_of, emotion_score: r.emotion_score ?? undefined, emotion_label: r.emotion_label, summary: r.summary }) + setPhase('done') + setError('') + }, []) + + const isGenerating = phase === 'loading' || phase === 'streaming' + const displayDate = viewing?.as_of ?? meta?.as_of ?? marketQuery.data?.as_of ?? asOf ?? '最新' + const data = marketQuery.data + + return ( + <> + } + subtitle={`${displayDate}${data?.emotion ? ` · 情绪 ${data.emotion.label}` : ''}`} + right={ +
+ + +
+ } + /> + +
+
+ + {marketQuery.isLoading && !data ? ( +
+
+ 加载市场数据… +
+
+ ) : !data || !data.as_of ? ( +
+
+
+ +
+
+
+
暂无市场数据
+

复盘需要日 K 与指数,请先前往「数据」页同步

+
+ + 前往数据页同步 + + +
+ ) : ( + <> + {/* ===== 指数行情条(对齐 Dashboard IndexTicker) ===== */} +
+ {data.indices.map(item => )} +
+ + {/* ===== KPI 网格 ===== */} +
+ {data.breadth.up}/{data.breadth.flat}/{data.breadth.down}} sub={`上涨率 ${data.breadth.up_pct.toFixed(1)}%`} /> + {data.limit.limit_up}/{data.limit.limit_down}} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}% · 炸板 ${data.limit.broken ?? 0}`} /> + + + + +
+ + {/* ===== 情绪雷达 + 板块排名 双栏 ===== */} +
+ + + +
+ + {/* ===== 关注点输入 ===== */} +
+ + setFocus(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && !isGenerating) generate() }} + placeholder="可选:补充复盘关注点,如「明日是否加仓半导体」「量能是否持续」" + className="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted/60" + /> + {focus && ( + + )} +
+ + {/* ===== 报告 + 历史 双栏 ===== */} +
+ + deleteMut.mutate(id)} + /> +
+ + )} +
+
+ + ) +} + +// ================================================================ +// 指数行情卡(对齐 Dashboard IndexTicker) +// ================================================================ +function IndexTicker({ item }: { item: OverviewMarket['indices'][number] }) { + const pct = item.change_pct + const isUp = (pct ?? 0) >= 0 + return ( +
+
{item.name || item.symbol}
+
{fmtPctAlready(pct, 2, true)}
+
{item.symbol}
+
+ {isUp ? : } + {fmtPrice(item.last_price)} +
+
+ ) +} + +// ================================================================ +// KPI 单元(对齐 Dashboard KpiCell) +// ================================================================ +function KpiCell({ label, value, sub, tone }: { + label: React.ReactNode + value: React.ReactNode + sub?: string + tone?: 'bull' | 'bear' | 'accent' +}) { + const isPlain = typeof value === 'string' || typeof value === 'number' + const color = tone === 'bull' ? 'text-bull' : tone === 'bear' ? 'text-bear' : tone === 'accent' ? 'text-accent' : 'text-foreground' + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ) +} + +// ================================================================ +// 章节标题(对齐 Dashboard SectionTitle) +// ================================================================ +function SectionTitle({ icon: Icon, title, hint }: { icon: typeof Activity; title: string; hint?: React.ReactNode }) { + return ( +
+
+ +

{title}

+
+ {hint && {hint}} +
+ ) +} + +// ================================================================ +// 情绪雷达章节(SVG 雷达图,对齐 Dashboard EmotionRadar) +// ================================================================ +function EmotionSection({ data }: { data: OverviewMarket }) { + const score = data.emotion.score + const color = scoreColor(score) + const radar = data.radar ?? [] + const size = 220 + const cx = size / 2 + const cy = size / 2 + const maxR = 68 + + const points = radar.map((r, i) => { + const angle = -Math.PI / 2 + i * 2 * Math.PI / radar.length + const radius = maxR * Math.max(0, Math.min(100, r.value)) / 100 + return { + ...r, + x: cx + Math.cos(angle) * radius, + y: cy + Math.sin(angle) * radius, + lx: cx + Math.cos(angle) * (maxR + 24), + ly: cy + Math.sin(angle) * (maxR + 24), + gx: cx + Math.cos(angle) * maxR, + gy: cy + Math.sin(angle) * maxR, + } + }) + const polygon = points.map(p => `${p.x},${p.y}`).join(' ') + const gridPolygons = [1, 0.66, 0.33].map((level, idx) => ({ + level, idx, + points: radar.map((_, i) => { + const angle = -Math.PI / 2 + i * 2 * Math.PI / radar.length + return `${cx + Math.cos(angle) * maxR * level},${cy + Math.sin(angle) * maxR * level}` + }).join(' '), + })) + + return ( +
+ + {radar.length === 0 ? ( +
暂无雷达数据
+ ) : ( +
+ + + + + + + + + + + + + {gridPolygons.map(g => ( + + ))} + {points.map(p => )} + + {points.map(p => )} + + {score} + {points.map(p => ( + {p.label} + ))} + +
+ )} +
+ ) +} + +// ================================================================ +// 板块排名章节(领涨/领跌) +// ================================================================ +function SectorSection({ title, rank, tone }: { + title: string + rank: OverviewMarket['concept_rank'] | OverviewMarket['industry_rank'] + tone: 'concept' | 'industry' +}) { + const leading = rank?.leading ?? [] + const lagging = rank?.lagging ?? [] + const hasData = leading.length > 0 || lagging.length > 0 + return ( +
+ + {!hasData ? ( +
暂无数据
+ ) : ( +
+ + +
+ )} +
+ ) +} + +function RankColumn({ rows, tone }: { rows: OverviewMarket['concept_rank']['leading']; tone: 'bull' | 'bear' }) { + return ( +
+
+ {tone === 'bull' ? '领涨' : '领跌'} +
+ {rows.slice(0, 5).map((r, idx) => ( +
+ {idx + 1} +
+
{r.name}
+
{r.count}只 · {r.leader?.name ?? '—'}
+
+
+ {fmtPctAlready((r.avg_pct ?? 0) * 100, 2, true)} +
+
+ ))} + {rows.length === 0 &&
} +
+ ) +} + +// ================================================================ +// 报告面板(流式 + 错误 + 历史/完成态) +// ================================================================ +function ReportPanel({ + phase, content, error, isGenerating, viewing, onSave, onRegenerate, reportEndRef, +}: { + phase: Phase + content: string + error: string + isGenerating: boolean + viewing: AiReviewReport | null + onSave: () => void + onRegenerate: () => void + reportEndRef: React.RefObject +}) { + if (phase === 'error') { + return ( +
+
+ +
+
复盘失败
+
{error || '请检查 AI 配置后重试'}
+ +
+ ) + } + + if (phase === 'idle' && !content) { + return ( +
+
+
+ +
+
+
+
AI 大盘复盘
+

+ 点击右上角「生成复盘」,基于今日指数结构、涨跌家数、连板梯队、板块轮动与情绪雷达, + 生成可直接指导次日仓位与节奏的盘后复盘报告。 +

+
+
+ + 七节结构化报告 · 一键归档 · 历史回看 +
+
+ ) + } + + const showCursor = isGenerating + const showSave = phase === 'done' && !!content && !viewing + const showViewingTag = !!viewing + const isLoading = phase === 'loading' && !content + + return ( + +
+
+ {isGenerating ? : } + + {showViewingTag ? `历史复盘 · ${viewing!.as_of}` : isGenerating ? 'AI 正在复盘…' : '复盘报告'} + +
+ {showSave && ( + + )} +
+
+ {isLoading ? ( +
+
+
+ +
+ +
+
AI 正在分析今日盘面…
+
读取指数结构 · 涨跌家数 · 连板梯队 · 板块轮动 · 情绪雷达
+
+ ) : ( +
+ + {showCursor && ( + + )} +
+ )} +
+
+ + ) +} + +// ================================================================ +// 历史面板 +// ================================================================ +function HistoryPanel({ + reports, loading, viewingId, onView, onDelete, +}: { + reports: AiReviewReport[] + loading: boolean + viewingId: string | null + onView: (r: AiReviewReport) => void + onDelete: (id: string) => void +}) { + return ( +
+
+ + 历史复盘 + ({reports.length}) +
+
+ {loading ? ( +
+ ) : reports.length === 0 ? ( +
+ +
暂无历史复盘
+
生成后点「归档」即可保存
+
+ ) : ( +
+ {reports.map((r) => { + const color = scoreColor(r.emotion_score) + return ( +
onView(r)} + > +
+ {r.emotion_score ?? '—'} +
+
+
+ {r.emotion_label ?? '—'} + {r.as_of} +
+
+ {r.summary ?? r.content.slice(0, 40)} +
+
+ + +
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/Watchlist.tsx b/frontend/src/pages/Watchlist.tsx index 789f77c..5a2e110 100644 --- a/frontend/src/pages/Watchlist.tsx +++ b/frontend/src/pages/Watchlist.tsx @@ -1,7 +1,7 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' -import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus } from 'lucide-react' +import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp } from 'lucide-react' import { api, type KlineRow } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { storage } from '@/lib/storage' @@ -591,6 +591,18 @@ export function Watchlist() { }, }) + const moveToTop = useMutation({ + mutationFn: (sym: string) => api.watchlistMoveToTop(sym), + onSuccess: (data) => { + qc.setQueryData(QK.watchlist, data) + qc.invalidateQueries({ queryKey: QK.watchlist }) + qc.invalidateQueries({ queryKey: ['watchlist-enriched'] }) + qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] }) + qc.invalidateQueries({ queryKey: QK.preferences }) + qc.invalidateQueries({ queryKey: QK.quoteStatus }) + }, + }) + const clearAll = useMutation({ mutationFn: () => api.watchlistClear(), onSuccess: () => { @@ -953,13 +965,25 @@ export function Watchlist() {
) : ( - +
+ + +
)}
diff --git a/frontend/src/pages/settings/Keys.tsx b/frontend/src/pages/settings/Keys.tsx index a4a1e1d..76f5d3f 100644 --- a/frontend/src/pages/settings/Keys.tsx +++ b/frontend/src/pages/settings/Keys.tsx @@ -14,7 +14,6 @@ import { Loader2, Save, Check, - Copy, HelpCircle, } from 'lucide-react' import { api } from '@/lib/api' @@ -34,7 +33,6 @@ export function SettingsKeysPanel() { const [revealing, setRevealing] = useState(false) const [confirmClear, setConfirmClear] = useState(false) const [saved, setSaved] = useState(false) - const [copiedCode, setCopiedCode] = useState(false) const save = useMutation({ mutationFn: () => api.saveTickflowKey(keyInput.trim()), @@ -89,27 +87,6 @@ export function SettingsKeysPanel() { {' '} 注册获取。API Key 存放为本地文件,不会上传任何第三方,请妥善保管。

-

- 通过上方链接注册或填写邀请码{' '} - - V3KDKGXPEA - - - ,即可免费领取概念行业等扩展数据。 -

{/* 当前状态 */}
@@ -131,7 +108,7 @@ export function SettingsKeysPanel() { ) : ( <> - 未配置 · Free 数据 + 未配置 )}
@@ -220,7 +197,7 @@ export function SettingsKeysPanel() {
保存成功 — 档位 {save.data.tier_label} - {save.data.mode === 'free' && '(免费档 · 历史日K)'} + {save.data.mode === 'free' && '(免费档 · 历史日K + 自选实时监控)'}
)} @@ -341,7 +318,7 @@ export function SettingsKeysPanel() {

清除 API Key

- 清除后将退回无档(仅历史日K),需要重新输入 Key 才能恢复。 + 清除后将退回 None 档(仅历史日K),需要重新输入 Key 才能恢复。

) })}
+
+ 高等档位包含较低档位的全部权益。 +
+ {/* 检测说明 */}
档位检测说明
-

保存 Key 后系统会在付费端点逐一试探数据能力:连单只日K都拿不到则判为「无」(不存 Key);有日K但无复权因子则判为「Free」;有复权因子再按代表能力判定 Starter/Pro/Expert。

-

无档与免费档运行时都走免费数据通道(仅历史日K),区别仅在于是否保存了 Key。付费档走付费端点,享有实时行情等完整能力。

+

保存 Key 后系统会在付费端点逐一试探数据能力:连单只日K都拿不到则判为「None」(不存 Key);有日K但无复权因子则判为「Free」;有复权因子再按代表能力判定 Starter/Pro/Expert。

+

None 档与 Free 档运行时都走免费数据通道(仅历史日K),区别仅在于是否保存了 Key。付费档走付费端点,享有实时行情等完整能力。

diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx index 06c481c..469c4bb 100644 --- a/frontend/src/pages/settings/Monitoring.tsx +++ b/frontend/src/pages/settings/Monitoring.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { useQueryClient, useMutation } from '@tanstack/react-query' +import { Link } from 'react-router-dom' +import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' import { Activity, Shield, @@ -46,8 +47,9 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = const { data: intervalData } = useQuoteInterval() const updateInterval = useUpdateQuoteInterval() const toggleQuote = useToggleRealtimeQuotes() - // none/free 档(无实时行情权限)→ rank < starter(1) - const isFreeTier = tierRank(caps?.label ?? '') < 1 + const tier = tierRank(caps?.label ?? '') + const isNoneTier = tier < 0 + const isFreeTier = tier === 0 const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false const refreshPages = prefs?.sse_refresh_pages ?? {} const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false @@ -60,6 +62,16 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = const interval = intervalData?.interval ?? 10 const minInterval = intervalData?.min_interval ?? 5 const maxInterval = intervalData?.max_interval ?? 60 + const [intervalDraft, setIntervalDraft] = useState(interval) + const watchlistSymbols = prefs?.realtime_watchlist_symbols ?? [] + const watchlist = useQuery({ + queryKey: QK.watchlist, + queryFn: () => api.watchlistList(), + enabled: isFreeTier && watchlistSymbols.length > 0, + }) + const watchlistNameBySymbol = new Map( + (watchlist.data?.symbols ?? []).map(row => [row.symbol, row.name] as const), + ) const save = useCallback(async (cfg: Record) => { try { @@ -110,6 +122,18 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = onError: () => toast('修正请求失败', 'error'), }) + useEffect(() => { + setIntervalDraft(interval) + }, [interval]) + + useEffect(() => { + if (intervalDraft === interval) return + const t = window.setTimeout(() => { + updateInterval.mutate(intervalDraft) + }, 2000) + return () => window.clearTimeout(t) + }, [intervalDraft, interval, updateInterval]) + // highlight=depth-fix 时闪烁高亮连板梯队修正卡片 const [flash, setFlash] = useState(false) const flashedRef = useRef(false) @@ -125,8 +149,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = } }, [highlight]) - // Free 档位 — 显示升级提示 - if (isFreeTier) { + if (isNoneTier) { return (
实时监控

- 实时行情轮询、策略监控等功能需要 Starter 及以上档位。 - 升级后可配置轮询间隔、选择监控策略池。 + 实时行情需要 Free 及以上档位。None 档可使用 free-api 获取历史日K(当日数据需盘后1-2小时),但不能调用付费服务器实时接口。

轮询间隔
-
每轮拉取全市场行情的时间间隔
+
+ {isFreeTier ? '每轮拉取自选股实时行情的时间间隔' : '每轮拉取全市场行情的时间间隔'} +
- {interval < 1 ? interval.toFixed(1) : interval.toFixed(0)}s + {intervalDraft < 1 ? intervalDraft.toFixed(1) : intervalDraft.toFixed(0)}s
@@ -179,18 +203,54 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = min={minInterval} max={maxInterval} step={minInterval < 1 ? 0.1 : minInterval < 3 ? 0.5 : 1} - value={interval} - onChange={(e) => updateInterval.mutate(parseFloat(e.target.value))} + value={intervalDraft} + onChange={(e) => setIntervalDraft(parseFloat(e.target.value))} className="flex-1 h-1 accent-accent cursor-pointer" /> - {minInterval}s — {maxInterval}s + {intervalDraft !== interval ? '2秒后保存' : `${minInterval}s — ${maxInterval}s`}
- {/* 页面刷新 */} + {isFreeTier && ( + +
+ Free 档开启实时行情时自动监控「自选」页面前 5 个标的,最低 6 秒刷新。 +
+ {watchlistSymbols.length > 0 ? ( +
+ {watchlistSymbols.map(symbol => { + const name = watchlistNameBySymbol.get(symbol) + return ( +
+
+ {symbol} + {name && {name}} +
+ 自选页 +
+ ) + })} +
+ ) : ( +
+ 自选列表为空,Free 实时行情开启前请先添加自选股。 +
+ )} +
+ 当前 {watchlistSymbols.length}/5 只 + + 管理自选 + +
+
+ )} + {!isFreeTier && (

选择哪些页面跟随 SSE 实时刷新数据。关闭的页面不会被推送, @@ -208,7 +268,9 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = ))}

+ )} + {!isFreeTier && (

选择实时行情开启时,左侧菜单底部显示哪些指数点位和涨跌幅。 @@ -233,6 +295,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = />

+ )}
{/* ========== 右列 ========== */} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index cf778c9..0e3a2cb 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -13,6 +13,7 @@ import { AnalysisDetail } from './pages/AnalysisDetail' import { ConceptAnalysis } from './pages/ConceptAnalysis' import { IndustryAnalysis } from './pages/IndustryAnalysis' import { StockAnalysis } from './pages/StockAnalysis' +import { Review } from './pages/Review' import { LimitUpLadder } from './pages/LimitUpLadder' import { Branding } from './pages/Branding' import { Settings } from './pages/Settings' @@ -65,6 +66,7 @@ export const router = createBrowserRouter([ { path: 'concept-analysis', element: }, { path: 'industry-analysis', element: }, { path: 'stock-analysis', element: }, + { path: 'review', element: }, { path: 'watchlist', element: }, { path: 'screener', element: }, { path: 'backtest', element: }, diff --git a/tiers.yaml b/tiers.yaml index 2c999e2..46139b0 100644 --- a/tiers.yaml +++ b/tiers.yaml @@ -19,20 +19,21 @@ # none = 无 key / 乱填 / 无效 key。运行时走 free-api 服务器(free 通道), # 仅历史日K(含批量),无实时行情。不存 API Key。 # free = 免费有效 key(付费端点验证:有单只日K、无复权因子)。 -# 运行时同样走 free-api 服务器(key 被忽略),能力与 none 等价; -# 存 key 仅作档位标记,便于将来 SDK 支持免费 key 时自动升级。 -# 两者运行时服务器相同(free-api),区别只在是否存 key。 +# 历史/盘后日K仍走 free-api 服务器;实时行情走付费服务器按标的接口。 +# 可用于自选股实时监控:10 次/分,每次最多 5 标的。 +# none 与 free 的历史日K通道相同(free-api),区别在于 free 有付费服务器按标的实时权限。 # starter+ 才走付费端点 api.tickflow.org,有实时行情。 none: # 无 key / 无效 key —— 走 free-api 服务器,仅历史日K(含批量),无实时行情 kline.daily.by_symbol: { rpm: 60, batch: 1 } - kline.daily.batch: { rpm: 60, batch: 50 } # free-api 服务器批量日K + kline.daily.batch: { rpm: 60, batch: 100 } # free-api 服务器批量日K(接口限制 60 次/分, 每批最多 100 标的) free: - # 免费有效 key —— 运行时同样走 free-api 服务器,能力与 none 等价 + # 免费有效 key —— 历史日K走 free-api;自选实时走付费服务器按标的接口 + quote.by_symbol: { rpm: 10, batch: 5 } kline.daily.by_symbol: { rpm: 60, batch: 1 } - kline.daily.batch: { rpm: 60, batch: 50 } # [补] 批量日K(此前缺失) + kline.daily.batch: { rpm: 60, batch: 100 } # free-api 服务器批量日K(同 none, 60 次/分, 每批最多 100 标的) starter: quote.by_symbol: { rpm: 60, batch: 50 }