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:
1 parent
a2e51a3011
commit
010d89e0f5
10 files changed
+589
-140
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')
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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<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 })
|
||||
}
|
||||
|
||||
export function listPositionHistorySymbols(): Promise<string[]> {
|
||||
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`)
|
||||
}
|
||||
|
||||
@@ -1,36 +1,74 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PositionHistory } from "@/apis/trading"
|
||||
import { CLOSE_REASON_LABELS, listPositionHistory } from "@/apis/trading"
|
||||
import type { PositionHistory, PositionHistoryPage } from "@/apis/trading"
|
||||
import dayjs from "dayjs"
|
||||
import { CLOSE_REASON_LABELS, listPositionHistory, listPositionHistorySymbols } from "@/apis/trading"
|
||||
import { formatTime } from "@/utils/text"
|
||||
|
||||
const emit = defineEmits<{ detail: [id: number] }>()
|
||||
const visible = defineModel<boolean>({ required: true })
|
||||
|
||||
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 exhausted = ref(false)
|
||||
const filters = reactive({
|
||||
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)
|
||||
// 快速切换筛选时只采用最后一次请求的结果
|
||||
let requestSeq = 0
|
||||
|
||||
async function load(more = false) {
|
||||
async function load() {
|
||||
const seq = ++requestSeq
|
||||
loading.value = true
|
||||
try {
|
||||
const page = await listPositionHistory({ before: more ? items.value.at(-1)?.analysis_id : undefined, limit: PAGE })
|
||||
items.value = more ? [...items.value, ...page] : page
|
||||
exhausted.value = page.length < PAGE
|
||||
const data = await listPositionHistory({
|
||||
symbol: filters.symbol,
|
||||
// 日期范围按自然日:结束日当天整天都算
|
||||
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 {
|
||||
loading.value = false
|
||||
if (seq === requestSeq)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, (open) => {
|
||||
if (open)
|
||||
load()
|
||||
function search() {
|
||||
page.value = 1
|
||||
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 */
|
||||
function price(value: string | null) {
|
||||
return value === null ? "-" : String(Number(Number(value).toPrecision(7)))
|
||||
function price(value: string) {
|
||||
return String(Number(Number(value).toPrecision(7)))
|
||||
}
|
||||
|
||||
function holding(row: PositionHistory) {
|
||||
@@ -49,80 +87,131 @@ function pnlClass(value: string) {
|
||||
|
||||
<template>
|
||||
<el-drawer v-model="visible" title="历史仓位" size="90%">
|
||||
<el-table v-loading="loading" :data="items" cell-class-name="num" empty-text="还没有平仓的仓位">
|
||||
<el-table-column label="币种" min-width="90">
|
||||
<template #default="{ row }">
|
||||
<span class="text-slate-900 font-semibold">{{ row.symbol }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="方向" width="76">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.direction === 'long' ? 'success' : 'danger'" size="small">
|
||||
{{ row.direction === "long" ? "↑ 多" : "↓ 空" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开仓时间" width="126">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.opened_at, "MM-DD HH:mm") }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平仓时间" width="126">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.closed_at, "MM-DD HH:mm") }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="持仓时长" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ holding(row as PositionHistory) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="仓位价值(USDT)" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.quantity" :title="`数量 ${Number(row.quantity)}`">{{ (Number(row.quantity) * Number(row.entry_price)).toFixed(2) }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开仓均价" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ price(row.entry_price) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平仓均价" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ price(row.exit_price) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平仓原因" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="font-sans">{{ CLOSE_REASON_LABELS[row.close_reason] ?? row.close_reason }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已实现盈亏" min-width="90">
|
||||
<template #default="{ row }">
|
||||
<span :class="pnlClass(row.realized_pnl)">{{ Number(row.realized_pnl).toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="手续费" min-width="70">
|
||||
<template #default="{ row }">
|
||||
<span class="text-slate-500">{{ Number(row.commission).toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="净盈亏" min-width="80">
|
||||
<template #default="{ row }">
|
||||
<b :class="pnlClass(row.net)">{{ Number(row.net).toFixed(2) }}</b>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分析" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" underline="never" title="查看这次计划的分析" @click="emit('detail', row.analysis_id)">
|
||||
#{{ row.analysis_id }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-button v-if="items.length && !exhausted" link type="primary" class="mt-3" :loading="loading" @click="load(true)">
|
||||
加载更早的仓位
|
||||
</el-button>
|
||||
<div class="flex flex-col gap-3">
|
||||
<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 }">
|
||||
<span class="text-slate-900 font-semibold">{{ row.symbol }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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 }">
|
||||
<el-tag :type="row.direction === 'long' ? 'success' : 'danger'" size="small">
|
||||
{{ row.direction === "long" ? "↑ 多" : "↓ 空" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开仓时间" width="126">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.opened_at, "MM-DD HH:mm") }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平仓时间" width="126">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.closed_at, "MM-DD HH:mm") }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="持仓时长" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ holding(row as PositionHistory) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="仓位价值(USDT)" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<span :title="`数量 ${Number(row.quantity)}`">{{ (Number(row.quantity) * Number(row.entry_price)).toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开仓均价" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ price(row.entry_price) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平仓均价" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ price(row.exit_price) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平仓原因" min-width="112">
|
||||
<template #default="{ row }">
|
||||
<span class="font-sans">{{ row.close_reason ? CLOSE_REASON_LABELS[row.close_reason] ?? row.close_reason : "手动" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已实现盈亏" min-width="84">
|
||||
<template #default="{ row }">
|
||||
<span :class="pnlClass(row.realized_pnl)">{{ Number(row.realized_pnl).toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="手续费" min-width="60">
|
||||
<template #default="{ row }">
|
||||
<span class="text-slate-500">{{ Number(row.commission).toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="净盈亏" min-width="80">
|
||||
<template #default="{ row }">
|
||||
<b :class="pnlClass(row.net)">{{ Number(row.net).toFixed(2) }}</b>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分析" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.analysis_id" type="primary" underline="never" title="查看这次计划的分析" @click="emit('detail', row.analysis_id)">
|
||||
#{{ row.analysis_id }}
|
||||
</el-link>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
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>
|
||||
</template>
|
||||
Reference in new issue
Block a user