diff --git a/backend/src/alphapulse/api/trading.py b/backend/src/alphapulse/api/trading.py index ec308ba..5ab450b 100644 --- a/backend/src/alphapulse/api/trading.py +++ b/backend/src/alphapulse/api/trading.py @@ -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: """排队一次手动分析:引擎停止时为试运行,计划只记录、不下单""" diff --git a/web/src/apis/trading.ts b/web/src/apis/trading.ts index ed9f345..90b29f6 100644 --- a/web/src/apis/trading.ts +++ b/web/src/apis/trading.ts @@ -253,6 +253,8 @@ export const CLOSE_REASON_LABELS: Record = { 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 { + 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`) } diff --git a/web/src/pages/trading/components/PositionHistoryDrawer.vue b/web/src/pages/trading/components/PositionHistoryDrawer.vue new file mode 100644 index 0000000..06ef2de --- /dev/null +++ b/web/src/pages/trading/components/PositionHistoryDrawer.vue @@ -0,0 +1,128 @@ + + + diff --git a/web/src/pages/trading/index.vue b/web/src/pages/trading/index.vue index 391c65c..221ecfd 100644 --- a/web/src/pages/trading/index.vue +++ b/web/src/pages/trading/index.vue @@ -1,7 +1,7 @@