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.
This commit is contained in:
eezd committed 2026-10-16 11:57:45 +08:00
1 parent a2e51a3011
commit 010d89e0f5
10 files changed
+527 -78

No files matched your search

@@ -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')
+69 -40
View File
@@ -1,10 +1,9 @@
"""模拟盘页:引擎状态、启停、交易币种、时间线。账户和持仓来自 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, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field 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.auth import require_admin
from alphapulse.core.db import get_session from alphapulse.core.db import get_session
from alphapulse.core.redis import KEY_PREFIX, redis 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.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.plan import DAILY_ANALYSIS_LIMIT
from alphapulse.trading.snapshot import read_snapshot from alphapulse.trading.snapshot import read_snapshot
@@ -142,21 +142,35 @@ class EventOut(BaseModel):
data: dict | None data: dict | None
class PositionHistoryOut(BaseModel): class PositionHistoryItem(BaseModel):
analysis_id: int
symbol: str symbol: str
direction: str direction: str
opened_at: datetime opened_at: datetime
closed_at: datetime closed_at: datetime
close_reason: str quantity: Decimal
# 本计划的入场成交。计划 3.1 之前,替换计划时持仓会交给新计划接管(如 #8),这类旧记录没有自己的入场成交,这两项为空 entry_price: Decimal
quantity: Decimal | None exit_price: Decimal
entry_price: Decimal | None
# 止盈、止损、市价平仓的成交均价;外部平仓(如网页手动平仓)没有成交记录,为空
exit_price: Decimal | None
realized_pnl: Decimal realized_pnl: Decimal
commission: Decimal commission: Decimal
net: 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: 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)] 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") @router.get("/positions/history")
async def list_position_history( async def list_position_history(
session: Annotated[AsyncSession, Depends(get_session)], 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, limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> list[PositionHistoryOut]: ) -> PositionHistoryPage:
"""已平仓的计划(入场成交过、持仓已归零),按计划 id 倒序分页;closed 的计划一定有 first_fill_at 和 result""" """由币安成交记录还原的已平仓仓位,按平仓时间倒序;start/end 按平仓时间筛选。
query = select(TradingAnalysis).where(TradingAnalysis.plan_status == "closed").order_by(TradingAnalysis.id.desc()).limit(limit) 个人账户成交量小,每次全量还原后在内存里筛选、统计、分页"""
if before: fills = list(await session.scalars(select(TradingFill).order_by(TradingFill.symbol, TradingFill.time, TradingFill.trade_id)))
query = query.where(TradingAnalysis.id < before) # 程序下的普通单记录了 orderId;条件单(止损)触发后的订单号没有记录,靠同一仓位里的其他订单关联
plans = list(await session.scalars(query)) order_plans = dict((await session.execute(
fills: dict[int, list[TradingOrder]] = defaultdict(list) select(TradingOrder.exchange_id, TradingOrder.analysis_id)
for order in await session.scalars( .where(TradingOrder.is_algo.is_(False), TradingOrder.exchange_id.is_not(None), TradingOrder.analysis_id.is_not(None))
select(TradingOrder).where(TradingOrder.analysis_id.in_([p.id for p in plans]), TradingOrder.filled_qty > 0) )).tuples().all())
): plans = {a.id: a for a in await session.scalars(
fills[order.analysis_id].append(order) select(TradingAnalysis).where(TradingAnalysis.id.in_(set(order_plans.values())))
)}
items = [] items = []
for plan in plans: for position in build_positions(fills):
entry = average_fill([o for o in fills[plan.id] if o.purpose == "entry"]) linked = [plans[order_plans[str(o)]] for o in position.order_ids if str(o) in order_plans]
exit_fill = average_fill([o for o in fills[plan.id] if o.purpose != "entry"]) # 早期替换计划时持仓会交给新计划接管,优先取最终平仓的那个计划
items.append(PositionHistoryOut( plan = max(linked, key=lambda a: (a.plan_status == "closed", a.id)) if linked else None
analysis_id=plan.id, symbol=plan.symbol, direction=plan.sizing["direction"], items.append(PositionHistoryItem(
opened_at=plan.first_fill_at, closed_at=plan.closed_at, close_reason=plan.close_reason, symbol=position.symbol, direction=position.direction, opened_at=position.opened_at, closed_at=position.closed_at,
quantity=entry[0] if entry else None, entry_price=entry[1] if entry else None, quantity=position.quantity, entry_price=position.entry_price, exit_price=position.exit_price,
exit_price=exit_fill[1] if exit_fill else None, **plan.result, 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) @router.post("/symbols/{symbol}/analyze", status_code=status.HTTP_202_ACCEPTED)
+2 -1
View File
@@ -2,7 +2,7 @@ from alphapulse.core.db import Base
from alphapulse.models.ai import AiProvider, ChatMessage, ChatSession, ContextVersion, SkillNode, SkillVersion from alphapulse.models.ai import AiProvider, ChatMessage, ChatSession, ContextVersion, SkillNode, SkillVersion
from alphapulse.models.market import Kline, KlineSyncState, MarketSymbol from alphapulse.models.market import Kline, KlineSyncState, MarketSymbol
from alphapulse.models.news import CrawlLog, CrawlRun, NewsItem, NewsRevision, RawBody, RawResponse, Source 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__ = [ __all__ = [
"AiProvider", "AiProvider",
@@ -25,6 +25,7 @@ __all__ = [
"TradingAccount", "TradingAccount",
"TradingAnalysis", "TradingAnalysis",
"TradingEvent", "TradingEvent",
"TradingFill",
"TradingOrder", "TradingOrder",
"TradingSymbol", "TradingSymbol",
] ]
+19
View File
@@ -125,3 +125,22 @@ class TradingEvent(Base):
level: Mapped[str] = mapped_column(String(8), default="info") level: Mapped[str] = mapped_column(String(8), default="info")
message: Mapped[str] = mapped_column(Text) message: Mapped[str] = mapped_column(Text)
data: Mapped[dict | None] = mapped_column(JSONB) 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))
+16 -1
View File
@@ -23,6 +23,7 @@ from alphapulse.core.db import SessionLocal
from alphapulse.market.binance import BinanceClient from alphapulse.market.binance import BinanceClient
from alphapulse.models import TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol 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.client import OWN_CLIENT_ID, DemoClient, ExchangeError, mark_price, symbol_rules
from alphapulse.trading.fills import sync_fills
from alphapulse.trading.plan import ( from alphapulse.trading.plan import (
MAX_HOLD_HOURS, TAKE_PROFIT_STEPS, OrderSpec, SymbolRules, condition_met, desired_orders, diff_orders, round_to_step, 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") logger = logging.getLogger("alphapulse.trading.executor")
EXECUTOR_SECONDS = 2 EXECUTOR_SECONDS = 2
# 成交记录只用于历史仓位,不需要跟对账一样频繁
FILLS_SYNC_SECONDS = 60
ACTIVE_ORDER_STATUSES = ("submitting", "new", "partially_filled") ACTIVE_ORDER_STATUSES = ("submitting", "new", "partially_filled")
LIVE_PLAN_STATUSES = ("pending", "open", "closing") LIVE_PLAN_STATUSES = ("pending", "open", "closing")
# 刚下的单可能短时间查不到(C1 实测约 0.25s) # 刚下的单可能短时间查不到(C1 实测约 0.25s)
@@ -78,6 +81,7 @@ class Executor:
self.kline_cache: dict[tuple[str, str], tuple[int, Decimal]] = {} self.kline_cache: dict[tuple[str, str], tuple[int, Decimal]] = {}
# 已提示过的无计划持仓:symbol -> 持仓均价,同一笔持仓只提示一次 # 已提示过的无计划持仓:symbol -> 持仓均价,同一笔持仓只提示一次
self.unmanaged_noticed: dict[str, Decimal] = {} self.unmanaged_noticed: dict[str, Decimal] = {}
self.fills_due = 0.0
# ---------- 基础 ---------- # ---------- 基础 ----------
@@ -537,7 +541,7 @@ class Executor:
account = await session.get(TradingAccount, 1) account = await session.get(TradingAccount, 1)
symbols = list((await session.scalars(select(TradingSymbol))).all()) symbols = list((await session.scalars(select(TradingSymbol))).all())
client = await self.ensure_client(account) client = await self.ensure_client(account)
if client is None or not symbols: if client is None:
return return
positions = {} positions = {}
for p in await client.request("GET", "/fapi/v3/positionRisk"): 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"]), side="long" if amount > 0 else "short", qty=abs(amount), entry=Decimal(p["entryPrice"]),
break_even=Decimal(p["breakEvenPrice"]), mark=Decimal(p["markPrice"]), 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: for symbol in symbols:
try: try:
await self.reconcile(session, client, symbol, account.running, positions.get(symbol.symbol)) await self.reconcile(session, client, symbol, account.running, positions.get(symbol.symbol))
+70
View File
@@ -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
@@ -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
+76
View File
@@ -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")
+31 -9
View File
@@ -267,28 +267,50 @@ 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 { export interface PositionHistory {
analysis_id: number
symbol: string symbol: string
direction: "long" | "short" direction: "long" | "short"
opened_at: string opened_at: string
closed_at: string closed_at: string
close_reason: string quantity: string
/** 计划 3.1 之前替换计划时会接管旧计划的持仓,这类旧记录没有自己的入场成交,为空 */ entry_price: string
quantity: string | null exit_price: string
entry_price: string | null
/** 外部平仓没有成交记录,为空 */
exit_price: string | null
realized_pnl: string realized_pnl: string
commission: string commission: string
net: string net: string
source: "program" | "manual"
/** 手动仓位没有关联计划,为空 */
analysis_id: number | null
close_reason: string | null
} }
export function listPositionHistory(params: { before?: number, limit?: number }): Promise<PositionHistory[]> { 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<PositionHistoryPage> {
return http.get("/trading/positions/history", { params }) return http.get("/trading/positions/history", { params })
} }
export function listPositionHistorySymbols(): Promise<string[]> {
return http.get("/trading/positions/history/symbols")
}
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`)
} }
@@ -1,36 +1,74 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { PositionHistory } from "@/apis/trading" import type { PositionHistory, PositionHistoryPage } from "@/apis/trading"
import { CLOSE_REASON_LABELS, listPositionHistory } from "@/apis/trading" import dayjs from "dayjs"
import { CLOSE_REASON_LABELS, listPositionHistory, listPositionHistorySymbols } from "@/apis/trading"
import { formatTime } from "@/utils/text" import { formatTime } from "@/utils/text"
const emit = defineEmits<{ detail: [id: number] }>() const emit = defineEmits<{ detail: [id: number] }>()
const visible = defineModel<boolean>({ required: true }) const visible = defineModel<boolean>({ required: true })
const PAGE = 50 const PAGE = 50
const DATE_SHORTCUTS = [
{ text: "今日", value: () => [dayjs().startOf("day").toDate(), new Date()] },
{ text: "近 7 天", value: () => [dayjs().subtract(6, "day").startOf("day").toDate(), new Date()] },
{ text: "近 30 天", value: () => [dayjs().subtract(29, "day").startOf("day").toDate(), new Date()] }
]
const items = ref<PositionHistory[]>([]) const filters = reactive({
const exhausted = ref(false) symbol: undefined as string | undefined,
range: null as [Date, Date] | null,
outcome: "" as "" | "profit" | "loss",
source: "" as "" | "program" | "manual"
})
const page = ref(1)
const result = ref<PositionHistoryPage>()
const symbols = ref<string[]>([])
const loading = ref(false) const loading = ref(false)
// 快速切换筛选时只采用最后一次请求的结果
let requestSeq = 0
async function load(more = false) { async function load() {
const seq = ++requestSeq
loading.value = true loading.value = true
try { try {
const page = await listPositionHistory({ before: more ? items.value.at(-1)?.analysis_id : undefined, limit: PAGE }) const data = await listPositionHistory({
items.value = more ? [...items.value, ...page] : page symbol: filters.symbol,
exhausted.value = page.length < PAGE // 日期范围按自然日:结束日当天整天都算
start: filters.range ? dayjs(filters.range[0]).startOf("day").toISOString() : undefined,
end: filters.range ? dayjs(filters.range[1]).startOf("day").add(1, "day").toISOString() : undefined,
outcome: filters.outcome || undefined,
source: filters.source || undefined,
page: page.value,
limit: PAGE
})
if (seq === requestSeq)
result.value = data
} finally { } finally {
if (seq === requestSeq)
loading.value = false loading.value = false
} }
} }
watch(visible, (open) => { function search() {
if (open) page.value = 1
load() load()
}
watch(visible, async (open) => {
if (!open)
return
search()
symbols.value = await listPositionHistorySymbols()
})
const winRate = computed(() => {
const s = result.value?.summary
return s?.count ? `${Math.round(s.wins / s.count * 1000) / 10}%` : "-"
}) })
/** 均价保留 7 位有效数字,去掉末尾的 0 */ /** 均价保留 7 位有效数字,去掉末尾的 0 */
function price(value: string | null) { function price(value: string) {
return value === null ? "-" : String(Number(Number(value).toPrecision(7))) return String(Number(Number(value).toPrecision(7)))
} }
function holding(row: PositionHistory) { function holding(row: PositionHistory) {
@@ -49,13 +87,61 @@ function pnlClass(value: string) {
<template> <template>
<el-drawer v-model="visible" title="历史仓位" size="90%"> <el-drawer v-model="visible" title="历史仓位" size="90%">
<el-table v-loading="loading" :data="items" cell-class-name="num" empty-text="还没有平仓的仓位"> <div class="flex flex-col gap-3">
<el-table-column label="币种" min-width="90"> <div class="flex flex-wrap items-center gap-3">
<el-select v-model="filters.symbol" placeholder="全部币种" clearable class="w-40" @change="search">
<el-option v-for="s in symbols" :key="s" :value="s" :label="s" />
</el-select>
<el-date-picker
v-model="filters.range" type="daterange" start-placeholder="平仓开始日" end-placeholder="平仓结束日"
:shortcuts="DATE_SHORTCUTS" class="max-w-72" @change="search"
/>
<el-radio-group v-model="filters.outcome" @change="search">
<el-radio-button value="">
全部
</el-radio-button>
<el-radio-button value="profit">
盈利
</el-radio-button>
<el-radio-button value="loss">
亏损
</el-radio-button>
</el-radio-group>
<el-radio-group v-model="filters.source" @change="search">
<el-radio-button value="">
全部来源
</el-radio-button>
<el-radio-button value="program">
程序
</el-radio-button>
<el-radio-button value="manual">
手动
</el-radio-button>
</el-radio-group>
</div>
<div v-if="result" class="flex flex-wrap gap-x-8 gap-y-1 border border-slate-200 rounded-lg border-solid bg-slate-50 px-4 py-3 text-xs text-slate-500">
<span>笔数 <b class="num ml-1 text-sm text-slate-900">{{ result.summary.count }}</b></span>
<span>盈利 <b class="num ml-1 text-sm text-slate-900">{{ result.summary.wins }}</b></span>
<span>胜率 <b class="num ml-1 text-sm text-slate-900">{{ winRate }}</b></span>
<span>净盈亏 <b class="num ml-1 text-sm" :class="pnlClass(result.summary.net)">{{ Number(result.summary.net).toFixed(2) }} USDT</b></span>
<span>手续费 <b class="num ml-1 text-sm text-slate-900">{{ Number(result.summary.commission).toFixed(2) }} USDT</b></span>
</div>
<el-table v-loading="loading" :data="result?.items ?? []" cell-class-name="num" empty-text="没有符合条件的仓位">
<el-table-column label="币种" min-width="84">
<template #default="{ row }"> <template #default="{ row }">
<span class="text-slate-900 font-semibold">{{ row.symbol }}</span> <span class="text-slate-900 font-semibold">{{ row.symbol }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="方向" width="76"> <el-table-column label="来源" width="64">
<template #default="{ row }">
<el-tag :type="row.source === 'program' ? 'primary' : 'warning'" size="small">
{{ row.source === "program" ? "程序" : "手动" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="方向" width="70">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.direction === 'long' ? 'success' : 'danger'" size="small"> <el-tag :type="row.direction === 'long' ? 'success' : 'danger'" size="small">
{{ row.direction === "long" ? "↑ 多" : "↓ 空" }} {{ row.direction === "long" ? "↑ 多" : "↓ 空" }}
@@ -77,10 +163,9 @@ function pnlClass(value: string) {
{{ holding(row as PositionHistory) }} {{ holding(row as PositionHistory) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="仓位价值(USDT)" min-width="110"> <el-table-column label="仓位价值(USDT)" min-width="100">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row.quantity" :title="`数量 ${Number(row.quantity)}`">{{ (Number(row.quantity) * Number(row.entry_price)).toFixed(2) }}</span> <span :title="`数量 ${Number(row.quantity)}`">{{ (Number(row.quantity) * Number(row.entry_price)).toFixed(2) }}</span>
<span v-else>-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="开仓均价" min-width="100"> <el-table-column label="开仓均价" min-width="100">
@@ -93,17 +178,17 @@ function pnlClass(value: string) {
{{ price(row.exit_price) }} {{ price(row.exit_price) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="平仓原因" min-width="120"> <el-table-column label="平仓原因" min-width="112">
<template #default="{ row }"> <template #default="{ row }">
<span class="font-sans">{{ CLOSE_REASON_LABELS[row.close_reason] ?? row.close_reason }}</span> <span class="font-sans">{{ row.close_reason ? CLOSE_REASON_LABELS[row.close_reason] ?? row.close_reason : "手动" }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="已实现盈亏" min-width="90"> <el-table-column label="已实现盈亏" min-width="84">
<template #default="{ row }"> <template #default="{ row }">
<span :class="pnlClass(row.realized_pnl)">{{ Number(row.realized_pnl).toFixed(2) }}</span> <span :class="pnlClass(row.realized_pnl)">{{ Number(row.realized_pnl).toFixed(2) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="手续费" min-width="70"> <el-table-column label="手续费" min-width="60">
<template #default="{ row }"> <template #default="{ row }">
<span class="text-slate-500">{{ Number(row.commission).toFixed(2) }}</span> <span class="text-slate-500">{{ Number(row.commission).toFixed(2) }}</span>
</template> </template>
@@ -113,16 +198,20 @@ function pnlClass(value: string) {
<b :class="pnlClass(row.net)">{{ Number(row.net).toFixed(2) }}</b> <b :class="pnlClass(row.net)">{{ Number(row.net).toFixed(2) }}</b>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="分析" width="80"> <el-table-column label="分析" width="70">
<template #default="{ row }"> <template #default="{ row }">
<el-link type="primary" underline="never" title="查看这次计划的分析" @click="emit('detail', row.analysis_id)"> <el-link v-if="row.analysis_id" type="primary" underline="never" title="查看这次计划的分析" @click="emit('detail', row.analysis_id)">
#{{ row.analysis_id }} #{{ row.analysis_id }}
</el-link> </el-link>
<span v-else>-</span>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-button v-if="items.length && !exhausted" link type="primary" class="mt-3" :loading="loading" @click="load(true)">
加载更早的仓位 <el-pagination
</el-button> v-if="result && result.total > PAGE" v-model:current-page="page" :page-size="PAGE" :total="result.total"
layout="total, prev, pager, next" class="justify-end" @current-change="load"
/>
</div>
</el-drawer> </el-drawer>
</template> </template>