修复 OKX K 线无 since 时只拉 300 根的问题,并加入行情快捷键

- fetch_ohlcv_for_hub:无 since 时按目标根数分页拉取(OKX/Gate 单次约 300)

- hub_kline_store 全量补拉传 fetch_start_ms

- 行情区:数字键切换周期、Ctrl+空格全屏、Esc 退出全屏

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-02 14:51:37 +08:00
parent 16927444d7
commit bfffc7d984
6 changed files with 241 additions and 26 deletions
+1 -1
View File
@@ -255,7 +255,7 @@ def resolve_chart_bars(
remote_err: Optional[str] = None
if need_fetch:
since = None
since = fetch_start_ms
if db_rows and not force_refresh and newest_ok and len(db_rows) >= need:
since = max(0, int(newest_db) - period_ms * 2)
remote = remote_fetch(
+7 -5
View File
@@ -189,13 +189,18 @@ def fetch_ohlcv_for_hub(
ex_sym = normalize_exchange_symbol(sym)
want = max(1, min(int(limit or bar_limit_for_timeframe(tf)), 1500))
chunk_max = 300
period = TIMEFRAME_MS[tf]
collected: list = []
if since_ms is not None and int(since_ms) > 0:
since = int(since_ms)
else:
# OKX/Gate 等无 since 时单次常被限制在 ~300 根,须从目标起点分页向前拉
since = max(0, int(time.time() * 1000) - want * period)
guard = 0
prev_since = None
while len(collected) < want and guard < 60:
while len(collected) < want and guard < 80:
guard += 1
req_limit = min(chunk_max, want - len(collected))
batch = exchange.fetch_ohlcv(
@@ -204,14 +209,11 @@ def fetch_ohlcv_for_hub(
if not batch:
break
collected.extend(batch)
next_since = int(batch[-1][0]) + 1
next_since = int(batch[-1][0]) + period
if prev_since is not None and next_since <= prev_since:
break
prev_since = since
since = next_since
else:
batch = exchange.fetch_ohlcv(ex_sym, timeframe=tf, limit=want)
collected = list(batch or [])
bars = _bars_to_dicts(collected)
if not bars:
+4
View File
@@ -1970,6 +1970,10 @@ body.login-page {
font-variant-numeric: tabular-nums;
}
.market-countdown.market-tf-key-hint {
color: #ffb84d;
}
.market-chart-wrap {
display: flex;
flex-direction: column;
+180 -3
View File
@@ -22,6 +22,28 @@
"1d": 24 * 60 * 60_000,
"1w": 7 * 24 * 60 * 60_000,
};
const TF_BY_MINUTES = {
"1": "1m",
"5": "5m",
"15": "15m",
"60": "1h",
"240": "4h",
"1440": "1d",
"10080": "1w",
};
const TF_MINUTE_KEYS = Object.keys(TF_BY_MINUTES).sort(function (a, b) {
return b.length - a.length;
});
const TF_CN_LABEL = {
"1m": "1分钟",
"5m": "5分钟",
"15m": "15分钟",
"1h": "1小时",
"4h": "4小时",
"1d": "日线",
"1w": "周线",
};
const TF_DIGIT_TIMEOUT_MS = 650;
const chartHost = document.getElementById("market-chart");
if (!chartHost) return;
@@ -103,6 +125,9 @@
let lastViewKey = "";
let currentTf = "1d";
let priceTagTimer = null;
let tfDigitBuf = "";
let tfDigitTimer = null;
let tfHintTimer = null;
function escHtml(s) {
return String(s || "")
@@ -654,6 +679,155 @@
updateHeaderLabels(elSymbol && elSymbol.value, elTf && elTf.value);
}
function isMarketPageActive() {
const page = document.getElementById("page-market");
return !!(page && !page.classList.contains("hidden"));
}
function isTypingInField(target) {
if (!target) return false;
const tag = (target.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return true;
return !!target.isContentEditable;
}
function canUseTfKeyboard(e) {
if (!isMarketPageActive()) return false;
if (e.altKey || e.ctrlKey || e.metaKey) return false;
if (isTypingInField(e.target)) return false;
return true;
}
function canExtendTfDigitBuffer(buf) {
if (!buf) return false;
return TF_MINUTE_KEYS.some(function (k) {
return k.indexOf(buf) === 0;
});
}
function resolveTfFromDigitBuffer(buf) {
if (!buf) return null;
return TF_BY_MINUTES[buf] || null;
}
function flashTfSwitchHint(tf) {
const label = TF_CN_LABEL[tf] || tf;
const text = "周期 → " + label + "" + tf + "";
if (elTfLabel) elTfLabel.textContent = tf;
if (elBarCountdown) {
if (tfHintTimer) clearTimeout(tfHintTimer);
elBarCountdown.textContent = text;
elBarCountdown.classList.add("market-tf-key-hint");
tfHintTimer = setTimeout(function () {
tfHintTimer = null;
elBarCountdown.classList.remove("market-tf-key-hint");
tickLiveClock();
}, 1200);
return;
}
if (elStatus) {
if (tfHintTimer) clearTimeout(tfHintTimer);
const prevClass = elStatus.className;
const prevText = elStatus.textContent;
elStatus.className = "market-status";
elStatus.textContent = text;
tfHintTimer = setTimeout(function () {
tfHintTimer = null;
elStatus.className = prevClass;
elStatus.textContent = prevText;
}, 1200);
}
}
function applyTimeframe(tf, fromKeyboard) {
if (!tf || !TF_MS[tf]) return false;
const cur = (elTf && elTf.value) || currentTf;
if (cur === tf) return false;
if (elTf) elTf.value = tf;
if (elFsTf) elFsTf.value = tf;
currentTf = tf;
tickLiveClock();
updateHeaderLabels(
elSymbol && elSymbol.value.trim().toUpperCase(),
tf
);
syncFsToolbarFromMain();
if (fromKeyboard) flashTfSwitchHint(tf);
loadChart(false);
return true;
}
function commitTfDigitBuffer() {
const buf = tfDigitBuf;
tfDigitBuf = "";
if (tfDigitTimer) {
clearTimeout(tfDigitTimer);
tfDigitTimer = null;
}
const tf = resolveTfFromDigitBuffer(buf);
if (tf) applyTimeframe(tf, true);
}
function handleTfDigitKey(digit) {
if (!digit) return;
if (tfDigitBuf && !canExtendTfDigitBuffer(tfDigitBuf)) {
tfDigitBuf = "";
}
tfDigitBuf += digit;
const immediate = resolveTfFromDigitBuffer(tfDigitBuf);
if (immediate) {
commitTfDigitBuffer();
return;
}
if (!canExtendTfDigitBuffer(tfDigitBuf)) {
tfDigitBuf = digit;
const again = resolveTfFromDigitBuffer(tfDigitBuf);
if (again) {
commitTfDigitBuffer();
return;
}
}
if (tfDigitTimer) clearTimeout(tfDigitTimer);
tfDigitTimer = setTimeout(commitTfDigitBuffer, TF_DIGIT_TIMEOUT_MS);
}
function isFullscreenShortcut(e) {
return (
e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
!e.shiftKey &&
(e.code === "Space" || e.key === " " || e.key === "Spacebar")
);
}
function onMarketKeydown(e) {
if (!isMarketPageActive()) return;
if (e.key === "Escape" && chartFullscreen) {
e.preventDefault();
setChartFullscreen(false);
return;
}
if (isFullscreenShortcut(e)) {
e.preventDefault();
toggleChartFullscreen();
return;
}
if (!canUseTfKeyboard(e)) return;
if (e.key >= "0" && e.key <= "9") {
e.preventDefault();
handleTfDigitKey(e.key);
return;
}
if (e.key === "Enter" && tfDigitBuf) {
e.preventDefault();
commitTfDigitBuffer();
}
}
function populateFsExchangeOptions() {
if (!elFsExchange || !elExchange) return;
elFsExchange.innerHTML = elExchange.innerHTML;
@@ -1367,6 +1541,11 @@
}
if (elTf) {
elTf.addEventListener("change", function () {
tfDigitBuf = "";
if (tfDigitTimer) {
clearTimeout(tfDigitTimer);
tfDigitTimer = null;
}
currentTf = (elTf && elTf.value) || "1d";
tickLiveClock();
syncFsToolbarFromMain();
@@ -1418,9 +1597,7 @@
updateIndicators();
});
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && chartFullscreen) setChartFullscreen(false);
});
document.addEventListener("keydown", onMarketKeydown);
if (elFsExchange) {
elFsExchange.addEventListener("change", function () {
syncMainFromFsToolbar();
+3 -3
View File
@@ -60,7 +60,7 @@
<div id="page-market" class="page hidden">
<div class="page-head">
<h1><span class="head-tag">MKT</span> 行情区</h1>
<p class="page-desc">按需拉取 K 线,本地库保留 15 天(无后台自动更新)</p>
<p class="page-desc">按需拉取 K 线,本地库保留 15 天(无后台自动更新)。快捷键:<kbd>Ctrl</kbd>+<kbd>空格</kbd> 全屏/退出全屏(全屏时 <kbd>Esc</kbd> 退出);数字键切换周期(分钟):1/5/15/60/240/1440/10080。</p>
</div>
<details class="hint-box">
<summary>数据说明</summary>
@@ -112,7 +112,7 @@
<label class="market-ind-opt"><input type="checkbox" id="market-ind-rsi" value="rsi" /> RSI</label>
</div>
</details>
<button type="button" id="market-chart-fullscreen" class="ghost market-fs-btn" title="K线全屏">全屏</button>
<button type="button" id="market-chart-fullscreen" class="ghost market-fs-btn" title="K线全屏Ctrl+空格)">全屏</button>
</div>
</div>
<div class="market-ohlcv-row">
@@ -240,7 +240,7 @@
<div id="toast"></div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart.js?v=20260528-hub-ind-pane-fix"></script>
<script src="/assets/chart.js?v=20260528-hub-fs-keys"></script>
<script src="/assets/app.js?v=20260528-hub-tpsl-fix"></script>
</body>
</html>
+33 -1
View File
@@ -17,7 +17,9 @@ class _FakeExchange:
if not self.pages:
return []
page = self.pages.pop(0)
return [b for b in page if b[0] >= since] if since else page
if since is None:
return page
return [b for b in page if b[0] >= since]
class TestHubOhlcvLib(unittest.TestCase):
@@ -63,6 +65,36 @@ class TestHubOhlcvLib(unittest.TestCase):
tick = price_tick_from_market(_Ex(), "INJ/USDT:USDT")
self.assertAlmostEqual(tick, 0.001)
def test_full_fetch_without_since_paginates_okx_style(self):
"""OKX 等无 since 单次约 300 根,须分页至 limit。"""
from hub_ohlcv_lib import TIMEFRAME_MS
step = TIMEFRAME_MS["1h"]
want = 1000
base = max(0, int(__import__("time").time() * 1000) - want * step)
pages = [
[[base + i * step, 1.0, 1.1, 0.9, 1.05, 100.0] for i in range(300)],
[[base + (300 + i) * step, 2.0, 2.1, 1.9, 2.05, 200.0] for i in range(300)],
[[base + (600 + i) * step, 3.0, 3.1, 2.9, 3.05, 300.0] for i in range(300)],
[[base + (900 + i) * step, 4.0, 4.1, 3.9, 4.05, 400.0] for i in range(100)],
]
ex = _FakeExchange(pages)
out = fetch_ohlcv_for_hub(
symbol="ONDO/USDT",
timeframe="1h",
since_ms=None,
limit=want,
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"))
self.assertEqual(len(out.get("bars") or []), 1000)
self.assertGreaterEqual(len(ex.calls), 4)
self.assertAlmostEqual(out["bars"][-1]["close"], 4.05)
def test_pagination_continues_when_page_smaller_than_chunk(self):
"""Gate 等常返回 299 根/次,不应误判为已到末尾。"""
base = 1_700_000_000_000