alphax/app/db/live_trading.py
2026-05-23 12:22:48 +08:00

586 lines
22 KiB
Python

"""Live trading account, risk and execution audit helpers."""
from __future__ import annotations
import json
from datetime import datetime
from app.config.system_config import live_trading_config
from app.db.schema import get_conn
def _now() -> str:
return datetime.now().isoformat()
def _loads(value, fallback=None):
try:
if isinstance(value, str) and value.strip():
return json.loads(value)
if isinstance(value, (dict, list)):
return value
except Exception:
pass
return fallback if fallback is not None else {}
def _dumps(value) -> str:
return json.dumps(value if value is not None else {}, ensure_ascii=False, sort_keys=True, default=str)
def _safe_float(value, default: float = 0.0) -> float:
try:
if value is None or value == "":
return default
return float(value)
except Exception:
return default
def _safe_int(value, default: int = 0) -> int:
try:
return int(value or 0)
except Exception:
return default
def _normalize_symbol(symbol: str) -> str:
value = str(symbol or "").strip().upper()
if value and "/" not in value and value.endswith("USDT"):
value = value[:-4] + "/USDT"
return value
def _deep_merge(base: dict, override: dict) -> dict:
merged = dict(base or {})
for key, value in (override or {}).items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = _deep_merge(merged[key], value)
else:
merged[key] = value
return merged
def _row(row) -> dict:
if not row:
return {}
item = dict(row)
for key in ("permissions_json", "risk_config_json", "risk_check_json", "request_json", "response_json", "payload_json"):
if key in item:
item[key.replace("_json", "")] = _loads(item.pop(key), {})
for key in ("testnet", "reduce_only"):
if key in item:
item[key] = bool(item[key])
return item
def get_effective_live_trading_config() -> dict:
return live_trading_config()
def upsert_live_account(
account_code: str = "",
*,
exchange: str = "",
market_type: str = "",
testnet: bool | None = None,
status: str = "",
api_key_env: str = "",
api_secret_env: str = "",
permissions: dict | None = None,
risk_config: dict | None = None,
) -> dict:
cfg = get_effective_live_trading_config()
now = _now()
account_code = account_code or str(cfg.get("account_code") or "binance_um_futures")
exchange = exchange or str(cfg.get("exchange") or "binance")
market_type = market_type or str(cfg.get("market_type") or "um_futures")
if testnet is None:
testnet = bool(cfg.get("testnet", True))
status = status or ("enabled" if bool(cfg.get("enabled")) else "disabled")
api_key_env = api_key_env or str(cfg.get("api_key_env") or "ALPHAX_BINANCE_API_KEY")
api_secret_env = api_secret_env or str(cfg.get("api_secret_env") or "ALPHAX_BINANCE_API_SECRET")
permissions = permissions if isinstance(permissions, dict) else {"trade": False, "read": True}
risk_config = risk_config if isinstance(risk_config, dict) else cfg.get("risk", {})
conn = get_conn()
try:
row = conn.execute(
"""
INSERT INTO live_trade_accounts (
account_code, exchange, market_type, testnet, status,
api_key_env, api_secret_env, permissions_json, risk_config_json,
created_at, updated_at
)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT(account_code) DO UPDATE SET
exchange=excluded.exchange,
market_type=excluded.market_type,
testnet=excluded.testnet,
status=excluded.status,
api_key_env=excluded.api_key_env,
api_secret_env=excluded.api_secret_env,
permissions_json=excluded.permissions_json,
risk_config_json=excluded.risk_config_json,
updated_at=excluded.updated_at
RETURNING *
""",
(
account_code,
exchange,
market_type,
int(bool(testnet)),
status,
api_key_env,
api_secret_env,
_dumps(permissions),
_dumps(risk_config),
now,
now,
),
).fetchone()
conn.commit()
finally:
conn.close()
return _row(row)
def update_live_account(
account_id: int,
*,
account_code: str = "",
exchange: str = "",
market_type: str = "",
testnet: bool | None = None,
status: str = "",
api_key_env: str = "",
api_secret_env: str = "",
permissions: dict | None = None,
risk_config: dict | None = None,
) -> dict:
account_id = _safe_int(account_id)
if account_id <= 0:
return {"ok": False, "reason": "invalid_account_id"}
current = get_live_account(account_id)
if not current:
return {"ok": False, "reason": "account_not_found"}
now = _now()
account_code = account_code or str(current.get("account_code") or "")
exchange = exchange or str(current.get("exchange") or "binance")
market_type = market_type or str(current.get("market_type") or "um_futures")
if testnet is None:
testnet = bool(current.get("testnet", True))
status = status or str(current.get("status") or "disabled")
api_key_env = api_key_env or str(current.get("api_key_env") or "")
api_secret_env = api_secret_env or str(current.get("api_secret_env") or "")
permissions = permissions if isinstance(permissions, dict) else current.get("permissions", {})
risk_config = risk_config if isinstance(risk_config, dict) else current.get("risk_config", {})
conn = get_conn()
try:
row = conn.execute(
"""
UPDATE live_trade_accounts
SET account_code=%s,
exchange=%s,
market_type=%s,
testnet=%s,
status=%s,
api_key_env=%s,
api_secret_env=%s,
permissions_json=%s,
risk_config_json=%s,
updated_at=%s
WHERE id=%s
RETURNING *
""",
(
account_code,
exchange,
market_type,
int(bool(testnet)),
status,
api_key_env,
api_secret_env,
_dumps(permissions),
_dumps(risk_config),
now,
account_id,
),
).fetchone()
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
if not row:
return {"ok": False, "reason": "account_not_found"}
item = _row(row)
item["ok"] = True
return item
def list_live_accounts() -> dict:
conn = get_conn()
try:
rows = conn.execute("SELECT * FROM live_trade_accounts ORDER BY updated_at DESC, id DESC").fetchall()
finally:
conn.close()
return {"items": [_row(r) for r in rows], "total": len(rows)}
def get_live_account(account_id: int) -> dict:
account_id = _safe_int(account_id)
if account_id <= 0:
return {}
conn = get_conn()
try:
row = conn.execute("SELECT * FROM live_trade_accounts WHERE id=%s", (account_id,)).fetchone()
finally:
conn.close()
return _row(row)
def delete_live_account(account_id: int) -> dict:
account_id = _safe_int(account_id)
if account_id <= 0:
return {"ok": False, "reason": "invalid_account_id"}
conn = get_conn()
try:
row = conn.execute("DELETE FROM live_trade_accounts WHERE id=%s RETURNING *", (account_id,)).fetchone()
conn.commit()
finally:
conn.close()
if not row:
return {"ok": False, "reason": "account_not_found"}
return {"ok": True, "account": _row(row)}
def list_enabled_live_accounts() -> list[dict]:
conn = get_conn()
try:
rows = conn.execute(
"SELECT * FROM live_trade_accounts WHERE status='enabled' ORDER BY id"
).fetchall()
finally:
conn.close()
return [_row(r) for r in rows]
def _config_for_account(account: dict | None = None) -> dict:
cfg = get_effective_live_trading_config()
if account:
account_risk = account.get("risk_config") if isinstance(account.get("risk_config"), dict) else {}
cfg = _deep_merge(cfg, {
"exchange": account.get("exchange") or cfg.get("exchange"),
"market_type": account.get("market_type") or cfg.get("market_type"),
"testnet": account.get("testnet", cfg.get("testnet")),
"sandbox_mode": account_risk.get("sandbox_mode") or cfg.get("sandbox_mode"),
"risk": _deep_merge(cfg.get("risk") or {}, account_risk),
})
return cfg
def _risk_settings(cfg: dict) -> dict:
risk = cfg.get("risk") if isinstance(cfg.get("risk"), dict) else {}
max_symbol_leverage = _safe_float(risk.get("max_symbol_leverage"), _safe_float(cfg.get("max_symbol_leverage"), 1))
max_order_margin = _safe_float(risk.get("max_order_margin_usdt"), _safe_float(cfg.get("max_order_margin_usdt"), 0))
max_order_notional = _safe_float(risk.get("max_order_notional_usdt"), _safe_float(cfg.get("max_order_notional_usdt"), 0))
if max_order_notional <= 0 and max_order_margin > 0:
max_order_notional = max_order_margin * max(1.0, max_symbol_leverage)
return {
"max_order_margin_usdt": max_order_margin,
"max_order_notional_usdt": max_order_notional,
"max_symbol_leverage": max_symbol_leverage,
"max_cumulative_leverage": _safe_float(risk.get("max_cumulative_leverage"), _safe_float(cfg.get("max_cumulative_leverage"), 1)),
"max_daily_order_count": _safe_int(risk.get("max_daily_order_count"), _safe_int(cfg.get("max_daily_order_count"), 0)),
"allowed_symbols": [str(x).upper() for x in (risk.get("allowed_symbols") or cfg.get("allowed_symbols") or []) if str(x).strip()],
}
def _risk_check(payload: dict, cfg: dict, account: dict | None = None) -> tuple[str, str, dict]:
symbol = _normalize_symbol(payload.get("symbol"))
notional = _safe_float(payload.get("notional_usdt"))
leverage = _safe_float(payload.get("leverage"), _safe_float(cfg.get("default_leverage"), 1))
risk = _risk_settings(cfg)
allowed_symbols = risk["allowed_symbols"]
max_notional = risk["max_order_notional_usdt"]
max_margin = risk["max_order_margin_usdt"]
max_leverage = risk["max_symbol_leverage"]
margin = notional / leverage if leverage > 0 else notional
checks = {
"enabled": bool(cfg.get("enabled")),
"execution_mode": cfg.get("execution_mode", "exchange_api"),
"require_human_approval": bool(cfg.get("require_human_approval", True)),
"account_id": _safe_int((account or {}).get("id")),
"account_code": (account or {}).get("account_code", ""),
"symbol": symbol,
"notional_usdt": notional,
"margin_usdt": margin,
"max_order_margin_usdt": max_margin,
"max_order_notional_usdt": max_notional,
"leverage": leverage,
"max_symbol_leverage": max_leverage,
"max_cumulative_leverage": risk["max_cumulative_leverage"],
"allowed_symbols": allowed_symbols,
}
if not symbol:
return "blocked", "missing_symbol", checks
if not bool(cfg.get("enabled")):
return "blocked", "live_trading_disabled", checks
if account and account.get("status") != "enabled":
return "blocked", "account_disabled", checks
if allowed_symbols and symbol not in allowed_symbols:
return "blocked", "symbol_not_allowed", checks
if max_margin > 0 and margin > max_margin:
return "blocked", "margin_exceeds_limit", checks
if max_notional > 0 and notional > max_notional:
return "blocked", "notional_exceeds_limit", checks
if max_leverage > 0 and leverage > max_leverage:
return "blocked", "leverage_exceeds_limit", checks
if bool(cfg.get("require_human_approval", True)):
return "pending_approval", "waiting_human_approval", checks
mode = str(cfg.get("execution_mode") or "exchange_api").strip().lower()
if mode in ("exchange_api", "demo"):
return "prepared", "exchange_ready_for_executor", checks
return "prepared", "ready_for_executor", checks
def create_live_order_intent(payload: dict, *, source_type: str = "manual", source_id: int = 0) -> dict:
account = get_live_account(_safe_int(payload.get("account_id")))
cfg = _config_for_account(account)
now = _now()
symbol = _normalize_symbol(payload.get("symbol"))
side = str(payload.get("side") or "long").strip().lower()
if side not in ("long", "short"):
side = "long"
status, reason, risk = _risk_check({**payload, "symbol": symbol, "side": side}, cfg, account)
conn = get_conn()
try:
row = conn.execute(
"""
INSERT INTO live_order_intents (
source_type, source_id, recommendation_id, paper_trade_id, paper_order_id,
account_id, exchange, market_type, symbol, side, position_side, order_type,
status, reason, quantity, price, stop_loss, take_profit, notional_usdt,
leverage, reduce_only, client_order_id, risk_check_json, request_json,
created_at, updated_at
)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
source_type,
_safe_int(source_id),
_safe_int(payload.get("recommendation_id")),
_safe_int(payload.get("paper_trade_id")),
_safe_int(payload.get("paper_order_id")),
_safe_int(payload.get("account_id")),
str(account.get("exchange") or cfg.get("exchange") or payload.get("exchange") or "binance"),
str(account.get("market_type") or cfg.get("market_type") or payload.get("market_type") or "um_futures"),
symbol,
side,
side,
str(payload.get("order_type") or "market").lower(),
status,
reason,
_safe_float(payload.get("quantity")),
_safe_float(payload.get("price")),
_safe_float(payload.get("stop_loss")),
_safe_float(payload.get("take_profit")),
_safe_float(payload.get("notional_usdt")),
_safe_float(payload.get("leverage"), _safe_float(cfg.get("default_leverage"), 1)),
int(bool(payload.get("reduce_only"))),
str(payload.get("client_order_id") or ""),
_dumps(risk),
_dumps(payload),
now,
now,
),
).fetchone()
conn.execute(
"""
INSERT INTO live_order_events (intent_id, event_type, status, message, payload_json, event_time)
VALUES (%s,%s,%s,%s,%s,%s)
""",
(row["id"], "intent_created", status, reason, _dumps(risk), now),
)
conn.commit()
finally:
conn.close()
return _row(row)
def create_live_order_intents_for_accounts(payload: dict, account_ids: list[int] | None = None, *, source_type: str = "manual", source_id: int = 0) -> dict:
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:
items.append(create_live_order_intent({**payload, "account_id": account["id"]}, source_type=source_type, source_id=source_id))
return {"ok": True, "items": items, "total": len(items)}
def list_live_order_intents(limit: int = 50, offset: int = 0, status: str = "", account_id: int = 0) -> dict:
limit = max(1, min(_safe_int(limit, 50), 200))
offset = max(0, _safe_int(offset))
params: list = []
clauses: list[str] = []
if status:
clauses.append("status=%s")
params.append(status)
if _safe_int(account_id) > 0:
clauses.append("account_id=%s")
params.append(_safe_int(account_id))
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
conn = get_conn()
try:
total = conn.execute(f"SELECT COUNT(*) FROM live_order_intents {where}", tuple(params)).fetchone()[0]
rows = conn.execute(
f"SELECT * FROM live_order_intents {where} ORDER BY updated_at DESC, id DESC LIMIT %s OFFSET %s",
tuple(params + [limit, offset]),
).fetchall()
finally:
conn.close()
return {"items": [_row(r) for r in rows], "total": total, "limit": limit, "offset": offset}
def get_live_order_intent(intent_id: int) -> dict:
intent_id = _safe_int(intent_id)
if intent_id <= 0:
return {}
conn = get_conn()
try:
row = conn.execute("SELECT * FROM live_order_intents WHERE id=%s", (intent_id,)).fetchone()
finally:
conn.close()
return _row(row)
def update_live_order_intent(intent_id: int, **fields) -> dict:
intent_id = _safe_int(intent_id)
allowed = {
"status", "reason", "quantity", "price", "exchange_order_id",
"response_json", "submitted_at", "finished_at", "updated_at",
}
updates = []
params = []
for key, value in fields.items():
if key not in allowed:
continue
column_value = _dumps(value) if key == "response_json" else value
updates.append(f"{key}=%s")
params.append(column_value)
if not updates or intent_id <= 0:
return get_live_order_intent(intent_id)
params.append(intent_id)
conn = get_conn()
try:
row = conn.execute(
f"UPDATE live_order_intents SET {', '.join(updates)} WHERE id=%s RETURNING *",
tuple(params),
).fetchone()
conn.commit()
finally:
conn.close()
return _row(row)
def record_live_order_event(intent_id: int, event_type: str, status: str, message: str = "", payload=None) -> dict:
conn = get_conn()
try:
row = conn.execute(
"""
INSERT INTO live_order_events (intent_id, event_type, status, message, payload_json, event_time)
VALUES (%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(_safe_int(intent_id), event_type, status, message, _dumps(payload or {}), _now()),
).fetchone()
conn.commit()
finally:
conn.close()
return _row(row)
def list_live_order_events(limit: int = 80, offset: int = 0, intent_id: int = 0) -> dict:
limit = max(1, min(_safe_int(limit, 80), 200))
offset = max(0, _safe_int(offset))
params: list = []
where = ""
if _safe_int(intent_id) > 0:
where = "WHERE intent_id=%s"
params.append(_safe_int(intent_id))
conn = get_conn()
try:
total = conn.execute(f"SELECT COUNT(*) FROM live_order_events {where}", tuple(params)).fetchone()[0]
rows = conn.execute(
f"SELECT * FROM live_order_events {where} ORDER BY event_time DESC, id DESC LIMIT %s OFFSET %s",
tuple(params + [limit, offset]),
).fetchall()
finally:
conn.close()
return {"items": [_row(r) for r in rows], "total": total, "limit": limit, "offset": offset}
def prepare_intent_from_paper_trade(paper_trade_id: int, account_ids: list[int] | None = None) -> dict:
conn = get_conn()
try:
trade = conn.execute("SELECT * FROM paper_trades WHERE id=%s", (_safe_int(paper_trade_id),)).fetchone()
finally:
conn.close()
if not trade:
return {"ok": False, "reason": "paper_trade_not_found"}
payload = {
"symbol": trade["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": _safe_float(trade.get("notional_usdt")),
"leverage": _safe_float(trade.get("leverage"), 1),
"recommendation_id": _safe_int(trade.get("recommendation_id")),
"paper_trade_id": _safe_int(trade.get("id")),
}
result = create_live_order_intents_for_accounts(payload, account_ids=account_ids, source_type="paper_trade", source_id=_safe_int(trade.get("id")))
return result if result.get("ok") else {"ok": False, "reason": result.get("reason", "intent_create_failed"), "items": result.get("items", [])}
def get_live_trading_summary() -> dict:
cfg = get_effective_live_trading_config()
risk = _risk_settings(cfg)
conn = get_conn()
try:
status_rows = conn.execute(
"SELECT status, COUNT(*) AS count FROM live_order_intents GROUP BY status ORDER BY status"
).fetchall()
latest_rows = conn.execute(
"SELECT * FROM live_order_intents ORDER BY updated_at DESC, id DESC LIMIT 8"
).fetchall()
account_count = conn.execute("SELECT COUNT(*) FROM live_trade_accounts").fetchone()[0]
finally:
conn.close()
return {
"enabled": bool(cfg.get("enabled")),
"execution_mode": cfg.get("execution_mode", "exchange_api"),
"exchange": cfg.get("exchange", "binance"),
"market_type": cfg.get("market_type", "um_futures"),
"testnet": bool(cfg.get("testnet", True)),
"require_human_approval": bool(cfg.get("require_human_approval", True)),
"max_order_margin_usdt": risk["max_order_margin_usdt"],
"max_order_notional_usdt": risk["max_order_notional_usdt"],
"max_symbol_leverage": risk["max_symbol_leverage"],
"max_cumulative_leverage": risk["max_cumulative_leverage"],
"max_daily_order_count": risk["max_daily_order_count"],
"account_count": account_count,
"intent_status": {r["status"]: r["count"] for r in status_rows},
"latest_intents": [_row(r) for r in latest_rows],
}