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)