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
+216
-1
No files matched your search
@@ -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:
|
||||
"""排队一次手动分析:引擎停止时为试运行,计划只记录、不下单"""
|
||||
|
||||
@@ -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>
|
||||
<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