"""Terminmarktdaten: Zuordnung ohne Blick in die Zukunft, Merkmale, Ausfallverhalten.""" from __future__ import annotations import numpy as np import pytest from trademind.config import Config, DerivativesConfig from trademind.derivatives import ( DerivativeSeries, DerivativesProvider, forward_fill_to_bars, perpetual_symbol, ) from trademind.features import ( BASE_FEATURE_NAMES, build_feature_matrix, feature_names, n_features, ) from trademind.models import Candles from .conftest import make_candles BAR = 300_000 # ------------------------------------------------------------------ Symbolik @pytest.mark.parametrize( ("spot", "perp"), [ ("BTC/USDT", "BTC/USDT:USDT"), ("ETH/USDC", "ETH/USDC:USDC"), ("SOL/USDT:USDT", "SOL/USDT:USDT"), # schon ein Perpetual ], ) def test_perpetual_symbol(spot: str, perp: str): assert perpetual_symbol(spot) == perp # ------------------------------------------------- Zuordnung ohne Lookahead def test_forward_fill_uses_only_past_values(): bars = np.array([1000, 2000, 3000, 4000], dtype=np.int64) src_ts = np.array([1500, 3500], dtype=np.int64) src_val = np.array([10.0, 20.0]) values, known = forward_fill_to_bars(bars, src_ts, src_val) assert not known[0], "vor dem ersten Quellwert darf nichts bekannt sein" assert values[1] == 10.0 # 1500 <= 2000 assert values[2] == 10.0 # 3500 liegt in der Zukunft von 3000 assert values[3] == 20.0 # 3500 <= 4000 assert list(known) == [False, True, True, True] def test_value_exactly_on_the_bar_counts_as_known(): values, known = forward_fill_to_bars( np.array([2000], dtype=np.int64), np.array([2000], dtype=np.int64), np.array([7.0]) ) assert known[0] and values[0] == 7.0 def test_unsorted_source_is_handled(): values, _ = forward_fill_to_bars( np.array([5000], dtype=np.int64), np.array([3000, 1000, 2000], dtype=np.int64), np.array([30.0, 10.0, 20.0]), ) assert values[0] == 30.0, "der jüngste Wert vor der Kerze zählt" def test_empty_source_yields_nothing_known(): values, known = forward_fill_to_bars( np.arange(3, dtype=np.int64), np.empty(0, np.int64), np.empty(0) ) assert not known.any() assert np.isnan(values).all() # --------------------------------------------------------- Merkmalsanzahl def test_feature_count_depends_on_configuration(): assert n_features(None) == len(BASE_FEATURE_NAMES) == 18 assert n_features(DerivativesConfig(enabled=False)) == 18 assert n_features(DerivativesConfig(enabled=True)) == 22 assert n_features(DerivativesConfig(enabled=True, open_interest=False)) == 20 assert n_features(DerivativesConfig(enabled=True, funding_rate=False)) == 20 def test_feature_names_are_unique_and_ordered(): names = feature_names(DerivativesConfig(enabled=True)) assert names[:18] == BASE_FEATURE_NAMES assert len(set(names)) == len(names) assert names[18:] == ("funding_bps", "funding_trend", "oi_change", "oi_price_divergence") # ------------------------------------------------------ Merkmalsberechnung def series_for(candles, funding: float = 0.0001, oi_growth: float = 0.0) -> DerivativeSeries: n = len(candles) oi = 100_000.0 * (1.0 + oi_growth * np.arange(n) / max(n - 1, 1)) return DerivativeSeries( symbol=candles.symbol, timestamp=candles.timestamp, funding_rate=np.full(n, funding), open_interest=oi, funding_coverage=1.0, oi_coverage=1.0, ) def test_matrix_gains_columns_when_enabled(candles, rules): plain = build_feature_matrix(candles, rules) enriched = build_feature_matrix( candles, rules, DerivativesConfig(enabled=True), series_for(candles) ) assert plain.values.shape[1] == 18 assert enriched.values.shape[1] == 22 assert np.allclose(plain.values, enriched.values[:, :18]), "Basismerkmale dürfen sich nicht ändern" def test_funding_is_converted_to_basis_points(candles, rules): matrix = build_feature_matrix( candles, rules, DerivativesConfig(enabled=True), series_for(candles, funding=0.0003) ) snapshot = matrix.snapshot(-1).as_dict() assert snapshot["funding_bps"] == pytest.approx(3.0) # 0,03 % = 3 bps assert snapshot["funding_trend"] == pytest.approx(0.0, abs=1e-9) # konstant, kein Trend def test_rising_open_interest_shows_up_as_positive_change(candles, rules): matrix = build_feature_matrix( candles, rules, DerivativesConfig(enabled=True), series_for(candles, oi_growth=0.5) ) assert matrix.snapshot(-1).as_dict()["oi_change"] > 0 def test_divergence_sign_follows_open_interest(rules): up = make_candles(n=400, trend=0.002, noise=0.0002, seed=3) rising = build_feature_matrix( up, rules, DerivativesConfig(enabled=True), series_for(up, oi_growth=0.5) ).snapshot(-1).as_dict() falling = build_feature_matrix( up, rules, DerivativesConfig(enabled=True), series_for(up, oi_growth=-0.3) ).snapshot(-1).as_dict() # Steigender Kurs mit steigendem OI = neue Positionen, mit fallendem OI = Glattstellung. assert rising["oi_price_divergence"] > 0 assert falling["oi_price_divergence"] < 0 def test_missing_series_keeps_the_dimension_stable(candles, rules): """Fällt die Datenquelle aus, bleiben die Spalten erhalten – neutral gefüllt.""" matrix = build_feature_matrix(candles, rules, DerivativesConfig(enabled=True), None) assert matrix.values.shape[1] == 22 assert np.isfinite(matrix.values).all() snapshot = matrix.snapshot(-1).as_dict() assert snapshot["funding_bps"] == 0.0 assert snapshot["oi_change"] == 0.0 def test_values_stay_within_the_clip_limit(candles, rules): """Auch absurde Terminmarktwerte dürfen die Normierung nicht sprengen.""" n = len(candles) extreme = DerivativeSeries( symbol=candles.symbol, timestamp=candles.timestamp, funding_rate=np.full(n, 0.75), # 7500 bps open_interest=np.geomspace(1.0, 1e9, n), funding_coverage=1.0, oi_coverage=1.0, ) matrix = build_feature_matrix(candles, rules, DerivativesConfig(enabled=True), extreme) assert np.abs(matrix.values).max() <= 8.0 assert np.isfinite(matrix.values).all() # ---------------------------------------------------------- Ausfallverhalten class FlakyExchange: """Börse, die für Funding funktioniert und bei Open Interest scheitert.""" rateLimit = 0 def __init__(self, funding_rows=None): self.funding_rows = funding_rows or [] self.oi_calls = 0 async def fetch_funding_rate_history(self, symbol, since=None, limit=None): rows = [r for r in self.funding_rows if since is None or r["timestamp"] >= since] return rows[:limit] if limit else rows async def fetch_open_interest_history(self, symbol, timeframe, since=None, limit=None): self.oi_calls += 1 raise RuntimeError("startTime is invalid") async def close(self): return None async def test_failing_source_is_recorded_not_raised(candles): start = int(candles.timestamp[0]) rows = [{"timestamp": start - 3_600_000, "fundingRate": 0.0002}] provider = DerivativesProvider(FlakyExchange(rows), DerivativesConfig(enabled=True)) series = await provider.series_for(candles) assert series.funding_coverage == 1.0 assert series.oi_coverage == 0.0 assert any("open_interest" in key for key in provider.failures) assert provider.snapshot()["failures"] async def test_provider_returns_empty_series_for_empty_candles(): blank = Candles.from_rows("BTC/USDT", "5m", []) provider = DerivativesProvider(FlakyExchange(), DerivativesConfig(enabled=True)) series = await provider.series_for(blank) assert series.timestamp.size == 0 assert not series.usable async def test_incremental_fetch_does_not_refetch_everything(candles): start = int(candles.timestamp[0]) rows = [{"timestamp": start - 3_600_000 + i * 8 * 3_600_000, "fundingRate": 0.0001} for i in range(4)] class Counting(FlakyExchange): def __init__(self, rows): super().__init__(rows) self.funding_calls = 0 async def fetch_funding_rate_history(self, symbol, since=None, limit=None): self.funding_calls += 1 return await super().fetch_funding_rate_history(symbol, since, limit) exchange = Counting(rows) provider = DerivativesProvider(exchange, DerivativesConfig(enabled=True, open_interest=False)) await provider.series_for(candles) first = exchange.funding_calls await provider.series_for(candles) assert exchange.funding_calls - first <= 1, "der zweite Lauf darf nur nachladen" # -------------------------------------------------------------- Konfiguration def test_derivatives_switches_require_a_restart(): from trademind.config import requires_restart for path in ("strategy.derivatives.enabled", "strategy.derivatives.funding_rate", "strategy.derivatives.open_interest"): assert requires_restart(path), f"{path} ändert die Modelldimension" def test_derivatives_are_off_by_default(): assert Config().strategy.derivatives.enabled is False