mirror of
https://github.com/OpenByteInc/QuantDinger.git
synced 2026-09-28 15:22:56 +08:00
114 lines
3.8 KiB
Python
114 lines
3.8 KiB
Python
import math
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from app.services.backtest.metrics import benchmark_level_curve, calculate_information_ratio
|
|
|
|
|
|
def _curve(returns, *, frequency="D"):
|
|
value = 100.0
|
|
index = pd.date_range("2026-01-01", periods=len(returns) + 1, freq=frequency, tz="UTC")
|
|
points = [{"time": index[0].isoformat(), "value": value}]
|
|
for timestamp, periodic_return in zip(index[1:], returns):
|
|
value *= 1.0 + periodic_return
|
|
points.append({"time": timestamp.isoformat(), "value": value})
|
|
return points
|
|
|
|
|
|
def test_information_ratio_uses_aligned_periodic_returns_and_sample_tracking_error():
|
|
result = calculate_information_ratio(
|
|
_curve([0.01, 0.02, -0.01, 0.005]),
|
|
_curve([0.005, 0.01, -0.005, 0.002]),
|
|
benchmark="USStock:SPY",
|
|
frequency="1d",
|
|
annualization_factor=252,
|
|
market="USStock",
|
|
)
|
|
|
|
assert result["status"] == "available"
|
|
assert result["portfolioReturnAnnualized"] == pytest.approx(1.575)
|
|
assert result["benchmarkReturnAnnualized"] == pytest.approx(0.756)
|
|
assert result["informationRatio"] == pytest.approx(8.270196225621152)
|
|
assert result["classification"] == "exceptional"
|
|
assert result["observations"] == 4
|
|
|
|
|
|
def test_metric_samples_portfolio_at_native_benchmark_frequency():
|
|
index = pd.date_range("2026-01-01", periods=61, freq="min", tz="UTC")
|
|
values = [100.0 * (1.0001**position) for position in range(len(index))]
|
|
portfolio = [
|
|
{"time": timestamp.isoformat(), "value": value}
|
|
for timestamp, value in zip(index, values)
|
|
]
|
|
benchmark_frame = pd.DataFrame({"close": values[::15]}, index=index[::15])
|
|
|
|
result = calculate_information_ratio(
|
|
portfolio,
|
|
benchmark_level_curve(benchmark_frame),
|
|
benchmark="Crypto:BTC/USDT",
|
|
frequency="15m",
|
|
annualization_factor=365.25 * 24 * 4,
|
|
market="Crypto",
|
|
)
|
|
|
|
assert result["status"] == "zero_tracking_error"
|
|
assert result["observations"] == 4
|
|
assert result["trackingErrorAnnualized"] == pytest.approx(0.0, abs=1e-12)
|
|
|
|
|
|
def test_metric_rejects_missing_continuous_market_periods():
|
|
times = ["2026-01-01T00:00:00Z", "2026-01-01T00:30:00Z", "2026-01-01T00:45:00Z"]
|
|
portfolio = [{"time": time, "value": value} for time, value in zip(times, [100.0, 121.0, 133.1])]
|
|
benchmark = [{"time": time, "value": value} for time, value in zip(times, [100.0, 102.01, 103.0301])]
|
|
|
|
result = calculate_information_ratio(
|
|
portfolio,
|
|
benchmark,
|
|
benchmark="Crypto:BTC/USDT",
|
|
frequency="15m",
|
|
annualization_factor=365.25 * 24 * 4,
|
|
market="Crypto",
|
|
)
|
|
|
|
assert result["status"] == "insufficient_history"
|
|
assert result["observations"] == 1
|
|
|
|
|
|
def test_metric_accepts_consecutive_equity_sessions_across_weekend():
|
|
portfolio = [
|
|
{"time": "2026-01-09T00:00:00Z", "value": 100.0},
|
|
{"time": "2026-01-12T00:00:00Z", "value": 101.0},
|
|
]
|
|
benchmark = [
|
|
{"time": "2026-01-09T00:00:00Z", "value": 100.0},
|
|
{"time": "2026-01-12T00:00:00Z", "value": 100.5},
|
|
]
|
|
|
|
result = calculate_information_ratio(
|
|
portfolio,
|
|
benchmark,
|
|
benchmark="USStock:SPY",
|
|
frequency="1d",
|
|
annualization_factor=252,
|
|
market="USStock",
|
|
)
|
|
|
|
assert result["observations"] == 1
|
|
assert result["portfolioReturnAnnualized"] == pytest.approx(2.52)
|
|
|
|
|
|
def test_information_ratio_preserves_negative_values_and_custom_bands():
|
|
result = calculate_information_ratio(
|
|
_curve([-0.01, -0.02, 0.005, -0.01]),
|
|
_curve([0.005, -0.005, 0.01, 0.002]),
|
|
benchmark="custom",
|
|
frequency="1d",
|
|
annualization_factor=252,
|
|
market="USStock",
|
|
classification_bands=((0.0, "negative"), (math.inf, "positive")),
|
|
)
|
|
|
|
assert result["informationRatio"] < 0
|
|
assert result["classification"] == "negative"
|