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:
1 parent
7536c9c5de
commit
78ca4422af
16 files changed
+1465
-151
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user