743 lines
27 KiB
Python
743 lines
27 KiB
Python
from fastapi.testclient import TestClient
|
|
|
|
from app.db import auth_db
|
|
from app.db.live_trading import (
|
|
create_live_order_intents_for_accounts,
|
|
delete_live_account,
|
|
get_live_account,
|
|
get_live_account_snapshot,
|
|
list_live_accounts,
|
|
list_live_order_events,
|
|
list_live_order_intents,
|
|
upsert_live_account,
|
|
)
|
|
from app.db.runtime_config_db import set_config
|
|
from app.integrations.binance_live import build_binance_client
|
|
from app.services.live_trading_account import get_live_account_overview
|
|
from app.services import live_trading_account
|
|
from app.services.live_trading_smoke import run_binance_testnet_smoke
|
|
from app.services.live_trading_sync import run_live_trading_sync, sync_live_protection_from_paper, sync_paper_trade_to_live
|
|
from app.web import web_server
|
|
from app.db.schema import get_conn
|
|
|
|
|
|
def _login_user(email: str, password: str = "StrongPass123", admin: bool = False) -> str:
|
|
reg = auth_db.register_user(email, password)
|
|
auth_db.verify_email(email, reg["verification_code"])
|
|
user = auth_db.get_user_by_email(email)
|
|
auth_db.claim_free_trial(user["id"])
|
|
if admin:
|
|
auth_db.set_user_admin(email, True)
|
|
return auth_db.login_user(email, password)["token"]
|
|
|
|
|
|
def test_live_trading_page_and_api_require_admin():
|
|
token = _login_user("normal-live@example.com")
|
|
client = TestClient(web_server.app)
|
|
client.cookies.set("altcoin_session", token)
|
|
|
|
page = client.get("/live-trading")
|
|
summary = client.get("/api/live-trading/summary")
|
|
|
|
assert page.status_code == 403
|
|
assert summary.status_code == 403
|
|
|
|
|
|
def test_live_trading_admin_can_access_page_and_seed_account():
|
|
token = _login_user("admin-live@example.com", admin=True)
|
|
client = TestClient(web_server.app)
|
|
client.cookies.set("altcoin_session", token)
|
|
|
|
page = client.get("/live-trading")
|
|
created = client.post("/api/live-trading/accounts", json={
|
|
"account_code": "binance_sub_1",
|
|
"exchange": "binance",
|
|
"market_type": "um_futures",
|
|
"status": "enabled",
|
|
"api_key_env": "ALPHAX_BINANCE_SUB1_API_KEY",
|
|
"api_secret_env": "ALPHAX_BINANCE_SUB1_API_SECRET",
|
|
})
|
|
accounts = client.get("/api/live-trading/accounts")
|
|
|
|
assert page.status_code == 200
|
|
assert "实盘控制台" in page.text
|
|
assert "账户表现" in page.text
|
|
assert "当前净值" in page.text
|
|
assert "历史仓位" in page.text
|
|
assert "留空=全部" in page.text
|
|
assert created.status_code == 200
|
|
assert created.json()["account_code"] == "binance_sub_1"
|
|
assert accounts.json()["total"] == 1
|
|
|
|
|
|
def test_live_account_edit_updates_existing_account_instead_of_creating_new_one():
|
|
token = _login_user("admin-live-edit@example.com", admin=True)
|
|
client = TestClient(web_server.app)
|
|
client.cookies.set("altcoin_session", token)
|
|
|
|
created = client.post("/api/live-trading/accounts", json={
|
|
"account_code": "binance_edit_before",
|
|
"exchange": "binance",
|
|
"market_type": "um_futures",
|
|
"status": "disabled",
|
|
"api_key_env": "ALPHAX_BINANCE_OLD_KEY",
|
|
"api_secret_env": "ALPHAX_BINANCE_OLD_SECRET",
|
|
})
|
|
account_id = created.json()["id"]
|
|
|
|
updated = client.put(f"/api/live-trading/accounts/{account_id}", json={
|
|
"account_code": "binance_edit_after",
|
|
"exchange": "binance",
|
|
"market_type": "um_futures",
|
|
"status": "enabled",
|
|
"api_key_env": "ALPHAX_BINANCE_NEW_KEY",
|
|
"api_secret_env": "ALPHAX_BINANCE_NEW_SECRET",
|
|
"risk_config": {"max_order_margin_usdt": 25, "allowed_symbols": ["BTC/USDT"]},
|
|
})
|
|
accounts = client.get("/api/live-trading/accounts").json()
|
|
|
|
assert updated.status_code == 200
|
|
assert updated.json()["id"] == account_id
|
|
assert updated.json()["account_code"] == "binance_edit_after"
|
|
assert updated.json()["status"] == "enabled"
|
|
assert updated.json()["risk_config"]["max_order_margin_usdt"] == 25
|
|
assert accounts["total"] == 1
|
|
|
|
|
|
def test_live_account_overview_returns_disabled_account_without_exchange_call():
|
|
account = upsert_live_account(
|
|
account_code="binance_overview_disabled",
|
|
status="disabled",
|
|
risk_config={"max_order_margin_usdt": 10, "max_symbol_leverage": 2, "allowed_symbols": []},
|
|
)
|
|
|
|
overview = get_live_account_overview(account["id"])
|
|
|
|
assert overview["account"]["account_code"] == "binance_overview_disabled"
|
|
assert overview["risk"]["symbol_policy"] == "all"
|
|
assert overview["balance"]["usdt"]["total"] == 0
|
|
assert overview["positions"] == []
|
|
|
|
|
|
def test_live_account_overview_does_not_hit_exchange_without_refresh(monkeypatch):
|
|
account = upsert_live_account(
|
|
account_code="binance_overview_fast",
|
|
status="enabled",
|
|
risk_config={"max_order_margin_usdt": 10, "max_symbol_leverage": 2, "allowed_symbols": []},
|
|
)
|
|
|
|
def fail_build(*args, **kwargs):
|
|
raise AssertionError("exchange should not be called without refresh")
|
|
|
|
monkeypatch.setattr(live_trading_account, "build_binance_client", fail_build)
|
|
overview = get_live_account_overview(account["id"], refresh=False)
|
|
|
|
assert overview["exchange_cache"]["requires_refresh"] is True
|
|
assert overview["balance"]["usdt"]["total"] == 0
|
|
assert overview["positions"] == []
|
|
|
|
|
|
def test_live_account_overview_refresh_compacts_position_value_side_and_leverage():
|
|
account = upsert_live_account(
|
|
account_code="binance_overview_refresh",
|
|
status="enabled",
|
|
risk_config={"max_order_margin_usdt": 10, "max_symbol_leverage": 3, "allowed_symbols": []},
|
|
)
|
|
|
|
class Client:
|
|
def load_markets(self):
|
|
return {}
|
|
|
|
def fetch_balance(self):
|
|
return {"total": {"USDT": 1000}, "free": {"USDT": 900}, "used": {"USDT": 100}}
|
|
|
|
def fetch_positions(self, symbols=None):
|
|
return [{
|
|
"symbol": "BTC/USDT",
|
|
"contracts": 0.02,
|
|
"entryPrice": 75000,
|
|
"markPrice": 76000,
|
|
"unrealizedPnl": 20,
|
|
"info": {"positionAmt": "0.02"},
|
|
}]
|
|
|
|
def fetch_open_orders(self, symbol=None):
|
|
return []
|
|
|
|
def fetch_orders(self, symbol=None, limit=30):
|
|
assert symbol == "BTC/USDT"
|
|
return []
|
|
|
|
overview = get_live_account_overview(account["id"], refresh=True, client_factory=lambda account: Client())
|
|
pos = overview["positions"][0]
|
|
|
|
assert overview["exchange_cache"]["loaded"] is True
|
|
assert pos["side_label"] == "多"
|
|
assert pos["position_value_usdt"] == 1520
|
|
assert pos["leverage"] == 3
|
|
assert pos["leverage_source"] == "account_config"
|
|
assert round(pos["pnl_pct"], 2) == 1.32
|
|
assert overview["performance"]["equity_usdt"] == 1000
|
|
assert overview["performance"]["unrealized_pnl_usdt"] == 20
|
|
|
|
|
|
def test_live_account_overview_refresh_persists_database_snapshot_and_reads_without_exchange(monkeypatch):
|
|
account = upsert_live_account(
|
|
account_code="binance_snapshot_cache",
|
|
status="enabled",
|
|
risk_config={"max_order_margin_usdt": 10, "max_symbol_leverage": 2, "allowed_symbols": ["BTC/USDT"]},
|
|
)
|
|
|
|
class Client:
|
|
def load_markets(self):
|
|
return {}
|
|
|
|
def fetch_balance(self):
|
|
return {"total": {"USDT": 1234}, "free": {"USDT": 1200}, "used": {"USDT": 34}}
|
|
|
|
def fetch_positions(self, symbols=None):
|
|
return []
|
|
|
|
def fetch_open_orders(self, symbol=None):
|
|
return []
|
|
|
|
def fetch_orders(self, symbol=None, limit=30):
|
|
return []
|
|
|
|
refreshed = get_live_account_overview(account["id"], refresh=True, client_factory=lambda account: Client())
|
|
snapshot = get_live_account_snapshot(account["id"])
|
|
|
|
def fail_build(*args, **kwargs):
|
|
raise AssertionError("database snapshot read must not call exchange")
|
|
|
|
monkeypatch.setattr(live_trading_account, "build_binance_client", fail_build)
|
|
cached = get_live_account_overview(account["id"], refresh=False)
|
|
|
|
assert refreshed["balance"]["usdt"]["total"] == 1234
|
|
assert snapshot["status"] == "ok"
|
|
assert snapshot["snapshot"]["balance"]["usdt"]["total"] == 1234
|
|
assert cached["balance"]["usdt"]["total"] == 1234
|
|
assert cached["exchange_cache"]["source"] == "database"
|
|
assert cached["exchange_cache"]["cached"] is True
|
|
|
|
|
|
def test_live_account_overview_fetches_order_history_per_symbol():
|
|
account = upsert_live_account(
|
|
account_code="binance_overview_orders_by_symbol",
|
|
status="enabled",
|
|
risk_config={"max_order_margin_usdt": 10, "max_symbol_leverage": 2, "allowed_symbols": ["BTC/USDT", "ETHUSDT"]},
|
|
)
|
|
|
|
class Client:
|
|
def __init__(self):
|
|
self.order_symbols = []
|
|
|
|
def load_markets(self):
|
|
return {}
|
|
|
|
def fetch_balance(self):
|
|
return {"total": {"USDT": 1000}, "free": {"USDT": 1000}, "used": {"USDT": 0}}
|
|
|
|
def fetch_positions(self, symbols=None):
|
|
return []
|
|
|
|
def fetch_open_orders(self, symbol=None):
|
|
return []
|
|
|
|
def fetch_orders(self, symbol=None, limit=30):
|
|
if symbol is None:
|
|
raise AssertionError("fetch_orders must be called with a symbol for binanceusdm")
|
|
self.order_symbols.append(symbol)
|
|
return [{
|
|
"id": f"{symbol}-1",
|
|
"symbol": symbol,
|
|
"type": "limit",
|
|
"side": "buy",
|
|
"status": "closed",
|
|
"price": 1,
|
|
"amount": 2,
|
|
"filled": 2,
|
|
"datetime": "2026-05-30T08:00:00",
|
|
"info": {"realizedPnl": "3.5"},
|
|
}]
|
|
|
|
client = Client()
|
|
overview = get_live_account_overview(account["id"], refresh=True, client_factory=lambda account: client)
|
|
|
|
assert client.order_symbols == ["BTC/USDT", "ETH/USDT"]
|
|
assert len(overview["order_history"]) == 2
|
|
assert overview["historical_positions"][0]["result"] == "盈利"
|
|
assert overview["errors"] == []
|
|
|
|
|
|
def test_live_account_can_be_deleted_without_deleting_history_contract():
|
|
account = upsert_live_account(account_code="binance_delete_me", status="disabled")
|
|
|
|
deleted = delete_live_account(account["id"])
|
|
missing = get_live_account(account["id"])
|
|
|
|
assert deleted["ok"] is True
|
|
assert deleted["account"]["account_code"] == "binance_delete_me"
|
|
assert missing == {}
|
|
|
|
|
|
def test_live_order_intent_fans_out_to_multiple_enabled_accounts():
|
|
set_config("system", "live_trading", {
|
|
"enabled": True,
|
|
"execution_mode": "exchange_api",
|
|
"require_human_approval": True,
|
|
"exchange": "binance",
|
|
"market_type": "um_futures",
|
|
"default_leverage": 1,
|
|
"risk": {
|
|
"max_order_margin_usdt": 50,
|
|
"max_order_notional_usdt": 100,
|
|
"max_symbol_leverage": 2,
|
|
"max_cumulative_leverage": 2,
|
|
"allowed_symbols": ["BTC/USDT"],
|
|
},
|
|
}, source="test")
|
|
a1 = upsert_live_account(account_code="binance_main", status="enabled", risk_config={"max_order_notional_usdt": 100})
|
|
a2 = upsert_live_account(account_code="binance_sub", status="enabled", risk_config={"max_order_notional_usdt": 20})
|
|
|
|
result = create_live_order_intents_for_accounts({
|
|
"symbol": "BTC/USDT",
|
|
"side": "long",
|
|
"notional_usdt": 50,
|
|
"leverage": 1,
|
|
})
|
|
|
|
intents = list_live_order_intents()["items"]
|
|
assert result["ok"] is True
|
|
assert result["total"] == 2
|
|
assert len(intents) == 2
|
|
by_account = {item["account_id"]: item for item in intents}
|
|
assert by_account[a1["id"]]["status"] == "pending_approval"
|
|
assert by_account[a2["id"]]["status"] == "blocked"
|
|
assert by_account[a2["id"]]["reason"] == "notional_exceeds_limit"
|
|
|
|
|
|
def test_live_trading_default_config_is_safe_disabled():
|
|
upsert_live_account(account_code="binance_safe", status="enabled")
|
|
|
|
result = create_live_order_intents_for_accounts({
|
|
"symbol": "BTC/USDT",
|
|
"side": "long",
|
|
"notional_usdt": 10,
|
|
"leverage": 1,
|
|
})
|
|
|
|
assert list_live_accounts()["total"] == 1
|
|
assert result["ok"] is True
|
|
intent = result["items"][0]
|
|
assert intent["status"] == "blocked"
|
|
assert intent["reason"] == "live_trading_disabled"
|
|
|
|
|
|
def test_exchange_api_execution_mode_marks_intent_ready_for_executor():
|
|
set_config("system", "live_trading", {
|
|
"enabled": True,
|
|
"execution_mode": "exchange_api",
|
|
"require_human_approval": False,
|
|
"exchange": "binance",
|
|
"market_type": "um_futures",
|
|
"testnet": True,
|
|
"risk": {
|
|
"max_order_margin_usdt": 100,
|
|
"max_order_notional_usdt": 100,
|
|
"max_symbol_leverage": 2,
|
|
"max_cumulative_leverage": 2,
|
|
"allowed_symbols": ["BTC/USDT"],
|
|
},
|
|
}, source="test")
|
|
account = upsert_live_account(account_code="binance_exchange_ready", status="enabled")
|
|
|
|
result = create_live_order_intents_for_accounts({
|
|
"symbol": "BTC/USDT",
|
|
"side": "long",
|
|
"notional_usdt": 10,
|
|
"leverage": 1,
|
|
}, account_ids=[account["id"]])
|
|
|
|
intent = result["items"][0]
|
|
assert intent["status"] == "prepared"
|
|
assert intent["reason"] == "exchange_ready_for_executor"
|
|
|
|
|
|
def test_live_risk_blocks_when_margin_exceeds_account_limit():
|
|
set_config("system", "live_trading", {
|
|
"enabled": True,
|
|
"execution_mode": "exchange_api",
|
|
"require_human_approval": False,
|
|
"exchange": "binance",
|
|
"market_type": "um_futures",
|
|
"testnet": True,
|
|
"risk": {
|
|
"max_order_margin_usdt": 5,
|
|
"max_symbol_leverage": 2,
|
|
"allowed_symbols": ["BTC/USDT"],
|
|
},
|
|
}, source="test")
|
|
account = upsert_live_account(account_code="binance_margin_guard", status="enabled")
|
|
|
|
result = create_live_order_intents_for_accounts({
|
|
"symbol": "BTC/USDT",
|
|
"side": "long",
|
|
"notional_usdt": 12,
|
|
"leverage": 2,
|
|
}, account_ids=[account["id"]])
|
|
|
|
intent = result["items"][0]
|
|
assert intent["status"] == "blocked"
|
|
assert intent["reason"] == "margin_exceeds_limit"
|
|
|
|
|
|
def test_live_risk_allows_any_symbol_when_allowed_symbols_empty():
|
|
set_config("system", "live_trading", {
|
|
"enabled": True,
|
|
"execution_mode": "exchange_api",
|
|
"require_human_approval": False,
|
|
"exchange": "binance",
|
|
"market_type": "um_futures",
|
|
"testnet": True,
|
|
"risk": {
|
|
"max_order_margin_usdt": 20,
|
|
"max_symbol_leverage": 2,
|
|
"allowed_symbols": [],
|
|
},
|
|
}, source="test")
|
|
account = upsert_live_account(
|
|
account_code="binance_all_symbols",
|
|
status="enabled",
|
|
risk_config={"max_order_margin_usdt": 20, "max_symbol_leverage": 2, "allowed_symbols": []},
|
|
)
|
|
|
|
result = create_live_order_intents_for_accounts({
|
|
"symbol": "DOGE/USDT",
|
|
"side": "long",
|
|
"notional_usdt": 10,
|
|
"leverage": 1,
|
|
}, account_ids=[account["id"]])
|
|
|
|
intent = result["items"][0]
|
|
assert get_live_account(account["id"])["risk_config"]["allowed_symbols"] == []
|
|
assert intent["status"] == "prepared"
|
|
assert intent["reason"] == "exchange_ready_for_executor"
|
|
|
|
|
|
class _FakeBinanceClient:
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.order_id = 100
|
|
|
|
def load_markets(self):
|
|
self.calls.append(("load_markets",))
|
|
return {"BTC/USDT": {}}
|
|
|
|
def fetch_balance(self):
|
|
self.calls.append(("fetch_balance",))
|
|
return {"USDT": {"free": 1000}}
|
|
|
|
def fetch_ticker(self, symbol):
|
|
self.calls.append(("fetch_ticker", symbol))
|
|
return {"last": 100.0}
|
|
|
|
def fetch_positions(self, symbols=None):
|
|
self.calls.append(("fetch_positions", symbols))
|
|
return []
|
|
|
|
def set_leverage(self, symbol, leverage):
|
|
self.calls.append(("set_leverage", symbol, leverage))
|
|
return {"symbol": symbol, "leverage": leverage}
|
|
|
|
def amount_to_precision(self, symbol, amount):
|
|
self.calls.append(("amount_to_precision", symbol, amount))
|
|
return round(amount, 4)
|
|
|
|
def min_notional(self, symbol):
|
|
self.calls.append(("min_notional", symbol))
|
|
return 5
|
|
|
|
def _order(self, kind, *args):
|
|
self.order_id += 1
|
|
self.calls.append((kind, *args))
|
|
return {"id": str(self.order_id), "kind": kind}
|
|
|
|
def create_market_order(self, symbol, side, amount, params=None):
|
|
return self._order("market_order", symbol, side, amount, params or {})
|
|
|
|
def create_limit_order(self, symbol, side, amount, price, params=None):
|
|
return self._order("limit_order", symbol, side, amount, price, params or {})
|
|
|
|
def cancel_order(self, order_id, symbol):
|
|
self.calls.append(("cancel_order", order_id, symbol))
|
|
return {"id": order_id, "status": "canceled"}
|
|
|
|
def cancel_algo_order(self, *, algo_id=None, client_algo_id=None):
|
|
self.calls.append(("cancel_algo_order", algo_id, client_algo_id))
|
|
return {"algoId": algo_id, "clientAlgoId": client_algo_id, "status": "canceled"}
|
|
|
|
def create_stop_loss_order(self, symbol, side, amount, stop_price, params=None):
|
|
return self._order("stop_loss", symbol, side, amount, stop_price, params or {})
|
|
|
|
def create_take_profit_order(self, symbol, side, amount, stop_price, params=None):
|
|
return self._order("take_profit", symbol, side, amount, stop_price, params or {})
|
|
|
|
|
|
def _insert_paper_trade(symbol="DOGE/USDT", notional=5000, leverage=5):
|
|
conn = get_conn()
|
|
try:
|
|
row = conn.execute(
|
|
"""
|
|
INSERT INTO paper_trades (
|
|
recommendation_id, symbol, side, status, opened_at,
|
|
entry_price, qty, notional_usdt, margin_usdt, leverage,
|
|
stop_loss, tp1, tp2, max_price, min_price, current_price,
|
|
pnl_pct, fee_usdt, source_status, source_action, strategy_version,
|
|
created_at, updated_at
|
|
)
|
|
VALUES (%s,%s,'long','open','2026-05-22T00:00:00',0.1,100,%s,%s,%s,0.09,0.12,0.13,0.1,0.1,0.1,0,0,'buy_now','buy_now','test','2026-05-22T00:00:00','2026-05-22T00:00:00')
|
|
RETURNING *
|
|
""",
|
|
(990000 + int(notional), symbol, notional, notional / leverage, leverage),
|
|
).fetchone()
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
return dict(row)
|
|
|
|
|
|
def test_paper_trade_sync_executes_scaled_live_order_once():
|
|
set_config("system", "live_trading", {
|
|
"enabled": True,
|
|
"execution_mode": "exchange_api",
|
|
"require_human_approval": False,
|
|
"exchange": "binance",
|
|
"market_type": "um_futures",
|
|
"testnet": True,
|
|
"risk": {"max_order_margin_usdt": 20, "max_symbol_leverage": 2, "allowed_symbols": []},
|
|
}, source="test")
|
|
account = upsert_live_account(
|
|
account_code="binance_auto_sync",
|
|
status="enabled",
|
|
risk_config={"max_order_margin_usdt": 20, "max_symbol_leverage": 2, "max_cumulative_leverage": 5, "allowed_symbols": []},
|
|
)
|
|
trade = _insert_paper_trade()
|
|
fake = _FakeBinanceClient()
|
|
fake.fetch_balance = lambda: {"total": {"USDT": 1000}}
|
|
|
|
result = sync_paper_trade_to_live(
|
|
trade["id"],
|
|
account_ids=[account["id"]],
|
|
execute=True,
|
|
client_factory=lambda acct: fake,
|
|
)
|
|
again = sync_paper_trade_to_live(
|
|
trade["id"],
|
|
account_ids=[account["id"]],
|
|
execute=True,
|
|
client_factory=lambda acct: fake,
|
|
)
|
|
|
|
call_names = [c[0] for c in fake.calls]
|
|
assert result["ok"] is True
|
|
assert result["items"][0]["executed"] is True
|
|
assert result["items"][0]["sizing"]["notional_usdt"] == 40
|
|
assert "market_order" in call_names
|
|
assert "stop_loss" in call_names
|
|
assert "take_profit" in call_names
|
|
assert again["items"][0]["reason"] == "already_synced"
|
|
|
|
|
|
def test_live_trading_sync_job_refreshes_snapshots_and_syncs_open_paper_trade():
|
|
set_config("system", "live_trading", {
|
|
"enabled": True,
|
|
"execution_mode": "exchange_api",
|
|
"require_human_approval": False,
|
|
"exchange": "binance",
|
|
"market_type": "um_futures",
|
|
"testnet": True,
|
|
"risk": {"max_order_margin_usdt": 20, "max_symbol_leverage": 2, "allowed_symbols": []},
|
|
}, source="test")
|
|
account = upsert_live_account(
|
|
account_code="binance_scheduler_sync",
|
|
status="enabled",
|
|
risk_config={"max_order_margin_usdt": 20, "max_symbol_leverage": 2, "max_cumulative_leverage": 5, "allowed_symbols": []},
|
|
)
|
|
trade = _insert_paper_trade(symbol="BTC/USDT")
|
|
fake = _FakeBinanceClient()
|
|
fake.fetch_balance = lambda: {"total": {"USDT": 1000}, "free": {"USDT": 900}, "used": {"USDT": 100}}
|
|
fake.fetch_open_orders = lambda symbol=None: []
|
|
fake.fetch_orders = lambda symbol=None, limit=30: []
|
|
|
|
result = run_live_trading_sync(limit=20, execute=True, client_factory=lambda acct: fake)
|
|
snapshot = get_live_account_snapshot(account["id"])
|
|
intents = list_live_order_intents(account_id=account["id"])["items"]
|
|
|
|
assert result["ok"] is True
|
|
assert result["snapshots"]["ok_count"] == 1
|
|
assert result["paper_sync"]["processed_count"] == 1
|
|
assert snapshot["snapshot"]["balance"]["usdt"]["total"] == 1000
|
|
assert intents[0]["paper_trade_id"] == trade["id"]
|
|
assert intents[0]["status"] == "submitted"
|
|
|
|
|
|
def test_live_protection_sync_replaces_live_stop_when_paper_trailing_moves():
|
|
set_config("system", "live_trading", {
|
|
"enabled": True,
|
|
"execution_mode": "exchange_api",
|
|
"require_human_approval": False,
|
|
"exchange": "binance",
|
|
"market_type": "um_futures",
|
|
"testnet": True,
|
|
"risk": {"max_order_margin_usdt": 20, "max_symbol_leverage": 2, "allowed_symbols": []},
|
|
}, source="test")
|
|
account = upsert_live_account(
|
|
account_code="binance_protection_move",
|
|
status="enabled",
|
|
risk_config={"max_order_margin_usdt": 20, "max_symbol_leverage": 2, "max_cumulative_leverage": 5, "allowed_symbols": []},
|
|
)
|
|
trade = _insert_paper_trade(symbol="BTC/USDT")
|
|
fake = _FakeBinanceClient()
|
|
fake.fetch_balance = lambda: {"total": {"USDT": 1000}}
|
|
sync_paper_trade_to_live(trade["id"], account_ids=[account["id"]], execute=True, client_factory=lambda acct: fake)
|
|
|
|
conn = get_conn()
|
|
try:
|
|
conn.execute("UPDATE paper_trades SET trailing_stop=0.105, updated_at='2026-05-22T00:10:00' WHERE id=%s", (trade["id"],))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
result = sync_live_protection_from_paper(client_factory=lambda acct: fake)
|
|
intent = list_live_order_intents(account_id=account["id"])["items"][0]
|
|
events = list_live_order_events(limit=20)["items"]
|
|
call_names = [c[0] for c in fake.calls]
|
|
|
|
assert result["ok"] is True
|
|
assert result["results"][0]["action"] == "replace_stop"
|
|
assert intent["stop_loss"] == 0.105
|
|
assert "cancel_algo_order" in call_names
|
|
assert call_names.count("stop_loss") == 2
|
|
assert any(e["event_type"] == "live_protection_stop_replace" for e in events)
|
|
|
|
|
|
def test_live_protection_sync_closes_live_position_when_paper_trade_closed():
|
|
set_config("system", "live_trading", {
|
|
"enabled": True,
|
|
"execution_mode": "exchange_api",
|
|
"require_human_approval": False,
|
|
"exchange": "binance",
|
|
"market_type": "um_futures",
|
|
"testnet": True,
|
|
"risk": {"max_order_margin_usdt": 20, "max_symbol_leverage": 2, "allowed_symbols": []},
|
|
}, source="test")
|
|
account = upsert_live_account(
|
|
account_code="binance_protection_close",
|
|
status="enabled",
|
|
risk_config={"max_order_margin_usdt": 20, "max_symbol_leverage": 2, "max_cumulative_leverage": 5, "allowed_symbols": []},
|
|
)
|
|
trade = _insert_paper_trade(symbol="BTC/USDT")
|
|
fake = _FakeBinanceClient()
|
|
fake.fetch_balance = lambda: {"total": {"USDT": 1000}}
|
|
sync_paper_trade_to_live(trade["id"], account_ids=[account["id"]], execute=True, client_factory=lambda acct: fake)
|
|
|
|
conn = get_conn()
|
|
try:
|
|
conn.execute(
|
|
"UPDATE paper_trades SET status='closed', exit_reason='trailing_stop', closed_at='2026-05-22T00:20:00', updated_at='2026-05-22T00:20:00' WHERE id=%s",
|
|
(trade["id"],),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
result = sync_live_protection_from_paper(client_factory=lambda acct: fake)
|
|
intent = list_live_order_intents(account_id=account["id"])["items"][0]
|
|
events = list_live_order_events(limit=20)["items"]
|
|
market_orders = [c for c in fake.calls if c[0] == "market_order"]
|
|
|
|
assert result["ok"] is True
|
|
assert result["results"][0]["action"] == "close"
|
|
assert intent["status"] == "closed"
|
|
assert market_orders[-1][-1]["reduceOnly"] is True
|
|
assert any(e["event_type"] == "live_sync_close" for e in events)
|
|
|
|
|
|
def test_binance_testnet_smoke_covers_market_limit_cancel_tp_sl_interfaces():
|
|
account = upsert_live_account(
|
|
account_code="binance_testnet",
|
|
exchange="binance",
|
|
market_type="um_futures",
|
|
testnet=True,
|
|
status="enabled",
|
|
api_key_env="ALPHAX_BINANCE_TESTNET_API_KEY",
|
|
api_secret_env="ALPHAX_BINANCE_TESTNET_API_SECRET",
|
|
)
|
|
fake = _FakeBinanceClient()
|
|
|
|
result = run_binance_testnet_smoke(
|
|
account_id=account["id"],
|
|
symbol="BTC/USDT",
|
|
notional_usdt=10,
|
|
leverage=1,
|
|
client=fake,
|
|
)
|
|
|
|
call_names = [c[0] for c in fake.calls]
|
|
assert result["ok"] is True
|
|
assert "fetch_balance" in call_names
|
|
assert "set_leverage" in call_names
|
|
assert "market_order" in call_names
|
|
assert "limit_order" in call_names
|
|
assert "stop_loss" in call_names
|
|
assert "take_profit" in call_names
|
|
assert "cancel_order" in call_names
|
|
assert call_names.count("market_order") == 2
|
|
|
|
|
|
def test_binance_smoke_blocks_before_order_when_notional_below_exchange_minimum():
|
|
account = upsert_live_account(
|
|
account_code="binance_smoke_min_notional",
|
|
exchange="binance",
|
|
market_type="um_futures",
|
|
testnet=True,
|
|
status="enabled",
|
|
api_key_env="ALPHAX_BINANCE_TESTNET_API_KEY",
|
|
api_secret_env="ALPHAX_BINANCE_TESTNET_API_SECRET",
|
|
)
|
|
fake = _FakeBinanceClient()
|
|
fake.min_notional = lambda symbol: 50
|
|
|
|
try:
|
|
run_binance_testnet_smoke(
|
|
account_id=account["id"],
|
|
symbol="BTC/USDT",
|
|
notional_usdt=10,
|
|
leverage=1,
|
|
client=fake,
|
|
)
|
|
assert False, "expected minimum notional validation error"
|
|
except Exception as exc:
|
|
assert "minimum notional" in str(exc)
|
|
|
|
call_names = [c[0] for c in fake.calls]
|
|
assert "market_order" not in call_names
|
|
|
|
|
|
def test_binance_client_uses_futures_demo_endpoint(monkeypatch):
|
|
monkeypatch.setenv("ALPHAX_BINANCE_DEMO_API_KEY", "demo-key")
|
|
monkeypatch.setenv("ALPHAX_BINANCE_DEMO_API_SECRET", "demo-secret")
|
|
|
|
client = build_binance_client({
|
|
"exchange": "binance",
|
|
"market_type": "um_futures",
|
|
"testnet": True,
|
|
"api_key_env": "ALPHAX_BINANCE_DEMO_API_KEY",
|
|
"api_secret_env": "ALPHAX_BINANCE_DEMO_API_SECRET",
|
|
"risk_config": {"sandbox_mode": "demo"},
|
|
})
|
|
|
|
assert client.exchange.urls["api"]["fapiPrivate"] == "https://demo-fapi.binance.com/fapi/v1"
|
|
assert client.exchange.urls["api"]["fapiPublic"] == "https://demo-fapi.binance.com/fapi/v1"
|