mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 16:54:20 +08:00
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:
@@ -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 复盘」按钮,异步任务 + 自动归档) |
|
||||
|
||||
## 批次
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 "-")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<SentimentDay[]>([])
|
||||
@@ -212,6 +221,7 @@ const volEl = ref<HTMLDivElement>()
|
||||
let volChart: echarts.ECharts | null = null
|
||||
const volRatio = ref<number | null>(null)
|
||||
const volDate = ref('')
|
||||
const fundDays = ref<BoardFundDay[]>([])
|
||||
|
||||
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<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 () => {
|
||||
await load()
|
||||
loadVolume()
|
||||
loadFund()
|
||||
timer = window.setInterval(() => {
|
||||
if (document.hidden) return
|
||||
load()
|
||||
@@ -389,6 +479,41 @@ onBeforeUnmount(() => {
|
||||
</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 日情绪 -->
|
||||
<div class="section">
|
||||
<div class="sec-title">近 60 日 · 涨停/跌停家数(vipdoc 回补)与上涨占比(采样积累)</div>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user