update
This commit is contained in:
parent
c8f59e3561
commit
f83c1949ac
29
.env.example
29
.env.example
@ -89,9 +89,38 @@ ALPHAX_PAPER_TRAILING_VOL_DISTANCE_MULT=0.7
|
||||
ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_INTERVAL_SECONDS=300
|
||||
ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_STEP_PCT=2
|
||||
|
||||
# 实盘准备模块。默认关闭且 dry-run,只生成订单意图,不真实下单。
|
||||
# 多 API 账号请在页面中配置不同 account_code 和不同 env key 名。
|
||||
ALPHAX_LIVE_TRADING_ENABLED=0
|
||||
ALPHAX_LIVE_TRADING_EXECUTION_MODE=exchange_api
|
||||
ALPHAX_LIVE_TRADING_REQUIRE_HUMAN_APPROVAL=1
|
||||
ALPHAX_LIVE_TRADING_EXCHANGE=binance
|
||||
ALPHAX_LIVE_TRADING_MARKET_TYPE=um_futures
|
||||
ALPHAX_LIVE_TRADING_TESTNET=1
|
||||
ALPHAX_LIVE_TRADING_SANDBOX_MODE=demo
|
||||
ALPHAX_LIVE_TRADING_ACCOUNT_CODE=binance_um_futures_main
|
||||
ALPHAX_BINANCE_API_KEY_ENV=ALPHAX_BINANCE_API_KEY
|
||||
ALPHAX_BINANCE_API_SECRET_ENV=ALPHAX_BINANCE_API_SECRET
|
||||
ALPHAX_BINANCE_API_KEY=
|
||||
ALPHAX_BINANCE_API_SECRET=
|
||||
# 建议先使用 Binance Futures Testnet key 跑接口 smoke test。
|
||||
# 多账号可新增类似 ALPHAX_BINANCE_SUB1_API_KEY / ALPHAX_BINANCE_SUB1_API_SECRET,并在页面配置 env key 名。
|
||||
ALPHAX_BINANCE_TESTNET_API_KEY=
|
||||
ALPHAX_BINANCE_TESTNET_API_SECRET=
|
||||
ALPHAX_LIVE_TRADING_DEFAULT_LEVERAGE=1
|
||||
ALPHAX_LIVE_TRADING_MAX_ORDER_MARGIN_USDT=10
|
||||
ALPHAX_LIVE_TRADING_MAX_ORDER_NOTIONAL_USDT=50
|
||||
ALPHAX_LIVE_TRADING_MAX_SYMBOL_LEVERAGE=1
|
||||
ALPHAX_LIVE_TRADING_MAX_CUMULATIVE_LEVERAGE=1
|
||||
ALPHAX_LIVE_TRADING_MAX_DAILY_ORDER_COUNT=5
|
||||
ALPHAX_LIVE_TRADING_ALLOWED_SYMBOLS=
|
||||
|
||||
ALPHAX_SYSTEM_ERROR_FEISHU_ENABLED=0
|
||||
ALPHAX_SYSTEM_ERROR_FEISHU_WEBHOOK=
|
||||
|
||||
ALPHAX_BINANCE_DEMO_API_KEY=r7dHchnHGVeyDU6rNUnZgZHZpqRpzWjqTzDAB46sUVDua5mp5amW7KSrltDipSuk
|
||||
ALPHAX_BINANCE_DEMO_API_SECRET=jLKzapcO0iPtyxdPgKMK0FKMXLHpkg1EuhNYNHGUqCISwuJmuX7kQ6nardqK4K2Y
|
||||
|
||||
# 邮箱验证码 SMTP 配置。没有配置时,注册验证码只会生成,不会发邮件。
|
||||
ASTOCK_SMTP_HOST=
|
||||
ASTOCK_SMTP_PORT=465
|
||||
|
||||
24
AGENTS.md
24
AGENTS.md
@ -79,7 +79,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
||||
|
||||
### 4.1 推荐系统业务闭环
|
||||
|
||||
建议把系统理解为 8 个层次:
|
||||
建议把系统理解为 9 个层次:
|
||||
|
||||
1. `app/services/market_overview.py`
|
||||
采集全市场快照,为行情环境、涨幅榜和市场温度提供数据。
|
||||
@ -95,7 +95,9 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
||||
负责可执行推荐的价格跟踪、状态迁移和动态风险提示。
|
||||
7. `app/services/paper_trader.py`
|
||||
负责模拟交易账本同步,真实 TP/SL、移动止盈、杠杆和资金口径在 paper trading 层管理。
|
||||
8. `app/services/review_engine.py`
|
||||
8. `app/db/live_trading.py` / `app/web/routes_live_trading.py`
|
||||
负责实盘控制台:多交易所/多 API 账户配置、账号级风控、交易所接口验收和执行审计事件。页面不再使用“订单意图”作为产品概念,也不区分 Demo/正式环境,实际环境由 endpoint/API key 配置决定。
|
||||
9. `app/services/review_engine.py`
|
||||
负责复盘与策略自迭代,包括信号绩效、漏选复盘、规则候选、版本演进。
|
||||
|
||||
### 4.1.1 链上数据源
|
||||
@ -117,6 +119,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
||||
- `app/web/routes_strategy.py`
|
||||
- `app/web/routes_onchain.py`
|
||||
- `app/web/routes_paper_trading.py`
|
||||
- `app/web/routes_live_trading.py`
|
||||
- `app/web/routes_market.py`
|
||||
- `app/web/routes_admin.py`
|
||||
- `app/web/routes_pages.py`
|
||||
@ -184,6 +187,8 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
||||
- 运行时系统配置。
|
||||
- `app/db/paper_trading.py`
|
||||
- 模拟交易账本、仓位、成交事件和资金口径。
|
||||
- `app/db/live_trading.py`
|
||||
- 实盘控制台账本,多 API 账户、账号级风控、交易所接口验收与执行事件;不保存真实 API secret。
|
||||
- `app/db/market_db.py`
|
||||
- 市场快照。
|
||||
- `app/db/system_logs.py`
|
||||
@ -206,7 +211,11 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
||||
- `scheduler_runtime_status`
|
||||
- `scheduler_manual_trigger`
|
||||
- `paper_trades`
|
||||
- `paper_orders`
|
||||
- `paper_trade_events`
|
||||
- `live_trade_accounts`
|
||||
- `live_order_intents`
|
||||
- `live_order_events`
|
||||
- `market_snapshots`
|
||||
- `sentiment_events`
|
||||
- `onchain_*`
|
||||
@ -224,6 +233,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
||||
- `app/config/rules_schema.py` 负责规则结构校验。
|
||||
- `app/config/system_config.py` 负责运行时系统配置,如 scheduler dry run、poll interval 等。
|
||||
- `system_config` / `strategy_runtime_config` 等 PostgreSQL 表承载运行态配置。
|
||||
- `live_trading` runtime config 使用 `execution_mode=exchange_api` 表示真实调用当前配置的交易所 API endpoint;API key/secret 只通过环境变量名引用;多账户配置保存在 `live_trade_accounts`。
|
||||
|
||||
如果要改筛选阈值、确认门槛、止盈止损、动态权重逻辑,优先检查 `rules.yaml` 和 `app/config/config_loader.py`。如果要改调度行为或系统开关,优先检查 runtime config,而不是只看环境变量。
|
||||
|
||||
@ -232,7 +242,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
||||
- `/app`
|
||||
- 真实实现层,按职责拆成 `services`, `db`, `core`, `config`, `integrations`, `analysis`, `web`
|
||||
- `/static`
|
||||
- 页面文件,如 `app.html`, `pipeline.html`, `paper_trading.html`, `review_center.html`, `market.html`, `onchain.html`, `chat.html`
|
||||
- 页面文件,如 `app.html`, `pipeline.html`, `paper_trading.html`, `live_trading.html`, `review_center.html`, `market.html`, `onchain.html`, `chat.html`
|
||||
- `/tests`
|
||||
- 状态机、认证订阅、推荐链路、调度、模拟交易、行情、复盘、前端页面约束等回归测试
|
||||
- `/scripts`
|
||||
@ -315,6 +325,14 @@ python -m app.cli llm-insights --scope sentiment --limit 40
|
||||
|
||||
Docker 内建议通过 `docker compose exec alphax-web python -m app.cli ...` 执行,确保使用容器内 `DATABASE_URL` 和依赖环境。
|
||||
|
||||
实盘接口 smoke test 会调用当前配置的 Binance Futures API endpoint:
|
||||
|
||||
```bash
|
||||
docker compose exec alphax-web python -m app.cli live-trading-smoke --account-id 1 --symbol BTC/USDT --notional-usdt 10 --leverage 1
|
||||
```
|
||||
|
||||
该命令会依次测试余额/行情、设置杠杆、市价单、止盈单、止损单、限价挂单、撤单、最后市价平仓,并写入 `live_order_events`。不要把真实 API key 写入数据库或聊天;只在环境变量中保存密钥,`live_trade_accounts` 只保存 env key 名。
|
||||
|
||||
### 8.3 测试与校验
|
||||
|
||||
常用回归命令:
|
||||
|
||||
15
app/cli.py
15
app/cli.py
@ -3,7 +3,7 @@
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from app.services import altcoin_confirm, altcoin_screener, event_driven_screener, market_overview, onchain_monitor, paper_trader, price_streamer, price_tracker, review_engine, sentiment_monitor
|
||||
from app.services import altcoin_confirm, altcoin_screener, event_driven_screener, live_trading_smoke, market_overview, onchain_monitor, paper_trader, price_streamer, price_tracker, review_engine, sentiment_monitor
|
||||
|
||||
|
||||
def build_parser():
|
||||
@ -43,6 +43,12 @@ def build_parser():
|
||||
llm.add_argument("--scope", choices=["recommendations", "sentiment", "sentiment-events", "review"], default="recommendations")
|
||||
llm.add_argument("--limit", type=int, default=30)
|
||||
|
||||
live_smoke = subparsers.add_parser("live-trading-smoke", help="运行 Binance 合约接口 smoke test")
|
||||
live_smoke.add_argument("--account-id", type=int, required=True, help="live_trade_accounts.id")
|
||||
live_smoke.add_argument("--symbol", default="BTC/USDT", help="测试交易对")
|
||||
live_smoke.add_argument("--notional-usdt", type=float, default=10.0, help="测试名义金额,默认 10U")
|
||||
live_smoke.add_argument("--leverage", type=float, default=1.0, help="测试杠杆,默认 1x")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
@ -100,6 +106,13 @@ def main():
|
||||
result = llm_insights.run(scope=args.scope, limit=args.limit)
|
||||
print(sentiment_monitor.json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return result
|
||||
if args.command == "live-trading-smoke":
|
||||
return live_trading_smoke.main(
|
||||
account_id=args.account_id,
|
||||
symbol=args.symbol,
|
||||
notional_usdt=args.notional_usdt,
|
||||
leverage=args.leverage,
|
||||
)
|
||||
|
||||
parser.error(f"unknown command: {args.command}")
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ from app.db.runtime_config_db import (
|
||||
get_email_config,
|
||||
get_event_driven_config,
|
||||
get_llm_config,
|
||||
get_live_trading_config,
|
||||
get_monitoring_config,
|
||||
get_notification_config,
|
||||
get_onchain_config,
|
||||
@ -132,6 +133,32 @@ def default_paper_trading_config():
|
||||
}
|
||||
|
||||
|
||||
def default_live_trading_config():
|
||||
return {
|
||||
"enabled": _env_bool("ALPHAX_LIVE_TRADING_ENABLED", False),
|
||||
"execution_mode": _env_str("ALPHAX_LIVE_TRADING_EXECUTION_MODE", "exchange_api"),
|
||||
"require_human_approval": _env_bool("ALPHAX_LIVE_TRADING_REQUIRE_HUMAN_APPROVAL", True),
|
||||
"exchange": _env_str("ALPHAX_LIVE_TRADING_EXCHANGE", "binance"),
|
||||
"market_type": _env_str("ALPHAX_LIVE_TRADING_MARKET_TYPE", "um_futures"),
|
||||
"testnet": _env_bool("ALPHAX_LIVE_TRADING_TESTNET", True),
|
||||
"sandbox_mode": _env_str("ALPHAX_LIVE_TRADING_SANDBOX_MODE", "demo"),
|
||||
"account_code": _env_str("ALPHAX_LIVE_TRADING_ACCOUNT_CODE", "binance_um_futures_main"),
|
||||
"api_key_env": _env_str("ALPHAX_BINANCE_API_KEY_ENV", "ALPHAX_BINANCE_API_KEY"),
|
||||
"api_secret_env": _env_str("ALPHAX_BINANCE_API_SECRET_ENV", "ALPHAX_BINANCE_API_SECRET"),
|
||||
"supported_exchanges": ["binance"],
|
||||
"supported_market_types": ["spot", "um_futures"],
|
||||
"default_leverage": _env_float("ALPHAX_LIVE_TRADING_DEFAULT_LEVERAGE", 1),
|
||||
"risk": {
|
||||
"max_order_margin_usdt": _env_float("ALPHAX_LIVE_TRADING_MAX_ORDER_MARGIN_USDT", 10),
|
||||
"max_order_notional_usdt": _env_float("ALPHAX_LIVE_TRADING_MAX_ORDER_NOTIONAL_USDT", 50),
|
||||
"max_symbol_leverage": _env_float("ALPHAX_LIVE_TRADING_MAX_SYMBOL_LEVERAGE", 1),
|
||||
"max_cumulative_leverage": _env_float("ALPHAX_LIVE_TRADING_MAX_CUMULATIVE_LEVERAGE", 1),
|
||||
"max_daily_order_count": _env_int("ALPHAX_LIVE_TRADING_MAX_DAILY_ORDER_COUNT", 5),
|
||||
"allowed_symbols": _env_list("ALPHAX_LIVE_TRADING_ALLOWED_SYMBOLS", []),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def default_price_streamer_config():
|
||||
return {
|
||||
"enabled": _env_bool("ALPHAX_PRICE_STREAMER_ENABLED", True),
|
||||
@ -319,6 +346,7 @@ def seed_runtime_system_defaults():
|
||||
"llm": (default_llm_config(), "LLM provider and module switches; API key remains in env"),
|
||||
"onchain": (default_onchain_config(), "On-chain provider and signal thresholds; API keys remain in env"),
|
||||
"paper_trading": (default_paper_trading_config(), "Paper trading account and execution model"),
|
||||
"live_trading": (default_live_trading_config(), "Live trading exchange, account and risk settings; API secrets remain in env"),
|
||||
"price_streamer": (default_price_streamer_config(), "Realtime websocket price streamer settings"),
|
||||
"sentiment": (default_sentiment_config(), "Sentiment monitoring settings"),
|
||||
"event_driven": (default_event_driven_config(), "Event/news driven screening settings"),
|
||||
@ -358,6 +386,14 @@ def paper_trading_config():
|
||||
return deep_merge(default_paper_trading_config(), cfg or {})
|
||||
|
||||
|
||||
def live_trading_config():
|
||||
cfg = get_live_trading_config(default=None)
|
||||
if cfg is None:
|
||||
_seed_one("live_trading", default_live_trading_config(), "Live trading exchange, account and risk settings; API secrets remain in env")
|
||||
cfg = get_live_trading_config(default=None)
|
||||
return deep_merge(default_live_trading_config(), cfg or {})
|
||||
|
||||
|
||||
def price_streamer_config():
|
||||
cfg = get_price_streamer_config(default=None)
|
||||
if cfg is None:
|
||||
@ -428,6 +464,7 @@ __all__ = [
|
||||
"default_email_config",
|
||||
"default_event_driven_config",
|
||||
"default_llm_config",
|
||||
"default_live_trading_config",
|
||||
"default_monitoring_config",
|
||||
"default_notification_config",
|
||||
"default_onchain_config",
|
||||
@ -438,6 +475,7 @@ __all__ = [
|
||||
"email_config",
|
||||
"event_driven_config",
|
||||
"llm_config",
|
||||
"live_trading_config",
|
||||
"monitoring_config",
|
||||
"notification_config",
|
||||
"onchain_config",
|
||||
|
||||
449
app/db/live_trading.py
Normal file
449
app/db/live_trading.py
Normal file
@ -0,0 +1,449 @@
|
||||
"""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 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 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],
|
||||
}
|
||||
71
app/db/migrations/0012_live_trading.sql
Normal file
71
app/db/migrations/0012_live_trading.sql
Normal file
@ -0,0 +1,71 @@
|
||||
CREATE TABLE IF NOT EXISTS live_trade_accounts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
account_code TEXT NOT NULL UNIQUE,
|
||||
exchange TEXT NOT NULL DEFAULT 'binance',
|
||||
market_type TEXT NOT NULL DEFAULT 'um_futures',
|
||||
testnet INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'disabled',
|
||||
api_key_env TEXT DEFAULT '',
|
||||
api_secret_env TEXT DEFAULT '',
|
||||
permissions_json TEXT DEFAULT '{}',
|
||||
risk_config_json TEXT DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_checked_at TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_live_trade_accounts_status ON live_trade_accounts(status, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS live_order_intents (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
source_type TEXT NOT NULL DEFAULT 'manual',
|
||||
source_id BIGINT DEFAULT 0,
|
||||
recommendation_id BIGINT DEFAULT 0,
|
||||
paper_trade_id BIGINT DEFAULT 0,
|
||||
paper_order_id BIGINT DEFAULT 0,
|
||||
account_id BIGINT DEFAULT 0,
|
||||
exchange TEXT NOT NULL DEFAULT 'binance',
|
||||
market_type TEXT NOT NULL DEFAULT 'um_futures',
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL DEFAULT 'long',
|
||||
position_side TEXT NOT NULL DEFAULT 'long',
|
||||
order_type TEXT NOT NULL DEFAULT 'market',
|
||||
status TEXT NOT NULL DEFAULT 'blocked',
|
||||
reason TEXT DEFAULT '',
|
||||
quantity DOUBLE PRECISION DEFAULT 0,
|
||||
price DOUBLE PRECISION DEFAULT 0,
|
||||
stop_loss DOUBLE PRECISION DEFAULT 0,
|
||||
take_profit DOUBLE PRECISION DEFAULT 0,
|
||||
notional_usdt DOUBLE PRECISION DEFAULT 0,
|
||||
leverage DOUBLE PRECISION DEFAULT 1,
|
||||
reduce_only INTEGER NOT NULL DEFAULT 0,
|
||||
client_order_id TEXT DEFAULT '',
|
||||
exchange_order_id TEXT DEFAULT '',
|
||||
risk_check_json TEXT DEFAULT '{}',
|
||||
request_json TEXT DEFAULT '{}',
|
||||
response_json TEXT DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
approved_at TEXT DEFAULT '',
|
||||
submitted_at TEXT DEFAULT '',
|
||||
finished_at TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_live_order_intents_status_updated ON live_order_intents(status, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_live_order_intents_symbol_status ON live_order_intents(symbol, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_live_order_intents_source ON live_order_intents(source_type, source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_live_order_intents_recommendation ON live_order_intents(recommendation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_live_order_intents_paper_trade ON live_order_intents(paper_trade_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS live_order_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
intent_id BIGINT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
status TEXT DEFAULT '',
|
||||
message TEXT DEFAULT '',
|
||||
payload_json TEXT DEFAULT '{}',
|
||||
event_time TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_live_order_events_intent_time ON live_order_events(intent_id, event_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_live_order_events_type_time ON live_order_events(event_type, event_time DESC);
|
||||
@ -221,6 +221,14 @@ def set_paper_trading_config(value, updated_by="", source="manual"):
|
||||
return set_config("system", "paper_trading", value, description="Paper trading account and execution model", source=source, updated_by=updated_by)
|
||||
|
||||
|
||||
def get_live_trading_config(default=None):
|
||||
return get_config("system", "live_trading", default=default)
|
||||
|
||||
|
||||
def set_live_trading_config(value, updated_by="", source="manual"):
|
||||
return set_config("system", "live_trading", value, description="Live trading exchange, account and risk settings; API secrets stay in env", source=source, updated_by=updated_by)
|
||||
|
||||
|
||||
def get_price_streamer_config(default=None):
|
||||
return get_config("system", "price_streamer", default=default)
|
||||
|
||||
|
||||
179
app/integrations/binance_live.py
Normal file
179
app/integrations/binance_live.py
Normal file
@ -0,0 +1,179 @@
|
||||
"""Binance live-trading adapter.
|
||||
|
||||
Only transport details live here. Business safety checks and audit logging stay
|
||||
in the live_trading DB/service layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import hashlib
|
||||
import hmac
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
|
||||
import ccxt
|
||||
|
||||
|
||||
class LiveTradingConfigError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class BinanceLiveClient:
|
||||
exchange: object
|
||||
market_type: str = "um_futures"
|
||||
|
||||
def load_markets(self):
|
||||
return self.exchange.load_markets()
|
||||
|
||||
def fetch_balance(self):
|
||||
return self.exchange.fetch_balance()
|
||||
|
||||
def fetch_ticker(self, symbol: str):
|
||||
return self.exchange.fetch_ticker(symbol)
|
||||
|
||||
def fetch_positions(self, symbols: list[str] | None = None):
|
||||
if hasattr(self.exchange, "fetch_positions"):
|
||||
return self.exchange.fetch_positions(symbols)
|
||||
return []
|
||||
|
||||
def fetch_open_orders(self, symbol: str | None = None):
|
||||
return self.exchange.fetch_open_orders(symbol)
|
||||
|
||||
def fetch_orders(self, symbol: str | None = None, limit: int = 30):
|
||||
if hasattr(self.exchange, "fetch_orders"):
|
||||
return self.exchange.fetch_orders(symbol, None, limit)
|
||||
return []
|
||||
|
||||
def set_leverage(self, symbol: str, leverage: float):
|
||||
if hasattr(self.exchange, "set_leverage"):
|
||||
return self.exchange.set_leverage(int(leverage), symbol)
|
||||
return {"skipped": True, "reason": "exchange_does_not_support_set_leverage"}
|
||||
|
||||
def amount_to_precision(self, symbol: str, amount: float) -> float:
|
||||
try:
|
||||
return float(self.exchange.amount_to_precision(symbol, amount))
|
||||
except Exception:
|
||||
return float(amount)
|
||||
|
||||
def price_to_precision(self, symbol: str, price: float) -> str:
|
||||
try:
|
||||
return str(self.exchange.price_to_precision(symbol, price))
|
||||
except Exception:
|
||||
return str(price)
|
||||
|
||||
def min_notional(self, symbol: str) -> float:
|
||||
try:
|
||||
market = self.exchange.market(symbol)
|
||||
limits = market.get("limits") if isinstance(market, dict) else {}
|
||||
cost = limits.get("cost") if isinstance(limits.get("cost"), dict) else {}
|
||||
value = cost.get("min")
|
||||
return float(value or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def create_market_order(self, symbol: str, side: str, amount: float, params: dict | None = None):
|
||||
return self.exchange.create_order(symbol, "market", side, amount, None, params or {})
|
||||
|
||||
def create_limit_order(self, symbol: str, side: str, amount: float, price: float, params: dict | None = None):
|
||||
return self.exchange.create_order(symbol, "limit", side, amount, price, params or {})
|
||||
|
||||
def cancel_order(self, order_id: str, symbol: str):
|
||||
return self.exchange.cancel_order(order_id, symbol)
|
||||
|
||||
def _market_id(self, symbol: str) -> str:
|
||||
try:
|
||||
return str(self.exchange.market(symbol).get("id") or symbol.replace("/", ""))
|
||||
except Exception:
|
||||
return symbol.replace("/", "")
|
||||
|
||||
def _signed_fapi_request(self, method: str, path: str, params: dict) -> dict:
|
||||
timestamp = int(self.exchange.milliseconds()) if hasattr(self.exchange, "milliseconds") else 0
|
||||
payload = {**(params or {}), "timestamp": timestamp}
|
||||
query = urlencode(payload, doseq=True)
|
||||
secret = str(getattr(self.exchange, "secret", "") or "")
|
||||
signature = hmac.new(secret.encode("utf-8"), query.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
signed_query = f"{query}&signature={signature}"
|
||||
urls = getattr(self.exchange, "urls", {}) if isinstance(getattr(self.exchange, "urls", {}), dict) else {}
|
||||
api_urls = urls.get("api") if isinstance(urls.get("api"), dict) else {}
|
||||
base_url = str(api_urls.get("fapiPrivate") or "https://fapi.binance.com/fapi/v1").rstrip("/")
|
||||
url = f"{base_url}{path}"
|
||||
headers = {"X-MBX-APIKEY": str(getattr(self.exchange, "apiKey", "") or "")}
|
||||
response = requests.request(method.upper(), url, params=signed_query, headers=headers, timeout=15)
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception:
|
||||
data = {"raw": response.text}
|
||||
if response.status_code >= 400 or (isinstance(data, dict) and int(data.get("code", 0) or 0) < 0):
|
||||
raise ccxt.ExchangeError(f"binanceusdm {data}")
|
||||
return data
|
||||
|
||||
def _create_algo_order(self, symbol: str, side: str, order_type: str, amount: float, trigger_price: float, params: dict | None = None):
|
||||
merged = {
|
||||
"algoType": "CONDITIONAL",
|
||||
"symbol": self._market_id(symbol),
|
||||
"side": side.upper(),
|
||||
"type": order_type,
|
||||
"quantity": self.exchange.amount_to_precision(symbol, amount),
|
||||
"triggerPrice": self.price_to_precision(symbol, trigger_price),
|
||||
"workingType": "CONTRACT_PRICE",
|
||||
"reduceOnly": "true",
|
||||
**(params or {}),
|
||||
}
|
||||
merged.pop("stopPrice", None)
|
||||
return self._signed_fapi_request("POST", "/algoOrder", merged)
|
||||
|
||||
def create_stop_loss_order(self, symbol: str, side: str, amount: float, stop_price: float, params: dict | None = None):
|
||||
return self._create_algo_order(symbol, side, "STOP_MARKET", amount, stop_price, params)
|
||||
|
||||
def create_take_profit_order(self, symbol: str, side: str, amount: float, stop_price: float, params: dict | None = None):
|
||||
return self._create_algo_order(symbol, side, "TAKE_PROFIT_MARKET", amount, stop_price, params)
|
||||
|
||||
def cancel_algo_order(self, *, algo_id: str | int | None = None, client_algo_id: str | None = None):
|
||||
params: dict = {}
|
||||
if algo_id:
|
||||
params["algoId"] = algo_id
|
||||
if client_algo_id:
|
||||
params["clientAlgoId"] = client_algo_id
|
||||
if not params:
|
||||
raise ValueError("algo_id or client_algo_id is required")
|
||||
return self._signed_fapi_request("DELETE", "/algoOrder", params)
|
||||
|
||||
|
||||
def build_binance_client(account: dict, *, require_testnet: bool = True) -> BinanceLiveClient:
|
||||
market_type = str(account.get("market_type") or "um_futures")
|
||||
testnet = bool(account.get("testnet", True))
|
||||
risk_config = account.get("risk_config") if isinstance(account.get("risk_config"), dict) else {}
|
||||
sandbox_mode = str(risk_config.get("sandbox_mode") or os.getenv("ALPHAX_LIVE_TRADING_SANDBOX_MODE", "demo")).strip().lower()
|
||||
if require_testnet and not testnet:
|
||||
raise LiveTradingConfigError("mainnet execution is not allowed by this smoke tester")
|
||||
|
||||
api_key_env = str(account.get("api_key_env") or "ALPHAX_BINANCE_API_KEY")
|
||||
api_secret_env = str(account.get("api_secret_env") or "ALPHAX_BINANCE_API_SECRET")
|
||||
api_key = os.getenv(api_key_env, "").strip()
|
||||
api_secret = os.getenv(api_secret_env, "").strip()
|
||||
if not api_key or not api_secret:
|
||||
raise LiveTradingConfigError(f"missing Binance credentials env: {api_key_env}/{api_secret_env}")
|
||||
|
||||
klass = ccxt.binanceusdm if market_type == "um_futures" else ccxt.binance
|
||||
exchange = klass({
|
||||
"apiKey": api_key,
|
||||
"secret": api_secret,
|
||||
"enableRateLimit": True,
|
||||
"options": {
|
||||
"defaultType": "future" if market_type == "um_futures" else "spot",
|
||||
"fetchCurrencies": False,
|
||||
"warnOnFetchOpenOrdersWithoutSymbol": False,
|
||||
},
|
||||
})
|
||||
if testnet and sandbox_mode == "demo" and isinstance(getattr(exchange, "urls", None), dict) and exchange.urls.get("demo"):
|
||||
exchange.urls["api"] = exchange.urls["demo"]
|
||||
elif hasattr(exchange, "set_sandbox_mode"):
|
||||
exchange.set_sandbox_mode(testnet)
|
||||
return BinanceLiveClient(exchange=exchange, market_type=market_type)
|
||||
|
||||
|
||||
__all__ = ["BinanceLiveClient", "LiveTradingConfigError", "build_binance_client"]
|
||||
121
app/services/live_trading_account.py
Normal file
121
app/services/live_trading_account.py
Normal file
@ -0,0 +1,121 @@
|
||||
"""Account-centric read model for live trading console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.db.live_trading import _safe_float, get_live_account, list_live_order_events, list_live_order_intents
|
||||
from app.integrations.binance_live import LiveTradingConfigError, build_binance_client
|
||||
|
||||
|
||||
def _compact_balance(balance: dict) -> dict:
|
||||
total = balance.get("total") if isinstance(balance.get("total"), dict) else {}
|
||||
free = balance.get("free") if isinstance(balance.get("free"), dict) else {}
|
||||
used = balance.get("used") if isinstance(balance.get("used"), dict) else {}
|
||||
assets = []
|
||||
for asset in sorted(set(total) | set(free) | set(used)):
|
||||
total_value = _safe_float(total.get(asset))
|
||||
free_value = _safe_float(free.get(asset))
|
||||
used_value = _safe_float(used.get(asset))
|
||||
if abs(total_value) > 0 or abs(free_value) > 0 or abs(used_value) > 0:
|
||||
assets.append({"asset": asset, "free": free_value, "used": used_value, "total": total_value})
|
||||
return {
|
||||
"assets": assets,
|
||||
"usdt": {
|
||||
"free": _safe_float(free.get("USDT")),
|
||||
"used": _safe_float(used.get("USDT")),
|
||||
"total": _safe_float(total.get("USDT")),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _compact_position(item: dict) -> dict:
|
||||
info = item.get("info") if isinstance(item.get("info"), dict) else {}
|
||||
contracts = _safe_float(item.get("contracts") or info.get("positionAmt"))
|
||||
notional = _safe_float(item.get("notional") or info.get("notional"))
|
||||
return {
|
||||
"symbol": item.get("symbol") or info.get("symbol"),
|
||||
"side": item.get("side") or ("long" if contracts > 0 else ("short" if contracts < 0 else "")),
|
||||
"contracts": contracts,
|
||||
"entry_price": _safe_float(item.get("entryPrice") or info.get("entryPrice")),
|
||||
"mark_price": _safe_float(item.get("markPrice") or info.get("markPrice")),
|
||||
"notional": notional,
|
||||
"unrealized_pnl": _safe_float(item.get("unrealizedPnl") or info.get("unrealizedProfit")),
|
||||
"leverage": _safe_float(item.get("leverage") or info.get("leverage")),
|
||||
}
|
||||
|
||||
|
||||
def _compact_order(item: dict) -> dict:
|
||||
info = item.get("info") if isinstance(item.get("info"), dict) else {}
|
||||
return {
|
||||
"id": str(item.get("id") or info.get("orderId") or ""),
|
||||
"client_order_id": item.get("clientOrderId") or info.get("clientOrderId") or "",
|
||||
"symbol": item.get("symbol") or info.get("symbol"),
|
||||
"type": item.get("type") or info.get("type"),
|
||||
"side": item.get("side") or info.get("side"),
|
||||
"status": item.get("status") or info.get("status"),
|
||||
"price": _safe_float(item.get("price") or info.get("price")),
|
||||
"amount": _safe_float(item.get("amount") or info.get("origQty")),
|
||||
"filled": _safe_float(item.get("filled") or info.get("executedQty")),
|
||||
"average": _safe_float(item.get("average") or info.get("avgPrice")),
|
||||
"timestamp": item.get("datetime") or item.get("timestamp") or info.get("updateTime") or info.get("time"),
|
||||
}
|
||||
|
||||
|
||||
def _account_risk_view(account: dict) -> dict:
|
||||
risk = account.get("risk_config") if isinstance(account.get("risk_config"), dict) else {}
|
||||
allowed = [str(x).strip().upper() for x in risk.get("allowed_symbols", []) if str(x).strip()]
|
||||
max_leverage = _safe_float(risk.get("max_symbol_leverage"), 1)
|
||||
margin = _safe_float(risk.get("max_order_margin_usdt"), 0)
|
||||
return {
|
||||
"max_order_margin_usdt": margin,
|
||||
"max_symbol_leverage": max_leverage,
|
||||
"max_order_notional_usdt": _safe_float(risk.get("max_order_notional_usdt"), margin * max(1.0, max_leverage)),
|
||||
"max_cumulative_leverage": _safe_float(risk.get("max_cumulative_leverage"), 1),
|
||||
"max_daily_order_count": int(risk.get("max_daily_order_count") or 0),
|
||||
"allowed_symbols": allowed,
|
||||
"symbol_policy": "all" if not allowed else "allowlist",
|
||||
}
|
||||
|
||||
|
||||
def get_live_account_overview(account_id: int, *, history_limit: int = 30) -> dict:
|
||||
account = get_live_account(account_id)
|
||||
if not account:
|
||||
raise LiveTradingConfigError("live account not found")
|
||||
overview = {
|
||||
"account": account,
|
||||
"risk": _account_risk_view(account),
|
||||
"balance": {"assets": [], "usdt": {"free": 0, "used": 0, "total": 0}},
|
||||
"positions": [],
|
||||
"open_orders": [],
|
||||
"order_history": [],
|
||||
"intent_history": list_live_order_intents(limit=history_limit, account_id=account_id).get("items", []),
|
||||
"events": list_live_order_events(limit=history_limit).get("items", []),
|
||||
"errors": [],
|
||||
}
|
||||
if account.get("status") != "enabled":
|
||||
return overview
|
||||
try:
|
||||
client = build_binance_client(account, require_testnet=True)
|
||||
client.load_markets()
|
||||
except Exception as exc:
|
||||
overview["errors"].append(f"账户连接失败:{exc}")
|
||||
return overview
|
||||
try:
|
||||
overview["balance"] = _compact_balance(client.fetch_balance())
|
||||
except Exception as exc:
|
||||
overview["errors"].append(f"余额读取失败:{exc}")
|
||||
try:
|
||||
overview["positions"] = [
|
||||
item for item in (_compact_position(p) for p in client.fetch_positions(None))
|
||||
if abs(_safe_float(item.get("contracts"))) > 0
|
||||
]
|
||||
except Exception as exc:
|
||||
overview["errors"].append(f"持仓读取失败:{exc}")
|
||||
try:
|
||||
overview["open_orders"] = [_compact_order(o) for o in client.fetch_open_orders(None)]
|
||||
except Exception as exc:
|
||||
overview["errors"].append(f"挂单读取失败:{exc}")
|
||||
try:
|
||||
overview["order_history"] = [_compact_order(o) for o in client.fetch_orders(None, limit=history_limit)]
|
||||
except Exception as exc:
|
||||
overview["errors"].append(f"订单历史读取失败:{exc}")
|
||||
return overview
|
||||
220
app/services/live_trading_smoke.py
Normal file
220
app/services/live_trading_smoke.py
Normal file
@ -0,0 +1,220 @@
|
||||
"""End-to-end smoke tests for exchange execution interfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from app.db.live_trading import _dumps, _safe_float, get_live_account
|
||||
from app.db.schema import get_conn, init_db
|
||||
from app.integrations.binance_live import LiveTradingConfigError, build_binance_client
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now().isoformat()
|
||||
|
||||
|
||||
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 _record_event(intent_id: int, event_type: str, status: str, message: str = "", payload=None) -> None:
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO live_order_events (intent_id, event_type, status, message, payload_json, event_time)
|
||||
VALUES (%s,%s,%s,%s,%s,%s)
|
||||
""",
|
||||
(int(intent_id or 0), event_type, status, message, _dumps(payload or {}), _now()),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
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 _extract_order_id(order: dict) -> str:
|
||||
return str((order or {}).get("id") or (order or {}).get("orderId") or "")
|
||||
|
||||
|
||||
def _extract_algo_id(order: dict) -> str:
|
||||
return str((order or {}).get("algoId") or (order or {}).get("clientAlgoId") or "")
|
||||
|
||||
|
||||
def _cancel_conditional_order(client, order: dict, symbol: str):
|
||||
algo_id = (order or {}).get("algoId")
|
||||
client_algo_id = (order or {}).get("clientAlgoId")
|
||||
if (algo_id or client_algo_id) and hasattr(client, "cancel_algo_order"):
|
||||
return client.cancel_algo_order(algo_id=algo_id, client_algo_id=client_algo_id)
|
||||
order_id = _extract_order_id(order)
|
||||
if order_id:
|
||||
return client.cancel_order(order_id, symbol)
|
||||
return {"skipped": True, "reason": "missing_order_id"}
|
||||
|
||||
|
||||
def _test_price(anchor_price: float, side: str, distance_pct: float) -> float:
|
||||
if side == "buy":
|
||||
return round(anchor_price * (1 - distance_pct / 100), 8)
|
||||
return round(anchor_price * (1 + distance_pct / 100), 8)
|
||||
|
||||
|
||||
def _compact_result(name: str, result):
|
||||
if name == "load_markets" and isinstance(result, dict):
|
||||
return {"markets_loaded": len(result)}
|
||||
if name == "fetch_balance" and isinstance(result, dict):
|
||||
total = result.get("total") if isinstance(result.get("total"), dict) else {}
|
||||
free = result.get("free") if isinstance(result.get("free"), dict) else {}
|
||||
return {
|
||||
"USDT": {
|
||||
"free": _safe_float(free.get("USDT")),
|
||||
"total": _safe_float(total.get("USDT")),
|
||||
}
|
||||
}
|
||||
if name == "fetch_ticker" and isinstance(result, dict):
|
||||
return {
|
||||
"symbol": result.get("symbol"),
|
||||
"last": result.get("last") or result.get("close"),
|
||||
"percentage": result.get("percentage"),
|
||||
}
|
||||
if name == "fetch_positions" and isinstance(result, list):
|
||||
return [
|
||||
{
|
||||
"symbol": item.get("symbol"),
|
||||
"contracts": item.get("contracts"),
|
||||
"positionAmt": (item.get("info") or {}).get("positionAmt") if isinstance(item, dict) else None,
|
||||
}
|
||||
for item in result
|
||||
]
|
||||
if isinstance(result, dict):
|
||||
return {
|
||||
k: v
|
||||
for k, v in result.items()
|
||||
if k in {"id", "orderId", "algoId", "clientOrderId", "clientAlgoId", "status", "symbol", "side", "type", "orderType", "price", "triggerPrice", "amount", "average", "filled", "code", "msg"}
|
||||
} or result
|
||||
return result
|
||||
|
||||
|
||||
def _position_amount(position: dict) -> float:
|
||||
return _safe_float((position or {}).get("contracts") or (position or {}).get("info", {}).get("positionAmt"))
|
||||
|
||||
|
||||
def run_binance_testnet_smoke(
|
||||
*,
|
||||
account_id: int,
|
||||
symbol: str = "BTC/USDT",
|
||||
notional_usdt: float = 10.0,
|
||||
leverage: float = 1.0,
|
||||
intent_id: int = 0,
|
||||
client=None,
|
||||
) -> dict:
|
||||
init_db()
|
||||
account = get_live_account(account_id)
|
||||
if not account:
|
||||
raise LiveTradingConfigError("live account not found")
|
||||
if str(account.get("exchange") or "binance") != "binance":
|
||||
raise LiveTradingConfigError("only Binance smoke test is implemented")
|
||||
if not bool(account.get("testnet", True)):
|
||||
raise LiveTradingConfigError("account is not enabled for the configured exchange endpoint")
|
||||
|
||||
symbol = _normalize_symbol(symbol)
|
||||
notional_usdt = max(5.0, _safe_float(notional_usdt, 10.0))
|
||||
leverage = max(1.0, _safe_float(leverage, 1.0))
|
||||
client = client or build_binance_client(account, require_testnet=True)
|
||||
side = str(account.get("risk_config", {}).get("smoke_side") or "long")
|
||||
open_side, close_side = _side_to_exchange(side)
|
||||
steps: list[dict] = []
|
||||
|
||||
def step(name: str, fn):
|
||||
try:
|
||||
result = fn()
|
||||
compact = _compact_result(name, result)
|
||||
item = {"step": name, "ok": True, "result": compact}
|
||||
_record_event(intent_id, f"smoke_{name}", "ok", "", compact)
|
||||
except Exception as exc:
|
||||
item = {"step": name, "ok": False, "error": str(exc)}
|
||||
_record_event(intent_id, f"smoke_{name}", "error", str(exc), {})
|
||||
steps.append(item)
|
||||
raise
|
||||
steps.append(item)
|
||||
return result
|
||||
|
||||
step("load_markets", client.load_markets)
|
||||
step("fetch_balance", client.fetch_balance)
|
||||
ticker = step("fetch_ticker", lambda: client.fetch_ticker(symbol))
|
||||
last = _safe_float((ticker or {}).get("last") or (ticker or {}).get("close"))
|
||||
if last <= 0:
|
||||
raise LiveTradingConfigError("ticker price is unavailable")
|
||||
|
||||
min_notional = client.min_notional(symbol) if hasattr(client, "min_notional") else 0.0
|
||||
if min_notional > 0 and notional_usdt < min_notional:
|
||||
raise LiveTradingConfigError(
|
||||
f"{symbol} minimum notional is {min_notional:g} USDT; current test notional is {notional_usdt:g} USDT"
|
||||
)
|
||||
|
||||
amount = client.amount_to_precision(symbol, notional_usdt / last)
|
||||
if amount <= 0:
|
||||
raise LiveTradingConfigError("calculated order amount is zero")
|
||||
|
||||
step("set_leverage", lambda: client.set_leverage(symbol, leverage))
|
||||
if hasattr(client, "fetch_positions"):
|
||||
positions = step("fetch_positions", lambda: client.fetch_positions([symbol]))
|
||||
open_positions = [p for p in positions or [] if abs(_position_amount(p)) > 0]
|
||||
if open_positions:
|
||||
raise LiveTradingConfigError("symbol has existing position; close it before smoke test")
|
||||
market_order = None
|
||||
close_market = None
|
||||
try:
|
||||
market_order = step("market_order", lambda: client.create_market_order(symbol, open_side, amount, {"newClientOrderId": f"alphax_smoke_mkt_{int(datetime.now().timestamp())}"}))
|
||||
|
||||
stop_price = round(last * 0.99, 8)
|
||||
take_profit_price = round(last * 1.01, 8)
|
||||
stop_loss = step("stop_loss", lambda: client.create_stop_loss_order(symbol, close_side, amount, stop_price))
|
||||
take_profit = step("take_profit", lambda: client.create_take_profit_order(symbol, close_side, amount, take_profit_price))
|
||||
|
||||
limit_price = _test_price(last, open_side, 5.0)
|
||||
limit_order = step("limit_order", lambda: client.create_limit_order(symbol, open_side, amount, limit_price, {"timeInForce": "GTC"}))
|
||||
limit_order_id = _extract_order_id(limit_order)
|
||||
if limit_order_id:
|
||||
step("cancel_limit_order", lambda: client.cancel_order(limit_order_id, symbol))
|
||||
|
||||
for name, order in (("cancel_stop_loss", stop_loss), ("cancel_take_profit", take_profit)):
|
||||
if _extract_order_id(order) or _extract_algo_id(order):
|
||||
step(name, lambda item=order: _cancel_conditional_order(client, item, symbol))
|
||||
|
||||
close_market = step("close_market_order", lambda: client.create_market_order(symbol, close_side, amount, {"reduceOnly": True}))
|
||||
finally:
|
||||
if market_order and not close_market:
|
||||
try:
|
||||
close_market = step("emergency_close_market_order", lambda: client.create_market_order(symbol, close_side, amount, {"reduceOnly": True}))
|
||||
except Exception as exc:
|
||||
_record_event(intent_id, "smoke_emergency_close_failed", "error", str(exc), {})
|
||||
summary = {
|
||||
"ok": all(x["ok"] for x in steps),
|
||||
"account_id": account_id,
|
||||
"side": side,
|
||||
"symbol": symbol,
|
||||
"notional_usdt": notional_usdt,
|
||||
"leverage": leverage,
|
||||
"amount": amount,
|
||||
"market_order_id": _extract_order_id(market_order),
|
||||
"close_order_id": _extract_order_id(close_market),
|
||||
"steps": steps,
|
||||
}
|
||||
_record_event(intent_id, "smoke_completed", "ok", "binance_exchange_smoke_completed", summary)
|
||||
return summary
|
||||
|
||||
|
||||
def main(account_id: int, symbol: str = "BTC/USDT", notional_usdt: float = 10.0, leverage: float = 1.0):
|
||||
result = run_binance_testnet_smoke(account_id=account_id, symbol=symbol, notional_usdt=notional_usdt, leverage=leverage)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
|
||||
return result
|
||||
129
app/web/routes_live_trading.py
Normal file
129
app/web/routes_live_trading.py
Normal file
@ -0,0 +1,129 @@
|
||||
from fastapi import APIRouter, Body, Cookie, HTTPException
|
||||
|
||||
from app.db.live_trading import (
|
||||
create_live_order_intent,
|
||||
delete_live_account,
|
||||
get_live_trading_summary,
|
||||
create_live_order_intents_for_accounts,
|
||||
list_live_accounts,
|
||||
list_live_order_events,
|
||||
list_live_order_intents,
|
||||
prepare_intent_from_paper_trade,
|
||||
upsert_live_account,
|
||||
)
|
||||
from app.services.live_trading_smoke import run_binance_testnet_smoke
|
||||
from app.services.live_trading_account import get_live_account_overview
|
||||
from app.integrations.binance_live import LiveTradingConfigError
|
||||
from app.web.shared import require_admin
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/live-trading/summary")
|
||||
async def api_live_trading_summary(altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
return get_live_trading_summary()
|
||||
|
||||
|
||||
@router.get("/api/live-trading/accounts")
|
||||
async def api_live_trading_accounts(altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
return list_live_accounts()
|
||||
|
||||
|
||||
@router.get("/api/live-trading/accounts/{account_id}/overview")
|
||||
async def api_live_trading_account_overview(account_id: int, altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
return get_live_account_overview(account_id)
|
||||
|
||||
|
||||
@router.post("/api/live-trading/accounts")
|
||||
async def api_live_trading_account(payload: dict = Body(default={}), altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
return upsert_live_account(
|
||||
account_code=payload.get("account_code", ""),
|
||||
exchange=payload.get("exchange", ""),
|
||||
market_type=payload.get("market_type", ""),
|
||||
testnet=payload.get("testnet") if "testnet" in payload else None,
|
||||
status=payload.get("status", ""),
|
||||
api_key_env=payload.get("api_key_env", ""),
|
||||
api_secret_env=payload.get("api_secret_env", ""),
|
||||
permissions=payload.get("permissions") if isinstance(payload.get("permissions"), dict) else None,
|
||||
risk_config=payload.get("risk_config") if isinstance(payload.get("risk_config"), dict) else None,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api/live-trading/accounts/{account_id}")
|
||||
async def api_live_trading_delete_account(account_id: int, altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
result = delete_live_account(account_id)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=404, detail=result.get("reason", "account_not_found"))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/live-trading/accounts/default")
|
||||
async def api_live_trading_default_account(altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
return upsert_live_account()
|
||||
|
||||
|
||||
@router.get("/api/live-trading/intents")
|
||||
async def api_live_trading_intents(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
status: str = "",
|
||||
altcoin_session: str = Cookie(default=""),
|
||||
):
|
||||
require_admin(altcoin_session)
|
||||
return list_live_order_intents(limit=limit, offset=offset, status=status)
|
||||
|
||||
|
||||
@router.post("/api/live-trading/intents")
|
||||
async def api_live_trading_create_intent(payload: dict = Body(default={}), altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
account_ids = payload.pop("account_ids", None)
|
||||
if isinstance(account_ids, list) and account_ids:
|
||||
return create_live_order_intents_for_accounts(payload, account_ids=account_ids, source_type="manual")
|
||||
return create_live_order_intent(payload, source_type="manual")
|
||||
|
||||
|
||||
@router.post("/api/live-trading/intents/from-paper/{paper_trade_id}")
|
||||
async def api_live_trading_from_paper(paper_trade_id: int, payload: dict = Body(default={}), altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
account_ids = payload.get("account_ids") if isinstance(payload, dict) else None
|
||||
return prepare_intent_from_paper_trade(paper_trade_id, account_ids=account_ids if isinstance(account_ids, list) else None)
|
||||
|
||||
|
||||
@router.get("/api/live-trading/events")
|
||||
async def api_live_trading_events(
|
||||
limit: int = 80,
|
||||
offset: int = 0,
|
||||
intent_id: int = 0,
|
||||
altcoin_session: str = Cookie(default=""),
|
||||
):
|
||||
require_admin(altcoin_session)
|
||||
return list_live_order_events(limit=limit, offset=offset, intent_id=intent_id)
|
||||
|
||||
|
||||
@router.post("/api/live-trading/smoke/binance")
|
||||
async def api_live_trading_binance_smoke(payload: dict = Body(default={}), altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
try:
|
||||
return run_binance_testnet_smoke(
|
||||
account_id=int(payload.get("account_id") or 0),
|
||||
symbol=str(payload.get("symbol") or "BTC/USDT"),
|
||||
notional_usdt=float(payload.get("notional_usdt") or 10),
|
||||
leverage=float(payload.get("leverage") or 1),
|
||||
intent_id=int(payload.get("intent_id") or 0),
|
||||
)
|
||||
except LiveTradingConfigError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"exchange smoke failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.post("/api/live-trading/smoke/binance-testnet")
|
||||
async def api_live_trading_binance_testnet_smoke(payload: dict = Body(default={}), altcoin_session: str = Cookie(default="")):
|
||||
return await api_live_trading_binance_smoke(payload=payload, altcoin_session=altcoin_session)
|
||||
@ -140,6 +140,17 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
|
||||
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", status_code=exc.status_code)
|
||||
return render_page("paper_trading.html", request, active_nav="paper_trading")
|
||||
|
||||
@router.get("/live-trading", response_class=HTMLResponse)
|
||||
async def live_trading_page(request: Request):
|
||||
user, redirect = require_page_user(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
try:
|
||||
require_admin(request.cookies.get("altcoin_session", ""))
|
||||
except HTTPException as exc:
|
||||
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", status_code=exc.status_code)
|
||||
return render_page("live_trading.html", request, active_nav="live_trading")
|
||||
|
||||
@router.get("/review-center", response_class=HTMLResponse)
|
||||
async def review_center_page(request: Request):
|
||||
user, redirect = require_page_user(request)
|
||||
|
||||
@ -19,6 +19,7 @@ from app.web.routes_auth import router as auth_router
|
||||
from app.web.routes_chat import router as chat_router
|
||||
from app.web.routes_content import build_router as build_content_router
|
||||
from app.web.routes_market import router as market_router
|
||||
from app.web.routes_live_trading import router as live_trading_router
|
||||
from app.web.routes_onchain import router as onchain_router
|
||||
from app.web.routes_paper_trading import router as paper_trading_router
|
||||
from app.web.routes_pages import build_router as build_pages_router
|
||||
@ -52,6 +53,7 @@ app.include_router(review_center_router)
|
||||
app.include_router(strategy_router)
|
||||
app.include_router(onchain_router)
|
||||
app.include_router(paper_trading_router)
|
||||
app.include_router(live_trading_router)
|
||||
app.include_router(market_router)
|
||||
app.include_router(build_admin_router(templates))
|
||||
app.include_router(build_content_router(REPO_ROOT))
|
||||
|
||||
@ -184,6 +184,7 @@ a { color: inherit; text-decoration: none; }
|
||||
<a class="sidebar-link {% if active_nav == 'referral' %}active{% endif %}" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>邀请</a>
|
||||
<div class="sidebar-section-label admin-link" style="display:none">管理员菜单</div>
|
||||
<a class="sidebar-link admin-link {% if active_nav == 'paper_trading' %}active{% endif %}" href="/paper-trading" style="display:none"><svg class="link-icon"><use href="#svg-paper"/></svg>策略交易</a>
|
||||
<a class="sidebar-link admin-link {% if active_nav == 'live_trading' %}active{% endif %}" href="/live-trading" style="display:none"><svg class="link-icon"><use href="#svg-shield"/></svg>实盘控制台</a>
|
||||
<a class="sidebar-link admin-link {% if active_nav == 'review_center' %}active{% endif %}" href="/review-center" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>复盘中心</a>
|
||||
<a class="sidebar-link admin-link {% if active_nav in ['logs','pipeline','system_logs','chat_logs'] %}active{% endif %}" href="/logs" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>日志中心</a>
|
||||
<a class="sidebar-link admin-link {% if active_nav == 'llm_insights' %}active{% endif %}" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||
|
||||
136
static/live_trading.html
Normal file
136
static/live_trading.html
Normal file
File diff suppressed because one or more lines are too long
@ -77,6 +77,9 @@ _ID_TABLES = {
|
||||
"paper_orders",
|
||||
"paper_trades",
|
||||
"paper_trade_events",
|
||||
"live_trade_accounts",
|
||||
"live_order_intents",
|
||||
"live_order_events",
|
||||
"cron_run_log",
|
||||
"review_log",
|
||||
"missed_explosions",
|
||||
|
||||
362
tests/test_live_trading.py
Normal file
362
tests/test_live_trading.py
Normal file
@ -0,0 +1,362 @@
|
||||
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,
|
||||
list_live_accounts,
|
||||
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.live_trading_smoke import run_binance_testnet_smoke
|
||||
from app.web import web_server
|
||||
|
||||
|
||||
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 created.status_code == 200
|
||||
assert created.json()["account_code"] == "binance_sub_1"
|
||||
assert accounts.json()["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_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 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 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 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"
|
||||
@ -3,7 +3,7 @@ import json
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import config_loader
|
||||
from app.config.system_config import bootstrap_admin_config, email_config, notification_config, scheduler_config
|
||||
from app.config.system_config import bootstrap_admin_config, email_config, live_trading_config, notification_config, scheduler_config
|
||||
from app.db import auth_db
|
||||
from app.db.paper_trading import get_paper_trading_summary
|
||||
from app.db.runtime_config_db import delete_config, get_event_sources, set_config, set_event_driven_config, set_event_sources, set_strategy_meta
|
||||
@ -179,7 +179,7 @@ def test_runtime_config_api_can_manage_system_config():
|
||||
|
||||
|
||||
def test_runtime_config_api_seeds_all_system_defaults_when_listing():
|
||||
for key in ["llm", "onchain", "paper_trading", "price_streamer", "notification", "email", "bootstrap_admin", "scheduler"]:
|
||||
for key in ["llm", "onchain", "paper_trading", "live_trading", "price_streamer", "notification", "email", "bootstrap_admin", "scheduler"]:
|
||||
delete_config("system", key)
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
@ -187,7 +187,7 @@ def test_runtime_config_api_seeds_all_system_defaults_when_listing():
|
||||
|
||||
assert resp.status_code == 200
|
||||
keys = {x["config_key"] for x in resp.json()["items"]}
|
||||
for key in ["llm", "onchain", "paper_trading", "price_streamer", "notification", "email", "bootstrap_admin", "scheduler"]:
|
||||
for key in ["llm", "onchain", "paper_trading", "live_trading", "price_streamer", "notification", "email", "bootstrap_admin", "scheduler"]:
|
||||
assert key in keys
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user