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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-08 07:54:37 +08:00
parent 4afea6bb97
commit 3ac854d74c
5 changed files with 50 additions and 66 deletions
+1 -1
View File
@@ -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: 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 = 0
n += purge_timeframe_by_days("5m", HUB_KLINE_5M_1H_RETENTION_DAYS, db_path) 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) n += purge_timeframe_by_days("1h", HUB_KLINE_5M_1H_RETENTION_DAYS, db_path)
+5 -10
View File
@@ -15,7 +15,6 @@ CHART_TIMEFRAMES = frozenset(
"1h", "1h",
"2h", "2h",
"4h", "4h",
"12h",
"1d", "1d",
"1w", "1w",
} }
@@ -27,15 +26,14 @@ CHART_TIMEFRAME_ORDER = (
"1h", "1h",
"2h", "2h",
"4h", "4h",
"12h",
"1d", "1d",
"1w", "1w",
) )
DAILY_PLUS_TIMEFRAMES = frozenset({"1d", "1w"}) DAILY_PLUS_TIMEFRAMES = frozenset({"1d", "1w"})
# 入库 / 同步真源(交易所拉取) # 入库 / 同步真源(交易所拉取)
STORED_TIMEFRAMES = frozenset({"1m", "5m", "1h", "12h", "1d", "1w"}) STORED_TIMEFRAMES = frozenset({"1m", "5m", "1h", "1d", "1w"})
PERMANENT_STORED_TIMEFRAMES = frozenset({"12h", "1d", "1w"}) PERMANENT_STORED_TIMEFRAMES = frozenset({"1d", "1w"})
YEAR_ROLLING_STORED = frozenset({"5m", "1h"}) 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_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"))) HUB_KLINE_SEED_BARS = max(100, int(os.getenv("HUB_KLINE_SEED_BARS", "500")))
# 部分交易所 ccxt 无原生 12h,或原生 K 线间隔异常时从 1h 聚合(仅远程拉取 fallback # 交易所无原生周期时的远程拉取 fallback(行情区当前无映射
OHLCV_AGGREGATE_FROM: dict[str, str] = { OHLCV_AGGREGATE_FROM: dict[str, str] = {}
"12h": "1h",
}
TIMEFRAME_MS: dict[str, int] = { TIMEFRAME_MS: dict[str, int] = {
"1m": 60_000, "1m": 60_000,
@@ -126,7 +122,7 @@ def bar_limit_for_timeframe(timeframe: str) -> int:
def storage_retention_days(storage_tf: str) -> int | None: def storage_retention_days(storage_tf: str) -> int | None:
"""None 表示不按天截断(1m 按根数;12h/1d/1w 永久)。""" """None 表示不按天截断(1m 按根数;1d/1w 永久)。"""
tf = normalize_chart_timeframe(storage_tf) tf = normalize_chart_timeframe(storage_tf)
if tf in YEAR_ROLLING_STORED: if tf in YEAR_ROLLING_STORED:
return HUB_KLINE_5M_1H_RETENTION_DAYS 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}, "1m": {"mode": "bars", "max_bars": HUB_KLINE_1M_MAX_BARS},
"5m": {"mode": "days", "days": HUB_KLINE_5M_1H_RETENTION_DAYS}, "5m": {"mode": "days", "days": HUB_KLINE_5M_1H_RETENTION_DAYS},
"1h": {"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"}, "1d": {"mode": "permanent"},
"1w": {"mode": "permanent"}, "1w": {"mode": "permanent"},
"aggregate_from": dict(CHART_DISPLAY_AGGREGATE_FROM), "aggregate_from": dict(CHART_DISPLAY_AGGREGATE_FROM),
+39 -17
View File
@@ -14,7 +14,6 @@
"1h": 200, "1h": 200,
"2h": 200, "2h": 200,
"4h": 200, "4h": 200,
"12h": 200,
"1d": 200, "1d": 200,
"1w": 150, "1w": 150,
}; };
@@ -25,7 +24,6 @@
"1h": 300, "1h": 300,
"2h": 300, "2h": 300,
"4h": 300, "4h": 300,
"12h": 200,
"1d": 200, "1d": 200,
"1w": 150, "1w": 150,
}; };
@@ -36,7 +34,6 @@
"1h": 1000, "1h": 1000,
"2h": 1000, "2h": 1000,
"4h": 1000, "4h": 1000,
"12h": 1000,
"1d": 1000, "1d": 1000,
"1w": 500, "1w": 500,
}; };
@@ -56,7 +53,6 @@
"1h": 60 * 60_000, "1h": 60 * 60_000,
"2h": 2 * 60 * 60_000, "2h": 2 * 60 * 60_000,
"4h": 4 * 60 * 60_000, "4h": 4 * 60 * 60_000,
"12h": 12 * 60 * 60_000,
"1d": 24 * 60 * 60_000, "1d": 24 * 60 * 60_000,
"1w": 7 * 24 * 60 * 60_000, "1w": 7 * 24 * 60 * 60_000,
}; };
@@ -67,7 +63,6 @@
"60": "1h", "60": "1h",
"120": "2h", "120": "2h",
"240": "4h", "240": "4h",
"720": "12h",
"1440": "1d", "1440": "1d",
"10080": "1w", "10080": "1w",
}; };
@@ -81,7 +76,6 @@
"1h": "1小时", "1h": "1小时",
"2h": "2小时", "2h": "2小时",
"4h": "4小时", "4h": "4小时",
"12h": "12小时",
"1d": "日线", "1d": "日线",
"1w": "周线", "1w": "周线",
}; };
@@ -179,6 +173,8 @@
let loadingLeft = false; let loadingLeft = false;
let chartDataLoading = false; let chartDataLoading = false;
let chartViewEpoch = 0; let chartViewEpoch = 0;
let rangeUiTimer = null;
let loadOlderTimer = null;
let priceTagTimer = null; let priceTagTimer = null;
let tfDigitBuf = ""; let tfDigitBuf = "";
let tfDigitTimer = null; let tfDigitTimer = null;
@@ -1954,8 +1950,7 @@
}); });
chart.timeScale().subscribeVisibleLogicalRangeChange(function (range) { chart.timeScale().subscribeVisibleLogicalRangeChange(function (range) {
updateVisibleRangeMarkers(); scheduleRangeUiUpdate();
updatePriceTag();
if ( if (
!range || !range ||
chartDataLoading || chartDataLoading ||
@@ -1967,9 +1962,7 @@
return; return;
} }
if (currentChartViewKey() !== lastViewKey) return; if (currentChartViewKey() !== lastViewKey) return;
if (range.from < CHART_LOAD_LEFT_THRESHOLD) { scheduleLoadOlderOnRange(range);
void loadOlderCandles();
}
}); });
window.addEventListener("resize", function () { window.addEventListener("resize", function () {
@@ -2032,6 +2025,37 @@
return range.to >= maxTo - 24; 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) { function tailVisibleLogicalRange(candleCount) {
const n = Math.max(0, Number(candleCount) || 0); const n = Math.max(0, Number(candleCount) || 0);
if (n <= 0) return null; if (n <= 0) return null;
@@ -2196,20 +2220,18 @@
if (epochAtStart !== chartViewEpoch) return; if (epochAtStart !== chartViewEpoch) return;
const n = lastCandles.length; const n = lastCandles.length;
const curRange = chart && chart.timeScale().getVisibleLogicalRange(); const curRange = chart && chart.timeScale().getVisibleLogicalRange();
if (wasViewingTail) { const minorTailUpdate = Math.abs(n - candleCountBefore) <= 5;
const tailRange = tailVisibleLogicalRange(n); if (
if (tailRange) chart.timeScale().setVisibleLogicalRange(tailRange);
} else if (
savedRange && savedRange &&
isVisibleRangeValidForCandles(savedRange, n) && isVisibleRangeValidForCandles(savedRange, n) &&
Math.abs(n - candleCountBefore) < chartChunkLimit(tf) (minorTailUpdate || !wasViewingTail)
) { ) {
chart.timeScale().setVisibleLogicalRange(savedRange); chart.timeScale().setVisibleLogicalRange(savedRange);
} else if (!isVisibleRangeValidForCandles(curRange, n)) { } else if (!isVisibleRangeValidForCandles(curRange, n)) {
const tailRange = tailVisibleLogicalRange(n); const tailRange = tailVisibleLogicalRange(n);
if (tailRange) chart.timeScale().setVisibleLogicalRange(tailRange); if (tailRange) chart.timeScale().setVisibleLogicalRange(tailRange);
} }
updateVisibleRangeMarkers(); scheduleRangeUiUpdate();
if (posContext) { if (posContext) {
updateLivePosPnl(); updateLivePosPnl();
refreshPosPnlFromBoard(); refreshPosPnlFromBoard();
-2
View File
@@ -93,7 +93,6 @@
<option value="1h">1h</option> <option value="1h">1h</option>
<option value="2h">2h</option> <option value="2h">2h</option>
<option value="4h">4h</option> <option value="4h">4h</option>
<option value="12h">12h</option>
<option value="1d" selected>1d</option> <option value="1d" selected>1d</option>
<option value="1w">1w</option> <option value="1w">1w</option>
</select> </select>
@@ -150,7 +149,6 @@
<option value="1h">1h</option> <option value="1h">1h</option>
<option value="2h">2h</option> <option value="2h">2h</option>
<option value="4h">4h</option> <option value="4h">4h</option>
<option value="12h">12h</option>
<option value="1d">1d</option> <option value="1d">1d</option>
<option value="1w">1w</option> <option value="1w">1w</option>
</select> </select>
+5 -36
View File
@@ -167,37 +167,6 @@ class TestHubOhlcvLib(unittest.TestCase):
self.assertGreaterEqual(len(ex.calls), 3) self.assertGreaterEqual(len(ex.calls), 3)
self.assertAlmostEqual(out["bars"][-1]["close"], 3.05) 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): def test_pagination_stops_when_next_since_reaches_now(self):
"""Gate 等:分页 since 不得越过当前时间,避免 from>to。""" """Gate 等:分页 since 不得越过当前时间,避免 from>to。"""
from hub_ohlcv_lib import TIMEFRAME_MS from hub_ohlcv_lib import TIMEFRAME_MS
@@ -230,8 +199,8 @@ class TestHubOhlcvLib(unittest.TestCase):
from hub_ohlcv_lib import TIMEFRAME_MS from hub_ohlcv_lib import TIMEFRAME_MS
h1 = TIMEFRAME_MS["1h"] h1 = TIMEFRAME_MS["1h"]
h12 = TIMEFRAME_MS["12h"] h4 = TIMEFRAME_MS["4h"]
base = (1_700_000_000_000 // h12) * h12 base = (1_700_000_000_000 // h4) * h4
src = [ src = [
{ {
"open_time_ms": base + i * h1, "open_time_ms": base + i * h1,
@@ -241,11 +210,11 @@ class TestHubOhlcvLib(unittest.TestCase):
"close": 1.5, "close": 1.5,
"volume": 1.0, "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(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]["high"], 2.0)
self.assertEqual(out[0]["low"], 0.5) self.assertEqual(out[0]["low"], 0.5)