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:
eezd committed 2026-10-15 11:07:00 +08:00
1 parent 78ca4422af
commit 1fcf41952c
12 files changed
+1192 -67

No files matched your search

+55 -4
View File
@@ -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 }}
+32 -6
View File
@@ -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"
/>