feat(web): 信号雷达——一键扫描全部已保存策略的最近买卖信号

新增导航页 /signals:把策略库 single/portfolio/multi 策略统一展开成
"策略×标的"子任务,按标的去重取最近 800 根 K 线,用与回测引擎同口径的
逐 bar 信号流程(含仓位跟踪)判断最近 N 根(窗口 1/3/5/10 可选,默认 5)
的买/卖信号,汇总卡片 + 筛选 tab + 明细表展示;上次结果缓存 localStorage。

- 后端 signal_scan.py(展开/去重取数/信号评估/汇总)+ POST /backtest/signal-scan/run/async
- 只扫信号不重跑回测,不改写策略库业绩快照;单行失败(未知策略/停牌/参数非法)不中断整批
- normalize_symbol 按代码段纠正历史错标市场前缀(与前端 detectMarket 同规则)
- 新增 20 个单测(含与回测引擎成交序列一致性对照),全套 1030 个单测通过
This commit is contained in:
GitHub
2026-08-21 16:57:12 +08:00
parent 67a5e1d08a
commit 9336273f17
9 changed files with 1499 additions and 2 deletions
+1
View File
@@ -12,6 +12,7 @@
<RouterLink to="/optimize" active-class="active">参数寻优</RouterLink>
<RouterLink to="/compare" active-class="active">结果对比</RouterLink>
<RouterLink to="/strategies" active-class="active">策略库</RouterLink>
<RouterLink to="/signals" active-class="active">信号雷达</RouterLink>
<RouterLink to="/settings" active-class="active">服务器设置</RouterLink>
</nav>
</header>
+51
View File
@@ -17,6 +17,8 @@ import type {
ServerHostInfo,
ServerHostListResponse,
ServerSwitchResult,
SignalScanRequest,
SignalScanResult,
StrategiesResponse,
TaskListResponse,
TaskState,
@@ -237,6 +239,55 @@ export async function runBacktestWithPolling(
// ── 策略库(已保存策略)──────────────────────────────────────────────────────
/** 提交「信号雷达」一键扫描后台任务,返回 task_id。 */
export async function submitSignalScanTask(
req: SignalScanRequest = {},
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/signal-scan/run/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/**
* 提交信号扫描并轮询直到 done/failed。
*
* 与 runBacktestWithPolling 的区别:扫描要在请求内逐标的取行情(提交本身
* 就可能耗时数十秒),且标的较多时总时长可能超过 2 分钟,故默认 300s 超时。
*/
export async function runSignalScanWithPolling(
req: SignalScanRequest = {},
onPoll?: (state: TaskState) => void,
intervalMs = 500,
timeoutMs = 300_000,
): Promise<TaskState> {
const { task_id } = await submitSignalScanTask(req)
const start = Date.now()
// eslint-disable-next-line no-constant-condition
while (true) {
const state = await fetchTask(task_id)
onPoll?.(state)
if (state.status === 'done' || state.status === 'failed') return state
if (Date.now() - start > timeoutMs) {
throw new Error(`信号扫描超时(${timeoutMs / 1000}s),可稍后重试或减小窗口`)
}
await new Promise((r) => setTimeout(r, intervalMs))
}
}
/** 断言任务结果为信号扫描结果(类型收窄用)。 */
export function asSignalScanResult(state: TaskState): SignalScanResult {
if (state.status === 'failed') throw new Error(state.error || '信号扫描失败')
const result = state.result as SignalScanResult | null
if (!result || !Array.isArray(result.rows)) {
throw new Error('信号扫描结果格式异常(缺少 rows)')
}
return result
}
/** 列出全部已保存策略(按创建时间倒序)。 */
export async function fetchSavedStrategies(): Promise<SavedStrategyListResponse> {
const resp = await fetch(`${BASE}/strategies`)
+4 -1
View File
@@ -5,15 +5,18 @@ import CompareView from './views/CompareView.vue'
import OptimizeView from './views/OptimizeView.vue'
import PortfolioView from './views/PortfolioView.vue'
import ServerSettingsView from './views/ServerSettingsView.vue'
import SignalRadarView from './views/SignalRadarView.vue'
import StrategiesView from './views/StrategiesView.vue'
// 单标的回测(/+ 组合回测(/portfolio+ 参数寻优(/optimize+ 结果对比(/compare+ 策略库(/strategies+ 服务器设置(/settings)。
// 单标的回测(/+ 组合回测(/portfolio+ 参数寻优(/optimize+ 结果对比(/compare
// + 策略库(/strategies+ 信号雷达(/signals+ 服务器设置(/settings)。
const routes = [
{ path: '/', name: 'backtest', component: BacktestView },
{ path: '/portfolio', name: 'portfolio', component: PortfolioView },
{ path: '/optimize', name: 'optimize', component: OptimizeView },
{ path: '/compare', name: 'compare', component: CompareView },
{ path: '/strategies', name: 'strategies', component: StrategiesView },
{ path: '/signals', name: 'signals', component: SignalRadarView },
{ path: '/settings', name: 'settings', component: ServerSettingsView },
]
+49 -1
View File
@@ -131,7 +131,13 @@ export type TaskStatus = 'pending' | 'running' | 'done' | 'failed'
export interface TaskState {
task_id: string
status: TaskStatus
result: BacktestResult | PortfolioResult | OptimizeResult | OptimizeAllResult | null
result:
| BacktestResult
| PortfolioResult
| OptimizeResult
| OptimizeAllResult
| SignalScanResult
| null
error: string | null
description: string
elapsed: number
@@ -307,6 +313,48 @@ export interface SavedStrategyListResponse {
count: number
}
// ── 信号雷达(POST /api/v1/backtest/signal-scan/run/async ──────────────────
/** 信号扫描请求:window_bars = 检查最近 N 根 K 线内的信号。 */
export interface SignalScanRequest {
window_bars?: number
}
/** 窗口内单根 K 线的信号。 */
export interface SignalScanRecentSignal {
date: string
direction: 'BUY' | 'SELL'
}
/** 扫描结果单行:一个"策略×标的"子任务的信号摘要。 */
export interface SignalScanRow {
strategy_id: string
strategy_name: string
kind: 'single' | 'portfolio' | 'multi'
strategy: string
strategy_label: string
params: Record<string, number | string | boolean>
symbol: string
category: string
latest_signal: 'BUY' | 'SELL' | null
signal_date: string | null
recent_signals: SignalScanRecentSignal[]
position: 'holding' | 'flat' | null
last_close: number | null
last_bar_date: string | null
error: string | null
}
/** 信号扫描结果:全部子任务行 + 汇总计数。 */
export interface SignalScanResult {
rows: SignalScanRow[]
total: number
buy_count: number
sell_count: number
error_count: number
elapsed: number
}
// ── 多策略组合回测(资金分仓,POST /api/v1/backtest/multi-strategy/run/async ──
/** 多策略组合的单个策略槽位(一个策略 + 参数 + 它要跑的原标的 + 日期)。 */
+587
View File
@@ -0,0 +1,587 @@
<script setup lang="ts">
// 信号雷达页:一键扫描策略库全部已保存策略(单标的/多标的/多策略组合),
// 把每种策略展开成"策略×标的"子任务,用最近 N 根 K 线(窗口可选,默认 5)
// 判断买/卖信号并汇总列出——方便每天跟踪"今天哪些策略有信号"。
// 后端 POST /backtest/signal-scan/run/async;取行情在提交请求内完成(标的多时
// 提交本身就要等一会儿),结果轮询拿 SignalScanResult。上次扫描结果缓存在
// localStorage,进页面先展示,避免每次都要重扫。
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { asSignalScanResult, formatError, runSignalScanWithPolling } from '../api'
import type { SignalScanResult, SignalScanRow } from '../types'
const router = useRouter()
const WINDOW_OPTIONS = [1, 3, 5, 10]
const STORAGE_KEY = 'easy-tdx.signal-radar.last'
const windowBars = ref(5)
const scanning = ref(false)
const error = ref('')
const result = ref<SignalScanResult | null>(null)
const scannedAt = ref('') // 本地时间戳(上次扫描完成时刻)
const elapsedSec = ref('') // 上次扫描总耗时(提交+计算)
interface CachedScan {
result: SignalScanResult
scannedAt: string
windowBars: number
}
onMounted(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
const cached = JSON.parse(raw) as CachedScan
if (cached?.result?.rows) {
result.value = cached.result
scannedAt.value = cached.scannedAt || ''
if (WINDOW_OPTIONS.includes(cached.windowBars)) windowBars.value = cached.windowBars
}
} catch {
// 缓存损坏则忽略,直接空态
}
})
async function onScan() {
if (scanning.value) return
scanning.value = true
error.value = ''
const t0 = Date.now()
try {
const state = await runSignalScanWithPolling({ window_bars: windowBars.value })
result.value = asSignalScanResult(state)
scannedAt.value = new Date().toLocaleString('zh-CN', { hour12: false })
elapsedSec.value = ((Date.now() - t0) / 1000).toFixed(1)
const cached: CachedScan = {
result: result.value,
scannedAt: scannedAt.value,
windowBars: windowBars.value,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(cached))
} catch (e) {
error.value = formatError(e)
} finally {
scanning.value = false
}
}
// ── 筛选 ─────────────────────────────────────────────────────────────────────
type FilterKey = 'signal' | 'buy' | 'sell' | 'error' | 'all'
const activeFilter = ref<FilterKey>('signal') // 默认只看有信号的
function hasBuy(r: SignalScanRow): boolean {
return r.recent_signals.some((s) => s.direction === 'BUY')
}
function hasSell(r: SignalScanRow): boolean {
return r.recent_signals.some((s) => s.direction === 'SELL')
}
const filterDefs = computed(() => {
const rows = result.value?.rows || []
const defs: { key: FilterKey; label: string; count: number }[] = [
{ key: 'signal', label: '有信号', count: rows.filter((r) => !r.error && r.recent_signals.length > 0).length },
{ key: 'buy', label: '买入', count: rows.filter((r) => !r.error && hasBuy(r)).length },
{ key: 'sell', label: '卖出', count: rows.filter((r) => !r.error && hasSell(r)).length },
{ key: 'error', label: '失败', count: rows.filter((r) => r.error).length },
{ key: 'all', label: '全部', count: rows.length },
]
return defs
})
const visibleRows = computed(() => {
const rows = result.value?.rows || []
switch (activeFilter.value) {
case 'signal':
return rows.filter((r) => !r.error && r.recent_signals.length > 0)
case 'buy':
return rows.filter((r) => !r.error && hasBuy(r))
case 'sell':
return rows.filter((r) => !r.error && hasSell(r))
case 'error':
return rows.filter((r) => r.error)
default:
return rows
}
})
// ── 展示辅助 ─────────────────────────────────────────────────────────────────
function kindLabel(kind: SignalScanRow['kind']): string {
return kind === 'multi' ? '多策略' : kind === 'portfolio' ? '多标的' : '单标的'
}
/** 窗口内信号序列,如 "B 08-19 · S 08-20"B=买 S=卖)。 */
function signalSeq(r: SignalScanRow): string {
return r.recent_signals
.map((s) => `${s.direction === 'BUY' ? 'B' : 'S'} ${s.date.slice(5, 10)}`)
.join(' · ')
}
/** 跳转单标的回测页回填该子策略(query 模式与策略库「载入」一致)。 */
function onLoad(r: SignalScanRow) {
const codeOnly = r.symbol.includes(':') ? r.symbol.split(':').pop()! : r.symbol
router.push({
path: '/',
query: {
strategy: r.strategy,
params: JSON.stringify(r.params),
symbol: codeOnly || undefined,
category: r.category || undefined,
endDate: new Date().toISOString().slice(0, 10),
},
})
}
</script>
<template>
<div class="radar-view">
<header class="page-header">
<div>
<h2>信号雷达</h2>
<p class="subtitle">
一键扫描策略库全部已保存策略列出最近 K 线内出现买入/卖出信号的策略
<template v-if="scannedAt">
上次扫描 {{ scannedAt }}<template v-if="elapsedSec">{{ elapsedSec }}s</template>
</template>
</p>
</div>
<div class="header-actions">
<label class="window-picker">
窗口
<select v-model="windowBars" :disabled="scanning">
<option v-for="w in WINDOW_OPTIONS" :key="w" :value="w">{{ w }} </option>
</select>
</label>
<button class="primary" :disabled="scanning" @click="onScan">
{{ scanning ? '扫描中' : ' 一键扫描' }}
</button>
</div>
</header>
<div v-if="error" class="error-banner"> {{ error }}</div>
<!-- 扫描中提交请求内要逐标的取行情需要等待 -->
<div v-if="scanning" class="scanning-box">
<span class="spinner"></span>
正在扫描逐标的取最近 800 K 线并计算信号标的较多时约需几十秒请稍候
</div>
<template v-if="result && !scanning">
<!-- 汇总卡片 -->
<div class="stat-cards">
<div class="stat">
<span class="k">子任务</span>
<span class="v">{{ result.total }}</span>
</div>
<div class="stat">
<span class="k">买入信号</span>
<span class="v buy">{{ result.buy_count }}</span>
</div>
<div class="stat">
<span class="k">卖出信号</span>
<span class="v sell">{{ result.sell_count }}</span>
</div>
<div class="stat">
<span class="k">失败</span>
<span class="v dim">{{ result.error_count }}</span>
</div>
</div>
<p class="hint">
窗口 = 最近 {{ windowBars }} {{ result.rows[0]?.category === 'DAY' ? '交易日' : 'K 线' }}
盘中最后一根 K 线未收盘信号为盘中即时值收盘后为准
</p>
<!-- 筛选 tab -->
<nav class="tabs">
<button
v-for="f in filterDefs"
:key="f.key"
:class="['tab', { active: activeFilter === f.key }]"
@click="activeFilter = f.key"
>
{{ f.label }}<span class="tab-count">{{ f.count }}</span>
</button>
</nav>
<div v-if="visibleRows.length === 0" class="placeholder">
<p>{{ activeFilter === 'signal' ? '窗口内没有任何买卖信号。' : '该筛选下没有子任务。' }}</p>
<p class="hint">可切换更大的窗口 10 或点一键扫描重新检查</p>
</div>
<table v-else class="radar-table">
<thead>
<tr>
<th>策略</th>
<th>类型</th>
<th>子策略 / 参数</th>
<th>标的</th>
<th>最新信号</th>
<th>窗口内信号</th>
<th class="num">最新收盘</th>
<th>仓位</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in visibleRows" :key="`${r.strategy_id}-${i}`" :class="{ errored: r.error }">
<td class="name" :title="r.strategy_name">{{ r.strategy_name }}</td>
<td><span class="kind-badge" :class="r.kind">{{ kindLabel(r.kind) }}</span></td>
<td class="sub-strat">
{{ r.strategy_label || r.strategy }}
<span class="params">{{ JSON.stringify(r.params) }}</span>
</td>
<td class="sym">{{ r.symbol }}</td>
<td v-if="r.error" class="err" colspan="4"> {{ r.error }}</td>
<template v-else>
<td>
<span v-if="r.latest_signal" class="signal-tag" :class="r.latest_signal">
{{ r.latest_signal === 'BUY' ? '买入' : '卖出' }}
</span>
<span v-else class="none-tag"></span>
</td>
<td class="seq">{{ signalSeq(r) || '—' }}</td>
<td class="num">{{ r.last_close != null ? r.last_close.toFixed(2) : '-' }}</td>
<td>
<span v-if="r.position" class="pos-tag" :class="r.position">
{{ r.position === 'holding' ? '持仓' : '空仓' }}
</span>
</td>
</template>
<td>
<button v-if="!r.error" class="ghost sm" @click="onLoad(r)">载入</button>
</td>
</tr>
</tbody>
</table>
</template>
<div v-if="!result && !scanning && !error" class="placeholder">
<p>还没有扫描结果</p>
<p class="hint">
点右上角 一键扫描把策略库里保存的单策略与组合策略全部检查一遍
列出最近 {{ windowBars }} K 线内出现买卖信号的策略每天收盘后扫一次即可跟踪
</p>
</div>
</div>
</template>
<style scoped>
.radar-view {
height: 100%;
overflow-y: auto;
padding: 16px 20px 32px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-header h2 {
font-size: 16px;
font-weight: 600;
}
.subtitle {
font-size: 12px;
color: var(--text-dim);
margin-top: 4px;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.window-picker {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
.window-picker select {
background: var(--bg-panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 5px 8px;
font-size: 12px;
cursor: pointer;
}
.primary {
font-size: 13px;
padding: 7px 18px;
background: var(--accent);
border: 1px solid var(--accent);
color: #fff;
font-weight: 600;
border-radius: var(--radius);
cursor: pointer;
}
.primary:hover:not(:disabled) {
filter: brightness(1.1);
}
.primary:disabled {
opacity: 0.6;
cursor: default;
}
.error-banner {
background: rgba(239, 65, 70, 0.12);
border: 1px solid var(--up);
color: var(--up);
padding: 10px 14px;
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 13px;
}
.scanning-box {
display: flex;
align-items: center;
gap: 10px;
padding: 18px 16px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
font-size: 13px;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
height: 50%;
color: var(--text-dim);
gap: 8px;
}
.placeholder .hint,
.hint {
font-size: 12px;
color: var(--text-dim);
max-width: 560px;
line-height: 1.6;
}
/* 汇总卡片 */
.stat-cards {
display: flex;
gap: 14px;
margin-bottom: 10px;
}
.stat {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
}
.stat .k {
font-size: 12px;
color: var(--text-dim);
}
.stat .v {
font-size: 22px;
font-weight: 700;
font-family: var(--font-mono);
}
.stat .v.buy {
color: var(--up);
}
.stat .v.sell {
color: var(--down);
}
.stat .v.dim {
color: var(--text-dim);
}
/* 筛选 tab(与策略库页同风格) */
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
margin: 14px 0 12px;
}
.tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.tab-count {
font-size: 11px;
padding: 1px 6px;
border-radius: 8px;
background: var(--border);
color: var(--text-dim);
font-weight: 400;
}
.tab.active .tab-count {
background: rgba(74, 158, 255, 0.18);
color: var(--accent);
}
/* 结果表 */
.radar-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.radar-table th,
.radar-table td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.radar-table th {
color: var(--text-dim);
font-size: 12px;
font-weight: 600;
}
.radar-table .num {
text-align: right;
font-family: var(--font-mono);
}
.radar-table .name {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.radar-table .sym {
font-family: var(--font-mono);
font-weight: 600;
white-space: nowrap;
}
.radar-table .sub-strat {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub-strat .params {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
margin-left: 6px;
}
.radar-table .seq {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
}
.radar-table .err {
color: var(--up);
font-size: 12px;
}
.radar-table tr.errored {
opacity: 0.75;
}
/* 徽章 */
.kind-badge {
font-size: 11px;
padding: 2px 7px;
border-radius: 4px;
background: rgba(74, 158, 255, 0.15);
color: var(--accent);
white-space: nowrap;
}
.kind-badge.portfolio {
background: rgba(140, 110, 220, 0.18);
color: #b39ddb;
}
.kind-badge.multi {
background: rgba(245, 158, 11, 0.18);
color: #f59e0b;
}
.signal-tag {
font-size: 12px;
padding: 2px 10px;
border-radius: 4px;
font-weight: 600;
white-space: nowrap;
}
/* A股习惯:买入红、卖出绿 */
.signal-tag.BUY {
background: rgba(239, 65, 70, 0.14);
color: var(--up);
}
.signal-tag.SELL {
background: rgba(24, 160, 88, 0.16);
color: var(--down);
}
.none-tag {
color: var(--text-dim);
}
.pos-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
white-space: nowrap;
}
.pos-tag.holding {
background: rgba(239, 65, 70, 0.12);
color: var(--up);
}
.pos-tag.flat {
background: var(--border);
color: var(--text-dim);
}
.ghost {
font-size: 12px;
padding: 4px 12px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
cursor: pointer;
}
.ghost:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.sm {
font-size: 12px;
padding: 4px 12px;
}
</style>