1
This commit is contained in:
parent
fca8a961ba
commit
870b068a5a
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user