Files
QuantDinger/backend_api_python/tests/test_crypto_timeframe_resample.py
T
Yeadon8888 80a124ca9a fix(crypto): resample on backend when exchange lacks the requested timeframe
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.
2026-05-12 16:57:21 +08:00

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'