This commit is contained in:
aaron 2026-05-23 12:22:48 +08:00
parent 3a6aeedef0
commit 89250046c3
4 changed files with 140 additions and 4 deletions

View File

@ -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:

View File

@ -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)

View File

@ -63,7 +63,7 @@
<div><div class="panel-title">账号配置</div><div class="panel-note">API Key、杠杆、保证金和币种范围按账号独立保存</div></div>
<div class="actions">
<button class="btn danger" onclick="deleteAccount()">删除账号</button>
<button class="btn primary" onclick="saveAccount()">保存账号配置</button>
<button class="btn primary" id="saveAccountBtn" onclick="saveAccount()">保存账号配置</button>
</div>
</div>
<div class="panel-body">
@ -115,8 +115,8 @@ function showTab(id,btn){document.querySelectorAll('.tab').forEach(function(x){x
function card(k,v,cls,s){return '<div class="kpi"><span>'+esc(k)+'</span><b class="'+(cls||'')+'">'+esc(v)+'</b><small>'+esc(s||'')+'</small></div>'}
function renderAccounts(){var rows=state.accounts||[];if(!rows.length){$('accounts').innerHTML='<div class="empty">暂无账号配置</div>';return}$('accounts').innerHTML=rows.map(function(x){var r=x.risk_config||{},allowed=r.allowed_symbols||[];return '<div class="account '+(Number(x.id)===Number(state.selectedId)?'active':'')+'" onclick="selectAccount('+esc(x.id)+')"><div class="account-head"><b>'+esc(x.account_code)+'</b>'+badge(x.status)+'</div><p>'+esc(x.exchange)+' · '+esc(x.market_type)+' · '+esc(x.api_key_env||'--')+'</p><div class="meta"><span class="badge">保证金 '+fmt(r.max_order_margin_usdt||0,2)+'U</span><span class="badge">杠杆 '+fmt(r.max_symbol_leverage||1,2)+'x</span><span class="badge '+(allowed.length?'blue':'green')+'">'+(allowed.length?'限制 '+allowed.length+' 个币':'全部币种')+'</span></div></div>'}).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 '<div class="info"><span>'+esc(k)+'</span><b>'+esc(v)+'</b></div>'}
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?'<div class="note" style="grid-column:1/-1">账户数据读取异常:'+esc(errors[0])+'</div>':'');renderPositions();renderOpenOrders();renderOrderHistory();renderEvents()}
function table(headers,rows,empty){if(!rows.length)return '<div class="empty">'+esc(empty||'暂无数据')+'</div>';return '<table><thead><tr>'+headers.map(function(h){return '<th>'+esc(h)+'</th>'}).join('')+'</tr></thead><tbody>'+rows.join('')+'</tbody></table>'}
@ -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 '<tr><td>'+time(e.event_time)+'</td><td>'+esc(e.event_type)+'</td><td>'+badge(e.status)+'</td><td>'+esc(e.message||'--')+'</td></tr>'});$('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()}

View File

@ -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",