feat: release v0.1.60

This commit is contained in:
shy3130
2026-06-27 23:23:34 +08:00
parent a832836938
commit 2acaa38a2b
50 changed files with 4574 additions and 655 deletions
+16 -13
View File
@@ -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` 免费领取概念行业等扩展数据**。<br>
**任意接入第三方数据**(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 兼容接口,留空关闭 | 可选 |
<table>
@@ -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(可选):策略生成
+1 -1
View File
@@ -2,7 +2,7 @@
import sys
__version__ = "0.1.53"
__version__ = "0.1.60"
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
+7 -40
View File
@@ -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("")
+122 -6
View File
@@ -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",
+108
View File
@@ -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}
+11 -209
View File
@@ -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")
+70 -1
View File
@@ -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
+28 -8
View File
@@ -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("")
+8
View File
@@ -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"]
+68
View File
@@ -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."""
+99
View File
@@ -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")
+15
View File
@@ -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()
+18
View File
@@ -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",
]
@@ -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 [])
+116 -32
View File
@@ -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(
+2 -1
View File
@@ -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)
+235 -31
View File
@@ -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()
+42 -6
View File
@@ -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():
@@ -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,
})
+356
View File
@@ -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
@@ -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")
+153
View File
@@ -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", {})
+179 -29
View File
@@ -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 "全量"
+14
View File
@@ -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()
+18 -1
View File
@@ -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:
+3 -4
View File
@@ -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
+421 -7
View File
@@ -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:
"""按资产类型写入 enrichedstock/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)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -2491,7 +2491,7 @@ all = [
[[package]]
name = "tickflow-stock-panel-backend"
version = "0.1.45"
version = "0.1.60"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tickflow-stock-panel-frontend",
"private": true,
"version": "0.1.53",
"version": "0.1.60",
"type": "module",
"scripts": {
"dev": "vite",
+45 -21
View File
@@ -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 (
<NavLink
@@ -310,8 +313,10 @@ export function Layout() {
const toggleQuote = useToggleRealtimeQuotes()
const isRunning = quoteStatus?.running ?? false
const isTrading = quoteStatus?.is_trading_hours ?? false
// none/free 档(无实时行情权限)→ rank < starter(1)
const isFreeTier = tierRank(caps?.label ?? '') < 1
const tier = tierRank(caps?.label ?? '')
const isNoneTier = tier < 0
const isWatchlistMode = tier === 0
const realtimeModeLabel = isWatchlistMode ? '自选股' : '全市场'
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
const alertsTotalQuery = useQuery({
@@ -356,7 +361,12 @@ export function Layout() {
queryKey: QK.capabilities,
queryFn: api.capabilities,
})
if (tierRank(fresh.label ?? '') < 1) return
const freshTier = tierRank(fresh.label ?? '')
if (freshTier < 0) return
if (freshTier === 0 && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) {
navigate('/watchlist')
return
}
}
await toggleQuote.mutateAsync(enabled)
// 仅在交易时段立即获取一次行情
@@ -423,7 +433,7 @@ export function Layout() {
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1">{label}</span>
{/* 个股分析 Beta 标识 */}
{to === '/stock-analysis' && (
{(to === '/stock-analysis' || to === '/review') && (
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400 shrink-0">
Beta
</span>
@@ -445,13 +455,27 @@ export function Layout() {
{/* 全局行情开关 */}
<div className="border-t border-border px-3 py-2.5 shrink-0">
{isFreeTier ? (
/* Free 档位 — 显示升级提示 */
<div className="flex items-center justify-between">
<span className="text-xs text-secondary truncate"></span>
<span className="text-[10px] text-accent/70 font-medium bg-accent/10 px-1.5 py-0.5 rounded">
Starter+
</span>
{isNoneTier ? (
<div>
<div className="flex items-center justify-between">
<span className="text-xs text-secondary truncate"></span>
<span className="text-[10px] text-accent/70 font-medium bg-accent/10 px-1.5 py-0.5 rounded">
Free+
</span>
</div>
<div className="mt-1.5 text-[10px] leading-snug text-muted">
<a
href={TICKFLOW_REGISTER_URL}
target="_blank"
rel="noreferrer"
className="mx-1 inline-flex items-baseline gap-0.5 text-accent/80 hover:text-accent hover:underline"
>
TickFlow
<ExternalLink className="h-2.5 w-2.5 self-center" />
</a>
</div>
</div>
) : (
/* Starter+ — 开关 + 跳转设置 */
@@ -465,14 +489,14 @@ export function Layout() {
: 'bg-muted'
}`} />
<span className="text-xs text-secondary truncate">
· {realtimeModeLabel}
</span>
<button
onClick={() => navigate('/settings?tab=monitoring')}
className="text-secondary hover:text-foreground transition-colors shrink-0"
title="实时监控设置"
>
<Timer className="h-3 w-3" />
<Settings className="h-3 w-3" />
</button>
</div>
<button
@@ -492,7 +516,7 @@ export function Layout() {
)}
{/* 状态提示 */}
{realtimeEnabled && !isFreeTier && (
{realtimeEnabled && !isNoneTier && (
<div className="mt-1.5 text-[10px] leading-snug">
{isRunning && isTrading ? (
<span className="text-accent"></span>
@@ -501,7 +525,7 @@ export function Layout() {
) : null}
</div>
)}
{showSidebarQuotes && !isFreeTier && (
{showSidebarQuotes && !isWatchlistMode && !isNoneTier && (
<SidebarIndexQuotes rows={sidebarIndexQuotes?.rows} items={sidebarIndexes} />
)}
</div>
@@ -8,7 +8,7 @@ import type { PipelineJob } from '@/lib/api'
export const STAGE_LABELS: Record<string, string> = {
init: '初始化',
resolve_universe: '解析标的池',
sync_instruments: '同步标的维表',
sync_instruments: '同步个股维表',
sync_daily: '同步日 K',
sync_adj: '同步除权因子',
compute_enriched: '计算技术指标',
@@ -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<Record<CardKey, string>> = {
adj_factor: 'adj_factor',
minute: 'kline.minute.batch',
financials: 'financial',
}
/**
* 读取卡片显隐状态。结合档位能力决定默认值:
* - 用户显式设置过 → 用设置值
* - 未设置 + defaultHiddenIfNoCap + 当前无能力 → 隐藏
* - 其他 → 显示
*/
export function getCardVisibility(
caps: Record<string, unknown> | undefined,
): Record<string, boolean> {
const has = (capKey: string) => !capKey || !!caps?.[capKey]
const override = storage.dataCardVisible.get({})
const result: Record<string, boolean> = {}
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<string, unknown> | undefined
}) {
const [visible, setVisible] = useState<Record<string, boolean>>(() => 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 (
<div className="space-y-2.5">
<p className="text-xs text-secondary leading-relaxed">
,
</p>
<div className="space-y-1.5">
{DATA_CARD_DEFS.map((def) => {
const on = visible[def.key] ?? true
return (
<label
key={def.key}
className={`flex items-center gap-2.5 rounded-card border px-3 py-2 cursor-pointer transition-colors ${
on ? 'border-accent/40 bg-accent/[0.05]' : 'border-border bg-base/30 hover:border-border/70'
}`}
>
<button
type="button"
onClick={() => toggle(def.key)}
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
on ? 'bg-accent border-accent' : 'bg-base border-border'
}`}
role="checkbox"
aria-checked={on}
>
{on && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
</button>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-foreground">{def.label}</div>
<div className="text-[10px] text-muted leading-snug">{def.desc}</div>
</div>
</label>
)
})}
</div>
<div className="flex items-center justify-end pt-1">
<button
onClick={reset}
className="px-2 py-0.5 rounded-btn text-[10px] text-secondary hover:text-foreground transition-colors"
>
</button>
</div>
</div>
)
}
@@ -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<Record<PullKey, boolean>>) => 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 (
<div className="space-y-2.5">
<p className="text-xs text-secondary leading-relaxed">
,
</p>
<div className="space-y-1.5">
{ITEMS.map((item) => {
const locked = item.key === 'pipeline_pull_a_share'
const on = locked || getValue(item.key, item.defaultOn)
return (
<div key={item.key}>
<label
className={`flex items-start gap-2.5 rounded-card border px-3 py-2.5 transition-colors ${
locked ? 'cursor-default' : 'cursor-pointer'
} ${on ? 'border-accent/40 bg-accent/[0.05]' : 'border-border bg-base/30 hover:border-border/70'}`}
>
<button
type="button"
onClick={() => {
if (!locked) updateToggle.mutate({ [item.key]: !on } as never)
}}
disabled={locked || updateToggle.isPending}
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
on ? 'bg-accent border-accent' : 'bg-base border-border'
} ${locked ? 'opacity-80' : ''}`}
role="checkbox"
aria-checked={on}
>
{on && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-foreground">{item.label}</span>
</div>
<div className="text-[10px] text-muted leading-snug mt-0.5">{item.desc}</div>
</div>
</label>
</div>
)
})}
</div>
{updateToggle.isPending && (
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<Loader2 className="h-3 w-3 animate-spin" />
</div>
)}
<div className="text-[10px] text-muted leading-relaxed pt-1">
,
</div>
</div>
)
}
+4 -1
View File
@@ -4,7 +4,7 @@ import { api, type EnrichedField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
const TABLE_TITLES: Record<string, string> = {
instruments: '标的维表',
instruments: '个股维表',
daily: '日 K',
adj_factor: '除权因子',
enriched: 'Enriched',
@@ -12,6 +12,9 @@ const TABLE_TITLES: Record<string, string> = {
index_instruments: '指数维表',
index_daily: '指数日 K',
index_enriched: '指数 Enriched',
etf_instruments: 'ETF 维表',
etf_daily: 'ETF 日 K',
etf_enriched: 'ETF Enriched',
}
function categorize(name: string): string {
+3 -1
View File
@@ -5,7 +5,7 @@ import { fmtDate } from '@/lib/format'
import { Skeleton } from './Skeleton'
// 卡片能力定义:capKey → 查 capability limitstierReq → 无权限时显示的档位要求
// capKey 为空串表示该数据在 free-api 服务器(无档/免费档)即可获取,无需付费能力门控。
// capKey 为空串表示该数据在 free-api 服务器(None 档/Free 档)即可获取,无需付费能力门控。
export const CARD_META: Record<string, {
capKey: string // 对应的 capability key,空串表示本地计算 / free 服务器可用
tierReq: string // 最低档位要求(无权限时显示)
@@ -15,6 +15,8 @@ export const CARD_META: Record<string, {
daily: { capKey: 'kline.daily.batch', tierReq: 'Starter+' },
adj_factor: { capKey: 'adj_factor', tierReq: 'Starter+' },
enriched: { capKey: '', tierReq: '' },
// ETF 复用日K批量能力(免费档 kline.daily.batch 即可),不显示档位徽章
etf: { capKey: 'kline.daily.batch', tierReq: '' },
minute: { capKey: 'kline.minute.batch', tierReq: 'Pro+' },
financials: { capKey: 'financial', tierReq: 'Expert' },
}
+129 -1
View File
@@ -196,6 +196,7 @@ export interface WatchlistEntry {
symbol: string
added_at: string
note?: string
name?: string | null
}
export interface Quote {
@@ -317,6 +318,18 @@ export interface OverviewMarket {
industry_rank: { leading: OverviewDimensionRankItem[]; lagging: OverviewDimensionRankItem[] }
}
// ===== 大盘复盘 =====
export interface AiReviewReport {
id: string
as_of: string
focus?: string
content: string
summary?: string
emotion_score?: number | null
emotion_label?: string
created_at: string
}
// ===== Strategy Engine =====
export interface StrategyParamDef {
id: string
@@ -642,6 +655,20 @@ export interface Preferences {
indices_nav_pinned: boolean
minute_sync_enabled: boolean
minute_sync_days: number
daily_data_provider?: string
adj_factor_provider?: string
minute_data_provider?: string
realtime_data_provider?: string
realtime_watchlist_symbols?: string[]
realtime_pull_stock?: boolean
realtime_pull_etf?: boolean
realtime_pull_index?: boolean
realtime_index_mode?: 'core' | 'all'
realtime_index_symbols?: string[]
pipeline_pull_a_share: boolean
pipeline_pull_etf: boolean
pipeline_pull_index: boolean
pipeline_index_symbols: string
pipeline_schedule: { hour: number; minute: number }
instruments_schedule: { hour: number; minute: number }
enriched_batch_size: number
@@ -708,11 +735,30 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ minute_sync_enabled: enabled, minute_sync_days: days }),
}),
updatePipelinePullTypes: (cfg: Partial<Pick<Preferences, 'pipeline_pull_a_share' | 'pipeline_pull_etf' | 'pipeline_pull_index'>>) =>
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<Pick<Preferences, 'realtime_pull_stock' | 'realtime_pull_etf' | 'realtime_pull_index' | 'realtime_index_mode' | 'realtime_index_symbols'>>) =>
request<Partial<Preferences>>('/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
+7 -7
View File
@@ -1,6 +1,6 @@
// capability 内部名 → 用户能理解的中文标签
export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
'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<string, { name: string; hint: string }> = {
// 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。
// 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。
// none = 档(无 key / 无效 key),低于 free,仅历史日K无实时行情。
// none = None 档(无 key / 无效 key),低于 free,仅历史日K无实时行情。
export const TIER_RANK: Record<string, number> = { 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<string, TierStyle> = {
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 (
<span
className={`inline-flex h-[18px] max-w-[80px] shrink-0 items-center overflow-hidden rounded px-1.5 text-[10px] font-bold font-mono leading-none ${className}`}
+3
View File
@@ -70,6 +70,9 @@ export const QK = {
monitorRules: ['monitor-rules'] as const,
monitorRuleOptions: ['monitor-rule-options'] as const,
alerts: (source?: string) => ['alerts', source ?? ''] as const,
// AI 大盘复盘
reviewReports: ['review-reports'] as const,
} as const
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
+3
View File
@@ -107,4 +107,7 @@ export const storage = {
/** 行业分析页面字段配置 */
industryAnalysisConfig: kv<Record<string, any>>('industry-analysis-config'),
/** 数据页画像卡片显隐 (卡片key → 是否显示) */
dataCardVisible: kv<Record<string, boolean>>('data-card-visible'),
} as const
+218 -59
View File
@@ -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<string | undefined>()
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<string | null>(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 (
<div className="min-h-full bg-base p-3">
{/* none 档(无 key)提示横幅 —— 引导用户领取免费 Key 解锁完整能力 */}
{isNoKey && (
<div className="mb-3 flex items-center gap-2 rounded-card border border-warning/40 bg-warning/10 px-3 py-2 text-xs">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
<span className="text-secondary leading-relaxed">
API Key,
<a
href="https://tickflow.org/auth/register?ref=V3KDKGXPEA"
target="_blank"
rel="noreferrer"
className="mx-1 inline-flex items-baseline gap-0.5 font-medium text-warning hover:underline"
>
TickFlow
<ExternalLink className="h-3 w-3 self-center" />
</a>
({' '}
<span className="font-mono font-semibold text-warning inline-flex items-baseline gap-1">
V3KDKGXPEA
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText('V3KDKGXPEA').then(() => {
setCopiedCode(true)
setTimeout(() => setCopiedCode(false), 1500)
})
}}
className="text-warning/60 hover:text-warning transition-colors self-center"
aria-label="复制邀请码"
tabIndex={-1}
>
{copiedCode ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</button>
</span>
) API Key,
</span>
</div>
)}
{/* 无本地数据提示 —— 引导用户去数据页同步 (仅当已配置 Key 时显示, 无 Key 时优先提示配置 Key) */}
{hasNoData && !isNoKey && (
<div className="mb-3 flex items-center gap-2 rounded-card border border-warning/40 bg-warning/10 px-3 py-2 text-xs">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
<span className="text-secondary leading-relaxed">
,
<Link
to="/data"
className="ml-1 shrink-0 inline-flex items-center gap-0.5 font-medium text-warning hover:underline"
>
<ArrowUpRight className="h-3 w-3 self-center" />
</Link>
</span>
</div>
{/* 无本地数据常驻引导卡片 —— 一键触发盘后管道获取数据(无 Key 也可) */}
{hasNoData && (
<FetchDataCard
isFetching={isFetching}
isStarting={startFetch.isPending}
fetchFailed={fetchFailed}
stage={fetchStatus.data?.stage}
fetchPct={fetchStatus.data?.progress}
onStart={() => startFetch.mutate()}
isNoKey={isNoKey}
/>
)}
{/* 首次使用自动弹窗(同会话仅一次) */}
<AnimatePresence>
{showWelcomeModal && (
<WelcomeFetchModal
isNoKey={isNoKey}
onClose={() => setShowWelcomeModal(false)}
onStart={() => {
startFetch.mutate()
setShowWelcomeModal(false)
}}
/>
)}
</AnimatePresence>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-card border border-border bg-surface/85 px-3 py-2">
<div className="flex items-center gap-2">
<Gauge className="h-4 w-4 text-accent" />
@@ -720,3 +748,134 @@ export function Dashboard() {
</div>
)
}
// ===== 无数据常驻引导卡片: 一键触发盘后管道获取行情数据(无 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 (
<div className="mb-3 rounded-card border border-border bg-surface/85 p-3.5">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-accent/10 p-2 shrink-0">
<Database className="h-4 w-4 text-accent" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground"></div>
<p className="mt-1 text-xs text-secondary leading-relaxed">
使 1 A K( 5500 ), 1-3 ,
</p>
{isNoKey && (
<p className="mt-1 text-[11px] text-warning/80 leading-relaxed">
API Key, None K,+ Key
</p>
)}
{isFetching ? (
<div className="mt-3">
<div className="flex items-center justify-between text-[11px] text-muted mb-1.5">
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
{isStarting ? '正在启动同步任务…' : stageText}
</span>
<span className="font-mono tabular">
{typeof fetchPct === 'number' ? `${Math.round(fetchPct)}%` : ''}
</span>
</div>
<div className="h-1.5 rounded-full bg-elevated overflow-hidden">
<motion.div
className="h-full bg-accent"
initial={{ width: 0 }}
animate={{ width: `${Math.max(2, Math.min(100, fetchPct ?? 0))}%` }}
transition={{ duration: 0.4, ease: 'easeOut' }}
/>
</div>
</div>
) : fetchFailed ? (
<div className="mt-3 flex items-center gap-2">
<span className="text-xs text-danger">,</span>
<button
onClick={onStart}
className="inline-flex items-center gap-1.5 px-3 h-8 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 transition-colors"
>
<Play className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="mt-3 flex items-center gap-3">
<button
onClick={onStart}
className="inline-flex items-center gap-1.5 px-4 h-8 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 transition-colors"
>
<Play className="h-3.5 w-3.5" />
</button>
<Link
to="/data"
className="inline-flex items-center gap-0.5 text-xs text-secondary hover:text-accent transition-colors"
>
<ArrowUpRight className="h-3 w-3 self-center" />
</Link>
</div>
)}
</div>
</div>
</div>
)
}
// ===== 首次使用自动弹窗: 询问用户后触发盘后管道 =====
function WelcomeFetchModal({
isNoKey, onClose, onStart,
}: {
isNoKey: boolean
onClose: () => void
onStart: () => void
}) {
return (
<SettingsModal title="欢迎首次使用 · 获取行情数据" onClose={onClose}>
<div className="text-center">
<motion.div
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="mx-auto w-fit rounded-2xl bg-accent/10 p-3.5"
>
<Sparkles className="h-7 w-7 text-accent" />
</motion.div>
<h3 className="mt-4 text-base font-semibold text-foreground">使,</h3>
<p className="mt-2 text-xs text-secondary leading-relaxed">
1 A K( 5500 ), 1-3
,
</p>
{isNoKey && (
<div className="mt-3 rounded-btn bg-elevated/60 px-3 py-2 text-[11px] text-muted leading-relaxed">
API Key,None K数据
</div>
)}
<div className="mt-5 flex items-center justify-center gap-2.5">
<button
onClick={onClose}
className="px-4 h-9 rounded-btn text-sm text-secondary hover:text-foreground hover:bg-elevated transition-colors"
>
</button>
<button
onClick={onStart}
className="inline-flex items-center gap-2 px-5 h-9 rounded-xl bg-accent text-white text-sm font-semibold shadow-lg shadow-accent/20 hover:bg-accent/90 transition-all"
>
<Play className="h-4 w-4" />
</button>
</div>
</div>
</SettingsModal>
)
}
+139 -53
View File
@@ -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={
<div className="flex items-center gap-3">
{!hasData && !isLoading && !isNoKey && (
{!hasData && !isLoading && (
<span className="text-xs text-accent animate-pulse">使</span>
)}
{isNoKey ? (
<button
disabled
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium opacity-40 cursor-not-allowed transition-all duration-150"
>
<button
onClick={() => startSync.mutate()}
disabled={isStarting}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium hover:from-accent/35 hover:to-accent/20 disabled:opacity-40 transition-all duration-150"
>
{(isRunning || isStarting) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={() => startSync.mutate()}
disabled={isStarting}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium hover:from-accent/35 hover:to-accent/20 disabled:opacity-40 transition-all duration-150"
>
{(isRunning || isStarting) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="h-3.5 w-3.5" />
)}
{isStarting ? '启动中…' : isRunning ? '同步中…' : '立即同步'}
</button>
)}
)}
{isStarting ? '启动中…' : isRunning ? '同步中…' : '立即同步'}
</button>
<button
onClick={() => setOpenSettings('pipeline-scope')}
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn text-secondary hover:text-accent hover:bg-accent/8 text-xs transition-colors duration-150"
>
<CheckSquare className="h-3.5 w-3.5" />
</button>
<div className="w-px h-4 bg-border" />
<div className="flex items-center gap-1.5">
<button
@@ -334,6 +363,13 @@ export function Data() {
<Wifi className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setOpenSettings('page-settings')}
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn text-secondary hover:text-accent hover:bg-accent/8 text-xs transition-colors duration-150"
>
<SlidersHorizontal className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setShowClearConfirm(true)}
disabled={isRunning}
@@ -348,13 +384,14 @@ export function Data() {
/>
<div className="px-8 py-6 space-y-6 max-w-6xl">
{/* 未配置 API Key 告警条 —— 引导用户去配置 Key 后才能同步 */}
{/* None 档提示 —— 非阻断: 无需 Key 也可获取历史日K, 仅实时行情等扩展能力受限 */}
{isNoKey && (
<div className="flex items-center gap-2 rounded-card border border-warning/40 bg-warning/10 px-3 py-2 text-xs">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
<div className="flex items-center gap-2 rounded-card border border-border bg-elevated/40 px-3 py-2 text-xs">
<Info className="h-4 w-4 shrink-0 text-muted" />
<span className="text-secondary leading-relaxed">
API Key,,
<Link to="/settings?tab=account" className="mx-0.5 font-medium text-warning hover:underline">
None ,使K()
API Key ,
<Link to="/settings?tab=account" className="mx-0.5 font-medium text-accent hover:underline">
</Link>
@@ -411,7 +448,7 @@ export function Data() {
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-[10px] text-muted pb-2 border-b border-border/50">
<span className="text-accent/60 font-medium"></span>
<span></span>
<span></span>
<span className="text-border"></span>
<span className="text-accent/60 font-medium"></span>
{pipelineSteps.map((step, i) => (
@@ -427,7 +464,7 @@ export function Data() {
</div>
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1">
<span className="text-muted"> · </span>
<span className="text-muted"> · </span>
<span className="text-muted/50">·</span>
<span className="font-mono text-secondary">
{`${String(instrumentsSched.hour).padStart(2, '0')}:${String(instrumentsSched.minute).padStart(2, '0')}`}
@@ -548,7 +585,7 @@ export function Data() {
</div>
))
) : [
{ label: '标的维表', files: s?.storage.instruments_files, size: s?.storage.instruments_size_mb },
{ label: '个股维表', files: s?.storage.instruments_files, size: s?.storage.instruments_size_mb },
{ label: '日 K', files: s?.storage.daily_files, size: s?.storage.daily_size_mb },
{ label: '除权因子', files: s?.storage.adj_factor_files, size: s?.storage.adj_factor_size_mb },
{ label: 'Enriched', files: s?.storage.enriched_files, size: s?.storage.enriched_size_mb },
@@ -583,8 +620,9 @@ export function Data() {
<div>
<SectionTitle icon={Database}></SectionTitle>
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 items-stretch">
{cardVisible.instruments && (
<StatCard
title="标的维表"
title="个股维表"
hint="盘前同步 · 元数据快照"
stats={s?.instruments}
isInstrument
@@ -599,6 +637,8 @@ export function Data() {
auto
onShowFields={() => setSchemaTable('instruments')}
/>
)}
{cardVisible.daily && (
<StatCard
title="日 K"
hint="增量同步 · 全市场"
@@ -616,21 +656,8 @@ export function Data() {
onSettings={hasData ? () => setOpenSettings(v => v === 'daily' ? null : 'daily') : undefined}
settingsOpen={openSettings === 'daily'}
/>
<StatCard
title="除权因子"
hint="增量同步 · 全市场"
stats={s?.adj_factor}
loading={isLoading}
active={activeCard === 'adj_factor'}
done={doneStages.has('adj_factor')}
skipped={skippedCards.has('adj_factor')}
stagePct={activeCard === 'adj_factor' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="adj_factor"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('adj_factor')}
/>
)}
{cardVisible.enriched && (
<StatCard
title="Enriched"
hint="复权 OHLCV + 技术指标"
@@ -650,8 +677,10 @@ export function Data() {
onSettings={hasData ? () => setOpenSettings(v => v === 'enriched' ? null : 'enriched') : undefined}
settingsOpen={openSettings === 'enriched'}
/>
)}
{cardVisible.index && (
<StatCard
title="指数数据"
title="指数"
hint="CN_Index · 独立存储"
stats={indexOverviewStats}
loading={isLoading}
@@ -662,7 +691,7 @@ export function Data() {
tierKey="daily"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
auto={indexAuto}
subLabel={indexOverviewLabel}
fieldTabs={[
{ label: '维表', table: 'index_instruments' },
@@ -673,6 +702,44 @@ export function Data() {
onSettings={hasData ? () => setOpenSettings(v => v === 'index' ? null : 'index') : undefined}
settingsOpen={openSettings === 'index'}
/>
)}
{cardVisible.etf && (
<StatCard
title="ETF"
hint="场内基金 · 独立存储"
stats={etfOverviewStats}
loading={isLoading}
tierKey="etf"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={etfAuto}
subLabel="维表 · 日K · 指标"
fieldTabs={[
{ label: '维表', table: 'etf_instruments' },
{ label: '日K', table: 'etf_daily' },
{ label: '指标', table: 'etf_enriched' },
] as FieldTab[]}
onShowFields={(t) => setSchemaTable(t ?? 'etf_daily')}
/>
)}
{cardVisible.adj_factor && (
<StatCard
title="除权因子"
hint="增量同步 · 全市场"
stats={s?.adj_factor}
loading={isLoading}
active={activeCard === 'adj_factor'}
done={doneStages.has('adj_factor')}
skipped={skippedCards.has('adj_factor')}
stagePct={activeCard === 'adj_factor' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="adj_factor"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('adj_factor')}
/>
)}
{cardVisible.minute && (
<StatCard
title="分钟 K"
hint="全市场同步"
@@ -690,6 +757,8 @@ export function Data() {
onSettings={hasData ? () => setOpenSettings(v => v === 'minute' ? null : 'minute') : undefined}
settingsOpen={openSettings === 'minute'}
/>
)}
{cardVisible.financials && (
<StatCard
title="财务数据"
hint="利润表 / 资负表 / 现金流 / 指标"
@@ -699,6 +768,7 @@ export function Data() {
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
/>
)}
{(extConfigs.data?.items ?? []).map((ext) => (
<ExtDataStatCard
key={ext.id}
@@ -796,9 +866,25 @@ export function Data() {
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'pipeline-scope' && (
<SettingsModal title="盘后管道 · 拉取内容" onClose={() => setOpenSettings(null)}>
<PipelineScopeConfig />
</SettingsModal>
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'page-settings' && (
<SettingsModal title="页面设置 · 数据画像卡片" onClose={() => setOpenSettings(null)}>
<PageSettingsModal caps={caps.data?.capabilities} />
</SettingsModal>
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'index' && (
<SettingsModal title="指数数据 · 手动获取" onClose={() => setOpenSettings(null)}>
<SettingsModal title="指数 · 手动获取" onClose={() => setOpenSettings(null)}>
<div className="space-y-4">
<div className="rounded-card border border-border bg-base/30 p-4 space-y-3">
<div>
@@ -936,9 +1022,9 @@ export function Data() {
<span className="text-danger font-medium"></span>
</p>
<ul className="mt-2 text-[11px] text-muted leading-relaxed space-y-0.5">
<li>· K</li>
<li>· K</li>
<li>· Enriched K</li>
<li>· </li>
<li>· ETF</li>
</ul>
<p className="mt-2 text-[11px] text-danger/90">
+5 -1
View File
@@ -278,7 +278,11 @@ export function IndustryAnalysis() {
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? []
const activeConfigId = fieldConfig.configId || pickBestConfig(availableConfigs)
// 用户配置的 configId 可能已失效 (扩展数据被删除), 此时回退到自动选择,
// 避免用失效 ID 请求接口报错; 用户仍可点配置按钮重新选择。
const preferredConfigId = fieldConfig.configId || pickBestConfig(availableConfigs)
const preferredConfig = availableConfigs.find(c => c.id === preferredConfigId)
const activeConfigId = preferredConfig ? preferredConfigId : pickBestConfig(availableConfigs)
const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
const rowsQuery = useQuery({
+120 -62
View File
@@ -13,7 +13,6 @@ import {
ArrowRight,
ArrowLeft,
ExternalLink,
Copy,
Sparkles,
LineChart,
ScanSearch,
@@ -22,6 +21,10 @@ import {
Radar,
ShieldCheck,
BellRing,
TrendingUp,
FileText,
Landmark,
Database,
} from 'lucide-react'
import { api } from '@/lib/api'
import { useCapabilities, useSettings } from '@/lib/useSharedQueries'
@@ -37,12 +40,15 @@ const STEPS = ['欢迎', '配置 Key', '能力探测', '完成'] as const
const BRAND = '#8B5CF6'
const HIGHLIGHTS = [
{ icon: LineChart, title: '看板与自选', desc: '实时行情、MA/MACD 指标、自定义自选列表', tint: 'text-accent' },
{ icon: ScanSearch, title: '策略选股', desc: '内置多套选股策略,一键扫描全市场命中', tint: 'text-bull' },
{ icon: Flame, title: '连板梯队', desc: '涨停板梯队、概念行业热度、市场情绪一览', tint: 'text-warning' },
{ icon: Radar, title: '实时监控', desc: '自定义条件 / 策略监控,触发即推送告警', tint: 'text-bear' },
{ icon: ShieldCheck, title: '回测验证', desc: '策略历史回测、因子分析,用数据说话', tint: 'text-accent' },
{ icon: BellRing, title: '本地优先', desc: '数据本地存储,隐私可控,断网仍可查阅', tint: 'text-bull' },
{ icon: LineChart, title: '看板与自选', desc: '市场全景看板、涨跌分布、情绪雷达,自定义自选列表', tint: 'text-accent' },
{ icon: ScanSearch, title: '策略选股', desc: '内置多套选股策略,一键扫描全市场命中标的', tint: 'text-bull' },
{ icon: TrendingUp, title: '个股分析', desc: 'AI 四维分析个股,关键价位、技术形态一目了然', tint: 'text-warning' },
{ icon: Flame, title: '连板梯队', desc: '涨停梯队、封板强度、炸板监控,情绪温度计', tint: 'text-warning' },
{ icon: Landmark, title: '概念行业', desc: '概念板块、行业维度的资金流向与热度排名', tint: 'text-accent' },
{ icon: FileText, title: '财务分析', desc: 'AI 解读财报,利润、资负、现金流、核心指标', tint: 'text-bear' },
{ icon: ShieldCheck, title: '回测验证', desc: '策略历史回测、因子分析,用数据验证逻辑', tint: 'text-accent' },
{ icon: Radar, title: '实时监控', desc: '自定义条件 / 策略监控,盘中触发即推送告警', tint: 'text-bear' },
{ icon: BellRing, title: '本地优先', desc: '数据本地存储,隐私可控,断网仍可查阅', tint: 'text-bull' },
]
export function Onboarding() {
@@ -180,20 +186,24 @@ function WelcomeStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => voi
,使
</p>
{/* 6 个特性卡片 */}
<div className="mt-8 grid grid-cols-2 sm:grid-cols-3 gap-3 text-left">
{/* 特性卡片 —— 3×3 网格,横向布局压缩高度 */}
<div className="mt-8 grid grid-cols-2 sm:grid-cols-3 gap-2.5 text-left">
{HIGHLIGHTS.map((h, i) => (
<motion.div
key={h.title}
initial={{ opacity: 0, y: 10 }}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.05 * i + 0.1 }}
transition={{ duration: 0.3, delay: 0.04 * i + 0.1 }}
whileHover={{ y: -2 }}
className="group rounded-card border border-border bg-surface/80 backdrop-blur-sm p-3.5 transition-colors hover:border-accent/30"
className="group flex items-start gap-2.5 rounded-card border border-border bg-surface/80 backdrop-blur-sm p-2.5 transition-colors hover:border-accent/30"
>
<h.icon className={`h-5 w-5 ${h.tint} transition-transform group-hover:scale-110`} />
<div className="mt-2 text-sm font-medium text-foreground">{h.title}</div>
<div className="mt-1 text-xs text-muted leading-relaxed">{h.desc}</div>
<div className="rounded-lg bg-elevated/50 p-1.5 shrink-0">
<h.icon className={`h-4 w-4 ${h.tint} transition-transform group-hover:scale-110`} />
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-foreground">{h.title}</div>
<div className="mt-0.5 text-[11px] text-muted leading-snug line-clamp-2">{h.desc}</div>
</div>
</motion.div>
))}
</div>
@@ -225,7 +235,6 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
const [keyInput, setKeyInput] = useState('')
const [revealing, setRevealing] = useState(false)
const [copiedCode, setCopiedCode] = useState(false)
const [saved, setSaved] = useState(false)
const save = useMutation({
@@ -242,7 +251,7 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
},
})
// 已配置 key —— 免费档或付费档都算(只要不是无档 none)
// 已配置 key —— 免费档或付费档都算(只要不是 None)
const alreadyHasKey = settings.data?.mode !== 'none' && settings.data?.mode !== undefined
return (
@@ -254,41 +263,44 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
<h2 className="text-xl font-bold text-foreground"> TickFlow API Key</h2>
</div>
<p className="mt-2.5 text-sm text-secondary leading-relaxed">
Key 使 Key <span className="font-medium text-foreground"> </span>
使K; Key
TickFlow ,
,{' '}
<a
href="mailto:415333856@qq.com"
className="text-accent hover:underline font-medium"
>
415333856@qq.com
</a>
</p>
{/* 注册引导 */}
<div className="mt-5 rounded-card border border-border bg-surface/80 backdrop-blur-sm p-4 text-xs text-secondary leading-relaxed">
Key?{' '}
<a
href="https://tickflow.org/auth/register?ref=V3KDKGXPEA"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline inline-flex items-baseline gap-0.5 font-medium"
>
tickflow.org
<ExternalLink className="h-3 w-3 self-center" />
</a>{' '}
,{' '}
<span className="font-mono font-semibold text-accent inline-flex items-baseline gap-1">
V3KDKGXPEA
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText('V3KDKGXPEA').then(() => {
setCopiedCode(true)
setTimeout(() => setCopiedCode(false), 1500)
})
}}
className="text-muted hover:text-accent transition-colors self-center"
aria-label="复制邀请码"
tabIndex={-1}
>
{copiedCode ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</button>
</span>
,
{/* 档位对比说明 —— None 档 vs Free 档 */}
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{/* None 档 —— 不配置时默认 */}
<div className="rounded-card border border-accent/20 bg-accent/[0.04] p-3">
<div className="flex items-center gap-1.5">
<span className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-bold font-mono bg-accent/15 text-accent/70">None</span>
<span className="text-xs font-medium text-foreground">()</span>
</div>
<ul className="mt-2 space-y-1 text-[11px] text-muted leading-relaxed">
<li>· K数据,</li>
<li>· , 1-2 </li>
<li>· </li>
</ul>
</div>
{/* Free 档 —— 免费注册即可获取 */}
<div className="rounded-card border border-accent/35 bg-accent/[0.08] p-3">
<div className="flex items-center gap-1.5">
<span className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-bold font-mono bg-accent/15 text-accent">Free</span>
<span className="text-xs font-medium text-foreground"></span>
<span className="inline-flex items-center rounded-full bg-accent px-1.5 py-0.5 text-[10px] font-bold text-white shadow-sm shadow-accent/30"></span>
</div>
<ul className="mt-2 space-y-1 text-[11px] text-secondary leading-relaxed">
<li>· ,</li>
<li>· K + </li>
<li>· </li>
</ul>
</div>
</div>
{/* Key 已配置提示 */}
@@ -302,6 +314,27 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
</div>
)}
{/* 获取 Key 的说明 —— 黄框卡片 */}
<div className="mt-4 flex items-start gap-2 rounded-card border border-warning/40 bg-warning/10 px-3 py-2.5 text-xs text-foreground leading-relaxed">
<AlertCircle className="h-4 w-4 shrink-0 text-warning mt-px" />
<span>
Key {' '}
<a
href="https://tickflow.org/auth/register?ref=V3KDKGXPEA"
target="_blank"
rel="noreferrer"
className="text-warning hover:underline inline-flex items-baseline gap-0.5 font-medium"
>
tickflow.org
<ExternalLink className="h-3 w-3 self-center" />
</a>
<span className="block mt-1.5 text-foreground/70">
TickFlow ,
</span>
</span>
</div>
{/* 输入 */}
<form
onSubmit={(e) => {
@@ -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
<div className="mx-auto w-fit rounded-xl bg-elevated p-3">
<Zap className="h-6 w-6 text-warning" />
</div>
<div className="mt-3 text-sm font-medium text-foreground"></div>
<div className="mt-3 text-sm font-medium text-foreground"> None </div>
<p className="mt-2 text-xs text-muted leading-relaxed max-w-sm mx-auto">
Key,使K数据 Key ,
<span className="text-foreground font-medium"> </span>
Key,使 1 K数据 Key ,
<span className="text-foreground font-medium"> </span>
</p>
</div>
)}
@@ -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: (
<h1 className="mt-6 text-2xl font-bold text-foreground">!</h1>
<p className="mt-2.5 text-sm text-secondary leading-relaxed max-w-md mx-auto">
,
<span className="text-foreground font-medium"> </span>
{hasKey
? 'Key 已生效,进入面板后系统会自动引导你获取行情数据,完成后即可使用全部功能。'
: '当前为 None 档,进入面板后系统会自动引导你获取历史日K数据(无需 Key),即可开始体验。'}
</p>
{/* 快速上手提示 */}
<div className="mt-6 space-y-2 text-left">
{/* 首要行动:获取数据 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
className="mt-5 flex items-start gap-2.5 rounded-card border border-accent/30 bg-accent/[0.06] px-4 py-3 text-left"
>
<div className="rounded-lg bg-accent/15 p-1.5 shrink-0 mt-px">
<Database className="h-4 w-4 text-accent" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">下一步:获取行情数据</div>
<p className="mt-1 text-xs text-secondary leading-relaxed">
, 1 A K( 5500 , 1-3 )
</p>
</div>
</motion.div>
{/* 快速上手入口 */}
<div className="mt-4 space-y-2 text-left">
{tips.map((t, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.1 * i + 0.2 }}
transition={{ duration: 0.3, delay: 0.1 * i + 0.3 }}
className="flex items-center gap-3 rounded-card border border-border bg-surface/80 backdrop-blur-sm px-3.5 py-2.5"
>
<div className="rounded-lg bg-accent/10 p-1.5 shrink-0">
+662
View File
@@ -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<Phase>('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<AiReviewReport | null>(null) // 查看历史报告
const abortRef = useRef<AbortController | null>(null)
const reportEndRef = useRef<HTMLDivElement>(null)
// 看板数据(与总览页同源)
const marketQuery = useQuery<OverviewMarket>({
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 (
<>
<PageHeader
title="AI 复盘"
titleExtra={<Sparkles className="h-4 w-4 text-accent" />}
subtitle={`${displayDate}${data?.emotion ? ` · 情绪 ${data.emotion.label}` : ''}`}
right={
<div className="flex items-center gap-1">
<button
onClick={() => { marketQuery.refetch() }}
disabled={marketQuery.isFetching}
className="inline-flex items-center gap-1 rounded-btn border border-border bg-elevated px-2 py-1 text-[11px] text-secondary transition-colors hover:text-foreground disabled:opacity-50"
title="刷新看板数据"
>
<RefreshCw className={cn('h-3 w-3', marketQuery.isFetching && 'animate-spin')} />
</button>
<button
onClick={generate}
disabled={isGenerating}
className={cn(
'inline-flex items-center gap-1.5 rounded-btn px-3.5 py-1.5 text-xs font-medium transition-all',
isGenerating
? 'border border-accent/40 bg-accent/10 text-accent cursor-not-allowed'
: 'bg-accent text-white shadow-sm shadow-accent/25 hover:bg-accent/90 hover:shadow hover:shadow-accent/30',
)}
>
{isGenerating ? (
<><RefreshCw className="h-3.5 w-3.5 animate-spin" /></>
) : (
<><Sparkles className="h-3.5 w-3.5" /></>
)}
</button>
</div>
}
/>
<div className="min-h-full bg-[radial-gradient(circle_at_15%_-5%,rgba(59,130,246,0.10),transparent_30%),radial-gradient(circle_at_85%_5%,rgba(139,92,246,0.08),transparent_30%)] px-4 py-4 sm:px-6">
<div className="mx-auto max-w-[1440px] space-y-4">
{marketQuery.isLoading && !data ? (
<div className="flex h-40 items-center justify-center">
<div className="flex items-center gap-2 text-sm text-muted">
<RefreshCw className="h-4 w-4 animate-spin" />
</div>
</div>
) : !data || !data.as_of ? (
<div className="flex flex-col items-center justify-center gap-4 rounded-card border border-border bg-surface/80 px-6 py-16">
<div className="relative">
<div className="grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-accent/20 to-purple-500/15 border border-accent/30">
<Database className="h-6 w-6 text-accent" strokeWidth={1.8} />
</div>
</div>
<div className="text-center">
<div className="text-sm font-medium text-foreground"></div>
<p className="mt-1 text-xs text-muted"> K ,</p>
</div>
<Link
to="/data"
className="inline-flex items-center gap-1.5 rounded-btn bg-accent px-4 py-2 text-xs font-medium text-white shadow-sm transition-all hover:bg-accent/90 hover:shadow"
>
<Database className="h-3.5 w-3.5" />
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</div>
) : (
<>
{/* ===== 指数行情条(对齐 Dashboard IndexTicker) ===== */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{data.indices.map(item => <IndexTicker key={item.symbol} item={item} />)}
</div>
{/* ===== KPI 网格 ===== */}
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 xl:grid-cols-6">
<KpiCell label="涨 / 平 / 跌" value={<><span className="text-bull">{data.breadth.up}</span><span className="text-muted">/</span><span className="text-muted">{data.breadth.flat}</span><span className="text-muted">/</span><span className="text-bear">{data.breadth.down}</span></>} sub={`上涨率 ${data.breadth.up_pct.toFixed(1)}%`} />
<KpiCell label="涨停 / 跌停" value={<><span className="text-bull">{data.limit.limit_up}</span><span className="text-muted">/</span><span className="text-bear">{data.limit.limit_down}</span></>} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}% · 炸板 ${data.limit.broken ?? 0}`} />
<KpiCell label="最高连板" value={`${data.limit.max_boards || 0}`} sub={`梯队 ${data.limit.tiers.length}`} tone="accent" />
<KpiCell label="两市成交" value={`${((data.amount.total ?? 0) / 1e8).toFixed(0)}亿`} sub={`均额 ${((data.amount.avg ?? 0) / 1e8).toFixed(1)}亿`} />
<KpiCell label="换手 / 量比" value={`${fmtPrice(data.activity.avg_turnover, 1)}% / ${fmtPrice(data.activity.vol_ratio, 2)}`} sub={`高换手 ${data.activity.high_turnover}`} tone="accent" />
<KpiCell label="MA5 / 20 / 60" value={`${data.trend.above_ma5_pct.toFixed(0)}%`} sub={`${data.trend.above_ma20_pct.toFixed(0)}% / ${data.trend.above_ma60_pct.toFixed(0)}%`} />
</div>
{/* ===== 情绪雷达 + 板块排名 双栏 ===== */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
<EmotionSection data={data} />
<SectorSection title="概念板块" rank={data.concept_rank} tone="concept" />
<SectorSection title="行业板块" rank={data.industry_rank} tone="industry" />
</div>
{/* ===== 关注点输入 ===== */}
<div className="flex items-center gap-2 rounded-card border border-border bg-surface/80 px-3.5 py-2.5 transition-colors focus-within:border-accent/40">
<Wand2 className="h-3.5 w-3.5 shrink-0 text-accent" />
<input
value={focus}
onChange={(e) => 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 && (
<button onClick={() => setFocus('')} className="text-xs text-muted transition-colors hover:text-foreground"></button>
)}
</div>
{/* ===== 报告 + 历史 双栏 ===== */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[1fr_18rem]">
<ReportPanel
phase={phase}
content={content}
error={error}
isGenerating={isGenerating}
viewing={viewing}
onSave={saveCurrent}
onRegenerate={generate}
reportEndRef={reportEndRef}
/>
<HistoryPanel
reports={historyQuery.data?.reports ?? []}
loading={historyQuery.isLoading}
viewingId={viewing?.id ?? null}
onView={viewReport}
onDelete={(id) => deleteMut.mutate(id)}
/>
</div>
</>
)}
</div>
</div>
</>
)
}
// ================================================================
// 指数行情卡(对齐 Dashboard IndexTicker)
// ================================================================
function IndexTicker({ item }: { item: OverviewMarket['indices'][number] }) {
const pct = item.change_pct
const isUp = (pct ?? 0) >= 0
return (
<div className="grid min-w-0 grid-cols-[1fr_auto] items-center gap-x-2 gap-y-0.5 rounded-card border border-border bg-surface/80 px-3 py-2 transition-colors hover:border-accent/40">
<div className="truncate text-xs font-medium text-foreground">{item.name || item.symbol}</div>
<div className={cn('font-mono text-xs font-semibold tabular-nums', pctClass(pct))}>{fmtPctAlready(pct, 2, true)}</div>
<div className="font-mono text-[10px] text-muted">{item.symbol}</div>
<div className={cn('flex items-center gap-0.5 font-mono text-[11px] tabular-nums', pctClass(pct))}>
{isUp ? <ArrowUpRight className="h-3 w-3" /> : <ArrowDownRight className="h-3 w-3" />}
{fmtPrice(item.last_price)}
</div>
</div>
)
}
// ================================================================
// 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 (
<div className="min-w-0 rounded-card border border-border bg-surface/80 px-3 py-2">
<div className="flex items-center gap-1 text-[11px] text-muted">{label}</div>
<div className={cn('mt-1 truncate font-mono text-base font-semibold leading-none tabular-nums', isPlain ? color : 'text-foreground')}>{value}</div>
{sub && <div className="mt-1 truncate text-[10px] text-muted">{sub}</div>}
</div>
)
}
// ================================================================
// 章节标题(对齐 Dashboard SectionTitle)
// ================================================================
function SectionTitle({ icon: Icon, title, hint }: { icon: typeof Activity; title: string; hint?: React.ReactNode }) {
return (
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 text-accent" />
<h2 className="text-xs font-semibold text-foreground">{title}</h2>
</div>
{hint && <span className="font-mono text-[10px] text-muted">{hint}</span>}
</div>
)
}
// ================================================================
// 情绪雷达章节(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 (
<section
className="rounded-card border bg-surface/80 p-3"
style={{ borderColor: `${color}40` }}
>
<SectionTitle icon={Sparkles} title="情绪雷达" hint={`评分 ${score} · ${data.emotion.label}`} />
{radar.length === 0 ? (
<div className="flex h-44 items-center justify-center text-xs text-muted"></div>
) : (
<div className="flex justify-center">
<svg viewBox={`0 0 ${size} ${size}`} className="h-52 w-full">
<defs>
<radialGradient id="reviewRadarFill" cx="50%" cy="45%" r="70%">
<stop offset="0%" stopColor={`${color}57`} />
<stop offset="100%" stopColor={`${color}1f`} />
</radialGradient>
<radialGradient id="reviewRadarCenter" cx="50%" cy="50%" r="55%">
<stop offset="0%" stopColor="rgba(24,24,27,0.92)" />
<stop offset="68%" stopColor="rgba(24,24,27,0.70)" />
<stop offset="100%" stopColor="rgba(24,24,27,0)" />
</radialGradient>
</defs>
{gridPolygons.map(g => (
<polygon
key={g.level}
points={g.points}
fill={g.idx % 2 === 0 ? 'rgba(33,33,38,0.26)' : 'rgba(24,24,27,0.16)'}
stroke={g.level === 1 ? 'rgba(148,163,184,0.22)' : 'rgba(148,163,184,0.12)'}
strokeWidth={g.level === 1 ? 1.2 : 0.8}
/>
))}
{points.map(p => <line key={p.key} x1={cx} y1={cy} x2={p.gx} y2={p.gy} stroke="rgba(148,163,184,0.08)" />)}
<polygon points={polygon} fill="url(#reviewRadarFill)" stroke={color} strokeWidth="2" />
{points.map(p => <circle key={p.key} cx={p.x} cy={p.y} r="2.8" fill={color} stroke="rgba(24,24,27,0.9)" strokeWidth="1" />)}
<circle cx={cx} cy={cy} r="26" fill="url(#reviewRadarCenter)" />
<text x={cx} y={cy + 6} textAnchor="middle" className="fill-foreground font-mono text-[22px] font-bold">{score}</text>
{points.map(p => (
<text key={`${p.key}-label`} x={p.lx} y={p.ly + 4} textAnchor="middle" className="fill-secondary text-[9px] font-medium">{p.label}</text>
))}
</svg>
</div>
)}
</section>
)
}
// ================================================================
// 板块排名章节(领涨/领跌)
// ================================================================
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 (
<section className="rounded-card border border-border bg-surface/80 p-3">
<SectionTitle icon={tone === 'concept' ? Layers : BarChart3} title={title} hint="领涨/领跌" />
{!hasData ? (
<div className="py-6 text-center text-[11px] text-muted"></div>
) : (
<div className="grid grid-cols-2 gap-2">
<RankColumn rows={leading} tone="bull" />
<RankColumn rows={lagging} tone="bear" />
</div>
)}
</section>
)
}
function RankColumn({ rows, tone }: { rows: OverviewMarket['concept_rank']['leading']; tone: 'bull' | 'bear' }) {
return (
<div className="min-w-0 space-y-1">
<div className={cn('text-[10px] font-medium', tone === 'bull' ? 'text-bull' : 'text-bear')}>
{tone === 'bull' ? '领涨' : '领跌'}
</div>
{rows.slice(0, 5).map((r, idx) => (
<div key={`${r.name}-${idx}`} className="grid grid-cols-[14px_1fr_auto] items-center gap-1 rounded bg-elevated/40 px-1.5 py-1">
<span className="text-center font-mono text-[9px] text-muted">{idx + 1}</span>
<div className="min-w-0">
<div className="truncate text-[11px] text-foreground" title={r.name}>{r.name}</div>
<div className="truncate text-[9px] text-muted">{r.count} · {r.leader?.name ?? '—'}</div>
</div>
<div className={cn('font-mono text-[10px] font-semibold tabular-nums', pctClass(r.avg_pct))}>
{fmtPctAlready((r.avg_pct ?? 0) * 100, 2, true)}
</div>
</div>
))}
{rows.length === 0 && <div className="rounded border border-dashed border-border py-3 text-center text-[10px] text-muted"></div>}
</div>
)
}
// ================================================================
// 报告面板(流式 + 错误 + 历史/完成态)
// ================================================================
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<HTMLDivElement>
}) {
if (phase === 'error') {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-card border border-border bg-surface/80 px-6 py-14">
<div className="grid h-12 w-12 place-items-center rounded-full bg-danger/10">
<AlertTriangle className="h-5 w-5 text-danger" />
</div>
<div className="text-sm font-medium text-foreground"></div>
<div className="max-w-md text-center text-xs text-secondary">{error || '请检查 AI 配置后重试'}</div>
<button
onClick={onRegenerate}
className="mt-1 inline-flex items-center gap-1.5 rounded-btn bg-accent/15 px-3 py-1.5 text-xs text-accent transition-colors hover:bg-accent/20"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)
}
if (phase === 'idle' && !content) {
return (
<div className="flex min-h-[24rem] flex-col items-center justify-center gap-4 rounded-card border border-border bg-surface/80 px-6 py-12">
<div className="relative">
<div className="grid h-16 w-16 place-items-center rounded-2xl bg-gradient-to-br from-accent/20 to-purple-500/15 border border-accent/30">
<BookOpenCheck className="h-7 w-7 text-accent" strokeWidth={1.8} />
</div>
</div>
<div className="text-center">
<div className="text-sm font-medium text-foreground">AI </div>
<p className="mx-auto mt-1.5 max-w-sm text-xs leading-relaxed text-muted">
,,
</p>
</div>
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-muted">
<Sparkles className="h-3 w-3 text-accent" />
· ·
</div>
</div>
)
}
const showCursor = isGenerating
const showSave = phase === 'done' && !!content && !viewing
const showViewingTag = !!viewing
const isLoading = phase === 'loading' && !content
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="overflow-hidden rounded-card border border-border bg-surface/80"
>
<div className="flex items-center justify-between border-b border-border bg-gradient-to-r from-accent/5 to-transparent px-4 py-2.5">
<div className="flex items-center gap-1.5">
{isGenerating ? <RefreshCw className="h-3.5 w-3.5 animate-spin text-accent" /> : <BookOpenCheck className="h-3.5 w-3.5 text-accent" />}
<span className="text-xs font-medium text-foreground">
{showViewingTag ? `历史复盘 · ${viewing!.as_of}` : isGenerating ? 'AI 正在复盘…' : '复盘报告'}
</span>
</div>
{showSave && (
<button onClick={onSave} className="inline-flex items-center gap-1 rounded-btn bg-accent/10 px-2 py-1 text-[11px] text-accent transition-colors hover:bg-accent/20">
<History className="h-3 w-3" />
</button>
)}
</div>
<div className="max-h-[calc(100vh-26rem)] overflow-y-auto px-5 py-4">
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<div className="relative">
<div className="grid h-11 w-11 place-items-center rounded-full bg-gradient-to-br from-accent/20 to-purple-500/15 border border-accent/30">
<Sparkles className="h-5 w-5 animate-pulse text-accent" />
</div>
<RefreshCw className="absolute -inset-1 h-13 w-13 animate-spin text-accent/30" style={{ animationDuration: '3s' }} />
</div>
<div className="text-xs text-secondary">AI </div>
<div className="text-[10px] text-muted"> · · · · </div>
</div>
) : (
<div className="prose prose-invert max-w-none">
<MarkdownRenderer content={content} />
{showCursor && (
<span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-accent align-middle" />
)}
</div>
)}
<div ref={reportEndRef} />
</div>
</motion.div>
)
}
// ================================================================
// 历史面板
// ================================================================
function HistoryPanel({
reports, loading, viewingId, onView, onDelete,
}: {
reports: AiReviewReport[]
loading: boolean
viewingId: string | null
onView: (r: AiReviewReport) => void
onDelete: (id: string) => void
}) {
return (
<div className="overflow-hidden rounded-card border border-border bg-surface/80">
<div className="flex items-center gap-1.5 border-b border-border bg-gradient-to-r from-accent/5 to-transparent px-3 py-2.5">
<History className="h-3.5 w-3.5 text-accent" />
<span className="text-xs font-medium text-foreground"></span>
<span className="font-mono text-[10px] text-muted">({reports.length})</span>
</div>
<div className="max-h-[calc(100vh-26rem)] overflow-y-auto p-2">
{loading ? (
<div className="grid h-20 place-items-center"><RefreshCw className="h-4 w-4 animate-spin text-muted" /></div>
) : reports.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 px-3 py-10 text-center">
<History className="h-7 w-7 text-muted/40" strokeWidth={1.5} />
<div className="text-[11px] text-muted"></div>
<div className="text-[10px] text-muted/60"></div>
</div>
) : (
<div className="space-y-1">
{reports.map((r) => {
const color = scoreColor(r.emotion_score)
return (
<div
key={r.id}
className={cn(
'group flex items-center gap-2 rounded px-2 py-2 cursor-pointer transition-colors',
viewingId === r.id ? 'bg-accent/10 ring-1 ring-accent/20' : 'hover:bg-elevated/60',
)}
onClick={() => onView(r)}
>
<div
className="grid h-8 w-8 shrink-0 place-items-center rounded font-mono text-[10px] font-bold tabular-nums"
style={{ color, backgroundColor: `${color}1a` }}
>
{r.emotion_score ?? '—'}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-[11px] font-medium text-foreground">{r.emotion_label ?? '—'}</span>
<span className="font-mono text-[10px] text-muted">{r.as_of}</span>
</div>
<div className="mt-0.5 truncate text-[10px] text-muted">
{r.summary ?? r.content.slice(0, 40)}
</div>
</div>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted opacity-0 transition-opacity group-hover:opacity-100" />
<button
onClick={(e) => { e.stopPropagation(); onDelete(r.id) }}
className="shrink-0 p-1 text-muted opacity-0 transition-all hover:text-bear group-hover:opacity-100"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
})}
</div>
)}
</div>
</div>
)
}
+32 -8
View File
@@ -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() {
</button>
</div>
) : (
<button
onClick={() => setConfirmRemove(r.symbol)}
className="p-0.5 text-muted hover:text-danger transition-colors duration-150 ease-smooth"
aria-label="移除"
>
<Minus className="h-3.5 w-3.5" />
</button>
<div className="flex items-center gap-1">
<button
onClick={() => setConfirmRemove(r.symbol)}
className="p-0.5 text-muted hover:text-danger transition-colors duration-150 ease-smooth"
aria-label="移除"
title="移除"
>
<Minus className="h-3.5 w-3.5" />
</button>
<button
onClick={() => moveToTop.mutate(r.symbol)}
disabled={moveToTop.isPending || allSymbols[0] === r.symbol}
className="p-0.5 text-muted hover:text-accent transition-colors duration-150 ease-smooth disabled:opacity-30 disabled:hover:text-muted"
aria-label="移到顶部"
title="移到顶部"
>
<ChevronsUp className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</div>
+10 -29
View File
@@ -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() {
</a>{' '}
API Key ,,
</p>
<p className="text-xs text-secondary leading-relaxed mb-4">
{' '}
<span className="font-mono font-semibold text-accent inline-flex items-baseline gap-1">
V3KDKGXPEA
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText('V3KDKGXPEA').then(() => {
setCopiedCode(true)
setTimeout(() => setCopiedCode(false), 1500)
})
}}
className="text-muted hover:text-accent transition-colors duration-150 ease-smooth self-center"
aria-label="复制邀请码"
tabIndex={-1}
>
{copiedCode ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</button>
</span>
,
</p>
{/* 当前状态 */}
<div className="flex items-center justify-between mb-4">
@@ -131,7 +108,7 @@ export function SettingsKeysPanel() {
) : (
<>
<AlertCircle className="h-4 w-4 text-muted shrink-0" />
<span className="text-sm font-medium text-muted"> · Free </span>
<span className="text-sm font-medium text-muted"></span>
</>
)}
</div>
@@ -220,7 +197,7 @@ export function SettingsKeysPanel() {
<div className="mt-3 text-xs text-bear flex items-center gap-1.5">
<CheckCircle2 className="h-3 w-3" />
{save.data.tier_label}
{save.data.mode === 'free' && '(免费档 · 历史日K)'}
{save.data.mode === 'free' && '(免费档 · 历史日K + 自选实时监控)'}
</div>
)}
</Card>
@@ -341,7 +318,7 @@ export function SettingsKeysPanel() {
<div className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6">
<h3 className="text-sm font-medium text-foreground mb-2"> API Key</h3>
<p className="text-xs text-secondary mb-5">
退(K), Key
退 None (K), Key
</p>
<div className="flex items-center justify-end gap-2">
<button
@@ -407,18 +384,22 @@ function TierHelpPopover({ currentLabel }: { currentLabel: string }) {
return (
<div key={t} className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={s.dotStyle} />
<span className="font-mono font-bold w-12 shrink-0" style={s.labelTextStyle}>{t === 'none' ? '' : t}</span>
<span className="font-mono font-bold w-12 shrink-0" style={s.labelTextStyle}>{t === 'none' ? 'None' : t}</span>
<span className="text-secondary">{s.desc}</span>
</div>
)
})}
</div>
<div className="mb-3 rounded-btn border border-warning/30 bg-warning/10 px-2.5 py-1.5 text-[11px] font-medium text-warning">
</div>
{/* 检测说明 */}
<div className="text-secondary space-y-1.5">
<div className="font-medium text-foreground"></div>
<p> Key 后系统会在付费端点逐一试探数据能力:连单只日K都拿不到则判为( Key);K但无复权因子则判为Free; Starter/Pro/Expert</p>
<p className="text-muted">(K), Key,</p>
<p> Key 后系统会在付费端点逐一试探数据能力:连单只日K都拿不到则判为None( Key);K但无复权因子则判为Free; Starter/Pro/Expert</p>
<p className="text-muted">None Free (K), Key,</p>
</div>
</motion.div>
</>
+76 -13
View File
@@ -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<string, unknown>) => {
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 (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
@@ -135,8 +158,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
</div>
<h2 className="text-lg font-medium text-foreground mb-2"></h2>
<p className="text-sm text-secondary max-w-md mb-6">
Starter
Free None 使 free-api K1-2
</p>
<a
href="/settings?tab=account"
@@ -167,10 +189,12 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
<div className="flex items-center justify-between gap-4 py-1">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted"></div>
<div className="text-[11px] text-muted">
{isFreeTier ? '每轮拉取自选股实时行情的时间间隔' : '每轮拉取全市场行情的时间间隔'}
</div>
</div>
<span className="text-[11px] font-mono text-foreground shrink-0 tabular-nums">
{interval < 1 ? interval.toFixed(1) : interval.toFixed(0)}s
{intervalDraft < 1 ? intervalDraft.toFixed(1) : intervalDraft.toFixed(0)}s
</span>
</div>
<div className="flex items-center gap-3 mt-2">
@@ -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"
/>
<span className="text-[10px] text-muted shrink-0">
{minInterval}s {maxInterval}s
{intervalDraft !== interval ? '2秒后保存' : `${minInterval}s — ${maxInterval}s`}
</span>
</div>
</div>
</Card>
{/* 页面刷新 */}
{isFreeTier && (
<Card icon={Activity} title="自选股实时">
<div className="mb-3 rounded-btn border border-accent/25 bg-accent/10 px-3 py-2 text-xs font-medium leading-snug text-accent">
Free 5 6
</div>
{watchlistSymbols.length > 0 ? (
<div className="space-y-1.5">
{watchlistSymbols.map(symbol => {
const name = watchlistNameBySymbol.get(symbol)
return (
<div key={symbol} className="flex items-center justify-between rounded-btn bg-base/50 border border-border px-2 py-1.5">
<div className="min-w-0 flex items-baseline gap-1.5">
<span className="text-xs font-mono text-foreground">{symbol}</span>
{name && <span className="truncate text-[11px] text-secondary">{name}</span>}
</div>
<span className="text-[10px] text-muted shrink-0"></span>
</div>
)
})}
</div>
) : (
<div className="rounded-btn border border-border bg-base/40 px-3 py-3 text-xs text-muted">
Free
</div>
)}
<div className="mt-2 flex items-center justify-between gap-3">
<span className="text-[10px] text-muted"> {watchlistSymbols.length}/5 </span>
<Link
to="/watchlist"
className="px-3 py-1 rounded-btn bg-elevated text-secondary text-xs font-medium hover:text-foreground transition-colors"
>
</Link>
</div>
</Card>
)}
{!isFreeTier && (
<Card icon={Wifi} title="页面实时刷新">
<p className="text-xs text-secondary mb-4">
SSE
@@ -208,7 +268,9 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
))}
</div>
</Card>
)}
{!isFreeTier && (
<Card icon={BarChart3} title="左侧菜单指数">
<p className="text-xs text-secondary mb-4">
@@ -233,6 +295,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
/>
</div>
</Card>
)}
</div>
{/* ========== 右列 ========== */}
+2
View File
@@ -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: <ConceptAnalysis /> },
{ path: 'industry-analysis', element: <IndustryAnalysis /> },
{ path: 'stock-analysis', element: <StockAnalysis /> },
{ path: 'review', element: <Review /> },
{ path: 'watchlist', element: <Watchlist /> },
{ path: 'screener', element: <Screener /> },
{ path: 'backtest', element: <Backtest /> },
+7 -6
View File
@@ -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 }