Files
AlphaPulse/backend/src/alphapulse/trading/executor.py
T
eezd 1fcf41952c Add demo trading executor and analysis scheduling (plan 3 C4)
- Executor reconciles each symbol to its desired orders: journaled ap{id}
  client ids, reduce-only quantity stop placed before entries, make-before-
  break stop moves, breakeven after TP1, orphan cancel, -2021 market close
- Plan lifecycle: proposed plans are adopted only if the position is
  unchanged; holding-mode plans may only tighten the stop
- Program rules: time stop, short-term invalidation close, long-term 4h
  review, reevaluate conditions, takeover of unmanaged positions
- Trigger and routine analysis scheduling with a 10 min gap, daily cap and
  bounded retries
- Plan endpoint and plan status, fills and countdowns on the trading page
2026-10-15 11:07:00 +08:00

579 lines
34 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.
"""执行器:按「应有状态」对账。唯一会下单、撤单、推进计划状态的代码。
每轮对交易列表里的每个币:
1. 同步订单流水与交易所(成交、撤销、触发)。
2. 采纳分析器提交的新计划(按最新持仓核对,过时则作废重算)。
3. 推进计划状态,执行程序规则:到期、时间止损、失效条件、复核条件、无计划持仓。
4. 算出应有订单与现有订单对比:先挂止损,再撤多余,最后补缺(撤了旧入场单的这一轮不挂新入场单)。
"""
import asyncio
import logging
import time
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.ai.tools import LOCAL_TZ
from alphapulse.core.crypto import decrypt
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.plan import MAX_HOLD_HOURS, OrderSpec, SymbolRules, condition_met, desired_orders, diff_orders, round_to_step
from alphapulse.trading.schedule import request_analysis
logger = logging.getLogger("alphapulse.trading.executor")
EXECUTOR_SECONDS = 2
ACTIVE_ORDER_STATUSES = ("submitting", "new", "partially_filled")
LIVE_PLAN_STATUSES = ("pending", "open", "closing")
# 刚下的单可能短时间查不到(C1 实测约 0.25s)
SUBMIT_GRACE_SECONDS = 10
# 止损挂单连续失败(可重试错误)达到次数后撤单平仓
STOP_FAILURE_LIMIT = 3
# 同一错误的时间线事件间隔,避免刷屏
ERROR_EVENT_SECONDS = 600
ORDER_STATUSES = {"NEW": "new", "PARTIALLY_FILLED": "partially_filled", "FILLED": "filled", "CANCELED": "canceled",
"EXPIRED": "expired", "EXPIRED_IN_MATCH": "expired", "REJECTED": "rejected"}
PURPOSE_LABELS = {"entry": "入场", "tp": "止盈", "stop": "止损", "close": "市价平仓"}
CLOSE_REASON_LABELS = {"stop": "止损", "take_profit": "止盈", "time": "到达持仓时限", "invalidation": "短线失效条件触发",
"exit": "模型决定结束", "external": "外部平仓(如在网页手动平仓)", "stop_failed": "止损挂单失败",
"close": "市价平仓"}
@dataclass
class Position:
side: str # long / short
qty: Decimal
entry: Decimal
break_even: Decimal
mark: Decimal
def fmt(value: Decimal) -> str:
return format(value, "f")
def utcnow() -> datetime:
return datetime.now(UTC)
class Executor:
def __init__(self) -> None:
self.client: DemoClient | None = None
self.key_ciphertext: bytes | None = None
self.market = BinanceClient()
# 已设置为逐仓 3 倍的币;单向持仓是账户级,只设一次
self.configured: set[str] = set()
self.one_way_checked = False
self.stop_failures: dict[str, int] = {}
self.error_events: dict[str, float] = {}
# (symbol, 周期) -> (下一根收盘时间 ms, 最近收盘价)
self.kline_cache: dict[tuple[str, str], tuple[int, Decimal]] = {}
# 已请模型接管的无计划持仓:symbol -> 持仓均价,同一笔持仓只请求一次
self.takeover_requested: dict[str, Decimal] = {}
# ---------- 基础 ----------
async def ensure_client(self, account: TradingAccount | None) -> DemoClient | None:
ciphertext = account.api_key_encrypted if account else None
if ciphertext != self.key_ciphertext:
if self.client is not None:
await self.client.http.aclose()
self.client = None
self.configured.clear()
self.one_way_checked = False
self.key_ciphertext = ciphertext
if account is not None and ciphertext is not None:
self.client = DemoClient(decrypt(ciphertext), decrypt(account.api_secret_encrypted))
return self.client
async def report(self, session: AsyncSession, symbol: str | None, message: str, level: str = "error", analysis_id: int | None = None) -> None:
"""同一条错误十分钟内只记一次"""
key = f"{symbol}:{message}"
if time.monotonic() - self.error_events.get(key, -ERROR_EVENT_SECONDS) < ERROR_EVENT_SECONDS:
return
self.error_events[key] = time.monotonic()
logger.warning("%s %s", symbol, message)
session.add(TradingEvent(symbol=symbol, analysis_id=analysis_id, kind="executor_error", level=level, message=message))
async def last_close(self, symbol: str, interval: str) -> Decimal:
"""实盘该周期最近一根已收盘 K 线的收盘价;到下一根收盘前用缓存"""
now_ms = int(time.time() * 1000)
cached = self.kline_cache.get((symbol, interval))
if cached and now_ms < cached[0]:
return cached[1]
rows = await self.market.get("um", "klines", {"symbol": symbol, "interval": interval, "limit": 2})
closed = rows[-1] if rows[-1][6] < now_ms else rows[-2]
# 下一根收盘时间 = 当前未收盘 K 线的 close_time
next_close = rows[-1][6] + 1 if rows[-1][6] >= now_ms else now_ms
self.kline_cache[(symbol, interval)] = (next_close, Decimal(closed[4]))
return Decimal(closed[4])
async def condition_met(self, symbol: str, condition: dict, mark: Decimal) -> tuple[bool, Decimal]:
price = mark if condition["timeframe"] == "mark" else await self.last_close(symbol, condition["timeframe"])
return condition_met(condition["direction"], Decimal(condition["price"]), price), price
async def ensure_symbol_setup(self, client: DemoClient, symbol: str) -> None:
"""单向持仓、逐仓、3 倍杠杆。先查再改:有挂单时即使不需要修改,写操作也可能被拒(-4067)"""
if not self.one_way_checked:
if (await client.request("GET", "/fapi/v1/positionSide/dual"))["dualSidePosition"]:
await client.request("POST", "/fapi/v1/positionSide/dual", {"dualSidePosition": False})
self.one_way_checked = True
if symbol in self.configured:
return
config = (await client.request("GET", "/fapi/v1/symbolConfig", {"symbol": symbol}))[0]
if config["marginType"] != "ISOLATED":
await client.request("POST", "/fapi/v1/marginType", {"symbol": symbol, "marginType": "ISOLATED"})
if config["leverage"] != 3:
await client.request("POST", "/fapi/v1/leverage", {"symbol": symbol, "leverage": 3})
self.configured.add(symbol)
# ---------- 订单 ----------
async def place(self, session: AsyncSession, client: DemoClient, symbol: str, plan_id: int | None, purpose: str, spec: OrderSpec) -> ExchangeError | None:
"""先写流水并提交,再用 ap{id} 下单。结果不明(网络、5xx)时保持 submitting,下一轮按 id 查询,绝不换 id 重发"""
row = TradingOrder(
analysis_id=plan_id, symbol=symbol, purpose=purpose, leg=spec.leg, is_algo=spec.type == "STOP_MARKET",
side=spec.side, type=spec.type, price=spec.price, trigger_price=spec.trigger_price, quantity=spec.quantity,
reduce_only=purpose != "entry", status="submitting",
)
session.add(row)
await session.commit()
client_id = f"ap{row.id}"
try:
if row.is_algo:
result = await client.request("POST", "/fapi/v1/algoOrder", {
"algoType": "CONDITIONAL", "symbol": symbol, "side": spec.side, "type": "STOP_MARKET",
"triggerPrice": fmt(spec.trigger_price), "quantity": fmt(spec.quantity), "reduceOnly": True,
"workingType": "MARK_PRICE", "clientAlgoId": client_id,
})
row.exchange_id, row.status = str(result["algoId"]), "new"
else:
params = {"symbol": symbol, "side": spec.side, "type": spec.type, "quantity": fmt(spec.quantity),
"reduceOnly": row.reduce_only or None, "newClientOrderId": client_id}
if spec.type == "LIMIT":
params |= {"price": fmt(spec.price), "timeInForce": "GTC"}
result = await client.request("POST", "/fapi/v1/order", params)
row.exchange_id = str(result["orderId"])
row.status = ORDER_STATUSES.get(result["status"], result["status"].lower())
except ExchangeError as e:
row.error = str(e)
if not (e.retryable or e.status is None):
row.status = "rejected"
await session.commit()
return e
await session.commit()
return None
async def cancel(self, client: DemoClient, row: TradingOrder) -> None:
await self.cancel_client_id(client, row.symbol, f"ap{row.id}", row.is_algo)
async def cancel_client_id(self, client: DemoClient, symbol: str, client_id: str, is_algo: bool) -> None:
"""撤单后不直接改状态:可能恰好已成交,交给下一轮同步确认"""
try:
if is_algo:
await client.request("DELETE", "/fapi/v1/algoOrder", {"clientAlgoId": client_id})
else:
await client.request("DELETE", "/fapi/v1/order", {"symbol": symbol, "origClientOrderId": client_id})
except ExchangeError as e:
# -2011 / -2013:单已不在,等同步
if e.code not in (-2011, -2013):
raise
async def sync_order(self, session: AsyncSession, client: DemoClient, row: TradingOrder, open_orders: dict, open_algos: dict) -> None:
client_id = f"ap{row.id}"
before = row.filled_qty
try:
if row.is_algo:
if client_id in open_algos:
row.exchange_id, row.status = str(open_algos[client_id]["algoId"]), "new"
return
algo = await client.request("GET", "/fapi/v1/algoOrder", {"clientAlgoId": client_id})
if algo["algoStatus"] in ("NEW", "TRIGGERING"):
row.status = "new"
return
row.exchange_id = str(algo["algoId"])
if algo["algoStatus"] == "FINISHED" and algo.get("actualOrderId"):
actual = await client.request("GET", "/fapi/v1/order", {"symbol": row.symbol, "orderId": algo["actualOrderId"]})
row.filled_qty = Decimal(actual["executedQty"])
row.avg_price = Decimal(actual["avgPrice"]) or None
row.status = "filled" if actual["status"] == "FILLED" else ORDER_STATUSES.get(actual["status"], "finished")
if row.status == "new":
# 触发后的市价单还没成交完,下一轮再查
return
else:
row.status = algo["algoStatus"].lower()
else:
order = open_orders.get(client_id) or await client.request("GET", "/fapi/v1/order", {"symbol": row.symbol, "origClientOrderId": client_id})
row.exchange_id = str(order["orderId"])
row.status = ORDER_STATUSES.get(order["status"], order["status"].lower())
row.filled_qty = Decimal(order["executedQty"])
row.avg_price = Decimal(order["avgPrice"]) or None
except ExchangeError as e:
if e.code != -2013:
raise
if row.status == "submitting" and utcnow() - row.created_at < timedelta(seconds=SUBMIT_GRACE_SECONDS):
return
row.status, row.error = "failed", "交易所查无此单"
return
if row.filled_qty > before:
delta = row.filled_qty - before
leg = f"第 {row.leg + 1} 档" if row.purpose == "entry" else (f" {row.leg + 1} " if row.purpose == "tp" else "")
session.add(TradingEvent(
symbol=row.symbol, analysis_id=row.analysis_id, kind=f"{row.purpose}_filled",
level="warn" if row.purpose == "stop" else "info",
message=f"{PURPOSE_LABELS[row.purpose]}{leg}成交 {fmt(delta)} @ {row.avg_price}",
data={"order_id": row.id, "quantity": fmt(delta)},
))
if row.purpose == "tp":
await request_analysis(session, row.symbol, f"止盈 {row.leg + 1} 成交")
# ---------- 计划 ----------
async def adopt(self, session: AsyncSession, client: DemoClient, symbol: TradingSymbol, current: TradingAnalysis | None,
position: Position | None) -> TradingAnalysis | None:
"""采纳最新的提议;较早的提议作废。返回采纳后的当前计划"""
proposals = (await session.scalars(
select(TradingAnalysis).where(TradingAnalysis.symbol == symbol.symbol, TradingAnalysis.plan_status == "proposed")
.order_by(TradingAnalysis.id.desc())
)).all()
if not proposals:
return current
proposal, older = proposals[0], proposals[1:]
for p in older:
p.plan_status = "stale"
now = utcnow()
if proposal.action == "exit":
proposal.plan_status = "applied"
if current is not None:
current.plan_status, current.close_reason = "closing", "exit"
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=current.id, kind="plan_exit", level="warn",
message=f"按分析 #{proposal.id} 结束计划:撤销入场单,有持仓则市价平仓"))
elif position is not None:
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_exit", level="warn",
message=f"按分析 #{proposal.id} 市价平掉无计划的持仓 {fmt(position.qty)}"))
close_side = "SELL" if position.side == "long" else "BUY"
await self.place(session, client, symbol.symbol, proposal.id, "close", OrderSpec("close", None, close_side, "MARKET", None, None, position.qty))
return current
qty = position.qty if position else Decimal(0)
expected = Decimal(proposal.sizing["position_qty"])
if qty != expected or (current is not None and current.plan_status == "closing"):
proposal.plan_status = "stale"
reason = "计划正在平仓" if current is not None and current.plan_status == "closing" else f"持仓已从 {fmt(expected)} 变为 {fmt(qty)}"
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_stale", level="warn",
message=f"计划未采纳:{reason}"))
await request_analysis(session, symbol.symbol, "持仓变化,计划作废重算")
return current
if current is not None:
current.plan_status, current.close_reason, current.closed_at = "replaced", "replaced", now
if qty > 0:
proposal.plan_status = "open"
proposal.first_fill_at = current.first_fill_at if current and current.first_fill_at else now
# 持仓时限沿用原计划,不延长;接管无计划持仓时从现在起算
proposal.close_deadline = current.close_deadline if current and current.close_deadline else now + timedelta(hours=MAX_HOLD_HOURS[symbol.angle])
else:
proposal.plan_status = "pending"
proposal.valid_until = now + timedelta(hours=float(proposal.decision["entry_valid_hours"]))
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_adopted",
message=f"采纳计划 #{proposal.id}" + (f",替换 #{current.id}" if current else "")))
return proposal
async def realized(self, client: DemoClient, symbol: str, since: datetime | None) -> dict | None:
if since is None:
return None
trades = await client.request("GET", "/fapi/v1/userTrades", {"symbol": symbol, "startTime": int(since.timestamp() * 1000) - 1000, "limit": 1000})
pnl = sum((Decimal(t["realizedPnl"]) for t in trades), Decimal(0))
fee = sum((Decimal(t["commission"]) for t in trades if t["commissionAsset"] == "USDT"), Decimal(0))
return {"realized_pnl": fmt(pnl), "commission": fmt(fee), "net": fmt(pnl - fee)}
async def finish(self, session: AsyncSession, client: DemoClient, plan: TradingAnalysis, rows: list[TradingOrder]) -> None:
"""持仓归零:计划结束,记录原因和盈亏,排队分析"""
if plan.close_reason is None or plan.plan_status == "open":
filled = sorted((r for r in rows if r.filled_qty > 0 and r.purpose in ("stop", "tp", "close")), key=lambda r: r.updated_at)
last = filled[-1].purpose if filled else None
plan.close_reason = {"stop": "stop", "tp": "take_profit", "close": plan.close_reason or "close"}.get(last, "external")
plan.plan_status = "closed" if plan.first_fill_at else "canceled"
plan.closed_at = utcnow()
plan.result = await self.realized(client, plan.symbol, plan.first_fill_at)
net = f",净盈亏 {plan.result['net']} USDT(含手续费 {plan.result['commission']})" if plan.result else ""
label = CLOSE_REASON_LABELS.get(plan.close_reason, plan.close_reason)
session.add(TradingEvent(symbol=plan.symbol, analysis_id=plan.id, kind="plan_closed", level="warn" if plan.close_reason in ("stop", "stop_failed", "external") else "info",
message=f"计划 #{plan.id} 结束:{label}{net}", data=plan.result))
await request_analysis(session, plan.symbol, f"平仓({label})")
async def start_closing(self, session: AsyncSession, plan: TradingAnalysis, reason: str, detail: str = "") -> None:
plan.plan_status, plan.close_reason = "closing", reason
session.add(TradingEvent(symbol=plan.symbol, analysis_id=plan.id, kind="plan_closing", level="warn",
message=f"{CLOSE_REASON_LABELS[reason]}{detail}:撤销入场单并市价平仓"))
async def review_once(self, session: AsyncSession, plan_id: int, symbol: str, kind: str, index: int, message: str, reason: str) -> None:
"""失效复核、reevaluate 每个条件只触发一次:以时间线事件去重"""
exists = await session.scalar(select(func.count()).where(
TradingEvent.analysis_id == plan_id, TradingEvent.kind == kind, TradingEvent.data["index"].as_integer() == index,
))
if exists:
return
session.add(TradingEvent(symbol=symbol, analysis_id=plan_id, kind=kind, level="warn", message=message, data={"index": index}))
await request_analysis(session, symbol, reason)
# ---------- 对账 ----------
async def reconcile(self, session: AsyncSession, client: DemoClient, symbol: TradingSymbol, running: bool, position: Position | None) -> None:
name = symbol.symbol
rules = await symbol_rules(name)
open_orders = {o["clientOrderId"]: o for o in await client.request("GET", "/fapi/v1/openOrders", {"symbol": name})}
open_algos = {o["clientAlgoId"]: o for o in await client.request("GET", "/fapi/v1/openAlgoOrders", {"symbol": name})}
rows = list((await session.scalars(
select(TradingOrder).where(TradingOrder.symbol == name, TradingOrder.status.in_(ACTIVE_ORDER_STATUSES))
)).all())
for row in rows:
await self.sync_order(session, client, row, open_orders, open_algos)
await session.commit()
# 交易所上本程序的单,但流水里不是活动状态:孤儿单,撤掉(用户手动下的单不动)
active_ids = {f"ap{r.id}" for r in rows if r.status in ACTIVE_ORDER_STATUSES}
for client_id, order in [*open_orders.items(), *open_algos.items()]:
if OWN_CLIENT_ID.match(client_id) and client_id not in active_ids:
await self.cancel_client_id(client, name, client_id, client_id in open_algos)
session.add(TradingEvent(symbol=name, kind="orphan_canceled", level="warn", message=f"撤销孤儿单 {client_id}({order.get('type') or order.get('orderType')})"))
plan = await session.scalar(
select(TradingAnalysis).where(TradingAnalysis.symbol == name, TradingAnalysis.plan_status.in_(LIVE_PLAN_STATUSES))
.order_by(TradingAnalysis.id.desc()).limit(1)
)
plan = await self.adopt(session, client, symbol, plan, position)
await session.commit()
# 不属于当前计划的活动订单(被替换、已结束的计划):入场和止盈先撤;止损等新止损挂上后再撤,不留无保护窗口
stale = [r for r in rows if r.status in ACTIVE_ORDER_STATUSES and r.purpose != "close" and (plan is None or r.analysis_id != plan.id)]
for row in stale:
if row.purpose != "stop":
await self.cancel(client, row)
stale_entry_canceled = any(r.purpose == "entry" for r in stale)
if plan is None:
for row in stale:
if row.purpose == "stop":
await self.cancel(client, row)
if position is None:
self.takeover_requested.pop(name, None)
elif running:
await self.unmanaged(session, client, name, position, open_algos)
if running:
await self.check_reevaluate(session, name, position.mark if position else None)
await session.commit()
return
direction = plan.sizing["direction"]
if position is not None and position.side != direction:
await self.report(session, name, f"持仓方向({position.side})与计划 #{plan.id} 方向({direction})不一致,暂停该币的下单", analysis_id=plan.id)
await session.commit()
return
qty = position.qty if position else Decimal(0)
now = utcnow()
plan_rows = list((await session.scalars(select(TradingOrder).where(TradingOrder.analysis_id == plan.id))).all())
if plan.plan_status == "pending":
if qty > 0:
plan.plan_status, plan.first_fill_at = "open", now
plan.close_deadline = now + timedelta(hours=float(plan.decision["max_hold_hours"]))
session.add(TradingEvent(symbol=name, analysis_id=plan.id, kind="plan_open",
message=f"首档成交,计划 #{plan.id} 进入持仓;最晚 {plan.close_deadline.astimezone(LOCAL_TZ).strftime('%m-%d %H:%M')} 平仓"))
await request_analysis(session, name, "首档成交")
elif now >= plan.valid_until or not running:
plan.plan_status, plan.closed_at = "canceled", now
plan.close_reason = "expired" if running else "stopped"
session.add(TradingEvent(symbol=name, analysis_id=plan.id, kind="plan_canceled",
message=f"计划 #{plan.id} 撤销:{'入场单到期未成交' if running else '已停止交易,撤销未成交的入场单'}"))
if running:
await request_analysis(session, name, "计划到期未成交")
elif plan.plan_status == "open":
if qty == 0:
await self.finish(session, client, plan, plan_rows)
elif now >= plan.close_deadline:
await self.start_closing(session, plan, "time")
else:
await self.check_invalidation(session, plan, position)
elif plan.plan_status == "closing" and qty == 0:
await self.finish(session, client, plan, plan_rows)
await session.commit()
if plan.plan_status in LIVE_PLAN_STATUSES:
await self.sync_orders(session, client, plan, plan_rows, position, running, rules, stale_entry_canceled)
else:
# 计划已结束:只减仓止损不会被交易所自动撤销,必须主动撤,以免作用到之后的新仓位
for row in plan_rows:
if row.status in ACTIVE_ORDER_STATUSES and row.purpose != "close":
await self.cancel(client, row)
for row in stale:
if row.purpose == "stop":
await self.cancel(client, row)
if running and plan.plan_status != "closing":
await self.check_reevaluate(session, name, position.mark if position else None)
await session.commit()
async def check_invalidation(self, session: AsyncSession, plan: TradingAnalysis, position: Position) -> None:
condition = plan.decision["invalidation"]
met, price = await self.condition_met(plan.symbol, condition, position.mark)
if not met:
return
detail = f"({condition['timeframe']} 收盘 {fmt(price)} {'高于' if condition['direction'] == 'above' else '低于'} {condition['price']})"
if plan.angle == "short":
await self.start_closing(session, plan, "invalidation", detail)
else:
await self.review_once(session, plan.id, plan.symbol, "invalidation_review", 0,
f"长线失效条件触发{detail},请模型复核", f"失效条件触发{detail}")
async def check_reevaluate(self, session: AsyncSession, symbol: str, mark: Decimal | None) -> None:
"""最近一次交易中分析给出的 reevaluate 条件:满足任一个就排队分析"""
latest = await session.scalar(
select(TradingAnalysis).where(TradingAnalysis.symbol == symbol, TradingAnalysis.status == "done", TradingAnalysis.dry_run.is_(False))
.order_by(TradingAnalysis.id.desc()).limit(1)
)
if latest is None or not latest.decision["reevaluate"]:
return
if mark is None and any(c["timeframe"] == "mark" for c in latest.decision["reevaluate"]):
mark = await mark_price(symbol)
for i, condition in enumerate(latest.decision["reevaluate"]):
met, price = await self.condition_met(symbol, condition, mark)
if met:
text = f"{condition['timeframe']} {'高于' if condition['direction'] == 'above' else '低于'} {condition['price']}(现 {fmt(price)})"
await self.review_once(session, latest.id, symbol, "reevaluate_triggered", i, f"复核条件满足:{text}", f"复核条件满足:{text}")
async def unmanaged(self, session: AsyncSession, client: DemoClient, symbol: str, position: Position, open_algos: dict) -> None:
"""有持仓但没有计划:有止损就请模型接管,没有止损就市价平仓"""
close_side = "SELL" if position.side == "long" else "BUY"
has_stop = any(o["side"] == close_side and o["orderType"].startswith("STOP") for o in open_algos.values())
if has_stop:
if self.takeover_requested.get(symbol) != position.entry:
self.takeover_requested[symbol] = position.entry
session.add(TradingEvent(symbol=symbol, kind="unmanaged_position", level="warn",
message=f"{symbol} 有持仓 {fmt(position.qty)} 和止损但没有计划,请模型接管"))
await request_analysis(session, symbol, "有持仓但没有计划,请接管")
return
closing = await session.scalar(select(func.count()).where(
TradingOrder.symbol == symbol, TradingOrder.purpose == "close", TradingOrder.status.in_(ACTIVE_ORDER_STATUSES),
))
if closing:
return
session.add(TradingEvent(symbol=symbol, kind="unmanaged_closed", level="error",
message=f"{symbol} 有持仓 {fmt(position.qty)} 但没有计划也没有止损,市价平仓"))
await self.place(session, client, symbol, None, "close", OrderSpec("close", None, close_side, "MARKET", None, None, position.qty))
async def sync_orders(self, session: AsyncSession, client: DemoClient, plan: TradingAnalysis, plan_rows: list[TradingOrder],
position: Position | None, running: bool, rules: SymbolRules, block_entries: bool) -> None:
sizing = plan.sizing
qty = position.qty if position else Decimal(0)
n_entries, n_tps = len(sizing["entries"]), len(sizing["take_profits"])
entry_filled = [sum((r.filled_qty for r in plan_rows if r.purpose == "entry" and r.leg == i), Decimal(0)) for i in range(n_entries)]
tp_filled = [sum((r.filled_qty for r in plan_rows if r.purpose == "tp" and r.leg == j), Decimal(0)) for j in range(n_tps)]
tp1_hit = tp_filled[0] > 0
stop = Decimal(sizing["stop"])
if tp1_hit and position is not None:
# 止盈 1 后止损移到保本价:做多向上取整、做空向下取整,保证不亏;只收紧不放宽
long = sizing["direction"] == "long"
break_even = round_to_step(position.break_even, rules.tick_size, ROUND_CEILING if long else ROUND_FLOOR)
stop = max(stop, break_even) if long else min(stop, break_even)
active = [r for r in plan_rows if r.status in ACTIVE_ORDER_STATUSES and r.purpose in ("entry", "stop", "tp")]
long = sizing["direction"] == "long"
if plan.plan_status == "closing":
# 平仓中:撤入场和止盈,保留已有止损直到市价平仓成交(止损可能正因越过现价挂不上,不再补挂)
for row in active:
if row.purpose != "stop":
await self.cancel(client, row)
if qty > 0 and not any(r.purpose == "close" and r.status in ACTIVE_ORDER_STATUSES for r in plan_rows):
close_side = "SELL" if long else "BUY"
await self.place(session, client, plan.symbol, plan.id, "close", OrderSpec("close", None, close_side, "MARKET", None, None, qty))
return
entries_open = running and utcnow() < (plan.valid_until or utcnow()) and not tp1_hit
desired = desired_orders(sizing, entries_open=entries_open, entry_filled=entry_filled, tp_filled=tp_filled,
position_qty=qty, stop_price=stop, step_size=rules.step_size)
actual = [(r, OrderSpec(r.purpose, r.leg, r.side, r.type, r.price, r.trigger_price, r.quantity - r.filled_qty)) for r in active]
to_cancel, to_place = diff_orders(desired, actual)
entries = [d for d in to_place if d.purpose == "entry"]
if entries:
# 保证金模式和杠杆在有挂单时不能改,必须在挂任何单之前设好
await self.ensure_symbol_setup(client, plan.symbol)
# 1. 止损先挂新的再撤旧的(只减仓止损可以并存),任何时候都不留无保护窗口
for spec in [d for d in to_place if d.purpose == "stop"]:
error = await self.place(session, client, plan.symbol, plan.id, "stop", spec)
if error is not None:
await self.stop_failed(session, plan, error)
return
self.stop_failures.pop(plan.symbol, None)
# 2. 撤多余的单
canceled_entry = False
for row in to_cancel:
await self.cancel(client, row)
canceled_entry = canceled_entry or row.purpose == "entry"
# 3. 补止盈;入场单只在本轮没有撤旧入场单、且止损在场时挂
has_stop = any(r.purpose == "stop" for r in active if r not in to_cancel) or any(d.purpose == "stop" for d in to_place)
for spec in [d for d in to_place if d.purpose == "tp"]:
await self.place(session, client, plan.symbol, plan.id, "tp", spec)
if entries and has_stop and not canceled_entry and not block_entries:
for spec in entries:
await self.place(session, client, plan.symbol, plan.id, "entry", spec)
async def stop_failed(self, session: AsyncSession, plan: TradingAnalysis, error: ExchangeError) -> None:
"""会立即触发(-2021)或不可重试的错误:撤单平仓;可重试的错误连续失败多次后同样处理"""
if error.retryable or error.status is None:
count = self.stop_failures[plan.symbol] = self.stop_failures.get(plan.symbol, 0) + 1
if count < STOP_FAILURE_LIMIT:
await self.report(session, plan.symbol, f"挂止损失败(第 {count} 次),下一轮重试:{error}", "warn", plan.id)
return
self.stop_failures.pop(plan.symbol, None)
reason = "止损价已越过现价" if error.code == -2021 else f"挂止损失败:{error}"
session.add(TradingEvent(symbol=plan.symbol, analysis_id=plan.id, kind="stop_failed", level="error", message=reason))
await self.start_closing(session, plan, "stop_failed")
# ---------- 主循环 ----------
async def tick(self) -> None:
async with SessionLocal() as session:
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:
return
positions = {}
for p in await client.request("GET", "/fapi/v3/positionRisk"):
amount = Decimal(p["positionAmt"])
if amount != 0:
positions[p["symbol"]] = Position(
side="long" if amount > 0 else "short", qty=abs(amount), entry=Decimal(p["entryPrice"]),
break_even=Decimal(p["breakEvenPrice"]), mark=Decimal(p["markPrice"]),
)
for symbol in symbols:
try:
await self.reconcile(session, client, symbol, account.running, positions.get(symbol.symbol))
except ExchangeError as e:
await session.rollback()
await self.report(session, symbol.symbol, f"对账失败:{e}")
await session.commit()
except Exception as e:
logger.exception("%s 对账出错", symbol.symbol)
await session.rollback()
await self.report(session, symbol.symbol, f"对账出错:{e!r}")
await session.commit()
async def executor_loop() -> None:
executor = Executor()
while True:
try:
await executor.tick()
except ExchangeError as e:
logger.warning("执行器同步失败:%s", e)
except Exception:
logger.exception("执行器出错,稍后重试")
await asyncio.sleep(EXECUTOR_SECONDS)