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:
"""按周期策略清理: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)
+5 -10
View File
@@ -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),
+39 -17
View File
@@ -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();
-2
View File
@@ -93,7 +93,6 @@
<option value="1h">1h</option>
<option value="2h">2h</option>
<option value="4h">4h</option>
<option value="12h">12h</option>
<option value="1d" selected>1d</option>
<option value="1w">1w</option>
</select>
@@ -150,7 +149,6 @@
<option value="1h">1h</option>
<option value="2h">2h</option>
<option value="4h">4h</option>
<option value="12h">12h</option>
<option value="1d">1d</option>
<option value="1w">1w</option>
</select>
+5 -36
View File
@@ -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)