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:
+1
-1
@@ -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
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user