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:
eezd committed 2026-10-15 23:10:09 +08:00
1 parent c520ba4c2f
commit fb63ebb697
4 files changed
+216 -1

No files matched your search

+55
View File
@@ -1,6 +1,7 @@
"""模拟盘页:引擎状态、启停、交易币种、时间线。账户和持仓来自 trader 进程写入 Redis 的快照"""
import json
from collections import defaultdict
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Annotated
@@ -141,6 +142,23 @@ class EventOut(BaseModel):
data: dict | None
class PositionHistoryOut(BaseModel):
analysis_id: int
symbol: str
direction: str
opened_at: datetime
closed_at: datetime
close_reason: str
# 本计划的入场成交;计划被替换时持仓由新计划接管,新计划没有自己的入场成交,这两项为空
quantity: Decimal | None
entry_price: Decimal | None
# 止盈、止损、市价平仓的成交均价;外部平仓(如网页手动平仓)没有成交记录,为空
exit_price: Decimal | None
realized_pnl: Decimal
commission: Decimal
net: Decimal
async def get_account(session: AsyncSession) -> TradingAccount:
return await session.get(TradingAccount, 1) or TradingAccount(id=1, running=False)
@@ -327,6 +345,43 @@ async def list_events(
return [EventOut.model_validate(e, from_attributes=True) for e in await session.scalars(query)]
def average_fill(rows: list[TradingOrder]) -> tuple[Decimal, Decimal] | None:
"""成交数量与按量加权的均价;没有成交时为空"""
qty = sum((r.filled_qty for r in rows), Decimal(0))
if not qty:
return None
return qty, sum((r.filled_qty * r.avg_price for r in rows), Decimal(0)) / qty
@router.get("/positions/history")
async def list_position_history(
session: Annotated[AsyncSession, Depends(get_session)],
before: int | None = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> list[PositionHistoryOut]:
"""已平仓的计划(入场成交过、持仓已归零),按计划 id 倒序分页;closed 的计划一定有 first_fill_at 和 result"""
query = select(TradingAnalysis).where(TradingAnalysis.plan_status == "closed").order_by(TradingAnalysis.id.desc()).limit(limit)
if before:
query = query.where(TradingAnalysis.id < before)
plans = list(await session.scalars(query))
fills: dict[int, list[TradingOrder]] = defaultdict(list)
for order in await session.scalars(
select(TradingOrder).where(TradingOrder.analysis_id.in_([p.id for p in plans]), TradingOrder.filled_qty > 0)
):
fills[order.analysis_id].append(order)
items = []
for plan in plans:
entry = average_fill([o for o in fills[plan.id] if o.purpose == "entry"])
exit_fill = average_fill([o for o in fills[plan.id] if o.purpose != "entry"])
items.append(PositionHistoryOut(
analysis_id=plan.id, symbol=plan.symbol, direction=plan.sizing["direction"],
opened_at=plan.first_fill_at, closed_at=plan.closed_at, close_reason=plan.close_reason,
quantity=entry[0] if entry else None, entry_price=entry[1] if entry else None,
exit_price=exit_fill[1] if exit_fill else None, **plan.result,
))
return items
@router.post("/symbols/{symbol}/analyze", status_code=status.HTTP_202_ACCEPTED)
async def request_analysis(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
"""排队一次手动分析:引擎停止时为试运行,计划只记录、不下单"""
+24
View File
@@ -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>
+9 -1
View File
@@ -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>
<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>