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 @@