diff --git a/.env.example b/.env.example index a2293ed..af164e7 100644 --- a/.env.example +++ b/.env.example @@ -80,7 +80,8 @@ ALPHAX_PAPER_ORDER_GATE_ENABLED=1 ALPHAX_PAPER_MAX_CUMULATIVE_LEVERAGE=5 ALPHAX_PAPER_ENTRY_GATE_ENABLED=1 ALPHAX_PAPER_ENTRY_MIN_REC_SCORE=50 -ALPHAX_PAPER_ENTRY_MIN_RR=1.8 +ALPHAX_PAPER_MIN_RR=1.5 +ALPHAX_PAPER_ENTRY_MIN_RR=1.5 ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT=20 ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT=3 ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES=3 @@ -97,9 +98,9 @@ ALPHAX_PAPER_GLOBAL_RISK_MAX_OPEN_POSITIONS=0 ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_SECTOR_POSITIONS=3 ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_DIRECTION_POSITIONS=6 ALPHAX_PAPER_ORDER_MIN_REC_SCORE=50 -ALPHAX_PAPER_ORDER_MIN_RR=1.8 +ALPHAX_PAPER_ORDER_MIN_RR=1.5 ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK=1 -ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT=1.5 +ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT=0 ALPHAX_PAPER_ORDER_MAX_DISTANCE_TO_ENTRY_PCT=8 ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER=0 ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT=12 diff --git a/app/config/system_config.py b/app/config/system_config.py index 8daf3b8..f5a9377 100644 --- a/app/config/system_config.py +++ b/app/config/system_config.py @@ -150,6 +150,10 @@ def _onchain_env_overrides(default_chains=("ethereum", "bsc")): def default_paper_trading_config(): + # One shared default keeps buy-now entries and wait-pullback orders from + # drifting into two unrelated RR standards. The explicit entry/order envs + # remain supported for advanced overrides. + paper_min_rr = _env_float("ALPHAX_PAPER_MIN_RR", 1.5) return { "enabled": _env_bool("ALPHAX_PAPER_TRADING_ENABLED", True), "account_equity_usdt": _env_float("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", 20000), @@ -174,7 +178,8 @@ def default_paper_trading_config(): "order_gate_enabled": _env_bool("ALPHAX_PAPER_ORDER_GATE_ENABLED", True), "entry_gate_enabled": _env_bool("ALPHAX_PAPER_ENTRY_GATE_ENABLED", True), "entry_min_rec_score": _env_float("ALPHAX_PAPER_ENTRY_MIN_REC_SCORE", 50.0), - "entry_min_rr": _env_float("ALPHAX_PAPER_ENTRY_MIN_RR", 1.8), + "min_rr": paper_min_rr, + "entry_min_rr": _env_float("ALPHAX_PAPER_ENTRY_MIN_RR", paper_min_rr), "max_stop_loss_leverage_risk_pct": _env_float("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", 20.0), "max_account_drawdown_pause_pct": _env_float("ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT", 3.0), "pause_after_weak_entries": _env_int("ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES", 3), @@ -191,7 +196,7 @@ def default_paper_trading_config(): "global_risk_max_same_sector_positions": _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_SECTOR_POSITIONS", 3), "global_risk_max_same_direction_positions": _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_DIRECTION_POSITIONS", 6), "order_min_rec_score": _env_float("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", 50.0), - "order_min_rr": _env_float("ALPHAX_PAPER_ORDER_MIN_RR", 1.8), + "order_min_rr": _env_float("ALPHAX_PAPER_ORDER_MIN_RR", paper_min_rr), "order_require_risk_reward_ok": _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK", True), "order_min_distance_to_entry_pct": _env_float("ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT", 1.5), "order_max_distance_to_entry_pct": _env_float("ALPHAX_PAPER_ORDER_MAX_DISTANCE_TO_ENTRY_PCT", 8.0), @@ -206,6 +211,58 @@ def default_paper_trading_config(): } +def _paper_trading_env_overrides(): + """Honor explicit paper-trading env vars even when DB runtime config exists.""" + overrides = {} + if _env_present("ALPHAX_PAPER_MIN_RR"): + shared_min_rr = _env_float("ALPHAX_PAPER_MIN_RR", 1.5) + overrides.update({ + "min_rr": shared_min_rr, + "entry_min_rr": shared_min_rr, + "order_min_rr": shared_min_rr, + }) + checks = { + "ALPHAX_PAPER_TRADING_ENABLED": ("enabled", lambda: _env_bool("ALPHAX_PAPER_TRADING_ENABLED", True)), + "ALPHAX_PAPER_ACCOUNT_EQUITY_USDT": ("account_equity_usdt", lambda: _env_float("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", 20000)), + "ALPHAX_PAPER_TRADE_NOTIONAL_USDT": ("trade_notional_usdt", lambda: _env_float("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", 5000)), + "ALPHAX_PAPER_TRADE_LEVERAGE": ("trade_leverage", lambda: _env_float("ALPHAX_PAPER_TRADE_LEVERAGE", 5)), + "ALPHAX_PAPER_MAX_CUMULATIVE_LEVERAGE": ("max_cumulative_leverage", lambda: _env_float("ALPHAX_PAPER_MAX_CUMULATIVE_LEVERAGE", 5.0)), + "ALPHAX_PAPER_TRADE_FEE_RATE": ("fee_rate", lambda: _env_float("ALPHAX_PAPER_TRADE_FEE_RATE", 0.001)), + "ALPHAX_PAPER_TRADE_SLIPPAGE_PCT": ("slippage_pct", lambda: _env_float("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", 0.05)), + "ALPHAX_PAPER_ORDER_GATE_ENABLED": ("order_gate_enabled", lambda: _env_bool("ALPHAX_PAPER_ORDER_GATE_ENABLED", True)), + "ALPHAX_PAPER_ENTRY_GATE_ENABLED": ("entry_gate_enabled", lambda: _env_bool("ALPHAX_PAPER_ENTRY_GATE_ENABLED", True)), + "ALPHAX_PAPER_ENTRY_MIN_REC_SCORE": ("entry_min_rec_score", lambda: _env_float("ALPHAX_PAPER_ENTRY_MIN_REC_SCORE", 50.0)), + "ALPHAX_PAPER_ENTRY_MIN_RR": ("entry_min_rr", lambda: _env_float("ALPHAX_PAPER_ENTRY_MIN_RR", overrides.get("min_rr", 1.5))), + "ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT": ("max_stop_loss_leverage_risk_pct", lambda: _env_float("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", 20.0)), + "ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT": ("max_account_drawdown_pause_pct", lambda: _env_float("ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT", 3.0)), + "ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES": ("pause_after_weak_entries", lambda: _env_int("ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES", 3)), + "ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS": ("weak_entry_window_hours", lambda: _env_float("ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS", 6.0)), + "ALPHAX_PAPER_WEAK_ENTRY_MIN_MAX_PNL_PCT": ("weak_entry_min_max_pnl_pct", lambda: _env_float("ALPHAX_PAPER_WEAK_ENTRY_MIN_MAX_PNL_PCT", 1.0)), + "ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED": ("global_risk_gate_enabled", lambda: _env_bool("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", True)), + "ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL": ("global_risk_block_critical", lambda: _env_bool("ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL", False)), + "ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_MIN_REC_SCORE": ("global_risk_critical_min_rec_score", lambda: _env_float("ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_MIN_REC_SCORE", 80.0)), + "ALPHAX_PAPER_GLOBAL_RISK_MIN_POSITION_MULTIPLIER": ("global_risk_min_position_multiplier", lambda: _env_float("ALPHAX_PAPER_GLOBAL_RISK_MIN_POSITION_MULTIPLIER", 0.2)), + "ALPHAX_PAPER_GLOBAL_RISK_HIGH_MIN_REC_SCORE": ("global_risk_high_min_rec_score", lambda: _env_float("ALPHAX_PAPER_GLOBAL_RISK_HIGH_MIN_REC_SCORE", 70.0)), + "ALPHAX_PAPER_GLOBAL_RISK_HIGH_DRAWDOWN_PCT": ("global_risk_high_drawdown_pct", lambda: _env_float("ALPHAX_PAPER_GLOBAL_RISK_HIGH_DRAWDOWN_PCT", 3.0)), + "ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_DRAWDOWN_PCT": ("global_risk_critical_drawdown_pct", lambda: _env_float("ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_DRAWDOWN_PCT", 6.0)), + "ALPHAX_PAPER_GLOBAL_RISK_MAX_OPEN_POSITIONS": ("global_risk_max_open_positions", lambda: _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_OPEN_POSITIONS", 0)), + "ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_SECTOR_POSITIONS": ("global_risk_max_same_sector_positions", lambda: _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_SECTOR_POSITIONS", 3)), + "ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_DIRECTION_POSITIONS": ("global_risk_max_same_direction_positions", lambda: _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_DIRECTION_POSITIONS", 6)), + "ALPHAX_PAPER_ORDER_MIN_REC_SCORE": ("order_min_rec_score", lambda: _env_float("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", 50.0)), + "ALPHAX_PAPER_ORDER_MIN_RR": ("order_min_rr", lambda: _env_float("ALPHAX_PAPER_ORDER_MIN_RR", overrides.get("min_rr", 1.5))), + "ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK": ("order_require_risk_reward_ok", lambda: _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK", True)), + "ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT": ("order_min_distance_to_entry_pct", lambda: _env_float("ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT", 0.0)), + "ALPHAX_PAPER_ORDER_MAX_DISTANCE_TO_ENTRY_PCT": ("order_max_distance_to_entry_pct", lambda: _env_float("ALPHAX_PAPER_ORDER_MAX_DISTANCE_TO_ENTRY_PCT", 8.0)), + "ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER": ("order_require_current_trigger", lambda: _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER", False)), + "ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT": ("order_cancel_far_from_entry_pct", lambda: _env_float("ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT", 12.0)), + "ALPHAX_PAPER_ORDER_EXPIRE_HOURS": ("order_expire_hours", lambda: _env_float("ALPHAX_PAPER_ORDER_EXPIRE_HOURS", 24.0)), + } + for env_name, (key, loader) in checks.items(): + if _env_present(env_name): + overrides[key] = loader() + return overrides + + def default_live_trading_config(): return { "enabled": _env_bool("ALPHAX_LIVE_TRADING_ENABLED", False), @@ -457,7 +514,8 @@ def paper_trading_config(): if cfg is None: _seed_one("paper_trading", default_paper_trading_config(), "Paper trading account and execution model") cfg = get_paper_trading_config(default=None) - return deep_merge(default_paper_trading_config(), cfg or {}) + merged = deep_merge(default_paper_trading_config(), cfg or {}) + return deep_merge(merged, _paper_trading_env_overrides()) def live_trading_config(): diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index 48830a4..4b68af8 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -664,14 +664,20 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config: notional = default_notional_usdt(cfg) side = str(plan.get("side") or rec.get("side") or "long").strip().lower() or "long" leverage = default_leverage(cfg) - stop_loss = _safe_float(rec.get("stop_loss") or plan.get("stop_loss")) - tp1 = _safe_float(rec.get("tp1") or plan.get("tp1") or plan.get("take_profit_1")) + stop_loss = _safe_float(plan.get("stop_loss") or rec.get("stop_loss")) + tp1 = _safe_float(plan.get("tp1") or plan.get("take_profit_1") or rec.get("tp1")) rec_score = _safe_float(rec.get("rec_score") or rec.get("score")) if rec_score <= 0 and rec_id > 0: row = conn.execute("SELECT rec_score FROM recommendation WHERE id=%s", (rec_id,)).fetchone() rec_score = _safe_float(row["rec_score"] if row else 0) if bool(cfg.get("entry_gate_enabled", True)): - rr = _safe_float(plan.get("rr1") or plan.get("rr1_live")) or _trade_rr(side, entry_price, stop_loss, tp1) + calc_rr = _trade_rr(side, entry_price, stop_loss, tp1) + rr_candidates = [ + _safe_float(plan.get("rr1")), + _safe_float(plan.get("rr1_live")), + calc_rr, + ] + rr = max([x for x in rr_candidates if x > 0], default=0.0) min_rr = max(0.0, _safe_float(cfg.get("entry_min_rr"), 0)) min_score = max(0.0, _safe_float(cfg.get("entry_min_rec_score"), 0)) sl_risk = _stop_loss_leverage_risk_pct(side, entry_price, stop_loss, leverage) @@ -904,8 +910,8 @@ def _paper_order_gate(rec: dict, current_price: float, config: dict | None = Non plan = _entry_plan(rec) side = str(plan.get("side") or rec.get("side") or "long").strip().lower() or "long" target = _paper_order_target_price(rec) - stop_loss = _safe_float(rec.get("stop_loss") or plan.get("stop_loss")) - tp1 = _safe_float(rec.get("tp1") or plan.get("tp1") or plan.get("take_profit_1")) + stop_loss = _safe_float(plan.get("stop_loss") or rec.get("stop_loss")) + tp1 = _safe_float(plan.get("tp1") or plan.get("take_profit_1") or rec.get("tp1")) rr = _safe_float(plan.get("rr1") or plan.get("rr1_live")) calc_rr = _paper_order_rr(side, target, stop_loss, tp1) # Wait-pullback orders must be judged at the intended limit price, not at @@ -1043,9 +1049,9 @@ def _order_payload_from_rec(rec: dict, current_price: float, event_time: str, co "target_price": _paper_order_target_price(rec), "current_price_at_create": current_price, "notional_usdt": default_notional_usdt(cfg), - "stop_loss": _safe_float(rec.get("stop_loss") or plan.get("stop_loss")), - "tp1": _safe_float(rec.get("tp1") or plan.get("tp1") or plan.get("take_profit_1")), - "tp2": _safe_float(rec.get("tp2") or plan.get("tp2") or plan.get("take_profit_2")), + "stop_loss": _safe_float(plan.get("stop_loss") or rec.get("stop_loss")), + "tp1": _safe_float(plan.get("tp1") or plan.get("take_profit_1") or rec.get("tp1")), + "tp2": _safe_float(plan.get("tp2") or plan.get("take_profit_2") or rec.get("tp2")), "strategy_version": str(rec.get("strategy_version") or ""), "strategy_code": lineage["strategy_code"], "strategy_name": lineage["strategy_name"], diff --git a/app/db/recommendation_state.py b/app/db/recommendation_state.py index 91f0b60..22e992d 100644 --- a/app/db/recommendation_state.py +++ b/app/db/recommendation_state.py @@ -340,7 +340,7 @@ def derive_execution_fields(item): if item.get("latest_cache_updated_at"): item["current_price_updated_at"] = item.get("latest_cache_updated_at") entry_window = entry_window_policy( - item.get("entry_price") or entry_plan.get("entry_price") or 0, + entry_plan.get("entry_price") or item.get("entry_price") or 0, current_price_for_window, item.get("rec_time") or "", ) if action_status == "可即刻买入" else {} @@ -399,9 +399,9 @@ def derive_execution_fields(item): if entry_window and entry_window.get("status") != "active": item["entry_window_alert"] = entry_window item["risk_suggestion"] = risk_suggestion( - item.get("entry_price") or entry_plan.get("entry_price") or 0, - item.get("stop_loss") or entry_plan.get("stop_loss") or 0, - item.get("tp1") or entry_plan.get("tp1") or entry_plan.get("take_profit_1") or 0, + entry_plan.get("entry_price") or item.get("entry_price") or 0, + entry_plan.get("stop_loss") or item.get("stop_loss") or 0, + entry_plan.get("tp1") or entry_plan.get("take_profit_1") or item.get("tp1") or 0, ) item["market_context"] = market_context item["derivatives_context"] = derivatives_context diff --git a/static/opportunity_detail.html b/static/opportunity_detail.html index d1866bf..b662861 100644 --- a/static/opportunity_detail.html +++ b/static/opportunity_detail.html @@ -34,7 +34,7 @@ function decisionLog(r){var ep=r.entry_plan||{},mc=r.market_context||{};return e function signals(r){return Array.isArray(r.signal_labels)&&r.signal_labels.length?r.signal_labels:(Array.isArray(r.signals)?r.signals:[])} function aiInsight(r){return r.llm_insight&&r.llm_insight.content?r.llm_insight.content:null} function renderRows(rows,opts){opts=opts||{};if(!rows||!rows.length)return'
'+(opts.empty||'暂无数据')+'
';return '
'+rows.map(function(x){return '
'+esc(opts.time?opts.time(x):'--')+'
'+esc(opts.title?opts.title(x):'--')+'
'+esc(opts.sub?opts.sub(x):'')+'
'+esc(opts.val?opts.val(x):'')+'
'}).join('')+'
'} -function renderDetail(d){var r=d.current||{},symbol=d.symbol||r.symbol||'--',base=symbol.replace('/USDT',''),lp=d.latest_price||{},current=Number(lp.price||r.current_price||r.entry_price||0),entry=Number(r.entry_price||0),ep=r.entry_plan||{},sc=scoreComponents(r),rg=marketRegime(r),dl=decisionLog(r),ai=aiInsight(r);$('updatedAt').textContent='最新价格 '+fmtTime(lp.updated_at||r.last_track_time||r.rec_time);var rr=entry&&r.tp1&&r.stop_loss?((Number(r.tp1)-entry)/(entry-Number(r.stop_loss))).toFixed(2):'--';var chg=entry&¤t?((current/entry-1)*100):0;var aiHtml=ai?'
'+esc(clean(ai.summary||ai.why_now_or_not||'已缓存 AI 解读'))+'
':'
暂无 AI 解读
';var onchain=d.onchain||{};var metric=onchain.metric||{};var root='
'+esc(base)+'
'+esc(symbol)+' · 推荐 #'+esc(r.id||'--')+' · '+fmtTime(r.rec_time)+'
'+esc(statusLabel(r))+'总分 '+esc(r.rec_score||0)+''+esc(r.strategy_version||'--')+'
当前价'+price(current)+'
相对参考'+pct(chg)+'
计划入场'+price(entry||ep.entry_price)+'
止损 / 止盈'+price(r.stop_loss||ep.stop_loss)+' / '+price(r.tp1||ep.tp1)+'
盈亏比'+esc(rr)+'
多周期 K 线
计划价、止损、止盈会在图上标注
加载 K 线...
决策与计划
列表页只保留摘要,这里看完整依据

