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
@@ -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:
|
||||||
"""排队一次手动分析:引擎停止时为试运行,计划只记录、不下单"""
|
"""排队一次手动分析:引擎停止时为试运行,计划只记录、不下单"""
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
持仓({{ 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>
|
</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>
|
||||||
|
|||||||
Reference in new issue
Block a user