feat: 盘面洞察收官 — AI 盘面复盘 + 板块主力资金日历

- AI 盘面复盘(市场情绪页):一键汇总当前情绪/量能/涨停梯队数据生成
  摘要 Prompt,走 /llm/chat/async 异步任务 + 轮询,回复页内展示并经
  _record_history 自动归档「AI 解读历史」
- 板块资金日历:FundFlowSampler 每交易日 14:45 后经
  get_board_ranking(main_net_amount) 采样行业主力净流入 Top10,
  SentimentStore 新增 board_fund 表((date,rank) 幂等);口径为
  "涨幅前 50 名中主力净流入最高 10 个"(逐板块 summary 太贵,非全市场
  严格排序);/market/board-fund/history + 市场情绪页日历视图
- app.py lifespan 接线启停;路线图 ⑥⑩ 标记收官
This commit is contained in:
Justin Gu
2026-09-05 04:43:22 +08:00
parent 7f0f14532e
commit a9782b74b9
7 changed files with 360 additions and 3 deletions
+2 -2
View File
@@ -10,11 +10,11 @@
| ② | **涨停生态 / 连板天梯** `/limitup` | 连板高度、首板/二板分布、炸板率、跌停 | 本地 vipdoc .day 文件(strength 扫描器同款读取器),close==涨停价 连续天数可回算 | ✅ 第二批 | | ② | **涨停生态 / 连板天梯** `/limitup` | 连板高度、首板/二板分布、炸板率、跌停 | 本地 vipdoc .day 文件(strength 扫描器同款读取器),close==涨停价 连续天数可回算 | ✅ 第二批 |
| ① | **市场情绪时间线** `/sentiment` | 情绪处于冰点/回暖/高潮/退潮 | 涨跌家数、涨停跌停数逐分钟采样(新采样器 + sqlite) | ✅ 第三批 | | ① | **市场情绪时间线** `/sentiment` | 情绪处于冰点/回暖/高潮/退潮 | 涨跌家数、涨停跌停数逐分钟采样(新采样器 + sqlite) | ✅ 第三批 |
| ⑨ | **市场宽度分时** `/sentiment` | 指数新高但上涨家数背离的顶部信号 | 依赖 ① 的采样器 | ✅ 第三批(随①) | | ⑨ | **市场宽度分时** `/sentiment` | 指数新高但上涨家数背离的顶部信号 | 依赖 ① 的采样器 | ✅ 第三批(随①) |
| ⑥ | **板块资金日历** | 哪天钱涌向了哪个板块 | board summary 主力净额逐日采样 | ⏳ 后续批次 | | ⑥ | **板块资金日历** `/sentiment` | 哪天钱涌向了哪个板块 | FundFlowSampler 每交易日 14:45 后采一次行业主力净流入 Top10(涨幅前 50 名口径) | ✅ 收官 |
| ⑤ | **板块相关性热力图** `/hotspots` | 哪些板块同涨同跌(抱团 vs 分散) | 热点滚动已缓存的 60 日涨跌矩阵求两两相关 | ✅ 第四批(热点滚动页内「相关性」视图) | | ⑤ | **板块相关性热力图** `/hotspots` | 哪些板块同涨同跌(抱团 vs 分散) | 热点滚动已缓存的 60 日涨跌矩阵求两两相关 | ✅ 第四批(热点滚动页内「相关性」视图) |
| ③ | ~~异动雷达时间线~~ | ~~异动密度骤增 = 盘面转折点~~ | `/mac/unusual` 盘中数据源质量差(盘中几乎无记录),**整项取消**(页面/导航/端点已移除) | ❌ 已取消 | | ③ | ~~异动雷达时间线~~ | ~~异动密度骤增 = 盘面转折点~~ | `/mac/unusual` 盘中数据源质量差(盘中几乎无记录),**整项取消**(页面/导航/端点已移除) | ❌ 已取消 |
| ⑧ | **量能仪表盘** `/sentiment` | 放量/缩量(两市累计成交 vs 5日同期均值) | 指数 5 分钟线现成 | ✅ 第四批(并入市场情绪页) | | ⑧ | **量能仪表盘** `/sentiment` | 放量/缩量(两市累计成交 vs 5日同期均值) | 指数 5 分钟线现成 | ✅ 第四批(并入市场情绪页) |
| ⑩ | **AI 盘面早报/复盘** | 把以上所有数据"自动读"给你听 | LLM 管道 + ai-history 归档现成 | 尾(必须做 | | ⑩ | **AI 盘面复盘** `/sentiment` | 把以上所有数据"自动读"给你听 | LLM 管道 + ai-history 归档现成 | 官(情绪页「生成 AI 复盘」按钮,异步任务 + 自动归档 |
## 批次 ## 批次
+21
View File
@@ -173,6 +173,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
ex_client = None ex_client = None
app.state.ex_client = ex_client 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/* --- # --- 市场情绪采样器(交易时段每分钟落一条广度快照,供 /market/sentiment/* ---
# 依赖标准 TDX 客户端(get_market_stat),mock 模式缩短间隔让曲线快速成形。 # 依赖标准 TDX 客户端(get_market_stat),mock 模式缩短间隔让曲线快速成形。
app.state.sentiment_sampler = None app.state.sentiment_sampler = None
@@ -191,6 +204,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
yield 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) sampler_svc = getattr(app.state, "sentiment_sampler", None)
if sampler_svc is not None: if sampler_svc is not None:
+15
View File
@@ -195,6 +195,21 @@ async def limitup_history(
return DictResponse.from_dict(payload) 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) @router.get("/fund-flow", response_model=DataFrameResponse)
async def fund_flow( async def fund_flow(
market: str = Query(..., description="市场: SZ, SH"), market: str = Query(..., description="市场: SZ, SH"),
+78
View File
@@ -97,3 +97,81 @@ class SentimentSampler:
} }
) )
self.samples += 1 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("板块资金采样完成:%sTop1 %s", today, boards[0]["name"] if boards else "-")
+68
View File
@@ -56,6 +56,18 @@ class SentimentStore:
""" """
) )
conn.execute("CREATE INDEX IF NOT EXISTS idx_samples_date ON samples(date)") 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() conn.commit()
finally: finally:
conn.close() conn.close()
@@ -94,6 +106,62 @@ class SentimentStore:
finally: finally:
conn.close() 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]]: def day_samples(self, date: int) -> list[dict[str, Any]]:
"""某交易日的全部分钟采样(按时间升序)。""" """某交易日的全部分钟采样(按时间升序)。"""
conn = self._connect() conn = self._connect()
+7
View File
@@ -748,6 +748,13 @@ export interface LimitUpHistoryRow {
limit_down: number 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)────────────── // ── Walk-Forward 样本外验证(v1.27 POST /backtest/wf/run/async)──────────────
export interface WalkForwardWindow { export interface WalkForwardWindow {
+169 -1
View File
@@ -7,14 +7,23 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import echarts, { DOWN_COLOR, UP_COLOR } from '../echarts-setup' import echarts, { DOWN_COLOR, UP_COLOR } from '../echarts-setup'
import { import {
fetchBars, fetchBars,
fetchBoardFundHistory,
fetchLimitUpEcology,
fetchLimitUpHistory, fetchLimitUpHistory,
fetchMarketStat, fetchMarketStat,
fetchSentimentHistory, fetchSentimentHistory,
fetchSentimentToday, fetchSentimentToday,
formatError, formatError,
runLlmChatWithPolling,
} from '../api' } from '../api'
import { fmtAmount, fmtPctSigned } from '../format' 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 today = ref<{ date: number; count?: number; samples: SentimentSample[] } | null>(null)
const histDays = ref<SentimentDay[]>([]) const histDays = ref<SentimentDay[]>([])
@@ -212,6 +221,7 @@ const volEl = ref<HTMLDivElement>()
let volChart: echarts.ECharts | null = null let volChart: echarts.ECharts | null = null
const volRatio = ref<number | null>(null) const volRatio = ref<number | null>(null)
const volDate = ref('') const volDate = ref('')
const fundDays = ref<BoardFundDay[]>([])
async function loadVolume() { async function loadVolume() {
try { try {
@@ -304,11 +314,91 @@ async function loadVolume() {
} }
} }
async function loadFund() {
try {
fundDays.value = await fetchBoardFundHistory(15)
} catch {
fundDays.value = [] // 资金日历独立降级
}
}
let timer = 0 let timer = 0
// ── ⑩ AI 盘面复盘:自动汇总上方数据 → LLM 生成 → 自动归档「AI 解读历史」 ─────
const aiReply = ref('')
const aiBusy = ref(false)
const aiError = ref('')
const aiModel = ref('')
async function buildDigest(): Promise<string> {
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 () => { onMounted(async () => {
await load() await load()
loadVolume() loadVolume()
loadFund()
timer = window.setInterval(() => { timer = window.setInterval(() => {
if (document.hidden) return if (document.hidden) return
load() load()
@@ -389,6 +479,41 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<!-- 板块主力资金日历 -->
<div class="section">
<div class="sec-title">行业主力资金 · 每日净流入 Top 10交易日 14:45 后采样需积累</div>
<div class="card fund-card">
<div v-for="d in fundDays" :key="d.date" class="fund-row">
<span class="mono dim fund-date">{{ String(d.date).slice(4, 6) }}-{{ String(d.date).slice(6, 8) }}</span>
<span v-for="b in d.boards" :key="b.code" class="fund-chip mono">
{{ b.name }} <span class="up">+{{ (b.main_net / 1e8).toFixed(1) }}亿</span>
</span>
</div>
<div v-if="fundDays.length === 0" class="empty-hint dim">
尚无采样每个交易日的 14:45 后自动记录一次行业主力净流入排行涨幅前 50 名口径持续运行后日历成形
</div>
</div>
</div>
<!-- AI 盘面复盘 -->
<div class="section">
<div class="sec-title-ai">
AI 盘面复盘
<button class="gen-btn" :disabled="aiBusy" @click="generateReview">
{{ aiBusy ? '生成中 1~3 分钟' : aiReply ? '重新生成' : '生成 AI 复盘' }}
</button>
<span v-if="aiModel" class="dim">{{ aiModel }}</span>
</div>
<div class="card ai-card">
<div v-if="aiBusy" class="dim">模型基于上方情绪 / 量能 / 涨停数据生成中</div>
<div v-else-if="aiError" class="up">{{ aiError }}</div>
<div v-else-if="aiReply" class="ai-reply">{{ aiReply }}</div>
<div v-else class="dim">
汇总本页情绪 / 量能 / 涨停数据交给已配置的模型生成复盘自动归档到AI 解读历史
</div>
</div>
</div>
<!-- 60 日情绪 --> <!-- 60 日情绪 -->
<div class="section"> <div class="section">
<div class="sec-title"> 60 · 涨停/跌停家数vipdoc 回补与上涨占比采样积累</div> <div class="sec-title"> 60 · 涨停/跌停家数vipdoc 回补与上涨占比采样积累</div>
@@ -501,6 +626,49 @@ onBeforeUnmount(() => {
font-weight: 600; font-weight: 600;
color: var(--text-muted); 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 { .chart-card {
padding: 8px; padding: 8px;
position: relative; position: relative;