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
This commit is contained in:
eezd committed 2026-10-15 11:07:00 +08:00
1 parent 78ca4422af
commit 1fcf41952c
12 files changed
+1192 -67

No files matched your search

+578
View File
@@ -0,0 +1,578 @@
"""执行器:按「应有状态」对账。唯一会下单、撤单、推进计划状态的代码。
每轮对交易列表里的每个币:
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)