diff --git a/hub_kline_store.py b/hub_kline_store.py index ba02981..78020d1 100644 --- a/hub_kline_store.py +++ b/hub_kline_store.py @@ -255,7 +255,7 @@ def resolve_chart_bars( remote_err: Optional[str] = None if need_fetch: - since = None + since = fetch_start_ms if db_rows and not force_refresh and newest_ok and len(db_rows) >= need: since = max(0, int(newest_db) - period_ms * 2) remote = remote_fetch( diff --git a/hub_ohlcv_lib.py b/hub_ohlcv_lib.py index fc37857..ba4a271 100644 --- a/hub_ohlcv_lib.py +++ b/hub_ohlcv_lib.py @@ -189,29 +189,31 @@ def fetch_ohlcv_for_hub( ex_sym = normalize_exchange_symbol(sym) want = max(1, min(int(limit or bar_limit_for_timeframe(tf)), 1500)) chunk_max = 300 + period = TIMEFRAME_MS[tf] collected: list = [] if since_ms is not None and int(since_ms) > 0: since = int(since_ms) - guard = 0 - prev_since = None - while len(collected) < want and guard < 60: - guard += 1 - req_limit = min(chunk_max, want - len(collected)) - batch = exchange.fetch_ohlcv( - ex_sym, timeframe=tf, since=since, limit=req_limit - ) - if not batch: - break - collected.extend(batch) - next_since = int(batch[-1][0]) + 1 - if prev_since is not None and next_since <= prev_since: - break - prev_since = since - since = next_since else: - batch = exchange.fetch_ohlcv(ex_sym, timeframe=tf, limit=want) - collected = list(batch or []) + # OKX/Gate 等无 since 时单次常被限制在 ~300 根,须从目标起点分页向前拉 + since = max(0, int(time.time() * 1000) - want * period) + + guard = 0 + prev_since = None + while len(collected) < want and guard < 80: + guard += 1 + req_limit = min(chunk_max, want - len(collected)) + batch = exchange.fetch_ohlcv( + ex_sym, timeframe=tf, since=since, limit=req_limit + ) + if not batch: + break + collected.extend(batch) + next_since = int(batch[-1][0]) + period + if prev_since is not None and next_since <= prev_since: + break + prev_since = since + since = next_since bars = _bars_to_dicts(collected) if not bars: diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index ac155b7..368e7ef 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -1970,6 +1970,10 @@ body.login-page { font-variant-numeric: tabular-nums; } +.market-countdown.market-tf-key-hint { + color: #ffb84d; +} + .market-chart-wrap { display: flex; flex-direction: column; diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index df1f28a..87af65c 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -22,6 +22,28 @@ "1d": 24 * 60 * 60_000, "1w": 7 * 24 * 60 * 60_000, }; + const TF_BY_MINUTES = { + "1": "1m", + "5": "5m", + "15": "15m", + "60": "1h", + "240": "4h", + "1440": "1d", + "10080": "1w", + }; + const TF_MINUTE_KEYS = Object.keys(TF_BY_MINUTES).sort(function (a, b) { + return b.length - a.length; + }); + const TF_CN_LABEL = { + "1m": "1分钟", + "5m": "5分钟", + "15m": "15分钟", + "1h": "1小时", + "4h": "4小时", + "1d": "日线", + "1w": "周线", + }; + const TF_DIGIT_TIMEOUT_MS = 650; const chartHost = document.getElementById("market-chart"); if (!chartHost) return; @@ -103,6 +125,9 @@ let lastViewKey = ""; let currentTf = "1d"; let priceTagTimer = null; + let tfDigitBuf = ""; + let tfDigitTimer = null; + let tfHintTimer = null; function escHtml(s) { return String(s || "") @@ -654,6 +679,155 @@ updateHeaderLabels(elSymbol && elSymbol.value, elTf && elTf.value); } + function isMarketPageActive() { + const page = document.getElementById("page-market"); + return !!(page && !page.classList.contains("hidden")); + } + + function isTypingInField(target) { + if (!target) return false; + const tag = (target.tagName || "").toLowerCase(); + if (tag === "input" || tag === "textarea" || tag === "select") return true; + return !!target.isContentEditable; + } + + function canUseTfKeyboard(e) { + if (!isMarketPageActive()) return false; + if (e.altKey || e.ctrlKey || e.metaKey) return false; + if (isTypingInField(e.target)) return false; + return true; + } + + function canExtendTfDigitBuffer(buf) { + if (!buf) return false; + return TF_MINUTE_KEYS.some(function (k) { + return k.indexOf(buf) === 0; + }); + } + + function resolveTfFromDigitBuffer(buf) { + if (!buf) return null; + return TF_BY_MINUTES[buf] || null; + } + + function flashTfSwitchHint(tf) { + const label = TF_CN_LABEL[tf] || tf; + const text = "周期 → " + label + "(" + tf + ")"; + if (elTfLabel) elTfLabel.textContent = tf; + if (elBarCountdown) { + if (tfHintTimer) clearTimeout(tfHintTimer); + elBarCountdown.textContent = text; + elBarCountdown.classList.add("market-tf-key-hint"); + tfHintTimer = setTimeout(function () { + tfHintTimer = null; + elBarCountdown.classList.remove("market-tf-key-hint"); + tickLiveClock(); + }, 1200); + return; + } + if (elStatus) { + if (tfHintTimer) clearTimeout(tfHintTimer); + const prevClass = elStatus.className; + const prevText = elStatus.textContent; + elStatus.className = "market-status"; + elStatus.textContent = text; + tfHintTimer = setTimeout(function () { + tfHintTimer = null; + elStatus.className = prevClass; + elStatus.textContent = prevText; + }, 1200); + } + } + + function applyTimeframe(tf, fromKeyboard) { + if (!tf || !TF_MS[tf]) return false; + const cur = (elTf && elTf.value) || currentTf; + if (cur === tf) return false; + if (elTf) elTf.value = tf; + if (elFsTf) elFsTf.value = tf; + currentTf = tf; + tickLiveClock(); + updateHeaderLabels( + elSymbol && elSymbol.value.trim().toUpperCase(), + tf + ); + syncFsToolbarFromMain(); + if (fromKeyboard) flashTfSwitchHint(tf); + loadChart(false); + return true; + } + + function commitTfDigitBuffer() { + const buf = tfDigitBuf; + tfDigitBuf = ""; + if (tfDigitTimer) { + clearTimeout(tfDigitTimer); + tfDigitTimer = null; + } + const tf = resolveTfFromDigitBuffer(buf); + if (tf) applyTimeframe(tf, true); + } + + function handleTfDigitKey(digit) { + if (!digit) return; + if (tfDigitBuf && !canExtendTfDigitBuffer(tfDigitBuf)) { + tfDigitBuf = ""; + } + tfDigitBuf += digit; + const immediate = resolveTfFromDigitBuffer(tfDigitBuf); + if (immediate) { + commitTfDigitBuffer(); + return; + } + if (!canExtendTfDigitBuffer(tfDigitBuf)) { + tfDigitBuf = digit; + const again = resolveTfFromDigitBuffer(tfDigitBuf); + if (again) { + commitTfDigitBuffer(); + return; + } + } + if (tfDigitTimer) clearTimeout(tfDigitTimer); + tfDigitTimer = setTimeout(commitTfDigitBuffer, TF_DIGIT_TIMEOUT_MS); + } + + function isFullscreenShortcut(e) { + return ( + e.ctrlKey && + !e.altKey && + !e.metaKey && + !e.shiftKey && + (e.code === "Space" || e.key === " " || e.key === "Spacebar") + ); + } + + function onMarketKeydown(e) { + if (!isMarketPageActive()) return; + + if (e.key === "Escape" && chartFullscreen) { + e.preventDefault(); + setChartFullscreen(false); + return; + } + + if (isFullscreenShortcut(e)) { + e.preventDefault(); + toggleChartFullscreen(); + return; + } + + if (!canUseTfKeyboard(e)) return; + if (e.key >= "0" && e.key <= "9") { + e.preventDefault(); + handleTfDigitKey(e.key); + return; + } + if (e.key === "Enter" && tfDigitBuf) { + e.preventDefault(); + commitTfDigitBuffer(); + } + } + function populateFsExchangeOptions() { if (!elFsExchange || !elExchange) return; elFsExchange.innerHTML = elExchange.innerHTML; @@ -1367,6 +1541,11 @@ } if (elTf) { elTf.addEventListener("change", function () { + tfDigitBuf = ""; + if (tfDigitTimer) { + clearTimeout(tfDigitTimer); + tfDigitTimer = null; + } currentTf = (elTf && elTf.value) || "1d"; tickLiveClock(); syncFsToolbarFromMain(); @@ -1418,9 +1597,7 @@ updateIndicators(); }); }); - document.addEventListener("keydown", function (e) { - if (e.key === "Escape" && chartFullscreen) setChartFullscreen(false); - }); + document.addEventListener("keydown", onMarketKeydown); if (elFsExchange) { elFsExchange.addEventListener("change", function () { syncMainFromFsToolbar(); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 2941ae5..7e44996 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -60,7 +60,7 @@ - +
@@ -240,7 +240,7 @@
- + diff --git a/tests/test_hub_ohlcv_lib.py b/tests/test_hub_ohlcv_lib.py index b108023..bd9a1fa 100644 --- a/tests/test_hub_ohlcv_lib.py +++ b/tests/test_hub_ohlcv_lib.py @@ -17,7 +17,9 @@ class _FakeExchange: if not self.pages: return [] page = self.pages.pop(0) - return [b for b in page if b[0] >= since] if since else page + if since is None: + return page + return [b for b in page if b[0] >= since] class TestHubOhlcvLib(unittest.TestCase): @@ -63,6 +65,36 @@ class TestHubOhlcvLib(unittest.TestCase): tick = price_tick_from_market(_Ex(), "INJ/USDT:USDT") self.assertAlmostEqual(tick, 0.001) + def test_full_fetch_without_since_paginates_okx_style(self): + """OKX 等无 since 单次约 300 根,须分页至 limit。""" + from hub_ohlcv_lib import TIMEFRAME_MS + + step = TIMEFRAME_MS["1h"] + want = 1000 + base = max(0, int(__import__("time").time() * 1000) - want * step) + pages = [ + [[base + i * step, 1.0, 1.1, 0.9, 1.05, 100.0] for i in range(300)], + [[base + (300 + i) * step, 2.0, 2.1, 1.9, 2.05, 200.0] for i in range(300)], + [[base + (600 + i) * step, 3.0, 3.1, 2.9, 3.05, 300.0] for i in range(300)], + [[base + (900 + i) * step, 4.0, 4.1, 3.9, 4.05, 400.0] for i in range(100)], + ] + ex = _FakeExchange(pages) + + out = fetch_ohlcv_for_hub( + symbol="ONDO/USDT", + timeframe="1h", + since_ms=None, + limit=want, + normalize_symbol_input=lambda s: str(s).strip().upper(), + normalize_exchange_symbol=lambda s: f"{s}:USDT" if ":" not in s else s, + ensure_markets_loaded=lambda: None, + exchange=ex, + ) + self.assertTrue(out.get("ok")) + self.assertEqual(len(out.get("bars") or []), 1000) + self.assertGreaterEqual(len(ex.calls), 4) + self.assertAlmostEqual(out["bars"][-1]["close"], 4.05) + def test_pagination_continues_when_page_smaller_than_chunk(self): """Gate 等常返回 299 根/次,不应误判为已到末尾。""" base = 1_700_000_000_000