From 89250046c39c03427878b846cb9aba9a3ce3e798 Mon Sep 17 00:00:00 2001
From: aaron <>
Date: Sat, 23 May 2026 12:22:48 +0800
Subject: [PATCH] 1
---
app/db/live_trading.py | 78 ++++++++++++++++++++++++++++++++++
app/web/routes_live_trading.py | 24 +++++++++++
static/live_trading.html | 8 ++--
tests/test_live_trading.py | 34 +++++++++++++++
4 files changed, 140 insertions(+), 4 deletions(-)
diff --git a/app/db/live_trading.py b/app/db/live_trading.py
index 3ef693c..14904cd 100644
--- a/app/db/live_trading.py
+++ b/app/db/live_trading.py
@@ -145,6 +145,84 @@ def upsert_live_account(
return _row(row)
+def update_live_account(
+ account_id: int,
+ *,
+ account_code: str = "",
+ exchange: str = "",
+ market_type: str = "",
+ testnet: bool | None = None,
+ status: str = "",
+ api_key_env: str = "",
+ api_secret_env: str = "",
+ permissions: dict | None = None,
+ risk_config: dict | None = None,
+) -> dict:
+ account_id = _safe_int(account_id)
+ if account_id <= 0:
+ return {"ok": False, "reason": "invalid_account_id"}
+
+ current = get_live_account(account_id)
+ if not current:
+ return {"ok": False, "reason": "account_not_found"}
+
+ now = _now()
+ account_code = account_code or str(current.get("account_code") or "")
+ exchange = exchange or str(current.get("exchange") or "binance")
+ market_type = market_type or str(current.get("market_type") or "um_futures")
+ if testnet is None:
+ testnet = bool(current.get("testnet", True))
+ status = status or str(current.get("status") or "disabled")
+ api_key_env = api_key_env or str(current.get("api_key_env") or "")
+ api_secret_env = api_secret_env or str(current.get("api_secret_env") or "")
+ permissions = permissions if isinstance(permissions, dict) else current.get("permissions", {})
+ risk_config = risk_config if isinstance(risk_config, dict) else current.get("risk_config", {})
+
+ conn = get_conn()
+ try:
+ row = conn.execute(
+ """
+ UPDATE live_trade_accounts
+ SET account_code=%s,
+ exchange=%s,
+ market_type=%s,
+ testnet=%s,
+ status=%s,
+ api_key_env=%s,
+ api_secret_env=%s,
+ permissions_json=%s,
+ risk_config_json=%s,
+ updated_at=%s
+ WHERE id=%s
+ RETURNING *
+ """,
+ (
+ account_code,
+ exchange,
+ market_type,
+ int(bool(testnet)),
+ status,
+ api_key_env,
+ api_secret_env,
+ _dumps(permissions),
+ _dumps(risk_config),
+ now,
+ account_id,
+ ),
+ ).fetchone()
+ conn.commit()
+ except Exception:
+ conn.rollback()
+ raise
+ finally:
+ conn.close()
+ if not row:
+ return {"ok": False, "reason": "account_not_found"}
+ item = _row(row)
+ item["ok"] = True
+ return item
+
+
def list_live_accounts() -> dict:
conn = get_conn()
try:
diff --git a/app/web/routes_live_trading.py b/app/web/routes_live_trading.py
index 64cbbf2..9b84f1c 100644
--- a/app/web/routes_live_trading.py
+++ b/app/web/routes_live_trading.py
@@ -9,6 +9,7 @@ from app.db.live_trading import (
list_live_order_events,
list_live_order_intents,
prepare_intent_from_paper_trade,
+ update_live_account,
upsert_live_account,
)
from app.services.live_trading_smoke import run_binance_testnet_smoke
@@ -55,6 +56,29 @@ async def api_live_trading_account(payload: dict = Body(default={}), altcoin_ses
)
+@router.put("/api/live-trading/accounts/{account_id}")
+async def api_live_trading_update_account(account_id: int, payload: dict = Body(default={}), altcoin_session: str = Cookie(default="")):
+ require_admin(altcoin_session)
+ try:
+ result = update_live_account(
+ account_id,
+ 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,
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=409, detail=f"account_update_failed: {exc}") from exc
+ if not result.get("ok"):
+ raise HTTPException(status_code=404, detail=result.get("reason", "account_not_found"))
+ return result
+
+
@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)
diff --git a/static/live_trading.html b/static/live_trading.html
index cd25e1c..bcbd76d 100644
--- a/static/live_trading.html
+++ b/static/live_trading.html
@@ -63,7 +63,7 @@
@@ -115,8 +115,8 @@ function showTab(id,btn){document.querySelectorAll('.tab').forEach(function(x){x
function card(k,v,cls,s){return '
'+esc(k)+''+esc(v)+''+esc(s||'')+'
'}
function renderAccounts(){var rows=state.accounts||[];if(!rows.length){$('accounts').innerHTML='
暂无账号配置
';return}$('accounts').innerHTML=rows.map(function(x){var r=x.risk_config||{},allowed=r.allowed_symbols||[];return '
'+esc(x.account_code)+''+badge(x.status)+'
'+esc(x.exchange)+' · '+esc(x.market_type)+' · '+esc(x.api_key_env||'--')+'
保证金 '+fmt(r.max_order_margin_usdt||0,2)+'U杠杆 '+fmt(r.max_symbol_leverage||1,2)+'x'+(allowed.length?'限制 '+allowed.length+' 个币':'全部币种')+'
'}).join('')}
function renderKpis(){var a=selectedAccountObj(),o=state.overview||{},risk=o.risk||{},b=(o.balance||{}).usdt||{},pos=o.positions||[];$('kpis').innerHTML=[card('当前账号',a.account_code||'未选择','blue',a.exchange?esc(a.exchange)+' · '+esc(a.market_type):'--'),card('USDT 总额',fmt(b.total,2),'','可用 '+fmt(b.free,2)+' / 占用 '+fmt(b.used,2)),card('持仓数量',pos.length,'','当前未平仓合约'),card('币种权限',risk.symbol_policy==='all'?'全部币种':'白名单 '+(risk.allowed_symbols||[]).length+' 个',risk.symbol_policy==='all'?'green':'blue','留空即不限制')].join('')}
-function fillAccountForm(id){var x=(state.accounts||[]).find(function(a){return Number(a.id)===Number(id)})||{},r=x.risk_config||{};$('accountCode').value=x.account_code||'';$('accountStatus').value=x.status||'disabled';$('accountExchange').value=x.exchange||'binance';$('accountMarket').value=x.market_type||'um_futures';$('apiKeyEnv').value=x.api_key_env||'ALPHAX_BINANCE_API_KEY';$('apiSecretEnv').value=x.api_secret_env||'ALPHAX_BINANCE_API_SECRET';$('maxOrderMargin').value=r.max_order_margin_usdt||10;$('maxSymbolLeverage').value=r.max_symbol_leverage||1;$('maxCumulativeLeverage').value=r.max_cumulative_leverage||1;$('allowedSymbols').value=(r.allowed_symbols||[]).join(',')}
-function resetForm(){state.selectedId=0;renderAccounts();fillAccountForm(0);document.querySelectorAll('.tab').forEach(function(x){x.classList.remove('active')});document.querySelectorAll('.tab-panel').forEach(function(x){x.classList.remove('active')});document.querySelector('.tab[onclick*="config"]').classList.add('active');$('configPane').classList.add('active')}
+function fillAccountForm(id){var x=(state.accounts||[]).find(function(a){return Number(a.id)===Number(id)})||{},r=x.risk_config||{};$('accountCode').value=x.account_code||'';$('accountStatus').value=x.status||'disabled';$('accountExchange').value=x.exchange||'binance';$('accountMarket').value=x.market_type||'um_futures';$('apiKeyEnv').value=x.api_key_env||'ALPHAX_BINANCE_API_KEY';$('apiSecretEnv').value=x.api_secret_env||'ALPHAX_BINANCE_API_SECRET';$('maxOrderMargin').value=r.max_order_margin_usdt||10;$('maxSymbolLeverage').value=r.max_symbol_leverage||1;$('maxCumulativeLeverage').value=r.max_cumulative_leverage||1;$('allowedSymbols').value=(r.allowed_symbols||[]).join(',');if($('saveAccountBtn'))$('saveAccountBtn').textContent=Number(id)>0?'保存修改':'新增账号'}
+function resetForm(){state.selectedId=0;state.overview=null;renderAccounts();fillAccountForm(0);renderKpis();renderOverview();document.querySelectorAll('.tab').forEach(function(x){x.classList.remove('active')});document.querySelectorAll('.tab-panel').forEach(function(x){x.classList.remove('active')});document.querySelector('.tab[onclick*="config"]').classList.add('active');$('configPane').classList.add('active')}
function info(k,v){return '
'+esc(k)+''+esc(v)+'
'}
function renderOverview(){var a=selectedAccountObj(),o=state.overview||{},risk=o.risk||{},errors=o.errors||[];$('accountInfo').innerHTML=[info('账号状态',a.status||'--'),info('API Key 变量',a.api_key_env||'--'),info('每单保证金上限',fmt(risk.max_order_margin_usdt,2)+' USDT'),info('单币杠杆上限',fmt(risk.max_symbol_leverage,2)+'x'),info('累计杠杆上限',fmt(risk.max_cumulative_leverage,2)+'x'),info('允许交易币种',risk.symbol_policy==='all'?'全部币种':(risk.allowed_symbols||[]).join(', '))].join('')+(errors.length?'
账户数据读取异常:'+esc(errors[0])+'
':'');renderPositions();renderOpenOrders();renderOrderHistory();renderEvents()}
function table(headers,rows,empty){if(!rows.length)return '
'+esc(empty||'暂无数据')+'
';return '
'+headers.map(function(h){return '| '+esc(h)+' | '}).join('')+'
'+rows.join('')+'
'}
@@ -126,7 +126,7 @@ function renderOrderHistory(){var orders=(state.overview?.order_history||[]).map
function renderEvents(){var rows=(state.events||[]).slice(0,20).map(function(e){return '
| '+time(e.event_time)+' | '+esc(e.event_type)+' | '+badge(e.status)+' | '+esc(e.message||'--')+' |
'});$('events').innerHTML=table(['时间','事件','状态','说明'],rows,'暂无维护日志')}
function renderAll(){if(!state.selectedId && state.accounts[0])state.selectedId=state.accounts[0].id;renderAccounts();fillAccountForm(state.selectedId);renderKpis();renderOverview()}
async function selectAccount(id){state.selectedId=Number(id);renderAccounts();fillAccountForm(id);await loadOverview()}
-async function saveAccount(){var allowed=String($('allowedSymbols').value||'').split(',').map(function(x){return x.trim().toUpperCase()}).filter(Boolean);var lev=Number($('maxSymbolLeverage').value||1),margin=Number($('maxOrderMargin').value||10);var body={account_code:$('accountCode').value,exchange:$('accountExchange').value,market_type:$('accountMarket').value,status:$('accountStatus').value,api_key_env:$('apiKeyEnv').value,api_secret_env:$('apiSecretEnv').value,testnet:true,permissions:{read:true,trade:true},risk_config:{sandbox_mode:'demo',max_order_margin_usdt:margin,max_order_notional_usdt:margin*Math.max(1,lev),max_symbol_leverage:lev,max_cumulative_leverage:Number($('maxCumulativeLeverage').value||1),allowed_symbols:allowed}};var saved=await (await fetch('/api/live-trading/accounts',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();state.selectedId=saved.id||state.selectedId;await loadAll()}
+async function saveAccount(){var allowed=String($('allowedSymbols').value||'').split(',').map(function(x){return x.trim().toUpperCase()}).filter(Boolean);var lev=Number($('maxSymbolLeverage').value||1),margin=Number($('maxOrderMargin').value||10);var body={account_code:$('accountCode').value,exchange:$('accountExchange').value,market_type:$('accountMarket').value,status:$('accountStatus').value,api_key_env:$('apiKeyEnv').value,api_secret_env:$('apiSecretEnv').value,testnet:true,permissions:{read:true,trade:true},risk_config:{sandbox_mode:'demo',max_order_margin_usdt:margin,max_order_notional_usdt:margin*Math.max(1,lev),max_symbol_leverage:lev,max_cumulative_leverage:Number($('maxCumulativeLeverage').value||1),allowed_symbols:allowed}};var editing=Number(state.selectedId)>0,url=editing?('/api/live-trading/accounts/'+state.selectedId):'/api/live-trading/accounts',method=editing?'PUT':'POST';var resp=await fetch(url,{method:method,headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});var saved=await resp.json().catch(function(){return {}});if(!resp.ok){alert((editing?'修改':'新增')+'账号失败:'+(saved.detail||'unknown_error'));return}state.selectedId=saved.id||state.selectedId;await loadAll()}
async function deleteAccount(){if(!state.selectedId){alert('请先选择要删除的账号');return}var acct=selectedAccountObj();if(!confirm('确认删除账号配置:'+(acct.account_code||state.selectedId)+'?\\n历史订单和调用日志会保留。'))return;var resp=await fetch('/api/live-trading/accounts/'+state.selectedId,{method:'DELETE'});var d=await resp.json().catch(function(){return {}});if(!resp.ok){alert('删除失败:'+(d.detail||'unknown_error'));return}state.selectedId=0;await loadAll()}
async function runSmoke(){if(!state.selectedId){alert('请先选择账号');return}var acct=selectedAccountObj(),risk=acct.risk_config||{},lev=Number(risk.max_symbol_leverage||1),margin=Number($('smokeMargin').value||risk.max_order_margin_usdt||10),notional=margin*Math.max(1,lev);var btn=$('smokeBtn');btn.disabled=true;btn.textContent='验收中...';try{var resp=await fetch('/api/live-trading/smoke/binance',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account_id:state.selectedId,symbol:$('smokeSymbol').value,notional_usdt:notional,leverage:lev})});var d=await resp.json();if(!resp.ok||d.detail){alert('接口验收失败:'+(d.detail||JSON.stringify(d).slice(0,220)))}await loadAll()}catch(e){alert('接口验收请求失败:'+e.message)}finally{btn.disabled=false;btn.textContent='开始验收'}}
async function loadOverview(){if(!state.selectedId){state.overview=null;renderAll();return}state.overview=await (await fetch('/api/live-trading/accounts/'+state.selectedId+'/overview?_ts='+Date.now(),{cache:'no-store'})).json();renderAll()}
diff --git a/tests/test_live_trading.py b/tests/test_live_trading.py
index ddaa690..ad03d53 100644
--- a/tests/test_live_trading.py
+++ b/tests/test_live_trading.py
@@ -65,6 +65,40 @@ def test_live_trading_admin_can_access_page_and_seed_account():
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",