1
This commit is contained in:
parent
3a6aeedef0
commit
89250046c3
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user