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 @@
账号配置
API Key、杠杆、保证金和币种范围按账号独立保存
- +
@@ -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.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 ''}).join('')+''+rows.join('')+'
'+esc(h)+'
'} @@ -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",