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
+217 -2

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