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 的快照""" """模拟盘页:引擎状态、启停、交易币种、时间线。账户和持仓来自 trader 进程写入 Redis 的快照"""
import json import json
from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Annotated from typing import Annotated
@@ -141,6 +142,23 @@ class EventOut(BaseModel):
data: dict | None 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: async def get_account(session: AsyncSession) -> TradingAccount:
return await session.get(TradingAccount, 1) or TradingAccount(id=1, running=False) 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)] 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) @router.post("/symbols/{symbol}/analyze", status_code=status.HTTP_202_ACCEPTED)
async def request_analysis(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None: 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: "外部平仓", external: "外部平仓",
stop_failed: "止损挂单失败", stop_failed: "止损挂单失败",
close: "市价平仓", close: "市价平仓",
// 计划 3.1 之前的旧原因,历史仓位里还会出现
invalidation: "短线失效条件触发",
replaced: "被新计划替换", replaced: "被新计划替换",
expired: "限价单到期未成交", expired: "限价单到期未成交",
stopped: "已停止交易", stopped: "已停止交易",
@@ -265,6 +267,28 @@ export const TAKE_PROFIT_STEPS: Record<"long" | "short", [number, number][]> = {
short: [[0.005, 0.8], [0.0075, 0.2]] 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[] }> { export function getSymbolPlan(symbol: string): Promise<{ plan: Analysis | null, orders: OrderRow[] }> {
return http.get(`/trading/symbols/${symbol}/plan`) 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> <script lang="ts" setup>
import type { Effort, Provider } from "@/apis/settings" import type { Effort, Provider } from "@/apis/settings"
import type { Analysis, OrderRow, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading" 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 { EFFORTS, listProviders } from "@/apis/settings"
import { deleteSymbol, getStatus, getSymbolPlan, isOwnOrder, listAnalyses, listEvents, listSymbols, patchConfig, requestAnalysis, startTrading, stopTrading } from "@/apis/trading" import { deleteSymbol, getStatus, getSymbolPlan, isOwnOrder, listAnalyses, listEvents, listSymbols, patchConfig, requestAnalysis, startTrading, stopTrading } from "@/apis/trading"
import { usePersisted } from "@/utils/storage" import { usePersisted } from "@/utils/storage"
@@ -9,6 +9,7 @@ import { formatTime } from "@/utils/text"
import AddSymbolDialog from "./components/AddSymbolDialog.vue" import AddSymbolDialog from "./components/AddSymbolDialog.vue"
import AnalysisDrawer from "./components/AnalysisDrawer.vue" import AnalysisDrawer from "./components/AnalysisDrawer.vue"
import PlanCard from "./components/PlanCard.vue" import PlanCard from "./components/PlanCard.vue"
import PositionHistoryDrawer from "./components/PositionHistoryDrawer.vue"
const STATUS_REFRESH_MS = 3000 const STATUS_REFRESH_MS = 3000
const EVENTS_PAGE = 50 const EVENTS_PAGE = 50
@@ -28,6 +29,7 @@ const planOrders = ref<OrderRow[]>([])
const requesting = ref(false) const requesting = ref(false)
const drawerVisible = ref(false) const drawerVisible = ref(false)
const drawerAnalysisId = ref<number | null>(null) const drawerAnalysisId = ref<number | null>(null)
const historyVisible = ref(false)
const snapshot = computed(() => status.value?.snapshot ?? null) const snapshot = computed(() => status.value?.snapshot ?? null)
const account = computed(() => snapshot.value?.account ?? null) const account = computed(() => snapshot.value?.account ?? null)
@@ -373,7 +375,12 @@ onBeforeUnmount(() => clearInterval(timer))
<!-- 持仓 --> <!-- 持仓 -->
<el-card shadow="never" body-class="p-0"> <el-card shadow="never" body-class="p-0">
<template #header> <template #header>
<div class="flex items-center justify-between">
持仓({{ positions.length }}) 持仓({{ positions.length }})
<el-button link type="primary" :icon="Clock" @click="historyVisible = true">
历史仓位
</el-button>
</div>
</template> </template>
<el-table :data="positions" empty-text="没有持仓" cell-class-name="num"> <el-table :data="positions" empty-text="没有持仓" cell-class-name="num">
<el-table-column label="币种" min-width="120"> <el-table-column label="币种" min-width="120">
@@ -563,6 +570,7 @@ onBeforeUnmount(() => clearInterval(timer))
</div> </div>
<AddSymbolDialog v-model="addVisible" @added="onAdded" /> <AddSymbolDialog v-model="addVisible" @added="onAdded" />
<PositionHistoryDrawer v-model="historyVisible" @detail="openAnalysis" />
<AnalysisDrawer v-model="drawerVisible" :analysis-id="drawerAnalysisId" /> <AnalysisDrawer v-model="drawerVisible" :analysis-id="drawerAnalysisId" />
</div> </div>
</template> </template>