mirror of
https://github.com/OpenByteInc/QuantDinger.git
synced 2026-09-28 15:22:56 +08:00
200 lines
6.5 KiB
Python
200 lines
6.5 KiB
Python
import pandas as pd
|
|
import pytest
|
|
|
|
from app.services.strategy_v2.factor_research import FactorResearchEngine
|
|
|
|
|
|
def test_factor_research_returns_ic_groups_costs_and_stability():
|
|
index = pd.date_range("2025-01-01", periods=90, freq="B")
|
|
frames = {}
|
|
for offset, symbol in enumerate(["A", "B", "C", "D", "E", "F"]):
|
|
prices = [100 + offset * 3 + day * (0.1 + offset * 0.02) for day in range(len(index))]
|
|
frames[f"USStock:{symbol}"] = pd.DataFrame({
|
|
"open": prices,
|
|
"high": [value * 1.01 for value in prices],
|
|
"low": [value * 0.99 for value in prices],
|
|
"close": prices,
|
|
"volume": [100000] * len(index),
|
|
"industry": ["Tech" if offset < 3 else "Finance"] * len(index),
|
|
}, index=index)
|
|
|
|
result = FactorResearchEngine().run(
|
|
frames=frames,
|
|
factor_id="momentum_20",
|
|
start_date=index[0],
|
|
end_date=index[-1],
|
|
groups=3,
|
|
holding_period=5,
|
|
commission=0.0005,
|
|
slippage=0.0005,
|
|
neutralize_industry=True,
|
|
)
|
|
|
|
assert result["icSeries"]
|
|
assert len(result["groupCurves"]) == 3
|
|
assert result["coverage"] > 0
|
|
assert result["missingRate"] < 1
|
|
assert result["neutralized"] is True
|
|
assert result["factorCorrelation"]["factors"]
|
|
assert "rankAutocorrelation" in result["stability"]
|
|
|
|
|
|
def test_factor_research_rejects_empty_cross_sectional_observations():
|
|
index = pd.date_range("2025-01-01", periods=30, freq="B")
|
|
frames = {
|
|
f"USStock:{symbol}": pd.DataFrame({
|
|
"open": [100.0] * len(index),
|
|
"close": [100.0] * len(index),
|
|
}, index=index)
|
|
for symbol in ["A", "B", "C"]
|
|
}
|
|
|
|
with pytest.raises(ValueError, match="factorResearchInsufficientObservations"):
|
|
FactorResearchEngine().run(
|
|
frames=frames,
|
|
factor_id="quality",
|
|
start_date=index[0],
|
|
end_date=index[-1],
|
|
groups=3,
|
|
)
|
|
|
|
|
|
def test_factor_research_preserves_warmup_before_selected_start():
|
|
index = pd.date_range("2025-01-01", periods=70, freq="B")
|
|
frames = {}
|
|
for offset, symbol in enumerate(["A", "B", "C", "D", "E", "F"]):
|
|
prices = [100 + offset + day * (0.1 + offset * 0.03) for day in range(len(index))]
|
|
frames[symbol] = pd.DataFrame({"open": prices, "close": prices}, index=index)
|
|
|
|
result = FactorResearchEngine().run(
|
|
frames=frames,
|
|
factor_id="momentum_20",
|
|
start_date=index[25],
|
|
end_date=index[-1],
|
|
groups=3,
|
|
holding_period=5,
|
|
)
|
|
|
|
assert pd.Timestamp(result["icSeries"][0]["time"]) == index[25]
|
|
|
|
|
|
def test_factor_research_applies_membership_intervals_on_each_research_date():
|
|
index = pd.date_range("2025-01-01", periods=70, freq="B")
|
|
symbols = ["A", "B", "C", "D", "E", "F"]
|
|
frames = {}
|
|
members = []
|
|
for offset, symbol in enumerate(symbols):
|
|
prices = [100 + offset + day * (0.1 + offset * 0.02) for day in range(len(index))]
|
|
frames[symbol] = pd.DataFrame({"open": prices, "close": prices}, index=index)
|
|
members.append({
|
|
"key": symbol,
|
|
"valid_from": index[0] if symbol in {"A", "B", "C"} else index[35],
|
|
"valid_to": None,
|
|
})
|
|
|
|
result = FactorResearchEngine().run(
|
|
frames=frames,
|
|
factor_id="momentum_20",
|
|
start_date=index[25],
|
|
end_date=index[-1],
|
|
groups=3,
|
|
holding_period=5,
|
|
members=members,
|
|
)
|
|
|
|
first_time = result["icSeries"][0]["time"]
|
|
first_members = {
|
|
symbol
|
|
for row in result["groupObservations"]
|
|
if row["time"] == first_time
|
|
for symbol in row["members"]
|
|
}
|
|
assert first_members == {"A", "B", "C"}
|
|
assert result["pointInTimeUniverseApplied"] is True
|
|
|
|
|
|
def test_factor_research_preserves_membership_gaps_and_reentry():
|
|
index = pd.date_range("2025-01-01", periods=6, freq="D")
|
|
|
|
panel = FactorResearchEngine._membership_panel(
|
|
[{
|
|
"key": "A",
|
|
"membership_periods": [
|
|
{"valid_from": index[0], "valid_to": index[2]},
|
|
{"valid_from": index[4], "valid_to": None},
|
|
],
|
|
}],
|
|
index,
|
|
pd.Index(["A", "B"]),
|
|
)
|
|
|
|
assert panel["A"].tolist() == [True, True, False, False, True, True]
|
|
assert panel["B"].tolist() == [False] * len(index)
|
|
|
|
|
|
def test_factor_research_long_short_net_return_deducts_both_legs_costs():
|
|
long_values = [{
|
|
"time": "2025-01-01",
|
|
"grossReturn": 0.10,
|
|
"netReturn": 0.09,
|
|
"cost": 0.01,
|
|
}]
|
|
short_values = [{
|
|
"time": "2025-01-01",
|
|
"grossReturn": -0.05,
|
|
"netReturn": -0.07,
|
|
"cost": 0.02,
|
|
}]
|
|
|
|
points = FactorResearchEngine._long_short_curve(long_values, short_values)
|
|
|
|
assert points[0]["gross"] == pytest.approx(1.15)
|
|
assert points[0]["net"] == pytest.approx(1.12)
|
|
|
|
|
|
def test_factor_research_reduces_groups_for_small_cross_sections():
|
|
index = pd.date_range("2025-01-01", periods=70, freq="B")
|
|
frames = {}
|
|
for offset, symbol in enumerate(["A", "B", "C", "D", "E", "F", "G"]):
|
|
prices = [100 + offset + day * (0.1 + offset * 0.02) for day in range(len(index))]
|
|
frames[symbol] = pd.DataFrame({"open": prices, "close": prices}, index=index)
|
|
|
|
result = FactorResearchEngine().run(
|
|
frames=frames,
|
|
factor_id="momentum_20",
|
|
start_date=index[25],
|
|
end_date=index[-1],
|
|
groups=5,
|
|
holding_period=5,
|
|
annualization_periods=365.25,
|
|
)
|
|
|
|
assert result["effectiveGroups"] == 3
|
|
assert result["requestedGroups"] == 5
|
|
assert result["methodologyVersion"] == 2
|
|
assert "groupsReduced" in result["sampleDiagnostics"]["warnings"]
|
|
assert result["executionAssumptions"]["periodsPerYear"] == pytest.approx(365.25)
|
|
|
|
|
|
def test_factor_research_sizes_groups_from_usable_factor_cross_section():
|
|
index = pd.date_range("2025-01-01", periods=40, freq="B")
|
|
frames = {}
|
|
for offset, symbol in enumerate("ABCDEFGHIJ"):
|
|
prices = [100 + offset + day * (0.1 + offset * 0.02) for day in range(len(index))]
|
|
frame = pd.DataFrame({"open": prices, "close": prices}, index=index)
|
|
if offset < 4:
|
|
frame["pe_ratio"] = 10.0 + offset
|
|
frames[symbol] = frame
|
|
|
|
result = FactorResearchEngine().run(
|
|
frames=frames,
|
|
factor_id="value",
|
|
start_date=index[0],
|
|
end_date=index[-1],
|
|
groups=5,
|
|
holding_period=5,
|
|
)
|
|
|
|
assert result["effectiveGroups"] == 2
|
|
assert result["sampleDiagnostics"]["medianCrossSectionSize"] == 4
|