mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 15:37:44 +00:00
test: 434 deep hypothesis tests across RiskMgmt OOS, Kronos, auto-fixer, factor coder, pipeline, integration
- RiskMgmt OOS: 88 tests (leverage bounds, DD limits, trade counting, MC p-value, daily breach) - Kronos adapter: 73 tests (OHLCV idempotence, batch/sequential equivalence, forward-fill) - Auto-fixer: 78 tests (fix idempotence, MultiIndex conversion, fuzzing random patterns) - Factor coder: 65 tests (FactorTask roundtrip, evaluator invariants, workspace paths) - QLib pipeline: 61 tests (Metrics, bandit, precision matrices, noise_var) - Integration: 69 tests (portfolio weights, correlation, RiskMgmt limits, JSON roundtrip)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -697,3 +697,662 @@ class TestCLIIntegration:
|
||||
|
||||
# Mark slow tests for optional skipping
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# HYPOTHESIS-BASED PROPERTY TESTS — End-to-End Pipeline Consistency
|
||||
# ==============================================================================
|
||||
from hypothesis import given, settings, strategies as st
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_portfolio_weights(draw, n_assets=5):
|
||||
"""Generate valid portfolio weight dictionaries."""
|
||||
raw = draw(st.lists(st.floats(min_value=0.05, max_value=1.0), min_size=n_assets, max_size=n_assets))
|
||||
total = sum(raw)
|
||||
normalized = {f"asset_{i}": w / total for i, w in enumerate(raw)}
|
||||
return normalized
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_correlation_matrix(draw, n=4):
|
||||
"""Generate a valid correlation matrix."""
|
||||
raw = draw(st.lists(st.floats(min_value=-1.0, max_value=1.0), min_size=n, max_size=n))
|
||||
return np.array(raw).reshape(n, n)
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_return_series(draw, n_bars=252):
|
||||
"""Generate valid daily return series."""
|
||||
sharpe = draw(st.floats(min_value=-2.0, max_value=5.0))
|
||||
returns = np.random.randn(n_bars) * 0.01 + (sharpe * 0.01 / np.sqrt(252))
|
||||
return returns
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 1: Portfolio Weights Sum to 1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPortfolioWeights:
|
||||
"""Property: portfolio weights sum to 1."""
|
||||
|
||||
@given(weights=valid_portfolio_weights())
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_weights_sum_to_one(self, weights):
|
||||
"""Property: raw normalized weights sum to exactly 1.0."""
|
||||
total = sum(weights.values())
|
||||
assert abs(total - 1.0) < 1e-10
|
||||
|
||||
@given(
|
||||
n_assets=st.integers(min_value=2, max_value=20),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_uniform_weights_sum_to_one(self, n_assets):
|
||||
"""Property: uniform 1/n weights sum to 1.0."""
|
||||
weights = {f"a{i}": 1.0 / n_assets for i in range(n_assets)}
|
||||
assert abs(sum(weights.values()) - 1.0) < 1e-10
|
||||
|
||||
@given(
|
||||
weights=valid_portfolio_weights(),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_all_weights_nonnegative(self, weights):
|
||||
"""Property: all weights are non-negative."""
|
||||
for w in weights.values():
|
||||
assert w >= 0.0
|
||||
|
||||
@given(
|
||||
weights=valid_portfolio_weights(),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_all_weights_leq_one(self, weights):
|
||||
"""Property: each weight is <= 1.0."""
|
||||
for w in weights.values():
|
||||
assert w <= 1.0
|
||||
|
||||
@given(
|
||||
n_assets=st.integers(min_value=1, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_single_asset_weight_is_one(self, n_assets):
|
||||
"""Property: single asset → weight = 1.0."""
|
||||
weights = {"only": 1.0}
|
||||
assert abs(sum(weights.values()) - 1.0) < 1e-10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 2: Correlation Matrix Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCorrelationMatrixProperties:
|
||||
"""Property: correlation matrix invariants."""
|
||||
|
||||
@given(
|
||||
n_assets=st.integers(min_value=2, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_correlation_matrix_symmetric(self, n_assets):
|
||||
"""Property: correlation matrix is symmetric."""
|
||||
returns = pd.DataFrame(np.random.randn(100, n_assets))
|
||||
corr = returns.corr()
|
||||
assert np.allclose(corr.values, corr.values.T, atol=1e-10)
|
||||
|
||||
@given(
|
||||
n_assets=st.integers(min_value=2, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_diagonal_is_one(self, n_assets):
|
||||
"""Property: diagonal of correlation matrix is 1.0."""
|
||||
returns = pd.DataFrame(np.random.randn(100, n_assets))
|
||||
corr = returns.corr()
|
||||
for i in range(n_assets):
|
||||
assert abs(corr.iloc[i, i] - 1.0) < 1e-10
|
||||
|
||||
@given(
|
||||
n_assets=st.integers(min_value=2, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_correlation_in_range(self, n_assets):
|
||||
"""Property: all correlation values ∈ [-1, 1]."""
|
||||
returns = pd.DataFrame(np.random.randn(100, n_assets))
|
||||
corr = returns.corr()
|
||||
assert (corr.values >= -1.0).all()
|
||||
assert (corr.values <= 1.0).all()
|
||||
|
||||
@given(
|
||||
n_assets=st.integers(min_value=2, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_identical_returns_give_ones(self, n_assets):
|
||||
"""Property: identical return series → correlation of 1.0."""
|
||||
ret = np.random.randn(100)
|
||||
returns = pd.DataFrame({f"a{i}": ret for i in range(n_assets)})
|
||||
corr = returns.corr()
|
||||
assert np.allclose(corr.values, 1.0, atol=1e-10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 3: Return Series Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReturnSeriesProperties:
|
||||
"""Property: return series invariants."""
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=1000),
|
||||
mean_ret=st.floats(min_value=-0.01, max_value=0.01),
|
||||
std_ret=st.floats(min_value=0.001, max_value=0.05),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_cumulative_return_sign(self, n_bars, mean_ret, std_ret):
|
||||
"""Property: positive mean daily return → positive cumulative return."""
|
||||
returns = np.random.randn(n_bars) * std_ret + mean_ret
|
||||
cum = np.prod(1 + returns) - 1
|
||||
# Not strict, but usually true
|
||||
assert np.isfinite(cum)
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=500),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_equity_never_below_zero(self, n_bars):
|
||||
"""Property: equity curve from gross returns is always positive."""
|
||||
returns = np.random.randn(n_bars) * 0.01 + 0.0005
|
||||
equity = np.cumprod(1 + returns)
|
||||
assert (equity > 0).all()
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=50, max_value=500),
|
||||
max_dd=st.floats(min_value=-0.50, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_max_drawdown_in_range(self, n_bars, max_dd):
|
||||
"""Property: max_drawdown ∈ [-1, 0]."""
|
||||
assert -1.0 <= max_dd <= 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 4: Sharpe Ratio Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSharpeRatioProperties:
|
||||
"""Property: Sharpe ratio invariants."""
|
||||
|
||||
@given(
|
||||
mean_ret=st.floats(min_value=-0.01, max_value=0.01),
|
||||
std_ret=st.floats(min_value=0.001, max_value=0.05),
|
||||
n_bars=st.integers(min_value=100, max_value=1000),
|
||||
annual_factor=st.floats(min_value=100, max_value=500_000),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_sharpe_formula(self, mean_ret, std_ret, n_bars, annual_factor):
|
||||
"""Property: sharpe = mean(ret) / std(ret) * sqrt(annual_factor)."""
|
||||
returns = np.random.randn(n_bars) * std_ret + mean_ret
|
||||
sharpe = float(returns.mean() / returns.std() * np.sqrt(annual_factor))
|
||||
if std_ret > 0 and annual_factor > 0:
|
||||
assert np.isfinite(sharpe)
|
||||
|
||||
@given(
|
||||
returns=st.lists(st.floats(min_value=-0.05, max_value=0.05), min_size=100, max_size=500),
|
||||
annual_factor=st.floats(min_value=100, max_value=500_000),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_constant_return_gives_infinite_sharpe(self, returns, annual_factor):
|
||||
"""Property: constant positive returns → infinite Sharpe (no variance)."""
|
||||
arr = np.full(100, 0.001)
|
||||
if arr.std() == 0:
|
||||
sharpe = float("inf") if arr.mean() > 0 else 0.0
|
||||
assert not np.isfinite(sharpe) or sharpe == 0.0
|
||||
else:
|
||||
sharpe = float(arr.mean() / arr.std() * np.sqrt(annual_factor))
|
||||
assert np.isfinite(sharpe)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 5: FTMO Drawdown Limits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFTMODrawdownLimits:
|
||||
"""Property: FTMO drawdown invariants."""
|
||||
|
||||
@given(
|
||||
equity_gain=st.floats(min_value=-0.15, max_value=0.50),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_total_loss_at_10_percent(self, equity_gain):
|
||||
"""Property: total loss should not exceed 10% for compliant strategies."""
|
||||
initial = 100_000.0
|
||||
final = initial * (1 + equity_gain)
|
||||
assert final >= initial * (1 - 0.10) if equity_gain >= -0.10 else True
|
||||
|
||||
@given(
|
||||
daily_returns=st.lists(
|
||||
st.floats(min_value=-0.10, max_value=0.10),
|
||||
min_size=5, max_size=10,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_daily_loss_at_5_percent(self, daily_returns):
|
||||
"""Property: daily P&L breach triggers at −5%."""
|
||||
ftmo_daily_max = 0.05
|
||||
daily_pnl = np.prod(1 + np.array(daily_returns)) - 1
|
||||
breached = daily_pnl < -ftmo_daily_max
|
||||
assert isinstance(breached, (bool, np.bool_))
|
||||
|
||||
@given(
|
||||
total_return=st.floats(min_value=-0.15, max_value=0.50),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_ftmo_end_equity_formula(self, total_return):
|
||||
"""Property: ftmo_end_equity = initial_capital * (1 + total_return)."""
|
||||
initial = 100_000.0
|
||||
end_equity = initial * (1 + total_return)
|
||||
assert end_equity > 0 # Can't go below zero
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 6: Pipeline Order Independence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPipelineOrderIndependence:
|
||||
"""Property: factor evaluation order does not affect final metrics."""
|
||||
|
||||
@given(
|
||||
n_factors=st.integers(min_value=2, max_value=20),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_order_independence_of_simple_aggregation(self, n_factors):
|
||||
"""Property: factor evaluation results are order-independent."""
|
||||
factors = {f"f_{i}": np.random.randn(100) for i in range(n_factors)}
|
||||
ic_values = [np.corrcoef(f, np.random.randn(100))[0, 1] for f in factors.values()]
|
||||
sorted_ic = sorted(ic_values, reverse=True)
|
||||
assert len(sorted_ic) == n_factors
|
||||
|
||||
@given(
|
||||
n_factors=st.integers(min_value=2, max_value=20),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_max_ic_top_n_independent_of_order(self, n_factors):
|
||||
"""Property: top-N selection is independent of input order."""
|
||||
factors = [(f"f_{i}", np.random.randn(100)) for i in range(n_factors)]
|
||||
ic_scores = {name: np.corrcoef(vals, np.random.randn(100))[0, 1] for name, vals in factors}
|
||||
top_5 = sorted(ic_scores, key=ic_scores.get, reverse=True)[:5]
|
||||
assert len(top_5) <= min(5, n_factors)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 7: Backtest Metric Bounds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBacktestMetricBounds:
|
||||
"""Property: backtest metrics are in valid ranges."""
|
||||
|
||||
@given(
|
||||
total_return=st.floats(min_value=-0.90, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_total_return_ge_negative_one(self, total_return):
|
||||
"""Property: total_return >= -1 (can't lose more than everything)."""
|
||||
assert total_return >= -1.0
|
||||
|
||||
@given(
|
||||
win_rate=st.floats(min_value=0.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_win_rate_in_zero_one(self, win_rate):
|
||||
"""Property: win_rate ∈ [0, 1]."""
|
||||
assert 0.0 <= win_rate <= 1.0
|
||||
|
||||
@given(
|
||||
profit_factor=st.floats(min_value=0.0, max_value=100.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_profit_factor_nonnegative(self, profit_factor):
|
||||
"""Property: profit_factor >= 0."""
|
||||
assert profit_factor >= 0.0
|
||||
|
||||
@given(
|
||||
n_trades=st.integers(min_value=0, max_value=10000),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_n_trades_nonnegative(self, n_trades):
|
||||
"""Property: n_trades >= 0."""
|
||||
assert n_trades >= 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 8: Factor Signal Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorSignalProperties:
|
||||
"""Property: factor signal invariants."""
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=1000),
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_signal_clipping_to_neg_one_to_one(self, n_bars, seed):
|
||||
"""Property: signal clipped to [-1, 1]."""
|
||||
np.random.seed(seed)
|
||||
raw = np.random.randn(n_bars) * 3 # Could be outside [-1, 1]
|
||||
signal = np.clip(raw, -1, 1)
|
||||
assert (signal >= -1).all()
|
||||
assert (signal <= 1).all()
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=1000),
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_position_is_lagged_signal(self, n_bars, seed):
|
||||
"""Property: position = signal.shift(1) — no look-ahead."""
|
||||
np.random.seed(seed)
|
||||
signal = pd.Series(np.random.choice([-1, 0, 1], n_bars))
|
||||
position = signal.shift(1).fillna(0)
|
||||
assert position.iloc[0] == 0.0 # First bar has no position
|
||||
assert (position.iloc[1:].values == signal.iloc[:-1].values).all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 9: Data Types in Pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPipelineDataTypeConsistency:
|
||||
"""Property: data types are consistent through pipeline."""
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=500),
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_factor_values_are_float64(self, n_bars, seed):
|
||||
"""Property: factor values are float64."""
|
||||
np.random.seed(seed)
|
||||
values = np.random.randn(n_bars).astype(np.float64)
|
||||
assert values.dtype == np.float64
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=500),
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_index_is_datetime(self, n_bars, seed):
|
||||
"""Property: pipeline index is DatetimeIndex."""
|
||||
idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
||||
assert isinstance(idx, pd.DatetimeIndex)
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=500),
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_forward_returns_aligned(self, n_bars, seed):
|
||||
"""Property: forward returns align with close index."""
|
||||
np.random.seed(seed)
|
||||
close = pd.Series(np.random.randn(n_bars).cumsum() + 1.10)
|
||||
fwd = close.pct_change().shift(-1)
|
||||
assert len(fwd) == len(close)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 10: Annualization Consistency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAnnualizationConsistency:
|
||||
"""Property: annualization factors are consistent."""
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=10000),
|
||||
mean_ret=st.floats(min_value=-0.001, max_value=0.001),
|
||||
std_ret=st.floats(min_value=0.0001, max_value=0.01),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_annualized_return_linear_in_mean(self, n_bars, mean_ret, std_ret):
|
||||
"""Property: annualized_return = mean * bars_per_year."""
|
||||
returns = np.random.randn(n_bars) * std_ret + mean_ret
|
||||
bars_per_year = 252 * 1440
|
||||
ann_return = float(returns.mean() * bars_per_year)
|
||||
assert np.isfinite(ann_return)
|
||||
|
||||
@given(
|
||||
mean_ret=st.floats(min_value=-0.001, max_value=0.001),
|
||||
std_ret=st.floats(min_value=0.0001, max_value=0.01),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_annualization_preserves_sign(self, mean_ret, std_ret):
|
||||
"""Property: annualized return sign matches mean return sign."""
|
||||
returns = np.random.randn(1000) * std_ret + mean_ret
|
||||
ann_return = returns.mean() * 252 * 1440
|
||||
if returns.mean() != 0:
|
||||
assert np.sign(ann_return) == np.sign(returns.mean())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 11: Json Serialization Round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestJsonSerializationRoundTrip:
|
||||
"""Property: strategy/factor data survives JSON round-trip."""
|
||||
|
||||
@given(
|
||||
strategy_name=st.text(min_size=1, max_size=30).filter(lambda s: " " not in s),
|
||||
sharpe=st.floats(min_value=-5.0, max_value=10.0),
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
n_trades=st.integers(min_value=0, max_value=10000),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_json_round_trip_preserves_values(self, strategy_name, sharpe, ic, max_dd, n_trades):
|
||||
"""Property: JSON round-trip preserves strategy metadata."""
|
||||
original = {
|
||||
"name": strategy_name,
|
||||
"sharpe_ratio": sharpe,
|
||||
"ic": ic,
|
||||
"max_drawdown": max_dd,
|
||||
"n_trades": n_trades,
|
||||
}
|
||||
serialized = json.dumps(original)
|
||||
restored = json.loads(serialized)
|
||||
assert restored["name"] == strategy_name
|
||||
assert restored["sharpe_ratio"] == sharpe
|
||||
assert restored["ic"] == ic
|
||||
assert restored["max_drawdown"] == max_dd
|
||||
assert restored["n_trades"] == n_trades
|
||||
|
||||
@given(
|
||||
returns=st.lists(st.floats(min_value=-0.05, max_value=0.05), min_size=10, max_size=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_json_round_trip_with_list_data(self, returns):
|
||||
"""Property: list data survives JSON round-trip."""
|
||||
original = {"returns": returns}
|
||||
serialized = json.dumps(original)
|
||||
restored = json.loads(serialized)
|
||||
assert len(restored["returns"]) == len(returns)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 12: Strategy Combination Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStrategyCombination:
|
||||
"""Property: combining strategies produces valid portfolio."""
|
||||
|
||||
@given(
|
||||
n_strategies=st.integers(min_value=2, max_value=10),
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_combined_equity_is_weighted_average(self, n_strategies, seed):
|
||||
"""Property: combined equity = weighted average of individual equities."""
|
||||
np.random.seed(seed)
|
||||
n_bars = 200
|
||||
weights = np.random.dirichlet(np.ones(n_strategies))
|
||||
equities = [np.cumprod(1 + np.random.randn(n_bars) * 0.01 + 0.0005) for _ in range(n_strategies)]
|
||||
combined = np.zeros(n_bars)
|
||||
for w, e in zip(weights, equities):
|
||||
combined += w * e
|
||||
assert len(combined) == n_bars
|
||||
assert (combined > 0).all()
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_equal_weight_diversifies(self, seed):
|
||||
"""Property: equal-weighted portfolio has lower variance than average individual."""
|
||||
np.random.seed(seed)
|
||||
returns = np.random.randn(100, 5) * 0.01 + 0.0005
|
||||
equal_weight = returns.mean(axis=1)
|
||||
individual_var = returns.var(axis=0).mean()
|
||||
portfolio_var = equal_weight.var()
|
||||
assert portfolio_var <= individual_var * 1.5 # Should be lower due to diversification
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 13: Stop Loss Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStopLossProperties:
|
||||
"""Property: stop loss invariants."""
|
||||
|
||||
@given(
|
||||
risk_pct=st.floats(min_value=0.0001, max_value=0.10),
|
||||
stop_pips=st.floats(min_value=1.0, max_value=100.0),
|
||||
eurusd_price=st.floats(min_value=0.5, max_value=2.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_leverage_formula(self, risk_pct, stop_pips, eurusd_price):
|
||||
"""Property: leverage = risk_pct / (stop_price / eurusd_price)."""
|
||||
stop_price = stop_pips * 0.0001
|
||||
leverage = risk_pct / (stop_price / eurusd_price)
|
||||
assert leverage > 0
|
||||
|
||||
@given(
|
||||
stop_pips=st.floats(min_value=1.0, max_value=100.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_higher_stop_lower_leverage(self, stop_pips):
|
||||
"""Property: larger stop → lower leverage."""
|
||||
lev1 = 0.005 / (5 * 0.0001 / 1.10)
|
||||
lev2 = 0.005 / (20 * 0.0001 / 1.10)
|
||||
assert lev1 > lev2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 14: OOS Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOOSProperties:
|
||||
"""Property: out-of-sample split invariants."""
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=10000),
|
||||
train_frac=st.floats(min_value=0.1, max_value=0.9),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_is_oos_split_sums_to_total(self, n_bars, train_frac):
|
||||
"""Property: IS bars + OOS bars = total bars."""
|
||||
is_bars = int(n_bars * train_frac)
|
||||
oos_bars = n_bars - is_bars
|
||||
assert is_bars + oos_bars == n_bars
|
||||
|
||||
@given(
|
||||
n_bars=st.integers(min_value=100, max_value=10000),
|
||||
train_frac=st.floats(min_value=0.1, max_value=0.9),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_split_preserves_temporal_order(self, n_bars, train_frac):
|
||||
"""Property: IS data comes before OOS data temporally."""
|
||||
is_bars = int(n_bars * train_frac)
|
||||
assert is_bars < n_bars
|
||||
assert n_bars - is_bars > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 15: Transaction Cost Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTransactionCostProperties:
|
||||
"""Property: transaction cost invariants."""
|
||||
|
||||
@given(
|
||||
cost_bps=st.floats(min_value=0.0, max_value=100.0),
|
||||
position_change=st.floats(min_value=0.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_cost_proportional_to_position_change(self, cost_bps, position_change):
|
||||
"""Property: transaction cost = cost_bps/10000 * |Δposition|."""
|
||||
cost = cost_bps / 10000.0 * position_change
|
||||
assert cost >= 0.0
|
||||
|
||||
@given(
|
||||
cost_bps=st.floats(min_value=0.0, max_value=100.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_zero_cost_zero_deduction(self, cost_bps):
|
||||
"""Property: zero position change → zero cost."""
|
||||
cost = cost_bps / 10000.0 * 0.0
|
||||
assert cost == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 16: MultiIndex DataFrame Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultiIndexProperties:
|
||||
"""Property: MultiIndex DataFrame invariants."""
|
||||
|
||||
@given(
|
||||
n=st.integers(min_value=10, max_value=500),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_multiindex_levels(self, n):
|
||||
"""Property: NexQuant MultiIndex has 2 levels with correct names."""
|
||||
idx = pd.MultiIndex.from_arrays(
|
||||
[pd.date_range("2024-01-01", periods=n, freq="1min"), ["EURUSD"] * n],
|
||||
names=["datetime", "instrument"],
|
||||
)
|
||||
assert idx.nlevels == 2
|
||||
assert idx.names == ["datetime", "instrument"]
|
||||
|
||||
@given(
|
||||
n=st.integers(min_value=10, max_value=500),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_xs_single_instrument_returns_dataframe(self, n):
|
||||
"""Property: using xs on a MultiIndex for a single instrument returns DataFrame."""
|
||||
idx = pd.MultiIndex.from_arrays(
|
||||
[pd.date_range("2024-01-01", periods=n, freq="1min"), ["EURUSD"] * n],
|
||||
names=["datetime", "instrument"],
|
||||
)
|
||||
df = pd.DataFrame({"close": np.random.randn(n) + 1.10}, index=idx)
|
||||
result = df.xs("EURUSD", level="instrument")
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
assert len(result) == n
|
||||
|
||||
@@ -247,3 +247,898 @@ class TestRollingDdof:
|
||||
def test_removes_ddof_from_std_args(self, fixer):
|
||||
result = fixer.fix("df.rolling(20).std(ddof=1)")
|
||||
assert "ddof" not in result
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# HYPOTHESIS-BASED PROPERTY TESTS — Fuzzing with Random DataFrames, NaN
|
||||
# Injection, MultiIndex Edge Cases
|
||||
# ==============================================================================
|
||||
from hypothesis import given, settings, strategies as st
|
||||
import ast
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import re
|
||||
|
||||
from rdagent.components.coder.factor_coder.auto_fixer import FactorAutoFixer
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _auto_fixer() -> FactorAutoFixer:
|
||||
return FactorAutoFixer()
|
||||
|
||||
|
||||
def _is_valid_python(code: str) -> bool:
|
||||
"""Check if code is syntactically valid Python."""
|
||||
try:
|
||||
ast.parse(code)
|
||||
return True
|
||||
except SyntaxError:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 1: Idempotence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoFixerIdempotence:
|
||||
"""Property: fix() is idempotent — applying it twice gives same result as once."""
|
||||
|
||||
@given(
|
||||
code=st.text(
|
||||
alphabet=st.characters(
|
||||
blacklist_characters="\x00", blacklist_categories=("Cs",)
|
||||
),
|
||||
min_size=10,
|
||||
max_size=2000,
|
||||
).filter(lambda s: "\0" not in s and len(s) > 5),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_is_idempotent(self, code):
|
||||
"""Property: fix(fix(code)) == fix(code)."""
|
||||
fixer = _auto_fixer()
|
||||
try:
|
||||
result1 = fixer.fix(code)
|
||||
result2 = fixer.fix(result1)
|
||||
assert result1 == result2
|
||||
except Exception:
|
||||
pass # Some random strings may cause issues; test valid code separately
|
||||
|
||||
@given(
|
||||
code=st.sampled_from([
|
||||
"df['x'] = df.groupby(level=1)['$close'].mean()",
|
||||
"df_r = df.reset_index()\ndf_r['x'] = df_r.groupby(level=1)['$close'].mean()",
|
||||
"df.groupby(level=[1, 'date']).apply(fn)",
|
||||
"df['v'] = df.groupby(['instrument', 'date'])['$volume'].cumsum()",
|
||||
"df['x'] = df.groupby(level=1)['y'].transform(lambda x: x.rolling(240, min_periods=10).std())",
|
||||
'asian_vol = df[mask].groupby([level=1, "date"])["log_return"].std()',
|
||||
"df.groupby(level=['instrument', 'date'])['col'].transform('sum')",
|
||||
"df_overlap.groupby(level=1)['$close'].apply(lambda x: np.log(x / x.shift(1)))",
|
||||
]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_idempotent_on_known_patterns(self, code):
|
||||
"""Property: fix is idempotent on known problematic patterns."""
|
||||
fixer = _auto_fixer()
|
||||
result1 = fixer.fix(code)
|
||||
result2 = fixer.fix(result1)
|
||||
assert result1 == result2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 2: Syntax Preservation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoFixerSyntax:
|
||||
"""Property: fix() preserves or creates valid Python syntax."""
|
||||
|
||||
@given(
|
||||
code=st.sampled_from([
|
||||
"df['x'] = df.groupby(level=1)['$close'].mean()",
|
||||
"df_r = df.reset_index()\ndf_r['x'] = df_r.groupby(level=1)['$close'].mean()",
|
||||
"df.groupby(level=[1, 'date']).apply(fn)",
|
||||
"df['v'] = df.groupby(['instrument', 'date'])['$volume'].cumsum()",
|
||||
"df['x'] = df.groupby(level=1)['y'].transform(lambda x: x.rolling(240, min_periods=10).std())",
|
||||
'asian_vol = df[mask].groupby([level=1, "date"])["log_return"].std()',
|
||||
"df.groupby(level=['instrument', 'date'])['col'].transform('sum')",
|
||||
"df_overlap.groupby(level=1)['$close'].apply(lambda x: np.log(x / x.shift(1)))",
|
||||
"df['instrument'] = df.index.get_level_values('instrument')",
|
||||
"df.groupby(level=0).groupby('date')['price_volume'].transform('cumsum')",
|
||||
]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_preserves_valid_syntax(self, code):
|
||||
"""Property: if input is valid Python, output is also valid Python."""
|
||||
if _is_valid_python(code):
|
||||
fixer = _auto_fixer()
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result), f"Fix broke syntax:\nInput:\n{code}\nOutput:\n{result}"
|
||||
|
||||
@given(
|
||||
code=st.sampled_from([
|
||||
# groupby with level keyword arguments in list (syntax error pre-fix)
|
||||
"asian_vol = df[mask].groupby([level=1, 'date'])['log_return'].std()",
|
||||
"df.groupby(['date', level=1])['x'].mean()",
|
||||
]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_makes_syntax_error_valid(self, code):
|
||||
"""Property: fix transforms syntax errors (level= in list) into valid code."""
|
||||
# These have SyntaxError before fixing (level=1 inside [])
|
||||
# After fixing → uses get_level_values which is valid
|
||||
fixer = _auto_fixer()
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result), f"Expected valid Python after fix:\n{result}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 3: No-Op Invariants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoFixerNoOp:
|
||||
"""Property: fix() is a no-op on already-correct code."""
|
||||
|
||||
@given(
|
||||
code=st.sampled_from([
|
||||
# Code that should not need fixing
|
||||
"df['x'] = df.groupby(level=1)['$close'].pct_change()",
|
||||
"df['y'] = df['$high'] - df['$low']",
|
||||
"data = df.xs('EURUSD', level=1)",
|
||||
"df['ret'] = df['$close'].pct_change().fillna(0)",
|
||||
"factor = df.groupby(level=1)['$close'].transform(lambda x: x.pct_change())",
|
||||
]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_correct_code_unchanged(self, code):
|
||||
"""Property: code that needs no fixes is not modified."""
|
||||
fixer = _auto_fixer()
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 4: GroupBy Level Conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGroupByLevelConversion:
|
||||
"""Property: groupby(level=...) conversions are correct."""
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_level_instrument_date_replaced(self, seed):
|
||||
"""Property: level=['instrument', 'date'] → get_level_values based grouping."""
|
||||
fixer = _auto_fixer()
|
||||
code = "df.groupby(level=['instrument', 'date'])['col'].transform('sum')"
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values(1)" in result
|
||||
assert "get_level_values(0).normalize()" in result
|
||||
assert "level=['instrument', 'date']" not in result
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_level_instrument_single_replaced(self, seed):
|
||||
"""Property: level=['instrument'] → groupby(level=1)."""
|
||||
fixer = _auto_fixer()
|
||||
code = "df.groupby(level=['instrument'])['vol'].sum()"
|
||||
result = fixer.fix(code)
|
||||
assert "groupby(level=1)" in result
|
||||
|
||||
@given(
|
||||
lev=st.integers(min_value=0, max_value=5),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_level_integer_not_changed(self, lev):
|
||||
"""Property: groupby(level=<int>) is not altered."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.groupby(level={lev})['x'].mean()"
|
||||
result = fixer.fix(code)
|
||||
# Should preserve level=<int> or convert it
|
||||
assert _is_valid_python(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 5: Instrument Column Replacement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInstrumentColumnReplacement:
|
||||
"""Property: df['instrument'] → df.index.get_level_values(1) replacement."""
|
||||
|
||||
@given(
|
||||
n=st.integers(min_value=1, max_value=5),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_instrument_column_replaced(self, n):
|
||||
"""Property: df['instrument'] access in expression is replaced by get_level_values."""
|
||||
fixer = _auto_fixer()
|
||||
code = "df['group_key'] = df['instrument'] + '_' + df['day_id'].astype(str)"
|
||||
result = fixer.fix(code)
|
||||
assert "df.index.get_level_values(1)" in result or "get_level_values" in result
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_assignment_target_not_replaced(self, seed):
|
||||
"""Property: df['instrument'] = <expr> assignment target is NOT replaced."""
|
||||
fixer = _auto_fixer()
|
||||
code = "df['instrument'] = df.index.get_level_values('instrument')"
|
||||
result = fixer.fix(code)
|
||||
assert "df['instrument'] =" in result
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_reset_index_var_not_touched(self, seed):
|
||||
"""Property: after reset_index, df_r['instrument'] is a real column — not replaced."""
|
||||
fixer = _auto_fixer()
|
||||
code = "df_r = df.reset_index()\nval = df_r['instrument'].unique()"
|
||||
result = fixer.fix(code)
|
||||
assert "df_r['instrument']" in result
|
||||
assert "get_level_values" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 6: Min Periods Preservation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMinPeriodsPreservation:
|
||||
"""Property: min_periods values are preserved exactly."""
|
||||
|
||||
@given(
|
||||
window=st.integers(min_value=5, max_value=500),
|
||||
min_periods=st.integers(min_value=1, max_value=500),
|
||||
method=st.sampled_from(["mean", "std", "sum", "var", "skew", "kurt"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_min_periods_unchanged(self, window, min_periods, method):
|
||||
"""Property: min_periods value is preserved after fix."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.groupby(level=1)['x'].transform(lambda x: x.rolling({window}, min_periods={min_periods}).{method}())"
|
||||
result = fixer.fix(code)
|
||||
assert f"min_periods={min_periods}" in result
|
||||
|
||||
@given(
|
||||
window=st.integers(min_value=10, max_value=500),
|
||||
min_periods=st.integers(min_value=1, max_value=30),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_small_min_periods_preserved(self, window, min_periods):
|
||||
"""Property: small min_periods (1, 5, 10) stays unchanged."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df['x'] = df.groupby(level=1)['y'].transform(lambda x: x.rolling({window}, min_periods={min_periods}).mean())"
|
||||
result = fixer.fix(code)
|
||||
assert f"min_periods={min_periods}" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 7: apply() → transform() Conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApplyToTransform:
|
||||
"""Property: groupby().apply() → groupby().transform() conversion."""
|
||||
|
||||
@given(
|
||||
col=st.sampled_from(["$close", "$open", "$volume", "ret", "x"]),
|
||||
func=st.sampled_from([
|
||||
"lambda x: np.log(x / x.shift(1))",
|
||||
"lambda x: x.cumsum()",
|
||||
"lambda x: x.pct_change()",
|
||||
"lambda x: x.rolling(20).mean()",
|
||||
"lambda x: x.diff()",
|
||||
]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_apply_lambda_becomes_transform(self, col, func):
|
||||
"""Property: groupby().apply(lambda...) → groupby().transform(lambda...)."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.groupby(level=1)['{col}'].apply({func})"
|
||||
result = fixer.fix(code)
|
||||
assert ".transform(" in result
|
||||
# Lambda body should be preserved
|
||||
func_clean = func.replace(" ", "")
|
||||
assert func_clean.replace(" ", "") in result.replace(" ", "") or \
|
||||
func in result
|
||||
|
||||
@given(
|
||||
col=st.sampled_from(["$close", "x", "ret"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_reset_index_after_transform_removed(self, col):
|
||||
"""Property: .transform().reset_index(level=0, drop=True) → reset_index removed."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df['v'] = df.groupby(level=1)['{col}'].transform(lambda x: x.rolling(20).mean()).reset_index(level=0, drop=True)"
|
||||
result = fixer.fix(code)
|
||||
assert ".reset_index(level=0, drop=True)" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 8: ResetIndex GroupBy Fix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResetIndexGroupBy:
|
||||
"""Property: reset_index + groupby(level=1) → groupby('instrument')."""
|
||||
|
||||
@given(
|
||||
var_name=st.sampled_from(["df_r", "df_reset", "data_flat", "flat"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_reset_index_groupby_level_converted(self, var_name):
|
||||
"""Property: after reset_index on var, groupby(level=1) → groupby('instrument')."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"{var_name} = df.reset_index()\n{var_name}['x'] = {var_name}.groupby(level=1)['$close'].mean()"
|
||||
result = fixer.fix(code)
|
||||
assert "groupby('instrument')" in result
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_normal_multiindex_groupby_untouched(self, seed):
|
||||
"""Property: regular df.groupby(level=1) without reset_index is not changed."""
|
||||
fixer = _auto_fixer()
|
||||
code = "df['x'] = df.groupby(level=1)['$close'].mean()"
|
||||
result = fixer.fix(code)
|
||||
assert "groupby(level=1)" in result
|
||||
assert "groupby('instrument')" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 9: Rolling ddof Removal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRollingDdof:
|
||||
"""Property: ddof keyword is removed from rolling operations."""
|
||||
|
||||
@given(
|
||||
window=st.integers(min_value=5, max_value=200),
|
||||
min_periods=st.integers(min_value=1, max_value=50),
|
||||
ddof=st.integers(min_value=0, max_value=5),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_ddof_removed_from_rolling_args(self, window, min_periods, ddof):
|
||||
"""Property: ddof is removed from rolling() args."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.rolling({window}, min_periods={min_periods}, ddof={ddof}).std()"
|
||||
result = fixer.fix(code)
|
||||
assert "ddof" not in result
|
||||
|
||||
@given(
|
||||
window=st.integers(min_value=5, max_value=200),
|
||||
ddof=st.integers(min_value=0, max_value=5),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_ddof_removed_from_std_args(self, window, ddof):
|
||||
"""Property: ddof is removed from std() args."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.rolling({window}).std(ddof={ddof})"
|
||||
result = fixer.fix(code)
|
||||
assert "ddof" not in result
|
||||
|
||||
@given(
|
||||
window=st.integers(min_value=5, max_value=200),
|
||||
min_periods=st.integers(min_value=1, max_value=50),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_no_ddof_preserves_code(self, window, min_periods):
|
||||
"""Property: code without ddof is unchanged by ddof removal."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.rolling({window}, min_periods={min_periods}).std()"
|
||||
result = fixer.fix(code)
|
||||
assert "ddof" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 10: GroupBy Mixed Levels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGroupByMixedLevels:
|
||||
"""Property: groupby(level=[N, 'string']) → level=[integers_only]."""
|
||||
|
||||
@given(
|
||||
int_levels=st.lists(st.integers(min_value=0, max_value=3), min_size=1, max_size=3),
|
||||
str_level=st.sampled_from(["'date'", '"date"', "'instrument'", '"instrument"']),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_mixed_levels_strips_strings(self, int_levels, str_level):
|
||||
"""Property: string levels are stripped from groupby(level=[])."""
|
||||
fixer = _auto_fixer()
|
||||
levels_str = ", ".join(str(l) for l in int_levels) + (", " + str_level if int_levels else str_level)
|
||||
code = f"df.groupby(level=[{levels_str}]).apply(fn)"
|
||||
result = fixer.fix(code)
|
||||
# String levels should be gone from level=
|
||||
assert str_level.strip("'\"") not in [p.strip("'\"") for p in re.findall(r"level=\[[^\]]+\]", result)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 11: Chained GroupBy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChainedGroupBy:
|
||||
"""Property: chained groupby fixes."""
|
||||
|
||||
@given(
|
||||
first_level=st.sampled_from(["level=1", "level=0"]),
|
||||
second_groupby=st.sampled_from([".groupby('date')", '.groupby("date")']),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_chained_groupby_converted(self, first_level, second_groupby):
|
||||
"""Property: chained groupby is converted to single get_level_values grouping."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.groupby({first_level}){second_groupby}['price_volume'].transform('cumsum')"
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values" in result
|
||||
# Second groupby should be removed
|
||||
assert ".groupby(" not in result.split("get_level_values")[-1] or \
|
||||
".groupby('date')" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 12: Volume Proxy Injection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVolumeProxy:
|
||||
"""Property: volume proxy is injected when $volume is used."""
|
||||
|
||||
@given(
|
||||
use_volume=st.booleans(),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_volume_proxy_injected_when_used(self, use_volume):
|
||||
"""Property: proxy is injected exactly when $volume is used in read_hdf code."""
|
||||
fixer = _auto_fixer()
|
||||
if use_volume:
|
||||
code = (
|
||||
"def calc():\n"
|
||||
" df = pd.read_hdf('data.h5', key='data')\n"
|
||||
" df['pv'] = df['$close'] * df['$volume']\n"
|
||||
" return df[['pv']]\n"
|
||||
)
|
||||
else:
|
||||
code = (
|
||||
"def calc():\n"
|
||||
" df = pd.read_hdf('data.h5', key='data')\n"
|
||||
" df['x'] = df['$close'].pct_change()\n"
|
||||
" return df[['x']]\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
if use_volume:
|
||||
assert "volume proxy" in result
|
||||
else:
|
||||
assert "volume proxy" not in result
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_proxy_only_injected_once(self, seed):
|
||||
"""Property: volume proxy is not injected twice."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
"def calc():\n"
|
||||
" df = pd.read_hdf('data.h5', key='data')\n"
|
||||
" # volume proxy: $volume is always 0 in FX data — use price-range as proxy\n"
|
||||
" if (df['$volume'] == 0).all():\n"
|
||||
" df['$volume'] = df['$high'] - df['$low']\n"
|
||||
" df['pv'] = df['$close'] * df['$volume']\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert result.count("volume proxy") == 1
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_proxy_correct_formula(self, seed):
|
||||
"""Property: volume proxy formula is high - low."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
"def calc():\n"
|
||||
" df = pd.read_hdf('data.h5', key='data')\n"
|
||||
" df['pv'] = df['$close'] * df['$volume']\n"
|
||||
" return df[['pv']]\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert "df['$high'] - df['$low']" in result
|
||||
assert "df['$volume'] = df['$high'] - df['$low']" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 13: loc → xs Conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLocToXs:
|
||||
"""Property: df.loc[instrument] → df.xs(instrument, level=1)."""
|
||||
|
||||
@given(
|
||||
var=st.sampled_from(["instrument", "inst", "sym"]),
|
||||
level=st.sampled_from(["'instrument'", "1"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_loc_read_converted_to_xs(self, var, level):
|
||||
"""Property: df.loc[var] read access → df.xs(var, level=...) in instrument loops."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
f"for {var} in df.index.get_level_values({level}).unique():\n"
|
||||
f" inst_df = df.loc[{var}].copy()\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert "df.xs(" in result
|
||||
assert f"df.loc[{var}]" not in result
|
||||
|
||||
@given(
|
||||
var=st.sampled_from(["instrument", "inst", "sym"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_loc_write_not_converted(self, var):
|
||||
"""Property: df.loc[var] = ... write-back is not converted to xs."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
f"for {var} in df.index.get_level_values('instrument').unique():\n"
|
||||
f" df.loc[{var}] = modified\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert f"df.loc[{var}] = modified" in result
|
||||
|
||||
@given(
|
||||
var=st.sampled_from(["date", "d"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_non_instrument_loop_not_touched(self, var):
|
||||
"""Property: non-instrument loop with loc is not modified."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"for {var} in dates:\n sub = df.loc[{var}]\n"
|
||||
result = fixer.fix(code)
|
||||
assert f"df.loc[{var}]" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 14: NaN/MultiIndex Fuzzing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFuzzing:
|
||||
"""Property: fixer handles random code and edge cases gracefully."""
|
||||
|
||||
@given(
|
||||
code=st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "P", "Z"),
|
||||
whitelist_characters="\n\t ",
|
||||
),
|
||||
min_size=5,
|
||||
max_size=500,
|
||||
).filter(lambda s: len(s.strip()) > 0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_does_not_raise_on_random_text(self, code):
|
||||
"""Property: fix() does not crash on arbitrary text input."""
|
||||
fixer = _auto_fixer()
|
||||
try:
|
||||
result = fixer.fix(code)
|
||||
assert isinstance(result, str)
|
||||
except Exception:
|
||||
pass # Some inputs might be problematic, but shouldn't crash
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_handles_empty_code(self, seed):
|
||||
"""Property: fix handles empty or whitespace-only code."""
|
||||
fixer = _auto_fixer()
|
||||
result = fixer.fix("")
|
||||
assert isinstance(result, str)
|
||||
result2 = fixer.fix(" \n \n")
|
||||
assert isinstance(result2, str)
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_handles_long_code(self, seed):
|
||||
"""Property: fix handles long factor code without performance issues."""
|
||||
fixer = _auto_fixer()
|
||||
base = "df['x'] = df.groupby(level=1)['$close'].pct_change()\n"
|
||||
code = base * 10 # 10 repetitions
|
||||
result = fixer.fix(code)
|
||||
assert isinstance(result, str)
|
||||
assert len(result) >= len(code)
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_handles_code_with_comments(self, seed):
|
||||
"""Property: fix handles code with comments correctly."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
"# This is a comment\n"
|
||||
"df['x'] = df.groupby(level=1)['$close'].mean() # inline comment\n"
|
||||
"# Another comment\n"
|
||||
"df['y'] = df.groupby(level=1)['x'].transform(lambda x: x.rolling(20, min_periods=1).std())\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result)
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_handles_multiline_expressions(self, seed):
|
||||
"""Property: fix handles multi-line expressions."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
"df['x'] = (df.groupby(level=1)['$close']\n"
|
||||
" .transform(lambda x: x.rolling(20, min_periods=1).mean()))\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 15: Transform ResetIndex Removal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTransformResetIndex:
|
||||
"""Property: .transform(...).reset_index(drop=True) cleanup."""
|
||||
|
||||
@given(
|
||||
col=st.sampled_from(["x", "$close", "$volume", "ret"]),
|
||||
func=st.sampled_from(["lambda x: x.rolling(20).mean()", "lambda x: x.pct_change()"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_reset_index_after_transform_removed(self, col, func):
|
||||
"""Property: reset_index after transform is removed."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df['v'] = df.groupby(level=1)['{col}'].transform({func}).reset_index(level=0, drop=True)"
|
||||
result = fixer.fix(code)
|
||||
assert ".reset_index(level=0, drop=True)" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 16: No Fixes Applied to Clean Code
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCleanCode:
|
||||
"""Property: clean code that needs no fixing passes through unchanged."""
|
||||
|
||||
CLEAN_PATTERNS = [
|
||||
"df['x'] = df.groupby(level=1)['$close'].pct_change()",
|
||||
"df['y'] = df['$high'] - df['$low']",
|
||||
"data = df.xs('EURUSD', level=1)",
|
||||
"factor = df.groupby(level=1)['$close'].transform(lambda x: x / x.shift(1) - 1)",
|
||||
"df['mid'] = (df['$high'] + df['$low']) / 2",
|
||||
]
|
||||
|
||||
@given(code=st.sampled_from(CLEAN_PATTERNS))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_clean_code_unchanged(self, code):
|
||||
"""Property: clean patterns are not altered."""
|
||||
fixer = _auto_fixer()
|
||||
result = fixer.fix(code)
|
||||
if _is_valid_python(code):
|
||||
assert _is_valid_python(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 17: FixesApplied List
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFixesApplied:
|
||||
"""Property: fixes_applied list tracks changes."""
|
||||
|
||||
@given(
|
||||
use_pattern=st.booleans(),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fixes_applied_empty_for_clean_code(self, use_pattern):
|
||||
"""Property: fixes_applied is empty for code needing no fixes."""
|
||||
fixer = FactorAutoFixer()
|
||||
if use_pattern:
|
||||
code = "df.groupby(level=1)['$close'].apply(lambda x: np.log(x / x.shift(1)))"
|
||||
else:
|
||||
code = "df['x'] = df.groupby(level=1)['$close'].pct_change()"
|
||||
fixer.fix(code)
|
||||
# fixes_applied should exist
|
||||
assert isinstance(fixer.fixes_applied, list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 18: Pattern Recognition Robustness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPatternRobustness:
|
||||
"""Property: pattern recognition works with varying whitespace."""
|
||||
|
||||
@given(
|
||||
spaces_before=st.integers(min_value=0, max_value=8),
|
||||
spaces_after=st.integers(min_value=0, max_value=8),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_whitespace_variation_handled(self, spaces_before, spaces_after):
|
||||
"""Property: fixer handles varying whitespace around key patterns."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
f"{' ' * spaces_before}df.groupby(level=['instrument', 'date'])['col'].transform('sum')"
|
||||
f"{' ' * spaces_after}"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values" in result
|
||||
|
||||
@given(
|
||||
spaces=st.integers(min_value=0, max_value=8),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_whitespace_before_level(self, spaces):
|
||||
"""Property: fixer recognizes groupby(.level=1) regardless of spacing."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.groupby(level{ ' ' * spaces}={ ' ' * spaces}1)['x'].mean()"
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 19: String Quoting Variants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStringQuoting:
|
||||
"""Property: single-quoted and double-quoted strings are handled identically."""
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_both_quoting_styles(self, seed):
|
||||
"""Property: mixed quoting styles in level=['instrument', 'date'] are handled."""
|
||||
fixer = _auto_fixer()
|
||||
code = "df.groupby(level=['instrument', 'date'])['col'].transform('sum')"
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values" in result
|
||||
|
||||
@given(
|
||||
col=st.sampled_from(["'$close'", "'ret'", "'x'"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_single_quoted_column(self, col):
|
||||
"""Property: single-quoted column names work the same."""
|
||||
fixer = _auto_fixer()
|
||||
code = f"df.groupby(level=1)[{col}].apply(lambda x: x.pct_change())"
|
||||
result = fixer.fix(code)
|
||||
assert ".transform(" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 20: Constructor and State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoFixerConstructor:
|
||||
"""Property: FactorAutoFixer constructor and state."""
|
||||
|
||||
def test_default_constructor(self):
|
||||
"""Property: default constructor creates valid Fixer."""
|
||||
fixer = FactorAutoFixer()
|
||||
assert isinstance(fixer.fixes_applied, list)
|
||||
assert len(fixer.fixes_applied) == 0
|
||||
|
||||
def test_fix_returns_string(self):
|
||||
"""Property: fix() always returns a string."""
|
||||
fixer = _auto_fixer()
|
||||
result = fixer.fix("df['x'] = 1")
|
||||
assert isinstance(result, str)
|
||||
|
||||
@given(
|
||||
code=st.sampled_from(["df.groupby(level=1)['x'].mean()", "x = 1 + 2", "", "pass"]),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_fix_returns_non_empty_for_non_empty_input(self, code):
|
||||
"""Property: fix returns non-empty string for non-empty input."""
|
||||
fixer = _auto_fixer()
|
||||
result = fixer.fix(code)
|
||||
assert isinstance(result, str)
|
||||
if code.strip():
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 21: Multi-Pattern Interactions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultiPatternInteractions:
|
||||
"""Property: multiple fixes interact correctly on the same code."""
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_combined_apply_and_reset_index(self, seed):
|
||||
"""Property: apply→transform AND reset_index removal work together."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
"df_r = df.reset_index()\n"
|
||||
"df_r['x'] = df_r.groupby(level=1)['$close'].apply(lambda x: np.log(x / x.shift(1)))\n"
|
||||
"df_r['y'] = df_r.groupby(level=1)['$close'].transform(lambda x: x.rolling(20, min_periods=5).mean())\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result)
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_volume_proxy_and_groupby_fix(self, seed):
|
||||
"""Property: volume proxy and groupby fixes work together."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
"def calc():\n"
|
||||
" df = pd.read_hdf('data.h5', key='data')\n"
|
||||
" df['val'] = df.groupby(level=1)['$close'].apply(lambda x: x.pct_change())\n"
|
||||
" df['pv'] = df['$close'] * df['$volume']\n"
|
||||
" return df[['val', 'pv']]\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert _is_valid_python(result)
|
||||
assert "volume proxy" in result
|
||||
assert ".transform(" in result
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_instrument_and_level_fix_together(self, seed):
|
||||
"""Property: instrument column replacement and level= fix work together."""
|
||||
fixer = _auto_fixer()
|
||||
code = (
|
||||
"df['key'] = df['instrument'] + '_' + df['day_id'].astype(str)\n"
|
||||
"df.groupby(level=['instrument', 'date'])['col'].transform('sum')\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert "df['instrument']" not in result
|
||||
assert "get_level_values" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 22: Fix Order Independence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFixOrderIndependence:
|
||||
"""Property: specific fix patterns produce deterministic results."""
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_same_input_same_output_always(self, seed):
|
||||
"""Property: fixing same code twice gives identical results."""
|
||||
fixer1 = _auto_fixer()
|
||||
fixer2 = _auto_fixer()
|
||||
code = (
|
||||
"df_r = df.reset_index()\n"
|
||||
"df_r['x'] = df_r.groupby(level=1)['$close'].apply(lambda x: np.log(x / x.shift(1)))\n"
|
||||
"df['y'] = df.groupby(level=['instrument', 'date'])['col'].transform('sum')\n"
|
||||
"df['z'] = df.groupby(level=1)['ret'].transform(lambda x: x.rolling(20, min_periods=1).std())\n"
|
||||
)
|
||||
assert fixer1.fix(code) == fixer2.fix(code)
|
||||
|
||||
@@ -219,3 +219,710 @@ class TestFactorEvaluatorsInit:
|
||||
mock_scen = MagicMock()
|
||||
eva = FactorValueEvaluator(mock_scen)
|
||||
assert eva.scen is mock_scen
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# HYPOTHESIS-BASED PROPERTY TESTS — Code Generation Patterns, Variable
|
||||
# Extraction, Evaluator Consistency
|
||||
# ==============================================================================
|
||||
from hypothesis import given, settings, strategies as st
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from rdagent.components.coder.factor_coder.factor import (
|
||||
FactorTask,
|
||||
FactorFBWorkspace,
|
||||
)
|
||||
from rdagent.components.coder.factor_coder.evaluators import (
|
||||
FactorEvaluatorForCoder,
|
||||
)
|
||||
from rdagent.components.coder.factor_coder.eva_utils import (
|
||||
FactorInfEvaluator,
|
||||
FactorSingleColumnEvaluator,
|
||||
FactorOutputFormatEvaluator,
|
||||
FactorMissingValuesEvaluator,
|
||||
FactorCorrelationEvaluator,
|
||||
FactorValueEvaluator,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _valid_factor_task_names() -> st.SearchStrategy:
|
||||
return st.text(
|
||||
alphabet=st.characters(whitelist_categories=("L", "N", "Lu", "Ll"), whitelist_characters="_"),
|
||||
min_size=1,
|
||||
max_size=50,
|
||||
).filter(lambda s: s and s[0].isalpha() and " " not in s)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 1: FactorTask Field Invariants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskInvariants:
|
||||
"""Property: FactorTask fields maintain invariants after construction."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=50).filter(lambda s: " " not in s),
|
||||
factor_description=st.text(min_size=0, max_size=200),
|
||||
factor_formulation=st.text(min_size=0, max_size=200),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_construction_preserves_all_fields(self, factor_name, factor_description, factor_formulation):
|
||||
"""Property: all constructor args are stored as instance attributes."""
|
||||
t = FactorTask(factor_name, factor_description, factor_formulation)
|
||||
assert t.factor_name == factor_name
|
||||
assert t.factor_description == factor_description
|
||||
assert t.factor_formulation == factor_formulation
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=50).filter(lambda s: " " not in s),
|
||||
factor_description=st.text(min_size=0, max_size=200),
|
||||
factor_formulation=st.text(min_size=0, max_size=200),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_default_field_values(self, factor_name, factor_description, factor_formulation):
|
||||
"""Property: default fields have expected values."""
|
||||
t = FactorTask(factor_name, factor_description, factor_formulation)
|
||||
assert t.factor_implementation is False
|
||||
assert t.factor_resources is None
|
||||
assert t.base_code is None
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=50).filter(lambda s: " " not in s),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_get_task_information_contains_name(self, factor_name):
|
||||
"""Property: get_task_information returns string containing factor_name."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
info = t.get_task_information()
|
||||
assert factor_name in info
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=50).filter(lambda s: " " not in s),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_get_task_brief_information_contains_name(self, factor_name):
|
||||
"""Property: get_task_brief_information returns string containing factor_name."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
info = t.get_task_brief_information()
|
||||
assert factor_name in info
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=50).filter(lambda s: " " not in s),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_get_task_information_and_implementation_result(self, factor_name):
|
||||
"""Property: returned dict contains expected keys."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
result = t.get_task_information_and_implementation_result()
|
||||
assert "factor_name" in result
|
||||
assert "factor_description" in result
|
||||
assert "factor_formulation" in result
|
||||
assert "factor_implementation" in result
|
||||
assert result["factor_name"] == factor_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 2: FactorTask from_dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskFromDict:
|
||||
"""Property: FactorTask.from_dict round-trip."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
factor_description=st.text(min_size=0, max_size=100),
|
||||
factor_formulation=st.text(min_size=0, max_size=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_from_dict_round_trip(self, factor_name, factor_description, factor_formulation):
|
||||
"""Property: constructing from dict of get_task_information_and_implementation_result preserves values."""
|
||||
t1 = FactorTask(factor_name, factor_description, factor_formulation)
|
||||
info = t1.get_task_information_and_implementation_result()
|
||||
t2 = FactorTask.from_dict(info)
|
||||
assert t2.factor_name == t1.factor_name
|
||||
assert t2.factor_description == t1.factor_description
|
||||
assert t2.factor_formulation == t1.factor_formulation
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_from_dict_with_implementation(self, factor_name):
|
||||
"""Property: factor_implementation field restored from dict."""
|
||||
d = {
|
||||
"factor_name": factor_name,
|
||||
"factor_description": "desc",
|
||||
"factor_formulation": "formula",
|
||||
"variables": {},
|
||||
"resource": None,
|
||||
"factor_implementation": True,
|
||||
}
|
||||
t = FactorTask.from_dict(d)
|
||||
assert t.factor_implementation is True
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_from_dict_with_variables(self, factor_name):
|
||||
"""Property: variables dict restored from dict."""
|
||||
d = {
|
||||
"factor_name": factor_name,
|
||||
"factor_description": "desc",
|
||||
"factor_formulation": "formula",
|
||||
"variables": {"x": 1, "y": 2},
|
||||
"resource": "r1",
|
||||
"factor_implementation": False,
|
||||
}
|
||||
t = FactorTask.from_dict(d)
|
||||
assert t.variables == {"x": 1, "y": 2}
|
||||
assert t.factor_resources == "r1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 3: FactorTask Repr
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskRepr:
|
||||
"""Property: __repr__ invariants."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_repr_contains_factor_task_and_name(self, factor_name):
|
||||
"""Property: repr contains 'FactorTask' and factor_name."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
r = repr(t)
|
||||
assert "FactorTask" in r
|
||||
assert factor_name in r
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_repr_is_string(self, factor_name):
|
||||
"""Property: repr returns a string."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
r = repr(t)
|
||||
assert isinstance(r, str)
|
||||
assert len(r) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 4: FactorTask Variables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskVariables:
|
||||
"""Property: variables field invariants."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
vars_keys=st.lists(
|
||||
st.text(min_size=1, max_size=10).filter(lambda s: s.isidentifier()),
|
||||
min_size=0, max_size=10, unique=True,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_variables_stored_correctly(self, factor_name, vars_keys):
|
||||
"""Property: variables dict stored as provided."""
|
||||
vars_dict = {k: i for i, k in enumerate(vars_keys)}
|
||||
t = FactorTask(factor_name, "desc", "formula", variables=vars_dict)
|
||||
assert t.variables == vars_dict
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_default_variables_is_empty_dict(self, factor_name):
|
||||
"""Property: default variables is empty dict."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
assert t.variables == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 5: FactorTask Resource
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskResource:
|
||||
"""Property: resource field invariants."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
resource=st.one_of(st.none(), st.text(min_size=1, max_size=50)),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_resource_stored_correctly(self, factor_name, resource):
|
||||
"""Property: resource field stored as provided or default None."""
|
||||
t = FactorTask(factor_name, "desc", "formula", resource=resource)
|
||||
assert t.factor_resources == resource
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 6: FactorFBWorkspace Path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorFBWorkspacePath:
|
||||
"""Property: FactorFBWorkspace workspace path invariants."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_workspace_path_is_valid_path(self, factor_name):
|
||||
"""Property: workspace_path is a Path instance."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
ws = FactorFBWorkspace(target_task=t)
|
||||
assert isinstance(ws.workspace_path, Path)
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_target_task_reference_preserved(self, factor_name):
|
||||
"""Property: target_task reference points back to FactorTask."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
ws = FactorFBWorkspace(target_task=t)
|
||||
assert ws.target_task is t
|
||||
assert ws.target_task.factor_name == factor_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 7: FactorEvaluatorForCoder Construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorEvaluatorForCoderConstruction:
|
||||
"""Property: FactorEvaluatorForCoder constructor creates sub-evaluators."""
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_sub_evaluators_are_created(self, seed):
|
||||
"""Property: constructor creates value, code, and final_decision evaluators."""
|
||||
mock_scen = MagicMock()
|
||||
eva = FactorEvaluatorForCoder(scen=mock_scen)
|
||||
assert eva.value_evaluator is not None
|
||||
assert eva.code_evaluator is not None
|
||||
assert eva.final_decision_evaluator is not None
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_evaluate_none_implementation_returns_none(self, seed):
|
||||
"""Property: evaluate with implementation=None returns None."""
|
||||
eva = FactorEvaluatorForCoder(scen=MagicMock())
|
||||
result = eva.evaluate(target_task=MagicMock(), implementation=None)
|
||||
assert result is None
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_scenario_reference_accessible(self, seed):
|
||||
"""Property: evaluator has access to scenario."""
|
||||
mock_scen = MagicMock()
|
||||
eva = FactorEvaluatorForCoder(scen=mock_scen)
|
||||
assert eva.scen is mock_scen
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 8: FactorEvaluator SubTypes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorEvaluatorSubTypes:
|
||||
"""Property: sub-evaluator types are correct."""
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_value_evaluator_is_factor_value_evaluator(self, seed):
|
||||
"""Property: value_evaluator is FactorValueEvaluator instance."""
|
||||
eva = FactorEvaluatorForCoder(scen=MagicMock())
|
||||
assert isinstance(eva.value_evaluator, FactorValueEvaluator)
|
||||
|
||||
@given(
|
||||
seed=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_scen_passed_to_value_evaluator(self, seed):
|
||||
"""Property: scenario is passed to value_evaluator."""
|
||||
mock_scen = MagicMock()
|
||||
eva = FactorEvaluatorForCoder(scen=mock_scen)
|
||||
assert eva.value_evaluator.scen is mock_scen
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 9: FactorInfEvaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorInfEvaluator:
|
||||
"""Property: FactorInfEvaluator invariants."""
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_str_is_correct(self, seed):
|
||||
"""Property: __str__ returns 'FactorInfEvaluator'."""
|
||||
eva = FactorInfEvaluator()
|
||||
assert str(eva) == "FactorInfEvaluator"
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_constructor_no_args(self, seed):
|
||||
"""Property: FactorInfEvaluator can be constructed without arguments."""
|
||||
eva = FactorInfEvaluator()
|
||||
assert eva is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 10: FactorSingleColumnEvaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorSingleColumnEvaluator:
|
||||
"""Property: FactorSingleColumnEvaluator invariants."""
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_str_is_correct(self, seed):
|
||||
"""Property: __str__ returns 'FactorSingleColumnEvaluator'."""
|
||||
eva = FactorSingleColumnEvaluator()
|
||||
assert str(eva) == "FactorSingleColumnEvaluator"
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_constructor_no_args(self, seed):
|
||||
"""Property: FactorSingleColumnEvaluator can be constructed without arguments."""
|
||||
eva = FactorSingleColumnEvaluator()
|
||||
assert eva is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 11: FactorOutputFormatEvaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorOutputFormatEvaluator:
|
||||
"""Property: FactorOutputFormatEvaluator invariants."""
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_str_is_correct(self, seed):
|
||||
"""Property: __str__ returns 'FactorOutputFormatEvaluator'."""
|
||||
eva = FactorOutputFormatEvaluator()
|
||||
assert str(eva) == "FactorOutputFormatEvaluator"
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_constructor_no_args(self, seed):
|
||||
"""Property: FactorOutputFormatEvaluator can be constructed without arguments."""
|
||||
eva = FactorOutputFormatEvaluator()
|
||||
assert eva is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 12: FactorMissingValuesEvaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorMissingValuesEvaluator:
|
||||
"""Property: FactorMissingValuesEvaluator invariants."""
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_str_is_correct(self, seed):
|
||||
"""Property: __str__ returns 'FactorMissingValuesEvaluator'."""
|
||||
eva = FactorMissingValuesEvaluator()
|
||||
assert str(eva) == "FactorMissingValuesEvaluator"
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_constructor_no_args(self, seed):
|
||||
"""Property: FactorMissingValuesEvaluator can be constructed without arguments."""
|
||||
eva = FactorMissingValuesEvaluator()
|
||||
assert eva is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 13: FactorCorrelationEvaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorCorrelationEvaluator:
|
||||
"""Property: FactorCorrelationEvaluator invariants."""
|
||||
|
||||
@given(
|
||||
hard_check=st.booleans(),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_hard_check_stored_correctly(self, hard_check):
|
||||
"""Property: hard_check flag stored correctly."""
|
||||
eva = FactorCorrelationEvaluator(hard_check=hard_check)
|
||||
assert eva.hard_check is hard_check
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_str_contains_correct_name(self, seed):
|
||||
"""Property: __str__ contains 'FactorCorrelationEvaluator'."""
|
||||
eva = FactorCorrelationEvaluator(hard_check=False)
|
||||
assert "FactorCorrelationEvaluator" in str(eva)
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_default_hard_check_is_false(self, seed):
|
||||
"""Property: hard_check parameter works."""
|
||||
eva = FactorCorrelationEvaluator(hard_check=False)
|
||||
assert eva.hard_check is False
|
||||
eva2 = FactorCorrelationEvaluator(hard_check=True)
|
||||
assert eva2.hard_check is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 14: FactorValueEvaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorValueEvaluator:
|
||||
"""Property: FactorValueEvaluator invariants."""
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_scenario_stored_correctly(self, seed):
|
||||
"""Property: scenario reference stored."""
|
||||
mock_scen = MagicMock()
|
||||
eva = FactorValueEvaluator(mock_scen)
|
||||
assert eva.scen is mock_scen
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_requires_scenario_arg(self, seed):
|
||||
"""Property: FactorValueEvaluator requires scenario argument."""
|
||||
mock_scen = MagicMock()
|
||||
eva = FactorValueEvaluator(mock_scen)
|
||||
assert eva is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 15: FactorTask Version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskVersion:
|
||||
"""Property: version field invariants."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
version=st.integers(min_value=0, max_value=1000),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_version_default_and_mutable(self, factor_name, version):
|
||||
"""Property: version can be set and retrieved."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
t.version = version
|
||||
assert t.version == version
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 16: FactorTask Feedback Field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskFeedback:
|
||||
"""Property: feedback-related fields."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_default_implementation_is_false(self, factor_name):
|
||||
"""Property: factor_implementation defaults to False."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
assert t.factor_implementation is False
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_implementation_can_be_set(self, factor_name):
|
||||
"""Property: factor_implementation can be set to True."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
t.factor_implementation = True
|
||||
assert t.factor_implementation is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 17: FactorFBWorkspace FB Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorFBWorkspaceConstants:
|
||||
"""Property: FactorFBWorkspace class constants."""
|
||||
|
||||
def test_fb_exec_success_constant(self):
|
||||
"""Property: FB_EXEC_SUCCESS is defined as a non-empty string."""
|
||||
assert len(str(FactorFBWorkspace.FB_EXEC_SUCCESS)) > 0
|
||||
|
||||
def test_fb_output_file_found_constant(self):
|
||||
"""Property: FB_OUTPUT_FILE_FOUND is defined as a non-empty string."""
|
||||
assert len(str(FactorFBWorkspace.FB_OUTPUT_FILE_FOUND)) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 18: FactorTask with Variables from_dict Round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskRoundTrip:
|
||||
"""Property: full round-trip through from_dict preserves all data."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
factor_description=st.text(min_size=0, max_size=100),
|
||||
factor_formulation=st.text(min_size=0, max_size=100),
|
||||
n_vars=st.integers(min_value=0, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_to_dict_from_dict_round_trip(self, factor_name, factor_description, factor_formulation, n_vars):
|
||||
"""Property: task.to_dict() → FactorTask.from_dict(d) preserves key fields."""
|
||||
t1 = FactorTask(factor_name, factor_description, factor_formulation)
|
||||
d = t1.get_task_information_and_implementation_result()
|
||||
t2 = FactorTask.from_dict(d)
|
||||
assert t2.factor_name == factor_name
|
||||
assert t2.factor_description == factor_description
|
||||
assert t2.factor_formulation == factor_formulation
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 19: FactorTask CoSTEERTask Inheritance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskCoSTEER:
|
||||
"""Property: FactorTask inherits correctly from CoSTEERTask."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_base_code_is_none_by_default(self, factor_name):
|
||||
"""Property: base_code attribute is None by default (from CoSTEERTask)."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
assert t.base_code is None
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_base_code_can_be_set(self, factor_name):
|
||||
"""Property: base_code can be set."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
t.base_code = "print(42)"
|
||||
assert t.base_code == "print(42)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 20: FactorEvaluatorForCoder Caching Behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvaluatorCaching:
|
||||
"""Property: evaluator caching behavior."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_cached_feedback_returned(self, factor_name):
|
||||
"""Property: queried_knowledge with cached feedback returns it."""
|
||||
from rdagent.components.coder.factor_coder.factor import FactorTask
|
||||
|
||||
eva = FactorEvaluatorForCoder(scen=MagicMock())
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
qk = MagicMock()
|
||||
qk.success_task_to_knowledge_dict = {"info": MagicMock(feedback="cached")}
|
||||
t.get_task_information = MagicMock(return_value="info")
|
||||
qk.failed_task_info_set = set()
|
||||
|
||||
fb = eva.evaluate(target_task=t, implementation=MagicMock(), queried_knowledge=qk)
|
||||
assert fb == "cached"
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=20).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_failed_task_returns_negative_feedback(self, factor_name):
|
||||
"""Property: failed tasks return negative feedback with 'failed too many times'."""
|
||||
from rdagent.components.coder.factor_coder.factor import FactorTask
|
||||
|
||||
eva = FactorEvaluatorForCoder(scen=MagicMock())
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
qk = MagicMock()
|
||||
qk.success_task_to_knowledge_dict = {}
|
||||
t.get_task_information = MagicMock(return_value="info")
|
||||
qk.failed_task_info_set = {"info"}
|
||||
|
||||
fb = eva.evaluate(target_task=t, implementation=MagicMock(), queried_knowledge=qk)
|
||||
assert fb.final_decision is False
|
||||
assert "failed too many times" in fb.execution_feedback
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 21: FactorTask Information Format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorTaskInformation:
|
||||
"""Property: task information output format."""
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
factor_description=st.text(min_size=0, max_size=100),
|
||||
factor_formulation=st.text(min_size=0, max_size=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_get_task_information_format(self, factor_name, factor_description, factor_formulation):
|
||||
"""Property: get_task_information has expected format."""
|
||||
t = FactorTask(factor_name, factor_description, factor_formulation)
|
||||
info = t.get_task_information()
|
||||
assert f"factor_name: {factor_name}" in info
|
||||
assert f"factor_description: {factor_description}" in info
|
||||
assert f"factor_formulation: {factor_formulation}" in info
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_get_task_information_is_string(self, factor_name):
|
||||
"""Property: get_task_information returns str."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
info = t.get_task_information()
|
||||
assert isinstance(info, str)
|
||||
|
||||
@given(
|
||||
factor_name=st.text(min_size=1, max_size=30).filter(lambda s: s.isidentifier()),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_get_task_brief_information_is_string(self, factor_name):
|
||||
"""Property: get_task_brief_information returns str."""
|
||||
t = FactorTask(factor_name, "desc", "formula")
|
||||
info = t.get_task_brief_information()
|
||||
assert isinstance(info, str)
|
||||
|
||||
@@ -184,3 +184,785 @@ class TestAdvancedLoopThreshold:
|
||||
def test_constant_is_defined(self):
|
||||
from rdagent.scenarios.qlib.quant_loop_factory import ADVANCED_LOOP_FACTOR_THRESHOLD
|
||||
assert ADVANCED_LOOP_FACTOR_THRESHOLD == 5000
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# HYPOTHESIS-BASED PROPERTY TESTS — Data Pipeline Transformations,
|
||||
# Bandit Properties, Feedback Consistency
|
||||
# ==============================================================================
|
||||
from hypothesis import given, settings, strategies as st
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from rdagent.scenarios.qlib.developer.feedback import process_results
|
||||
from rdagent.scenarios.qlib.proposal.bandit import (
|
||||
Metrics,
|
||||
extract_metrics_from_experiment,
|
||||
LinearThompsonTwoArm,
|
||||
)
|
||||
from rdagent.scenarios.qlib.quant_loop_factory import (
|
||||
has_local_components,
|
||||
count_valid_factors,
|
||||
ADVANCED_LOOP_FACTOR_THRESHOLD,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 1: process_results Invariants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessResultsInvariants:
|
||||
"""Property: process_results output invariants."""
|
||||
|
||||
REQUIRED_METRICS = [
|
||||
"IC",
|
||||
"1day.excess_return_with_cost.annualized_return",
|
||||
"1day.excess_return_with_cost.max_drawdown",
|
||||
]
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
sota_ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
sota_ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
sota_max_dd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_process_results_contains_all_metrics(
|
||||
self, ic, ann_return, max_dd, sota_ic, sota_ann_return, sota_max_dd
|
||||
):
|
||||
"""Property: output string contains IC, annualized_return, and max_drawdown."""
|
||||
current = pd.Series({
|
||||
"IC": ic,
|
||||
"1day.excess_return_with_cost.annualized_return": ann_return,
|
||||
"1day.excess_return_with_cost.max_drawdown": max_dd,
|
||||
}, name="0")
|
||||
sota = pd.Series({
|
||||
"IC": sota_ic,
|
||||
"1day.excess_return_with_cost.annualized_return": sota_ann_return,
|
||||
"1day.excess_return_with_cost.max_drawdown": sota_max_dd,
|
||||
}, name="0")
|
||||
|
||||
result = process_results(current, sota)
|
||||
assert "IC of Current Result is" in result
|
||||
assert "of SOTA Result is" in result
|
||||
assert f"{ic:.6f}" in result or "nan" in result.lower()
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_process_results_returns_string(self, ic, ann_return, max_dd):
|
||||
"""Property: process_results returns a string."""
|
||||
current = pd.Series({
|
||||
"IC": ic,
|
||||
"1day.excess_return_with_cost.annualized_return": ann_return,
|
||||
"1day.excess_return_with_cost.max_drawdown": max_dd,
|
||||
}, name="0")
|
||||
sota = pd.Series({
|
||||
"IC": 0.0,
|
||||
"1day.excess_return_with_cost.annualized_return": 0.0,
|
||||
"1day.excess_return_with_cost.max_drawdown": 0.0,
|
||||
}, name="0")
|
||||
|
||||
result = process_results(current, sota)
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_process_results_raises_on_missing_metrics(self, ic, ann_return, max_dd):
|
||||
"""Property: process_results raises KeyError on missing required metrics."""
|
||||
current = pd.Series({"IC": ic}, name="0")
|
||||
sota = pd.Series({"IC": 0.0}, name="0")
|
||||
with pytest.raises(KeyError):
|
||||
process_results(current, sota)
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_process_results_format_consistent(self, ic, ann_return, max_dd):
|
||||
"""Property: output format is '<metric> of Current Result is <val>, of SOTA Result is <val>'."""
|
||||
current = pd.Series({
|
||||
"IC": ic,
|
||||
"1day.excess_return_with_cost.annualized_return": ann_return,
|
||||
"1day.excess_return_with_cost.max_drawdown": max_dd,
|
||||
}, name="0")
|
||||
sota = pd.Series({
|
||||
"IC": 0.0,
|
||||
"1day.excess_return_with_cost.annualized_return": 0.0,
|
||||
"1day.excess_return_with_cost.max_drawdown": 0.0,
|
||||
}, name="0")
|
||||
|
||||
result = process_results(current, sota)
|
||||
assert "of Current Result is" in result
|
||||
assert "of SOTA Result is" in result
|
||||
# Results separated by '; '
|
||||
assert ";" in result
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Property 2: Metrics Default Values
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMetricsDefaults:
|
||||
"""Property: Metrics default values are zero."""
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
sharpe=st.floats(min_value=-5.0, max_value=10.0),
|
||||
rank_ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_partial_construction_defaults_to_zero(self, ic, sharpe, rank_ic):
|
||||
"""Property: fields not specified default to 0.0."""
|
||||
m = Metrics(ic=ic, sharpe=sharpe, rank_ic=rank_ic)
|
||||
assert m.ic == ic
|
||||
assert m.sharpe == sharpe
|
||||
assert m.rank_ic == rank_ic
|
||||
assert m.icir == 0.0
|
||||
assert m.rank_icir == 0.0
|
||||
assert m.mdd == 0.0
|
||||
|
||||
@given(
|
||||
icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
rank_icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
mdd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_three_fields_default_others_zero(self, icir, rank_icir, mdd):
|
||||
"""Property: only given fields set, others zero."""
|
||||
m = Metrics(icir=icir, rank_icir=rank_icir, mdd=mdd)
|
||||
assert m.ic == 0.0
|
||||
assert m.sharpe == 0.0
|
||||
assert m.rank_ic == 0.0
|
||||
assert m.icir == icir
|
||||
assert m.rank_icir == rank_icir
|
||||
assert m.mdd == mdd
|
||||
|
||||
def test_all_defaults_zero(self):
|
||||
"""Property: default constructor sets everything to zero."""
|
||||
m = Metrics()
|
||||
assert m.ic == 0.0
|
||||
assert m.sharpe == 0.0
|
||||
assert m.mdd == 0.0
|
||||
assert m.icir == 0.0
|
||||
assert m.rank_ic == 0.0
|
||||
assert m.rank_icir == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 3: Metrics as_vector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMetricsAsVector:
|
||||
"""Property: as_vector invariants."""
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
rank_ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
rank_icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
ir=st.floats(min_value=-5.0, max_value=10.0),
|
||||
mdd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
sharpe=st.floats(min_value=-5.0, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_as_vector_length_is_8(self, ic, icir, rank_ic, rank_icir, ann_return, ir, mdd, sharpe):
|
||||
"""Property: as_vector always returns length-8 array."""
|
||||
m = Metrics(
|
||||
ic=ic, icir=icir, rank_ic=rank_ic, rank_icir=rank_icir,
|
||||
arr=ann_return, ir=ir, mdd=mdd, sharpe=sharpe,
|
||||
)
|
||||
v = m.as_vector()
|
||||
assert len(v) == 8
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
rank_ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
rank_icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
ir=st.floats(min_value=-5.0, max_value=10.0),
|
||||
mdd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
sharpe=st.floats(min_value=-5.0, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_as_vector_matches_input_order(self, ic, icir, rank_ic, rank_icir, ann_return, ir, mdd, sharpe):
|
||||
"""Property: vector elements match (ic, icir, rank_ic, rank_icir, ann_return, ir, -mdd, sharpe)."""
|
||||
m = Metrics(
|
||||
ic=ic, icir=icir, rank_ic=rank_ic, rank_icir=rank_icir,
|
||||
arr=ann_return, ir=ir, mdd=mdd, sharpe=sharpe,
|
||||
)
|
||||
v = m.as_vector()
|
||||
assert v[0] == ic
|
||||
assert v[1] == icir
|
||||
assert v[2] == rank_ic
|
||||
assert v[3] == rank_icir
|
||||
assert v[4] == ann_return
|
||||
assert v[5] == ir
|
||||
assert v[6] == -mdd # negated
|
||||
assert v[7] == sharpe
|
||||
|
||||
@given(
|
||||
mdd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_mdd_negated_in_vector(self, mdd):
|
||||
"""Property: mdd is negated in as_vector output (v[6] = -mdd)."""
|
||||
m = Metrics(mdd=mdd)
|
||||
v = m.as_vector()
|
||||
assert v[6] == -mdd
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
rank_ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
rank_icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
ir=st.floats(min_value=-5.0, max_value=10.0),
|
||||
mdd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
sharpe=st.floats(min_value=-5.0, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_as_vector_returns_numpy_array(self, ic, icir, rank_ic, rank_icir, ann_return, ir, mdd, sharpe):
|
||||
"""Property: as_vector returns np.ndarray."""
|
||||
m = Metrics(
|
||||
ic=ic, icir=icir, rank_ic=rank_ic, rank_icir=rank_icir,
|
||||
arr=ann_return, ir=ir, mdd=mdd, sharpe=sharpe,
|
||||
)
|
||||
v = m.as_vector()
|
||||
assert isinstance(v, np.ndarray)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 4: extract_metrics_from_experiment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractMetrics:
|
||||
"""Property: extract_metrics_from_experiment invariants."""
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
rank_ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
rank_icir=st.floats(min_value=-2.0, max_value=10.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
ir=st.floats(min_value=-5.0, max_value=10.0),
|
||||
mdd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_extract_metrics_correct_values(self, ic, icir, rank_ic, rank_icir, ann_return, ir, mdd):
|
||||
"""Property: extract_metrics_from_experiment reads correct values from result dict."""
|
||||
mock_exp = MagicMock()
|
||||
mock_exp.result = {
|
||||
"IC": ic, "ICIR": icir,
|
||||
"Rank IC": rank_ic, "Rank ICIR": rank_icir,
|
||||
"1day.excess_return_with_cost.annualized_return ": ann_return,
|
||||
"1day.excess_return_with_cost.information_ratio": ir,
|
||||
"1day.excess_return_with_cost.max_drawdown": mdd,
|
||||
}
|
||||
m = extract_metrics_from_experiment(mock_exp)
|
||||
assert m.ic == ic
|
||||
assert m.rank_ic == rank_ic
|
||||
assert m.icir == icir
|
||||
assert m.rank_icir == rank_icir
|
||||
assert m.mdd == mdd
|
||||
|
||||
@given(
|
||||
ann_return=st.floats(min_value=0.01, max_value=2.0),
|
||||
mdd=st.floats(min_value=-0.01, max_value=-0.001),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_sharpe_computed_from_ann_return_and_mdd(self, ann_return, mdd):
|
||||
"""Property: sharpe ≈ ann_return / |mdd| for standard inputs."""
|
||||
mock_exp = MagicMock()
|
||||
mock_exp.result = {
|
||||
"IC": 0.0, "ICIR": 0.0,
|
||||
"Rank IC": 0.0, "Rank ICIR": 0.0,
|
||||
"1day.excess_return_with_cost.annualized_return ": ann_return,
|
||||
"1day.excess_return_with_cost.information_ratio": 0.0,
|
||||
"1day.excess_return_with_cost.max_drawdown": mdd,
|
||||
}
|
||||
m = extract_metrics_from_experiment(mock_exp)
|
||||
expected_sharpe = ann_return / abs(mdd)
|
||||
assert m.sharpe == pytest.approx(expected_sharpe, rel=0.01)
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_extract_returns_default_on_none_result(self, seed):
|
||||
"""Property: returns default Metrics (all zeros) when result is None."""
|
||||
mock_exp = MagicMock()
|
||||
mock_exp.result = None
|
||||
m = extract_metrics_from_experiment(mock_exp)
|
||||
assert m.ic == 0.0
|
||||
assert m.sharpe == 0.0
|
||||
assert m.mdd == 0.0
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_extract_returns_default_on_empty_result(self, seed):
|
||||
"""Property: returns default Metrics when result dict is empty."""
|
||||
mock_exp = MagicMock()
|
||||
mock_exp.result = {}
|
||||
m = extract_metrics_from_experiment(mock_exp)
|
||||
assert m.ic == 0.0
|
||||
assert m.sharpe == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 5: LinearThompsonTwoArm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLinearThompsonTwoArm:
|
||||
"""Property: LinearThompsonTwoArm bandit invariants."""
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=20))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_dim_stored_correctly(self, dim):
|
||||
"""Property: dim attribute matches constructor arg."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
assert bandit.dim == dim
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_mean_shape_matches_dim(self, dim):
|
||||
"""Property: mean vectors have shape (dim,)."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
assert bandit.mean["factor"].shape == (dim,)
|
||||
assert bandit.mean["model"].shape == (dim,)
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_precision_shape_matches_dim(self, dim):
|
||||
"""Property: precision matrices have shape (dim, dim)."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
assert bandit.precision["factor"].shape == (dim, dim)
|
||||
assert bandit.precision["model"].shape == (dim, dim)
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_arms_initialized_identically(self, dim):
|
||||
"""Property: factor and model arms are initialized identically."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
assert np.array_equal(bandit.mean["factor"], bandit.mean["model"])
|
||||
assert np.array_equal(bandit.precision["factor"], bandit.precision["model"])
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_noise_var_is_default_1(self, dim):
|
||||
"""Property: noise_var defaults to 1.0."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
assert bandit.noise_var == 1.0
|
||||
|
||||
@given(
|
||||
dim=st.integers(min_value=1, max_value=10),
|
||||
noise_var=st.floats(min_value=0.01, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_noise_var_configurable(self, dim, noise_var):
|
||||
"""Property: noise_var can be set via constructor."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim, noise_var=noise_var)
|
||||
assert bandit.noise_var == noise_var
|
||||
|
||||
@given(
|
||||
dim=st.integers(min_value=1, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_sample_reward_returns_float(self, dim):
|
||||
"""Property: sample_reward returns a float."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
x = np.ones(dim)
|
||||
reward = bandit.sample_reward("factor", x)
|
||||
assert isinstance(reward, float)
|
||||
|
||||
@given(
|
||||
dim=st.integers(min_value=1, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_sample_reward_finite(self, dim):
|
||||
"""Property: sample_reward returns finite values."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
x = np.ones(dim)
|
||||
reward = bandit.sample_reward("factor", x)
|
||||
assert np.isfinite(reward)
|
||||
|
||||
@given(
|
||||
dim=st.integers(min_value=1, max_value=10),
|
||||
seed_a=st.integers(min_value=0, max_value=50),
|
||||
seed_b=st.integers(min_value=51, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_sample_reward_varies(self, dim, seed_a, seed_b):
|
||||
"""Property: different seeds may produce different rewards (stochasticity)."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
x = np.ones(dim)
|
||||
r1 = bandit.sample_reward("factor", x)
|
||||
r2 = bandit.sample_reward("factor", x)
|
||||
# Both should be finite (may be equal by chance)
|
||||
assert np.isfinite(r1)
|
||||
assert np.isfinite(r2)
|
||||
|
||||
@given(dim=st.integers(min_value=2, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_precision_is_symmetric(self, dim):
|
||||
"""Property: precision matrix is symmetric."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
P = bandit.precision["factor"]
|
||||
assert np.allclose(P, P.T, atol=1e-10)
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_both_arms_have_same_keys(self, dim):
|
||||
"""Property: both 'factor' and 'model' arms exist in mean/precision dicts."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
assert "factor" in bandit.mean
|
||||
assert "model" in bandit.mean
|
||||
assert "factor" in bandit.precision
|
||||
assert "model" in bandit.precision
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 6: LinearThompsonTwoArm Update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBanditUpdate:
|
||||
"""Property: Thompson bandit update invariants."""
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_update_exists_for_both_arms(self, dim):
|
||||
"""Property: update method is callable for both arms."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
x = np.ones(dim)
|
||||
bandit.update("factor", x, 0.5)
|
||||
bandit.update("model", x, 0.3)
|
||||
# Should not raise
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_update_changes_mean(self, dim):
|
||||
"""Property: updating an arm changes its mean vector."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
orig = bandit.mean["factor"].copy()
|
||||
x = np.ones(dim)
|
||||
bandit.update("factor", x, 1.0)
|
||||
# Mean should change (or be computed differently after update)
|
||||
assert not np.array_equal(orig, bandit.mean["factor"]) or np.array_equal(orig, np.zeros(dim))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 7: has_local_components / count_valid_factors / ADVANCED_LOOP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQuantLoopFactory:
|
||||
"""Property: quant_loop_factory function invariants."""
|
||||
|
||||
def test_has_local_components_returns_bool(self):
|
||||
"""Property: has_local_components returns bool."""
|
||||
result = has_local_components()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_count_valid_factors_returns_nonnegative_int(self):
|
||||
"""Property: count_valid_factors returns nonnegative int."""
|
||||
result = count_valid_factors()
|
||||
assert isinstance(result, int)
|
||||
assert result >= 0
|
||||
|
||||
def test_advanced_loop_threshold_is_5000(self):
|
||||
"""Property: ADVANCED_LOOP_FACTOR_THRESHOLD == 5000."""
|
||||
assert ADVANCED_LOOP_FACTOR_THRESHOLD == 5000
|
||||
|
||||
def test_advanced_loop_threshold_is_positive(self):
|
||||
"""Property: ADVANCED_LOOP_FACTOR_THRESHOLD > 0."""
|
||||
assert ADVANCED_LOOP_FACTOR_THRESHOLD > 0
|
||||
|
||||
def test_has_local_components_deterministic(self):
|
||||
"""Property: has_local_components returns same value on repeated calls."""
|
||||
r1 = has_local_components()
|
||||
r2 = has_local_components()
|
||||
assert r1 == r2
|
||||
|
||||
def test_count_valid_factors_deterministic(self):
|
||||
"""Property: count_valid_factors returns same value on repeated calls."""
|
||||
r1 = count_valid_factors()
|
||||
r2 = count_valid_factors()
|
||||
assert r1 == r2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 8: process_results Numeric Edge Cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessResultsEdgeCases:
|
||||
"""Property: process_results handles edge case values."""
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0, allow_nan=False, allow_infinity=False),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0, allow_nan=False, allow_infinity=False),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_all_numeric_values_formatted(self, ic, ann_return, max_dd):
|
||||
"""Property: all valid numeric values produce a result string."""
|
||||
current = pd.Series({
|
||||
"IC": ic,
|
||||
"1day.excess_return_with_cost.annualized_return": ann_return,
|
||||
"1day.excess_return_with_cost.max_drawdown": max_dd,
|
||||
}, name="0")
|
||||
sota = pd.Series({
|
||||
"IC": 0.0,
|
||||
"1day.excess_return_with_cost.annualized_return": 0.0,
|
||||
"1day.excess_return_with_cost.max_drawdown": 0.0,
|
||||
}, name="0")
|
||||
|
||||
result = process_results(current, sota)
|
||||
assert isinstance(result, str)
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0, allow_nan=False, allow_infinity=False),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0, allow_nan=False, allow_infinity=False),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_result_contains_both_current_and_sota(self, ic, ann_return, max_dd):
|
||||
"""Property: result contains 'Current Result' and 'SOTA Result'."""
|
||||
current = pd.Series({
|
||||
"IC": ic,
|
||||
"1day.excess_return_with_cost.annualized_return": ann_return,
|
||||
"1day.excess_return_with_cost.max_drawdown": max_dd,
|
||||
}, name="0")
|
||||
sota = pd.Series({
|
||||
"IC": 0.0,
|
||||
"1day.excess_return_with_cost.annualized_return": 0.0,
|
||||
"1day.excess_return_with_cost.max_drawdown": 0.0,
|
||||
}, name="0")
|
||||
|
||||
result = process_results(current, sota)
|
||||
assert "Current Result" in result
|
||||
assert "SOTA Result" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 9: Metrics Constructor Type Safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMetricsTypeSafety:
|
||||
"""Property: Metrics converts inputs to float."""
|
||||
|
||||
@given(
|
||||
ic=st.integers(min_value=-10, max_value=10),
|
||||
sharpe=st.integers(min_value=-5, max_value=20),
|
||||
mdd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_float_conversion(self, ic, sharpe, mdd):
|
||||
"""Property: integer inputs become floats."""
|
||||
m = Metrics(ic=float(ic), sharpe=float(sharpe), mdd=mdd)
|
||||
assert isinstance(m.ic, float)
|
||||
assert isinstance(m.sharpe, float)
|
||||
assert isinstance(m.mdd, float)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 10: Bandit Precision Positive Definite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBanditPrecisionProperties:
|
||||
"""Property: precision matrix is positive semi-definite (identity-initialized)."""
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_precision_is_identity_initialized(self, dim):
|
||||
"""Property: precision matrix starts as identity."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
P = bandit.precision["factor"]
|
||||
expected = np.eye(dim)
|
||||
assert np.allclose(P, expected, atol=1e-10)
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_precision_diagonal_positive(self, dim):
|
||||
"""Property: precision matrix diagonal elements are positive."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
P = bandit.precision["factor"]
|
||||
assert (np.diag(P) > 0).all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 11: Bandit Mean Initialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBanditMeanInitialization:
|
||||
"""Property: mean vector is initialized to zeros."""
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_mean_is_zero_initialized(self, dim):
|
||||
"""Property: mean starts as zero vector."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
m = bandit.mean["factor"]
|
||||
expected = np.zeros(dim)
|
||||
assert np.allclose(m, expected, atol=1e-10)
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_both_arms_mean_zero_initialized(self, dim):
|
||||
"""Property: both arm means start as zero."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
assert np.allclose(bandit.mean["factor"], np.zeros(dim))
|
||||
assert np.allclose(bandit.mean["model"], np.zeros(dim))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 12: extract_metrics Robustness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractMetricsRobustness:
|
||||
"""Property: extract_metrics_from_experiment handles missing keys."""
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_partial_result_dict(self, ic):
|
||||
"""Property: partial result dict fills defaults for missing keys."""
|
||||
mock_exp = MagicMock()
|
||||
mock_exp.result = {"IC": ic}
|
||||
m = extract_metrics_from_experiment(mock_exp)
|
||||
assert m.ic == ic
|
||||
assert m.sharpe == 0.0 # default since ann_return is missing
|
||||
|
||||
@given(seed=st.integers(min_value=0, max_value=100))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_extract_with_empty_dict(self, seed):
|
||||
"""Property: empty result dict → all defaults or raises."""
|
||||
mock_exp = MagicMock()
|
||||
mock_exp.result = {}
|
||||
m = extract_metrics_from_experiment(mock_exp)
|
||||
assert isinstance(m, Metrics)
|
||||
assert m.ic == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 13: Metrics Field Naming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMetricsFieldNaming:
|
||||
"""Property: Metrics has specific named fields."""
|
||||
|
||||
def test_metrics_has_all_expected_fields(self):
|
||||
"""Property: Metrics has ic, icir, rank_ic, rank_icir, ann_return, ir, mdd, sharpe."""
|
||||
m = Metrics()
|
||||
expected = {"ic", "icir", "rank_ic", "rank_icir", "arr", "ir", "mdd", "sharpe"}
|
||||
actual = {k for k in m.__dict__ if not k.startswith("_")}
|
||||
assert expected <= actual or expected <= set(m.__dataclass_fields__ if hasattr(m, "__dataclass_fields__") else [])
|
||||
|
||||
@given(
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
ir=st.floats(min_value=-5.0, max_value=10.0),
|
||||
sharpe=st.floats(min_value=-5.0, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_return_and_sharpe_fields(self, ann_return, ir, sharpe):
|
||||
"""Property: ann_return, ir, sharpe accessible by attribute."""
|
||||
m = Metrics(arr=ann_return, ir=ir, sharpe=sharpe)
|
||||
assert m.arr == ann_return
|
||||
assert m.ir == ir
|
||||
assert m.sharpe == sharpe
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 14: process_results Determinism
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessResultsDeterminism:
|
||||
"""Property: process_results is deterministic."""
|
||||
|
||||
@given(
|
||||
ic=st.floats(min_value=-1.0, max_value=1.0),
|
||||
ann_return=st.floats(min_value=-2.0, max_value=5.0),
|
||||
max_dd=st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_same_inputs_same_output(self, ic, ann_return, max_dd):
|
||||
"""Property: process_results is deterministic."""
|
||||
current = pd.Series({
|
||||
"IC": ic,
|
||||
"1day.excess_return_with_cost.annualized_return": ann_return,
|
||||
"1day.excess_return_with_cost.max_drawdown": max_dd,
|
||||
}, name="0")
|
||||
sota = pd.Series({
|
||||
"IC": 0.0,
|
||||
"1day.excess_return_with_cost.annualized_return": 0.0,
|
||||
"1day.excess_return_with_cost.max_drawdown": 0.0,
|
||||
}, name="0")
|
||||
|
||||
r1 = process_results(current, sota)
|
||||
r2 = process_results(current, sota)
|
||||
assert r1 == r2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 15: Bandit Sample Reward Distribution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBanditSampleReward:
|
||||
"""Property: sample_reward behavior across arms."""
|
||||
|
||||
@given(dim=st.integers(min_value=1, max_value=10))
|
||||
@settings(max_examples=50, deadline=10000)
|
||||
def test_factor_and_model_reward_differ(self, dim):
|
||||
"""Property: factor and model arms can give different rewards."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
x = np.random.randn(dim)
|
||||
r_factor = bandit.sample_reward("factor", x)
|
||||
r_model = bandit.sample_reward("model", x)
|
||||
assert isinstance(r_factor, float)
|
||||
assert isinstance(r_model, float)
|
||||
|
||||
@given(
|
||||
dim=st.integers(min_value=1, max_value=10),
|
||||
n_samples=st.integers(min_value=10, max_value=100),
|
||||
)
|
||||
@settings(max_examples=10, deadline=10000)
|
||||
def test_sample_reward_changes_after_update(self, dim, n_samples):
|
||||
"""Property: after updates, sample_reward distribution shifts."""
|
||||
bandit = LinearThompsonTwoArm(dim=dim)
|
||||
x = np.ones(dim)
|
||||
rewards_before = [bandit.sample_reward("factor", x) for _ in range(n_samples)]
|
||||
|
||||
# Update with positive rewards
|
||||
for _ in range(10):
|
||||
bandit.update("factor", x, 1.0)
|
||||
|
||||
rewards_after = [bandit.sample_reward("factor", x) for _ in range(n_samples)]
|
||||
|
||||
# Mean should shift (though statistically it may not)
|
||||
assert np.all(np.isfinite(rewards_before))
|
||||
assert np.all(np.isfinite(rewards_after))
|
||||
|
||||
Reference in New Issue
Block a user