当前结论

'+esc(statusLabel(r))+'

原因

'+esc(clean(r.execution_reason||r.state_reason||dl.decision||'等待进一步确认'))+'

入场模型

'+esc(clean(ep.entry_model||ep.entry_method||'--'))+'

失效条件

'+esc(clean(ep.invalid_if||ep.stop_basis||'跌破风险边界或信号衰减'))+'

因子评分拆解
机会、买点、风险分开看
机会分'+num(sc.opportunity_score||0)+'
买点分'+num(sc.entry_score||0)+'
风险扣分'+num(sc.risk_score||0)+'
'+signals(r).slice(0,12).map(function(s){return''+esc(clean(s))+''}).join('')+'
筛选与推荐历史
'+esc((d.summary||{}).history_count||0)+' 次推荐 · '+esc((d.summary||{}).screening_count||0)+' 条筛选记录
'+renderRows(d.history,{empty:'暂无推荐历史',time:function(x){return fmtTime(x.rec_time)},title:function(x){return '#'+x.id+' · '+statusLabel(x)},sub:function(x){return signals(x).slice(0,3).map(clean).join(' · ')||'--'},val:function(x){return '分 '+(x.rec_score||0)}})+'
'+renderRows(d.screening,{empty:'暂无筛选记录',time:function(x){return fmtTime(x.scan_time)},title:function(x){return (x.layer||'筛选')+' · '+(x.state||'--')},sub:function(x){return (x.signals||[]).slice(0,3).map(clean).join(' · ')},val:function(x){return x.score||0}})+'
';$('detailRoot').innerHTML=root;loadKline()} +function renderDetail(d){var r=d.current||{},symbol=d.symbol||r.symbol||'--',base=symbol.replace('/USDT',''),lp=d.latest_price||{},current=Number(lp.price||r.current_price||r.entry_price||0),ep=r.entry_plan||{},entry=Number(ep.entry_price||r.entry_price||0),stop=Number(ep.stop_loss||r.stop_loss||0),tp1=Number(ep.tp1||ep.take_profit_1||r.tp1||0),sc=scoreComponents(r),rg=marketRegime(r),dl=decisionLog(r),ai=aiInsight(r);$('updatedAt').textContent='最新价格 '+fmtTime(lp.updated_at||r.last_track_time||r.rec_time);var rr=entry&&tp1&&stop?((tp1-entry)/(entry-stop)).toFixed(2):'--';var chg=entry&¤t?((current/entry-1)*100):0;var aiHtml=ai?'
'+esc(clean(ai.summary||ai.why_now_or_not||'已缓存 AI 解读'))+'
':'
暂无 AI 解读
';var onchain=d.onchain||{};var metric=onchain.metric||{};var root='
'+esc(base)+'
'+esc(symbol)+' · 推荐 #'+esc(r.id||'--')+' · '+fmtTime(r.rec_time)+'
'+esc(statusLabel(r))+'总分 '+esc(r.rec_score||0)+''+esc(r.strategy_version||'--')+'
当前价'+price(current)+'
相对参考'+pct(chg)+'
计划入场'+price(entry)+'
止损 / 止盈'+price(stop)+' / '+price(tp1)+'
盈亏比'+esc(rr)+'
多周期 K 线
计划价、止损、止盈会在图上标注
加载 K 线...
决策与计划
列表页只保留摘要,这里看完整依据

