Rebuild position history from Binance fills

Sync account fills into a new trading_fills table every 60s and
reconstruct closed positions from them, so positions opened outside
the program are included and external closes get an exit price.
Positions link to plans by order id. The history drawer gains symbol,
close-date, outcome and source filters, a summary bar and pagination.
This commit is contained in:
eezd committed 2026-10-16 11:57:45 +08:00
1 parent a2e51a3011
commit 010d89e0f5
10 files changed
+589 -140

No files matched your search

+31 -9
View File
@@ -267,28 +267,50 @@ 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
/** 计划 3.1 之前替换计划时会接管旧计划的持仓,这类旧记录没有自己的入场成交,为空 */
quantity: string | null
entry_price: string | null
/** 外部平仓没有成交记录,为空 */
exit_price: string | null
quantity: string
entry_price: string
exit_price: string
realized_pnl: string
commission: string
net: string
source: "program" | "manual"
/** 手动仓位没有关联计划,为空 */
analysis_id: number | null
close_reason: string | null
}
export function listPositionHistory(params: { before?: number, limit?: number }): Promise<PositionHistory[]> {
export interface PositionHistoryQuery {
symbol?: string
/** 按平仓时间筛选,ISO 时间 */
start?: string
end?: string
outcome?: "profit" | "loss"
source?: "program" | "manual"
page: number
limit: number
}
export interface PositionHistoryPage {
/** 按筛选条件统计全部仓位 */
summary: { count: number, wins: number, net: string, commission: string }
total: number
items: PositionHistory[]
}
export function listPositionHistory(params: PositionHistoryQuery): Promise<PositionHistoryPage> {
return http.get("/trading/positions/history", { params })
}
export function listPositionHistorySymbols(): Promise<string[]> {
return http.get("/trading/positions/history/symbols")
}
export function getSymbolPlan(symbol: string): Promise<{ plan: Analysis | null, orders: OrderRow[] }> {
return http.get(`/trading/symbols/${symbol}/plan`)
}
@@ -1,36 +1,74 @@
<script lang="ts" setup>
import type { PositionHistory } from "@/apis/trading"
import { CLOSE_REASON_LABELS, listPositionHistory } from "@/apis/trading"
import type { PositionHistory, PositionHistoryPage } from "@/apis/trading"
import dayjs from "dayjs"
import { CLOSE_REASON_LABELS, listPositionHistory, listPositionHistorySymbols } from "@/apis/trading"
import { formatTime } from "@/utils/text"
const emit = defineEmits<{ detail: [id: number] }>()
const visible = defineModel<boolean>({ required: true })
const PAGE = 50
const DATE_SHORTCUTS = [
{ text: "今日", value: () => [dayjs().startOf("day").toDate(), new Date()] },
{ text: "近 7 天", value: () => [dayjs().subtract(6, "day").startOf("day").toDate(), new Date()] },
{ text: "近 30 天", value: () => [dayjs().subtract(29, "day").startOf("day").toDate(), new Date()] }
]
const items = ref<PositionHistory[]>([])
const exhausted = ref(false)
const filters = reactive({
symbol: undefined as string | undefined,
range: null as [Date, Date] | null,
outcome: "" as "" | "profit" | "loss",
source: "" as "" | "program" | "manual"
})
const page = ref(1)
const result = ref<PositionHistoryPage>()
const symbols = ref<string[]>([])
const loading = ref(false)
// 快速切换筛选时只采用最后一次请求的结果
let requestSeq = 0
async function load(more = false) {
async function load() {
const seq = ++requestSeq
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
const data = await listPositionHistory({
symbol: filters.symbol,
// 日期范围按自然日:结束日当天整天都算
start: filters.range ? dayjs(filters.range[0]).startOf("day").toISOString() : undefined,
end: filters.range ? dayjs(filters.range[1]).startOf("day").add(1, "day").toISOString() : undefined,
outcome: filters.outcome || undefined,
source: filters.source || undefined,
page: page.value,
limit: PAGE
})
if (seq === requestSeq)
result.value = data
} finally {
loading.value = false
if (seq === requestSeq)
loading.value = false
}
}
watch(visible, (open) => {
if (open)
load()
function search() {
page.value = 1
load()
}
watch(visible, async (open) => {
if (!open)
return
search()
symbols.value = await listPositionHistorySymbols()
})
const winRate = computed(() => {
const s = result.value?.summary
return s?.count ? `${Math.round(s.wins / s.count * 1000) / 10}%` : "-"
})
/** 均价保留 7 位有效数字,去掉末尾的 0 */
function price(value: string | null) {
return value === null ? "-" : String(Number(Number(value).toPrecision(7)))
function price(value: string) {
return String(Number(Number(value).toPrecision(7)))
}
function holding(row: PositionHistory) {
@@ -49,80 +87,131 @@ function pnlClass(value: string) {
<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>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-3">
<el-select v-model="filters.symbol" placeholder="全部币种" clearable class="w-40" @change="search">
<el-option v-for="s in symbols" :key="s" :value="s" :label="s" />
</el-select>
<el-date-picker
v-model="filters.range" type="daterange" start-placeholder="平仓开始日" end-placeholder="平仓结束日"
:shortcuts="DATE_SHORTCUTS" class="max-w-72" @change="search"
/>
<el-radio-group v-model="filters.outcome" @change="search">
<el-radio-button value="">
全部
</el-radio-button>
<el-radio-button value="profit">
盈利
</el-radio-button>
<el-radio-button value="loss">
亏损
</el-radio-button>
</el-radio-group>
<el-radio-group v-model="filters.source" @change="search">
<el-radio-button value="">
全部来源
</el-radio-button>
<el-radio-button value="program">
程序
</el-radio-button>
<el-radio-button value="manual">
手动
</el-radio-button>
</el-radio-group>
</div>
<div v-if="result" class="flex flex-wrap gap-x-8 gap-y-1 border border-slate-200 rounded-lg border-solid bg-slate-50 px-4 py-3 text-xs text-slate-500">
<span>笔数 <b class="num ml-1 text-sm text-slate-900">{{ result.summary.count }}</b></span>
<span>盈利 <b class="num ml-1 text-sm text-slate-900">{{ result.summary.wins }}</b></span>
<span>胜率 <b class="num ml-1 text-sm text-slate-900">{{ winRate }}</b></span>
<span>净盈亏 <b class="num ml-1 text-sm" :class="pnlClass(result.summary.net)">{{ Number(result.summary.net).toFixed(2) }} USDT</b></span>
<span>手续费 <b class="num ml-1 text-sm text-slate-900">{{ Number(result.summary.commission).toFixed(2) }} USDT</b></span>
</div>
<el-table v-loading="loading" :data="result?.items ?? []" cell-class-name="num" empty-text="没有符合条件的仓位">
<el-table-column label="币种" min-width="84">
<template #default="{ row }">
<span class="text-slate-900 font-semibold">{{ row.symbol }}</span>
</template>
</el-table-column>
<el-table-column label="来源" width="64">
<template #default="{ row }">
<el-tag :type="row.source === 'program' ? 'primary' : 'warning'" size="small">
{{ row.source === "program" ? "程序" : "手动" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="方向" width="70">
<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="100">
<template #default="{ row }">
<span :title="`数量 ${Number(row.quantity)}`">{{ (Number(row.quantity) * Number(row.entry_price)).toFixed(2) }}</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="112">
<template #default="{ row }">
<span class="font-sans">{{ row.close_reason ? CLOSE_REASON_LABELS[row.close_reason] ?? row.close_reason : "手动" }}</span>
</template>
</el-table-column>
<el-table-column label="已实现盈亏" min-width="84">
<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="60">
<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="70">
<template #default="{ row }">
<el-link v-if="row.analysis_id" type="primary" underline="never" title="查看这次计划的分析" @click="emit('detail', row.analysis_id)">
#{{ row.analysis_id }}
</el-link>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="result && result.total > PAGE" v-model:current-page="page" :page-size="PAGE" :total="result.total"
layout="total, prev, pager, next" class="justify-end" @current-change="load"
/>
</div>
</el-drawer>
</template>