alphax/tests/test_live_trading.py
2026-06-08 00:02:01 +08:00

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"