Files
AlphaPulse/backend/src/alphapulse/api/trading.py
T
eezd 0397e30870 Run demo trading and allocate capital per symbol
Move running, running_changed_at and capital_usdt from the account to
each trading symbol; the migration copies the account capital to every
symbol and leaves them all stopped. Each symbol is sized against its
own capital with a 3x notional cap, and the capitals together must not
exceed account equity. Scheduling, dry-run decisions and the executor
follow the per-symbol flag. The global start/stop button is replaced by
start/stop and capital controls on each symbol's plan card.
2026-10-16 13:44:02 +08:00

504 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""模拟盘页:引擎状态、启停、交易币种、时间线。账户和持仓来自 trader 进程写入 Redis 的快照"""
import json
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.ai import llm
from alphapulse.api.crawler import LOCAL_TZ
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, TradingFill, TradingOrder, TradingSymbol
from alphapulse.trading.client import OWN_CLIENT_ID, ExchangeError, public_get
from alphapulse.trading.executor import MANUAL_ACTION_KEY
from alphapulse.trading.positions import build_positions
from alphapulse.trading.plan import DAILY_ANALYSIS_LIMIT
from alphapulse.trading.snapshot import read_snapshot
router = APIRouter(prefix="/api/trading", tags=["trading"], dependencies=[Depends(require_admin)])
SYMBOLS_CACHE_KEY = f"{KEY_PREFIX}trading:symbols"
SYMBOLS_CACHE_SECONDS = 3600
# 删除前要求快照足够新,否则无法确认没有仓位和挂单
SNAPSHOT_FRESH_SECONDS = 30
# 平仓、清除挂单的请求等执行器取走的时限:引擎没及时处理就作废,不在之后意外执行
MANUAL_ACTION_TTL_SECONDS = 30
# 执行器正在管理的计划状态
LIVE_PLAN_STATUSES = ("pending", "open", "closing")
class TodayUsage(BaseModel):
analyses: int
limit: int
input_tokens: int
output_tokens: int
class StatusOut(BaseModel):
configured: bool
provider_id: int | None
reasoning_effort: str | None
# trader 进程的心跳快照;进程离线时为空
snapshot: dict | None
today: TodayUsage
class ConfigIn(BaseModel):
provider_id: int | None = None
reasoning_effort: llm.Effort | None = None
class SymbolOption(BaseModel):
symbol: str
base: str
class SymbolOut(BaseModel):
symbol: str
added_at: datetime
analysis_requested_at: datetime | None
analyzing: bool
running: bool
running_changed_at: datetime | None
capital_usdt: Decimal | None
# 实际用于仓位计算的资金:min(交易资金, 账户权益);没有快照时无法计算
effective_capital: Decimal | None
# 当前计划:pending / open / closing,没有计划为空
plan_id: int | None
plan_status: str | None
close_deadline: datetime | None
class SymbolConfigIn(BaseModel):
capital_usdt: Annotated[Decimal, Field(gt=0, decimal_places=2)] | None
class SymbolIn(BaseModel):
symbol: str
class AnalysisOut(BaseModel):
id: int
symbol: str
reasons: list[str]
dry_run: bool
status: str
action: str | None
decision: dict | None
sizing: dict | None
error: str | None
model: str | None
reasoning_effort: str | None
usage: dict | None
started_at: datetime
finished_at: datetime | None
plan_status: str | None
valid_until: datetime | None
first_fill_at: datetime | None
close_deadline: datetime | None
closed_at: datetime | None
close_reason: str | None
result: dict | None
class AnalysisDetail(AnalysisOut):
text: str | None
transcript: list | None
class OrderOut(BaseModel):
id: int
purpose: str
leg: int | None
side: str
type: str
price: Decimal | None
trigger_price: Decimal | None
quantity: Decimal
status: str
filled_qty: Decimal
avg_price: Decimal | None
error: str | None
created_at: datetime
class SymbolPlanOut(BaseModel):
# 执行器正在管理的计划;没有时为最近一次待采纳的提议
plan: AnalysisOut | None
orders: list[OrderOut]
class EventOut(BaseModel):
id: int
created_at: datetime
symbol: str | None
analysis_id: int | None
kind: str
level: str
message: str
data: dict | None
class PositionHistoryItem(BaseModel):
symbol: str
direction: str
opened_at: datetime
closed_at: datetime
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:
return await session.get(TradingAccount, 1) or TradingAccount(id=1)
@router.get("/status")
async def get_status(session: Annotated[AsyncSession, Depends(get_session)]) -> StatusOut:
account = await get_account(session)
today = datetime.now(LOCAL_TZ).replace(hour=0, minute=0, second=0, microsecond=0)
count, input_tokens, output_tokens = (await session.execute(
select(
func.count(),
func.coalesce(func.sum(TradingAnalysis.usage["input_tokens"].as_integer()), 0),
func.coalesce(func.sum(TradingAnalysis.usage["output_tokens"].as_integer()), 0),
).where(TradingAnalysis.started_at >= today)
)).one()
return StatusOut(
configured=account.api_key_encrypted is not None, provider_id=account.provider_id, reasoning_effort=account.reasoning_effort,
snapshot=await read_snapshot(),
today=TodayUsage(analyses=count, limit=DAILY_ANALYSIS_LIMIT, input_tokens=input_tokens, output_tokens=output_tokens),
)
@router.patch("/config")
async def patch_config(body: ConfigIn, session: Annotated[AsyncSession, Depends(get_session)]) -> StatusOut:
account = await get_account(session)
fields = body.model_dump(exclude_unset=True)
if fields.get("provider_id") is not None and await session.get(AiProvider, fields["provider_id"]) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "模型配置不存在")
for key, value in fields.items():
setattr(account, key, value)
session.add(account)
await session.commit()
return await get_status(session)
async def symbol_options() -> list[dict]:
"""可交易的币:模拟盘和实盘都在交易的 USDT 永续,只含加密货币(排除美股、商品等 TradFi)"""
if cached := await redis.get(SYMBOLS_CACHE_KEY):
return json.loads(cached)
def crypto_perpetuals(info: dict) -> dict[str, str]:
return {
s["symbol"]: s["baseAsset"] for s in info["symbols"]
if s["contractType"] == "PERPETUAL" and s.get("underlyingType") == "COIN"
and s["status"] == "TRADING" and s["quoteAsset"] == "USDT"
}
try:
demo = crypto_perpetuals(await public_get("/fapi/v1/exchangeInfo"))
except ExchangeError as e:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(e)) from None
# 分析用实盘行情,执行在模拟盘,两边都要有
mainnet = crypto_perpetuals(await call_binance(market_client.get("um", "exchange_info")))
options = [{"symbol": s, "base": b} for s, b in sorted(demo.items()) if s in mainnet]
await redis.set(SYMBOLS_CACHE_KEY, json.dumps(options), ex=SYMBOLS_CACHE_SECONDS)
return options
@router.get("/symbol-options")
async def list_symbol_options() -> list[SymbolOption]:
return [SymbolOption.model_validate(o) for o in await symbol_options()]
async def current_equity() -> Decimal | None:
"""账户权益,取自 trader 的快照;进程离线或未同步到币安时为空"""
snapshot = await read_snapshot()
return Decimal(snapshot["account"]["margin_balance"]) if snapshot and snapshot["account"] else None
async def get_symbol(session: AsyncSession, symbol: str) -> TradingSymbol:
s = await session.get(TradingSymbol, symbol)
if s is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"{symbol} 不在交易列表中")
return s
async def set_running(session: AsyncSession, s: TradingSymbol, running: bool) -> None:
if running and (await get_account(session)).api_key_encrypted is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "还没有填写模拟盘 key,请先到环境变量页填写")
if running and s.capital_usdt is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{s.symbol} 还没有设置交易资金")
if s.running != running:
s.running = running
s.running_changed_at = datetime.now(LOCAL_TZ)
session.add(TradingEvent(
symbol=s.symbol, kind="started" if running else "stopped",
message="开始行情交易" if running else "停止行情交易:不再分析,撤销未成交的入场单;已有持仓的止损、止盈等程序规则继续执行",
))
await session.commit()
@router.post("/symbols/{symbol}/start")
async def start(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
await set_running(session, await get_symbol(session, symbol), True)
@router.post("/symbols/{symbol}/stop")
async def stop(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
await set_running(session, await get_symbol(session, symbol), False)
@router.patch("/symbols/{symbol}")
async def patch_symbol(symbol: str, body: SymbolConfigIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
"""设置该币的交易资金:各币合计不超过账户权益(取不到权益时不校验)"""
s = await get_symbol(session, symbol)
capital = body.capital_usdt
if capital == s.capital_usdt:
return
if capital is None and s.running:
raise HTTPException(status.HTTP_409_CONFLICT, "交易中不能清空交易资金")
equity = await current_equity()
if capital is not None and equity:
others = await session.scalar(
select(func.coalesce(func.sum(TradingSymbol.capital_usdt), 0)).where(TradingSymbol.symbol != symbol)
)
if capital + others > equity:
raise HTTPException(status.HTTP_400_BAD_REQUEST,
f"各币交易资金合计不能超过账户权益 {equity:.2f} USDT(其他币已分配 {others:.2f},最多还能设 {max(equity - others, 0):.2f})")
s.capital_usdt = capital
session.add(TradingEvent(symbol=symbol, kind="capital_changed",
message=f"交易资金设为 {capital} USDT" if capital is not None else "清空交易资金"))
await session.commit()
@router.get("/symbols")
async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -> list[SymbolOut]:
symbols = (await session.scalars(select(TradingSymbol).order_by(TradingSymbol.added_at))).all()
equity = await current_equity()
analyzing = set((await session.scalars(select(TradingAnalysis.symbol).where(TradingAnalysis.status == "running"))).all())
plans = {p.symbol: p for p in await session.scalars(
select(TradingAnalysis).where(TradingAnalysis.plan_status.in_(LIVE_PLAN_STATUSES)).order_by(TradingAnalysis.id)
)}
return [
SymbolOut(
symbol=s.symbol, added_at=s.added_at, analysis_requested_at=s.analysis_requested_at,
analyzing=s.symbol in analyzing, running=s.running, running_changed_at=s.running_changed_at, capital_usdt=s.capital_usdt,
effective_capital=min(s.capital_usdt, equity) if s.capital_usdt and equity else None,
plan_id=plans[s.symbol].id if s.symbol in plans else None,
plan_status=plans[s.symbol].plan_status if s.symbol in plans else None,
close_deadline=plans[s.symbol].close_deadline if s.symbol in plans else None,
)
for s in symbols
]
@router.post("/symbols", status_code=status.HTTP_201_CREATED)
async def add_symbol(body: SymbolIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
symbol = body.symbol.strip().upper()
if not any(o["symbol"] == symbol for o in await symbol_options()):
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{symbol} 不是模拟盘和实盘都在交易的加密货币 USDT 永续合约")
session.add(TradingSymbol(symbol=symbol))
session.add(TradingEvent(symbol=symbol, kind="symbol_added", message="加入交易列表"))
try:
await session.commit()
except IntegrityError:
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 已在交易列表中") from None
async def fresh_snapshot(symbol: str, action: str) -> dict:
"""引擎在线且刚和币安同步过的快照"""
snapshot = await read_snapshot()
synced_at = snapshot and snapshot["synced_at"] and datetime.fromisoformat(snapshot["synced_at"])
if not synced_at or datetime.now(LOCAL_TZ) - synced_at > timedelta(seconds=SNAPSHOT_FRESH_SECONDS):
raise HTTPException(status.HTTP_409_CONFLICT, f"无法确认 {symbol} 的仓位和挂单(引擎离线或连接币安失败),暂时不能{action}")
return snapshot
async def ensure_idle(session: AsyncSession, symbol: str, action: str) -> TradingSymbol:
"""只有空仓、没有本程序的挂单、没有进行中的计划和分析时,才能删除"""
s = await session.get(TradingSymbol, symbol)
if s is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"{symbol} 不在交易列表中")
busy = await session.scalar(select(func.count()).where(
TradingAnalysis.symbol == symbol,
or_(TradingAnalysis.status == "running", TradingAnalysis.plan_status.in_(("proposed", *LIVE_PLAN_STATUSES))),
))
if busy:
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 有进行中的分析或计划,不能{action}")
snapshot = await fresh_snapshot(symbol, action)
if any(p["symbol"] == symbol for p in snapshot["positions"]):
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 有持仓,不能{action}")
if any(o["symbol"] == symbol and OWN_CLIENT_ID.match(o["client_id"]) for o in snapshot["orders"] + snapshot["algo_orders"]):
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 有本程序的挂单,不能{action}")
return s
@router.delete("/symbols/{symbol}")
async def delete_symbol(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
"""只移出列表;分析、订单流水和时间线保留"""
s = await ensure_idle(session, symbol, "删除")
await session.delete(s)
session.add(TradingEvent(symbol=symbol, kind="symbol_removed", message="移出交易列表"))
await session.commit()
@router.post("/symbols/{symbol}/manual/{action}", status_code=status.HTTP_202_ACCEPTED)
async def manual_action(symbol: str, action: Literal["close", "cancel"], session: Annotated[AsyncSession, Depends(get_session)]) -> None:
"""一键平仓(同时停止该币的行情交易)、清除挂单:交给执行器执行,结果写入时间线"""
label = "平仓" if action == "close" else "清除挂单"
s = await get_symbol(session, symbol)
await fresh_snapshot(symbol, label)
if action == "close":
# 不停止的话,平仓后会重新分析,可能马上又开仓
await set_running(session, s, False)
await redis.set(MANUAL_ACTION_KEY.format(symbol), action, ex=MANUAL_ACTION_TTL_SECONDS)
@router.get("/events")
async def list_events(
session: Annotated[AsyncSession, Depends(get_session)],
symbol: str | None = None,
before: int | None = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> list[EventOut]:
"""按 id 倒序分页;指定 symbol 时同时返回账户级事件(启停等)"""
query = select(TradingEvent).order_by(TradingEvent.id.desc()).limit(limit)
if symbol:
query = query.where(or_(TradingEvent.symbol == symbol, TradingEvent.symbol.is_(None)))
if before:
query = query.where(TradingEvent.id < before)
return [EventOut.model_validate(e, from_attributes=True) for e in await session.scalars(query)]
@router.get("/positions/history")
async def list_position_history(
session: Annotated[AsyncSession, Depends(get_session)],
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,
) -> 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 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,
))
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)
async def request_analysis(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
"""排队一次手动分析:该币停止交易时为试运行,计划只记录、不下单"""
s = await get_symbol(session, symbol)
if s.capital_usdt is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{symbol} 还没有设置交易资金")
running = await session.scalar(select(func.count()).where(TradingAnalysis.symbol == symbol, TradingAnalysis.status == "running"))
if running or s.analysis_requested_at is not None:
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 已在分析或排队中")
s.analysis_requested_at = datetime.now(LOCAL_TZ)
s.analysis_reasons = ["手动分析"]
await session.commit()
@router.get("/analyses")
async def list_analyses(
session: Annotated[AsyncSession, Depends(get_session)],
symbol: str | None = None,
limit: Annotated[int, Query(ge=1, le=50)] = 10,
) -> list[AnalysisOut]:
query = select(TradingAnalysis).order_by(TradingAnalysis.id.desc()).limit(limit)
if symbol:
query = query.where(TradingAnalysis.symbol == symbol)
return [AnalysisOut.model_validate(a, from_attributes=True) for a in await session.scalars(query)]
@router.get("/analyses/{analysis_id}")
async def get_analysis(analysis_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> AnalysisDetail:
analysis = await session.get(TradingAnalysis, analysis_id)
if analysis is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "分析不存在")
return AnalysisDetail.model_validate(analysis, from_attributes=True)
@router.get("/symbols/{symbol}/plan")
async def get_symbol_plan(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> SymbolPlanOut:
plan = await session.scalar(
select(TradingAnalysis).where(TradingAnalysis.symbol == symbol, TradingAnalysis.plan_status.in_(("proposed", *LIVE_PLAN_STATUSES)))
.order_by(TradingAnalysis.id.desc()).limit(1)
)
if plan is None:
return SymbolPlanOut(plan=None, orders=[])
orders = await session.scalars(select(TradingOrder).where(TradingOrder.analysis_id == plan.id).order_by(TradingOrder.id))
return SymbolPlanOut(
plan=AnalysisOut.model_validate(plan, from_attributes=True),
orders=[OrderOut.model_validate(o, from_attributes=True) for o in orders],
)