chore: release v1.0.2
This commit is contained in:
@@ -27,6 +27,68 @@ LINDATA = np.arange(1.0, 6.0) # [1,2,3,4,5]
|
||||
CONSTDATA = np.ones(10) # all 1.0
|
||||
|
||||
|
||||
def _naive_linreg_window(window: np.ndarray) -> tuple[float, float]:
|
||||
x = np.arange(len(window), dtype=np.float64)
|
||||
sum_x = float(np.sum(x))
|
||||
sum_y = float(np.sum(window))
|
||||
sum_xy = float(np.sum(x * window))
|
||||
sum_x2 = float(np.sum(x * x))
|
||||
n = float(len(window))
|
||||
denom = n * sum_x2 - sum_x * sum_x
|
||||
slope = (n * sum_xy - sum_x * sum_y) / denom if denom != 0.0 else 0.0
|
||||
intercept = (sum_y - slope * sum_x) / n
|
||||
return slope, intercept
|
||||
|
||||
|
||||
def _naive_linearreg(series: np.ndarray, timeperiod: int, x_value: float) -> np.ndarray:
|
||||
out = np.full(len(series), np.nan, dtype=np.float64)
|
||||
for end in range(timeperiod - 1, len(series)):
|
||||
slope, intercept = _naive_linreg_window(series[end + 1 - timeperiod : end + 1])
|
||||
out[end] = intercept + slope * x_value
|
||||
return out
|
||||
|
||||
|
||||
def _naive_correl(x: np.ndarray, y: np.ndarray, timeperiod: int) -> np.ndarray:
|
||||
out = np.full(len(x), np.nan, dtype=np.float64)
|
||||
for end in range(timeperiod - 1, len(x)):
|
||||
x_window = x[end + 1 - timeperiod : end + 1]
|
||||
y_window = y[end + 1 - timeperiod : end + 1]
|
||||
mean_x = float(np.sum(x_window)) / timeperiod
|
||||
mean_y = float(np.sum(y_window)) / timeperiod
|
||||
cov = float(np.sum((x_window - mean_x) * (y_window - mean_y)))
|
||||
std_x = float(np.sqrt(np.sum((x_window - mean_x) ** 2)))
|
||||
std_y = float(np.sqrt(np.sum((y_window - mean_y) ** 2)))
|
||||
denom = std_x * std_y
|
||||
out[end] = cov / denom if denom != 0.0 else np.nan
|
||||
return out
|
||||
|
||||
|
||||
def _naive_beta(x: np.ndarray, y: np.ndarray, timeperiod: int) -> np.ndarray:
|
||||
out = np.full(len(x), np.nan, dtype=np.float64)
|
||||
for end in range(timeperiod, len(x)):
|
||||
start = end - timeperiod
|
||||
rx = np.array(
|
||||
[
|
||||
x[idx + 1] / x[idx] - 1.0 if x[idx] != 0.0 else np.nan
|
||||
for idx in range(start, end)
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
ry = np.array(
|
||||
[
|
||||
y[idx + 1] / y[idx] - 1.0 if y[idx] != 0.0 else np.nan
|
||||
for idx in range(start, end)
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
mean_x = float(np.sum(rx)) / timeperiod
|
||||
mean_y = float(np.sum(ry)) / timeperiod
|
||||
cov = float(np.sum((rx - mean_x) * (ry - mean_y))) / timeperiod
|
||||
var_x = float(np.sum((rx - mean_x) ** 2)) / timeperiod
|
||||
out[end] = cov / var_x if var_x != 0.0 else np.nan
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STDDEV
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -100,6 +162,11 @@ class TestLINEARREG:
|
||||
def test_length(self):
|
||||
assert len(LINEARREG(_A, 14)) == N
|
||||
|
||||
def test_matches_naive_regression(self):
|
||||
expected = _naive_linearreg(_A, timeperiod=14, x_value=13.0)
|
||||
result = LINEARREG(_A, timeperiod=14)
|
||||
np.testing.assert_allclose(result, expected, equal_nan=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LINEARREG_SLOPE
|
||||
@@ -179,6 +246,11 @@ class TestBETA:
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(np.isfinite(valid))
|
||||
|
||||
def test_matches_naive_beta(self):
|
||||
expected = _naive_beta(_A, _B, timeperiod=5)
|
||||
result = BETA(_A, _B, timeperiod=5)
|
||||
np.testing.assert_allclose(result, expected, equal_nan=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CORREL
|
||||
@@ -205,6 +277,11 @@ class TestCOREL:
|
||||
def test_length(self):
|
||||
assert len(CORREL(_A, _B, 10)) == N
|
||||
|
||||
def test_matches_naive_correlation(self):
|
||||
expected = _naive_correl(_A, _B, timeperiod=10)
|
||||
result = CORREL(_A, _B, timeperiod=10)
|
||||
np.testing.assert_allclose(result, expected, equal_nan=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TSF
|
||||
@@ -226,3 +303,8 @@ class TestTSF:
|
||||
|
||||
def test_length(self):
|
||||
assert len(TSF(_A, 14)) == N
|
||||
|
||||
def test_matches_naive_tsf(self):
|
||||
expected = _naive_linearreg(_A, timeperiod=14, x_value=14.0)
|
||||
result = TSF(_A, timeperiod=14)
|
||||
np.testing.assert_allclose(result, expected, equal_nan=True)
|
||||
|
||||
@@ -323,6 +323,21 @@ class TestSignalComposition:
|
||||
score = compose(sigs, method="rank")
|
||||
assert score.shape == (30,)
|
||||
|
||||
def test_compose_rank_matches_manual_column_ranks(self):
|
||||
from ferro_ta.analysis.signals import compose
|
||||
|
||||
sigs = np.array(
|
||||
[
|
||||
[3.0, 1.0],
|
||||
[1.0, 2.0],
|
||||
[2.0, 2.0],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
score = compose(sigs, method="rank")
|
||||
expected = np.array([4.0, 3.5, 4.5], dtype=np.float64)
|
||||
np.testing.assert_allclose(score, expected)
|
||||
|
||||
def test_compose_equal_weights_default(self):
|
||||
from ferro_ta.analysis.signals import compose
|
||||
|
||||
@@ -574,6 +589,75 @@ class TestFeatureMatrix:
|
||||
fm = feature_matrix(ohlcv, ["SMA"])
|
||||
assert "SMA" in fm
|
||||
|
||||
def test_feature_matrix_mixed_fastpath_and_multi_output(self):
|
||||
from ferro_ta.analysis.features import feature_matrix
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(80)
|
||||
ohlcv = {"close": c, "high": h, "low": l, "open": o, "volume": v}
|
||||
fm = feature_matrix(
|
||||
ohlcv,
|
||||
[
|
||||
("SMA", {"timeperiod": 10}),
|
||||
("ATR", {"timeperiod": 14}),
|
||||
("BBANDS", {"timeperiod": 10}, 1),
|
||||
],
|
||||
)
|
||||
assert "SMA" in fm
|
||||
assert "ATR" in fm
|
||||
assert "BBANDS_1" in fm
|
||||
|
||||
|
||||
class TestComputeMany:
|
||||
def test_close_indicators_match_public_api(self):
|
||||
from ferro_ta import EMA, RSI, SMA
|
||||
from ferro_ta.data.batch import compute_many
|
||||
|
||||
_, _, _, close, _ = _make_ohlcv(80)
|
||||
results = compute_many(
|
||||
[
|
||||
("SMA", {"timeperiod": 10}),
|
||||
("EMA", {"timeperiod": 12}),
|
||||
("RSI", {"timeperiod": 14}),
|
||||
],
|
||||
close=close,
|
||||
)
|
||||
|
||||
np.testing.assert_allclose(results[0], SMA(close, timeperiod=10), equal_nan=True)
|
||||
np.testing.assert_allclose(results[1], EMA(close, timeperiod=12), equal_nan=True)
|
||||
np.testing.assert_allclose(results[2], RSI(close, timeperiod=14), equal_nan=True)
|
||||
|
||||
def test_hlc_indicators_match_public_api(self):
|
||||
from ferro_ta import ADX, ATR
|
||||
from ferro_ta.data.batch import compute_many
|
||||
|
||||
_, high, low, close, _ = _make_ohlcv(80)
|
||||
results = compute_many(
|
||||
[
|
||||
("ATR", {"timeperiod": 14}),
|
||||
("ADX", {"timeperiod": 14}),
|
||||
],
|
||||
close=close,
|
||||
high=high,
|
||||
low=low,
|
||||
)
|
||||
|
||||
np.testing.assert_allclose(
|
||||
results[0], ATR(high, low, close, timeperiod=14), equal_nan=True
|
||||
)
|
||||
np.testing.assert_allclose(
|
||||
results[1], ADX(high, low, close, timeperiod=14), equal_nan=True
|
||||
)
|
||||
|
||||
def test_unsupported_kwargs_fall_back_cleanly(self):
|
||||
from ferro_ta import STDDEV
|
||||
from ferro_ta.data.batch import compute_many
|
||||
|
||||
_, _, _, close, _ = _make_ohlcv(80)
|
||||
result = compute_many([("STDDEV", {"timeperiod": 10, "nbdev": 2.0})], close=close)
|
||||
np.testing.assert_allclose(
|
||||
result[0], STDDEV(close, timeperiod=10, nbdev=2.0), equal_nan=True
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Viz (smoke tests)
|
||||
|
||||
@@ -297,6 +297,40 @@ class TestBacktest:
|
||||
result_no_slip.n_trades == 0
|
||||
)
|
||||
|
||||
def test_commission_matches_reference_loop(self):
|
||||
close = np.array([100.0, 102.0, 101.0, 104.0, 103.0, 105.0], dtype=np.float64)
|
||||
raw_signals = np.array([0.0, 1.0, 1.0, -1.0, -1.0, 0.0], dtype=np.float64)
|
||||
|
||||
def strategy(_, **__):
|
||||
return raw_signals
|
||||
|
||||
commission = 0.02
|
||||
result = backtest(close, strategy=strategy, commission_per_trade=commission)
|
||||
|
||||
expected_positions = np.array(
|
||||
[0.0, 0.0, 1.0, 1.0, -1.0, -1.0], dtype=np.float64
|
||||
)
|
||||
expected_returns = np.empty_like(close)
|
||||
expected_returns[0] = 0.0
|
||||
expected_returns[1:] = np.diff(close) / close[:-1]
|
||||
expected_strategy_returns = expected_positions * expected_returns
|
||||
position_changed = np.concatenate(
|
||||
[[False], expected_positions[1:] != expected_positions[:-1]]
|
||||
)
|
||||
|
||||
expected_equity = np.empty_like(close)
|
||||
expected_equity[0] = 1.0
|
||||
for i in range(1, len(close)):
|
||||
expected_equity[i] = expected_equity[i - 1] * (
|
||||
1.0 + expected_strategy_returns[i]
|
||||
)
|
||||
if position_changed[i]:
|
||||
expected_equity[i] -= commission
|
||||
|
||||
np.testing.assert_allclose(result.positions, expected_positions)
|
||||
np.testing.assert_allclose(result.strategy_returns, expected_strategy_returns)
|
||||
np.testing.assert_allclose(result.equity, expected_equity)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin / Registry
|
||||
@@ -509,7 +543,13 @@ class TestChoppinessIndex:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from ferro_ta import EMA, RSI, SMA
|
||||
from ferro_ta.data.batch import batch_apply, batch_ema, batch_rsi, batch_sma
|
||||
from ferro_ta.data.batch import (
|
||||
batch_apply,
|
||||
batch_atr,
|
||||
batch_ema,
|
||||
batch_rsi,
|
||||
batch_sma,
|
||||
)
|
||||
|
||||
|
||||
class TestBatchSMA:
|
||||
@@ -587,6 +627,15 @@ class TestBatchApply:
|
||||
batch_apply(np.zeros((5, 5, 5)), SMA, timeperiod=3)
|
||||
|
||||
|
||||
class TestBatchShapeValidation:
|
||||
def test_batch_atr_shape_mismatch_raises(self):
|
||||
high = np.ones((5, 2), dtype=np.float64)
|
||||
low = np.ones((4, 2), dtype=np.float64)
|
||||
close = np.ones((5, 2), dtype=np.float64)
|
||||
with pytest.raises(ValueError, match="shape"):
|
||||
batch_atr(high, low, close, timeperiod=3)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Release playbook and version consistency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user