Add a position history view to demo trading
New GET /api/trading/positions/history lists closed plans (entry filled, position back to zero) newest first with before/limit paging. Each row carries the filled quantity, volume-weighted entry and exit prices from the plan's own fills, the close reason and the realized PnL, commission and net from the plan result. The positions panel gets a "历史仓位" button that opens a drawer with the table, a "load earlier" button and links to each plan's analysis.
This commit is contained in:
1 parent
c520ba4c2f
commit
fb63ebb697
4 files changed
+217
-2
No files matched your search
@@ -253,6 +253,8 @@ export const CLOSE_REASON_LABELS: Record<string, string> = {
|
||||
external: "外部平仓",
|
||||
stop_failed: "止损挂单失败",
|
||||
close: "市价平仓",
|
||||
// 计划 3.1 之前的旧原因,历史仓位里还会出现
|
||||
invalidation: "短线失效条件触发",
|
||||
replaced: "被新计划替换",
|
||||
expired: "限价单到期未成交",
|
||||
stopped: "已停止交易",
|
||||
@@ -265,6 +267,28 @@ export const TAKE_PROFIT_STEPS: Record<"long" | "short", [number, number][]> = {
|
||||
short: [[0.005, 0.8], [0.0075, 0.2]]
|
||||
}
|
||||
|
||||
/** 已平仓的仓位:一个计划从首次入场到持仓归零;数值为十进制字符串 */
|
||||
export interface PositionHistory {
|
||||
analysis_id: number
|
||||
symbol: string
|
||||
direction: "long" | "short"
|
||||
opened_at: string
|
||||
closed_at: string
|
||||
close_reason: string
|
||||
/** 计划被替换后接管持仓的新计划没有自己的入场成交,为空 */
|
||||
quantity: string | null
|
||||
entry_price: string | null
|
||||
/** 外部平仓没有成交记录,为空 */
|
||||
exit_price: string | null
|
||||
realized_pnl: string
|
||||
commission: string
|
||||
net: string
|
||||
}
|
||||
|
||||
export function listPositionHistory(params: { before?: number, limit?: number }): Promise<PositionHistory[]> {
|
||||
return http.get("/trading/positions/history", { params })
|
||||
}
|
||||
|
||||
export function getSymbolPlan(symbol: string): Promise<{ plan: Analysis | null, orders: OrderRow[] }> {
|
||||
return http.get(`/trading/symbols/${symbol}/plan`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PositionHistory } from "@/apis/trading"
|
||||
import { CLOSE_REASON_LABELS, listPositionHistory } from "@/apis/trading"
|
||||
import { formatTime } from "@/utils/text"
|
||||
|
||||
const emit = defineEmits<{ detail: [id: number] }>()
|
||||
const visible = defineModel<boolean>({ required: true })
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
const items = ref<PositionHistory[]>([])
|
||||
const exhausted = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
async function load(more = false) {
|
||||
loading.value = true
|
||||
try {
|
||||
const page = await listPositionHistory({ before: more ? items.value.at(-1)?.analysis_id : undefined, limit: PAGE })
|
||||
items.value = more ? [...items.value, ...page] : page
|
||||
exhausted.value = page.length < PAGE
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, (open) => {
|
||||
if (open)
|
||||
load()
|
||||
})
|
||||
|
||||
/** 均价保留 7 位有效数字,去掉末尾的 0 */
|
||||
function price(value: string | null) {
|
||||
return value === null ? "-" : String(Number(Number(value).toPrecision(7)))
|
||||
}
|
||||
|
||||
function holding(row: PositionHistory) {
|
||||
const seconds = Math.round((new Date(row.closed_at).getTime() - new Date(row.opened_at).getTime()) / 1000)
|
||||
if (seconds < 60)
|
||||
return `${seconds}s`
|
||||
const minutes = Math.round(seconds / 60)
|
||||
return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
|
||||
}
|
||||
|
||||
function pnlClass(value: string) {
|
||||
const n = Number(value)
|
||||
return n > 0 ? "text-emerald-600" : n < 0 ? "text-rose-600" : ""
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer v-model="visible" title="历史仓位" size="90%">
|
||||
<el-table v-loading="loading" :data="items" cell-class-name="num" empty-text="还没有平仓的仓位">
|
||||
<el-table-column label="币种" min-width="90">
|
||||
<template #default="{ row }">
|
||||
<span class="text-slate-900 font-semibold">{{ row.symbol }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="方向" width="76">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.direction === 'long' ? 'success' : 'danger'" size="small">
|
||||
{{ row.direction === "long" ? "↑ 多" : "↓ 空" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开仓时间" width="126">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.opened_at, "MM-DD HH:mm") }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平仓时间" width="126">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.closed_at, "MM-DD HH:mm") }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="持仓时长" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ holding(row as PositionHistory) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="仓位价值(USDT)" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.quantity" :title="`数量 ${Number(row.quantity)}`">{{ (Number(row.quantity) * Number(row.entry_price)).toFixed(2) }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开仓均价" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ price(row.entry_price) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平仓均价" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ price(row.exit_price) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平仓原因" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="font-sans">{{ CLOSE_REASON_LABELS[row.close_reason] ?? row.close_reason }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已实现盈亏" min-width="90">
|
||||
<template #default="{ row }">
|
||||
<span :class="pnlClass(row.realized_pnl)">{{ Number(row.realized_pnl).toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="手续费" min-width="70">
|
||||
<template #default="{ row }">
|
||||
<span class="text-slate-500">{{ Number(row.commission).toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="净盈亏" min-width="80">
|
||||
<template #default="{ row }">
|
||||
<b :class="pnlClass(row.net)">{{ Number(row.net).toFixed(2) }}</b>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分析" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" underline="never" title="查看这次计划的分析" @click="emit('detail', row.analysis_id)">
|
||||
#{{ row.analysis_id }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-button v-if="items.length && !exhausted" link type="primary" class="mt-3" :loading="loading" @click="load(true)">
|
||||
加载更早的仓位
|
||||
</el-button>
|
||||
</el-drawer>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Effort, Provider } from "@/apis/settings"
|
||||
import type { Analysis, OrderRow, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading"
|
||||
import { Edit, Plus } from "@element-plus/icons-vue"
|
||||
import { Clock, Edit, Plus } from "@element-plus/icons-vue"
|
||||
import { EFFORTS, listProviders } from "@/apis/settings"
|
||||
import { deleteSymbol, getStatus, getSymbolPlan, isOwnOrder, listAnalyses, listEvents, listSymbols, patchConfig, requestAnalysis, startTrading, stopTrading } from "@/apis/trading"
|
||||
import { usePersisted } from "@/utils/storage"
|
||||
@@ -9,6 +9,7 @@ import { formatTime } from "@/utils/text"
|
||||
import AddSymbolDialog from "./components/AddSymbolDialog.vue"
|
||||
import AnalysisDrawer from "./components/AnalysisDrawer.vue"
|
||||
import PlanCard from "./components/PlanCard.vue"
|
||||
import PositionHistoryDrawer from "./components/PositionHistoryDrawer.vue"
|
||||
|
||||
const STATUS_REFRESH_MS = 3000
|
||||
const EVENTS_PAGE = 50
|
||||
@@ -28,6 +29,7 @@ const planOrders = ref<OrderRow[]>([])
|
||||
const requesting = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const drawerAnalysisId = ref<number | null>(null)
|
||||
const historyVisible = ref(false)
|
||||
|
||||
const snapshot = computed(() => status.value?.snapshot ?? null)
|
||||
const account = computed(() => snapshot.value?.account ?? null)
|
||||
@@ -373,7 +375,12 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
<!-- 持仓 -->
|
||||
<el-card shadow="never" body-class="p-0">
|
||||
<template #header>
|
||||
持仓({{ positions.length }})
|
||||
<div class="flex items-center justify-between">
|
||||
持仓({{ positions.length }})
|
||||
<el-button link type="primary" :icon="Clock" @click="historyVisible = true">
|
||||
历史仓位
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="positions" empty-text="没有持仓" cell-class-name="num">
|
||||
<el-table-column label="币种" min-width="120">
|
||||
@@ -563,6 +570,7 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
</div>
|
||||
|
||||
<AddSymbolDialog v-model="addVisible" @added="onAdded" />
|
||||
<PositionHistoryDrawer v-model="historyVisible" @detail="openAnalysis" />
|
||||
<AnalysisDrawer v-model="drawerVisible" :analysis-id="drawerAnalysisId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in new issue
Block a user