alphax/app/services/live_trading_sync.py
2026-06-07 23:43:54 +08:00

316 lines
13 KiB
Python

"""Synchronize strategy-trading ledger entries to configured live accounts."""
from __future__ import annotations
from datetime import datetime
from app.db.live_trading import (
_safe_float,
_safe_int,
create_live_order_intent,
get_live_account,
get_live_order_intent,
list_enabled_live_accounts,
record_live_order_event,
update_live_order_intent,
_row,
)
from app.config.system_config import live_trading_config
from app.db.schema import get_conn, init_db
from app.integrations.binance_live import LiveTradingConfigError, build_binance_client
from app.services.live_trading_account import sync_live_account_snapshots
def _now() -> str:
return datetime.now().isoformat()
def _side_to_exchange(side: str) -> tuple[str, str]:
side = str(side or "long").lower()
if side == "short":
return "sell", "buy"
return "buy", "sell"
def _paper_trade(paper_trade_id: int) -> dict:
conn = get_conn()
try:
row = conn.execute("SELECT * FROM paper_trades WHERE id=%s", (_safe_int(paper_trade_id),)).fetchone()
finally:
conn.close()
return _row(row) if row else {}
def _existing_intent_for_paper_trade(paper_trade_id: int, account_id: int) -> dict:
conn = get_conn()
try:
row = conn.execute(
"""
SELECT *
FROM live_order_intents
WHERE paper_trade_id=%s
AND account_id=%s
AND source_type='paper_trade_sync'
AND status NOT IN ('blocked','error')
ORDER BY id DESC
LIMIT 1
""",
(_safe_int(paper_trade_id), _safe_int(account_id)),
).fetchone()
finally:
conn.close()
return dict(row) if row else {}
def _open_unsynced_paper_trades(limit: int = 20) -> list[dict]:
conn = get_conn()
try:
rows = conn.execute(
"""
SELECT pt.*
FROM paper_trades pt
WHERE pt.status='open'
ORDER BY pt.opened_at DESC, pt.id DESC
LIMIT %s
""",
(max(1, min(_safe_int(limit, 20), 100)),),
).fetchall()
finally:
conn.close()
return [dict(r) for r in rows]
def _risk_for_account(account: dict) -> dict:
return account.get("risk_config") if isinstance(account.get("risk_config"), dict) else {}
def _live_sizing(paper_trade: dict, account: dict) -> dict:
risk = _risk_for_account(account)
paper_leverage = max(1.0, _safe_float(paper_trade.get("leverage"), 1))
max_leverage = max(1.0, _safe_float(risk.get("max_symbol_leverage"), paper_leverage))
leverage = min(paper_leverage, max_leverage)
max_margin = _safe_float(risk.get("max_order_margin_usdt"), 0)
paper_notional = _safe_float(paper_trade.get("notional_usdt"))
if max_margin > 0:
notional = min(paper_notional, max_margin * leverage) if paper_notional > 0 else max_margin * leverage
else:
notional = paper_notional
return {
"notional_usdt": round(max(0.0, notional), 8),
"leverage": leverage,
"paper_notional_usdt": paper_notional,
"sizing_mode": "account_risk_cap" if max_margin > 0 else "paper_notional",
}
def _position_notional(position: dict) -> float:
info = position.get("info") if isinstance(position.get("info"), dict) else {}
return abs(_safe_float(position.get("notional") or info.get("notional")))
def _check_live_cumulative_leverage(client, account: dict, additional_notional: float) -> tuple[bool, dict]:
risk = _risk_for_account(account)
cap = _safe_float(risk.get("max_cumulative_leverage"), 0)
if cap <= 0:
return True, {"disabled": True, "max_cumulative_leverage": cap}
balance = client.fetch_balance()
total = balance.get("total") if isinstance(balance.get("total"), dict) else {}
equity = _safe_float(total.get("USDT"))
positions = client.fetch_positions(None) if hasattr(client, "fetch_positions") else []
open_notional = sum(_position_notional(p) for p in positions or [])
projected = open_notional + max(0.0, _safe_float(additional_notional))
projected_leverage = projected / equity if equity > 0 else 0
detail = {
"account_equity_usdt": round(equity, 8),
"open_notional_usdt": round(open_notional, 8),
"additional_notional_usdt": round(additional_notional, 8),
"projected_notional_usdt": round(projected, 8),
"projected_cumulative_leverage": round(projected_leverage, 6),
"max_cumulative_leverage": cap,
}
return projected_leverage <= cap + 1e-12, detail
def execute_live_order_intent(intent_id: int, *, client=None) -> dict:
intent = get_live_order_intent(intent_id)
if not intent:
raise LiveTradingConfigError("live order intent not found")
if intent.get("status") not in ("prepared", "pending_approval"):
return {"ok": False, "reason": f"intent_status_{intent.get('status')}", "intent": intent}
account = get_live_account(intent.get("account_id"))
if not account:
raise LiveTradingConfigError("live account not found")
if account.get("status") != "enabled":
raise LiveTradingConfigError("live account disabled")
symbol = str(intent.get("symbol") or "").upper()
notional = _safe_float(intent.get("notional_usdt"))
leverage = max(1.0, _safe_float(intent.get("leverage"), 1))
if notional <= 0:
raise LiveTradingConfigError("live order notional is zero")
client = client or build_binance_client(account, require_testnet=True)
client.load_markets()
min_notional = client.min_notional(symbol) if hasattr(client, "min_notional") else 0.0
if min_notional > 0 and notional < min_notional:
raise LiveTradingConfigError(f"{symbol} minimum notional is {min_notional:g} USDT; live sync notional is {notional:g} USDT")
ok, leverage_detail = _check_live_cumulative_leverage(client, account, notional)
if not ok:
update_live_order_intent(intent_id, status="blocked", reason="live_cumulative_leverage_exceeded", updated_at=_now())
record_live_order_event(intent_id, "live_sync_blocked", "blocked", "live_cumulative_leverage_exceeded", leverage_detail)
return {"ok": False, "reason": "live_cumulative_leverage_exceeded", "risk": leverage_detail}
open_side, close_side = _side_to_exchange(intent.get("side"))
ticker = client.fetch_ticker(symbol)
last = _safe_float(ticker.get("last") or ticker.get("close"))
amount = client.amount_to_precision(symbol, notional / last) if last > 0 else 0
if amount <= 0:
raise LiveTradingConfigError("calculated live order amount is zero")
submitted_at = _now()
update_live_order_intent(intent_id, status="submitting", quantity=amount, submitted_at=submitted_at, updated_at=submitted_at)
record_live_order_event(intent_id, "live_sync_submit", "submitting", "submitting_live_market_order", {"amount": amount, "notional_usdt": notional})
market_order = None
stop_order = None
take_profit_order = None
try:
client.set_leverage(symbol, leverage)
market_order = client.create_market_order(symbol, open_side, amount, {"newClientOrderId": f"alphax_live_{intent_id}_{int(datetime.now().timestamp())}"})
stop_loss = _safe_float(intent.get("stop_loss"))
take_profit = _safe_float(intent.get("take_profit"))
if stop_loss > 0:
stop_order = client.create_stop_loss_order(symbol, close_side, amount, stop_loss)
if take_profit > 0:
take_profit_order = client.create_take_profit_order(symbol, close_side, amount, take_profit)
finished_at = _now()
response = {"market_order": market_order, "stop_loss_order": stop_order, "take_profit_order": take_profit_order, "risk": leverage_detail}
updated = update_live_order_intent(
intent_id,
status="submitted",
reason="live_order_submitted",
exchange_order_id=str((market_order or {}).get("id") or (market_order or {}).get("orderId") or ""),
response_json=response,
finished_at=finished_at,
updated_at=finished_at,
)
record_live_order_event(intent_id, "live_sync_submitted", "submitted", "live_order_submitted", response)
return {"ok": True, "intent": updated, "market_order": market_order, "stop_loss_order": stop_order, "take_profit_order": take_profit_order}
except Exception as exc:
failed_at = _now()
update_live_order_intent(intent_id, status="error", reason=str(exc), response_json={"market_order": market_order}, finished_at=failed_at, updated_at=failed_at)
record_live_order_event(intent_id, "live_sync_error", "error", str(exc), {"market_order": market_order})
raise
def sync_paper_trade_to_live(
paper_trade_id: int,
*,
account_ids: list[int] | None = None,
execute: bool = True,
client_factory=None,
) -> dict:
init_db()
cfg = live_trading_config()
if not bool(cfg.get("enabled")):
return {"ok": False, "reason": "live_trading_disabled", "items": []}
if str(cfg.get("execution_mode") or "exchange_api").strip().lower() != "exchange_api":
return {"ok": False, "reason": "live_trading_not_exchange_api", "items": []}
trade = _paper_trade(paper_trade_id)
if not trade:
return {"ok": False, "reason": "paper_trade_not_found", "items": []}
if trade.get("status") != "open":
return {"ok": False, "reason": f"paper_trade_{trade.get('status')}", "items": []}
accounts = list_enabled_live_accounts()
selected = {_safe_int(x) for x in (account_ids or []) if _safe_int(x) > 0}
if selected:
accounts = [a for a in accounts if _safe_int(a.get("id")) in selected]
if not accounts:
return {"ok": False, "reason": "no_enabled_accounts", "items": []}
items = []
for account in accounts:
existing = _existing_intent_for_paper_trade(trade.get("id"), account.get("id"))
if existing:
items.append({
"account_id": account["id"],
"intent": get_live_order_intent(existing["id"]),
"sizing": {},
"executed": existing.get("status") == "submitted",
"skipped": True,
"reason": "already_synced",
})
continue
sizing = _live_sizing(trade, account)
payload = {
"account_id": account["id"],
"symbol": trade.get("symbol"),
"side": trade.get("side") or "long",
"order_type": "market",
"price": _safe_float(trade.get("entry_price")),
"stop_loss": _safe_float(trade.get("stop_loss")),
"take_profit": _safe_float(trade.get("tp1")),
"notional_usdt": sizing["notional_usdt"],
"leverage": sizing["leverage"],
"recommendation_id": _safe_int(trade.get("recommendation_id")),
"paper_trade_id": _safe_int(trade.get("id")),
}
intent = create_live_order_intent(payload, source_type="paper_trade_sync", source_id=_safe_int(trade.get("id")))
item = {"account_id": account["id"], "intent": intent, "sizing": sizing, "executed": False}
if execute and intent.get("status") == "prepared":
factory_client = client_factory(account) if client_factory else None
try:
item["execution"] = execute_live_order_intent(intent["id"], client=factory_client)
item["executed"] = bool(item["execution"].get("ok"))
except Exception as exc:
item["execution"] = {"ok": False, "reason": str(exc)}
items.append(item)
return {"ok": True, "paper_trade_id": _safe_int(paper_trade_id), "execute": execute, "items": items, "total": len(items)}
def sync_open_paper_trades_to_live(*, limit: int = 20, execute: bool = True, client_factory=None) -> dict:
trades = _open_unsynced_paper_trades(limit=limit)
results = []
for trade in trades:
results.append(sync_paper_trade_to_live(
trade["id"],
execute=execute,
client_factory=client_factory,
))
return {
"ok": True,
"processed_count": len(results),
"execute": execute,
"results": results,
}
def run_live_trading_sync(
*,
limit: int = 20,
execute: bool = True,
sync_snapshots: bool = True,
sync_paper: bool = True,
client_factory=None,
) -> dict:
"""Single scheduler entrypoint for live account snapshots and paper sync."""
init_db()
cfg = live_trading_config()
snapshot_result = {"skipped": True, "reason": "snapshot_sync_disabled"}
paper_result = {"skipped": True, "reason": "paper_sync_disabled"}
if sync_snapshots:
snapshot_result = sync_live_account_snapshots(client_factory=client_factory)
if sync_paper:
if not bool(cfg.get("enabled")):
paper_result = {"ok": False, "skipped": True, "reason": "live_trading_disabled", "results": []}
else:
paper_result = sync_open_paper_trades_to_live(
limit=limit,
execute=execute,
client_factory=client_factory,
)
return {
"ok": bool(snapshot_result.get("ok", True)) and bool(paper_result.get("ok", True)),
"enabled": bool(cfg.get("enabled")),
"execute": execute,
"snapshots": snapshot_result,
"paper_sync": paper_result,
}