From 3ac854d74cfbcb94df76590af022e3d6e030a985 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 8 Jun 2026 07:54:37 +0800 Subject: [PATCH] Remove 12h timeframe and stabilize chart wheel zoom. Drop 12h from market chart options and storage, and avoid left-pan reload and tail refresh from resetting the viewport while zooming. Co-authored-by: Cursor --- hub_kline_store.py | 2 +- hub_ohlcv_lib.py | 15 +++----- manual_trading_hub/static/chart.js | 56 +++++++++++++++++++--------- manual_trading_hub/static/index.html | 2 - tests/test_hub_ohlcv_lib.py | 41 +++----------------- 5 files changed, 50 insertions(+), 66 deletions(-) diff --git a/hub_kline_store.py b/hub_kline_store.py index cd43345..3a15832 100644 --- a/hub_kline_store.py +++ b/hub_kline_store.py @@ -200,7 +200,7 @@ def purge_1m_bar_cap(db_path: Path | None = None, *, max_bars: int | None = None def purge_retention(db_path: Path | None = None) -> int: - """按周期策略清理:5m/1h 一年;1m 保留最近 N 根;12h/1d/1w 不删。""" + """按周期策略清理:5m/1h 一年;1m 保留最近 N 根;1d/1w 不删。""" n = 0 n += purge_timeframe_by_days("5m", HUB_KLINE_5M_1H_RETENTION_DAYS, db_path) n += purge_timeframe_by_days("1h", HUB_KLINE_5M_1H_RETENTION_DAYS, db_path) diff --git a/hub_ohlcv_lib.py b/hub_ohlcv_lib.py index 3420571..49ca062 100644 --- a/hub_ohlcv_lib.py +++ b/hub_ohlcv_lib.py @@ -15,7 +15,6 @@ CHART_TIMEFRAMES = frozenset( "1h", "2h", "4h", - "12h", "1d", "1w", } @@ -27,15 +26,14 @@ CHART_TIMEFRAME_ORDER = ( "1h", "2h", "4h", - "12h", "1d", "1w", ) DAILY_PLUS_TIMEFRAMES = frozenset({"1d", "1w"}) # 入库 / 同步真源(交易所拉取) -STORED_TIMEFRAMES = frozenset({"1m", "5m", "1h", "12h", "1d", "1w"}) -PERMANENT_STORED_TIMEFRAMES = frozenset({"12h", "1d", "1w"}) +STORED_TIMEFRAMES = frozenset({"1m", "5m", "1h", "1d", "1w"}) +PERMANENT_STORED_TIMEFRAMES = frozenset({"1d", "1w"}) YEAR_ROLLING_STORED = frozenset({"5m", "1h"}) # 展示周期 → 本地聚合源(不落库) @@ -52,10 +50,8 @@ HUB_KLINE_1M_MAX_BARS = max(1000, int(os.getenv("HUB_KLINE_1M_MAX_BARS", "10000" HUB_KLINE_5M_1H_RETENTION_DAYS = max(30, int(os.getenv("HUB_KLINE_5M_1H_RETENTION_DAYS", "365"))) HUB_KLINE_SEED_BARS = max(100, int(os.getenv("HUB_KLINE_SEED_BARS", "500"))) -# 部分交易所 ccxt 无原生 12h,或原生 K 线间隔异常时从 1h 聚合(仅远程拉取 fallback) -OHLCV_AGGREGATE_FROM: dict[str, str] = { - "12h": "1h", -} +# 交易所无原生周期时的远程拉取 fallback(行情区当前无映射) +OHLCV_AGGREGATE_FROM: dict[str, str] = {} TIMEFRAME_MS: dict[str, int] = { "1m": 60_000, @@ -126,7 +122,7 @@ def bar_limit_for_timeframe(timeframe: str) -> int: def storage_retention_days(storage_tf: str) -> int | None: - """None 表示不按天截断(1m 按根数;12h/1d/1w 永久)。""" + """None 表示不按天截断(1m 按根数;1d/1w 永久)。""" tf = normalize_chart_timeframe(storage_tf) if tf in YEAR_ROLLING_STORED: return HUB_KLINE_5M_1H_RETENTION_DAYS @@ -159,7 +155,6 @@ def retention_policy_meta() -> dict[str, Any]: "1m": {"mode": "bars", "max_bars": HUB_KLINE_1M_MAX_BARS}, "5m": {"mode": "days", "days": HUB_KLINE_5M_1H_RETENTION_DAYS}, "1h": {"mode": "days", "days": HUB_KLINE_5M_1H_RETENTION_DAYS}, - "12h": {"mode": "permanent"}, "1d": {"mode": "permanent"}, "1w": {"mode": "permanent"}, "aggregate_from": dict(CHART_DISPLAY_AGGREGATE_FROM), diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index bae3e74..16e1e38 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -14,7 +14,6 @@ "1h": 200, "2h": 200, "4h": 200, - "12h": 200, "1d": 200, "1w": 150, }; @@ -25,7 +24,6 @@ "1h": 300, "2h": 300, "4h": 300, - "12h": 200, "1d": 200, "1w": 150, }; @@ -36,7 +34,6 @@ "1h": 1000, "2h": 1000, "4h": 1000, - "12h": 1000, "1d": 1000, "1w": 500, }; @@ -56,7 +53,6 @@ "1h": 60 * 60_000, "2h": 2 * 60 * 60_000, "4h": 4 * 60 * 60_000, - "12h": 12 * 60 * 60_000, "1d": 24 * 60 * 60_000, "1w": 7 * 24 * 60 * 60_000, }; @@ -67,7 +63,6 @@ "60": "1h", "120": "2h", "240": "4h", - "720": "12h", "1440": "1d", "10080": "1w", }; @@ -81,7 +76,6 @@ "1h": "1小时", "2h": "2小时", "4h": "4小时", - "12h": "12小时", "1d": "日线", "1w": "周线", }; @@ -179,6 +173,8 @@ let loadingLeft = false; let chartDataLoading = false; let chartViewEpoch = 0; + let rangeUiTimer = null; + let loadOlderTimer = null; let priceTagTimer = null; let tfDigitBuf = ""; let tfDigitTimer = null; @@ -1954,8 +1950,7 @@ }); chart.timeScale().subscribeVisibleLogicalRangeChange(function (range) { - updateVisibleRangeMarkers(); - updatePriceTag(); + scheduleRangeUiUpdate(); if ( !range || chartDataLoading || @@ -1967,9 +1962,7 @@ return; } if (currentChartViewKey() !== lastViewKey) return; - if (range.from < CHART_LOAD_LEFT_THRESHOLD) { - void loadOlderCandles(); - } + scheduleLoadOlderOnRange(range); }); window.addEventListener("resize", function () { @@ -2032,6 +2025,37 @@ return range.to >= maxTo - 24; } + function shouldLoadOlderOnRange(range) { + if (!range || !lastCandles.length) return false; + const n = lastCandles.length; + const maxTo = n - 1 + RIGHT_OFFSET_BARS; + if (range.from >= CHART_LOAD_LEFT_THRESHOLD) return false; + // 缩小图表时 from 会变小,但 to 仍靠近最新 — 不应触发左拖补历史 + if (range.to >= maxTo - 30) return false; + return true; + } + + function scheduleRangeUiUpdate() { + if (rangeUiTimer) clearTimeout(rangeUiTimer); + rangeUiTimer = setTimeout(function () { + rangeUiTimer = null; + updateVisibleRangeMarkers(); + updatePriceTag(); + }, 120); + } + + function scheduleLoadOlderOnRange(range) { + if (!shouldLoadOlderOnRange(range)) return; + if (loadOlderTimer) clearTimeout(loadOlderTimer); + loadOlderTimer = setTimeout(function () { + loadOlderTimer = null; + if (!chart) return; + const cur = chart.timeScale().getVisibleLogicalRange(); + if (!shouldLoadOlderOnRange(cur)) return; + void loadOlderCandles(); + }, 280); + } + function tailVisibleLogicalRange(candleCount) { const n = Math.max(0, Number(candleCount) || 0); if (n <= 0) return null; @@ -2196,20 +2220,18 @@ if (epochAtStart !== chartViewEpoch) return; const n = lastCandles.length; const curRange = chart && chart.timeScale().getVisibleLogicalRange(); - if (wasViewingTail) { - const tailRange = tailVisibleLogicalRange(n); - if (tailRange) chart.timeScale().setVisibleLogicalRange(tailRange); - } else if ( + const minorTailUpdate = Math.abs(n - candleCountBefore) <= 5; + if ( savedRange && isVisibleRangeValidForCandles(savedRange, n) && - Math.abs(n - candleCountBefore) < chartChunkLimit(tf) + (minorTailUpdate || !wasViewingTail) ) { chart.timeScale().setVisibleLogicalRange(savedRange); } else if (!isVisibleRangeValidForCandles(curRange, n)) { const tailRange = tailVisibleLogicalRange(n); if (tailRange) chart.timeScale().setVisibleLogicalRange(tailRange); } - updateVisibleRangeMarkers(); + scheduleRangeUiUpdate(); if (posContext) { updateLivePosPnl(); refreshPosPnlFromBoard(); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index de8f387..a415ddd 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -93,7 +93,6 @@ - @@ -150,7 +149,6 @@ - diff --git a/tests/test_hub_ohlcv_lib.py b/tests/test_hub_ohlcv_lib.py index 8cd1498..99786ff 100644 --- a/tests/test_hub_ohlcv_lib.py +++ b/tests/test_hub_ohlcv_lib.py @@ -167,37 +167,6 @@ class TestHubOhlcvLib(unittest.TestCase): self.assertGreaterEqual(len(ex.calls), 3) self.assertAlmostEqual(out["bars"][-1]["close"], 3.05) - def test_aggregate_12h_from_1h_when_exchange_lacks_native(self): - """无原生 12h 时应从 1h 聚合。""" - from hub_ohlcv_lib import TIMEFRAME_MS - - h1 = TIMEFRAME_MS["1h"] - h12 = TIMEFRAME_MS["12h"] - base = (1_700_000_000_000 // h12) * h12 - one_h = [ - [base + i * h1, 100.0 + i, 101.0 + i, 99.0 + i, 100.5 + i, 10.0] - for i in range(48) - ] - ex = _FakeExchange( - [one_h], - timeframes={"1h": "1h", "4h": "4h"}, - ) - out = fetch_ohlcv_for_hub( - symbol="BTC/USDT", - timeframe="12h", - since_ms=base, - limit=4, - 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")) - bars = out.get("bars") or [] - self.assertEqual(len(bars), 4) - self.assertTrue(bars_spacing_matches_timeframe(bars, "12h")) - self.assertEqual(ex.calls[0]["timeframe"], "1h") - def test_pagination_stops_when_next_since_reaches_now(self): """Gate 等:分页 since 不得越过当前时间,避免 from>to。""" from hub_ohlcv_lib import TIMEFRAME_MS @@ -230,8 +199,8 @@ class TestHubOhlcvLib(unittest.TestCase): from hub_ohlcv_lib import TIMEFRAME_MS h1 = TIMEFRAME_MS["1h"] - h12 = TIMEFRAME_MS["12h"] - base = (1_700_000_000_000 // h12) * h12 + h4 = TIMEFRAME_MS["4h"] + base = (1_700_000_000_000 // h4) * h4 src = [ { "open_time_ms": base + i * h1, @@ -241,11 +210,11 @@ class TestHubOhlcvLib(unittest.TestCase): "close": 1.5, "volume": 1.0, } - for i in range(12) + for i in range(4) ] - out = aggregate_ohlcv_bars(src, "12h") + out = aggregate_ohlcv_bars(src, "4h") self.assertEqual(len(out), 1) - self.assertEqual(out[0]["volume"], 12.0) + self.assertEqual(out[0]["volume"], 4.0) self.assertEqual(out[0]["high"], 2.0) self.assertEqual(out[0]["low"], 0.5)