Add demo trading executor and analysis scheduling (plan 3 C4)
- Executor reconciles each symbol to its desired orders: journaled ap{id}
client ids, reduce-only quantity stop placed before entries, make-before-
break stop moves, breakeven after TP1, orphan cancel, -2021 market close
- Plan lifecycle: proposed plans are adopted only if the position is
unchanged; holding-mode plans may only tighten the stop
- Program rules: time stop, short-term invalidation close, long-term 4h
review, reevaluate conditions, takeover of unmanaged positions
- Trigger and routine analysis scheduling with a 10 min gap, daily cap and
bounded retries
- Plan endpoint and plan status, fills and countdowns on the trading page
This commit is contained in:
1 parent
78ca4422af
commit
1fcf41952c
12 files changed
+1192
-67
No files matched your search
+45
-3
@@ -77,7 +77,10 @@ export interface TradingSymbol {
|
||||
added_at: string
|
||||
analysis_requested_at: string | null
|
||||
analyzing: boolean
|
||||
plan_status: "pending" | "open" | null
|
||||
/** 执行器正在管理的计划 */
|
||||
plan_id: number | null
|
||||
plan_status: "pending" | "open" | "closing" | null
|
||||
close_deadline: string | null
|
||||
}
|
||||
|
||||
export interface SymbolOption {
|
||||
@@ -183,6 +186,10 @@ export interface Sizing {
|
||||
capped_by_leverage: boolean
|
||||
reward_risk: string
|
||||
reward_risk_first_only: string
|
||||
/** 采纳前核对用:提交时的持仓数量 */
|
||||
position_qty: string
|
||||
/** 持仓中调整的计划:不加仓 */
|
||||
managed: boolean
|
||||
}
|
||||
|
||||
export interface Analysis {
|
||||
@@ -201,13 +208,13 @@ export interface Analysis {
|
||||
usage: { input_tokens: number, output_tokens: number } | null
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
plan_status: "pending" | "open" | "closed" | "canceled" | "replaced" | null
|
||||
plan_status: PlanStatus | 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
|
||||
result: { realized_pnl: string, commission: string, net: string } | null
|
||||
}
|
||||
|
||||
/** 与协议无关的消息格式,同 Chat */
|
||||
@@ -221,6 +228,41 @@ export interface AnalysisDetail extends Analysis {
|
||||
transcript: TranscriptMessage[] | null
|
||||
}
|
||||
|
||||
export type PlanStatus = "proposed" | "pending" | "open" | "closing" | "closed" | "canceled" | "replaced" | "stale" | "applied"
|
||||
|
||||
export const PLAN_STATUS_LABELS: Record<PlanStatus, string> = {
|
||||
proposed: "待采纳",
|
||||
pending: "挂单中",
|
||||
open: "持仓中",
|
||||
closing: "平仓中",
|
||||
closed: "已结束",
|
||||
canceled: "已撤销",
|
||||
replaced: "已被替换",
|
||||
stale: "已作废",
|
||||
applied: "已执行"
|
||||
}
|
||||
|
||||
/** 执行器的订单流水 */
|
||||
export interface OrderRow {
|
||||
id: number
|
||||
purpose: "entry" | "stop" | "tp" | "close"
|
||||
leg: number | null
|
||||
side: "BUY" | "SELL"
|
||||
type: string
|
||||
price: string | null
|
||||
trigger_price: string | null
|
||||
quantity: string
|
||||
status: string
|
||||
filled_qty: string
|
||||
avg_price: string | null
|
||||
error: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function getSymbolPlan(symbol: string): Promise<{ plan: Analysis | null, orders: OrderRow[] }> {
|
||||
return http.get(`/trading/symbols/${symbol}/plan`)
|
||||
}
|
||||
|
||||
export function requestAnalysis(symbol: string): Promise<void> {
|
||||
return http.post(`/trading/symbols/${symbol}/analyze`)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Analysis, PlanCondition, TradingSymbol } from "@/apis/trading"
|
||||
import { ANGLE_LABELS } from "@/apis/trading"
|
||||
import type { Analysis, OrderRow, PlanCondition, TradingSymbol } from "@/apis/trading"
|
||||
import { ANGLE_LABELS, PLAN_STATUS_LABELS } from "@/apis/trading"
|
||||
import { formatTime } from "@/utils/text"
|
||||
|
||||
const props = defineProps<{ symbol: TradingSymbol, analysis: Analysis | null, requesting: boolean }>()
|
||||
/** analysis:执行中的计划,没有时为最近一次分析;latest:最近一次分析(与 analysis 不同时另行提示) */
|
||||
const props = defineProps<{ symbol: TradingSymbol, analysis: Analysis | null, latest: Analysis | null, orders: OrderRow[], requesting: boolean }>()
|
||||
const emit = defineEmits<{ analyze: [], detail: [id: number] }>()
|
||||
|
||||
const ACTION_LABELS = { plan: "新计划", keep: "维持当前计划", exit: "结束当前计划", wait: "观望" } as const
|
||||
@@ -19,6 +20,20 @@ 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 filled(purpose: "entry" | "tp", leg: number) {
|
||||
const total = props.orders.filter(o => o.purpose === purpose && o.leg === leg).reduce((sum, o) => sum + Number(o.filled_qty), 0)
|
||||
return total ? String(Number(total.toFixed(8))) : "-"
|
||||
}
|
||||
|
||||
function countdown(deadline: string | null) {
|
||||
if (!deadline)
|
||||
return ""
|
||||
const seconds = Math.round((new Date(deadline).getTime() - now.value) / 1000)
|
||||
if (seconds <= 0)
|
||||
return "已到期"
|
||||
return `${Math.floor(seconds / 3600)} 小时 ${Math.floor(seconds % 3600 / 60)} 分`
|
||||
}
|
||||
|
||||
function condition(c: PlanCondition) {
|
||||
return `${TIMEFRAME_LABELS[c.timeframe]}${c.direction === "above" ? "高于" : "低于"} ${c.price}`
|
||||
}
|
||||
@@ -56,6 +71,9 @@ const invalidationAction = computed(() => props.analysis?.angle === "short" ? "
|
||||
<el-tag v-if="analysis.dry_run" type="info" size="small">
|
||||
试运行
|
||||
</el-tag>
|
||||
<el-tag v-if="analysis.plan_status" type="success" effect="dark" size="small">
|
||||
{{ PLAN_STATUS_LABELS[analysis.plan_status] }}
|
||||
</el-tag>
|
||||
<el-tag size="small">
|
||||
{{ ACTION_LABELS[decision.action] }}
|
||||
</el-tag>
|
||||
@@ -73,10 +91,33 @@ const invalidationAction = computed(() => props.analysis?.angle === "short" ? "
|
||||
<div class="font-bold">
|
||||
{{ decision.summary }}
|
||||
</div>
|
||||
<div v-if="latest && latest.id !== analysis.id" class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
最近一次分析 #{{ latest.id }}({{ formatTime(latest.started_at) }}):
|
||||
<template v-if="latest.status === 'done'">
|
||||
{{ latest.decision?.summary }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ latest.status === "running" ? "分析中" : `失败:${latest.error}` }}
|
||||
</template>
|
||||
<el-link type="primary" underline="never" class="ml-1 text-xs!" @click="emit('detail', latest.id)">
|
||||
查看
|
||||
</el-link>
|
||||
</div>
|
||||
<div v-if="analysis.valid_until || analysis.close_deadline" class="flex flex-wrap gap-x-6">
|
||||
<span v-if="analysis.plan_status === 'pending' && analysis.valid_until"><span class="label">入场单有效至</span>{{ formatTime(analysis.valid_until, "MM-DD HH:mm") }}(剩 {{ countdown(analysis.valid_until) }})</span>
|
||||
<span v-if="analysis.first_fill_at"><span class="label">首档成交</span>{{ formatTime(analysis.first_fill_at, "MM-DD HH:mm") }}</span>
|
||||
<span v-if="analysis.close_deadline"><span class="label">最晚平仓</span>{{ formatTime(analysis.close_deadline, "MM-DD HH:mm") }}(剩 {{ countdown(analysis.close_deadline) }})</span>
|
||||
</div>
|
||||
<div v-if="analysis.result">
|
||||
<span class="label">结果</span>{{ analysis.close_reason }},净盈亏 {{ analysis.result.net }} USDT(手续费 {{ analysis.result.commission }})
|
||||
</div>
|
||||
<div><span class="label">为什么</span>{{ decision.reasons }}</div>
|
||||
|
||||
<div v-if="sizing?.managed" class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
持仓中调整的计划:不加仓,止盈按当前持仓数量拆分
|
||||
</div>
|
||||
<template v-if="decision.action === 'plan' && sizing">
|
||||
<el-table :data="sizing.entries" border size="small">
|
||||
<el-table v-if="sizing.entries.length" :data="sizing.entries" border size="small">
|
||||
<el-table-column label="入场档位" min-width="110">
|
||||
<template #default="{ $index }">
|
||||
第 {{ $index + 1 }} 档
|
||||
@@ -90,6 +131,11 @@ const invalidationAction = computed(() => props.analysis?.angle === "short" ? "
|
||||
</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="80">
|
||||
<template #default="{ $index }">
|
||||
{{ filled("entry", $index) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="依据" min-width="200">
|
||||
<template #default="{ $index }">
|
||||
{{ decision.entries?.[$index]?.basis }}
|
||||
@@ -109,6 +155,11 @@ const invalidationAction = computed(() => props.analysis?.angle === "short" ? "
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="数量" min-width="90" />
|
||||
<el-table-column label="已成交" min-width="80">
|
||||
<template #default="{ $index }">
|
||||
{{ filled("tp", $index) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="依据" min-width="300">
|
||||
<template #default="{ $index }">
|
||||
{{ decision.take_profits?.[$index]?.basis }}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Effort, Provider } from "@/apis/settings"
|
||||
import type { Analysis, Angle, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading"
|
||||
import type { Analysis, Angle, OrderRow, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading"
|
||||
import { EFFORTS, listProviders } from "@/apis/settings"
|
||||
import { ANGLE_LABELS, deleteSymbol, getStatus, isOwnOrder, listAnalyses, listEvents, listSymbols, patchConfig, patchSymbol, requestAnalysis, startTrading, stopTrading } from "@/apis/trading"
|
||||
import { ANGLE_LABELS, deleteSymbol, getStatus, getSymbolPlan, 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"
|
||||
@@ -23,6 +23,8 @@ const eventsExhausted = ref(false)
|
||||
const addVisible = ref(false)
|
||||
const toggling = ref(false)
|
||||
const latestAnalysis = ref<Analysis | null>(null)
|
||||
const livePlan = ref<Analysis | null>(null)
|
||||
const planOrders = ref<OrderRow[]>([])
|
||||
const requesting = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const drawerAnalysisId = ref<number | null>(null)
|
||||
@@ -81,6 +83,8 @@ function exitPrices(p: SnapshotPosition) {
|
||||
function symbolState(s: TradingSymbol) {
|
||||
if (s.analyzing)
|
||||
return { label: "分析中", type: "warning" } as const
|
||||
if (s.plan_status === "closing")
|
||||
return { label: "平仓中", type: "danger" } as const
|
||||
if (positions.value.some(p => p.symbol === s.symbol))
|
||||
return { label: "持仓中", type: "success" } as const
|
||||
if (s.plan_status === "pending")
|
||||
@@ -97,9 +101,23 @@ async function refresh() {
|
||||
|
||||
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
|
||||
if (!symbol)
|
||||
return
|
||||
const [latest, { plan, orders }] = await Promise.all([listAnalyses({ symbol, limit: 1 }), getSymbolPlan(symbol)])
|
||||
if (symbol !== selected.value)
|
||||
return
|
||||
latestAnalysis.value = latest[0] ?? null
|
||||
livePlan.value = plan
|
||||
planOrders.value = orders
|
||||
}
|
||||
|
||||
/** 持仓的平仓倒计时:取该币执行中计划的最晚平仓时间 */
|
||||
function closeCountdown(symbol: string) {
|
||||
const deadline = symbols.value.find(s => s.symbol === symbol)?.close_deadline
|
||||
if (!deadline)
|
||||
return "-"
|
||||
const minutes = Math.round((new Date(deadline).getTime() - Date.now()) / 60000)
|
||||
return minutes <= 0 ? "已到期" : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
|
||||
}
|
||||
|
||||
async function analyze() {
|
||||
@@ -144,6 +162,8 @@ async function loadMoreEvents() {
|
||||
watch(selected, () => {
|
||||
events.value = []
|
||||
latestAnalysis.value = null
|
||||
livePlan.value = null
|
||||
planOrders.value = []
|
||||
refreshEvents()
|
||||
refreshAnalysis()
|
||||
})
|
||||
@@ -348,6 +368,11 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
{{ exitPrices(row as SnapshotPosition).stop }} / {{ exitPrices(row as SnapshotPosition).target }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平仓倒计时" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ closeCountdown(row.symbol) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="min-h-0 flex flex-1 gap-3">
|
||||
@@ -388,7 +413,8 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
<el-scrollbar class="flex-1">
|
||||
<div class="flex flex-col gap-3 pr-2">
|
||||
<PlanCard
|
||||
v-if="current" :symbol="current" :analysis="latestAnalysis" :requesting="requesting"
|
||||
v-if="current" :symbol="current" :analysis="livePlan ?? latestAnalysis" :latest="latestAnalysis" :orders="planOrders"
|
||||
:requesting="requesting"
|
||||
@analyze="analyze" @detail="openAnalysis"
|
||||
/>
|
||||
|
||||
|
||||
Reference in new issue
Block a user