Support selectable demo exchanges and configurable analysis limits
Persist manual requests until exchange confirmation, preserve stop protection, and reconcile fills before settling plans. Tighten model stream, crawler, skill, research accounting, and frontend race handling, with regression coverage. Keep queued analyses pending until a fresh account snapshot is available.
This commit is contained in:
1 parent
fcaedfb358
commit
285b3952d7
33 files changed
+1890
-325
No files matched your search
@@ -20,10 +20,11 @@ from alphapulse.api.market import client as market_client
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol
|
||||
from alphapulse.trading import plan as rules
|
||||
from alphapulse.trading.client import ExchangeError, mark_price, symbol_rules
|
||||
from alphapulse.trading.client import ExchangeError, active_exchange, mark_price, symbol_rules
|
||||
from alphapulse.trading.executor import CLOSE_REASON_LABELS
|
||||
from alphapulse.trading.plan import PlanContext, PlanError, TradePlan, atr, check_setup, parse_bars
|
||||
from alphapulse.trading.snapshot import read_snapshot
|
||||
from alphapulse.trading.schedule import daily_limit_reached
|
||||
from alphapulse.trading.snapshot import SnapshotError, read_snapshot, require_fresh_snapshot
|
||||
|
||||
logger = logging.getLogger("alphapulse.trading.analyst")
|
||||
|
||||
@@ -93,6 +94,7 @@ class AnalysisError(Exception):
|
||||
|
||||
@dataclass
|
||||
class Prepared:
|
||||
exchange: str
|
||||
provider: AiProvider
|
||||
effort: str | None
|
||||
dry_run: bool
|
||||
@@ -159,20 +161,21 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
|
||||
if provider is None:
|
||||
raise AnalysisError("没有可用的模型配置,请先到「环境变量」页面添加")
|
||||
session.expunge(provider)
|
||||
exchange = account.exchange
|
||||
recent = (await session.scalars(
|
||||
select(TradingAnalysis)
|
||||
.where(TradingAnalysis.symbol == symbol, TradingAnalysis.status == "done", TradingAnalysis.id != analysis_id)
|
||||
.where(TradingAnalysis.exchange == exchange, TradingAnalysis.symbol == symbol, TradingAnalysis.status == "done", TradingAnalysis.id != analysis_id)
|
||||
.order_by(TradingAnalysis.id.desc()).limit(RECENT_DECISIONS)
|
||||
)).all()
|
||||
trades = (await session.scalars(
|
||||
select(TradingAnalysis)
|
||||
.where(TradingAnalysis.symbol == symbol, TradingAnalysis.dry_run.is_(False),
|
||||
.where(TradingAnalysis.exchange == exchange, TradingAnalysis.symbol == symbol, TradingAnalysis.dry_run.is_(False),
|
||||
TradingAnalysis.first_fill_at >= datetime.now(UTC) - timedelta(hours=RECENT_TRADES_HOURS))
|
||||
.order_by(TradingAnalysis.first_fill_at.desc())
|
||||
)).all()
|
||||
current = await session.scalar(
|
||||
select(TradingAnalysis)
|
||||
.where(TradingAnalysis.symbol == symbol, TradingAnalysis.plan_status.in_(("pending", "open", "closing")), TradingAnalysis.dry_run.is_(False))
|
||||
.where(TradingAnalysis.exchange == exchange, TradingAnalysis.symbol == symbol, TradingAnalysis.plan_status.in_(("pending", "open", "closing")), TradingAnalysis.dry_run.is_(False))
|
||||
.order_by(TradingAnalysis.id.desc()).limit(1)
|
||||
)
|
||||
fills = [
|
||||
@@ -186,14 +189,17 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
|
||||
capital_usdt = target.capital_usdt
|
||||
effort = account.reasoning_effort or provider.reasoning_effort
|
||||
|
||||
snapshot = await read_snapshot()
|
||||
if not snapshot or not snapshot["account"]:
|
||||
raise AnalysisError("取不到模拟盘账户快照(引擎未同步到币安),无法计算仓位")
|
||||
try:
|
||||
snapshot = require_fresh_snapshot(await read_snapshot())
|
||||
except SnapshotError as e:
|
||||
raise AnalysisError(f"无法计算仓位:{e}") from e
|
||||
if snapshot["exchange"] != exchange:
|
||||
raise AnalysisError("分析期间切换了交易所,请重新分析")
|
||||
equity = Decimal(snapshot["account"]["margin_balance"])
|
||||
capital = min(capital_usdt, equity)
|
||||
position = next((p for p in snapshot["positions"] if p["symbol"] == symbol), None)
|
||||
symbol_rules_ = await symbol_rules(symbol)
|
||||
mark = await mark_price(symbol)
|
||||
symbol_rules_ = await symbol_rules(symbol, exchange)
|
||||
mark = await mark_price(symbol, exchange)
|
||||
context = PlanContext(
|
||||
mark_price=mark, capital=capital, rules=symbol_rules_, atr=await recent_atr(symbol),
|
||||
has_plan=current is not None, position_side=position_side(snapshot, symbol),
|
||||
@@ -212,7 +218,8 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
|
||||
f"- 本次单笔风险金额:{risk:.2f} USDT({_pct(rules.RISK_PCT)};置信度低时 {risk * rules.LOW_CONFIDENCE_FACTOR:.2f})",
|
||||
f"- 名义仓位上限:{capital * rules.MAX_LEVERAGE:.2f} USDT({rules.MAX_LEVERAGE} 倍该币交易资金)",
|
||||
f"- 模拟盘交易规则:价格精度 {symbol_rules_.tick_size},数量精度 {symbol_rules_.step_size},最小下单金额 {symbol_rules_.min_notional} USDT",
|
||||
f"- 模拟盘标记价:{mark}(校验以此为准;行情数据来自实盘,两者通常相差不到 0.1%)",
|
||||
f"- 执行交易所:{exchange} 模拟盘;行情研究数据来自币安实盘,跨交易所价格可能有差异",
|
||||
f"- 模拟盘标记价:{mark}(下单校验以此为准)",
|
||||
f"- 最近已收盘 5m K 线的 ATR14:{context.atr:.{max(0, -symbol_rules_.tick_size.as_tuple().exponent)}f}(程序按它计算止损)",
|
||||
"",
|
||||
"## 当前持仓与挂单(模拟盘)",
|
||||
@@ -238,7 +245,7 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
|
||||
"## 预取的行情(实盘数据,已取,不要用相同参数重复调用)",
|
||||
await prefetch(symbol),
|
||||
]
|
||||
return Prepared(provider=provider, effort=effort, dry_run=dry_run, system=system, user_text="\n".join(lines), context=context)
|
||||
return Prepared(exchange=exchange, provider=provider, effort=effort, dry_run=dry_run, system=system, user_text="\n".join(lines), context=context)
|
||||
|
||||
|
||||
async def complete(prepared: Prepared, messages: list[dict], tools: list[llm.ToolSpec]) -> llm.Completion:
|
||||
@@ -250,7 +257,8 @@ async def complete(prepared: Prepared, messages: list[dict], tools: list[llm.Too
|
||||
|
||||
async def add_event(symbol: str, analysis_id: int, kind: str, message: str, level: str = "info", data: dict | None = None) -> None:
|
||||
async with SessionLocal() as session:
|
||||
session.add(TradingEvent(symbol=symbol, analysis_id=analysis_id, kind=kind, level=level, message=message, data=data))
|
||||
analysis = await session.get(TradingAnalysis, analysis_id)
|
||||
session.add(TradingEvent(exchange=analysis.exchange, symbol=symbol, analysis_id=analysis_id, kind=kind, level=level, message=message, data=data))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -272,7 +280,7 @@ async def submit(prepared: Prepared, symbol: str, analysis_id: int, arguments: d
|
||||
except ValidationError as e:
|
||||
return dump({"error": "参数无效", "details": e.errors(include_url=False)}), None
|
||||
# 分析可能持续几分钟,按提交时的标记价、持仓和 ATR 校验
|
||||
prepared.context.mark_price = await mark_price(symbol)
|
||||
prepared.context.mark_price = await mark_price(symbol, prepared.exchange)
|
||||
prepared.context.atr = await recent_atr(symbol)
|
||||
snapshot = await read_snapshot()
|
||||
if snapshot:
|
||||
@@ -288,8 +296,13 @@ async def submit(prepared: Prepared, symbol: str, analysis_id: int, arguments: d
|
||||
async def run_analysis(symbol: str, reasons: list[str]) -> None:
|
||||
"""一次完整的分析;结果和失败原因都写进 trading_analyses 和时间线,不向外抛出"""
|
||||
async with SessionLocal() as session:
|
||||
# 串行核验额度并占用名额,防止并发分析或已排队请求越过新上限。
|
||||
await session.scalar(select(TradingAccount).where(TradingAccount.id == 1).with_for_update())
|
||||
if reasons != ["手动分析"] and await daily_limit_reached(session):
|
||||
await session.commit()
|
||||
return
|
||||
target = await session.get(TradingSymbol, symbol)
|
||||
analysis = TradingAnalysis(symbol=symbol, reasons=reasons, dry_run=not (target and target.running), status="running")
|
||||
analysis = TradingAnalysis(exchange=await active_exchange(session), symbol=symbol, reasons=reasons, dry_run=not (target and target.running), status="running")
|
||||
session.add(analysis)
|
||||
await session.commit()
|
||||
analysis_id, dry_run = analysis.id, analysis.dry_run
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Bybit USDT 永续模拟盘适配;交易地址固定为 Demo Trading,不能切换实盘。"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from alphapulse.trading.client import EXCHANGE_INFO_SECONDS, OWN_CLIENT_ID, RECV_WINDOW_MS, TIMEOUT_SECONDS, ExchangeError, step_decimal
|
||||
from alphapulse.trading.plan import OrderSpec, SymbolRules
|
||||
|
||||
BASE_URL = "https://api-demo.bybit.com"
|
||||
STATUSES = {
|
||||
"New": "NEW", "PartiallyFilled": "PARTIALLY_FILLED", "Filled": "FILLED",
|
||||
"Cancelled": "CANCELED", "PartiallyFilledCanceled": "CANCELED", "Rejected": "REJECTED",
|
||||
"Untriggered": "NEW", "Triggered": "NEW", "Deactivated": "CANCELED",
|
||||
}
|
||||
|
||||
|
||||
def response_result(response: httpx.Response) -> dict:
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
raise ExchangeError(response.status_code, None, "Bybit 返回了无法解析的响应") from None
|
||||
code = body.get("retCode")
|
||||
if response.status_code >= 400 or code != 0:
|
||||
raise ExchangeError(response.status_code, code, body.get("retMsg", "Bybit 请求失败"))
|
||||
return body["result"]
|
||||
|
||||
|
||||
async def public_get(path: str, params: dict | None = None) -> dict:
|
||||
async with httpx.AsyncClient(base_url=BASE_URL, timeout=TIMEOUT_SECONDS) as http:
|
||||
try:
|
||||
response = await http.get(path, params=params)
|
||||
except httpx.TransportError as e:
|
||||
raise ExchangeError(None, None, "无法连接 Bybit 模拟盘") from e
|
||||
return response_result(response)
|
||||
|
||||
|
||||
def normalize_order(row: dict, is_algo: bool = False) -> dict:
|
||||
status = STATUSES[row["orderStatus"]]
|
||||
result = {
|
||||
"symbol": row["symbol"], "orderId": row["orderId"], "clientOrderId": row["orderLinkId"] if OWN_CLIENT_ID.fullmatch(row["orderLinkId"]) else row["orderId"],
|
||||
"side": row["side"].upper(), "type": row["orderType"].upper(), "status": status,
|
||||
"price": row["price"] or "0", "origQty": row["qty"], "executedQty": row["cumExecQty"],
|
||||
"avgPrice": row["avgPrice"] or "0", "rejectReason": row.get("rejectReason"), "reduceOnly": row["reduceOnly"], "time": int(row["createdTime"]),
|
||||
}
|
||||
if is_algo:
|
||||
stop_type = row["stopOrderType"]
|
||||
if stop_type in ("StopLoss", "PartialStopLoss"):
|
||||
order_type = "STOP"
|
||||
elif stop_type in ("TakeProfit", "PartialTakeProfit"):
|
||||
order_type = "TAKE_PROFIT"
|
||||
elif stop_type == "Stop":
|
||||
order_type = "STOP" if (row["side"] == "Sell") == (row["triggerDirection"] == 2) else "TAKE_PROFIT"
|
||||
elif stop_type == "TrailingStop":
|
||||
order_type = "TRAILING_STOP"
|
||||
else:
|
||||
raise ExchangeError(None, None, f"Bybit 返回了不支持的条件单类型:{stop_type}")
|
||||
if row["orderType"] == "Market":
|
||||
order_type += "_MARKET"
|
||||
result |= {
|
||||
"algoId": row["orderId"], "clientAlgoId": result["clientOrderId"],
|
||||
"algoStatus": {"NEW": "NEW", "PARTIALLY_FILLED": "TRIGGERING", "FILLED": "FINISHED"}.get(status, status),
|
||||
"actualOrderId": row["orderId"] if Decimal(row["cumExecQty"]) > 0 else None,
|
||||
"orderType": order_type,
|
||||
"triggerPrice": row["triggerPrice"], "quantity": row["qty"],
|
||||
"closePosition": row["qty"] == "0" and row["reduceOnly"] and row["closeOnTrigger"], "createTime": int(row["createdTime"]),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def normalize_trade(row: dict) -> dict:
|
||||
return {
|
||||
"symbol": row["symbol"], "id": row["tradeId"], "orderId": row["orderId"], "side": row["side"].upper(),
|
||||
"price": row["tradePrice"], "qty": row["qty"], "realizedPnl": row["cashFlow"],
|
||||
"commission": row["fee"], "commissionAsset": row["currency"], "time": int(row["transactionTime"]),
|
||||
}
|
||||
|
||||
|
||||
class BybitDemoClient:
|
||||
exchange = "bybit"
|
||||
|
||||
def __init__(self, api_key: str, api_secret: str) -> None:
|
||||
self.api_key = api_key
|
||||
self.secret = api_secret.encode()
|
||||
self.http = httpx.AsyncClient(base_url=BASE_URL, timeout=TIMEOUT_SECONDS)
|
||||
self.time_offset = 0
|
||||
|
||||
async def __aenter__(self) -> "BybitDemoClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
await self.http.aclose()
|
||||
|
||||
async def request(self, method: str, path: str, params: dict[str, Any] | None = None) -> dict:
|
||||
# 只重发明确未受理的时间戳错误;超时的写请求由订单流水查询确认。
|
||||
for attempt in range(2):
|
||||
values = {k: v for k, v in (params or {}).items() if v is not None}
|
||||
payload = urlencode(values) if method == "GET" else json.dumps(values, separators=(",", ":"))
|
||||
timestamp = str(int(time.time() * 1000) + self.time_offset)
|
||||
signature = hmac.new(self.secret, f"{timestamp}{self.api_key}{RECV_WINDOW_MS}{payload}".encode(), hashlib.sha256).hexdigest()
|
||||
headers = {"X-BAPI-API-KEY": self.api_key, "X-BAPI-SIGN": signature,
|
||||
"X-BAPI-TIMESTAMP": timestamp, "X-BAPI-RECV-WINDOW": str(RECV_WINDOW_MS)}
|
||||
try:
|
||||
if method == "GET":
|
||||
response = await self.http.get(f"{path}?{payload}" if payload else path, headers=headers)
|
||||
else:
|
||||
response = await self.http.request(method, path, content=payload, headers={**headers, "Content-Type": "application/json"})
|
||||
except httpx.TransportError as e:
|
||||
raise ExchangeError(None, None, "无法连接 Bybit 模拟盘,请查询订单状态确认执行结果") from e
|
||||
try:
|
||||
return response_result(response)
|
||||
except ExchangeError as e:
|
||||
if e.code != 10002 or attempt:
|
||||
message = e.message.replace(self.api_key, "[已隐藏]").replace(self.secret.decode(), "[已隐藏]")
|
||||
raise ExchangeError(e.status, e.code, message) from None
|
||||
started = int(time.time() * 1000)
|
||||
server = await public_get("/v5/market/time")
|
||||
self.time_offset = int(server["timeNano"]) // 1_000_000 - (started + int(time.time() * 1000)) // 2
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
async def pages(self, path: str, params: dict) -> list[dict]:
|
||||
rows, cursor = [], None
|
||||
while True:
|
||||
result = await self.request("GET", path, {**params, "cursor": cursor, "limit": 50})
|
||||
rows.extend(result["list"])
|
||||
next_cursor = result.get("nextPageCursor")
|
||||
if not next_cursor:
|
||||
return rows
|
||||
if next_cursor == cursor:
|
||||
raise ExchangeError(None, None, "Bybit 分页游标没有推进,无法确认完整结果")
|
||||
cursor = next_cursor
|
||||
|
||||
async def account(self) -> dict:
|
||||
result = await self.request("GET", "/v5/account/wallet-balance", {"accountType": "UNIFIED", "coin": "USDT"})
|
||||
coins = result["list"][0]["coin"]
|
||||
coin = next((c for c in coins if c["coin"] == "USDT"), None)
|
||||
if coin is None:
|
||||
raise ExchangeError(None, None, "Bybit 模拟盘没有 USDT 余额,请先在模拟账户领取资金")
|
||||
# 统一账户的总额以 USD 计价且逐仓时为空;策略只能使用 USDT 自身的余额和保证金。
|
||||
available = (Decimal(coin["walletBalance"]) - Decimal(coin["totalPositionIM"] or "0")
|
||||
- Decimal(coin["totalOrderIM"] or "0") - Decimal(coin["locked"] or "0") - Decimal(coin["bonus"] or "0"))
|
||||
return {"totalWalletBalance": coin["walletBalance"], "totalMarginBalance": coin["equity"],
|
||||
"availableBalance": format(available, "f"), "totalUnrealizedProfit": coin["unrealisedPnl"]}
|
||||
|
||||
async def raw_positions(self, symbol: str | None = None) -> list[dict]:
|
||||
return await self.pages("/v5/position/list", {"category": "linear", "settleCoin": "USDT", "symbol": symbol})
|
||||
|
||||
async def positions(self) -> list[dict]:
|
||||
info = await self.request("GET", "/v5/account/info")
|
||||
isolated = info["marginMode"] == "ISOLATED_MARGIN"
|
||||
rows = await self.raw_positions()
|
||||
if any(p["positionIdx"] != 0 and Decimal(p["size"]) != 0 for p in rows):
|
||||
raise ExchangeError(None, None, "Bybit 有双向持仓,请先在模拟盘平仓并切换为单向持仓")
|
||||
return [{
|
||||
"symbol": p["symbol"], "positionAmt": ("-" if p["side"] == "Sell" else "") + p["size"],
|
||||
"entryPrice": p["avgPrice"] or "0", "breakEvenPrice": p["breakEvenPrice"] or p["avgPrice"] or "0",
|
||||
"markPrice": p["markPrice"], "unRealizedProfit": p["unrealisedPnl"], "liquidationPrice": p["liqPrice"] or "0",
|
||||
"notional": p["positionValue"], "isolatedMargin": p["positionIM"] if isolated else "0",
|
||||
"initialMargin": p["positionIM"],
|
||||
} for p in rows if Decimal(p["size"]) != 0]
|
||||
|
||||
async def position_mode(self) -> bool:
|
||||
return any(p["positionIdx"] != 0 for p in await self.raw_positions())
|
||||
|
||||
async def symbol_config(self, symbol: str) -> dict:
|
||||
info = await self.request("GET", "/v5/account/info")
|
||||
rows = await self.raw_positions(symbol)
|
||||
return {"leverage": int(Decimal(rows[0]["leverage"] or "0")) if rows else 0,
|
||||
"marginType": "ISOLATED" if info["marginMode"] == "ISOLATED_MARGIN" else "CROSSED"}
|
||||
|
||||
async def ensure_setup(self, symbol: str) -> None:
|
||||
rows = await self.raw_positions(symbol)
|
||||
if any(p["positionIdx"] != 0 for p in rows):
|
||||
await self.request("POST", "/v5/position/switch-mode", {"category": "linear", "symbol": symbol, "mode": 0})
|
||||
config = await self.symbol_config(symbol)
|
||||
if config["marginType"] != "ISOLATED":
|
||||
await self.request("POST", "/v5/account/set-margin-mode", {"setMarginMode": "ISOLATED_MARGIN"})
|
||||
if config["leverage"] != 3:
|
||||
try:
|
||||
await self.request("POST", "/v5/position/set-leverage", {"category": "linear", "symbol": symbol,
|
||||
"buyLeverage": "3", "sellLeverage": "3"})
|
||||
except ExchangeError as e:
|
||||
if e.code != 110043:
|
||||
raise
|
||||
|
||||
async def open_orders(self, symbol: str, is_algo: bool = False) -> list[dict]:
|
||||
rows = await self.pages("/v5/order/realtime", {"category": "linear", "symbol": symbol, "openOnly": 0})
|
||||
return [normalize_order(o, is_algo) for o in rows if bool(o.get("triggerPrice") and Decimal(o["triggerPrice"])) == is_algo]
|
||||
|
||||
async def order(self, symbol: str, client_id: str, is_algo: bool = False) -> dict:
|
||||
params = {"category": "linear", "symbol": symbol, **({"orderId": client_id} if not OWN_CLIENT_ID.fullmatch(client_id) else {"orderLinkId": client_id})}
|
||||
rows = (await self.request("GET", "/v5/order/realtime", params))["list"]
|
||||
if not rows:
|
||||
rows = (await self.request("GET", "/v5/order/history", params))["list"]
|
||||
if not rows:
|
||||
raise ExchangeError(200, 110001, "Bybit 查无此单")
|
||||
return normalize_order(rows[0], is_algo)
|
||||
|
||||
async def actual_order(self, symbol: str, order_id: str) -> dict:
|
||||
return await self.order(symbol, order_id)
|
||||
|
||||
async def place_order(self, symbol: str, client_id: str, spec: OrderSpec, reduce_only: bool) -> dict:
|
||||
params = {"category": "linear", "symbol": symbol, "side": spec.side.title(),
|
||||
"orderType": "Limit" if spec.type == "LIMIT" else "Market", "qty": format(spec.quantity, "f"),
|
||||
"orderLinkId": client_id, "positionIdx": 0, "reduceOnly": reduce_only}
|
||||
if spec.type == "LIMIT":
|
||||
params |= {"price": format(spec.price, "f"), "timeInForce": "GTC"}
|
||||
if spec.type == "STOP_MARKET":
|
||||
params |= {"triggerPrice": format(spec.trigger_price, "f"), "triggerDirection": 1 if spec.side == "BUY" else 2,
|
||||
"triggerBy": "MarkPrice", "closeOnTrigger": True}
|
||||
result = await self.request("POST", "/v5/order/create", params)
|
||||
# ACK 仅表示受理;保持 submitting,下一轮查询实际状态后才允许依赖止损的入场。
|
||||
return {"algoId": result["orderId"], "orderId": result["orderId"], "status": "SUBMITTING"}
|
||||
|
||||
async def cancel_order(self, symbol: str, client_id: str, is_algo: bool = False) -> None:
|
||||
params = {"category": "linear", "symbol": symbol,
|
||||
**({"orderId": client_id} if not OWN_CLIENT_ID.fullmatch(client_id) else {"orderLinkId": client_id})}
|
||||
try:
|
||||
await self.request("POST", "/v5/order/cancel", params)
|
||||
except ExchangeError as e:
|
||||
if e.code != 110001:
|
||||
raise
|
||||
|
||||
async def trades(self, symbol: str | None, since_ms: int) -> list[dict]:
|
||||
# 资金流水提供逐笔已实现盈亏;execution/list 没有该字段,不能伪造为 0。
|
||||
now_ms = int(time.time() * 1000)
|
||||
rows = []
|
||||
while since_ms <= now_ms:
|
||||
end = min(now_ms, since_ms + 7 * 86400_000 - 1)
|
||||
page = await self.pages("/v5/account/transaction-log", {
|
||||
"accountType": "UNIFIED", "category": "linear", "currency": "USDT", "type": "TRADE",
|
||||
"startTime": since_ms, "endTime": end,
|
||||
})
|
||||
rows.extend(normalize_trade(t) for t in page if t["symbol"].endswith("USDT") and (symbol is None or t["symbol"] == symbol))
|
||||
since_ms = end + 1
|
||||
return sorted(rows, key=lambda t: (t["time"], t["id"]))
|
||||
|
||||
|
||||
_instruments: tuple[float, dict[str, dict]] | None = None
|
||||
|
||||
|
||||
async def instruments() -> dict[str, dict]:
|
||||
global _instruments
|
||||
if _instruments is None or time.monotonic() - _instruments[0] > EXCHANGE_INFO_SECONDS:
|
||||
parsed, cursor = {}, None
|
||||
while True:
|
||||
result = await public_get("/v5/market/instruments-info", {"category": "linear", "limit": 1000, **({"cursor": cursor} if cursor else {})})
|
||||
parsed.update({s["symbol"]: s for s in result["list"] if s["contractType"] == "LinearPerpetual" and s["settleCoin"] == "USDT" and s["status"] == "Trading"})
|
||||
next_cursor = result.get("nextPageCursor")
|
||||
if not next_cursor:
|
||||
break
|
||||
if next_cursor == cursor:
|
||||
raise ExchangeError(None, None, "Bybit 交易规则分页游标没有推进")
|
||||
cursor = next_cursor
|
||||
_instruments = time.monotonic(), parsed
|
||||
return _instruments[1]
|
||||
|
||||
|
||||
async def symbol_rules(symbol: str) -> SymbolRules:
|
||||
info = (await instruments()).get(symbol)
|
||||
if info is None:
|
||||
raise ExchangeError(None, None, f"Bybit 模拟盘没有交易对 {symbol}")
|
||||
return SymbolRules(tick_size=step_decimal(info["priceFilter"]["tickSize"]),
|
||||
step_size=step_decimal(info["lotSizeFilter"]["qtyStep"]),
|
||||
min_notional=Decimal(info["lotSizeFilter"]["minNotionalValue"]))
|
||||
@@ -13,7 +13,7 @@ from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from alphapulse.trading.plan import SymbolRules
|
||||
from alphapulse.trading.plan import OrderSpec, SymbolRules
|
||||
|
||||
BASE_URL = "https://demo-fapi.binance.com"
|
||||
RECV_WINDOW_MS = 5000
|
||||
@@ -24,7 +24,7 @@ OWN_CLIENT_ID = re.compile(r"^ap\d+$")
|
||||
|
||||
|
||||
class ExchangeError(Exception):
|
||||
"""交易所或网络错误;code 为币安错误码(如 -2021),网络层错误时 status、code 为空"""
|
||||
"""交易所或网络错误;code 保留交易所原始错误码,网络错误时 status、code 为空"""
|
||||
|
||||
def __init__(self, status: int | None, code: int | None, message: str) -> None:
|
||||
super().__init__(f"{status} {code}: {message}")
|
||||
@@ -34,13 +34,13 @@ class ExchangeError(Exception):
|
||||
|
||||
@property
|
||||
def retryable(self) -> bool:
|
||||
# 只有限流、服务端错误、-1001(内部断连)可以重试;业务错误重试也不会成功
|
||||
return self.status in (418, 429) or (self.status or 0) >= 500 or self.code == -1001
|
||||
# 超时只允许查询确认状态,不代表可以直接重发下单;业务拒绝不能重试
|
||||
return self.status in (408, 418, 429) or (self.status or 0) >= 500 or self.code in (-1001, -1007, 10000, 10006, 10016, 10019)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.status is None:
|
||||
return self.message
|
||||
return f"币安返回 {self.status}({self.code}):{self.message}"
|
||||
return f"交易所返回 {self.status}({self.code}):{self.message}"
|
||||
|
||||
|
||||
async def public_get(path: str, params: dict[str, Any] | None = None) -> Any:
|
||||
@@ -56,6 +56,8 @@ async def public_get(path: str, params: dict[str, Any] | None = None) -> Any:
|
||||
|
||||
|
||||
class DemoClient:
|
||||
exchange = "binance"
|
||||
|
||||
def __init__(self, api_key: str, api_secret: str) -> None:
|
||||
self.secret = api_secret.encode()
|
||||
self.http = httpx.AsyncClient(base_url=BASE_URL, timeout=TIMEOUT_SECONDS, headers={"X-MBX-APIKEY": api_key})
|
||||
@@ -101,6 +103,88 @@ class DemoClient:
|
||||
raise ExchangeError(resp.status_code, code, body.get("msg", resp.text[:200]) if isinstance(body, dict) else resp.text[:200])
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
async def account(self) -> dict:
|
||||
return await self.request("GET", "/fapi/v3/account")
|
||||
|
||||
async def positions(self) -> list[dict]:
|
||||
return await self.request("GET", "/fapi/v3/positionRisk")
|
||||
|
||||
async def position_mode(self) -> bool:
|
||||
return (await self.request("GET", "/fapi/v1/positionSide/dual"))["dualSidePosition"]
|
||||
|
||||
async def symbol_config(self, symbol: str) -> dict:
|
||||
return (await self.request("GET", "/fapi/v1/symbolConfig", {"symbol": symbol}))[0]
|
||||
|
||||
async def ensure_setup(self, symbol: str) -> None:
|
||||
if await self.position_mode():
|
||||
await self.request("POST", "/fapi/v1/positionSide/dual", {"dualSidePosition": False})
|
||||
config = await self.symbol_config(symbol)
|
||||
if config["marginType"] != "ISOLATED":
|
||||
await self.request("POST", "/fapi/v1/marginType", {"symbol": symbol, "marginType": "ISOLATED"})
|
||||
if config["leverage"] != 3:
|
||||
await self.request("POST", "/fapi/v1/leverage", {"symbol": symbol, "leverage": 3})
|
||||
|
||||
async def open_orders(self, symbol: str, is_algo: bool = False) -> list[dict]:
|
||||
return await self.request("GET", "/fapi/v1/openAlgoOrders" if is_algo else "/fapi/v1/openOrders", {"symbol": symbol})
|
||||
|
||||
async def order(self, symbol: str, client_id: str, is_algo: bool = False) -> dict:
|
||||
if is_algo:
|
||||
return await self.request("GET", "/fapi/v1/algoOrder", {"clientAlgoId": client_id})
|
||||
return await self.request("GET", "/fapi/v1/order", {"symbol": symbol, "origClientOrderId": client_id})
|
||||
|
||||
async def actual_order(self, symbol: str, order_id: str) -> dict:
|
||||
return await self.request("GET", "/fapi/v1/order", {"symbol": symbol, "orderId": order_id})
|
||||
|
||||
async def place_order(self, symbol: str, client_id: str, spec: OrderSpec, reduce_only: bool) -> dict:
|
||||
if spec.type == "STOP_MARKET":
|
||||
return await self.request("POST", "/fapi/v1/algoOrder", {
|
||||
"algoType": "CONDITIONAL", "symbol": symbol, "side": spec.side, "type": "STOP_MARKET",
|
||||
"triggerPrice": format(spec.trigger_price, "f"), "quantity": format(spec.quantity, "f"),
|
||||
"reduceOnly": True, "workingType": "MARK_PRICE", "clientAlgoId": client_id,
|
||||
})
|
||||
params = {"symbol": symbol, "side": spec.side, "type": spec.type, "quantity": format(spec.quantity, "f"),
|
||||
"reduceOnly": reduce_only or None, "newClientOrderId": client_id}
|
||||
if spec.type == "LIMIT":
|
||||
params |= {"price": format(spec.price, "f"), "timeInForce": "GTC"}
|
||||
return await self.request("POST", "/fapi/v1/order", params)
|
||||
|
||||
async def cancel_order(self, symbol: str, client_id: str, is_algo: bool = False) -> None:
|
||||
if is_algo:
|
||||
await self.request("DELETE", "/fapi/v1/algoOrder", {"clientAlgoId": client_id})
|
||||
else:
|
||||
await self.request("DELETE", "/fapi/v1/order", {"symbol": symbol, "origClientOrderId": client_id})
|
||||
|
||||
async def trades(self, symbol: str, since_ms: int) -> list[dict]:
|
||||
rows = []
|
||||
params = {"symbol": symbol, "startTime": since_ms, "limit": 1000}
|
||||
while True:
|
||||
page = await self.request("GET", "/fapi/v1/userTrades", params)
|
||||
rows.extend(page)
|
||||
if len(page) < 1000:
|
||||
return rows
|
||||
params = {"symbol": symbol, "fromId": page[-1]["id"] + 1, "limit": 1000}
|
||||
|
||||
|
||||
def credential_key(credential) -> tuple | None:
|
||||
if credential is None or credential.api_key_encrypted is None:
|
||||
return None
|
||||
return credential.exchange, credential.api_key_encrypted, credential.api_secret_encrypted
|
||||
|
||||
|
||||
def create_client(credential):
|
||||
from alphapulse.core.crypto import decrypt
|
||||
from alphapulse.trading.bybit import BybitDemoClient
|
||||
|
||||
client_type = {"binance": DemoClient, "bybit": BybitDemoClient}[credential.exchange]
|
||||
return client_type(decrypt(credential.api_key_encrypted), decrypt(credential.api_secret_encrypted))
|
||||
|
||||
|
||||
async def active_exchange(session) -> str:
|
||||
from alphapulse.models import TradingAccount
|
||||
|
||||
account = await session.get(TradingAccount, 1)
|
||||
return account.exchange if account else "binance"
|
||||
|
||||
|
||||
_exchange_info: tuple[float, dict[str, SymbolRules]] | None = None
|
||||
|
||||
@@ -111,8 +195,11 @@ def step_decimal(raw: str) -> Decimal:
|
||||
return value.quantize(Decimal(1)) if value.as_tuple().exponent > 0 else value
|
||||
|
||||
|
||||
async def symbol_rules(symbol: str) -> SymbolRules:
|
||||
async def symbol_rules(symbol: str, exchange: str = "binance") -> SymbolRules:
|
||||
"""模拟盘的价格精度、数量精度、最小下单金额,按小时缓存"""
|
||||
if exchange == "bybit":
|
||||
from alphapulse.trading.bybit import symbol_rules as bybit_rules
|
||||
return await bybit_rules(symbol)
|
||||
global _exchange_info
|
||||
if _exchange_info is None or time.monotonic() - _exchange_info[0] > EXCHANGE_INFO_SECONDS:
|
||||
info = await public_get("/fapi/v1/exchangeInfo")
|
||||
@@ -130,5 +217,9 @@ async def symbol_rules(symbol: str) -> SymbolRules:
|
||||
return _exchange_info[1][symbol]
|
||||
|
||||
|
||||
async def mark_price(symbol: str) -> Decimal:
|
||||
async def mark_price(symbol: str, exchange: str = "binance") -> Decimal:
|
||||
if exchange == "bybit":
|
||||
from alphapulse.trading.bybit import public_get as bybit_get
|
||||
result = await bybit_get("/v5/market/tickers", {"category": "linear", "symbol": symbol})
|
||||
return Decimal(result["list"][0]["markPrice"])
|
||||
return Decimal((await public_get("/fapi/v1/premiumIndex", {"symbol": symbol}))["markPrice"])
|
||||
@@ -15,7 +15,7 @@ from alphapulse.trading.analyst import run_analysis
|
||||
from alphapulse.trading.executor import executor_loop
|
||||
from alphapulse.trading.news_signals import news_signal_loop
|
||||
from alphapulse.trading.schedule import routine_requests
|
||||
from alphapulse.trading.snapshot import snapshot_loop
|
||||
from alphapulse.trading.snapshot import SnapshotError, read_snapshot, require_fresh_snapshot, snapshot_loop
|
||||
|
||||
logger = logging.getLogger("alphapulse.trading.engine")
|
||||
|
||||
@@ -36,6 +36,11 @@ async def fail_interrupted() -> None:
|
||||
|
||||
async def claim_requests(limit: int, busy: set[str]) -> list[tuple[str, list[str]]]:
|
||||
"""取出到期的分析请求并清空,同一请求里的多个触发原因合并为一次分析"""
|
||||
try:
|
||||
require_fresh_snapshot(await read_snapshot())
|
||||
except SnapshotError:
|
||||
# 领取后就会创建分析记录;账户未就绪时保留请求,避免空耗次数和分析间隔。
|
||||
return []
|
||||
async with SessionLocal() as session:
|
||||
symbols = (await session.scalars(
|
||||
select(TradingSymbol)
|
||||
|
||||
@@ -14,20 +14,19 @@ 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 import BigInteger, cast, 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.core.redis import KEY_PREFIX, redis
|
||||
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.models import TradingAccount, TradingAnalysis, TradingCredential, TradingEvent, TradingFill, TradingOrder, TradingSymbol
|
||||
from alphapulse.trading.client import OWN_CLIENT_ID, DemoClient, ExchangeError, create_client, credential_key, mark_price, symbol_rules
|
||||
from alphapulse.trading.fills import save_fills, sync_fills
|
||||
from alphapulse.trading.plan import (
|
||||
MAX_HOLD_HOURS, TAKE_PROFIT_STEPS, OrderSpec, SymbolRules, condition_met, desired_orders, diff_orders, round_to_step,
|
||||
)
|
||||
from alphapulse.trading.positions import build_positions
|
||||
from alphapulse.trading.schedule import request_analysis
|
||||
|
||||
logger = logging.getLogger("alphapulse.trading.executor")
|
||||
@@ -49,10 +48,8 @@ PURPOSE_LABELS = {"entry": "入场", "tp": "止盈", "stop": "止损", "close":
|
||||
CLOSE_REASON_LABELS = {"stop": "止损", "take_profit": "止盈", "time": "到达持仓时限", "exit": "模型决定结束",
|
||||
"external": "外部平仓(如在网页手动平仓)", "stop_failed": "止损挂单失败", "close": "市价平仓",
|
||||
"manual_close": "一键平仓", "manual_cancel": "清除挂单"}
|
||||
# 页面上的「平仓」「清除挂单」:API 写入请求,执行器下一轮取走执行,避免和对账同时改计划
|
||||
MANUAL_ACTION_KEY = f"{KEY_PREFIX}trader:manual:{{}}"
|
||||
MANUAL_ACTIONS = {"close": "一键平仓", "cancel": "清除挂单"}
|
||||
CANCEL_REASON_LABELS = {"expired": "限价单到期未成交", "stopped": "已停止交易", "entry_failed": "入场单被拒"}
|
||||
CANCEL_REASON_LABELS = {"expired": "限价单到期未成交", "stopped": "已停止交易", "entry_failed": "入场单被拒", "replaced": "被新计划替换"}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -75,11 +72,11 @@ def utcnow() -> datetime:
|
||||
class Executor:
|
||||
def __init__(self) -> None:
|
||||
self.client: DemoClient | None = None
|
||||
self.key_ciphertext: bytes | None = None
|
||||
self.key_ciphertext: tuple | None = None
|
||||
self.exchange = "binance"
|
||||
self.market = BinanceClient()
|
||||
# 已设置为逐仓 3 倍的币;单向持仓是账户级,只设一次
|
||||
# 每家交易所分别缓存已设置为单向、逐仓 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, 最近收盘价)
|
||||
@@ -90,17 +87,21 @@ class Executor:
|
||||
|
||||
# ---------- 基础 ----------
|
||||
|
||||
async def ensure_client(self, account: TradingAccount | None) -> DemoClient | None:
|
||||
ciphertext = account.api_key_encrypted if account else None
|
||||
async def ensure_client(self, account: TradingCredential | None) -> DemoClient | None:
|
||||
ciphertext = credential_key(account)
|
||||
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))
|
||||
self.client = create_client(account)
|
||||
self.exchange = account.exchange
|
||||
self.stop_failures.clear()
|
||||
self.unmanaged_noticed.clear()
|
||||
self.error_events.clear()
|
||||
self.fills_due = 0
|
||||
return self.client
|
||||
|
||||
async def report(self, session: AsyncSession, symbol: str | None, message: str, level: str = "error", analysis_id: int | None = None) -> None:
|
||||
@@ -110,7 +111,7 @@ class Executor:
|
||||
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))
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=symbol, analysis_id=analysis_id, kind="executor_error", level=level, message=message))
|
||||
|
||||
async def last_close(self, symbol: str, interval: str) -> Decimal:
|
||||
"""实盘该周期最近一根已收盘 K 线的收盘价;到下一根收盘前用缓存"""
|
||||
@@ -131,17 +132,9 @@ class Executor:
|
||||
|
||||
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})
|
||||
await client.ensure_setup(symbol)
|
||||
self.configured.add(symbol)
|
||||
|
||||
# ---------- 订单 ----------
|
||||
@@ -149,7 +142,7 @@ class Executor:
|
||||
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",
|
||||
exchange=client.exchange, 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",
|
||||
)
|
||||
@@ -157,21 +150,9 @@ class Executor:
|
||||
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())
|
||||
result = await client.place_order(symbol, client_id, spec, row.reduce_only)
|
||||
row.exchange_id = str(result["algoId"] if row.is_algo else result["orderId"])
|
||||
row.status = "new" if row.is_algo and client.exchange == "binance" else 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):
|
||||
@@ -187,30 +168,27 @@ class Executor:
|
||||
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})
|
||||
await client.cancel_order(symbol, client_id, is_algo)
|
||||
except ExchangeError as e:
|
||||
# -2011 / -2013:单已不在,等同步
|
||||
if e.code not in (-2011, -2013):
|
||||
if e.code not in (-2011, -2013, 110001):
|
||||
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 row.is_algo and client.exchange == "binance":
|
||||
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})
|
||||
algo = await client.order(row.symbol, client_id, True)
|
||||
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"]})
|
||||
actual = await client.actual_order(row.symbol, 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")
|
||||
@@ -220,13 +198,15 @@ class Executor:
|
||||
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})
|
||||
order = (open_algos if row.is_algo else open_orders).get(client_id) or await client.order(row.symbol, client_id, row.is_algo)
|
||||
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
|
||||
if row.status == "rejected":
|
||||
row.error = order.get("rejectReason") or "交易所异步拒绝订单"
|
||||
except ExchangeError as e:
|
||||
if e.code != -2013:
|
||||
if e.code not in (-2013, 110001):
|
||||
raise
|
||||
if row.status == "submitting" and utcnow() - row.created_at < timedelta(seconds=SUBMIT_GRACE_SECONDS):
|
||||
return
|
||||
@@ -235,7 +215,7 @@ class Executor:
|
||||
if row.filled_qty > before:
|
||||
delta = row.filled_qty - before
|
||||
leg = f" {row.leg + 1} " if row.purpose == "tp" else ""
|
||||
session.add(TradingEvent(
|
||||
session.add(TradingEvent(exchange=self.exchange,
|
||||
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}",
|
||||
@@ -247,8 +227,10 @@ class Executor:
|
||||
async def adopt(self, session: AsyncSession, client: DemoClient, symbol: TradingSymbol, current: TradingAnalysis | None,
|
||||
position: Position | None) -> TradingAnalysis | None:
|
||||
"""采纳最新的提议;较早的提议作废。返回采纳后的当前计划"""
|
||||
if current is not None and current.plan_status == "closing":
|
||||
return current
|
||||
proposals = (await session.scalars(
|
||||
select(TradingAnalysis).where(TradingAnalysis.symbol == symbol.symbol, TradingAnalysis.plan_status == "proposed")
|
||||
select(TradingAnalysis).where(TradingAnalysis.exchange == client.exchange, TradingAnalysis.symbol == symbol.symbol, TradingAnalysis.plan_status == "proposed")
|
||||
.order_by(TradingAnalysis.id.desc())
|
||||
)).all()
|
||||
if not proposals:
|
||||
@@ -261,10 +243,10 @@ class Executor:
|
||||
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",
|
||||
session.add(TradingEvent(exchange=self.exchange, 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",
|
||||
session.add(TradingEvent(exchange=self.exchange, 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))
|
||||
@@ -274,29 +256,46 @@ class Executor:
|
||||
if qty > 0 or (current is not None and current.plan_status != "pending"):
|
||||
proposal.plan_status = "stale"
|
||||
reason = f"已有持仓 {fmt(qty)}" if qty > 0 else "计划正在平仓"
|
||||
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_stale", level="warn",
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_stale", level="warn",
|
||||
message=f"计划未采纳:{reason}"))
|
||||
return current
|
||||
if current is not None:
|
||||
current.plan_status, current.close_reason, current.closed_at = "replaced", "replaced", now
|
||||
# 撤单回执不保证未成交;旧计划收尾前不能把迟到成交归到新计划。
|
||||
await self.cancel_plan(session, current, "replaced", "等待旧入场单撤销确认后替换计划")
|
||||
return current
|
||||
proposal.plan_status = "pending"
|
||||
proposal.valid_until = now + timedelta(hours=float(proposal.decision["valid_hours"]))
|
||||
entry = proposal.sizing["entry"]
|
||||
side = "做多" if proposal.sizing["direction"] == "long" else "做空"
|
||||
session.add(TradingEvent(
|
||||
session.add(TradingEvent(exchange=self.exchange,
|
||||
symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_adopted",
|
||||
message=f"采纳计划 #{proposal.id}" + (f",替换 #{current.id}" if current else "")
|
||||
+ f":限价{side} {entry['price']} × {entry['quantity']},止损 {entry['stop']}({entry['stop_pct']}%),"
|
||||
message=f"采纳计划 #{proposal.id}:限价{side} {entry['price']} × {entry['quantity']},止损 {entry['stop']}({entry['stop_pct']}%),"
|
||||
f"有效至 {proposal.valid_until.astimezone(LOCAL_TZ).strftime('%H:%M')}",
|
||||
))
|
||||
return proposal
|
||||
|
||||
async def realized(self, client: DemoClient, symbol: str, since: datetime | None) -> dict | None:
|
||||
if since is None:
|
||||
async def realized(self, session: AsyncSession, client: DemoClient, plan: TradingAnalysis, rows: list[TradingOrder]) -> dict | None:
|
||||
if plan.first_fill_at 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))
|
||||
entries = [r for r in rows if r.purpose == "entry" and r.filled_qty > 0]
|
||||
if not entries:
|
||||
raise ExchangeError(None, None, "入场成交尚未同步,暂不能结算计划盈亏")
|
||||
if any(r.exchange_id is None for r in entries):
|
||||
raise RuntimeError("已成交的入场单缺少交易所订单 ID")
|
||||
since = min(r.created_at for r in entries) - timedelta(seconds=1)
|
||||
trades = await client.trades(plan.symbol, int(since.timestamp() * 1000))
|
||||
await save_fills(session, client.exchange, trades)
|
||||
trade_order = cast(TradingFill.trade_id, BigInteger) if client.exchange == "binance" else TradingFill.trade_id
|
||||
fills = list(await session.scalars(select(TradingFill).where(
|
||||
TradingFill.exchange == client.exchange, TradingFill.symbol == plan.symbol, TradingFill.time >= since,
|
||||
).order_by(TradingFill.time, trade_order)))
|
||||
entry_ids = {r.exchange_id for r in entries}
|
||||
positions = [p for p in build_positions(fills) if p.order_ids & entry_ids]
|
||||
complete_entries = all(sum((p.entry_quantities.get(r.exchange_id, Decimal(0)) for p in positions), Decimal(0)) == r.filled_qty for r in entries)
|
||||
if not positions or not complete_entries:
|
||||
raise ExchangeError(None, None, "完整的开平仓成交尚未同步,暂不能结算计划盈亏")
|
||||
pnl = sum((p.realized_pnl for p in positions), Decimal(0))
|
||||
fee = sum((p.commission for p in positions), 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:
|
||||
@@ -305,25 +304,25 @@ class Executor:
|
||||
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.result = await self.realized(session, client, plan, rows)
|
||||
plan.plan_status = "closed" if plan.first_fill_at else "replaced" if plan.close_reason == "replaced" 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",
|
||||
label = CLOSE_REASON_LABELS.get(plan.close_reason) or CANCEL_REASON_LABELS.get(plan.close_reason, plan.close_reason)
|
||||
session.add(TradingEvent(exchange=self.exchange, 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})")
|
||||
if plan.close_reason not in ("manual_close", "manual_cancel", "replaced"):
|
||||
await request_analysis(session, plan.symbol, f"平仓({label})")
|
||||
|
||||
async def cancel_plan(self, session: AsyncSession, plan: TradingAnalysis, reason: str, detail: str, level: str = "info") -> None:
|
||||
"""未入场的计划撤销,排队重新分析(停止交易时 request_analysis 不排队)"""
|
||||
plan.plan_status, plan.close_reason, plan.closed_at = "canceled", reason, utcnow()
|
||||
session.add(TradingEvent(symbol=plan.symbol, analysis_id=plan.id, kind="plan_canceled", level=level,
|
||||
"""先撤入场单,确认终态和持仓后才结束;迟到的成交仍由原计划保护。"""
|
||||
plan.plan_status, plan.close_reason = "closing", reason
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=plan.symbol, analysis_id=plan.id, kind="plan_canceled", level=level,
|
||||
message=f"计划 #{plan.id} 撤销:{detail}"))
|
||||
await request_analysis(session, plan.symbol, f"计划撤销({CANCEL_REASON_LABELS[reason]})")
|
||||
|
||||
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",
|
||||
session.add(TradingEvent(exchange=self.exchange, 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:
|
||||
@@ -333,41 +332,63 @@ class Executor:
|
||||
))
|
||||
if exists:
|
||||
return
|
||||
session.add(TradingEvent(symbol=symbol, analysis_id=plan_id, kind=kind, level="warn", message=message, data={"index": index}))
|
||||
session.add(TradingEvent(exchange=self.exchange, 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,
|
||||
manual_action: str | None) -> None:
|
||||
async def reconcile(self, session: AsyncSession, client: DemoClient, symbol: TradingSymbol, running: bool,
|
||||
manual_request: TradingEvent | 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})}
|
||||
rules = await symbol_rules(name, client.exchange)
|
||||
open_orders = {o["clientOrderId"]: o for o in await client.open_orders(name)}
|
||||
open_algos = {o["clientAlgoId"]: o for o in await client.open_orders(name, True)}
|
||||
|
||||
rows = list((await session.scalars(
|
||||
select(TradingOrder).where(TradingOrder.symbol == name, TradingOrder.status.in_(ACTIVE_ORDER_STATUSES))
|
||||
select(TradingOrder).where(TradingOrder.exchange == client.exchange, 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()
|
||||
|
||||
# 同步订单和成交可能耗时,不能用本轮开始前的仓位判断是否已经平仓。
|
||||
position = None
|
||||
for p in await client.positions():
|
||||
if p["symbol"] == name and Decimal(p["positionAmt"]) != 0:
|
||||
amount = Decimal(p["positionAmt"])
|
||||
position = Position("long" if amount > 0 else "short", abs(amount), Decimal(p["entryPrice"]),
|
||||
Decimal(p["breakEvenPrice"]), Decimal(p["markPrice"]))
|
||||
|
||||
# 交易所上本程序的单,但流水里不是活动状态:孤儿单,撤掉(用户手动下的单不动)
|
||||
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')})"))
|
||||
session.add(TradingEvent(exchange=self.exchange, 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))
|
||||
select(TradingAnalysis).where(TradingAnalysis.exchange == client.exchange, 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)
|
||||
if manual_request is None and running:
|
||||
pending_old_entry = any(r.purpose == "entry" and r.status in ACTIVE_ORDER_STATUSES and (plan is None or r.analysis_id != plan.id) for r in rows)
|
||||
if not pending_old_entry:
|
||||
plan = await self.adopt(session, client, symbol, plan, position)
|
||||
else:
|
||||
for proposal in await session.scalars(select(TradingAnalysis).where(
|
||||
TradingAnalysis.exchange == client.exchange, TradingAnalysis.symbol == name, TradingAnalysis.plan_status == "proposed",
|
||||
)):
|
||||
proposal.plan_status = "stale"
|
||||
await session.commit()
|
||||
if manual_action is not None:
|
||||
await self.manual(session, client, name, manual_action, plan, position, open_orders, open_algos)
|
||||
if manual_request is not None:
|
||||
await self.manual(session, client, name, manual_request.data["action"], plan, position, open_orders, open_algos)
|
||||
await session.commit()
|
||||
if position is None and not open_orders and not open_algos and not any(r.status in ACTIVE_ORDER_STATUSES for r in rows) and (plan is None or plan.plan_status not in LIVE_PLAN_STATUSES):
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=name, kind="manual_completed",
|
||||
message=f"{MANUAL_ACTIONS[manual_request.data['action']]}完成:已确认无持仓、无挂单",
|
||||
data={"request_id": manual_request.id}))
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
# 不属于当前计划的活动订单(被替换、已结束的计划):入场和止盈先撤;止损等新止损挂上后再撤,不留无保护窗口
|
||||
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)]
|
||||
@@ -378,13 +399,13 @@ class Executor:
|
||||
|
||||
if plan is None:
|
||||
for row in stale:
|
||||
if row.purpose == "stop":
|
||||
if row.purpose == "stop" and position is None and not stale_entry_canceled:
|
||||
await self.cancel(client, row)
|
||||
if position is None:
|
||||
self.unmanaged_noticed.pop(name, None)
|
||||
elif running:
|
||||
elif running and manual_request is None:
|
||||
await self.unmanaged(session, client, name, position, open_algos)
|
||||
if running:
|
||||
if running and manual_request is None:
|
||||
await self.check_reevaluate(session, name, position.mark if position else None)
|
||||
await session.commit()
|
||||
return
|
||||
@@ -397,6 +418,12 @@ class Executor:
|
||||
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())
|
||||
entry_pending = any(r.purpose == "entry" and r.status in ACTIVE_ORDER_STATUSES for r in plan_rows)
|
||||
close_pending = any(r.purpose == "close" and r.status in ACTIVE_ORDER_STATUSES for r in plan_rows)
|
||||
filled_entry = any(r.purpose == "entry" and r.filled_qty > 0 for r in plan_rows)
|
||||
if plan.first_fill_at is None and (qty > 0 or filled_entry):
|
||||
plan.first_fill_at = now
|
||||
plan.close_deadline = now + timedelta(hours=MAX_HOLD_HOURS)
|
||||
|
||||
if plan.plan_status == "pending":
|
||||
entry_rows = [r for r in plan_rows if r.purpose == "entry"]
|
||||
@@ -405,7 +432,7 @@ class Executor:
|
||||
if qty > 0 or any(r.filled_qty > 0 for r in entry_rows):
|
||||
plan.plan_status, plan.first_fill_at = "open", now
|
||||
plan.close_deadline = now + timedelta(hours=MAX_HOLD_HOURS)
|
||||
session.add(TradingEvent(symbol=name, analysis_id=plan.id, kind="plan_open",
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=name, analysis_id=plan.id, kind="plan_open",
|
||||
message=f"限价单成交,计划 #{plan.id} 进入持仓;最晚 {plan.close_deadline.astimezone(LOCAL_TZ).strftime('%m-%d %H:%M')} 平仓"))
|
||||
elif not running:
|
||||
await self.cancel_plan(session, plan, "stopped", "已停止交易,撤销未成交的限价单")
|
||||
@@ -416,24 +443,31 @@ class Executor:
|
||||
await self.cancel_plan(session, plan, "entry_failed", f"限价单被拒:{rejected.error or rejected.status}", "error")
|
||||
elif plan.plan_status == "open":
|
||||
if qty == 0:
|
||||
await self.finish(session, client, plan, plan_rows)
|
||||
if entry_pending or close_pending:
|
||||
await self.start_closing(session, plan, "external")
|
||||
else:
|
||||
await self.finish(session, client, plan, plan_rows)
|
||||
elif now >= plan.close_deadline:
|
||||
await self.start_closing(session, plan, "time")
|
||||
elif plan.plan_status == "closing" and qty == 0:
|
||||
await self.finish(session, client, plan, plan_rows)
|
||||
elif plan.plan_status == "closing":
|
||||
if plan.close_reason in CANCEL_REASON_LABELS and (qty > 0 or filled_entry):
|
||||
plan.plan_status, plan.close_reason = "open", None
|
||||
elif qty == 0 and not entry_pending and not close_pending:
|
||||
await self.finish(session, client, plan, plan_rows)
|
||||
await session.commit()
|
||||
|
||||
protected = False
|
||||
if plan.plan_status in LIVE_PLAN_STATUSES:
|
||||
await self.sync_orders(session, client, plan, plan_rows, position, running, rules, stale_entry_canceled)
|
||||
protected = 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":
|
||||
if row.purpose == "stop" and not stale_entry_canceled and (protected or position is None and plan.plan_status not in LIVE_PLAN_STATUSES):
|
||||
await self.cancel(client, row)
|
||||
if running and plan.plan_status != "closing":
|
||||
if running and manual_request is None and plan.plan_status != "closing":
|
||||
await self.check_reevaluate(session, name, position.mark if position else None)
|
||||
await session.commit()
|
||||
|
||||
@@ -442,21 +476,14 @@ class Executor:
|
||||
"""一键平仓、清除挂单:撤掉该币全部挂单(含止损止盈和手动挂的),有持仓就市价平仓——程序不留没有止损的仓位。
|
||||
本程序的单通过结束计划撤销,否则下一轮对账会按计划补挂回来。撤销计划不像到期那样排队重新分析,免得马上又挂单"""
|
||||
reason = f"manual_{action}"
|
||||
label = MANUAL_ACTIONS[action]
|
||||
for client_id in [*open_orders, *open_algos]:
|
||||
if not OWN_CLIENT_ID.match(client_id):
|
||||
await self.cancel_client_id(client, symbol, client_id, client_id in open_algos)
|
||||
detail = f",市价平仓 {fmt(position.qty)}" if position else ""
|
||||
session.add(TradingEvent(symbol=symbol, analysis_id=plan.id if plan else None, kind=reason, level="warn",
|
||||
message=f"{label}:撤销全部挂单{detail}"))
|
||||
if plan is not None and plan.plan_status in ("pending", "open") and position is not None:
|
||||
# 平仓中的计划:撤入场和止盈、市价平仓,持仓归零后结束并撤掉止损
|
||||
plan.plan_status, plan.close_reason = "closing", reason
|
||||
elif plan is not None and plan.plan_status == "pending":
|
||||
plan.plan_status, plan.close_reason, plan.closed_at = "canceled", reason, utcnow()
|
||||
if plan is not None and plan.close_reason != reason:
|
||||
await self.start_closing(session, plan, reason)
|
||||
elif plan is None and position is not None:
|
||||
closing = await session.scalar(select(func.count()).where(
|
||||
TradingOrder.symbol == symbol, TradingOrder.purpose == "close", TradingOrder.status.in_(ACTIVE_ORDER_STATUSES),
|
||||
TradingOrder.exchange == client.exchange, TradingOrder.symbol == symbol, TradingOrder.purpose == "close", TradingOrder.status.in_(ACTIVE_ORDER_STATUSES),
|
||||
))
|
||||
if not closing:
|
||||
close_side = "SELL" if position.side == "long" else "BUY"
|
||||
@@ -465,13 +492,13 @@ class Executor:
|
||||
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))
|
||||
select(TradingAnalysis).where(TradingAnalysis.exchange == self.exchange, 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)
|
||||
mark = await mark_price(symbol, self.exchange)
|
||||
for i, condition in enumerate(latest.decision["reevaluate"]):
|
||||
met, price = await self.condition_met(symbol, condition, mark)
|
||||
if met:
|
||||
@@ -481,24 +508,31 @@ class Executor:
|
||||
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())
|
||||
has_stop = any(
|
||||
o["side"] == close_side and o["orderType"] == "STOP_MARKET" and (o["reduceOnly"] or o["closePosition"])
|
||||
and (o["closePosition"] or Decimal(o["quantity"]) >= position.qty)
|
||||
for o in open_algos.values()
|
||||
)
|
||||
if has_stop:
|
||||
if self.unmanaged_noticed.get(symbol) != position.entry:
|
||||
self.unmanaged_noticed[symbol] = position.entry
|
||||
session.add(TradingEvent(symbol=symbol, kind="unmanaged_position", level="warn",
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=symbol, kind="unmanaged_position", level="warn",
|
||||
message=f"{symbol} 有持仓 {fmt(position.qty)} 和止损但没有计划:程序不管理,由止损保护"))
|
||||
return
|
||||
closing = await session.scalar(select(func.count()).where(
|
||||
TradingOrder.symbol == symbol, TradingOrder.purpose == "close", TradingOrder.status.in_(ACTIVE_ORDER_STATUSES),
|
||||
TradingOrder.exchange == client.exchange, 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",
|
||||
session.add(TradingEvent(exchange=self.exchange, 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:
|
||||
position: Position | None, running: bool, rules: SymbolRules, block_entries: bool) -> bool:
|
||||
if client.exchange == "bybit" and plan.plan_status != "closing" and any(r.purpose == "stop" and r.status in ("rejected", "failed") for r in plan_rows):
|
||||
await self.start_closing(session, plan, "stop_failed")
|
||||
return False
|
||||
sizing = plan.sizing
|
||||
qty = position.qty if position else Decimal(0)
|
||||
long = sizing["direction"] == "long"
|
||||
@@ -508,10 +542,10 @@ class Executor:
|
||||
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):
|
||||
if qty > 0 and plan.close_reason not in CANCEL_REASON_LABELS 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
|
||||
return False
|
||||
|
||||
entry_rows = [r for r in plan_rows if r.purpose == "entry" and r.filled_qty > 0]
|
||||
filled = sum((r.filled_qty for r in entry_rows), Decimal(0))
|
||||
@@ -541,20 +575,23 @@ class Executor:
|
||||
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
|
||||
return False
|
||||
self.stop_failures.pop(plan.symbol, None)
|
||||
has_stop = any(r.purpose == "stop" and r.status == "new" for r in active if r not in to_cancel) or (client.exchange == "binance" and any(d.purpose == "stop" for d in to_place))
|
||||
# 2. 撤多余的单
|
||||
canceled_entry = False
|
||||
for row in to_cancel:
|
||||
if row.purpose == "stop" and not has_stop:
|
||||
continue
|
||||
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)
|
||||
return has_stop
|
||||
|
||||
async def stop_failed(self, session: AsyncSession, plan: TradingAnalysis, error: ExchangeError) -> None:
|
||||
"""会立即触发(-2021)或不可重试的错误:撤单平仓;可重试的错误连续失败多次后同样处理"""
|
||||
@@ -565,7 +602,7 @@ class Executor:
|
||||
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))
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=plan.symbol, analysis_id=plan.id, kind="stop_failed", level="error", message=reason))
|
||||
await self.start_closing(session, plan, "stop_failed")
|
||||
|
||||
# ---------- 主循环 ----------
|
||||
@@ -573,12 +610,13 @@ class Executor:
|
||||
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)
|
||||
symbols = list((await session.scalars(select(TradingSymbol.symbol))).all())
|
||||
credential = await session.get(TradingCredential, account.exchange) if account else None
|
||||
client = await self.ensure_client(credential)
|
||||
if client is None:
|
||||
return
|
||||
positions = {}
|
||||
for p in await client.request("GET", "/fapi/v3/positionRisk"):
|
||||
for p in await client.positions():
|
||||
amount = Decimal(p["positionAmt"])
|
||||
if amount != 0:
|
||||
positions[p["symbol"]] = Position(
|
||||
@@ -591,23 +629,30 @@ class Executor:
|
||||
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))
|
||||
fetched = await sync_fills(fills_session, client, set(symbols) | set(positions))
|
||||
if fetched:
|
||||
logger.info("同步成交记录 %d 条", fetched)
|
||||
except Exception:
|
||||
logger.warning("同步成交记录失败,%ds 后重试", FILLS_SYNC_SECONDS, exc_info=True)
|
||||
for symbol in symbols:
|
||||
for name in symbols:
|
||||
try:
|
||||
manual_action = await redis.getdel(MANUAL_ACTION_KEY.format(symbol.symbol))
|
||||
await self.reconcile(session, client, symbol, symbol.running, positions.get(symbol.symbol), manual_action)
|
||||
symbol = await session.get(TradingSymbol, name)
|
||||
completed = select(TradingEvent.data["request_id"].as_integer()).where(
|
||||
TradingEvent.exchange == client.exchange, TradingEvent.symbol == name, TradingEvent.kind == "manual_completed",
|
||||
)
|
||||
manual_request = await session.scalar(select(TradingEvent).where(
|
||||
TradingEvent.exchange == client.exchange, TradingEvent.symbol == name, TradingEvent.kind == "manual_requested",
|
||||
TradingEvent.id.not_in(completed),
|
||||
).order_by(TradingEvent.id).limit(1))
|
||||
await self.reconcile(session, client, symbol, symbol.running, manual_request)
|
||||
except ExchangeError as e:
|
||||
await session.rollback()
|
||||
await self.report(session, symbol.symbol, f"对账失败:{e}")
|
||||
await self.report(session, name, f"对账失败:{e}")
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.exception("%s 对账出错", symbol.symbol)
|
||||
logger.exception("%s 对账出错", name)
|
||||
await session.rollback()
|
||||
await self.report(session, symbol.symbol, f"对账出错:{e!r}")
|
||||
await self.report(session, name, f"对账出错:{e!r}")
|
||||
await session.commit()
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import BigInteger, cast, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -23,7 +23,7 @@ def to_ms(dt: datetime) -> int:
|
||||
|
||||
async def missing_starts(session: AsyncSession, client: DemoClient) -> dict[str, int]:
|
||||
"""用手续费流水(每笔成交一条,不限币种)发现新成交:返回每个币库里还没有的最小 trade id"""
|
||||
latest = await session.scalar(select(func.max(TradingFill.time)))
|
||||
latest = await session.scalar(select(func.max(TradingFill.time)).where(TradingFill.exchange == client.exchange))
|
||||
start = to_ms(latest - OVERLAP if latest else datetime.now(UTC) - timedelta(days=HISTORY_DAYS))
|
||||
found: dict[str, set[int]] = {}
|
||||
while True:
|
||||
@@ -37,7 +37,7 @@ async def missing_starts(session: AsyncSession, client: DemoClient) -> dict[str,
|
||||
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))))
|
||||
known = {int(v) for v in await session.scalars(select(TradingFill.trade_id).where(TradingFill.exchange == client.exchange, TradingFill.symbol == symbol, TradingFill.trade_id.in_([str(i) for i in ids])))}
|
||||
if ids - known:
|
||||
starts[symbol] = min(ids - known)
|
||||
return starts
|
||||
@@ -46,9 +46,16 @@ async def missing_starts(session: AsyncSession, client: DemoClient) -> dict[str,
|
||||
async def sync_fills(session: AsyncSession, client: DemoClient, symbols: set[str]) -> int:
|
||||
"""把新成交写入 trading_fills,返回拉到的成交数。symbols 为交易列表和当前持仓的币:
|
||||
每轮按库里最大 trade id 往后查一次,兜底手续费为 0、没有流水的成交"""
|
||||
if client.exchange == "bybit":
|
||||
latest = await session.scalar(select(func.max(TradingFill.time)).where(TradingFill.exchange == client.exchange))
|
||||
start = latest - timedelta(minutes=10) if latest else datetime.now(UTC) - timedelta(days=7)
|
||||
trades = await client.trades(None, to_ms(start))
|
||||
await save_fills(session, client.exchange, trades)
|
||||
await session.commit()
|
||||
return len(trades)
|
||||
starts = await missing_starts(session, client)
|
||||
for symbol in symbols:
|
||||
last = await session.scalar(select(func.max(TradingFill.trade_id)).where(TradingFill.symbol == symbol))
|
||||
last = await session.scalar(select(func.max(cast(TradingFill.trade_id, BigInteger))).where(TradingFill.exchange == client.exchange, TradingFill.symbol == symbol))
|
||||
if last is not None:
|
||||
starts[symbol] = min(starts.get(symbol, last + 1), last + 1)
|
||||
fetched = 0
|
||||
@@ -56,15 +63,21 @@ async def sync_fills(session: AsyncSession, client: DemoClient, symbols: set[str
|
||||
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())
|
||||
await save_fills(session, client.exchange, trades)
|
||||
fetched += len(trades)
|
||||
if len(trades) < PAGE:
|
||||
break
|
||||
from_id = trades[-1]["id"] + 1
|
||||
await session.commit()
|
||||
return fetched
|
||||
|
||||
|
||||
async def save_fills(session: AsyncSession, exchange: str, trades: list[dict]) -> None:
|
||||
if not trades:
|
||||
return
|
||||
await session.execute(insert(TradingFill).values([{
|
||||
"exchange": exchange, "symbol": t["symbol"], "trade_id": str(t["id"]), "order_id": str(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())
|
||||
@@ -62,12 +62,18 @@ def clean(text: str) -> str:
|
||||
return re.sub(r"^(财联社)?\d{1,2}月\d{1,2}日电[,,]?", "", text).strip()[:200]
|
||||
|
||||
|
||||
def is_forward_sample(published_at: datetime, labeled_at: datetime) -> bool:
|
||||
entry_at = published_at.replace(second=0, microsecond=0) + timedelta(minutes=OFFSETS["entry"])
|
||||
return labeled_at < entry_at
|
||||
|
||||
|
||||
async def label_new() -> int:
|
||||
"""给还没打标签的加密新闻打标签,返回本轮标注条数。模型漏掉的条目下一轮重试"""
|
||||
async with SessionLocal() as session:
|
||||
rows = (await session.execute(
|
||||
select(NewsItem.id, news_text()).outerjoin(NewsSignal, NewsSignal.news_id == NewsItem.id)
|
||||
.where(NewsSignal.news_id.is_(None), NewsItem.published_at >= FORWARD_START, news_text().op("~")(CRYPTO_PATTERN))
|
||||
.where(NewsSignal.news_id.is_(None), NewsItem.published_at >= FORWARD_START,
|
||||
NewsItem.published_at >= datetime.now(UTC) - timedelta(minutes=OFFSETS["entry"]), news_text().op("~")(CRYPTO_PATTERN))
|
||||
.order_by(NewsItem.published_at).limit(BATCH)
|
||||
)).all()
|
||||
if not rows:
|
||||
@@ -113,6 +119,8 @@ async def fill_prices() -> int:
|
||||
.where(NewsSignal.prices.is_(None), NewsItem.published_at <= cutoff)
|
||||
)).all()
|
||||
for signal, published_at in pending:
|
||||
if not is_forward_sample(published_at, signal.labeled_at):
|
||||
continue
|
||||
minute = int(published_at.timestamp()) // 60 * 60_000
|
||||
times = {key: minute + offset * 60_000 for key, offset in OFFSETS.items()}
|
||||
prices = {}
|
||||
|
||||
@@ -14,8 +14,6 @@ from pydantic import BaseModel, Field
|
||||
|
||||
Direction = Literal["long", "short"]
|
||||
|
||||
# 所有币合计每天(北京时间)最多自动分析的次数
|
||||
DAILY_ANALYSIS_LIMIT = 60
|
||||
RISK_PCT = Decimal("0.01")
|
||||
LOW_CONFIDENCE_FACTOR = Decimal("0.5")
|
||||
MAX_LEVERAGE = 3
|
||||
|
||||
@@ -10,7 +10,7 @@ class Fill(Protocol):
|
||||
"""一笔成交;models.TradingFill 满足此接口"""
|
||||
|
||||
symbol: str
|
||||
order_id: int
|
||||
order_id: str
|
||||
side: str
|
||||
price: Decimal
|
||||
qty: Decimal
|
||||
@@ -36,7 +36,9 @@ class ClosedPosition:
|
||||
# 只计 USDT 计价的手续费,与 Executor.realized 口径一致
|
||||
commission: Decimal = Decimal(0)
|
||||
# 参与这笔仓位的订单,用于关联程序的计划
|
||||
order_ids: set[int] = field(default_factory=set)
|
||||
order_ids: set[str] = field(default_factory=set)
|
||||
# 结算计划时核对入场成交是否全部进入已平仓仓位,不能只凭订单 ID 判断。
|
||||
entry_quantities: dict[str, Decimal] = field(default_factory=dict)
|
||||
# 带符号的持仓量:多为正、空为负
|
||||
position: Decimal = Decimal(0)
|
||||
|
||||
@@ -70,6 +72,7 @@ def build_positions(fills: list[Fill]) -> list[ClosedPosition]:
|
||||
part = remaining
|
||||
pos.quantity += abs(part)
|
||||
pos.open_value += abs(part) * fill.price
|
||||
pos.entry_quantities[fill.order_id] = pos.entry_quantities.get(fill.order_id, Decimal(0)) + abs(part)
|
||||
else:
|
||||
part = remaining if abs(remaining) <= abs(pos.position) else -pos.position
|
||||
pos.close_qty += abs(part)
|
||||
|
||||
@@ -7,8 +7,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from alphapulse.ai.tools import LOCAL_TZ
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.models import TradingAnalysis, TradingEvent, TradingSymbol
|
||||
from alphapulse.trading.plan import DAILY_ANALYSIS_LIMIT, MIN_ANALYSIS_GAP, next_routine_at
|
||||
from alphapulse.models import TradingAccount, TradingAnalysis, TradingEvent, TradingSymbol
|
||||
from alphapulse.trading.client import active_exchange
|
||||
from alphapulse.trading.plan import MIN_ANALYSIS_GAP, next_routine_at
|
||||
|
||||
# 自动分析失败后的重试:间隔、连续失败上限
|
||||
RETRY_AFTER = timedelta(minutes=5)
|
||||
@@ -16,14 +17,16 @@ MAX_CONSECUTIVE_FAILURES = 3
|
||||
|
||||
|
||||
async def daily_limit_reached(session: AsyncSession) -> bool:
|
||||
account = await session.get(TradingAccount, 1)
|
||||
limit = account.daily_analysis_limit if account else 60
|
||||
today = datetime.now(LOCAL_TZ).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
count = await session.scalar(select(func.count()).where(TradingAnalysis.started_at >= today))
|
||||
if count < DAILY_ANALYSIS_LIMIT:
|
||||
if count < limit:
|
||||
return False
|
||||
noticed = await session.scalar(select(func.count()).where(TradingEvent.kind == "daily_limit", TradingEvent.created_at >= today))
|
||||
if not noticed:
|
||||
session.add(TradingEvent(kind="daily_limit", level="warn",
|
||||
message=f"今日自动分析已达 {DAILY_ANALYSIS_LIMIT} 次上限,之后只执行程序规则(止损、止盈、到期平仓等)"))
|
||||
session.add(TradingEvent(exchange=await active_exchange(session), kind="daily_limit", level="warn",
|
||||
message=f"今日分析已达 {limit} 次上限,暂停自动分析,继续执行程序规则(止损、止盈、到期平仓等)"))
|
||||
return True
|
||||
|
||||
|
||||
@@ -33,7 +36,7 @@ async def request_analysis(session: AsyncSession, symbol: str, reason: str) -> N
|
||||
if target is None or not target.running or await daily_limit_reached(session):
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
last_started = await session.scalar(select(func.max(TradingAnalysis.started_at)).where(TradingAnalysis.symbol == symbol))
|
||||
last_started = await session.scalar(select(func.max(TradingAnalysis.started_at)).where(TradingAnalysis.exchange == await active_exchange(session), TradingAnalysis.symbol == symbol))
|
||||
at = max(now, last_started + MIN_ANALYSIS_GAP) if last_started else now
|
||||
target.analysis_requested_at = min(target.analysis_requested_at, at) if target.analysis_requested_at else at
|
||||
if reason not in target.analysis_reasons:
|
||||
@@ -49,7 +52,7 @@ async def routine_requests() -> None:
|
||||
if target.analysis_requested_at is not None:
|
||||
continue
|
||||
recent = (await session.scalars(
|
||||
select(TradingAnalysis).where(TradingAnalysis.symbol == target.symbol)
|
||||
select(TradingAnalysis).where(TradingAnalysis.exchange == await active_exchange(session), TradingAnalysis.symbol == target.symbol)
|
||||
.order_by(TradingAnalysis.id.desc()).limit(MAX_CONSECUTIVE_FAILURES)
|
||||
)).all()
|
||||
if recent and recent[0].status == "running":
|
||||
|
||||
@@ -8,11 +8,10 @@ from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from alphapulse.core.crypto import decrypt
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.core.redis import KEY_PREFIX, redis
|
||||
from alphapulse.models import TradingAccount, TradingSymbol
|
||||
from alphapulse.trading.client import DemoClient, ExchangeError
|
||||
from alphapulse.models import TradingAccount, TradingCredential, TradingSymbol
|
||||
from alphapulse.trading.client import DemoClient, ExchangeError, create_client, credential_key
|
||||
|
||||
logger = logging.getLogger("alphapulse.trading.snapshot")
|
||||
|
||||
@@ -20,21 +19,36 @@ logger = logging.getLogger("alphapulse.trading.snapshot")
|
||||
STATUS_KEY = f"{KEY_PREFIX}trader:status"
|
||||
STATUS_TTL_SECONDS = 15
|
||||
SNAPSHOT_SECONDS = 3
|
||||
SNAPSHOT_FRESH_SECONDS = 30
|
||||
# 杠杆和保证金模式很少变,按币缓存
|
||||
SYMBOL_CONFIG_SECONDS = 60
|
||||
|
||||
|
||||
class SnapshotError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def require_fresh_snapshot(snapshot: dict | None) -> dict:
|
||||
"""页面可以显示旧数据,但交易决策必须使用最近一次成功同步的账户。"""
|
||||
if snapshot is None or snapshot["error"] or not snapshot["synced_at"] or not snapshot["account"]:
|
||||
raise SnapshotError("取不到可用的模拟盘账户快照(引擎离线或同步失败)")
|
||||
synced_at = datetime.fromisoformat(snapshot["synced_at"])
|
||||
if (datetime.now(UTC) - synced_at).total_seconds() > SNAPSHOT_FRESH_SECONDS:
|
||||
raise SnapshotError("模拟盘账户快照已过期")
|
||||
return snapshot
|
||||
|
||||
|
||||
class Snapshot:
|
||||
def __init__(self) -> None:
|
||||
self.client: DemoClient | None = None
|
||||
self.key_ciphertext: bytes | None = None
|
||||
self.key_ciphertext: tuple | None = None
|
||||
# 最近一次成功同步的数据;同步失败时保留并标注错误,避免页面闪空
|
||||
self.data: dict | None = None
|
||||
self.synced_at: datetime | None = None
|
||||
self.symbol_config: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
async def ensure_client(self, account: TradingAccount | None) -> DemoClient | None:
|
||||
ciphertext = account.api_key_encrypted if account else None
|
||||
async def ensure_client(self, account: TradingCredential | None) -> DemoClient | None:
|
||||
ciphertext = credential_key(account)
|
||||
if ciphertext != self.key_ciphertext:
|
||||
# key 被修改或删除:旧连接作废,旧数据属于另一个账户,一并清掉
|
||||
if self.client is not None:
|
||||
@@ -44,26 +58,26 @@ class Snapshot:
|
||||
self.symbol_config.clear()
|
||||
self.key_ciphertext = ciphertext
|
||||
if account is not None and ciphertext is not None:
|
||||
self.client = DemoClient(decrypt(ciphertext), decrypt(account.api_secret_encrypted))
|
||||
self.client = create_client(account)
|
||||
return self.client
|
||||
|
||||
async def leverage_and_margin(self, client: DemoClient, symbol: str) -> dict:
|
||||
cached = self.symbol_config.get(symbol)
|
||||
if cached is None or time.monotonic() - cached[0] > SYMBOL_CONFIG_SECONDS:
|
||||
config = (await client.request("GET", "/fapi/v1/symbolConfig", {"symbol": symbol}))[0]
|
||||
config = await client.symbol_config(symbol)
|
||||
cached = (time.monotonic(), {"leverage": config["leverage"], "margin_type": config["marginType"]})
|
||||
self.symbol_config[symbol] = cached
|
||||
return cached[1]
|
||||
|
||||
async def fetch(self, client: DemoClient, symbols: list[str]) -> dict:
|
||||
account = await client.request("GET", "/fapi/v3/account")
|
||||
positions = [p for p in await client.request("GET", "/fapi/v3/positionRisk") if float(p["positionAmt"]) != 0]
|
||||
account = await client.account()
|
||||
positions = [p for p in await client.positions() if float(p["positionAmt"]) != 0]
|
||||
# 挂单只查交易列表里的币和有持仓的币:不带 symbol 查全部的权重是 40,3 秒一次太贵
|
||||
watched = sorted(set(symbols) | {p["symbol"] for p in positions})
|
||||
orders, algo_orders = [], []
|
||||
for symbol in watched:
|
||||
orders += await client.request("GET", "/fapi/v1/openOrders", {"symbol": symbol})
|
||||
algo_orders += await client.request("GET", "/fapi/v1/openAlgoOrders", {"symbol": symbol})
|
||||
orders += await client.open_orders(symbol)
|
||||
algo_orders += await client.open_orders(symbol, True)
|
||||
return {
|
||||
"account": {
|
||||
"wallet_balance": account["totalWalletBalance"],
|
||||
@@ -108,9 +122,10 @@ class Snapshot:
|
||||
async def publish(self) -> None:
|
||||
async with SessionLocal() as session:
|
||||
account = await session.get(TradingAccount, 1)
|
||||
credential = await session.get(TradingCredential, account.exchange) if account else None
|
||||
symbols = list((await session.scalars(select(TradingSymbol.symbol))).all())
|
||||
error = None
|
||||
client = await self.ensure_client(account)
|
||||
client = await self.ensure_client(credential)
|
||||
if client is None:
|
||||
error = "还没有填写模拟盘 key"
|
||||
else:
|
||||
@@ -121,6 +136,8 @@ class Snapshot:
|
||||
logger.warning("同步模拟盘失败:%s", e)
|
||||
error = str(e)
|
||||
status = {
|
||||
"exchange": account.exchange if account else "binance",
|
||||
"credential_updated_at": credential.updated_at.isoformat() if credential else None,
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
"synced_at": self.synced_at.isoformat() if self.synced_at else None,
|
||||
"error": error,
|
||||
@@ -143,4 +160,13 @@ async def snapshot_loop() -> None:
|
||||
|
||||
async def read_snapshot() -> dict | None:
|
||||
raw = await redis.get(STATUS_KEY)
|
||||
return json.loads(raw) if raw else None
|
||||
if not raw:
|
||||
return None
|
||||
snapshot = json.loads(raw)
|
||||
async with SessionLocal() as session:
|
||||
account = await session.get(TradingAccount, 1)
|
||||
exchange = account.exchange if account else "binance"
|
||||
credential = await session.get(TradingCredential, exchange)
|
||||
if snapshot.get("exchange") != exchange or snapshot.get("credential_updated_at") != (credential.updated_at.isoformat() if credential else None):
|
||||
return None
|
||||
return snapshot
|
||||
Reference in new issue
Block a user