diff --git a/docs/market-insights-roadmap.md b/docs/market-insights-roadmap.md index 49b3d01..73c0562 100644 --- a/docs/market-insights-roadmap.md +++ b/docs/market-insights-roadmap.md @@ -10,11 +10,11 @@ | ② | **涨停生态 / 连板天梯** `/limitup` | 连板高度、首板/二板分布、炸板率、跌停 | 本地 vipdoc .day 文件(strength 扫描器同款读取器),close==涨停价 连续天数可回算 | ✅ 第二批 | | ① | **市场情绪时间线** `/sentiment` | 情绪处于冰点/回暖/高潮/退潮 | 涨跌家数、涨停跌停数逐分钟采样(新采样器 + sqlite) | ✅ 第三批 | | ⑨ | **市场宽度分时** `/sentiment` | 指数新高但上涨家数背离的顶部信号 | 依赖 ① 的采样器 | ✅ 第三批(随①) | -| ⑥ | **板块资金日历** | 哪天钱涌向了哪个板块 | board summary 主力净额逐日采样 | ⏳ 后续批次 | +| ⑥ | **板块资金日历** `/sentiment` | 哪天钱涌向了哪个板块 | FundFlowSampler 每交易日 14:45 后采一次行业主力净流入 Top10(涨幅前 50 名口径) | ✅ 收官 | | ⑤ | **板块相关性热力图** `/hotspots` | 哪些板块同涨同跌(抱团 vs 分散) | 热点滚动已缓存的 60 日涨跌矩阵求两两相关 | ✅ 第四批(热点滚动页内「相关性」视图) | | ③ | ~~异动雷达时间线~~ | ~~异动密度骤增 = 盘面转折点~~ | `/mac/unusual` 盘中数据源质量差(盘中几乎无记录),**整项取消**(页面/导航/端点已移除) | ❌ 已取消 | | ⑧ | **量能仪表盘** `/sentiment` | 放量/缩量(两市累计成交 vs 5日同期均值) | 指数 5 分钟线现成 | ✅ 第四批(并入市场情绪页) | -| ⑩ | **AI 盘面早报/复盘** | 把以上所有数据"自动读"给你听 | LLM 管道 + ai-history 归档现成 | ⏳ 收尾(必须做) | +| ⑩ | **AI 盘面复盘** `/sentiment` | 把以上所有数据"自动读"给你听 | LLM 管道 + ai-history 归档现成 | ✅ 收官(情绪页「生成 AI 复盘」按钮,异步任务 + 自动归档) | ## 批次 diff --git a/src/easy_tdx/web/app.py b/src/easy_tdx/web/app.py index 0b3af2a..ea94a44 100644 --- a/src/easy_tdx/web/app.py +++ b/src/easy_tdx/web/app.py @@ -173,6 +173,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: ex_client = None app.state.ex_client = ex_client + # --- 板块资金采样器(交易日 14:45 后记一次行业主力净流入排行,供资金日历) --- + app.state.fund_flow_sampler = None + if mac_client is not None: + try: + from easy_tdx.web.sentiment_sampler import FundFlowSampler + + fund_sampler = FundFlowSampler(mac_client) + fund_sampler.start() + app.state.fund_flow_sampler = fund_sampler + logger.info("FundFlowSampler 已启动") + except Exception: + logger.warning("FundFlowSampler 启动失败 — 资金日历不可用", exc_info=True) + # --- 市场情绪采样器(交易时段每分钟落一条广度快照,供 /market/sentiment/*) --- # 依赖标准 TDX 客户端(get_market_stat),mock 模式缩短间隔让曲线快速成形。 app.state.sentiment_sampler = None @@ -191,6 +204,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: yield + # --- 停止板块资金采样器 --- + fund_svc = getattr(app.state, "fund_flow_sampler", None) + if fund_svc is not None: + try: + await fund_svc.stop() + except Exception: + logger.warning("FundFlowSampler stop failed", exc_info=True) + # --- 停止市场情绪采样器 --- sampler_svc = getattr(app.state, "sentiment_sampler", None) if sampler_svc is not None: diff --git a/src/easy_tdx/web/routers/market.py b/src/easy_tdx/web/routers/market.py index edba875..10f7e7a 100644 --- a/src/easy_tdx/web/routers/market.py +++ b/src/easy_tdx/web/routers/market.py @@ -195,6 +195,21 @@ async def limitup_history( return DictResponse.from_dict(payload) +@router.get("/market/board-fund/history", response_model=DictResponse) +async def board_fund_history( + days: int = Query(15, ge=1, le=90, description="返回交易日数"), +) -> DictResponse: + """行业主力净流入逐日排行(FundFlowSampler 每交易日 14:45 后采样一条)。 + + 口径:涨幅前 50 名行业中主力净流入最高的 10 个(逐板块 summary 太贵, + 非全市场严格排序)。数据需采样积累,页面空态有明示。 + """ + from easy_tdx.web.sentiment_store import get_sentiment_store + + days_rows = get_sentiment_store().list_fund_days(days) + return DictResponse.from_dict({"count": len(days_rows), "days": days_rows}) + + @router.get("/fund-flow", response_model=DataFrameResponse) async def fund_flow( market: str = Query(..., description="市场: SZ, SH"), diff --git a/src/easy_tdx/web/sentiment_sampler.py b/src/easy_tdx/web/sentiment_sampler.py index ae74494..f36e6b5 100644 --- a/src/easy_tdx/web/sentiment_sampler.py +++ b/src/easy_tdx/web/sentiment_sampler.py @@ -97,3 +97,81 @@ class SentimentSampler: } ) self.samples += 1 + + +class FundFlowSampler: + """每日收盘前记录一次行业主力净流入排行(板块资金日历数据源)。 + + 采样窗口:交易时段内 14:45 之后(临近收盘的净流入已基本定型), + 每日只采一次(``latest_fund_date`` 幂等)。数据走 MAC + ``get_board_ranking(sort_by="main_net_amount")``——该实现先按涨幅 + 取候选池再聚合 summary,因此口径是"涨幅前 ``top_n`` 名中主力净流入 + 最高的 ``keep`` 个行业",并非全市场严格排序(逐板块 summary 太贵)。 + """ + + def __init__( + self, + client: Any, + store: SentimentStore | None = None, + interval: float = 300.0, + top_n: int = 50, + keep: int = 10, + ): + self._client = client + self._store = store or get_sentiment_store() + self._interval = interval + self._top_n = top_n + self._keep = keep + self._task: asyncio.Task | None = None + + def start(self) -> None: + if self._task is None or self._task.done(): + self._task = asyncio.create_task(self._run()) + + async def stop(self) -> None: + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _run(self) -> None: + logger.info("FundFlowSampler 启动(间隔 %ss,交易日 14:45 后每日一条)", self._interval) + while True: + try: + now = datetime.now() + if is_trading_time(now) and (now.hour * 100 + now.minute) >= 1445: + await self._sample_once() + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 — 采样器永不退出 + logger.warning("板块资金采样失败", exc_info=True) + await asyncio.sleep(self._interval) + + async def _sample_once(self) -> None: + from easy_tdx.mac.enums import BoardType + + today = int(datetime.now().strftime("%Y%m%d")) + if self._store.latest_fund_date() == today: + return # 当日已采样 + df = await self._client.get_board_ranking( + board_type=BoardType.HY, + top_n=self._top_n, + sort_by="main_net_amount", + ascending=False, + ) + if df is None or df.empty: + return + ranked = df.sort_values("main_net_amount", ascending=False).head(self._keep) + boards = [ + { + "code": str(r["code"]), + "name": str(r.get("name", r["code"])), + "main_net": round(float(r["main_net_amount"]), 0), + } + for _, r in ranked.iterrows() + ] + self._store.upsert_fund_day(today, boards) + logger.info("板块资金采样完成:%s,Top1 %s", today, boards[0]["name"] if boards else "-") diff --git a/src/easy_tdx/web/sentiment_store.py b/src/easy_tdx/web/sentiment_store.py index 01f7935..2bf2eea 100644 --- a/src/easy_tdx/web/sentiment_store.py +++ b/src/easy_tdx/web/sentiment_store.py @@ -56,6 +56,18 @@ class SentimentStore: """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_samples_date ON samples(date)") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS board_fund ( + date INTEGER NOT NULL, -- YYYYMMDD + rank INTEGER NOT NULL, -- 主力净流入名次(1 起) + code TEXT NOT NULL, + name TEXT NOT NULL, + main_net REAL NOT NULL, -- 主力净流入(元) + PRIMARY KEY (date, rank) + ) + """ + ) conn.commit() finally: conn.close() @@ -94,6 +106,62 @@ class SentimentStore: finally: conn.close() + def latest_fund_date(self) -> int: + """最近有板块资金采样的交易日(YYYYMMDD),无数据返回 0。""" + conn = self._connect() + try: + row = conn.execute("SELECT MAX(date) AS d FROM board_fund").fetchone() + return int(row["d"] or 0) + finally: + conn.close() + + def upsert_fund_day(self, date: int, boards: list[dict[str, Any]]) -> None: + """覆盖写入某日行业主力净流入排行(rank 按列表顺序 1 起)。""" + with _write_lock: + conn = self._connect() + try: + conn.execute("DELETE FROM board_fund WHERE date = ?", (int(date),)) + conn.executemany( + "INSERT INTO board_fund (date, rank, code, name, main_net) VALUES (?,?,?,?,?)", + [ + (int(date), i + 1, str(b["code"]), str(b["name"]), float(b["main_net"])) + for i, b in enumerate(boards) + ], + ) + conn.commit() + finally: + conn.close() + + def list_fund_days(self, days: int = 15) -> list[dict[str, Any]]: + """近 N 个有采样的交易日(降序),每日主力净流入排行。""" + conn = self._connect() + try: + rows = conn.execute( + """ + SELECT date, rank, code, name, main_net + FROM board_fund + WHERE date IN ( + SELECT DISTINCT date FROM board_fund ORDER BY date DESC LIMIT ? + ) + ORDER BY date DESC, rank + """, + (int(days),), + ).fetchall() + grouped: dict[int, dict[str, Any]] = {} + for r in rows: + g = grouped.setdefault(int(r["date"]), {"date": int(r["date"]), "boards": []}) + g["boards"].append( + { + "rank": int(r["rank"]), + "code": str(r["code"]), + "name": str(r["name"]), + "main_net": float(r["main_net"]), + } + ) + return list(grouped.values()) + finally: + conn.close() + def day_samples(self, date: int) -> list[dict[str, Any]]: """某交易日的全部分钟采样(按时间升序)。""" conn = self._connect() diff --git a/web-ui/src/types.ts b/web-ui/src/types.ts index cd40c0f..6537286 100644 --- a/web-ui/src/types.ts +++ b/web-ui/src/types.ts @@ -748,6 +748,13 @@ export interface LimitUpHistoryRow { limit_down: number } +// ── 板块主力资金日历(GET /api/v1/market/board-fund/history,每日采样) ───── + +export interface BoardFundDay { + date: number + boards: Array<{ rank: number; code: string; name: string; main_net: number }> +} + // ── Walk-Forward 样本外验证(v1.27 POST /backtest/wf/run/async)────────────── export interface WalkForwardWindow { diff --git a/web-ui/src/views/SentimentView.vue b/web-ui/src/views/SentimentView.vue index c1f5fbe..6f6af67 100644 --- a/web-ui/src/views/SentimentView.vue +++ b/web-ui/src/views/SentimentView.vue @@ -7,14 +7,23 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import echarts, { DOWN_COLOR, UP_COLOR } from '../echarts-setup' import { fetchBars, + fetchBoardFundHistory, + fetchLimitUpEcology, fetchLimitUpHistory, fetchMarketStat, fetchSentimentHistory, fetchSentimentToday, formatError, + runLlmChatWithPolling, } from '../api' import { fmtAmount, fmtPctSigned } from '../format' -import type { LimitUpHistoryRow, MarketStat, SentimentDay, SentimentSample } from '../types' +import type { + BoardFundDay, + LimitUpHistoryRow, + MarketStat, + SentimentDay, + SentimentSample, +} from '../types' const today = ref<{ date: number; count?: number; samples: SentimentSample[] } | null>(null) const histDays = ref([]) @@ -212,6 +221,7 @@ const volEl = ref() let volChart: echarts.ECharts | null = null const volRatio = ref(null) const volDate = ref('') +const fundDays = ref([]) async function loadVolume() { try { @@ -304,11 +314,91 @@ async function loadVolume() { } } +async function loadFund() { + try { + fundDays.value = await fetchBoardFundHistory(15) + } catch { + fundDays.value = [] // 资金日历独立降级 + } +} + + let timer = 0 +// ── ⑩ AI 盘面复盘:自动汇总上方数据 → LLM 生成 → 自动归档「AI 解读历史」 ───── + +const aiReply = ref('') +const aiBusy = ref(false) +const aiError = ref('') +const aiModel = ref('') + +async function buildDigest(): Promise { + const lines: string[] = [] + const s = stat.value + if (s) { + const denom = Math.max(s.up_count + s.down_count, 1) + lines.push( + `上涨 ${s.up_count} 家 / 下跌 ${s.down_count} 家(上涨占比 ${((100 * s.up_count) / denom).toFixed(1)}%),` + + `涨停 ${s.limit_up_count} 家,跌停 ${s.limit_down_count} 家,两市成交 ${fmtAmount(s.total_amount)}。`, + ) + } + if (volRatio.value !== null) { + lines.push(`量能:当日两市累计成交较近 5 日同期均值 ${fmtPctSigned(volRatio.value)}。`) + } + try { + const eco = await fetchLimitUpEcology() + const sm = eco.summary + lines.push(`连板高度 ${sm.max_streak} 板(首板 ${sm.first_board}、二板 ${sm.second_board}、3 板以上 ${sm.plus3}),炸板率 ${sm.blown_rate ?? '-'}%。`) + } catch { + // 涨停生态不可用时跳过该维度 + } + const lu = luHistory.value.slice(-5) + if (lu.length) { + lines.push( + `近 5 日涨停家数:${lu.map((r) => `${String(r.date).slice(4, 6)}-${String(r.date).slice(6, 8)} ${r.limit_up}`).join(';')}。`, + ) + } + const sampled = histDays.value.filter((d) => d.n >= 10).slice(-5) + if (sampled.length) { + lines.push( + `采样上涨占比:${sampled.map((d) => `${String(d.date).slice(4, 6)}-${String(d.date).slice(6, 8)} ${d.up_ratio}%`).join(';')}。`, + ) + } + if (lines.length === 0) return '' + return `以下是最新的 A 股盘面数据摘要:\n${lines.join('\n')}\n\n` + + '请以资深市场分析师的口吻写一段 200~400 字的盘面复盘,依次覆盖:1) 市场情绪与赚钱效应;' + + '2) 量能特征(放量/缩量及其含义);3) 涨停梯队与炸板率反映的题材热度与分歧;4) 结尾一句风险提示。' + + '直接给观点和逻辑,不要复述数据。' +} + +async function generateReview() { + aiBusy.value = true + aiError.value = '' + aiReply.value = '' + try { + const digest = await buildDigest() + if (!digest) { + aiError.value = '暂无盘面数据可生成复盘' + return + } + const state = await runLlmChatWithPolling(digest) + if (state.status === 'failed') { + throw new Error(String((state as { error?: string }).error ?? 'AI 解读任务失败')) + } + const result = state.result as { reply?: string; model?: string } + aiReply.value = result.reply ?? '' + aiModel.value = result.model ?? '' + } catch (e) { + aiError.value = formatError(e) + } finally { + aiBusy.value = false + } +} + onMounted(async () => { await load() loadVolume() + loadFund() timer = window.setInterval(() => { if (document.hidden) return load() @@ -389,6 +479,41 @@ onBeforeUnmount(() => { + +
+
行业主力资金 · 每日净流入 Top 10(交易日 14:45 后采样,需积累)
+
+
+ {{ String(d.date).slice(4, 6) }}-{{ String(d.date).slice(6, 8) }} + + {{ b.name }} +{{ (b.main_net / 1e8).toFixed(1) }}亿 + +
+
+ 尚无采样:每个交易日的 14:45 后自动记录一次行业主力净流入排行(涨幅前 50 名口径),持续运行后日历成形。 +
+
+
+ + +
+
+ AI 盘面复盘 + + {{ aiModel }} +
+
+
模型基于上方情绪 / 量能 / 涨停数据生成中…
+
{{ aiError }}
+
{{ aiReply }}
+
+ 汇总本页情绪 / 量能 / 涨停数据交给已配置的模型生成复盘,自动归档到「AI 解读历史」。 +
+
+
+
近 60 日 · 涨停/跌停家数(vipdoc 回补)与上涨占比(采样积累)
@@ -501,6 +626,49 @@ onBeforeUnmount(() => { font-weight: 600; color: var(--text-muted); } +.sec-title-ai { + display: flex; + align-items: center; + gap: 10px; + font-size: 12.5px; + font-weight: 600; + color: var(--text-muted); +} +.gen-btn { + font-size: 11.5px; + padding: 3px 12px; +} +.gen-btn:disabled { + opacity: 0.6; +} +.fund-card { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + padding: 10px 12px; +} +.fund-row { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.fund-date { + width: 44px; + flex-shrink: 0; +} +.fund-chip { + padding: 2px 8px; + border-radius: 999px; + background: var(--bg-elevated); + border: 1px solid var(--border); +} +.ai-card { + font-size: 13px; + line-height: 1.8; + white-space: pre-wrap; +} .chart-card { padding: 8px; position: relative;