This commit is contained in:
aaron 2026-05-24 10:34:00 +08:00
parent fca8a961ba
commit 870b068a5a
7 changed files with 219 additions and 32 deletions

View File

@ -2,12 +2,12 @@
逆向分析模块 从涨幅榜复盘提取起爆前共性特征发现新规律
核心逻辑
1. 拉Binance 24h涨幅榜Top Nconfigurable
2. 对未被推荐的暴涨币回溯其起爆前K线
3. 用full_pa_analysis()提取特征连续K起爆点供需区(Q7)静K蓄力量价模式
4. 检查板块联动同板块是否有其他币也暴涨
5. 统计共性特征占比达到显著性阈值则add_learned_rule()
6. 返回结构化结果供feishu推送
1. 拉Binance 24h涨幅榜Top N已经涨起来的币只作为事后标签
2. 对未被推荐的暴涨币截掉最近24h起爆段回溯其起爆前/启动点K线
3. 用full_pa_analysis()提取涨前特征连续K起爆点供需区(Q7)静K蓄力量价模式
4. 检查板块联动同板块是否有其他币也暴涨并用未大涨高成交额样本做对照组
5. 统计涨前共性特征占比和对照组lift只生成候选规则不能直接变成追涨买入依据
6. 返回结构化结果供feishu推送和策略迭代中心展示
"""
import json
@ -278,7 +278,7 @@ def check_sector_alignment(symbol, top_gainers, config):
def compute_pattern_summary(all_features, total_count, control_features=None):
"""
统计所有top gainer的共性特征占比
统计所有top gainer起爆前窗口的共性特征占比
返回: [{feature_name, count, percentage, description}]
"""
if total_count == 0:
@ -437,7 +437,7 @@ def discover_new_rules(pattern_summary, all_features, sector_alignments, signifi
confidence_score=round(min(95, pct * max(lift, 1.0) / 2), 1),
sample_size=int(total_analyzed),
status="candidate",
notes=f"逆向涨幅榜规律,做对照组校验(lift={lift}),仍需等待推荐样本验证后再发布",
notes=f"逆向涨幅榜规律,只分析起爆前窗口并做对照组校验(lift={lift}),用于提前埋伏候选,不能作为涨后追买依据,仍需等待推荐样本验证后再发布",
source_ref=f"reverse:{feat_key}",
)
new_rules.append(rule)
@ -465,7 +465,7 @@ def discover_new_rules(pattern_summary, all_features, sector_alignments, signifi
confidence_score=60,
sample_size=len(sector_alignments),
status="candidate",
notes="板块联动候选规律,需等待推荐样本验证后再发布",
notes="板块联动候选规律,用于提前识别市场情绪扩散,不能作为涨后追买依据,需等待推荐样本验证后再发布",
source_ref="reverse:multi_sector_hot",
)
new_rules.append(rule)
@ -478,9 +478,9 @@ def discover_new_rules(pattern_summary, all_features, sector_alignments, signifi
def run_reverse_analysis():
"""
执行完整逆向分析流程
1. 拉涨幅榜Top N
1. 拉涨幅榜Top N只把它作为哪些币后来涨了的标签
2. 过滤掉已推荐的币
3. 对每个暴涨币回溯起爆前K线做PA分析
3. 对每个暴涨币回溯起爆前K线截掉涨后窗口再做PA分析
4. 统计共性特征发现规律
5. 写入DB返回结构化结果
"""
@ -640,6 +640,13 @@ def run_reverse_analysis():
# 6. 返回结构化结果
results = {
"timestamp": datetime.now().isoformat(),
"analysis_scope": "pre_explosion_only",
"feature_window": {
"lookback_hours": lookback_hours,
"excluded_recent_hours": 24,
"label_usage": "top_gainers_are_outcome_labels_only",
"note": "涨幅榜只用于标记哪些币后来涨了;因子只从起爆前/启动点窗口提取,不能使用涨后结果做追买依据。",
},
"total_gainers": len(gainers),
"total_unrecommended": len(unrecommended_gainers),
"total_analyzed": total_analyzed,

View File

@ -816,7 +816,10 @@ def _paper_order_gate(rec: dict, current_price: float, config: dict | None = Non
tp1 = _safe_float(rec.get("tp1") or plan.get("tp1") or plan.get("take_profit_1"))
rr = _safe_float(plan.get("rr1") or plan.get("rr1_live"))
calc_rr = _paper_order_rr(side, target, stop_loss, tp1)
effective_rr = rr if rr > 0 else calc_rr
# Wait-pullback orders must be judged at the intended limit price, not at
# the stale confirmation price. A buy-now RR can be invalid while the
# pullback target is perfectly tradeable.
effective_rr = max(rr, calc_rr)
min_rr = max(0.0, _safe_float(cfg.get("order_min_rr"), 1.2))
min_rec_score = max(0.0, _safe_float(cfg.get("order_min_rec_score"), 20.0))
min_distance = max(0.0, _safe_float(cfg.get("order_min_distance_to_entry_pct"), 0.0))
@ -849,9 +852,12 @@ def _paper_order_gate(rec: dict, current_price: float, config: dict | None = Non
reasons.append("missing_tp1")
if target > 0 and stop_loss > 0 and tp1 > 0 and calc_rr <= 0:
reasons.append("invalid_risk_geometry")
if risk_reward_ok is False:
target_rr_confirms = calc_rr + 1e-9 >= min_rr
if risk_reward_ok is False and not target_rr_confirms:
reasons.append("risk_reward_rejected")
if bool(cfg.get("order_require_risk_reward_ok", True)) and risk_reward_ok is not True:
if bool(cfg.get("order_require_risk_reward_ok", True)) and risk_reward_ok is not True and not (
risk_reward_ok is False and target_rr_confirms
):
reasons.append("risk_reward_not_confirmed")
if rec_score < min_rec_score:
reasons.append("rec_score_below_min")
@ -1114,6 +1120,7 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time:
"paper_order_id": order_id,
"target_price": payload["target_price"],
"current_price": current_price,
"gate_detail": gate_detail,
}
_push_order_created_card(order, event_time)
return result

View File

@ -5,6 +5,8 @@ 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
_ACCOUNT_OVERVIEW_CACHE: dict[int, dict] = {}
def _compact_balance(balance: dict) -> dict:
total = balance.get("total") if isinstance(balance.get("total"), dict) else {}
@ -27,19 +29,54 @@ def _compact_balance(balance: dict) -> dict:
}
def _compact_position(item: dict) -> dict:
def _position_side_label(side: str) -> str:
side = str(side or "").strip().lower()
if side in {"long", "buy"}:
return ""
if side in {"short", "sell"}:
return ""
return "--"
def _compact_position(item: dict, account: dict | None = None) -> 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"))
entry_price = _safe_float(item.get("entryPrice") or info.get("entryPrice"))
mark_price = _safe_float(item.get("markPrice") or info.get("markPrice"))
position_value = abs(notional)
if position_value <= 0 and abs(contracts) > 0 and mark_price > 0:
position_value = abs(contracts) * mark_price
margin = _safe_float(
item.get("initialMargin")
or item.get("collateral")
or info.get("initialMargin")
or info.get("positionInitialMargin")
or info.get("isolatedWallet")
)
leverage = _safe_float(item.get("leverage") or info.get("leverage"))
leverage_source = "exchange"
if leverage <= 0 and position_value > 0 and margin > 0:
leverage = position_value / margin
leverage_source = "computed"
if leverage <= 0 and account:
risk = account.get("risk_config") if isinstance(account.get("risk_config"), dict) else {}
leverage = _safe_float(risk.get("max_symbol_leverage"), 0)
leverage_source = "account_config" if leverage > 0 else "missing"
side = item.get("side") or ("long" if contracts > 0 else ("short" if contracts < 0 else ""))
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")),
"side": side,
"side_label": _position_side_label(side),
"contracts": abs(contracts),
"entry_price": entry_price,
"mark_price": mark_price,
"notional": notional,
"position_value_usdt": position_value,
"margin_usdt": margin,
"unrealized_pnl": _safe_float(item.get("unrealizedPnl") or info.get("unrealizedProfit")),
"leverage": _safe_float(item.get("leverage") or info.get("leverage")),
"leverage": leverage,
"leverage_source": leverage_source,
}
@ -76,7 +113,21 @@ def _account_risk_view(account: dict) -> dict:
}
def get_live_account_overview(account_id: int, *, history_limit: int = 30) -> dict:
def _cache_overview(account_id: int, overview: dict) -> dict:
_ACCOUNT_OVERVIEW_CACHE[int(account_id)] = overview
return overview
def _cached_overview(account_id: int) -> dict | None:
item = _ACCOUNT_OVERVIEW_CACHE.get(int(account_id))
if not item:
return None
cached = dict(item)
cached["exchange_cache"] = {**(cached.get("exchange_cache") or {}), "cached": True}
return cached
def get_live_account_overview(account_id: int, *, history_limit: int = 30, refresh: bool = False, client_factory=None) -> dict:
account = get_live_account(account_id)
if not account:
raise LiveTradingConfigError("live account not found")
@ -89,12 +140,23 @@ def get_live_account_overview(account_id: int, *, history_limit: int = 30) -> di
"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", []),
"exchange_cache": {"cached": False, "loaded": False, "requires_refresh": True},
"errors": [],
}
if account.get("status") != "enabled":
return overview
if not refresh:
cached = _cached_overview(account_id)
if cached:
cached["account"] = account
cached["risk"] = overview["risk"]
cached["intent_history"] = overview["intent_history"]
cached["events"] = overview["events"]
return cached
overview["exchange_cache"]["reason"] = "点击刷新交易所数据后读取余额、持仓和订单"
return overview
try:
client = build_binance_client(account, require_testnet=True)
client = client_factory(account) if client_factory else build_binance_client(account, require_testnet=True)
client.load_markets()
except Exception as exc:
overview["errors"].append(f"账户连接失败:{exc}")
@ -105,7 +167,7 @@ def get_live_account_overview(account_id: int, *, history_limit: int = 30) -> di
overview["errors"].append(f"余额读取失败:{exc}")
try:
overview["positions"] = [
item for item in (_compact_position(p) for p in client.fetch_positions(None))
item for item in (_compact_position(p, account) for p in client.fetch_positions(None))
if abs(_safe_float(item.get("contracts"))) > 0
]
except Exception as exc:
@ -118,4 +180,5 @@ def get_live_account_overview(account_id: int, *, history_limit: int = 30) -> di
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
overview["exchange_cache"] = {"cached": False, "loaded": True, "requires_refresh": False}
return _cache_overview(account_id, overview)

View File

@ -35,9 +35,9 @@ async def api_live_trading_accounts(altcoin_session: str = Cookie(default="")):
@router.get("/api/live-trading/accounts/{account_id}/overview")
async def api_live_trading_account_overview(account_id: int, altcoin_session: str = Cookie(default="")):
async def api_live_trading_account_overview(account_id: int, refresh: int = 0, altcoin_session: str = Cookie(default="")):
require_admin(altcoin_session)
return get_live_account_overview(account_id)
return get_live_account_overview(account_id, refresh=bool(refresh))
@router.post("/api/live-trading/accounts")

View File

@ -37,7 +37,7 @@
<section class="tab-panel active" id="overviewPane">
<section class="panel">
<div class="panel-head"><div><div class="panel-title">资金与持仓</div><div class="panel-note">来自当前账号 API</div></div></div>
<div class="panel-head"><div><div class="panel-title">资金与持仓</div><div class="panel-note" id="exchangeCacheNote">默认显示本地缓存,手动刷新才请求交易所</div></div><button class="btn" id="refreshExchangeBtn" onclick="refreshExchangeData()">刷新交易所数据</button></div>
<div class="panel-body">
<div class="grid" id="accountInfo"></div>
<div style="height:12px"></div>
@ -110,6 +110,7 @@ function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){retur
function fmt(n,d){n=Number(n||0);return n.toLocaleString(undefined,{maximumFractionDigits:d==null?4:d})}
function time(s){return esc(String(s||'').replace('T',' ').slice(0,19))}
function badge(v){var cls=v==='enabled'||v==='ok'||v==='closed'?'green':(v==='disabled'||v==='error'?'red':(v==='open'||v==='exchange_api'?'blue':'warn'));return '<span class="badge '+cls+'">'+esc(v||'--')+'</span>'}
function sideText(v){v=String(v||'').toLowerCase();if(v==='long'||v==='buy')return '多';if(v==='short'||v==='sell')return '空';return v||'--'}
function selectedAccountObj(){return (state.accounts||[]).find(function(x){return Number(x.id)===Number(state.selectedId)})||{}}
function showTab(id,btn){document.querySelectorAll('.tab').forEach(function(x){x.classList.remove('active')});document.querySelectorAll('.tab-panel').forEach(function(x){x.classList.remove('active')});btn.classList.add('active');$(id+'Pane').classList.add('active')}
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>'}
@ -118,18 +119,19 @@ function renderKpis(){var a=selectedAccountObj(),o=state.overview||{},risk=o.ris
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 renderOverview(){var a=selectedAccountObj(),o=state.overview||{},risk=o.risk||{},errors=o.errors||[],cache=o.exchange_cache||{};if($('exchangeCacheNote'))$('exchangeCacheNote').textContent=cache.loaded?(cache.cached?'交易所数据来自缓存':'交易所数据已刷新'):(cache.reason||'默认显示本地缓存,手动刷新才请求交易所');$('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>':'')+(!cache.loaded&&a.status==='enabled'?'<div class="note" style="grid-column:1/-1">为避免页面打开被 Binance API 拖慢,余额、持仓和订单默认不自动刷新。点击“刷新交易所数据”后读取。</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>'}
function renderPositions(){var rows=(state.overview?.positions||[]).map(function(x){return '<tr><td>'+esc(x.symbol)+'</td><td>'+badge(x.side)+'</td><td>'+fmt(x.contracts,6)+'</td><td>'+fmt(x.entry_price,6)+'</td><td>'+fmt(x.mark_price,6)+'</td><td>'+fmt(x.unrealized_pnl,4)+'</td><td>'+fmt(x.leverage,2)+'x</td></tr>'});$('positions').innerHTML=table(['币种','方向','数量','开仓价','标记价','未实现盈亏','杠杆'],rows,'当前账号暂无持仓')}
function renderOpenOrders(){var rows=(state.overview?.open_orders||[]).map(function(x){return '<tr><td>'+time(x.timestamp)+'</td><td>'+esc(x.symbol)+'</td><td>'+esc(x.type)+'</td><td>'+badge(x.side)+'</td><td>'+fmt(x.price,8)+'</td><td>'+fmt(x.amount,6)+'</td><td>'+badge(x.status)+'</td></tr>'});$('openOrders').innerHTML=table(['时间','币种','类型','方向','价格','数量','状态'],rows,'当前账号暂无挂单')}
function renderOrderHistory(){var orders=(state.overview?.order_history||[]).map(function(x){return '<tr><td>'+time(x.timestamp)+'</td><td>'+esc(x.symbol)+'</td><td>'+esc(x.type)+'</td><td>'+badge(x.side)+'</td><td>'+fmt(x.average||x.price,8)+'</td><td>'+fmt(x.filled||x.amount,6)+'</td><td>'+badge(x.status)+'</td></tr>'});$('orderHistory').innerHTML=table(['时间','币种','类型','方向','均价/价格','成交/数量','状态'],orders,'当前账号暂无订单历史')}
function renderPositions(){var rows=(state.overview?.positions||[]).map(function(x){var lev=Number(x.leverage||0);return '<tr><td>'+esc(x.symbol)+'</td><td>'+badge(x.side_label||sideText(x.side))+'</td><td>'+fmt(x.contracts,6)+'</td><td>'+fmt(x.position_value_usdt,2)+' U</td><td>'+fmt(x.entry_price,6)+'</td><td>'+fmt(x.mark_price,6)+'</td><td>'+fmt(x.unrealized_pnl,4)+'</td><td>'+fmt(lev,2)+'x</td></tr>'});$('positions').innerHTML=table(['币种','方向','数量','仓位价值','开仓价','标记价','未实现盈亏','杠杆'],rows,'当前账号暂无持仓')}
function renderOpenOrders(){var rows=(state.overview?.open_orders||[]).map(function(x){return '<tr><td>'+time(x.timestamp)+'</td><td>'+esc(x.symbol)+'</td><td>'+esc(x.type)+'</td><td>'+badge(sideText(x.side))+'</td><td>'+fmt(x.price,8)+'</td><td>'+fmt(x.amount,6)+'</td><td>'+badge(x.status)+'</td></tr>'});$('openOrders').innerHTML=table(['时间','币种','类型','方向','价格','数量','状态'],rows,'当前账号暂无挂单')}
function renderOrderHistory(){var orders=(state.overview?.order_history||[]).map(function(x){return '<tr><td>'+time(x.timestamp)+'</td><td>'+esc(x.symbol)+'</td><td>'+esc(x.type)+'</td><td>'+badge(sideText(x.side))+'</td><td>'+fmt(x.average||x.price,8)+'</td><td>'+fmt(x.filled||x.amount,6)+'</td><td>'+badge(x.status)+'</td></tr>'});$('orderHistory').innerHTML=table(['时间','币种','类型','方向','均价/价格','成交/数量','状态'],orders,'当前账号暂无订单历史')}
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 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()}
async function loadOverview(refresh){if(!state.selectedId){state.overview=null;renderAll();return}state.overview=await (await fetch('/api/live-trading/accounts/'+state.selectedId+'/overview?refresh='+(refresh?1:0)+'&_ts='+Date.now(),{cache:'no-store'})).json();renderAll()}
async function refreshExchangeData(){if(!state.selectedId){alert('请先选择账号');return}var btn=$('refreshExchangeBtn');if(btn){btn.disabled=true;btn.textContent='刷新中...'}try{await loadOverview(true)}finally{if(btn){btn.disabled=false;btn.textContent='刷新交易所数据'}}}
async function loadAll(){try{var s=await (await fetch('/api/live-trading/summary?_ts='+Date.now(),{cache:'no-store'})).json();var a=await (await fetch('/api/live-trading/accounts?_ts='+Date.now(),{cache:'no-store'})).json();var e=await (await fetch('/api/live-trading/events?limit=100&_ts='+Date.now(),{cache:'no-store'})).json();state.summary=s;state.accounts=a.items||[];state.events=e.items||[];if(!state.selectedId&&state.accounts[0])state.selectedId=state.accounts[0].id;await loadOverview()}catch(e){$('kpis').innerHTML='<div class="kpi"><span>状态</span><b>加载失败</b><small>'+esc(e.message)+'</small></div>'}}
loadAll();
</script>

View File

@ -12,6 +12,7 @@ from app.db.live_trading import (
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 import live_trading_account
from app.services.live_trading_smoke import run_binance_testnet_smoke
from app.services.live_trading_sync import sync_paper_trade_to_live
from app.web import web_server
@ -114,6 +115,64 @@ def test_live_account_overview_returns_disabled_account_without_exchange_call():
assert overview["positions"] == []
def test_live_account_overview_does_not_hit_exchange_without_refresh(monkeypatch):
account = upsert_live_account(
account_code="binance_overview_fast",
status="enabled",
risk_config={"max_order_margin_usdt": 10, "max_symbol_leverage": 2, "allowed_symbols": []},
)
def fail_build(*args, **kwargs):
raise AssertionError("exchange should not be called without refresh")
monkeypatch.setattr(live_trading_account, "build_binance_client", fail_build)
overview = get_live_account_overview(account["id"], refresh=False)
assert overview["exchange_cache"]["requires_refresh"] is True
assert overview["balance"]["usdt"]["total"] == 0
assert overview["positions"] == []
def test_live_account_overview_refresh_compacts_position_value_side_and_leverage():
account = upsert_live_account(
account_code="binance_overview_refresh",
status="enabled",
risk_config={"max_order_margin_usdt": 10, "max_symbol_leverage": 3, "allowed_symbols": []},
)
class Client:
def load_markets(self):
return {}
def fetch_balance(self):
return {"total": {"USDT": 1000}, "free": {"USDT": 900}, "used": {"USDT": 100}}
def fetch_positions(self, symbols=None):
return [{
"symbol": "BTC/USDT",
"contracts": 0.02,
"entryPrice": 75000,
"markPrice": 76000,
"unrealizedPnl": 20,
"info": {"positionAmt": "0.02"},
}]
def fetch_open_orders(self, symbol=None):
return []
def fetch_orders(self, symbol=None, limit=30):
return []
overview = get_live_account_overview(account["id"], refresh=True, client_factory=lambda account: Client())
pos = overview["positions"][0]
assert overview["exchange_cache"]["loaded"] is True
assert pos["side_label"] == ""
assert pos["position_value_usdt"] == 1520
assert pos["leverage"] == 3
assert pos["leverage_source"] == "account_config"
def test_live_account_can_be_deleted_without_deleting_history_contract():
account = upsert_live_account(account_code="binance_delete_me", status="disabled")

View File

@ -303,6 +303,55 @@ def test_wait_pullback_requires_confirmed_risk_reward(monkeypatch):
assert list_paper_orders()["total"] == 0
def test_wait_pullback_recalculates_rr_at_target_price(monkeypatch):
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "100")
monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0")
monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0")
altcoin_db.init_db()
rec_id = altcoin_db.create_recommendation(
symbol="TARGETRR/USDT",
rec_state="蓄力",
rec_score=24,
entry_price=95,
stop_loss=90,
tp1=105,
signals=["等待回踩"],
entry_plan={
"entry_action": "等回踩",
"entry_price": 95,
"stop_loss": 90,
"tp1": 105,
"risk_reward_ok": False,
"rr1": 0.6,
},
)
rec = {
"id": rec_id,
"symbol": "TARGETRR/USDT",
"execution_status": "wait_pullback",
"action_status": "等回踩",
"entry_price": 95,
"stop_loss": 90,
"tp1": 105,
"entry_plan": {
"entry_action": "等回踩",
"entry_price": 95,
"stop_loss": 90,
"tp1": 105,
"risk_reward_ok": False,
"rr1": 0.6,
},
}
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
assert result["reason"] == "paper_order_created"
assert result["gate_detail"]["rr1"] == pytest.approx(2.0)
assert result["gate_detail"]["calc_rr1"] == pytest.approx(2.0)
assert list_paper_orders()["total"] == 1
def test_wait_pullback_too_far_from_entry_does_not_create_order(monkeypatch):
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
altcoin_db.init_db()