Add demo trading analyst with plan validation and sizing

The analyst prompts the model with trading rules, published skills,
account sizing inputs and prefetched market data, exposes read-only
tools plus submit_trade_plan, and validates and sizes the submitted plan
in pure functions covered by pytest. Manual analyses run as dry runs
while trading is stopped; the trading page shows the latest plan and an
analysis detail drawer.
This commit is contained in:
eezd committed 2026-10-15 10:40:29 +08:00
1 parent 7536c9c5de
commit 78ca4422af
16 files changed
+1465 -151

No files matched your search

+92
View File
@@ -140,3 +140,95 @@ export function deleteSymbol(symbol: string): Promise<void> {
export function listEvents(params: { symbol?: string, before?: number, limit?: number }): Promise<TradingEvent[]> {
return http.get("/trading/events", { params })
}
export interface PlanCondition {
direction: "above" | "below"
price: string
timeframe: "mark" | "1m" | "15m" | "1h" | "4h"
basis: string
}
/** submit_trade_plan 的参数原样保存;数值为字符串 */
export interface TradeDecision {
action: "plan" | "keep" | "exit" | "wait"
summary: string
reasons: string
risks: string
reevaluate: PlanCondition[]
direction: "long" | "short" | null
confidence: "high" | "medium" | "low" | null
entries: { price: string, ratio: string, basis: string }[] | null
stop: string | null
stop_basis: string | null
take_profits: { price: string, basis: string }[] | null
entry_valid_hours: string | null
max_hold_hours: string | null
invalidation: PlanCondition | null
}
/** 程序按交易资金算出的数量和风险 */
export interface Sizing {
direction: "long" | "short"
entries: { price: string, ratio: string, quantity: string, notional: string }[]
stop: string
take_profits: { price: string, ratio: string, quantity: string }[]
average_price: string
quantity: string
notional: string
capital: string
leverage: string
risk_amount: string
risk_at_stop: string
risk_pct: string
capped_by_leverage: boolean
reward_risk: string
reward_risk_first_only: string
}
export interface Analysis {
id: number
symbol: string
angle: Angle
reasons: string[]
dry_run: boolean
status: "running" | "done" | "failed"
action: TradeDecision["action"] | null
decision: TradeDecision | null
sizing: Sizing | null
error: string | null
model: string | null
reasoning_effort: string | null
usage: { input_tokens: number, output_tokens: number } | null
started_at: string
finished_at: string | null
plan_status: "pending" | "open" | "closed" | "canceled" | "replaced" | null
valid_until: string | null
first_fill_at: string | null
close_deadline: string | null
closed_at: string | null
close_reason: string | null
result: Record<string, unknown> | null
}
/** 与协议无关的消息格式,同 Chat */
export type TranscriptMessage
= | { role: "user", text: string }
| { role: "assistant", text: string, tool_calls: { id: string, name: string, arguments: Record<string, unknown> }[] }
| { role: "tool", tool_call_id: string, name: string, result: string }
export interface AnalysisDetail extends Analysis {
text: string | null
transcript: TranscriptMessage[] | null
}
export function requestAnalysis(symbol: string): Promise<void> {
return http.post(`/trading/symbols/${symbol}/analyze`)
}
export function listAnalyses(params: { symbol?: string, limit?: number }): Promise<Analysis[]> {
return http.get("/trading/analyses", { params })
}
export function getAnalysis(id: number): Promise<AnalysisDetail> {
return http.get(`/trading/analyses/${id}`)
}
@@ -0,0 +1,68 @@
<script lang="ts" setup>
import type { AnalysisDetail } from "@/apis/trading"
import { getAnalysis } from "@/apis/trading"
import { renderMarkdown } from "@/utils/markdown"
import { formatTime } from "@/utils/text"
const props = defineProps<{ analysisId: number | null }>()
const visible = defineModel<boolean>({ required: true })
const detail = ref<AnalysisDetail>()
watch(() => [visible.value, props.analysisId] as const, async ([open, id]) => {
detail.value = undefined
if (open && id)
detail.value = await getAnalysis(id)
})
const toolResults = computed(() => {
const results = new Map<string, string>()
for (const m of detail.value?.transcript ?? []) {
if (m.role === "tool")
results.set(m.tool_call_id, m.result)
}
return results
})
function pretty(raw: string | undefined) {
if (raw === undefined)
return "无结果"
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
</script>
<template>
<el-drawer v-model="visible" :title="detail ? `分析 #${detail.id} · ${detail.symbol}` : '分析'" size="55%">
<div v-if="detail" class="flex flex-col gap-3 text-sm">
<div class="text-xs text-[var(--el-text-color-secondary)]">
{{ formatTime(detail.started_at) }} – {{ formatTime(detail.finished_at) }} · {{ detail.reasons.join(";") }}
· {{ detail.model }}{{ detail.reasoning_effort ? ` / ${detail.reasoning_effort}` : "" }}
· {{ detail.usage?.input_tokens.toLocaleString() }} / {{ detail.usage?.output_tokens.toLocaleString() }} token
{{ detail.dry_run ? "· 试运行" : "" }}
</div>
<el-alert v-if="detail.error" type="error" :closable="false" :title="detail.error" />
<!-- markdown-it 已关闭 html,模型输出中的标签按文本显示 -->
<div v-if="detail.text" class="markdown-body" v-html="renderMarkdown(detail.text)" />
<el-collapse>
<template v-for="(m, i) in detail.transcript ?? []" :key="i">
<el-collapse-item v-if="m.role === 'user'" :name="`u${i}`" :title="i === 0 ? '发给模型的数据(账户、持仓、预取行情)' : '程序提示'">
<pre class="m-0 max-h-120 overflow-auto whitespace-pre-wrap break-all text-xs">{{ m.text }}</pre>
</el-collapse-item>
<template v-else-if="m.role === 'assistant'">
<el-collapse-item v-for="call in m.tool_calls" :key="call.id" :name="call.id">
<template #title>
<span class="inline-block max-w-[90%] truncate text-xs font-mono">{{ call.name }} {{ JSON.stringify(call.arguments) }}</span>
</template>
<pre class="m-0 max-h-120 overflow-auto whitespace-pre-wrap break-all text-xs">{{ pretty(toolResults.get(call.id)) }}</pre>
</el-collapse-item>
</template>
</template>
</el-collapse>
</div>
<div v-else v-loading="true" class="h-40" />
</el-drawer>
</template>
@@ -0,0 +1,148 @@
<script lang="ts" setup>
import type { Analysis, PlanCondition, TradingSymbol } from "@/apis/trading"
import { ANGLE_LABELS } from "@/apis/trading"
import { formatTime } from "@/utils/text"
const props = defineProps<{ symbol: TradingSymbol, analysis: Analysis | null, requesting: boolean }>()
const emit = defineEmits<{ analyze: [], detail: [id: number] }>()
const ACTION_LABELS = { plan: "新计划", keep: "维持当前计划", exit: "结束当前计划", wait: "观望" } as const
const CONFIDENCE_LABELS = { high: "高", medium: "中", low: "低" } as const
const TIMEFRAME_LABELS = { "mark": "标记价触及", "1m": "1m 收盘", "15m": "15m 收盘", "1h": "1h 收盘", "4h": "4h 收盘" } as const
const now = ref(Date.now())
const ticker = setInterval(() => now.value = Date.now(), 1000)
onBeforeUnmount(() => clearInterval(ticker))
const decision = computed(() => props.analysis?.decision ?? null)
const sizing = computed(() => props.analysis?.sizing ?? null)
const busy = computed(() => props.requesting || props.symbol.analyzing || !!props.symbol.analysis_requested_at)
const elapsed = computed(() => props.analysis ? Math.max(0, Math.round((now.value - new Date(props.analysis.started_at).getTime()) / 1000)) : 0)
function condition(c: PlanCondition) {
return `${TIMEFRAME_LABELS[c.timeframe]}${c.direction === "above" ? "高于" : "低于"} ${c.price}`
}
/** 失效后的应急动作:短线立即市价平仓,长线等 4h 收盘后请模型复核 */
const invalidationAction = computed(() => props.analysis?.angle === "short" ? "立即市价平仓" : "请模型复核")
</script>
<template>
<div class="border border-[var(--el-border-color)] rounded border-solid p-3">
<div class="mb-2 flex items-center gap-2">
<span class="font-bold">{{ symbol.symbol }}</span>
<span class="text-xs text-[var(--el-text-color-secondary)]">{{ ANGLE_LABELS[symbol.angle] }}</span>
<div class="flex-1" />
<el-button v-if="analysis && analysis.status !== 'running'" link type="primary" @click="emit('detail', analysis.id)">
查看完整分析
</el-button>
<el-button size="small" type="primary" :loading="busy" @click="emit('analyze')">
{{ busy ? "分析中" : "立即分析" }}
</el-button>
</div>
<div v-if="!analysis" class="text-sm text-[var(--el-text-color-secondary)]">
还没有分析。引擎停止时点「立即分析」为试运行:计划只记录,不下单。
</div>
<div v-else-if="analysis.status === 'running'" class="text-sm text-[var(--el-text-color-secondary)]">
模型分析中,已用 {{ elapsed }} 秒(通常 1–3 分钟)…
</div>
<el-alert v-else-if="analysis.status === 'failed'" type="error" :closable="false" show-icon>
{{ formatTime(analysis.started_at) }} 分析失败:{{ analysis.error }}
</el-alert>
<div v-else-if="decision" class="flex flex-col gap-2 text-sm">
<div class="flex flex-wrap items-center gap-2">
<el-tag v-if="analysis.dry_run" type="info" size="small">
试运行
</el-tag>
<el-tag size="small">
{{ ACTION_LABELS[decision.action] }}
</el-tag>
<el-tag v-if="decision.direction" :type="decision.direction === 'long' ? 'success' : 'danger'" size="small">
{{ decision.direction === "long" ? "做多" : "做空" }}
</el-tag>
<el-tag v-if="decision.confidence" type="warning" size="small">
置信度{{ CONFIDENCE_LABELS[decision.confidence] }}
</el-tag>
<span class="text-xs text-[var(--el-text-color-secondary)]">
{{ formatTime(analysis.started_at) }} · {{ analysis.reasons.join(";") }} · {{ analysis.model }}{{ analysis.reasoning_effort ? ` / ${analysis.reasoning_effort}` : "" }}
· {{ analysis.usage?.input_tokens.toLocaleString() }} / {{ analysis.usage?.output_tokens.toLocaleString() }} token
</span>
</div>
<div class="font-bold">
{{ decision.summary }}
</div>
<div><span class="label">为什么</span>{{ decision.reasons }}</div>
<template v-if="decision.action === 'plan' && sizing">
<el-table :data="sizing.entries" border size="small">
<el-table-column label="入场档位" min-width="110">
<template #default="{ $index }">
第 {{ $index + 1 }} 档
</template>
</el-table-column>
<el-table-column prop="price" label="挂单价" min-width="100" />
<el-table-column label="比例" width="80">
<template #default="{ row }">
{{ Math.round(Number(row.ratio) * 100) }}%
</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" min-width="90" />
<el-table-column prop="notional" label="名义 USDT" min-width="100" />
<el-table-column label="依据" min-width="200">
<template #default="{ $index }">
{{ decision.entries?.[$index]?.basis }}
</template>
</el-table-column>
</el-table>
<el-table :data="sizing.take_profits" border size="small">
<el-table-column label="分批止盈" min-width="110">
<template #default="{ $index }">
止盈 {{ $index + 1 }}
</template>
</el-table-column>
<el-table-column prop="price" label="价格" min-width="100" />
<el-table-column label="平仓比例" width="80">
<template #default="{ row }">
{{ Math.round(Number(row.ratio) * 100) }}%
</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" min-width="90" />
<el-table-column label="依据" min-width="300">
<template #default="{ $index }">
{{ decision.take_profits?.[$index]?.basis }}
</template>
</el-table-column>
</el-table>
<div class="grid grid-cols-2 gap-x-6 gap-y-1">
<div><span class="label">止损</span>{{ sizing.stop }}(交易所挂单,标记价触发)· {{ decision.stop_basis }}</div>
<div>
<span class="label">失效条件</span>{{ decision.invalidation ? condition(decision.invalidation) : "-" }}
→ {{ invalidationAction }} · {{ decision.invalidation?.basis }}
</div>
<div><span class="label">均价 / 数量</span>{{ sizing.average_price }} / {{ sizing.quantity }}(名义 {{ sizing.notional }} USDT,{{ sizing.leverage }} 倍交易资金)</div>
<div><span class="label">止损亏损</span>{{ sizing.risk_at_stop }} USDT(交易资金的 {{ sizing.risk_pct }}%){{ sizing.capped_by_leverage ? ",受 3 倍上限限制" : "" }}</div>
<div><span class="label">盈亏比</span>全部成交 {{ sizing.reward_risk }},仅首档 {{ sizing.reward_risk_first_only }}</div>
<div><span class="label">时效</span>挂单 {{ decision.entry_valid_hours }} 小时内有效,首档成交后最长持仓 {{ decision.max_hold_hours }} 小时</div>
</div>
</template>
<div v-if="decision.reevaluate.length">
<span class="label">重新判断</span>
<div v-for="(c, i) in decision.reevaluate" :key="i" class="ml-2">
· {{ condition(c) }}:{{ c.basis }}
</div>
</div>
<div><span class="label">风险</span>{{ decision.risks }}</div>
</div>
</div>
</template>
<style scoped>
.label {
margin-right: 8px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>
+42 -11
View File
@@ -1,11 +1,13 @@
<script lang="ts" setup>
import type { Effort, Provider } from "@/apis/settings"
import type { Angle, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading"
import type { Analysis, Angle, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading"
import { EFFORTS, listProviders } from "@/apis/settings"
import { ANGLE_LABELS, deleteSymbol, getStatus, isOwnOrder, listEvents, listSymbols, patchConfig, patchSymbol, startTrading, stopTrading } from "@/apis/trading"
import { ANGLE_LABELS, deleteSymbol, getStatus, isOwnOrder, listAnalyses, listEvents, listSymbols, patchConfig, patchSymbol, requestAnalysis, startTrading, stopTrading } from "@/apis/trading"
import { usePersisted } from "@/utils/storage"
import { formatTime } from "@/utils/text"
import AddSymbolDialog from "./components/AddSymbolDialog.vue"
import AnalysisDrawer from "./components/AnalysisDrawer.vue"
import PlanCard from "./components/PlanCard.vue"
const STATUS_REFRESH_MS = 3000
const EVENTS_PAGE = 50
@@ -20,6 +22,10 @@ const events = ref<TradingEvent[]>([])
const eventsExhausted = ref(false)
const addVisible = ref(false)
const toggling = ref(false)
const latestAnalysis = ref<Analysis | null>(null)
const requesting = ref(false)
const drawerVisible = ref(false)
const drawerAnalysisId = ref<number | null>(null)
const snapshot = computed(() => status.value?.snapshot ?? null)
const account = computed(() => snapshot.value?.account ?? null)
@@ -86,6 +92,30 @@ async function refresh() {
[status.value, symbols.value] = await Promise.all([getStatus(), listSymbols()])
if (!current.value && symbols.value.length)
selected.value = symbols.value[0].symbol
await refreshAnalysis()
}
async function refreshAnalysis() {
const symbol = selected.value
const latest = symbol ? (await listAnalyses({ symbol, limit: 1 }))[0] ?? null : null
if (symbol === selected.value)
latestAnalysis.value = latest
}
async function analyze() {
requesting.value = true
try {
await requestAnalysis(selected.value)
ElMessage.success(status.value?.running ? "已排队分析" : "已排队试运行分析:计划只记录,不下单")
await refresh()
} finally {
requesting.value = false
}
}
function openAnalysis(id: number) {
drawerAnalysisId.value = id
drawerVisible.value = true
}
/** 拉取最新一页,与已加载的合并(保留已翻页加载的旧事件) */
@@ -113,7 +143,9 @@ async function loadMoreEvents() {
watch(selected, () => {
events.value = []
latestAnalysis.value = null
refreshEvents()
refreshAnalysis()
})
async function toggleRunning() {
@@ -355,15 +387,10 @@ onBeforeUnmount(() => clearInterval(timer))
<!-- 主区域:当前计划、挂单、时间线 -->
<el-scrollbar class="flex-1">
<div class="flex flex-col gap-3 pr-2">
<div v-if="current" class="border border-[var(--el-border-color)] rounded border-solid p-3">
<div class="mb-2 font-bold">
{{ current.symbol }} 当前计划
<span class="ml-2 text-xs text-[var(--el-text-color-secondary)] font-normal">{{ ANGLE_LABELS[current.angle] }}</span>
</div>
<div class="text-sm text-[var(--el-text-color-secondary)]">
暂无计划
</div>
</div>
<PlanCard
v-if="current" :symbol="current" :analysis="latestAnalysis" :requesting="requesting"
@analyze="analyze" @detail="openAnalysis"
/>
<div v-if="current">
<div class="mb-2 font-bold">
@@ -415,6 +442,9 @@ onBeforeUnmount(() => clearInterval(timer))
{{ e.symbol ?? "账户" }}
</el-tag>
<span :class="e.level === 'error' ? 'text-[var(--el-color-danger)]' : ''">{{ e.message }}</span>
<el-link v-if="e.analysis_id && e.kind !== 'analysis_started'" type="primary" underline="never" class="ml-2" @click="openAnalysis(e.analysis_id)">
查看分析
</el-link>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="还没有事件" :image-size="60" />
@@ -427,6 +457,7 @@ onBeforeUnmount(() => clearInterval(timer))
</div>
<AddSymbolDialog v-model="addVisible" @added="onAdded" />
<AnalysisDrawer v-model="drawerVisible" :analysis-id="drawerAnalysisId" />
</div>
</template>