From 010d89e0f5ff8087fe1030e0c8da9dfc04eeb0f9 Mon Sep 17 00:00:00 2001 From: eezd Date: Fri, 16 Oct 2026 11:57:45 +0800 Subject: [PATCH] Rebuild position history from Binance fills Sync account fills into a new trading_fills table every 60s and reconstruct closed positions from them, so positions opened outside the program are included and external closes get an exit price. Positions link to plans by order id. The history drawer gains symbol, close-date, outcome and source filters, a summary bar and pagination. --- .../20261016_f2a6c8d91b47_trading_fills.py | 43 +++ backend/src/alphapulse/api/trading.py | 109 ++++--- backend/src/alphapulse/models/__init__.py | 3 +- backend/src/alphapulse/models/trading.py | 19 ++ backend/src/alphapulse/trading/executor.py | 17 +- backend/src/alphapulse/trading/fills.py | 70 +++++ backend/src/alphapulse/trading/positions.py | 85 ++++++ backend/tests/test_positions.py | 76 +++++ web/src/apis/trading.ts | 40 ++- .../components/PositionHistoryDrawer.vue | 267 ++++++++++++------ 10 files changed, 589 insertions(+), 140 deletions(-) create mode 100644 backend/alembic/versions/20261016_f2a6c8d91b47_trading_fills.py create mode 100644 backend/src/alphapulse/trading/fills.py create mode 100644 backend/src/alphapulse/trading/positions.py create mode 100644 backend/tests/test_positions.py diff --git a/backend/alembic/versions/20261016_f2a6c8d91b47_trading_fills.py b/backend/alembic/versions/20261016_f2a6c8d91b47_trading_fills.py new file mode 100644 index 0000000..e64b4ba --- /dev/null +++ b/backend/alembic/versions/20261016_f2a6c8d91b47_trading_fills.py @@ -0,0 +1,43 @@ +"""trading fills + +Revision ID: f2a6c8d91b47 +Revises: e8b25c71d4a9 +Create Date: 2026-10-16 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f2a6c8d91b47' +down_revision: Union[str, Sequence[str], None] = 'e8b25c71d4a9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # 历史仓位要包含手动成交,改由币安成交记录还原(2026-10-16) + op.create_table('trading_fills', + sa.Column('symbol', sa.String(length=32), nullable=False), + sa.Column('trade_id', sa.BigInteger(), nullable=False), + sa.Column('order_id', sa.BigInteger(), nullable=False), + sa.Column('side', sa.String(length=4), nullable=False), + sa.Column('price', sa.Numeric(), nullable=False), + sa.Column('qty', sa.Numeric(), nullable=False), + sa.Column('realized_pnl', sa.Numeric(), nullable=False), + sa.Column('commission', sa.Numeric(), nullable=False), + sa.Column('commission_asset', sa.String(length=16), nullable=False), + sa.Column('time', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('symbol', 'trade_id') + ) + op.create_index('ix_trading_fills_symbol_time', 'trading_fills', ['symbol', 'time'], unique=False) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index('ix_trading_fills_symbol_time', table_name='trading_fills') + op.drop_table('trading_fills') diff --git a/backend/src/alphapulse/api/trading.py b/backend/src/alphapulse/api/trading.py index 0595846..966a452 100644 --- a/backend/src/alphapulse/api/trading.py +++ b/backend/src/alphapulse/api/trading.py @@ -1,10 +1,9 @@ """模拟盘页:引擎状态、启停、交易币种、时间线。账户和持仓来自 trader 进程写入 Redis 的快照""" import json -from collections import defaultdict from datetime import datetime, timedelta from decimal import Decimal -from typing import Annotated +from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field @@ -18,8 +17,9 @@ from alphapulse.api.market import call_binance, client as market_client from alphapulse.core.auth import require_admin from alphapulse.core.db import get_session from alphapulse.core.redis import KEY_PREFIX, redis -from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol +from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingFill, TradingOrder, TradingSymbol from alphapulse.trading.client import OWN_CLIENT_ID, ExchangeError, public_get +from alphapulse.trading.positions import build_positions from alphapulse.trading.plan import DAILY_ANALYSIS_LIMIT from alphapulse.trading.snapshot import read_snapshot @@ -142,21 +142,35 @@ class EventOut(BaseModel): data: dict | None -class PositionHistoryOut(BaseModel): - analysis_id: int +class PositionHistoryItem(BaseModel): symbol: str direction: str opened_at: datetime closed_at: datetime - close_reason: str - # 本计划的入场成交。计划 3.1 之前,替换计划时持仓会交给新计划接管(如 #8),这类旧记录没有自己的入场成交,这两项为空 - quantity: Decimal | None - entry_price: Decimal | None - # 止盈、止损、市价平仓的成交均价;外部平仓(如网页手动平仓)没有成交记录,为空 - exit_price: Decimal | None + quantity: Decimal + entry_price: Decimal + exit_price: Decimal realized_pnl: Decimal commission: Decimal net: Decimal + # program:成交里有程序计划的订单;manual:在币安网页等程序外开的仓 + source: str + analysis_id: int | None + close_reason: str | None + + +class PositionSummary(BaseModel): + count: int + wins: int + net: Decimal + commission: Decimal + + +class PositionHistoryPage(BaseModel): + # 按筛选条件统计全部仓位,不只是当前页 + summary: PositionSummary + total: int + items: list[PositionHistoryItem] async def get_account(session: AsyncSession) -> TradingAccount: @@ -345,41 +359,56 @@ 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, + symbol: str | None = None, + start: datetime | None = None, + end: datetime | None = None, + outcome: Literal["profit", "loss"] | None = None, + source: Literal["program", "manual"] | None = None, + page: Annotated[int, Query(ge=1)] = 1, 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) +) -> PositionHistoryPage: + """由币安成交记录还原的已平仓仓位,按平仓时间倒序;start/end 按平仓时间筛选。 + 个人账户成交量小,每次全量还原后在内存里筛选、统计、分页""" + fills = list(await session.scalars(select(TradingFill).order_by(TradingFill.symbol, TradingFill.time, TradingFill.trade_id))) + # 程序下的普通单记录了 orderId;条件单(止损)触发后的订单号没有记录,靠同一仓位里的其他订单关联 + order_plans = dict((await session.execute( + select(TradingOrder.exchange_id, TradingOrder.analysis_id) + .where(TradingOrder.is_algo.is_(False), TradingOrder.exchange_id.is_not(None), TradingOrder.analysis_id.is_not(None)) + )).tuples().all()) + plans = {a.id: a for a in await session.scalars( + select(TradingAnalysis).where(TradingAnalysis.id.in_(set(order_plans.values()))) + )} 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, + for position in build_positions(fills): + linked = [plans[order_plans[str(o)]] for o in position.order_ids if str(o) in order_plans] + # 早期替换计划时持仓会交给新计划接管,优先取最终平仓的那个计划 + plan = max(linked, key=lambda a: (a.plan_status == "closed", a.id)) if linked else None + items.append(PositionHistoryItem( + symbol=position.symbol, direction=position.direction, opened_at=position.opened_at, closed_at=position.closed_at, + quantity=position.quantity, entry_price=position.entry_price, exit_price=position.exit_price, + realized_pnl=position.realized_pnl, commission=position.commission, net=position.net, + source="program" if plan else "manual", analysis_id=plan.id if plan else None, close_reason=plan.close_reason if plan else None, )) - return items + items = [ + i for i in items + if (symbol is None or i.symbol == symbol) and (start is None or i.closed_at >= start) and (end is None or i.closed_at < end) + and (outcome is None or (i.net > 0 if outcome == "profit" else i.net < 0)) and (source is None or i.source == source) + ] + items.sort(key=lambda i: i.closed_at, reverse=True) + summary = PositionSummary( + count=len(items), wins=sum(1 for i in items if i.net > 0), + net=sum((i.net for i in items), Decimal(0)), commission=sum((i.commission for i in items), Decimal(0)), + ) + return PositionHistoryPage(summary=summary, total=len(items), items=items[(page - 1) * limit:page * limit]) + + +@router.get("/positions/history/symbols") +async def list_position_history_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -> list[str]: + """有过成交的币,供历史仓位筛选""" + return list(await session.scalars(select(TradingFill.symbol).distinct().order_by(TradingFill.symbol))) @router.post("/symbols/{symbol}/analyze", status_code=status.HTTP_202_ACCEPTED) diff --git a/backend/src/alphapulse/models/__init__.py b/backend/src/alphapulse/models/__init__.py index 7166efa..66e3a2a 100644 --- a/backend/src/alphapulse/models/__init__.py +++ b/backend/src/alphapulse/models/__init__.py @@ -2,7 +2,7 @@ from alphapulse.core.db import Base from alphapulse.models.ai import AiProvider, ChatMessage, ChatSession, ContextVersion, SkillNode, SkillVersion from alphapulse.models.market import Kline, KlineSyncState, MarketSymbol from alphapulse.models.news import CrawlLog, CrawlRun, NewsItem, NewsRevision, RawBody, RawResponse, Source -from alphapulse.models.trading import TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol +from alphapulse.models.trading import TradingAccount, TradingAnalysis, TradingEvent, TradingFill, TradingOrder, TradingSymbol __all__ = [ "AiProvider", @@ -25,6 +25,7 @@ __all__ = [ "TradingAccount", "TradingAnalysis", "TradingEvent", + "TradingFill", "TradingOrder", "TradingSymbol", ] diff --git a/backend/src/alphapulse/models/trading.py b/backend/src/alphapulse/models/trading.py index 403fd05..626266b 100644 --- a/backend/src/alphapulse/models/trading.py +++ b/backend/src/alphapulse/models/trading.py @@ -125,3 +125,22 @@ class TradingEvent(Base): level: Mapped[str] = mapped_column(String(8), default="info") message: Mapped[str] = mapped_column(Text) data: Mapped[dict | None] = mapped_column(JSONB) + + +class TradingFill(Base): + """币安成交记录(userTrades),历史仓位由它还原;包括程序外的手动成交。trade id 只在单个 symbol 内唯一""" + + __tablename__ = "trading_fills" + __table_args__ = (Index("ix_trading_fills_symbol_time", "symbol", "time"),) + + symbol: Mapped[str] = mapped_column(String(32), primary_key=True) + trade_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + order_id: Mapped[int] = mapped_column(BigInteger) + # BUY / SELL;账户为单向持仓,买为正、卖为负即可还原仓位 + side: Mapped[str] = mapped_column(String(4)) + price: Mapped[Decimal] = mapped_column(Numeric) + qty: Mapped[Decimal] = mapped_column(Numeric) + realized_pnl: Mapped[Decimal] = mapped_column(Numeric) + commission: Mapped[Decimal] = mapped_column(Numeric) + commission_asset: Mapped[str] = mapped_column(String(16)) + time: Mapped[datetime] = mapped_column(DateTime(timezone=True)) diff --git a/backend/src/alphapulse/trading/executor.py b/backend/src/alphapulse/trading/executor.py index 151dfde..e1ab895 100644 --- a/backend/src/alphapulse/trading/executor.py +++ b/backend/src/alphapulse/trading/executor.py @@ -23,6 +23,7 @@ from alphapulse.core.db import SessionLocal from alphapulse.market.binance import BinanceClient from alphapulse.models import TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol from alphapulse.trading.client import OWN_CLIENT_ID, DemoClient, ExchangeError, mark_price, symbol_rules +from alphapulse.trading.fills import sync_fills from alphapulse.trading.plan import ( MAX_HOLD_HOURS, TAKE_PROFIT_STEPS, OrderSpec, SymbolRules, condition_met, desired_orders, diff_orders, round_to_step, ) @@ -31,6 +32,8 @@ from alphapulse.trading.schedule import request_analysis logger = logging.getLogger("alphapulse.trading.executor") EXECUTOR_SECONDS = 2 +# 成交记录只用于历史仓位,不需要跟对账一样频繁 +FILLS_SYNC_SECONDS = 60 ACTIVE_ORDER_STATUSES = ("submitting", "new", "partially_filled") LIVE_PLAN_STATUSES = ("pending", "open", "closing") # 刚下的单可能短时间查不到(C1 实测约 0.25s) @@ -78,6 +81,7 @@ class Executor: self.kline_cache: dict[tuple[str, str], tuple[int, Decimal]] = {} # 已提示过的无计划持仓:symbol -> 持仓均价,同一笔持仓只提示一次 self.unmanaged_noticed: dict[str, Decimal] = {} + self.fills_due = 0.0 # ---------- 基础 ---------- @@ -537,7 +541,7 @@ class Executor: account = await session.get(TradingAccount, 1) symbols = list((await session.scalars(select(TradingSymbol))).all()) client = await self.ensure_client(account) - if client is None or not symbols: + if client is None: return positions = {} for p in await client.request("GET", "/fapi/v3/positionRisk"): @@ -547,6 +551,17 @@ class Executor: side="long" if amount > 0 else "short", qty=abs(amount), entry=Decimal(p["entryPrice"]), break_even=Decimal(p["breakEvenPrice"]), mark=Decimal(p["markPrice"]), ) + # 交易列表为空时也要同步:手动成交同样计入历史仓位。 + # 用独立会话:失败回滚会让会话里的对象全部过期,不能影响下面的对账 + if time.monotonic() >= self.fills_due: + self.fills_due = time.monotonic() + FILLS_SYNC_SECONDS + try: + async with SessionLocal() as fills_session: + fetched = await sync_fills(fills_session, client, {s.symbol for s in symbols} | set(positions)) + if fetched: + logger.info("同步成交记录 %d 条", fetched) + except Exception: + logger.warning("同步成交记录失败,%ds 后重试", FILLS_SYNC_SECONDS, exc_info=True) for symbol in symbols: try: await self.reconcile(session, client, symbol, account.running, positions.get(symbol.symbol)) diff --git a/backend/src/alphapulse/trading/fills.py b/backend/src/alphapulse/trading/fills.py new file mode 100644 index 0000000..661ffe9 --- /dev/null +++ b/backend/src/alphapulse/trading/fills.py @@ -0,0 +1,70 @@ +"""同步币安成交记录:历史仓位由它还原(见 positions.py),包括程序外的手动成交""" + +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +from sqlalchemy import func, select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from alphapulse.models import TradingFill +from alphapulse.trading.client import DemoClient + +PAGE = 1000 +# 币安只保留近 3 个月的成交和资金流水 +HISTORY_DAYS = 90 +# 流水时间只精确到秒:往前多取一段,已有的成交按 trade id 去重 +OVERLAP = timedelta(minutes=1) + + +def to_ms(dt: datetime) -> int: + return int(dt.timestamp() * 1000) + + +async def missing_starts(session: AsyncSession, client: DemoClient) -> dict[str, int]: + """用手续费流水(每笔成交一条,不限币种)发现新成交:返回每个币库里还没有的最小 trade id""" + latest = await session.scalar(select(func.max(TradingFill.time))) + start = to_ms(latest - OVERLAP if latest else datetime.now(UTC) - timedelta(days=HISTORY_DAYS)) + found: dict[str, set[int]] = {} + while True: + rows = await client.request("GET", "/fapi/v1/income", {"incomeType": "COMMISSION", "startTime": start, "limit": PAGE}) + for row in rows: + found.setdefault(row["symbol"], set()).add(int(row["tradeId"])) + if len(rows) < PAGE: + break + if rows[-1]["time"] == start: + raise RuntimeError("同一秒内的手续费流水超过一页,无法按时间翻页") + start = rows[-1]["time"] + starts = {} + for symbol, ids in found.items(): + known = set(await session.scalars(select(TradingFill.trade_id).where(TradingFill.symbol == symbol, TradingFill.trade_id.in_(ids)))) + if ids - known: + starts[symbol] = min(ids - known) + return starts + + +async def sync_fills(session: AsyncSession, client: DemoClient, symbols: set[str]) -> int: + """把新成交写入 trading_fills,返回拉到的成交数。symbols 为交易列表和当前持仓的币: + 每轮按库里最大 trade id 往后查一次,兜底手续费为 0、没有流水的成交""" + starts = await missing_starts(session, client) + for symbol in symbols: + last = await session.scalar(select(func.max(TradingFill.trade_id)).where(TradingFill.symbol == symbol)) + if last is not None: + starts[symbol] = min(starts.get(symbol, last + 1), last + 1) + fetched = 0 + for symbol, from_id in starts.items(): + while True: + trades = await client.request("GET", "/fapi/v1/userTrades", {"symbol": symbol, "fromId": from_id, "limit": PAGE}) + if trades: + await session.execute(insert(TradingFill).values([{ + "symbol": symbol, "trade_id": t["id"], "order_id": t["orderId"], "side": t["side"], + "price": Decimal(t["price"]), "qty": Decimal(t["qty"]), "realized_pnl": Decimal(t["realizedPnl"]), + "commission": Decimal(t["commission"]), "commission_asset": t["commissionAsset"], + "time": datetime.fromtimestamp(t["time"] / 1000, UTC), + } for t in trades]).on_conflict_do_nothing()) + fetched += len(trades) + if len(trades) < PAGE: + break + from_id = trades[-1]["id"] + 1 + await session.commit() + return fetched diff --git a/backend/src/alphapulse/trading/positions.py b/backend/src/alphapulse/trading/positions.py new file mode 100644 index 0000000..1f35e77 --- /dev/null +++ b/backend/src/alphapulse/trading/positions.py @@ -0,0 +1,85 @@ +"""由成交记录还原历史仓位。纯计算,不依赖数据库,便于测试""" + +from dataclasses import dataclass, field +from datetime import datetime +from decimal import Decimal +from typing import Protocol + + +class Fill(Protocol): + """一笔成交;models.TradingFill 满足此接口""" + + symbol: str + order_id: int + side: str + price: Decimal + qty: Decimal + realized_pnl: Decimal + commission: Decimal + commission_asset: str + time: datetime + + +@dataclass +class ClosedPosition: + symbol: str + # long / short + direction: str + opened_at: datetime + closed_at: datetime | None = None + # 开仓(含加仓)成交量合计,及开仓、平仓方向的成交额 + quantity: Decimal = Decimal(0) + open_value: Decimal = Decimal(0) + close_qty: Decimal = Decimal(0) + close_value: Decimal = Decimal(0) + realized_pnl: Decimal = Decimal(0) + # 只计 USDT 计价的手续费,与 Executor.realized 口径一致 + commission: Decimal = Decimal(0) + # 参与这笔仓位的订单,用于关联程序的计划 + order_ids: set[int] = field(default_factory=set) + # 带符号的持仓量:多为正、空为负 + position: Decimal = Decimal(0) + + @property + def entry_price(self) -> Decimal: + return self.open_value / self.quantity + + @property + def exit_price(self) -> Decimal: + return self.close_value / self.close_qty + + @property + def net(self) -> Decimal: + return self.realized_pnl - self.commission + + +def build_positions(fills: list[Fill]) -> list[ClosedPosition]: + """按币、按成交顺序累计带符号的持仓量(账户为单向持仓):从 0 开始到回到 0 为一笔仓位。 + 一笔成交跨过 0(反手)时拆成平仓和新开仓两段,realizedPnl 归平仓段,手续费按数量拆分。 + 还没回到 0 的仓位不算历史。fills 须按 (symbol, time, trade_id) 排序""" + closed: list[ClosedPosition] = [] + current: dict[str, ClosedPosition] = {} + for fill in fills: + fee = fill.commission if fill.commission_asset == "USDT" else Decimal(0) + remaining = fill.qty if fill.side == "BUY" else -fill.qty + while remaining: + pos = current.get(fill.symbol) + if pos is None: + pos = current[fill.symbol] = ClosedPosition(fill.symbol, "long" if remaining > 0 else "short", fill.time) + if pos.position == 0 or (pos.position > 0) == (remaining > 0): + part = remaining + pos.quantity += abs(part) + pos.open_value += abs(part) * fill.price + else: + part = remaining if abs(remaining) <= abs(pos.position) else -pos.position + pos.close_qty += abs(part) + pos.close_value += abs(part) * fill.price + pos.realized_pnl += fill.realized_pnl + pos.position += part + pos.commission += fee * abs(part) / fill.qty + pos.order_ids.add(fill.order_id) + remaining -= part + if pos.position == 0: + pos.closed_at = fill.time + closed.append(current.pop(fill.symbol)) + return closed diff --git a/backend/tests/test_positions.py b/backend/tests/test_positions.py new file mode 100644 index 0000000..243ea49 --- /dev/null +++ b/backend/tests/test_positions.py @@ -0,0 +1,76 @@ +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +from alphapulse.trading.positions import build_positions + +T0 = datetime(2026, 10, 15, 12, tzinfo=UTC) + + +@dataclass +class Fill: + symbol: str + order_id: int + side: str + price: Decimal + qty: Decimal + realized_pnl: Decimal + commission: Decimal + commission_asset: str + time: datetime + + +def fill(trade_id: int, side: str, qty: str, price: str, pnl: str = "0", fee: str = "0.1", asset: str = "USDT", + symbol: str = "BTCUSDT", order_id: int | None = None) -> Fill: + return Fill( + symbol=symbol, order_id=order_id or trade_id, side=side, qty=Decimal(qty), price=Decimal(price), + realized_pnl=Decimal(pnl), commission=Decimal(fee), commission_asset=asset, time=T0 + timedelta(seconds=trade_id), + ) + + +def test_open_and_close_once(): + [p] = build_positions([fill(1, "BUY", "0.01", "80000"), fill(2, "SELL", "0.01", "80500", pnl="5")]) + assert (p.direction, p.quantity, p.entry_price, p.exit_price) == ("long", Decimal("0.01"), 80000, 80500) + assert (p.realized_pnl, p.commission, p.net) == (5, Decimal("0.2"), Decimal("4.8")) + assert (p.opened_at, p.closed_at) == (T0 + timedelta(seconds=1), T0 + timedelta(seconds=2)) + + +def test_partial_closes_average_exit_price(): + # #40:0.0126 多单分三档止盈 + [p] = build_positions([ + fill(1, "BUY", "0.0126", "80790"), + fill(2, "SELL", "0.0075", "81193.9", pnl="3.02925"), + fill(3, "SELL", "0.0037", "81395.9", pnl="2.24183"), + fill(4, "SELL", "0.0014", "81597.9", pnl="1.13106"), + ]) + assert p.quantity == Decimal("0.0126") + assert round(p.exit_price, 2) == Decimal("81298.11") + assert p.realized_pnl == Decimal("6.40214") + + +def test_flip_through_zero_splits_positions(): + # 空 0.01 后一笔买 0.03:先平空 0.01,再开多 0.02;手续费按数量拆分 + first, second = build_positions([ + fill(1, "SELL", "0.01", "80000"), + fill(2, "BUY", "0.03", "79000", pnl="10", fee="0.3"), + fill(3, "SELL", "0.02", "79500", pnl="10"), + ]) + assert (first.direction, first.quantity, first.exit_price, first.realized_pnl) == ("short", Decimal("0.01"), 79000, 10) + assert first.commission == Decimal("0.2") + assert (second.direction, second.quantity, second.entry_price) == ("long", Decimal("0.02"), 79000) + assert second.commission == Decimal("0.3") + assert second.opened_at == first.closed_at + + +def test_open_position_is_not_history_and_symbols_are_independent(): + positions = build_positions([ + fill(1, "BUY", "1", "2400", symbol="ETHUSDT"), + fill(2, "BUY", "0.01", "80000"), + fill(3, "SELL", "0.01", "80100", pnl="1"), + ]) + assert [p.symbol for p in positions] == ["BTCUSDT"] + + +def test_non_usdt_commission_is_ignored(): + [p] = build_positions([fill(1, "BUY", "0.01", "80000", asset="BNB"), fill(2, "SELL", "0.01", "80000")]) + assert p.commission == Decimal("0.1") diff --git a/web/src/apis/trading.ts b/web/src/apis/trading.ts index a902540..fed5cf8 100644 --- a/web/src/apis/trading.ts +++ b/web/src/apis/trading.ts @@ -267,28 +267,50 @@ 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 - /** 计划 3.1 之前替换计划时会接管旧计划的持仓,这类旧记录没有自己的入场成交,为空 */ - quantity: string | null - entry_price: string | null - /** 外部平仓没有成交记录,为空 */ - exit_price: string | null + quantity: string + entry_price: string + exit_price: string realized_pnl: string commission: string net: string + source: "program" | "manual" + /** 手动仓位没有关联计划,为空 */ + analysis_id: number | null + close_reason: string | null } -export function listPositionHistory(params: { before?: number, limit?: number }): Promise { +export interface PositionHistoryQuery { + symbol?: string + /** 按平仓时间筛选,ISO 时间 */ + start?: string + end?: string + outcome?: "profit" | "loss" + source?: "program" | "manual" + page: number + limit: number +} + +export interface PositionHistoryPage { + /** 按筛选条件统计全部仓位 */ + summary: { count: number, wins: number, net: string, commission: string } + total: number + items: PositionHistory[] +} + +export function listPositionHistory(params: PositionHistoryQuery): Promise { return http.get("/trading/positions/history", { params }) } +export function listPositionHistorySymbols(): Promise { + return http.get("/trading/positions/history/symbols") +} + 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 index 06ef2de..0150325 100644 --- a/web/src/pages/trading/components/PositionHistoryDrawer.vue +++ b/web/src/pages/trading/components/PositionHistoryDrawer.vue @@ -1,36 +1,74 @@