diff --git a/app/db/analytics.py b/app/db/analytics.py index 0250bdd..09e9e7c 100644 --- a/app/db/analytics.py +++ b/app/db/analytics.py @@ -243,6 +243,37 @@ def _attach_paper_trade(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=""): """获取推荐列表。""" 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_usdt AS paper_realized_pnl_usdt, 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 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_orders po ON po.recommendation_id = r.id JOIN ( SELECT symbol, MAX(id) AS max_id 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 _derive_execution_fields(item) _attach_paper_trade(item) + _attach_paper_order(item) result.append(item) if not with_meta: diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index 00b1534..bd655c5 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -1355,7 +1355,29 @@ def sync_recommendation(rec: dict, current_price: float, event_time: str = "") - conn.commit() return result 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) + if pending_order: + result["paper_order"] = { + "canceled": True, + "order_id": pending_order["id"], + "cancel_reason": "upgraded_to_buy_now", + } conn.commit() return result except Exception: diff --git a/app/db/recommendation_queries.py b/app/db/recommendation_queries.py index c3d2b0f..02f4e0b 100644 --- a/app/db/recommendation_queries.py +++ b/app/db/recommendation_queries.py @@ -14,6 +14,37 @@ from app.db.tracking_queries import update_recommendation_tracking 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): """获取所有 active 推荐。""" conn = get_conn() @@ -153,9 +184,28 @@ def get_active_recommendations_deduped( f""" SELECT r.*, 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 LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol + LEFT JOIN paper_orders po ON po.recommendation_id = r.id JOIN ( SELECT symbol, MAX(id) AS max_id FROM recommendation @@ -189,6 +239,7 @@ def get_active_recommendations_deduped( item["recommendation_result"] = rec_result item["recommendation_result_label"] = rec_result_label _derive_execution_fields(item) + _attach_paper_order(item) is_expired = False if hours > 0: diff --git a/app/integrations/binance_live.py b/app/integrations/binance_live.py index f5ac3cb..b30ae70 100644 --- a/app/integrations/binance_live.py +++ b/app/integrations/binance_live.py @@ -6,10 +6,13 @@ in the live_trading DB/service layer. from __future__ import annotations -import os import hashlib import hmac +import json +import os +import time from dataclasses import dataclass +from pathlib import Path from urllib.parse import urlencode import requests @@ -21,13 +24,86 @@ class LiveTradingConfigError(RuntimeError): 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 class BinanceLiveClient: exchange: object 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): - 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): return self.exchange.fetch_balance() diff --git a/app/services/altcoin_screener.py b/app/services/altcoin_screener.py index c54893f..7e40cd4 100644 --- a/app/services/altcoin_screener.py +++ b/app/services/altcoin_screener.py @@ -62,6 +62,7 @@ from app.core.signal_taxonomy import signal_codes as build_signal_codes exchange = ccxt.binance({"enableRateLimit": True}) REPO_ROOT = Path(__file__).resolve().parents[2] +BINANCE_SPOT_BASE_URL = os.getenv("ALPHAX_BINANCE_SPOT_BASE_URL", "https://api.binance.com").rstrip("/") # ==================== 排除列表 ==================== 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(): - tickers = exchange.fetch_tickers() - try: - markets = exchange.markets or exchange.load_markets() - except Exception: - markets = {} + tickers = _fetch_spot_24h_tickers() usdt_pairs = {} universe_exclusions = [] for symbol, info in tickers.items(): if "/USDT" in symbol: base = symbol.split("/")[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") if ticker_dt: try: diff --git a/app/services/market_overview.py b/app/services/market_overview.py index d2b1635..6061d83 100644 --- a/app/services/market_overview.py +++ b/app/services/market_overview.py @@ -66,14 +66,8 @@ def _market_state(avg_change, advance_decline_ratio, hot_count, crash_count, ben } -def _benchmark_overview(): - try: - tickers = altcoin_screener.exchange.fetch_tickers(["BTC/USDT", "ETH/USDT"]) - except Exception: - try: - tickers = altcoin_screener.exchange.fetch_tickers() - except Exception: - tickers = {} +def _benchmark_overview(tickers=None): + tickers = tickers or {} result = {} for symbol in ("BTC/USDT", "ETH/USDT"): info = tickers.get(symbol) or {} @@ -142,7 +136,7 @@ def _funding_overview(universe_symbols=None): def compute_crypto_market_overview(): pairs = altcoin_screener.fetch_all_tickers() - benchmarks = _benchmark_overview() + benchmarks = _benchmark_overview(pairs) items = [] for symbol, info in (pairs or {}).items(): volume = _safe_float(info.get("volume_24h")) diff --git a/static/app.html b/static/app.html index f6343f4..f3a1723 100644 --- a/static/app.html +++ b/static/app.html @@ -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; } .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; } +.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 ===== */ .kline-wrap { padding: 0 8px 4px; } @@ -480,6 +486,23 @@ var MODULE_LABELS = { tracker: '跟踪', 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 = { market_regime: '市场环境', market_risk: '市场风险', @@ -562,6 +585,19 @@ function renderStrategyDiagnostics(r) { var html = renderScoreComponents(r) + renderRegimeBrief(r) + renderDecisionLogBrief(r); return html ? '
'+html+'
' : ''; } +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 '
策略挂单 · '+esc(label)+''+esc(meta.join(' · ') || '已关联到当前信号')+'
'; +} function normalizeTriggerCause(s) { return cleanDisplayText(s) .replace(/^15min入场窗口/, '15min 触发') @@ -886,7 +922,7 @@ function renderRecCard(r) { } else { entryPlanHtml = '
当前参考'+fmtP(price)+'不是入场价
观察重点待触发'+cleanDisplayText(entryModel || '需15m/1H当前信号')+'
绩效口径不计入未形成交易机会
观察阶段'+cleanDisplayText(horizon || '观察池候选')+''+cleanDisplayText(levelLabel)+'
'; } - return '
'+base.slice(0,2).toUpperCase()+'
'+base+'
'+score+'总分
$'+priceFmt+''+changeHtml+'
'+decisionHtml+renderStrategyDiagnostics(r)+signalLevelHtml+onchainHtml+aiInsightHtml+'
'+(isWeakObserve ? weakNoteHtml : entryPlanHtml)+(sigHtml?'
'+sigHtml+'
':'')+'
'; + return '
'+base.slice(0,2).toUpperCase()+'
'+base+'
'+score+'总分
$'+priceFmt+''+changeHtml+'
'+decisionHtml+renderPaperOrderBrief(r)+renderStrategyDiagnostics(r)+signalLevelHtml+onchainHtml+aiInsightHtml+'
'+(isWeakObserve ? weakNoteHtml : entryPlanHtml)+(sigHtml?'
'+sigHtml+'
':'')+'
'; } catch (e) { console.error('renderRecCard hard fail', r && r.symbol, e); return renderLiveFallbackCard(r);