修复 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
|
remote_err: Optional[str] = None
|
||||||
|
|
||||||
if need_fetch:
|
if need_fetch:
|
||||||
since = None
|
since = fetch_start_ms
|
||||||
if db_rows and not force_refresh and newest_ok and len(db_rows) >= need:
|
if db_rows and not force_refresh and newest_ok and len(db_rows) >= need:
|
||||||
since = max(0, int(newest_db) - period_ms * 2)
|
since = max(0, int(newest_db) - period_ms * 2)
|
||||||
remote = remote_fetch(
|
remote = remote_fetch(
|
||||||
|
|||||||
+20
-18
@@ -189,29 +189,31 @@ def fetch_ohlcv_for_hub(
|
|||||||
ex_sym = normalize_exchange_symbol(sym)
|
ex_sym = normalize_exchange_symbol(sym)
|
||||||
want = max(1, min(int(limit or bar_limit_for_timeframe(tf)), 1500))
|
want = max(1, min(int(limit or bar_limit_for_timeframe(tf)), 1500))
|
||||||
chunk_max = 300
|
chunk_max = 300
|
||||||
|
period = TIMEFRAME_MS[tf]
|
||||||
collected: list = []
|
collected: list = []
|
||||||
|
|
||||||
if since_ms is not None and int(since_ms) > 0:
|
if since_ms is not None and int(since_ms) > 0:
|
||||||
since = int(since_ms)
|
since = int(since_ms)
|
||||||
guard = 0
|
|
||||||
prev_since = None
|
|
||||||
while len(collected) < want and guard < 60:
|
|
||||||
guard += 1
|
|
||||||
req_limit = min(chunk_max, want - len(collected))
|
|
||||||
batch = exchange.fetch_ohlcv(
|
|
||||||
ex_sym, timeframe=tf, since=since, limit=req_limit
|
|
||||||
)
|
|
||||||
if not batch:
|
|
||||||
break
|
|
||||||
collected.extend(batch)
|
|
||||||
next_since = int(batch[-1][0]) + 1
|
|
||||||
if prev_since is not None and next_since <= prev_since:
|
|
||||||
break
|
|
||||||
prev_since = since
|
|
||||||
since = next_since
|
|
||||||
else:
|
else:
|
||||||
batch = exchange.fetch_ohlcv(ex_sym, timeframe=tf, limit=want)
|
# OKX/Gate 等无 since 时单次常被限制在 ~300 根,须从目标起点分页向前拉
|
||||||
collected = list(batch or [])
|
since = max(0, int(time.time() * 1000) - want * period)
|
||||||
|
|
||||||
|
guard = 0
|
||||||
|
prev_since = None
|
||||||
|
while len(collected) < want and guard < 80:
|
||||||
|
guard += 1
|
||||||
|
req_limit = min(chunk_max, want - len(collected))
|
||||||
|
batch = exchange.fetch_ohlcv(
|
||||||
|
ex_sym, timeframe=tf, since=since, limit=req_limit
|
||||||
|
)
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
collected.extend(batch)
|
||||||
|
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
|
||||||
|
|
||||||
bars = _bars_to_dicts(collected)
|
bars = _bars_to_dicts(collected)
|
||||||
if not bars:
|
if not bars:
|
||||||
|
|||||||
@@ -1970,6 +1970,10 @@ body.login-page {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.market-countdown.market-tf-key-hint {
|
||||||
|
color: #ffb84d;
|
||||||
|
}
|
||||||
|
|
||||||
.market-chart-wrap {
|
.market-chart-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -22,6 +22,28 @@
|
|||||||
"1d": 24 * 60 * 60_000,
|
"1d": 24 * 60 * 60_000,
|
||||||
"1w": 7 * 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");
|
const chartHost = document.getElementById("market-chart");
|
||||||
if (!chartHost) return;
|
if (!chartHost) return;
|
||||||
|
|
||||||
@@ -103,6 +125,9 @@
|
|||||||
let lastViewKey = "";
|
let lastViewKey = "";
|
||||||
let currentTf = "1d";
|
let currentTf = "1d";
|
||||||
let priceTagTimer = null;
|
let priceTagTimer = null;
|
||||||
|
let tfDigitBuf = "";
|
||||||
|
let tfDigitTimer = null;
|
||||||
|
let tfHintTimer = null;
|
||||||
|
|
||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
return String(s || "")
|
return String(s || "")
|
||||||
@@ -654,6 +679,155 @@
|
|||||||
updateHeaderLabels(elSymbol && elSymbol.value, elTf && elTf.value);
|
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() {
|
function populateFsExchangeOptions() {
|
||||||
if (!elFsExchange || !elExchange) return;
|
if (!elFsExchange || !elExchange) return;
|
||||||
elFsExchange.innerHTML = elExchange.innerHTML;
|
elFsExchange.innerHTML = elExchange.innerHTML;
|
||||||
@@ -1367,6 +1541,11 @@
|
|||||||
}
|
}
|
||||||
if (elTf) {
|
if (elTf) {
|
||||||
elTf.addEventListener("change", function () {
|
elTf.addEventListener("change", function () {
|
||||||
|
tfDigitBuf = "";
|
||||||
|
if (tfDigitTimer) {
|
||||||
|
clearTimeout(tfDigitTimer);
|
||||||
|
tfDigitTimer = null;
|
||||||
|
}
|
||||||
currentTf = (elTf && elTf.value) || "1d";
|
currentTf = (elTf && elTf.value) || "1d";
|
||||||
tickLiveClock();
|
tickLiveClock();
|
||||||
syncFsToolbarFromMain();
|
syncFsToolbarFromMain();
|
||||||
@@ -1418,9 +1597,7 @@
|
|||||||
updateIndicators();
|
updateIndicators();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.addEventListener("keydown", function (e) {
|
document.addEventListener("keydown", onMarketKeydown);
|
||||||
if (e.key === "Escape" && chartFullscreen) setChartFullscreen(false);
|
|
||||||
});
|
|
||||||
if (elFsExchange) {
|
if (elFsExchange) {
|
||||||
elFsExchange.addEventListener("change", function () {
|
elFsExchange.addEventListener("change", function () {
|
||||||
syncMainFromFsToolbar();
|
syncMainFromFsToolbar();
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
<div id="page-market" class="page hidden">
|
<div id="page-market" class="page hidden">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1><span class="head-tag">MKT</span> 行情区</h1>
|
<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>
|
</div>
|
||||||
<details class="hint-box">
|
<details class="hint-box">
|
||||||
<summary>数据说明</summary>
|
<summary>数据说明</summary>
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
<label class="market-ind-opt"><input type="checkbox" id="market-ind-rsi" value="rsi" /> RSI</label>
|
<label class="market-ind-opt"><input type="checkbox" id="market-ind-rsi" value="rsi" /> RSI</label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</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>
|
</div>
|
||||||
<div class="market-ohlcv-row">
|
<div class="market-ohlcv-row">
|
||||||
@@ -240,7 +240,7 @@
|
|||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
<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>
|
<script src="/assets/app.js?v=20260528-hub-tpsl-fix"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ class _FakeExchange:
|
|||||||
if not self.pages:
|
if not self.pages:
|
||||||
return []
|
return []
|
||||||
page = self.pages.pop(0)
|
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):
|
class TestHubOhlcvLib(unittest.TestCase):
|
||||||
@@ -63,6 +65,36 @@ class TestHubOhlcvLib(unittest.TestCase):
|
|||||||
tick = price_tick_from_market(_Ex(), "INJ/USDT:USDT")
|
tick = price_tick_from_market(_Ex(), "INJ/USDT:USDT")
|
||||||
self.assertAlmostEqual(tick, 0.001)
|
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):
|
def test_pagination_continues_when_page_smaller_than_chunk(self):
|
||||||
"""Gate 等常返回 299 根/次,不应误判为已到末尾。"""
|
"""Gate 等常返回 299 根/次,不应误判为已到末尾。"""
|
||||||
base = 1_700_000_000_000
|
base = 1_700_000_000_000
|
||||||
|
|||||||
Reference in New Issue
Block a user