diff --git a/app/analysis/reverse_analysis.py b/app/analysis/reverse_analysis.py index dcec666..3132b10 100644 --- a/app/analysis/reverse_analysis.py +++ b/app/analysis/reverse_analysis.py @@ -2,12 +2,12 @@ 逆向分析模块 — 从涨幅榜复盘,提取起爆前共性特征,发现新规律 核心逻辑: -1. 拉Binance 24h涨幅榜Top N(configurable) -2. 对未被推荐的暴涨币,回溯其起爆前K线 -3. 用full_pa_analysis()提取特征:连续K、起爆点、供需区(Q≥7)、静K蓄力、量价模式 -4. 检查板块联动(同板块是否有其他币也暴涨) -5. 统计共性特征占比,达到显著性阈值则add_learned_rule() -6. 返回结构化结果供feishu推送 +1. 拉Binance 24h涨幅榜Top N,把“已经涨起来的币”只作为事后标签。 +2. 对未被推荐的暴涨币,截掉最近24h起爆段,只回溯其起爆前/启动点K线。 +3. 用full_pa_analysis()提取涨前特征:连续K、起爆点、供需区(Q≥7)、静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, diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index 5d393b8..00b1534 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -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 diff --git a/app/services/live_trading_account.py b/app/services/live_trading_account.py index b60f1dc..c9bcc0a 100644 --- a/app/services/live_trading_account.py +++ b/app/services/live_trading_account.py @@ -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) diff --git a/app/web/routes_live_trading.py b/app/web/routes_live_trading.py index 9b84f1c..0053520 100644 --- a/app/web/routes_live_trading.py +++ b/app/web/routes_live_trading.py @@ -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") diff --git a/static/live_trading.html b/static/live_trading.html index bcbd76d..e2be172 100644 --- a/static/live_trading.html +++ b/static/live_trading.html @@ -37,7 +37,7 @@
-
资金与持仓
来自当前账号 API
+
资金与持仓
默认显示本地缓存,手动刷新才请求交易所
@@ -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 ''+esc(v||'--')+''} +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 '
'+esc(k)+''+esc(v)+''+esc(s||'')+'
'} @@ -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 '
'+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 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?'
账户数据读取异常:'+esc(errors[0])+'
':'')+(!cache.loaded&&a.status==='enabled'?'
为避免页面打开被 Binance API 拖慢,余额、持仓和订单默认不自动刷新。点击“刷新交易所数据”后读取。
':'');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)+'
'} -function renderPositions(){var rows=(state.overview?.positions||[]).map(function(x){return ''+esc(x.symbol)+''+badge(x.side)+''+fmt(x.contracts,6)+''+fmt(x.entry_price,6)+''+fmt(x.mark_price,6)+''+fmt(x.unrealized_pnl,4)+''+fmt(x.leverage,2)+'x'});$('positions').innerHTML=table(['币种','方向','数量','开仓价','标记价','未实现盈亏','杠杆'],rows,'当前账号暂无持仓')} -function renderOpenOrders(){var rows=(state.overview?.open_orders||[]).map(function(x){return ''+time(x.timestamp)+''+esc(x.symbol)+''+esc(x.type)+''+badge(x.side)+''+fmt(x.price,8)+''+fmt(x.amount,6)+''+badge(x.status)+''});$('openOrders').innerHTML=table(['时间','币种','类型','方向','价格','数量','状态'],rows,'当前账号暂无挂单')} -function renderOrderHistory(){var orders=(state.overview?.order_history||[]).map(function(x){return ''+time(x.timestamp)+''+esc(x.symbol)+''+esc(x.type)+''+badge(x.side)+''+fmt(x.average||x.price,8)+''+fmt(x.filled||x.amount,6)+''+badge(x.status)+''});$('orderHistory').innerHTML=table(['时间','币种','类型','方向','均价/价格','成交/数量','状态'],orders,'当前账号暂无订单历史')} +function renderPositions(){var rows=(state.overview?.positions||[]).map(function(x){var lev=Number(x.leverage||0);return ''+esc(x.symbol)+''+badge(x.side_label||sideText(x.side))+''+fmt(x.contracts,6)+''+fmt(x.position_value_usdt,2)+' U'+fmt(x.entry_price,6)+''+fmt(x.mark_price,6)+''+fmt(x.unrealized_pnl,4)+''+fmt(lev,2)+'x'});$('positions').innerHTML=table(['币种','方向','数量','仓位价值','开仓价','标记价','未实现盈亏','杠杆'],rows,'当前账号暂无持仓')} +function renderOpenOrders(){var rows=(state.overview?.open_orders||[]).map(function(x){return ''+time(x.timestamp)+''+esc(x.symbol)+''+esc(x.type)+''+badge(sideText(x.side))+''+fmt(x.price,8)+''+fmt(x.amount,6)+''+badge(x.status)+''});$('openOrders').innerHTML=table(['时间','币种','类型','方向','价格','数量','状态'],rows,'当前账号暂无挂单')} +function renderOrderHistory(){var orders=(state.overview?.order_history||[]).map(function(x){return ''+time(x.timestamp)+''+esc(x.symbol)+''+esc(x.type)+''+badge(sideText(x.side))+''+fmt(x.average||x.price,8)+''+fmt(x.filled||x.amount,6)+''+badge(x.status)+''});$('orderHistory').innerHTML=table(['时间','币种','类型','方向','均价/价格','成交/数量','状态'],orders,'当前账号暂无订单历史')} 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 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='
状态加载失败'+esc(e.message)+'
'}} loadAll(); diff --git a/tests/test_live_trading.py b/tests/test_live_trading.py index ad03d53..9ed02a9 100644 --- a/tests/test_live_trading.py +++ b/tests/test_live_trading.py @@ -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") diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py index 6fc3f7f..31ed5af 100644 --- a/tests/test_paper_trading.py +++ b/tests/test_paper_trading.py @@ -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()