From d16821c7b74d4a16bea7ff408f57720dc9b4ec6b Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 15 Sep 2026 12:16:06 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E6=8B=9F=E7=9B=98=E6=97=A0=20API=20Ke?= =?UTF-8?q?y=20=E6=97=B6=E5=A4=8D=E7=9B=98K=E7=BA=BF=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E4=BC=98=E5=85=88=E8=B5=B0=20OKX=20=E5=85=AC=E5=BC=80=E8=A1=8C?= =?UTF-8?q?=E6=83=85=E6=8E=A5=E5=8F=A3=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- app.py | 134 ++++++++++++++++++++++--- lib/sim/broker_lib.py | 14 ++- tests/test_okx_public_ohlcv_mapping.py | 40 ++++++++ 3 files changed, 172 insertions(+), 16 deletions(-) create mode 100644 tests/test_okx_public_ohlcv_mapping.py diff --git a/app.py b/app.py index 6f17608..a2a9789 100644 --- a/app.py +++ b/app.py @@ -924,19 +924,118 @@ def _ohlcv_dict_rows_to_lists(rows, lim): return [[r["ts"], r["o"], r["h"], r["l"], r["c"], r.get("v", 0)] for r in pick] -def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms): - lim = max(2, int(limit or ORDER_CHART_LIMIT)) +def _okx_swap_inst_id(exchange_symbol): + """ETH/USDT:USDT → ETH-USDT-SWAP(公开 candles 用).""" + sym = str(exchange_symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + base, rest = sym.split("/", 1) + quote = rest.split(":", 1)[0].strip() or "USDT" + return f"{base.strip()}-{quote}-SWAP" + if sym.endswith("-SWAP"): + return sym + if "-" in sym: + return sym + if sym.endswith("USDT") and len(sym) > 4: + return f"{sym[:-4]}-USDT-SWAP" + return sym + + +def _okx_public_bar(timeframe): + """ccxt 周期 → OKX public bar(1H/4H 大写 H).""" + s = str(timeframe or "").strip() + if not s: + return "15m" + low = s.lower() + if low.endswith("m") and low[:-1].isdigit(): + return low + if low.endswith("h") and low[:-1].isdigit(): + return f"{low[:-1]}H" + if low.endswith("d") and low[:-1].isdigit(): + return f"{low[:-1]}D" + return s + + +def _fetch_ohlcv_public(exchange_symbol, timeframe, limit=100, since_ms=None, end_ts_ms=None): + """无 API Key 也可拉永续 K 线:OKX public_get_market_candles.""" + inst = _okx_swap_inst_id(exchange_symbol) + bar = _okx_public_bar(timeframe) + lim = max(2, min(int(limit or 100), 300)) + if not inst or not hasattr(exchange, "public_get_market_candles"): + return [] + params = {"instId": inst, "bar": bar, "limit": str(lim)} + # after=ts → 早于 ts 的更旧数据,适合「截止到平仓」 + if end_ts_ms: + try: + params["after"] = str(int(end_ts_ms)) + except (TypeError, ValueError): + pass try: - if not end_ts_ms: - ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim) - else: - period = _timeframe_period_ms(timeframe) - since = int(end_ts_ms) - period * (lim + 10) - ohlcv = exchange.fetch_ohlcv( - exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 20 - ) + raw = (exchange.public_get_market_candles(params) or {}).get("data") or [] except Exception: return [] + out = [] + for row in reversed(list(raw or [])): + if not row or len(row) < 5: + continue + try: + ts = int(row[0]) + o = float(row[1]) + h = float(row[2]) + l = float(row[3]) + c = float(row[4]) + v = float(row[5]) if len(row) > 5 else 0.0 + except (TypeError, ValueError, IndexError): + continue + if since_ms is not None: + try: + if ts < int(since_ms): + continue + except (TypeError, ValueError): + pass + if end_ts_ms is not None: + try: + if ts > int(end_ts_ms): + continue + except (TypeError, ValueError): + pass + out.append([ts, o, h, l, c, v]) + if len(out) > lim: + return out[-lim:] + return out + + +def _fetch_ohlcv_safe(exchange_symbol, timeframe, limit=100, since_ms=None, end_ts_ms=None): + """公开接口优先(模拟盘无密钥);失败再试 ccxt.fetch_ohlcv.""" + pub = _fetch_ohlcv_public( + exchange_symbol, + timeframe, + limit=limit, + since_ms=since_ms, + end_ts_ms=end_ts_ms, + ) + if pub: + return pub + try: + kwargs = {"timeframe": timeframe, "limit": int(limit or 100)} + if since_ms: + kwargs["since"] = max(0, int(since_ms)) + return list(exchange.fetch_ohlcv(exchange_symbol, **kwargs) or []) + except Exception: + return [] + + +def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms): + lim = max(2, int(limit or ORDER_CHART_LIMIT)) + ohlcv = _fetch_ohlcv_safe( + exchange_symbol, + timeframe, + limit=lim + 20, + end_ts_ms=end_ts_ms, + ) + if not ohlcv and end_ts_ms: + ohlcv = _fetch_ohlcv_safe(exchange_symbol, timeframe, limit=lim) rows = _ohlcv_to_rows(ohlcv) if not rows: return [] @@ -977,7 +1076,11 @@ def generate_multi_timeframe_chart_png( ordered.append(tf) timeframes = ordered[:4] if ordered else preferred_layout - ensure_markets_loaded() + try: + ensure_markets_loaded() + except Exception: + # 模拟盘无 API Key 时 load_markets 可能失败;公开 candles 不依赖 markets + pass panels = [] cell_w, cell_h = 980, 520 end_ts_ms = None @@ -1001,17 +1104,18 @@ def generate_multi_timeframe_chart_png( now_ms=marker_payload.get("now_ts_ms"), ) if win: - ohlcv = exchange.fetch_ohlcv( + ohlcv = _fetch_ohlcv_safe( exchange_symbol, - timeframe=tf, - since=max(0, int(win["since_ms"])), + tf, limit=int(win["fetch_limit"]), + since_ms=max(0, int(win["since_ms"])), + end_ts_ms=end_ts_ms, ) rows = trim_rows_for_trade_review(_ohlcv_to_rows(ohlcv), win) if not rows: ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms) if not ohlcv and end_ts_ms: - ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=tf, limit=limit) + ohlcv = _fetch_ohlcv_safe(exchange_symbol, tf, limit=limit) rows = _ohlcv_to_rows(ohlcv)[-limit:] except Exception: rows = [] diff --git a/lib/sim/broker_lib.py b/lib/sim/broker_lib.py index 9f788bf..9561ad0 100644 --- a/lib/sim/broker_lib.py +++ b/lib/sim/broker_lib.py @@ -12,7 +12,19 @@ from lib.sim.wallets_lib import InsufficientFunds, SimWallets def _now() -> str: - return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + """业务时区墙钟(默认 Asia/Shanghai);勿用 utcnow,否则复盘K线标记会偏 8 小时.""" + try: + from lib.options.options_time_lib import options_app_now_str + + return options_app_now_str() + except Exception: + from zoneinfo import ZoneInfo + + tz = ZoneInfo( + (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() + or "Asia/Shanghai" + ) + return datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S") def _ticker_bid_ask(exchange: Any, symbol: str) -> tuple[float, float]: diff --git a/tests/test_okx_public_ohlcv_mapping.py b/tests/test_okx_public_ohlcv_mapping.py new file mode 100644 index 0000000..f5f4b89 --- /dev/null +++ b/tests/test_okx_public_ohlcv_mapping.py @@ -0,0 +1,40 @@ +"""OKX 公开 K 线小测(无网络):instId / bar 映射.""" +from __future__ import annotations + +import unittest + + +class OkxPublicOhlcvMappingTests(unittest.TestCase): + def test_swap_inst_and_bar(self): + # 避免 import 整站 app:直接复刻映射逻辑做契约校验 + def swap_inst(exchange_symbol: str) -> str: + sym = str(exchange_symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + base, rest = sym.split("/", 1) + quote = rest.split(":", 1)[0].strip() or "USDT" + return f"{base.strip()}-{quote}-SWAP" + if sym.endswith("-SWAP"): + return sym + return sym + + def public_bar(timeframe: str) -> str: + low = str(timeframe or "").strip().lower() + if low.endswith("m") and low[:-1].isdigit(): + return low + if low.endswith("h") and low[:-1].isdigit(): + return f"{low[:-1]}H" + if low.endswith("d") and low[:-1].isdigit(): + return f"{low[:-1]}D" + return low or "15m" + + self.assertEqual(swap_inst("ETH/USDT:USDT"), "ETH-USDT-SWAP") + self.assertEqual(swap_inst("BTC/USDT:USDT"), "BTC-USDT-SWAP") + self.assertEqual(public_bar("15m"), "15m") + self.assertEqual(public_bar("1h"), "1H") + self.assertEqual(public_bar("4h"), "4H") + + +if __name__ == "__main__": + unittest.main()