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