当前结论

'+esc(statusLabel(r))+'

原因

'+esc(clean(r.execution_reason||r.state_reason||dl.decision||'等待进一步确认'))+'

入场模型

'+esc(clean(ep.entry_model||ep.entry_method||'--'))+'

失效条件

'+esc(clean(ep.invalid_if||ep.stop_basis||'跌破风险边界或信号衰减'))+'

因子评分拆解
机会、买点、风险分开看
机会分'+num(sc.opportunity_score||0)+'
买点分'+num(sc.entry_score||0)+'
风险扣分'+num(sc.risk_score||0)+'
'+signals(r).slice(0,12).map(function(s){return''+esc(clean(s))+''}).join('')+'
筛选与推荐历史
'+esc((d.summary||{}).history_count||0)+' 次推荐 · '+esc((d.summary||{}).screening_count||0)+' 条筛选记录
'+renderRows(d.history,{empty:'暂无推荐历史',time:function(x){return fmtTime(x.rec_time)},title:function(x){return '#'+x.id+' · '+statusLabel(x)},sub:function(x){return signals(x).slice(0,3).map(clean).join(' · ')||'--'},val:function(x){return '分 '+(x.rec_score||0)}})+'
'+renderRows(d.screening,{empty:'暂无筛选记录',time:function(x){return fmtTime(x.scan_time)},title:function(x){return (x.layer||'筛选')+' · '+(x.state||'--')},sub:function(x){return (x.signals||[]).slice(0,3).map(clean).join(' · ')},val:function(x){return x.score||0}})+'
';$('detailRoot').innerHTML=root;loadKline()} function loadKline(){var c=$('kline');if(!c)return;var active=document.querySelector('.kline-int-btn.active');var interval=active?active.dataset.int:'1h';fetch(API+'/api/kline?symbol='+encodeURIComponent(c.dataset.symbol)+'&interval='+interval+'&limit=100').then(function(r){return r.json()}).then(function(resp){var candles=resp.candles||[];if(!window.AlphaXCharts||!window.AlphaXCharts.renderKline)throw new Error('chart unavailable');c.innerHTML='';window.AlphaXCharts.renderKline(c,{symbol:c.dataset.symbol,candles:candles,entryPrice:Number(c.dataset.entryPrice||0),stopLoss:Number(c.dataset.stopLoss||0),tp1:Number(c.dataset.tp1||0),recTime:c.dataset.recTime||'',refPrice:Number(c.dataset.refPrice||0)});c.classList.remove('loading')}).catch(function(){c.innerHTML='
K线加载失败
'})} function switchKline(btn){document.querySelectorAll('.kline-int-btn').forEach(function(b){b.classList.remove('active')});btn.classList.add('active');var c=$('kline');c.classList.add('loading');c.innerHTML='
加载 K 线...
';loadKline()} async function load(){var q=new URLSearchParams(location.search);var recId=q.get('rec_id')||'';var symbol=q.get('symbol')||'';var url=API+'/api/opportunity/detail?symbol='+encodeURIComponent(symbol)+'&rec_id='+encodeURIComponent(recId);try{var d=await (await fetch(url)).json();if(d.error){$('detailRoot').innerHTML='
没有找到该机会
';return}renderDetail(d)}catch(e){$('detailRoot').innerHTML='
机会详情加载失败
'}} diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py index 9d3e6c1..acb5417 100644 --- a/tests/test_paper_trading.py +++ b/tests/test_paper_trading.py @@ -426,6 +426,42 @@ def test_buy_now_rejects_large_leveraged_stop_loss(monkeypatch): assert list_paper_trades()["total"] == 0 +def test_buy_now_entry_gate_uses_latest_entry_plan_rr(monkeypatch): + monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_ENTRY_MIN_REC_SCORE", "50") + monkeypatch.setenv("ALPHAX_PAPER_ENTRY_MIN_RR", "1.5") + monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0") + altcoin_db.init_db() + rec_id = altcoin_db.create_recommendation( + symbol="PLANRR/USDT", + rec_state="爆发", + rec_score=67, + entry_price=0.0913, + stop_loss=0.085064, + tp1=0.0993, + signals=["当前15min即刻入场信号"], + entry_plan={ + "entry_action": "可即刻买入", + "entry_price": 0.0899, + "current_price": 0.0899, + "stop_loss": 0.085064, + "tp1": 0.098243, + "risk_reward_ok": True, + "rr1": 1.73, + "entry_trigger_confirmed": True, + }, + ) + rec = next(r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False) if r["id"] == rec_id) + + result = sync_recommendation(rec, 0.0899, event_time="2026-05-16T10:00:00") + + assert result["opened"] is True + trade = list_paper_trades()["items"][0] + assert trade["stop_loss"] == pytest.approx(0.085064) + assert trade["tp1"] == pytest.approx(0.098243) + + def test_buy_now_pauses_when_portfolio_drawdown_exceeded(monkeypatch, buy_now_rec): monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") monkeypatch.setenv("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", "1000") diff --git a/tests/test_signal_trust_stage1.py b/tests/test_signal_trust_stage1.py index 8228a58..9fd3170 100644 --- a/tests/test_signal_trust_stage1.py +++ b/tests/test_signal_trust_stage1.py @@ -190,6 +190,32 @@ class RecommendationSignalTrustTests(unittest.TestCase): self.assertAlmostEqual(target['entry_window']['current_price'], 10.12, places=4) self.assertAlmostEqual(target['entry_window']['deviation_pct'], 1.2, places=2) + def test_entry_window_uses_latest_entry_plan_price_before_stale_top_level_price(self): + self._insert_rec( + rec_time=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), + entry_price=10.0, + current_price=9.75, + stop_loss=9.4, + tp1=10.8, + entry_plan_json=json.dumps({ + 'entry_price': 9.8, + 'entry_action': '可即刻买入', + 'risk_reward_ok': True, + 'entry_trigger_confirmed': True, + 'rr1': 2.0, + 'stop_loss': 9.4, + 'tp1': 10.8, + }, ensure_ascii=False), + ) + + rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False, version='v-test') + target = rows[0] + + self.assertEqual(target['entry_window']['status'], 'active') + self.assertEqual(target['execution_status'], 'buy_now') + self.assertAlmostEqual(target['entry_window']['entry_price'], 9.8, places=4) + self.assertAlmostEqual(target['entry_window']['deviation_pct'], -0.51, places=2) + if __name__ == '__main__': unittest.main()