This commit is contained in:
aaron 2026-05-24 20:44:22 +08:00
parent 6b7011becb
commit b8c75bd0ef
7 changed files with 283 additions and 23 deletions

View File

@ -243,6 +243,37 @@ def _attach_paper_trade(item):
return item return item
def _attach_paper_order(item):
order_id = item.get("paper_order_id")
if not order_id:
item["paper_order"] = None
item["paper_order_status"] = ""
return item
order = {
"id": order_id,
"recommendation_id": item.get("paper_order_recommendation_id") or item.get("id"),
"symbol": item.get("paper_order_symbol") or item.get("symbol"),
"side": item.get("paper_order_side") or "long",
"order_type": item.get("paper_order_type") or "limit",
"status": item.get("paper_order_status_raw") or "",
"target_price": item.get("paper_order_target_price") or 0,
"current_price_at_create": item.get("paper_order_current_price_at_create") or 0,
"fill_price": item.get("paper_order_fill_price") or 0,
"stop_loss": item.get("paper_order_stop_loss") or 0,
"tp1": item.get("paper_order_tp1") or 0,
"tp2": item.get("paper_order_tp2") or 0,
"created_at": item.get("paper_order_created_at") or "",
"updated_at": item.get("paper_order_updated_at") or "",
"expires_at": item.get("paper_order_expires_at") or "",
"filled_at": item.get("paper_order_filled_at") or "",
"canceled_at": item.get("paper_order_canceled_at") or "",
"cancel_reason": item.get("paper_order_cancel_reason") or "",
}
item["paper_order"] = order
item["paper_order_status"] = order["status"]
return item
def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, with_meta=False, archive_filter=""): def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, with_meta=False, archive_filter=""):
"""获取推荐列表。""" """获取推荐列表。"""
conn = get_conn() conn = get_conn()
@ -379,10 +410,29 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0,
pt.realized_pnl_pct AS paper_realized_pnl_pct, pt.realized_pnl_pct AS paper_realized_pnl_pct,
pt.realized_pnl_usdt AS paper_realized_pnl_usdt, pt.realized_pnl_usdt AS paper_realized_pnl_usdt,
pt.exit_reason AS paper_exit_reason, pt.exit_reason AS paper_exit_reason,
pt.updated_at AS paper_updated_at pt.updated_at AS paper_updated_at,
po.id AS paper_order_id,
po.recommendation_id AS paper_order_recommendation_id,
po.symbol AS paper_order_symbol,
po.side AS paper_order_side,
po.order_type AS paper_order_type,
po.status AS paper_order_status_raw,
po.target_price AS paper_order_target_price,
po.current_price_at_create AS paper_order_current_price_at_create,
po.fill_price AS paper_order_fill_price,
po.stop_loss AS paper_order_stop_loss,
po.tp1 AS paper_order_tp1,
po.tp2 AS paper_order_tp2,
po.created_at AS paper_order_created_at,
po.updated_at AS paper_order_updated_at,
po.expires_at AS paper_order_expires_at,
po.filled_at AS paper_order_filled_at,
po.canceled_at AS paper_order_canceled_at,
po.cancel_reason AS paper_order_cancel_reason
FROM recommendation r FROM recommendation r
LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol
LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id
LEFT JOIN paper_orders po ON po.recommendation_id = r.id
JOIN ( JOIN (
SELECT symbol, MAX(id) AS max_id SELECT symbol, MAX(id) AS max_id
FROM recommendation FROM recommendation
@ -420,6 +470,7 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0,
item["recommendation_result_label"] = rec_result_label item["recommendation_result_label"] = rec_result_label
_derive_execution_fields(item) _derive_execution_fields(item)
_attach_paper_trade(item) _attach_paper_trade(item)
_attach_paper_order(item)
result.append(item) result.append(item)
if not with_meta: if not with_meta:

View File

@ -1355,7 +1355,29 @@ def sync_recommendation(rec: dict, current_price: float, event_time: str = "") -
conn.commit() conn.commit()
return result return result
return {"skipped": True, "reason": "not_buy_now"} return {"skipped": True, "reason": "not_buy_now"}
pending_order = conn.execute(
"SELECT * FROM paper_orders WHERE recommendation_id=%s AND status='pending'",
(rec_id,),
).fetchone()
if pending_order:
conn.execute(
"""
UPDATE paper_orders
SET status='canceled',
cancel_reason='upgraded_to_buy_now',
canceled_at=%s,
updated_at=%s
WHERE recommendation_id=%s AND status='pending'
""",
(event_time, event_time, rec_id),
)
result = _open_trade(conn, rec, current_price, event_time, config=cfg) result = _open_trade(conn, rec, current_price, event_time, config=cfg)
if pending_order:
result["paper_order"] = {
"canceled": True,
"order_id": pending_order["id"],
"cancel_reason": "upgraded_to_buy_now",
}
conn.commit() conn.commit()
return result return result
except Exception: except Exception:

View File

@ -14,6 +14,37 @@ from app.db.tracking_queries import update_recommendation_tracking
from app.services.llm_insights import attach_recommendation_insights from app.services.llm_insights import attach_recommendation_insights
def _attach_paper_order(item: dict) -> dict:
order_id = item.get("paper_order_id")
if not order_id:
item["paper_order"] = None
item["paper_order_status"] = ""
return item
order = {
"id": order_id,
"recommendation_id": item.get("paper_order_recommendation_id") or item.get("id"),
"symbol": item.get("paper_order_symbol") or item.get("symbol"),
"side": item.get("paper_order_side") or "long",
"order_type": item.get("paper_order_type") or "limit",
"status": item.get("paper_order_status_raw") or "",
"target_price": item.get("paper_order_target_price") or 0,
"current_price_at_create": item.get("paper_order_current_price_at_create") or 0,
"fill_price": item.get("paper_order_fill_price") or 0,
"stop_loss": item.get("paper_order_stop_loss") or 0,
"tp1": item.get("paper_order_tp1") or 0,
"tp2": item.get("paper_order_tp2") or 0,
"created_at": item.get("paper_order_created_at") or "",
"updated_at": item.get("paper_order_updated_at") or "",
"expires_at": item.get("paper_order_expires_at") or "",
"filled_at": item.get("paper_order_filled_at") or "",
"canceled_at": item.get("paper_order_canceled_at") or "",
"cancel_reason": item.get("paper_order_cancel_reason") or "",
}
item["paper_order"] = order
item["paper_order_status"] = order["status"]
return item
def get_active_recommendations(actionable_only: bool = False): def get_active_recommendations(actionable_only: bool = False):
"""获取所有 active 推荐。""" """获取所有 active 推荐。"""
conn = get_conn() conn = get_conn()
@ -153,9 +184,28 @@ def get_active_recommendations_deduped(
f""" f"""
SELECT r.*, SELECT r.*,
lpc.price AS latest_cache_price, lpc.price AS latest_cache_price,
lpc.updated_at AS latest_cache_updated_at lpc.updated_at AS latest_cache_updated_at,
po.id AS paper_order_id,
po.recommendation_id AS paper_order_recommendation_id,
po.symbol AS paper_order_symbol,
po.side AS paper_order_side,
po.order_type AS paper_order_type,
po.status AS paper_order_status_raw,
po.target_price AS paper_order_target_price,
po.current_price_at_create AS paper_order_current_price_at_create,
po.fill_price AS paper_order_fill_price,
po.stop_loss AS paper_order_stop_loss,
po.tp1 AS paper_order_tp1,
po.tp2 AS paper_order_tp2,
po.created_at AS paper_order_created_at,
po.updated_at AS paper_order_updated_at,
po.expires_at AS paper_order_expires_at,
po.filled_at AS paper_order_filled_at,
po.canceled_at AS paper_order_canceled_at,
po.cancel_reason AS paper_order_cancel_reason
FROM recommendation r FROM recommendation r
LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol
LEFT JOIN paper_orders po ON po.recommendation_id = r.id
JOIN ( JOIN (
SELECT symbol, MAX(id) AS max_id SELECT symbol, MAX(id) AS max_id
FROM recommendation FROM recommendation
@ -189,6 +239,7 @@ def get_active_recommendations_deduped(
item["recommendation_result"] = rec_result item["recommendation_result"] = rec_result
item["recommendation_result_label"] = rec_result_label item["recommendation_result_label"] = rec_result_label
_derive_execution_fields(item) _derive_execution_fields(item)
_attach_paper_order(item)
is_expired = False is_expired = False
if hours > 0: if hours > 0:

View File

@ -6,10 +6,13 @@ in the live_trading DB/service layer.
from __future__ import annotations from __future__ import annotations
import os
import hashlib import hashlib
import hmac import hmac
import json
import os
import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlencode from urllib.parse import urlencode
import requests import requests
@ -21,13 +24,86 @@ class LiveTradingConfigError(RuntimeError):
pass pass
def _cache_dir() -> Path:
return Path(os.getenv("ALPHAX_EXCHANGE_CACHE_DIR", "/app/data/exchange_cache"))
def _markets_cache_ttl_seconds() -> int:
try:
return max(300, int(os.getenv("ALPHAX_EXCHANGE_INFO_CACHE_SECONDS", "86400") or 86400))
except Exception:
return 86400
def _safe_cache_key(value: str) -> str:
return "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in value)
@dataclass @dataclass
class BinanceLiveClient: class BinanceLiveClient:
exchange: object exchange: object
market_type: str = "um_futures" market_type: str = "um_futures"
def _markets_cache_path(self) -> Path:
sandbox = "sandbox" if bool(getattr(self.exchange, "sandbox", False)) else "live"
exchange_id = str(getattr(self.exchange, "id", "binance") or "binance")
return _cache_dir() / f"{_safe_cache_key(exchange_id)}_{_safe_cache_key(self.market_type)}_{sandbox}_markets.json"
def _restore_cached_markets(self, *, allow_stale: bool = False) -> bool:
path = self._markets_cache_path()
if not path.exists():
return False
try:
payload = json.loads(path.read_text(encoding="utf-8"))
cached_at = float(payload.get("cached_at") or 0)
age = time.time() - cached_at
if not allow_stale and age > _markets_cache_ttl_seconds():
return False
markets = payload.get("markets") or {}
markets_by_id = payload.get("markets_by_id") or {}
symbols = payload.get("symbols") or sorted(markets.keys())
ids = payload.get("ids") or sorted(markets_by_id.keys())
if not markets:
return False
self.exchange.markets = markets
self.exchange.markets_by_id = markets_by_id
self.exchange.symbols = symbols
self.exchange.ids = ids
self.exchange.markets_loading = None
return True
except Exception:
return False
def _store_markets_cache(self, markets) -> None:
if not markets:
return
path = self._markets_cache_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"cached_at": time.time(),
"exchange_id": str(getattr(self.exchange, "id", "binance") or "binance"),
"market_type": self.market_type,
"symbols": list(getattr(self.exchange, "symbols", []) or sorted(markets.keys())),
"ids": list(getattr(self.exchange, "ids", []) or []),
"markets": markets,
"markets_by_id": getattr(self.exchange, "markets_by_id", {}) or {},
}
path.write_text(json.dumps(payload, ensure_ascii=False, default=str), encoding="utf-8")
except Exception:
pass
def load_markets(self): def load_markets(self):
return self.exchange.load_markets() if self._restore_cached_markets():
return self.exchange.markets
try:
markets = self.exchange.load_markets()
self._store_markets_cache(markets)
return markets
except Exception:
if self._restore_cached_markets(allow_stale=True):
return self.exchange.markets
raise
def fetch_balance(self): def fetch_balance(self):
return self.exchange.fetch_balance() return self.exchange.fetch_balance()

View File

@ -62,6 +62,7 @@ from app.core.signal_taxonomy import signal_codes as build_signal_codes
exchange = ccxt.binance({"enableRateLimit": True}) exchange = ccxt.binance({"enableRateLimit": True})
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
BINANCE_SPOT_BASE_URL = os.getenv("ALPHAX_BINANCE_SPOT_BASE_URL", "https://api.binance.com").rstrip("/")
# ==================== 排除列表 ==================== # ==================== 排除列表 ====================
STABLECOINS = { STABLECOINS = {
@ -86,22 +87,51 @@ def get_dynamic_weights():
# ==================== 工具函数 ==================== # ==================== 工具函数 ====================
def _fetch_spot_24h_tickers():
"""Fetch spot 24h tickers without ccxt market loading.
ccxt.fetch_tickers() can call Binance futures exchangeInfo as part of
load_markets(), which is exactly the endpoint most likely to be IP-banned.
The public spot 24h endpoint is enough for our broad universe scan.
"""
resp = requests.get(f"{BINANCE_SPOT_BASE_URL}/api/v3/ticker/24hr", timeout=15)
resp.raise_for_status()
data = resp.json()
if not isinstance(data, list):
return {}
tickers = {}
for item in data:
raw_symbol = str(item.get("symbol") or "")
if not raw_symbol.endswith("USDT"):
continue
base = raw_symbol[:-4]
if not base:
continue
close_time = item.get("closeTime")
ticker_dt = ""
try:
ticker_dt = datetime.utcfromtimestamp(float(close_time) / 1000).isoformat()
except Exception:
ticker_dt = ""
tickers[f"{base}/USDT"] = {
"last": float(item.get("lastPrice") or 0),
"percentage": float(item.get("priceChangePercent") or 0),
"quoteVolume": float(item.get("quoteVolume") or 0),
"high": float(item.get("highPrice") or 0),
"low": float(item.get("lowPrice") or 0),
"datetime": ticker_dt,
}
return tickers
def fetch_all_tickers(): def fetch_all_tickers():
tickers = exchange.fetch_tickers() tickers = _fetch_spot_24h_tickers()
try:
markets = exchange.markets or exchange.load_markets()
except Exception:
markets = {}
usdt_pairs = {} usdt_pairs = {}
universe_exclusions = [] universe_exclusions = []
for symbol, info in tickers.items(): for symbol, info in tickers.items():
if "/USDT" in symbol: if "/USDT" in symbol:
base = symbol.split("/")[0] base = symbol.split("/")[0]
vol_usd = info.get("quoteVolume", 0) or 0 vol_usd = info.get("quoteVolume", 0) or 0
market = markets.get(symbol) or {}
if market and (market.get("spot") is False or market.get("active") is False):
universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, "reason_code": "inactive_market", "reason_label": "非活跃现货交易对"})
continue
ticker_dt = info.get("datetime") ticker_dt = info.get("datetime")
if ticker_dt: if ticker_dt:
try: try:

View File

@ -66,14 +66,8 @@ def _market_state(avg_change, advance_decline_ratio, hot_count, crash_count, ben
} }
def _benchmark_overview(): def _benchmark_overview(tickers=None):
try: tickers = tickers or {}
tickers = altcoin_screener.exchange.fetch_tickers(["BTC/USDT", "ETH/USDT"])
except Exception:
try:
tickers = altcoin_screener.exchange.fetch_tickers()
except Exception:
tickers = {}
result = {} result = {}
for symbol in ("BTC/USDT", "ETH/USDT"): for symbol in ("BTC/USDT", "ETH/USDT"):
info = tickers.get(symbol) or {} info = tickers.get(symbol) or {}
@ -142,7 +136,7 @@ def _funding_overview(universe_symbols=None):
def compute_crypto_market_overview(): def compute_crypto_market_overview():
pairs = altcoin_screener.fetch_all_tickers() pairs = altcoin_screener.fetch_all_tickers()
benchmarks = _benchmark_overview() benchmarks = _benchmark_overview(pairs)
items = [] items = []
for symbol, info in (pairs or {}).items(): for symbol, info in (pairs or {}).items():
volume = _safe_float(info.get("volume_24h")) volume = _safe_float(info.get("volume_24h"))

View File

@ -209,6 +209,12 @@
.regime-reason { color: var(--stone); font-size: 11px; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; } .regime-reason { color: var(--stone); font-size: 11px; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }
.decision-log-brief { border: 1px dashed var(--hairline-strong); border-radius: var(--radius-lg); background: var(--canvas); padding: 8px 10px; color: var(--slate); font-size: 11px; line-height: 1.45; } .decision-log-brief { border: 1px dashed var(--hairline-strong); border-radius: var(--radius-lg); background: var(--canvas); padding: 8px 10px; color: var(--slate); font-size: 11px; line-height: 1.45; }
.decision-log-brief b { color: var(--ink); font-weight: 950; } .decision-log-brief b { color: var(--ink); font-weight: 950; }
.paper-order-brief { margin: 0 18px 8px; border: 1px solid rgba(66,98,255,.14); border-radius: var(--radius-lg); background: rgba(66,98,255,.04); padding: 8px 10px; display:flex; align-items:center; justify-content:space-between; gap:10px; color: var(--slate); font-size: 11px; line-height: 1.4; }
.paper-order-brief.pending { border-color: rgba(252,185,0,.24); background: var(--yellow-light); }
.paper-order-brief.filled { border-color: rgba(0,180,115,.18); background: var(--green-light); }
.paper-order-brief.canceled,.paper-order-brief.expired,.paper-order-brief.rejected { border-color: rgba(229,62,62,.18); background: var(--red-light); }
.paper-order-title { color: var(--ink); font-size: 12px; font-weight: 950; white-space: nowrap; }
.paper-order-meta { color: var(--stone); text-align:right; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* ===== K-LINE ===== */ /* ===== K-LINE ===== */
.kline-wrap { padding: 0 8px 4px; } .kline-wrap { padding: 0 8px 4px; }
@ -480,6 +486,23 @@ var MODULE_LABELS = {
tracker: '跟踪', tracker: '跟踪',
paper_trader: '策略交易' paper_trader: '策略交易'
}; };
var PAPER_ORDER_STATUS_LABELS = {
pending: '挂单中',
filled: '已成交',
canceled: '已取消',
expired: '已过期',
rejected: '已拒绝'
};
var PAPER_ORDER_REASON_LABELS = {
upgraded_to_buy_now: '信号已升级为入场窗口',
too_far_from_entry: '距离计划价过远',
recommendation_invalid: '信号已失效',
recommendation_missing: '原信号不存在',
expired: '超过有效时间',
global_risk_rejected: '全局风控拦截',
cumulative_leverage_exceeded: '累计杠杆超限',
stop_loss_leverage_risk_exceeded: '止损杠杆风险过高'
};
var FLAG_LABELS = { var FLAG_LABELS = {
market_regime: '市场环境', market_regime: '市场环境',
market_risk: '市场风险', market_risk: '市场风险',
@ -562,6 +585,19 @@ function renderStrategyDiagnostics(r) {
var html = renderScoreComponents(r) + renderRegimeBrief(r) + renderDecisionLogBrief(r); var html = renderScoreComponents(r) + renderRegimeBrief(r) + renderDecisionLogBrief(r);
return html ? '<div class="strategy-diagnostics">'+html+'</div>' : ''; return html ? '<div class="strategy-diagnostics">'+html+'</div>' : '';
} }
function renderPaperOrderBrief(r) {
var order = r && r.paper_order;
if (!order || !order.id) return '';
var status = String(order.status || '').toLowerCase();
var label = PAPER_ORDER_STATUS_LABELS[status] || translateInternalToken(status || '挂单');
var target = Number(order.target_price || 0);
var current = Number(r.current_price || order.current_price_at_create || 0);
var meta = [];
if (target > 0) meta.push('计划价 $' + fmtPrice(target, priceDecimals(target)));
if (current > 0 && status === 'pending') meta.push('现价 $' + fmtPrice(current, priceDecimals(current)));
if (order.cancel_reason) meta.push(PAPER_ORDER_REASON_LABELS[order.cancel_reason] || translateInternalToken(order.cancel_reason));
return '<div class="paper-order-brief '+esc(status)+'"><span class="paper-order-title">策略挂单 · '+esc(label)+'</span><span class="paper-order-meta">'+esc(meta.join(' · ') || '已关联到当前信号')+'</span></div>';
}
function normalizeTriggerCause(s) { function normalizeTriggerCause(s) {
return cleanDisplayText(s) return cleanDisplayText(s)
.replace(/^15min入场窗口/, '15min 触发') .replace(/^15min入场窗口/, '15min 触发')
@ -886,7 +922,7 @@ function renderRecCard(r) {
} else { } else {
entryPlanHtml = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">当前参考</span><span class="ep-val entry-ref">'+fmtP(price)+'</span><span class="ep-sub">不是入场价</span></div><div class="ep-item"><span class="ep-label">观察重点</span><span class="ep-val space-ref">待触发</span><span class="ep-sub">'+cleanDisplayText(entryModel || '需15m/1H当前信号')+'</span></div><div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">不计入</span><span class="ep-sub">未形成交易机会</span></div><div class="ep-item"><span class="ep-label">观察阶段</span><span class="ep-val level-ref">'+cleanDisplayText(horizon || '观察池候选')+'</span><span class="ep-sub">'+cleanDisplayText(levelLabel)+'</span></div></div>'; entryPlanHtml = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">当前参考</span><span class="ep-val entry-ref">'+fmtP(price)+'</span><span class="ep-sub">不是入场价</span></div><div class="ep-item"><span class="ep-label">观察重点</span><span class="ep-val space-ref">待触发</span><span class="ep-sub">'+cleanDisplayText(entryModel || '需15m/1H当前信号')+'</span></div><div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">不计入</span><span class="ep-sub">未形成交易机会</span></div><div class="ep-item"><span class="ep-label">观察阶段</span><span class="ep-val level-ref">'+cleanDisplayText(horizon || '观察池候选')+'</span><span class="ep-sub">'+cleanDisplayText(levelLabel)+'</span></div></div>';
} }
return '<div class="card '+(isWeakObserve?'weak-observe':'')+'"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span></div></div><div class="badge-group"><span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">总分</span></span></div></div><div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+decisionHtml+renderStrategyDiagnostics(r)+signalLevelHtml+onchainHtml+aiInsightHtml+'<div class="kline-wrap"><div class="kline-int-bar"><button class="kline-int-btn" data-int="15m" onclick="switchKlineInterval(this);event.stopPropagation()">15m</button><button class="kline-int-btn active" data-int="1h" onclick="switchKlineInterval(this);event.stopPropagation()">1H</button><button class="kline-int-btn" data-int="4h" onclick="switchKlineInterval(this);event.stopPropagation()">4H</button><button class="kline-int-btn" data-int="1d" onclick="switchKlineInterval(this);event.stopPropagation()">1D</button></div><div class="kline-container loading" data-symbol="'+r.symbol+'" data-entry-price="'+klineEntryPrice+'" data-stop-loss="'+klineStopLoss+'" data-tp1="'+klineTp1+'" data-rec-time="'+entryTime+'" data-tp1-time="'+tp1EventTime+'" data-sl-time="'+slEventTime+'" data-ref-price="'+price+'" data-action-status="'+(r.action_status||'')+'"><div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div></div></div>'+(isWeakObserve ? weakNoteHtml : entryPlanHtml)+(sigHtml?'<div class="signals-row">'+sigHtml+'</div>':'')+'<div class="card-footer"><span>'+fmtTime(r.rec_time)+'</span><span class="card-ver">'+ver+'</span>'+(isTpOrSl?'<span class="pnl-block '+pnlCls+'">'+pnlSign+pnl.toFixed(1)+'%</span>':'')+'</div></div>'; return '<div class="card '+(isWeakObserve?'weak-observe':'')+'"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span></div></div><div class="badge-group"><span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">总分</span></span></div></div><div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+decisionHtml+renderPaperOrderBrief(r)+renderStrategyDiagnostics(r)+signalLevelHtml+onchainHtml+aiInsightHtml+'<div class="kline-wrap"><div class="kline-int-bar"><button class="kline-int-btn" data-int="15m" onclick="switchKlineInterval(this);event.stopPropagation()">15m</button><button class="kline-int-btn active" data-int="1h" onclick="switchKlineInterval(this);event.stopPropagation()">1H</button><button class="kline-int-btn" data-int="4h" onclick="switchKlineInterval(this);event.stopPropagation()">4H</button><button class="kline-int-btn" data-int="1d" onclick="switchKlineInterval(this);event.stopPropagation()">1D</button></div><div class="kline-container loading" data-symbol="'+r.symbol+'" data-entry-price="'+klineEntryPrice+'" data-stop-loss="'+klineStopLoss+'" data-tp1="'+klineTp1+'" data-rec-time="'+entryTime+'" data-tp1-time="'+tp1EventTime+'" data-sl-time="'+slEventTime+'" data-ref-price="'+price+'" data-action-status="'+(r.action_status||'')+'"><div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div></div></div>'+(isWeakObserve ? weakNoteHtml : entryPlanHtml)+(sigHtml?'<div class="signals-row">'+sigHtml+'</div>':'')+'<div class="card-footer"><span>'+fmtTime(r.rec_time)+'</span><span class="card-ver">'+ver+'</span>'+(isTpOrSl?'<span class="pnl-block '+pnlCls+'">'+pnlSign+pnl.toFixed(1)+'%</span>':'')+'</div></div>';
} catch (e) { } catch (e) {
console.error('renderRecCard hard fail', r && r.symbol, e); console.error('renderRecCard hard fail', r && r.symbol, e);
return renderLiveFallbackCard(r); return renderLiveFallbackCard(r);