mirror of
https://github.com/OpenByteInc/QuantDinger.git
synced 2026-09-28 23:32:55 +08:00
When the configured CCXT exchange doesn't natively support the requested
timeframe — e.g. Coinbase Advanced Trade, which is the only Binance fallback
reachable from regions where Binance is geo-blocked (HTTP 451), and which
exposes only {1m,5m,15m,30m,1h,2h,6h,1d} — `fetch_ohlcv(..., '1w', ...)`
fails with `parsing field "granularity": "1w" is not a valid value` and the
indicator IDE chart shows "数据加载失败: No data found".
This affects 1W, 4H, and 3m on Coinbase out of the box.
Fix: detect at the data-source layer that `ccxt_timeframe not in exchange.timeframes`,
pick the finest supported source granularity, fetch that, and aggregate to the
requested period before returning. No frontend change required.
- New class constant `_RESAMPLE_CANDIDATES` declaring per-target source→bucket
preferences (3m → 1m×3, 4h → 2h×2 or 1h×4, 1w → 1d×7).
- New `_pick_resample_source(target, exchange.timeframes)` chooses the first
supported candidate; returns None when nothing finer is available so the
caller can log and return [] cleanly instead of hitting the upstream 422.
- New `_resample_ohlcv(ohlcv, bucket)` aggregates OHLCV rows: bucket open =
first.open, high = max, low = min, close = last.close, volume = sum,
timestamp = first.timestamp. Drops the trailing partial bucket so every
returned candle represents a full period.
- New `_ccxt_to_qd_timeframe` inverts `CCXTConfig.TIMEFRAME_MAP` for the
internal call to `_fetch_ohlcv`, keeping its time-range/limit accounting
consistent with the source granularity actually being fetched.
- `get_kline` now switches into the resample path when needed; fetch limit
is capped at 300 (CCXT/Coinbase single-call max) to avoid silent truncation.
For 1W this yields ~42 weekly candles per request, enough for chart display.
Tests: `tests/test_crypto_timeframe_resample.py` — 13 cases covering helper
selection (1W, 4H pref 2h, 4H fallback 1h, 3m, no candidate, unknown target),
OHLCV aggregation math (full bucket, multiple buckets, drop trailing partial,
bucket=1 passthrough, empty input), and TIMEFRAME_MAP round-trip. All pass.
Verified against running Coinbase-backed backend: before the fix,
`GET /api/indicator/kline?market=Crypto&symbol=BTC/USDT&timeframe=1W` returned
55 bytes (empty array + envelope) and logs showed the granularity-422.
118 lines
4.9 KiB
Python
118 lines
4.9 KiB
Python
"""Regression tests for CryptoDataSource timeframe resampling.
|
|
|
|
Background: Coinbase Advanced Trade exposes only {1m, 5m, 15m, 30m, 1h, 2h, 6h, 1d}
|
|
via CCXT — it has no 1w, 4h, or 3m. Before this fix, requesting timeframe=1W on a
|
|
Coinbase-backed deployment hit `parsing field "granularity": "1w" is not a valid value`
|
|
and the indicator IDE chart showed "No data found".
|
|
|
|
These tests pin the two helpers that make the resample work, without needing a live
|
|
exchange or the full data source __init__:
|
|
- `_pick_resample_source` chooses a finer supported granularity + bucket size
|
|
- `_resample_ohlcv` aggregates source candles into target-period candles correctly
|
|
"""
|
|
from app.data_sources.crypto import CryptoDataSource
|
|
|
|
|
|
# --- _pick_resample_source -------------------------------------------------
|
|
|
|
COINBASE_LIKE = {'1m': 1, '5m': 1, '15m': 1, '30m': 1, '1h': 1, '2h': 1, '6h': 1, '1d': 1}
|
|
BINANCE_LIKE = {'1m': 1, '3m': 1, '5m': 1, '15m': 1, '30m': 1, '1h': 1, '2h': 1, '4h': 1, '6h': 1, '12h': 1, '1d': 1, '1w': 1}
|
|
|
|
|
|
def test_pick_source_for_1w_on_coinbase_uses_1d_x7():
|
|
assert CryptoDataSource._pick_resample_source('1w', COINBASE_LIKE) == ('1d', 7)
|
|
|
|
|
|
def test_pick_source_for_4h_on_coinbase_prefers_2h_over_1h():
|
|
# 2h exists on coinbase and gives a smaller bucket (fewer requested candles), so prefer it.
|
|
assert CryptoDataSource._pick_resample_source('4h', COINBASE_LIKE) == ('2h', 2)
|
|
|
|
|
|
def test_pick_source_for_4h_falls_back_to_1h_when_2h_missing():
|
|
only_1h = {k: 1 for k in COINBASE_LIKE if k != '2h'}
|
|
assert CryptoDataSource._pick_resample_source('4h', only_1h) == ('1h', 4)
|
|
|
|
|
|
def test_pick_source_for_3m_on_coinbase_uses_1m_x3():
|
|
assert CryptoDataSource._pick_resample_source('3m', COINBASE_LIKE) == ('1m', 3)
|
|
|
|
|
|
def test_pick_source_returns_none_when_no_supported_finer_granularity():
|
|
# Made-up exchange with only daily resolution can't resample to 1w (well it could,
|
|
# 1d→1w x7 needs 1d), but it can't resample to 4h. Verify the None-on-miss branch.
|
|
only_daily = {'1d': 1}
|
|
assert CryptoDataSource._pick_resample_source('4h', only_daily) is None
|
|
|
|
|
|
def test_pick_source_returns_none_for_unknown_target_timeframe():
|
|
assert CryptoDataSource._pick_resample_source('99x', COINBASE_LIKE) is None
|
|
|
|
|
|
# --- _resample_ohlcv -------------------------------------------------------
|
|
|
|
def _candle(ts_ms, o, h, l, c, v):
|
|
return [ts_ms, o, h, l, c, v]
|
|
|
|
|
|
def test_resample_aggregates_OHLCV_correctly():
|
|
# 7 daily candles → 1 weekly candle
|
|
daily = [
|
|
_candle(1000, 100, 110, 95, 105, 10),
|
|
_candle(2000, 105, 120, 100, 115, 12),
|
|
_candle(3000, 115, 125, 110, 118, 8),
|
|
_candle(4000, 118, 130, 112, 122, 15),
|
|
_candle(5000, 122, 128, 115, 117, 11),
|
|
_candle(6000, 117, 119, 105, 108, 9),
|
|
_candle(7000, 108, 114, 98, 102, 13),
|
|
]
|
|
out = CryptoDataSource._resample_ohlcv(daily, 7)
|
|
assert len(out) == 1
|
|
[ts, op, hi, lo, cl, vol] = out[0]
|
|
assert ts == 1000 # bucket timestamp = first candle's
|
|
assert op == 100 # bucket open = first candle's open
|
|
assert hi == 130 # max high across the bucket
|
|
assert lo == 95 # min low across the bucket
|
|
assert cl == 102 # close = last candle's close
|
|
assert vol == 10+12+8+15+11+9+13
|
|
|
|
|
|
def test_resample_drops_incomplete_trailing_bucket():
|
|
# 10 daily candles, bucket=7 → 1 full bucket, 3 trailing dropped
|
|
daily = [_candle(1000 * (i + 1), 100, 110, 90, 105, 1) for i in range(10)]
|
|
out = CryptoDataSource._resample_ohlcv(daily, 7)
|
|
assert len(out) == 1
|
|
assert out[0][0] == 1000 # first bucket's start
|
|
|
|
|
|
def test_resample_two_full_buckets():
|
|
daily = [_candle(1000 * (i + 1), 100, 110, 90, 105, 2) for i in range(14)]
|
|
out = CryptoDataSource._resample_ohlcv(daily, 7)
|
|
assert len(out) == 2
|
|
assert out[0][0] == 1000 # first bucket starts at first candle
|
|
assert out[1][0] == 8000 # second bucket starts at 8th candle
|
|
assert out[0][5] == 14 # volume sum = 7 * 2
|
|
assert out[1][5] == 14
|
|
|
|
|
|
def test_resample_bucket_size_one_is_passthrough():
|
|
daily = [_candle(1000, 100, 110, 90, 105, 1)]
|
|
assert CryptoDataSource._resample_ohlcv(daily, 1) == daily
|
|
|
|
|
|
def test_resample_empty_returns_empty():
|
|
assert CryptoDataSource._resample_ohlcv([], 7) == []
|
|
|
|
|
|
# --- _ccxt_to_qd_timeframe -------------------------------------------------
|
|
|
|
def test_ccxt_to_qd_timeframe_inverts_known_mappings():
|
|
# The TIMEFRAME_MAP maps QD '1D' → ccxt '1d', '1W' → '1w', '1H' → '1h'.
|
|
# The reverse helper must round-trip these for the resample-path bookkeeping.
|
|
assert CryptoDataSource._ccxt_to_qd_timeframe('1d', fallback='1W') == '1D'
|
|
assert CryptoDataSource._ccxt_to_qd_timeframe('1h', fallback='4H') == '1H'
|
|
assert CryptoDataSource._ccxt_to_qd_timeframe('1m', fallback='1m') == '1m'
|
|
|
|
|
|
def test_ccxt_to_qd_timeframe_returns_fallback_for_unknown():
|
|
assert CryptoDataSource._ccxt_to_qd_timeframe('totally-fake', fallback='1W') == '1W'
|