release: cut v1.0.0
Prepare the first public 1.0.0 release and finish the remaining CI hardening work. Highlights: - align Python, Rust, WASM, Conda, API, MCP, and docs version metadata to 1.0.0 - promote package metadata to Production/Stable and update stability/versioning docs for the stable series - move the accumulated Unreleased notes into a dated 1.0.0 changelog section and keep a fresh top-level Unreleased block - strengthen the changelog checker so it validates a single top-level Unreleased section - fix the CI/package support mismatch by declaring Python >=3.10 consistently and gating pandas-ta extras to Python 3.12+ - restore Sphinx autodoc compatibility for documented ferro_ta.<module> imports by registering module aliases - make the TA-Lib benchmark guardrail less flaky by checking median and tail-percentile speedups instead of failing on a single mild outlier - switch PyPI publishing to OIDC-only trusted publishing and wire the changelog check into the required CI gate - apply the Ruff-driven cleanup across the Python and test tree and refresh uv/cargo lockfiles Validated locally: - python3 scripts/check_changelog.py - uv run --with ruff ruff check python tests - uv run --with ruff ruff format --check python tests - uv lock --check - sphinx-build -b html docs docs/_build -W --keep-going - build/install the ferro_ta 1.0.0 wheel successfully
This commit is contained in:
@@ -197,9 +197,9 @@ class TestStreamingATR:
|
||||
|
||||
# Streaming
|
||||
streamer = StreamingATR(period=period)
|
||||
stream_out = np.array([
|
||||
streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)
|
||||
])
|
||||
stream_out = np.array(
|
||||
[streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)]
|
||||
)
|
||||
|
||||
# Compare only the overlap region where both arrays are valid
|
||||
mask = np.isfinite(batch_out) & np.isfinite(stream_out)
|
||||
@@ -207,9 +207,9 @@ class TestStreamingATR:
|
||||
"""ATR values should be non-negative."""
|
||||
period = 14
|
||||
streamer = StreamingATR(period=period)
|
||||
stream_out = np.array([
|
||||
streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)
|
||||
])
|
||||
stream_out = np.array(
|
||||
[streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)]
|
||||
)
|
||||
|
||||
# Filter out NaN values
|
||||
valid = stream_out[~np.isnan(stream_out)]
|
||||
@@ -222,15 +222,21 @@ class TestStreamingATR:
|
||||
streamer = StreamingATR(period=period)
|
||||
|
||||
# First pass
|
||||
first_pass = np.array([
|
||||
streamer.update(h, l, c) for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50])
|
||||
])
|
||||
first_pass = np.array(
|
||||
[
|
||||
streamer.update(h, l, c)
|
||||
for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50])
|
||||
]
|
||||
)
|
||||
|
||||
# Reset and second pass
|
||||
streamer.reset()
|
||||
second_pass = np.array([
|
||||
streamer.update(h, l, c) for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50])
|
||||
])
|
||||
second_pass = np.array(
|
||||
[
|
||||
streamer.update(h, l, c)
|
||||
for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50])
|
||||
]
|
||||
)
|
||||
|
||||
assert np.allclose(first_pass, second_pass, equal_nan=True, atol=1e-12)
|
||||
|
||||
@@ -253,7 +259,9 @@ class TestStreamingBBands:
|
||||
verify proximity with atol=0.2 and confirm internal consistency separately.
|
||||
"""
|
||||
# Batch
|
||||
batch_upper, batch_middle, batch_lower = ferro_ta.BBANDS(CLOSE, timeperiod=period)
|
||||
batch_upper, batch_middle, batch_lower = ferro_ta.BBANDS(
|
||||
CLOSE, timeperiod=period
|
||||
)
|
||||
|
||||
# Streaming
|
||||
streamer = StreamingBBands(period=period, nbdevup=2.0, nbdevdn=2.0)
|
||||
@@ -265,8 +273,9 @@ class TestStreamingBBands:
|
||||
# Compare only overlapping valid region
|
||||
mask = np.isfinite(batch_middle)
|
||||
# Middle band (SMA) must match exactly
|
||||
assert np.allclose(stream_middle[mask], batch_middle[mask], atol=1e-10), \
|
||||
assert np.allclose(stream_middle[mask], batch_middle[mask], atol=1e-10), (
|
||||
"BBands middle (SMA) must match batch exactly"
|
||||
)
|
||||
# Upper/lower: streaming uses sample std; batch uses population std — use atol=0.2
|
||||
assert np.allclose(stream_upper[mask], batch_upper[mask], atol=0.2)
|
||||
assert np.allclose(stream_lower[mask], batch_lower[mask], atol=0.2)
|
||||
@@ -285,7 +294,9 @@ class TestStreamingBBands:
|
||||
|
||||
# Compare all three bands
|
||||
for i in range(len(first_pass)):
|
||||
assert np.allclose(first_pass[i], second_pass[i], equal_nan=True, atol=1e-14)
|
||||
assert np.allclose(
|
||||
first_pass[i], second_pass[i], equal_nan=True, atol=1e-14
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -341,7 +352,9 @@ class TestStreamingMACD:
|
||||
|
||||
# Compare all three outputs
|
||||
for i in range(len(first_pass)):
|
||||
assert np.allclose(first_pass[i], second_pass[i], equal_nan=True, atol=1e-14)
|
||||
assert np.allclose(
|
||||
first_pass[i], second_pass[i], equal_nan=True, atol=1e-14
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -356,19 +369,12 @@ class TestStreamingStoch:
|
||||
"""Streaming Stochastic should match batch Stochastic."""
|
||||
# Batch
|
||||
batch_slowk, batch_slowd = ferro_ta.STOCH(
|
||||
HIGH, LOW, CLOSE,
|
||||
fastk_period=5, slowk_period=3,
|
||||
slowd_period=3
|
||||
HIGH, LOW, CLOSE, fastk_period=5, slowk_period=3, slowd_period=3
|
||||
)
|
||||
|
||||
# Streaming
|
||||
streamer = StreamingStoch(
|
||||
fastk_period=5, slowk_period=3,
|
||||
slowd_period=3
|
||||
)
|
||||
stream_results = [
|
||||
streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)
|
||||
]
|
||||
streamer = StreamingStoch(fastk_period=5, slowk_period=3, slowd_period=3)
|
||||
stream_results = [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)]
|
||||
stream_slowk = np.array([r[0] for r in stream_results])
|
||||
stream_slowd = np.array([r[1] for r in stream_results])
|
||||
|
||||
@@ -380,13 +386,8 @@ class TestStreamingStoch:
|
||||
|
||||
def test_stoch_range_zero_to_hundred(self):
|
||||
"""Stochastic values should be in range [0, 100]."""
|
||||
streamer = StreamingStoch(
|
||||
fastk_period=5, slowk_period=3,
|
||||
slowd_period=3
|
||||
)
|
||||
stream_results = [
|
||||
streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)
|
||||
]
|
||||
streamer = StreamingStoch(fastk_period=5, slowk_period=3, slowd_period=3)
|
||||
stream_results = [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)]
|
||||
stream_slowk = np.array([r[0] for r in stream_results])
|
||||
stream_slowd = np.array([r[1] for r in stream_results])
|
||||
|
||||
@@ -401,10 +402,7 @@ class TestStreamingStoch:
|
||||
|
||||
def test_reset_gives_same_result(self):
|
||||
"""Reset and re-feed should give identical output."""
|
||||
streamer = StreamingStoch(
|
||||
fastk_period=5, slowk_period=3,
|
||||
slowd_period=3
|
||||
)
|
||||
streamer = StreamingStoch(fastk_period=5, slowk_period=3, slowd_period=3)
|
||||
|
||||
# First pass
|
||||
first_pass = [
|
||||
@@ -419,7 +417,9 @@ class TestStreamingStoch:
|
||||
|
||||
# Compare
|
||||
for i in range(len(first_pass)):
|
||||
assert np.allclose(first_pass[i], second_pass[i], equal_nan=True, atol=1e-14)
|
||||
assert np.allclose(
|
||||
first_pass[i], second_pass[i], equal_nan=True, atol=1e-14
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -437,9 +437,12 @@ class TestStreamingVWAP:
|
||||
|
||||
# Streaming (cumulative)
|
||||
streamer = StreamingVWAP()
|
||||
stream_out = np.array([
|
||||
streamer.update(h, l, c, v) for h, l, c, v in zip(HIGH, LOW, CLOSE, VOLUME)
|
||||
])
|
||||
stream_out = np.array(
|
||||
[
|
||||
streamer.update(h, l, c, v)
|
||||
for h, l, c, v in zip(HIGH, LOW, CLOSE, VOLUME)
|
||||
]
|
||||
)
|
||||
|
||||
# Compare
|
||||
assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-10)
|
||||
@@ -451,9 +454,12 @@ class TestStreamingVWAP:
|
||||
|
||||
# Streaming (cumulative)
|
||||
streamer = StreamingVWAP()
|
||||
stream_out = np.array([
|
||||
streamer.update(h, l, c, v) for h, l, c, v in zip(HIGH, LOW, CLOSE, VOLUME)
|
||||
])
|
||||
stream_out = np.array(
|
||||
[
|
||||
streamer.update(h, l, c, v)
|
||||
for h, l, c, v in zip(HIGH, LOW, CLOSE, VOLUME)
|
||||
]
|
||||
)
|
||||
|
||||
# Compare
|
||||
assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-10)
|
||||
@@ -463,17 +469,21 @@ class TestStreamingVWAP:
|
||||
streamer = StreamingVWAP()
|
||||
|
||||
# First pass
|
||||
first_pass = np.array([
|
||||
streamer.update(h, l, c, v)
|
||||
for h, l, c, v in zip(HIGH[:50], LOW[:50], CLOSE[:50], VOLUME[:50])
|
||||
])
|
||||
first_pass = np.array(
|
||||
[
|
||||
streamer.update(h, l, c, v)
|
||||
for h, l, c, v in zip(HIGH[:50], LOW[:50], CLOSE[:50], VOLUME[:50])
|
||||
]
|
||||
)
|
||||
|
||||
# Reset and second pass
|
||||
streamer.reset()
|
||||
second_pass = np.array([
|
||||
streamer.update(h, l, c, v)
|
||||
for h, l, c, v in zip(HIGH[:50], LOW[:50], CLOSE[:50], VOLUME[:50])
|
||||
])
|
||||
second_pass = np.array(
|
||||
[
|
||||
streamer.update(h, l, c, v)
|
||||
for h, l, c, v in zip(HIGH[:50], LOW[:50], CLOSE[:50], VOLUME[:50])
|
||||
]
|
||||
)
|
||||
|
||||
assert np.allclose(first_pass, second_pass, equal_nan=True, atol=1e-14)
|
||||
|
||||
@@ -498,9 +508,7 @@ class TestStreamingSupertrend:
|
||||
|
||||
# Streaming
|
||||
streamer = StreamingSupertrend(period=period, multiplier=multiplier)
|
||||
stream_results = [
|
||||
streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)
|
||||
]
|
||||
stream_results = [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)]
|
||||
stream_line = np.array([r[0] for r in stream_results])
|
||||
stream_dir = np.array([r[1] for r in stream_results])
|
||||
|
||||
@@ -527,4 +535,6 @@ class TestStreamingSupertrend:
|
||||
|
||||
# Compare
|
||||
for i in range(len(first_pass)):
|
||||
assert np.allclose(first_pass[i], second_pass[i], equal_nan=True, atol=1e-14)
|
||||
assert np.allclose(
|
||||
first_pass[i], second_pass[i], equal_nan=True, atol=1e-14
|
||||
)
|
||||
|
||||
@@ -181,7 +181,9 @@ class TestBBANDSVsPandasTA:
|
||||
pt_upper = pt_bbands[upper_col].to_numpy()
|
||||
|
||||
# Middle band (SMA) must be identical
|
||||
assert _allclose(ft_middle, pt_middle, atol=1e-8), "BBands middle (SMA) must match"
|
||||
assert _allclose(ft_middle, pt_middle, atol=1e-8), (
|
||||
"BBands middle (SMA) must match"
|
||||
)
|
||||
# Upper/lower: differ due to ddof=0 vs ddof=1
|
||||
assert _allclose(ft_upper, pt_upper, atol=0.1)
|
||||
assert _allclose(ft_lower, pt_lower, atol=0.1)
|
||||
@@ -259,18 +261,15 @@ class TestSTOCHVsPandasTA:
|
||||
close = ohlcv_500["close"]
|
||||
|
||||
ft_slowk, ft_slowd = ferro_ta.STOCH(
|
||||
high, low, close,
|
||||
fastk_period=14, slowk_period=3,
|
||||
slowd_period=3
|
||||
high, low, close, fastk_period=14, slowk_period=3, slowd_period=3
|
||||
)
|
||||
|
||||
# pandas-ta returns DataFrame
|
||||
pt_stoch = pandas_ta.stoch(
|
||||
pd.Series(high), pd.Series(low), pd.Series(close),
|
||||
k=14, d=3, smooth_k=3
|
||||
pd.Series(high), pd.Series(low), pd.Series(close), k=14, d=3, smooth_k=3
|
||||
)
|
||||
pt_slowk = pt_stoch[f"STOCHk_14_3_3"].to_numpy()
|
||||
pt_slowd = pt_stoch[f"STOCHd_14_3_3"].to_numpy()
|
||||
pt_slowk = pt_stoch["STOCHk_14_3_3"].to_numpy()
|
||||
pt_slowd = pt_stoch["STOCHd_14_3_3"].to_numpy()
|
||||
|
||||
assert _allclose(ft_slowk, pt_slowk, atol=1e-2, tail_fraction=0.3)
|
||||
assert _allclose(ft_slowd, pt_slowd, atol=1e-2, tail_fraction=0.3)
|
||||
@@ -291,7 +290,9 @@ class TestCCIVsPandasTA:
|
||||
# Compute CCI manually: (TP - SMA(TP)) / (0.015 * MeanAbsDev(TP))
|
||||
tp = (pd.Series(high) + pd.Series(low) + pd.Series(close)) / 3.0
|
||||
mean_tp = tp.rolling(period).mean()
|
||||
mad_tp = tp.rolling(period).apply(lambda x: np.mean(np.abs(x - x.mean())), raw=True)
|
||||
mad_tp = tp.rolling(period).apply(
|
||||
lambda x: np.mean(np.abs(x - x.mean())), raw=True
|
||||
)
|
||||
pt = ((tp - mean_tp) / (0.015 * mad_tp)).to_numpy()
|
||||
|
||||
assert _allclose(ft, pt, atol=1e-8)
|
||||
@@ -469,8 +470,8 @@ class TestVWAPVsPandasTA:
|
||||
n = len(tp)
|
||||
ref = np.full(n, np.nan)
|
||||
for i in range(period - 1, n):
|
||||
w = tp[i - period + 1: i + 1]
|
||||
v = vol[i - period + 1: i + 1]
|
||||
w = tp[i - period + 1 : i + 1]
|
||||
v = vol[i - period + 1 : i + 1]
|
||||
ref[i] = np.dot(w, v) / v.sum()
|
||||
|
||||
assert _allclose(ft, ref, atol=1e-8)
|
||||
@@ -522,7 +523,13 @@ class TestICHIMOKUVsPandasTA:
|
||||
close = ohlcv_500["close"]
|
||||
|
||||
ft_tenkan, ft_kijun, ft_senkou_a, ft_senkou_b, ft_chikou = ferro_ta.ICHIMOKU(
|
||||
high, low, close, tenkan_period=9, kijun_period=26, senkou_b_period=52, displacement=26
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
tenkan_period=9,
|
||||
kijun_period=26,
|
||||
senkou_b_period=52,
|
||||
displacement=26,
|
||||
)
|
||||
|
||||
df = pd.DataFrame({"high": high, "low": low, "close": close})
|
||||
@@ -547,12 +554,19 @@ class TestKELTNER_CHANNELSVsPandasTA:
|
||||
multiplier = 2.0
|
||||
|
||||
ft_upper, ft_middle, ft_lower = ferro_ta.KELTNER_CHANNELS(
|
||||
high, low, close, timeperiod=period, atr_period=atr_period, multiplier=multiplier
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
timeperiod=period,
|
||||
atr_period=atr_period,
|
||||
multiplier=multiplier,
|
||||
)
|
||||
|
||||
# Compute manually using pandas_ta EMA and ATR to match ferro_ta's exact formula
|
||||
pt_ema = pandas_ta.ema(pd.Series(close), length=period).to_numpy()
|
||||
pt_atr = pandas_ta.atr(pd.Series(high), pd.Series(low), pd.Series(close), length=atr_period).to_numpy()
|
||||
pt_atr = pandas_ta.atr(
|
||||
pd.Series(high), pd.Series(low), pd.Series(close), length=atr_period
|
||||
).to_numpy()
|
||||
pt_upper = pt_ema + multiplier * pt_atr
|
||||
pt_middle = pt_ema
|
||||
pt_lower = pt_ema - multiplier * pt_atr
|
||||
@@ -643,7 +657,9 @@ class TestCHANDELIER_EXITVsPandasTA:
|
||||
)
|
||||
|
||||
# Compute manually: long = rolling_max(H, n) - mult*ATR; short = rolling_min(L, n) + mult*ATR
|
||||
pt_atr = pandas_ta.atr(pd.Series(high), pd.Series(low), pd.Series(close), length=period).to_numpy()
|
||||
pt_atr = pandas_ta.atr(
|
||||
pd.Series(high), pd.Series(low), pd.Series(close), length=period
|
||||
).to_numpy()
|
||||
rolling_high = pd.Series(high).rolling(period).max().to_numpy()
|
||||
rolling_low = pd.Series(low).rolling(period).min().to_numpy()
|
||||
pt_long = rolling_high - multiplier * pt_atr
|
||||
|
||||
@@ -190,9 +190,7 @@ class TestSTOCHVsTA:
|
||||
close = ohlcv_500["close"]
|
||||
|
||||
ft_slowk, ft_slowd = ferro_ta.STOCH(
|
||||
high, low, close,
|
||||
fastk_period=14, slowk_period=3,
|
||||
slowd_period=3
|
||||
high, low, close, fastk_period=14, slowk_period=3, slowd_period=3
|
||||
)
|
||||
|
||||
# Values in valid region must be within [0, 100]
|
||||
@@ -200,16 +198,21 @@ class TestSTOCHVsTA:
|
||||
valid_d = ft_slowd[np.isfinite(ft_slowd)]
|
||||
assert len(valid_k) > 0, "STOCH slowk should have valid values"
|
||||
assert len(valid_d) > 0, "STOCH slowd should have valid values"
|
||||
assert np.all(valid_k >= 0.0) and np.all(valid_k <= 100.0), \
|
||||
assert np.all(valid_k >= 0.0) and np.all(valid_k <= 100.0), (
|
||||
"STOCH slowk must be in [0, 100]"
|
||||
assert np.all(valid_d >= 0.0) and np.all(valid_d <= 100.0), \
|
||||
)
|
||||
assert np.all(valid_d >= 0.0) and np.all(valid_d <= 100.0), (
|
||||
"STOCH slowd must be in [0, 100]"
|
||||
)
|
||||
|
||||
# Warm-up: TA-Lib STOCH NaN count = fastk_period + slowk_period - 1
|
||||
expected_nan = 14 + 3 + 1 - 1 # = fastk_period + slowk_period (TA-Lib convention)
|
||||
expected_nan = (
|
||||
14 + 3 + 1 - 1
|
||||
) # = fastk_period + slowk_period (TA-Lib convention)
|
||||
actual_nan_k = int(np.sum(np.isnan(ft_slowk)))
|
||||
assert actual_nan_k == expected_nan, \
|
||||
assert actual_nan_k == expected_nan, (
|
||||
f"STOCH slowk NaN warmup: expected {expected_nan}, got {actual_nan_k}"
|
||||
)
|
||||
|
||||
|
||||
class TestWILLRVsTA:
|
||||
|
||||
@@ -71,11 +71,11 @@ SIGN_AGREEMENT_THRESHOLD = 0.8
|
||||
# use lower thresholds with a documented reason.
|
||||
CDL_AGREEMENT_THRESHOLDS: dict[str, float] = {
|
||||
# Body/shadow ratio thresholds differ between ferro_ta and TA-Lib
|
||||
"CDLHIGHWAVE": 0.65, # Shadow length threshold differs; 69% observed
|
||||
"CDLHIGHWAVE": 0.65, # Shadow length threshold differs; 69% observed
|
||||
"CDLLONGLEGGEDDOJI": 0.70, # Long-leg threshold differs; 75% observed
|
||||
"CDLSHORTLINE": 0.20, # Body-size cutoff definition completely differs; 25% observed
|
||||
"CDLSPINNINGTOP": 0.75, # Body ratio threshold differs; 78% observed
|
||||
"CDLDOJI": 0.85, # Shadow ratio precision differs; 86% observed
|
||||
"CDLSHORTLINE": 0.20, # Body-size cutoff definition completely differs; 25% observed
|
||||
"CDLSPINNINGTOP": 0.75, # Body ratio threshold differs; 78% observed
|
||||
"CDLDOJI": 0.85, # Shadow ratio precision differs; 86% observed
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +150,9 @@ class TestEMA:
|
||||
ta = talib.EMA(CLOSE, timeperiod=5)
|
||||
# With 500 bars, compare last 30% with tighter tolerance
|
||||
tail_start = int(N * 0.7)
|
||||
assert np.allclose(ft[tail_start:], ta[tail_start:], atol=1e-5) # Tightened from 1e-3
|
||||
assert np.allclose(
|
||||
ft[tail_start:], ta[tail_start:], atol=1e-5
|
||||
) # Tightened from 1e-3
|
||||
|
||||
def test_values_finite_and_reasonable(self):
|
||||
ft = ferro_ta.EMA(CLOSE, timeperiod=5)
|
||||
@@ -266,7 +268,9 @@ class TestT3:
|
||||
ta = talib.T3(CLOSE, timeperiod=5)
|
||||
# With 500 bars, use last 30% with tighter tolerance
|
||||
tail_start = int(N * 0.7)
|
||||
assert np.allclose(ft[tail_start:], ta[tail_start:], atol=1e-3) # Tightened from 5e-2
|
||||
assert np.allclose(
|
||||
ft[tail_start:], ta[tail_start:], atol=1e-3
|
||||
) # Tightened from 5e-2
|
||||
|
||||
|
||||
class TestBBANDS:
|
||||
@@ -781,7 +785,9 @@ class TestSTOCHRSI:
|
||||
assert abs(_nan_count(ft_k) - _nan_count(ta_k)) <= 2
|
||||
|
||||
def test_range_0_to_100(self):
|
||||
ft_k, _ = ferro_ta.STOCHRSI(CLOSE, timeperiod=14, fastk_period=5, fastd_period=3)
|
||||
ft_k, _ = ferro_ta.STOCHRSI(
|
||||
CLOSE, timeperiod=14, fastk_period=5, fastd_period=3
|
||||
)
|
||||
finite = ft_k[~np.isnan(ft_k)]
|
||||
# Allow small numerical tolerance for float boundaries
|
||||
assert all(-1e-9 <= v <= 100.0 + 1e-9 for v in finite)
|
||||
@@ -834,6 +840,7 @@ class TestPPO:
|
||||
mask = _valid_mask(ppo, ta)
|
||||
corr = np.corrcoef(ppo[mask], ta[mask])[0, 1]
|
||||
assert corr > 0.85
|
||||
|
||||
"""CMO — same NaN count and shape; values may differ slightly.
|
||||
|
||||
Both libraries compute the Chande Momentum Oscillator as
|
||||
@@ -2034,9 +2041,7 @@ class TestHTTrendMode:
|
||||
mask = _valid_mask(ft, ta)
|
||||
if mask.sum() >= 5:
|
||||
agree = np.mean(ft[mask] == ta[mask])
|
||||
assert agree >= 0.50, (
|
||||
f"HT_TRENDMODE agreement {agree:.2f} < 0.50"
|
||||
)
|
||||
assert agree >= 0.50, f"HT_TRENDMODE agreement {agree:.2f} < 0.50"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2046,24 +2051,66 @@ class TestHTTrendMode:
|
||||
|
||||
# List of all candlestick patterns to test
|
||||
ALL_CDL_PATTERNS = [
|
||||
"CDL2CROWS", "CDL3BLACKCROWS", "CDL3INSIDE", "CDL3LINESTRIKE",
|
||||
"CDL3OUTSIDE", "CDL3STARSINSOUTH", "CDL3WHITESOLDIERS",
|
||||
"CDLABANDONEDBABY", "CDLADVANCEBLOCK", "CDLBELTHOLD", "CDLBREAKAWAY",
|
||||
"CDLCLOSINGMARUBOZU", "CDLCONCEALBABYSWALL", "CDLCOUNTERATTACK",
|
||||
"CDLDARKCLOUDCOVER", "CDLDOJI", "CDLDOJISTAR", "CDLDRAGONFLYDOJI",
|
||||
"CDLENGULFING", "CDLEVENINGDOJISTAR", "CDLEVENINGSTAR",
|
||||
"CDLGAPSIDESIDEWHITE", "CDLGRAVESTONEDOJI", "CDLHAMMER",
|
||||
"CDLHANGINGMAN", "CDLHARAMI", "CDLHARAMICROSS", "CDLHIGHWAVE",
|
||||
"CDLHIKKAKE", "CDLHIKKAKEMOD", "CDLHOMINGPIGEON",
|
||||
"CDLIDENTICAL3CROWS", "CDLINNECK", "CDLINVERTEDHAMMER",
|
||||
"CDLKICKING", "CDLKICKINGBYLENGTH", "CDLLADDERBOTTOM",
|
||||
"CDLLONGLEGGEDDOJI", "CDLLONGLINE", "CDLMARUBOZU",
|
||||
"CDLMATCHINGLOW", "CDLMATHOLD", "CDLMORNINGDOJISTAR",
|
||||
"CDLMORNINGSTAR", "CDLONNECK", "CDLPIERCING", "CDLRICKSHAWMAN",
|
||||
"CDLRISEFALL3METHODS", "CDLSEPARATINGLINES", "CDLSHOOTINGSTAR",
|
||||
"CDLSHORTLINE", "CDLSPINNINGTOP", "CDLSTALLEDPATTERN",
|
||||
"CDLSTICKSANDWICH", "CDLTAKURI", "CDLTASUKIGAP", "CDLTHRUSTING",
|
||||
"CDLTRISTAR", "CDLUNIQUE3RIVER", "CDLUPSIDEGAP2CROWS",
|
||||
"CDL2CROWS",
|
||||
"CDL3BLACKCROWS",
|
||||
"CDL3INSIDE",
|
||||
"CDL3LINESTRIKE",
|
||||
"CDL3OUTSIDE",
|
||||
"CDL3STARSINSOUTH",
|
||||
"CDL3WHITESOLDIERS",
|
||||
"CDLABANDONEDBABY",
|
||||
"CDLADVANCEBLOCK",
|
||||
"CDLBELTHOLD",
|
||||
"CDLBREAKAWAY",
|
||||
"CDLCLOSINGMARUBOZU",
|
||||
"CDLCONCEALBABYSWALL",
|
||||
"CDLCOUNTERATTACK",
|
||||
"CDLDARKCLOUDCOVER",
|
||||
"CDLDOJI",
|
||||
"CDLDOJISTAR",
|
||||
"CDLDRAGONFLYDOJI",
|
||||
"CDLENGULFING",
|
||||
"CDLEVENINGDOJISTAR",
|
||||
"CDLEVENINGSTAR",
|
||||
"CDLGAPSIDESIDEWHITE",
|
||||
"CDLGRAVESTONEDOJI",
|
||||
"CDLHAMMER",
|
||||
"CDLHANGINGMAN",
|
||||
"CDLHARAMI",
|
||||
"CDLHARAMICROSS",
|
||||
"CDLHIGHWAVE",
|
||||
"CDLHIKKAKE",
|
||||
"CDLHIKKAKEMOD",
|
||||
"CDLHOMINGPIGEON",
|
||||
"CDLIDENTICAL3CROWS",
|
||||
"CDLINNECK",
|
||||
"CDLINVERTEDHAMMER",
|
||||
"CDLKICKING",
|
||||
"CDLKICKINGBYLENGTH",
|
||||
"CDLLADDERBOTTOM",
|
||||
"CDLLONGLEGGEDDOJI",
|
||||
"CDLLONGLINE",
|
||||
"CDLMARUBOZU",
|
||||
"CDLMATCHINGLOW",
|
||||
"CDLMATHOLD",
|
||||
"CDLMORNINGDOJISTAR",
|
||||
"CDLMORNINGSTAR",
|
||||
"CDLONNECK",
|
||||
"CDLPIERCING",
|
||||
"CDLRICKSHAWMAN",
|
||||
"CDLRISEFALL3METHODS",
|
||||
"CDLSEPARATINGLINES",
|
||||
"CDLSHOOTINGSTAR",
|
||||
"CDLSHORTLINE",
|
||||
"CDLSPINNINGTOP",
|
||||
"CDLSTALLEDPATTERN",
|
||||
"CDLSTICKSANDWICH",
|
||||
"CDLTAKURI",
|
||||
"CDLTASUKIGAP",
|
||||
"CDLTHRUSTING",
|
||||
"CDLTRISTAR",
|
||||
"CDLUNIQUE3RIVER",
|
||||
"CDLUPSIDEGAP2CROWS",
|
||||
"CDLXSIDEGAP3METHODS",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"""Unit tests for ferro_ta.indicators.cycle"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta.indicators.cycle import (
|
||||
HT_DCPERIOD, HT_DCPHASE, HT_PHASOR, HT_SINE, HT_TRENDLINE, HT_TRENDMODE,
|
||||
HT_DCPERIOD,
|
||||
HT_DCPHASE,
|
||||
HT_PHASOR,
|
||||
HT_SINE,
|
||||
HT_TRENDLINE,
|
||||
HT_TRENDMODE,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -11,7 +17,7 @@ from ferro_ta.indicators.cycle import (
|
||||
|
||||
N = 200
|
||||
t = np.linspace(0, 10 * np.pi, N)
|
||||
SINE_CLOSE = 100 + 10 * np.sin(t) # clean sine wave
|
||||
SINE_CLOSE = 100 + 10 * np.sin(t) # clean sine wave
|
||||
|
||||
|
||||
def _warmup_end(arr):
|
||||
@@ -24,6 +30,7 @@ def _warmup_end(arr):
|
||||
# HT_DCPERIOD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHT_DCPERIOD:
|
||||
def test_length(self):
|
||||
result = HT_DCPERIOD(SINE_CLOSE)
|
||||
@@ -53,6 +60,7 @@ class TestHT_DCPERIOD:
|
||||
# HT_DCPHASE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHT_DCPHASE:
|
||||
def test_length(self):
|
||||
assert len(HT_DCPHASE(SINE_CLOSE)) == N
|
||||
@@ -72,6 +80,7 @@ class TestHT_DCPHASE:
|
||||
# HT_PHASOR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHT_PHASOR:
|
||||
def test_returns_two_arrays(self):
|
||||
result = HT_PHASOR(SINE_CLOSE)
|
||||
@@ -98,6 +107,7 @@ class TestHT_PHASOR:
|
||||
# HT_SINE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHT_SINE:
|
||||
def test_returns_two_arrays(self):
|
||||
result = HT_SINE(SINE_CLOSE)
|
||||
@@ -130,6 +140,7 @@ class TestHT_SINE:
|
||||
# HT_TRENDLINE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHT_TRENDLINE:
|
||||
def test_length(self):
|
||||
assert len(HT_TRENDLINE(SINE_CLOSE)) == N
|
||||
@@ -157,6 +168,7 @@ class TestHT_TRENDLINE:
|
||||
# HT_TRENDMODE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHT_TRENDMODE:
|
||||
def test_length(self):
|
||||
assert len(HT_TRENDMODE(SINE_CLOSE)) == N
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
"""Unit tests for ferro_ta.indicators.extended"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta.indicators.extended import (
|
||||
VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS,
|
||||
KELTNER_CHANNELS, HULL_MA, CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX,
|
||||
CHANDELIER_EXIT,
|
||||
CHOPPINESS_INDEX,
|
||||
DONCHIAN,
|
||||
HULL_MA,
|
||||
ICHIMOKU,
|
||||
KELTNER_CHANNELS,
|
||||
PIVOT_POINTS,
|
||||
SUPERTREND,
|
||||
VWAP,
|
||||
VWMA,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -23,6 +32,7 @@ _VOL = RNG.uniform(1000, 5000, N)
|
||||
# VWAP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVWAP:
|
||||
def test_length(self):
|
||||
result = VWAP(_H, _L, _C, _VOL)
|
||||
@@ -46,6 +56,7 @@ class TestVWAP:
|
||||
# SUPERTREND
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSUPERTREND:
|
||||
def test_returns_two_arrays(self):
|
||||
result = SUPERTREND(_H, _L, _C)
|
||||
@@ -69,6 +80,7 @@ class TestSUPERTREND:
|
||||
# ICHIMOKU
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestICHIMOKU:
|
||||
def test_returns_five_arrays(self):
|
||||
result = ICHIMOKU(_H, _L, _C)
|
||||
@@ -80,7 +92,9 @@ class TestICHIMOKU:
|
||||
assert len(arr) == N
|
||||
|
||||
def test_tenkan_warmup(self):
|
||||
tenkan, kijun, senkou_a, senkou_b, chikou = ICHIMOKU(_H, _L, _C, tenkan_period=9)
|
||||
tenkan, kijun, senkou_a, senkou_b, chikou = ICHIMOKU(
|
||||
_H, _L, _C, tenkan_period=9
|
||||
)
|
||||
assert np.all(np.isnan(tenkan[:8]))
|
||||
|
||||
def test_finite_after_warmup(self):
|
||||
@@ -94,6 +108,7 @@ class TestICHIMOKU:
|
||||
# DONCHIAN
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDONCHIAN:
|
||||
def test_returns_three_arrays(self):
|
||||
result = DONCHIAN(_H, _L)
|
||||
@@ -126,6 +141,7 @@ class TestDONCHIAN:
|
||||
# PIVOT_POINTS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPIVOT_POINTS:
|
||||
def test_returns_five_arrays(self):
|
||||
result = PIVOT_POINTS(_H, _L, _C)
|
||||
@@ -138,7 +154,7 @@ class TestPIVOT_POINTS:
|
||||
|
||||
def test_classic_pivot_formula(self):
|
||||
# PP = (H + L + C) / 3
|
||||
pp, r1, s1, r2, s2 = PIVOT_POINTS(_H, _L, _C, method='classic')
|
||||
pp, r1, s1, r2, s2 = PIVOT_POINTS(_H, _L, _C, method="classic")
|
||||
valid = ~np.isnan(pp)
|
||||
expected_pp = (_H[:-1] + _L[:-1] + _C[:-1]) / 3.0
|
||||
np.testing.assert_allclose(pp[valid], expected_pp[valid[1:]], rtol=1e-6)
|
||||
@@ -152,6 +168,7 @@ class TestPIVOT_POINTS:
|
||||
# KELTNER_CHANNELS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKELTNER_CHANNELS:
|
||||
def test_returns_three_arrays(self):
|
||||
result = KELTNER_CHANNELS(_H, _L, _C)
|
||||
@@ -175,6 +192,7 @@ class TestKELTNER_CHANNELS:
|
||||
# HULL_MA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHULL_MA:
|
||||
def test_length(self):
|
||||
assert len(HULL_MA(_C, timeperiod=16)) == N
|
||||
@@ -199,6 +217,7 @@ class TestHULL_MA:
|
||||
# CHANDELIER_EXIT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCHANDELIER_EXIT:
|
||||
def test_returns_two_arrays(self):
|
||||
result = CHANDELIER_EXIT(_H, _L, _C)
|
||||
@@ -223,6 +242,7 @@ class TestCHANDELIER_EXIT:
|
||||
# VWMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVWMA:
|
||||
def test_length(self):
|
||||
assert len(VWMA(_C, _VOL, timeperiod=20)) == N
|
||||
@@ -241,6 +261,7 @@ class TestVWMA:
|
||||
vol = np.ones(N) * 1000.0
|
||||
vwma = VWMA(_C, vol, timeperiod=20)
|
||||
from ferro_ta.indicators.overlap import SMA
|
||||
|
||||
sma = SMA(_C, timeperiod=20)
|
||||
valid = ~np.isnan(vwma) & ~np.isnan(sma)
|
||||
np.testing.assert_allclose(vwma[valid], sma[valid], rtol=1e-8)
|
||||
@@ -250,6 +271,7 @@ class TestVWMA:
|
||||
# CHOPPINESS_INDEX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCHOPPINESS_INDEX:
|
||||
def test_length(self):
|
||||
assert len(CHOPPINESS_INDEX(_H, _L, _C, timeperiod=14)) == N
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
"""Unit tests for ferro_ta.indicators.math_ops"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta.indicators.math_ops import (
|
||||
ADD, SUB, MULT, DIV, SUM, MAX, MIN, MAXINDEX, MININDEX,
|
||||
ACOS, ASIN, ATAN, CEIL, COS, COSH, EXP, FLOOR,
|
||||
LN, LOG10, SIN, SINH, SQRT, TAN, TANH,
|
||||
ACOS,
|
||||
ADD,
|
||||
ASIN,
|
||||
ATAN,
|
||||
CEIL,
|
||||
COS,
|
||||
COSH,
|
||||
DIV,
|
||||
EXP,
|
||||
FLOOR,
|
||||
LN,
|
||||
LOG10,
|
||||
MAX,
|
||||
MAXINDEX,
|
||||
MIN,
|
||||
MININDEX,
|
||||
MULT,
|
||||
SIN,
|
||||
SINH,
|
||||
SQRT,
|
||||
SUB,
|
||||
SUM,
|
||||
TAN,
|
||||
TANH,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -14,17 +36,18 @@ from ferro_ta.indicators.math_ops import (
|
||||
A3 = np.array([1.0, 2.0, 3.0])
|
||||
B3 = np.array([4.0, 5.0, 6.0])
|
||||
TRIG = np.array([0.0, np.pi / 6, np.pi / 4, np.pi / 3, np.pi / 2])
|
||||
UNIT = np.array([0.0, 0.25, 0.5, 0.75, 1.0]) # values in [0,1] for ASIN/ACOS
|
||||
UNIT = np.array([0.0, 0.25, 0.5, 0.75, 1.0]) # values in [0,1] for ASIN/ACOS
|
||||
|
||||
RNG = np.random.default_rng(17)
|
||||
N = 100
|
||||
_ARR = 1.0 + RNG.random(N) * 9.0 # positive values in (1, 10]
|
||||
_ARR = 1.0 + RNG.random(N) * 9.0 # positive values in (1, 10]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestADD:
|
||||
def test_known_values(self):
|
||||
result = ADD(A3, B3)
|
||||
@@ -41,6 +64,7 @@ class TestADD:
|
||||
# SUB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSUB:
|
||||
def test_known_values(self):
|
||||
result = SUB(B3, A3)
|
||||
@@ -54,6 +78,7 @@ class TestSUB:
|
||||
# MULT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMULT:
|
||||
def test_known_values(self):
|
||||
result = MULT(A3, B3)
|
||||
@@ -70,6 +95,7 @@ class TestMULT:
|
||||
# DIV
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDIV:
|
||||
def test_known_values(self):
|
||||
result = DIV(B3, A3)
|
||||
@@ -86,6 +112,7 @@ class TestDIV:
|
||||
# SUM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSUM:
|
||||
def test_known_values(self):
|
||||
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
@@ -106,6 +133,7 @@ class TestSUM:
|
||||
# MAX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMAX:
|
||||
def test_known_values(self):
|
||||
arr = np.array([1.0, 3.0, 2.0, 5.0, 4.0])
|
||||
@@ -127,6 +155,7 @@ class TestMAX:
|
||||
# MIN
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMIN:
|
||||
def test_known_values(self):
|
||||
arr = np.array([5.0, 3.0, 4.0, 1.0, 2.0])
|
||||
@@ -143,6 +172,7 @@ class TestMIN:
|
||||
# MAXINDEX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMAXINDEX:
|
||||
def test_known_values(self):
|
||||
arr = np.array([1.0, 5.0, 3.0, 2.0, 4.0])
|
||||
@@ -162,6 +192,7 @@ class TestMAXINDEX:
|
||||
# MININDEX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMININDEX:
|
||||
def test_known_values(self):
|
||||
arr = np.array([5.0, 1.0, 3.0, 2.0, 4.0])
|
||||
@@ -181,6 +212,7 @@ class TestMININDEX:
|
||||
# Trig functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSIN:
|
||||
def test_known_values(self):
|
||||
angles = np.array([0.0, np.pi / 2, np.pi])
|
||||
@@ -236,6 +268,7 @@ class TestTANH:
|
||||
# Rounding/exponential
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCEIL:
|
||||
def test_known_values(self):
|
||||
arr = np.array([1.1, 2.5, 3.9, -0.5])
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
"""Unit tests for ferro_ta.indicators.momentum"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta.indicators.momentum import (
|
||||
RSI, STOCH, STOCHF, STOCHRSI,
|
||||
ADX, ADXR, CCI, WILLR, AROON, AROONOSC,
|
||||
MFI, MOM, ROC, ROCP, ROCR, ROCR100,
|
||||
CMO, DX, MINUS_DI, MINUS_DM, PLUS_DI, PLUS_DM,
|
||||
PPO, APO, TRIX, ULTOSC, BOP,
|
||||
ADX,
|
||||
ADXR,
|
||||
APO,
|
||||
AROON,
|
||||
AROONOSC,
|
||||
BOP,
|
||||
CCI,
|
||||
CMO,
|
||||
DX,
|
||||
MFI,
|
||||
MINUS_DI,
|
||||
MINUS_DM,
|
||||
MOM,
|
||||
PLUS_DI,
|
||||
PLUS_DM,
|
||||
PPO,
|
||||
ROC,
|
||||
ROCP,
|
||||
ROCR,
|
||||
ROCR100,
|
||||
RSI,
|
||||
STOCH,
|
||||
STOCHF,
|
||||
STOCHRSI,
|
||||
TRIX,
|
||||
ULTOSC,
|
||||
WILLR,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -32,6 +55,7 @@ SMALL5_V = np.array([1000.0, 2000.0, 3000.0, 4000.0, 5000.0])
|
||||
# RSI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRSI:
|
||||
def test_nan_warmup(self):
|
||||
result = RSI(_CLOSE, timeperiod=14)
|
||||
@@ -50,6 +74,7 @@ class TestRSI:
|
||||
# STOCH
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSTOCH:
|
||||
def test_returns_two_arrays(self):
|
||||
result = STOCH(_HIGH, _LOW, _CLOSE)
|
||||
@@ -70,6 +95,7 @@ class TestSTOCH:
|
||||
# STOCHF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSTOCHF:
|
||||
def test_returns_two_arrays(self):
|
||||
result = STOCHF(_HIGH, _LOW, _CLOSE)
|
||||
@@ -99,6 +125,7 @@ class TestSTOCHF:
|
||||
# STOCHRSI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSTOCHRSI:
|
||||
def test_returns_two_arrays(self):
|
||||
result = STOCHRSI(_CLOSE)
|
||||
@@ -119,6 +146,7 @@ class TestSTOCHRSI:
|
||||
# ADX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestADX:
|
||||
def test_nan_warmup(self):
|
||||
result = ADX(_HIGH, _LOW, _CLOSE, timeperiod=14)
|
||||
@@ -137,6 +165,7 @@ class TestADX:
|
||||
# ADXR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestADXR:
|
||||
def test_length(self):
|
||||
assert len(ADXR(_HIGH, _LOW, _CLOSE, 14)) == N
|
||||
@@ -151,6 +180,7 @@ class TestADXR:
|
||||
# CCI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCCI:
|
||||
def test_known_constant_mean_dev(self):
|
||||
# Constant typical price → CCI = 0 after warmup
|
||||
@@ -181,6 +211,7 @@ class TestCCI:
|
||||
# WILLR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWILLR:
|
||||
def test_range(self):
|
||||
result = WILLR(_HIGH, _LOW, _CLOSE, 14)
|
||||
@@ -195,6 +226,7 @@ class TestWILLR:
|
||||
# AROON
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAROON:
|
||||
def test_returns_two_arrays(self):
|
||||
result = AROON(_HIGH, _LOW, 14)
|
||||
@@ -215,6 +247,7 @@ class TestAROON:
|
||||
# AROONOSC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAROONOSC:
|
||||
def test_known_values(self):
|
||||
h = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
|
||||
@@ -242,6 +275,7 @@ class TestAROONOSC:
|
||||
# MFI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMFI:
|
||||
def test_range(self):
|
||||
result = MFI(_HIGH, _LOW, _CLOSE, _VOL, 14)
|
||||
@@ -271,6 +305,7 @@ class TestMFI:
|
||||
# MOM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMOM:
|
||||
def test_known_values(self):
|
||||
result = MOM(SMALL5, timeperiod=2)
|
||||
@@ -286,6 +321,7 @@ class TestMOM:
|
||||
# ROC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestROC:
|
||||
def test_known_values(self):
|
||||
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
|
||||
@@ -301,6 +337,7 @@ class TestROC:
|
||||
# ROCP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestROCP:
|
||||
def test_known_values(self):
|
||||
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
|
||||
@@ -316,6 +353,7 @@ class TestROCP:
|
||||
# ROCR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestROCR:
|
||||
def test_known_values(self):
|
||||
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
|
||||
@@ -336,6 +374,7 @@ class TestROCR:
|
||||
# ROCR100
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestROCR100:
|
||||
def test_known_values(self):
|
||||
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
|
||||
@@ -357,6 +396,7 @@ class TestROCR100:
|
||||
# CMO
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCMO:
|
||||
def test_range(self):
|
||||
result = CMO(_CLOSE, 14)
|
||||
@@ -371,6 +411,7 @@ class TestCMO:
|
||||
# DX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDX:
|
||||
def test_range(self):
|
||||
result = DX(_HIGH, _LOW, _CLOSE, 14)
|
||||
@@ -385,6 +426,7 @@ class TestDX:
|
||||
# MINUS_DI / MINUS_DM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMINUS:
|
||||
def test_minus_di_range(self):
|
||||
result = MINUS_DI(_HIGH, _LOW, _CLOSE, 14)
|
||||
@@ -405,6 +447,7 @@ class TestMINUS:
|
||||
# PLUS_DI / PLUS_DM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPLUS:
|
||||
def test_plus_di_range(self):
|
||||
result = PLUS_DI(_HIGH, _LOW, _CLOSE, 14)
|
||||
@@ -425,6 +468,7 @@ class TestPLUS:
|
||||
# PPO
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPPO:
|
||||
def test_returns_three_arrays(self):
|
||||
result = PPO(_CLOSE, fastperiod=12, slowperiod=26)
|
||||
@@ -448,6 +492,7 @@ class TestPPO:
|
||||
# APO
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAPO:
|
||||
def test_known_direction(self):
|
||||
# Rising close → fast EMA > slow EMA → APO > 0 after warmup
|
||||
@@ -468,6 +513,7 @@ class TestAPO:
|
||||
# TRIX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTRIX:
|
||||
def test_length(self):
|
||||
assert len(TRIX(_CLOSE, 10)) == N
|
||||
@@ -494,6 +540,7 @@ class TestTRIX:
|
||||
# BOP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBOP:
|
||||
def test_known_values(self):
|
||||
o = np.array([10.0, 11.0])
|
||||
@@ -526,6 +573,7 @@ class TestBOP:
|
||||
# ULTOSC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestULTOSC:
|
||||
def test_range(self):
|
||||
result = ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28)
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
"""Unit tests for ferro_ta.indicators.overlap"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta.indicators.overlap import (
|
||||
SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, MA,
|
||||
MACD, MACDFIX, MACDEXT, BBANDS, SAR, SAREXT,
|
||||
MAMA, MAVP, MIDPOINT, MIDPRICE,
|
||||
BBANDS,
|
||||
DEMA,
|
||||
EMA,
|
||||
KAMA,
|
||||
MA,
|
||||
MACD,
|
||||
MACDEXT,
|
||||
MACDFIX,
|
||||
MAMA,
|
||||
MAVP,
|
||||
MIDPOINT,
|
||||
MIDPRICE,
|
||||
SAR,
|
||||
SAREXT,
|
||||
SMA,
|
||||
T3,
|
||||
TEMA,
|
||||
TRIMA,
|
||||
WMA,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -26,6 +43,7 @@ SMALL5_LOW = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
|
||||
# SMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSMA:
|
||||
def test_known_values(self):
|
||||
result = SMA(SMALL5, timeperiod=3)
|
||||
@@ -50,6 +68,7 @@ class TestSMA:
|
||||
# EMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEMA:
|
||||
def test_known_values(self):
|
||||
# k = 2/(3+1) = 0.5; seed = SMA(3) = 11.0
|
||||
@@ -80,13 +99,14 @@ class TestEMA:
|
||||
# WMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWMA:
|
||||
def test_known_values(self):
|
||||
arr = np.arange(1.0, 6.0)
|
||||
result = WMA(arr, timeperiod=3)
|
||||
# weights 1,2,3 / 6
|
||||
expected_2 = (1*1 + 2*2 + 3*3) / 6.0 # 14/6
|
||||
expected_3 = (1*2 + 2*3 + 3*4) / 6.0 # 20/6
|
||||
expected_2 = (1 * 1 + 2 * 2 + 3 * 3) / 6.0 # 14/6
|
||||
expected_3 = (1 * 2 + 2 * 3 + 3 * 4) / 6.0 # 20/6
|
||||
assert np.isnan(result[0]) and np.isnan(result[1])
|
||||
np.testing.assert_allclose(result[2], expected_2, rtol=1e-10)
|
||||
np.testing.assert_allclose(result[3], expected_3, rtol=1e-10)
|
||||
@@ -103,10 +123,11 @@ class TestWMA:
|
||||
# DEMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDEMA:
|
||||
def test_nan_warmup(self):
|
||||
result = DEMA(_CLOSE, timeperiod=5)
|
||||
assert np.all(np.isnan(result[:8])) # DEMA needs 2*(tp-1) bars
|
||||
assert np.all(np.isnan(result[:8])) # DEMA needs 2*(tp-1) bars
|
||||
|
||||
def test_length(self):
|
||||
assert len(DEMA(_CLOSE, 5)) == N
|
||||
@@ -131,6 +152,7 @@ class TestDEMA:
|
||||
# TEMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTEMA:
|
||||
def test_nan_warmup(self):
|
||||
result = TEMA(_CLOSE, timeperiod=5)
|
||||
@@ -150,6 +172,7 @@ class TestTEMA:
|
||||
# TRIMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTRIMA:
|
||||
def test_known_values(self):
|
||||
arr = np.arange(1.0, 11.0)
|
||||
@@ -171,6 +194,7 @@ class TestTRIMA:
|
||||
# KAMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKAMA:
|
||||
def test_nan_warmup(self):
|
||||
result = KAMA(_CLOSE, timeperiod=10)
|
||||
@@ -195,6 +219,7 @@ class TestKAMA:
|
||||
# T3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestT3:
|
||||
def test_nan_warmup(self):
|
||||
arr = np.linspace(10.0, 30.0, 100)
|
||||
@@ -223,6 +248,7 @@ class TestT3:
|
||||
# MA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMA:
|
||||
def test_default_is_sma(self):
|
||||
result_ma = MA(_CLOSE, timeperiod=10, matype=0)
|
||||
@@ -242,6 +268,7 @@ class TestMA:
|
||||
# MACD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMACD:
|
||||
def test_returns_three_arrays(self):
|
||||
result = MACD(_CLOSE, 12, 26, 9)
|
||||
@@ -266,6 +293,7 @@ class TestMACD:
|
||||
# MACDFIX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMACDFIX:
|
||||
def test_returns_three_arrays(self):
|
||||
result = MACDFIX(_CLOSE)
|
||||
@@ -285,6 +313,7 @@ class TestMACDFIX:
|
||||
# MACDEXT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMACDEXT:
|
||||
def test_returns_three_arrays(self):
|
||||
result = MACDEXT(_CLOSE)
|
||||
@@ -303,6 +332,7 @@ class TestMACDEXT:
|
||||
# BBANDS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBBANDS:
|
||||
def test_returns_three_arrays(self):
|
||||
result = BBANDS(_CLOSE, 20)
|
||||
@@ -331,6 +361,7 @@ class TestBBANDS:
|
||||
# SAR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSAR:
|
||||
def test_length(self):
|
||||
result = SAR(_HIGH, _LOW)
|
||||
@@ -349,6 +380,7 @@ class TestSAR:
|
||||
# SAREXT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSAREXT:
|
||||
def test_length(self):
|
||||
result = SAREXT(_HIGH, _LOW)
|
||||
@@ -367,6 +399,7 @@ class TestSAREXT:
|
||||
# MAMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMAMA:
|
||||
def test_returns_two_arrays(self):
|
||||
result = MAMA(_CLOSE)
|
||||
@@ -393,6 +426,7 @@ class TestMAMA:
|
||||
# MAVP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMAVP:
|
||||
def test_length(self):
|
||||
arr = np.linspace(10.0, 30.0, 50)
|
||||
@@ -412,6 +446,7 @@ class TestMAVP:
|
||||
# MIDPOINT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMIDPOINT:
|
||||
def test_known_values(self):
|
||||
arr = np.array([10.0, 12.0, 14.0, 16.0, 18.0])
|
||||
@@ -433,6 +468,7 @@ class TestMIDPOINT:
|
||||
# MIDPRICE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMIDPRICE:
|
||||
def test_known_values(self):
|
||||
result = MIDPRICE(SMALL5_HIGH, SMALL5_LOW, timeperiod=3)
|
||||
|
||||
@@ -1,20 +1,70 @@
|
||||
"""Unit tests for ferro_ta.indicators.pattern (CDL* functions)"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta.indicators.pattern import (
|
||||
CDL2CROWS, CDL3BLACKCROWS, CDL3INSIDE, CDL3LINESTRIKE, CDL3OUTSIDE,
|
||||
CDL3STARSINSOUTH, CDL3WHITESOLDIERS, CDLABANDONEDBABY, CDLADVANCEBLOCK,
|
||||
CDLBELTHOLD, CDLBREAKAWAY, CDLCLOSINGMARUBOZU, CDLCONCEALBABYSWALL,
|
||||
CDLCOUNTERATTACK, CDLDARKCLOUDCOVER, CDLDOJI, CDLDOJISTAR, CDLDRAGONFLYDOJI,
|
||||
CDLENGULFING, CDLEVENINGDOJISTAR, CDLEVENINGSTAR, CDLGAPSIDESIDEWHITE,
|
||||
CDLGRAVESTONEDOJI, CDLHAMMER, CDLHANGINGMAN, CDLHARAMI, CDLHARAMICROSS,
|
||||
CDLHIGHWAVE, CDLHIKKAKE, CDLHIKKAKEMOD, CDLHOMINGPIGEON, CDLIDENTICAL3CROWS,
|
||||
CDLINNECK, CDLINVERTEDHAMMER, CDLKICKING, CDLKICKINGBYLENGTH, CDLLADDERBOTTOM,
|
||||
CDLLONGLEGGEDDOJI, CDLLONGLINE, CDLMARUBOZU, CDLMATCHINGLOW, CDLMATHOLD,
|
||||
CDLMORNINGDOJISTAR, CDLMORNINGSTAR, CDLONNECK, CDLPIERCING, CDLRICKSHAWMAN,
|
||||
CDLRISEFALL3METHODS, CDLSEPARATINGLINES, CDLSHOOTINGSTAR, CDLSHORTLINE,
|
||||
CDLSPINNINGTOP, CDLSTALLEDPATTERN, CDLSTICKSANDWICH, CDLTAKURI, CDLTASUKIGAP,
|
||||
CDLTHRUSTING, CDLTRISTAR, CDLUNIQUE3RIVER, CDLUPSIDEGAP2CROWS, CDLXSIDEGAP3METHODS,
|
||||
CDL2CROWS,
|
||||
CDL3BLACKCROWS,
|
||||
CDL3INSIDE,
|
||||
CDL3LINESTRIKE,
|
||||
CDL3OUTSIDE,
|
||||
CDL3STARSINSOUTH,
|
||||
CDL3WHITESOLDIERS,
|
||||
CDLABANDONEDBABY,
|
||||
CDLADVANCEBLOCK,
|
||||
CDLBELTHOLD,
|
||||
CDLBREAKAWAY,
|
||||
CDLCLOSINGMARUBOZU,
|
||||
CDLCONCEALBABYSWALL,
|
||||
CDLCOUNTERATTACK,
|
||||
CDLDARKCLOUDCOVER,
|
||||
CDLDOJI,
|
||||
CDLDOJISTAR,
|
||||
CDLDRAGONFLYDOJI,
|
||||
CDLENGULFING,
|
||||
CDLEVENINGDOJISTAR,
|
||||
CDLEVENINGSTAR,
|
||||
CDLGAPSIDESIDEWHITE,
|
||||
CDLGRAVESTONEDOJI,
|
||||
CDLHAMMER,
|
||||
CDLHANGINGMAN,
|
||||
CDLHARAMI,
|
||||
CDLHARAMICROSS,
|
||||
CDLHIGHWAVE,
|
||||
CDLHIKKAKE,
|
||||
CDLHIKKAKEMOD,
|
||||
CDLHOMINGPIGEON,
|
||||
CDLIDENTICAL3CROWS,
|
||||
CDLINNECK,
|
||||
CDLINVERTEDHAMMER,
|
||||
CDLKICKING,
|
||||
CDLKICKINGBYLENGTH,
|
||||
CDLLADDERBOTTOM,
|
||||
CDLLONGLEGGEDDOJI,
|
||||
CDLLONGLINE,
|
||||
CDLMARUBOZU,
|
||||
CDLMATCHINGLOW,
|
||||
CDLMATHOLD,
|
||||
CDLMORNINGDOJISTAR,
|
||||
CDLMORNINGSTAR,
|
||||
CDLONNECK,
|
||||
CDLPIERCING,
|
||||
CDLRICKSHAWMAN,
|
||||
CDLRISEFALL3METHODS,
|
||||
CDLSEPARATINGLINES,
|
||||
CDLSHOOTINGSTAR,
|
||||
CDLSHORTLINE,
|
||||
CDLSPINNINGTOP,
|
||||
CDLSTALLEDPATTERN,
|
||||
CDLSTICKSANDWICH,
|
||||
CDLTAKURI,
|
||||
CDLTASUKIGAP,
|
||||
CDLTHRUSTING,
|
||||
CDLTRISTAR,
|
||||
CDLUNIQUE3RIVER,
|
||||
CDLUPSIDEGAP2CROWS,
|
||||
CDLXSIDEGAP3METHODS,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -98,6 +148,7 @@ ALL_CDL = [
|
||||
# Parametrised tests: all CDL patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,fn", ALL_CDL)
|
||||
def test_cdl_output_length(name, fn):
|
||||
result = fn(_O, _H, _L, _C)
|
||||
@@ -107,8 +158,9 @@ def test_cdl_output_length(name, fn):
|
||||
@pytest.mark.parametrize("name,fn", ALL_CDL)
|
||||
def test_cdl_values_in_valid_set(name, fn):
|
||||
result = fn(_O, _H, _L, _C)
|
||||
assert np.all(np.isin(result, [-100, 0, 100])), \
|
||||
assert np.all(np.isin(result, [-100, 0, 100])), (
|
||||
f"{name}: unexpected values {np.unique(result)}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,fn", ALL_CDL)
|
||||
@@ -121,6 +173,7 @@ def test_cdl_no_nan(name, fn):
|
||||
# Specific tests for previously untested patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCDLSPINNINGTOP:
|
||||
def test_detects_pattern(self):
|
||||
# Spinning top: small body, long upper and lower shadows
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for ferro_ta.indicators.price_transform"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta.indicators.price_transform import AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -17,6 +18,7 @@ C = np.array([11.0, 12.0, 13.0, 14.0])
|
||||
# AVGPRICE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAVGPRICE:
|
||||
def test_known_formula(self):
|
||||
result = AVGPRICE(O, H, L, C)
|
||||
@@ -39,6 +41,7 @@ class TestAVGPRICE:
|
||||
# MEDPRICE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMEDPRICE:
|
||||
def test_known_formula(self):
|
||||
result = MEDPRICE(H, L)
|
||||
@@ -61,6 +64,7 @@ class TestMEDPRICE:
|
||||
# TYPPRICE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTYPPRICE:
|
||||
def test_known_formula(self):
|
||||
result = TYPPRICE(H, L, C)
|
||||
@@ -83,6 +87,7 @@ class TestTYPPRICE:
|
||||
# WCLPRICE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWCLPRICE:
|
||||
def test_known_formula(self):
|
||||
result = WCLPRICE(H, L, C)
|
||||
@@ -99,7 +104,6 @@ class TestWCLPRICE:
|
||||
|
||||
def test_close_weight_double(self):
|
||||
# WCLPRICE weights close twice vs TYPPRICE
|
||||
typ = TYPPRICE(H, L, C)
|
||||
wcl = WCLPRICE(H, L, C)
|
||||
# On a rising series (H > L > 0), WCLPRICE > TYPPRICE when C > (H+L)/2
|
||||
# Just verify formula correctness already done above
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
"""Unit tests for ferro_ta.indicators.statistic"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta.indicators.statistic import (
|
||||
STDDEV, VAR, BETA, CORREL,
|
||||
LINEARREG, LINEARREG_ANGLE, LINEARREG_INTERCEPT, LINEARREG_SLOPE,
|
||||
BETA,
|
||||
CORREL,
|
||||
LINEARREG,
|
||||
LINEARREG_ANGLE,
|
||||
LINEARREG_INTERCEPT,
|
||||
LINEARREG_SLOPE,
|
||||
STDDEV,
|
||||
TSF,
|
||||
VAR,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -16,14 +23,15 @@ N = 100
|
||||
_A = 100 + np.cumsum(RNG.normal(0, 0.5, N))
|
||||
_B = 100 + np.cumsum(RNG.normal(0, 0.5, N))
|
||||
|
||||
LINDATA = np.arange(1.0, 6.0) # [1,2,3,4,5]
|
||||
CONSTDATA = np.ones(10) # all 1.0
|
||||
LINDATA = np.arange(1.0, 6.0) # [1,2,3,4,5]
|
||||
CONSTDATA = np.ones(10) # all 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STDDEV
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSTDDEV:
|
||||
def test_constant_is_zero(self):
|
||||
result = STDDEV(CONSTDATA, timeperiod=5)
|
||||
@@ -52,6 +60,7 @@ class TestSTDDEV:
|
||||
# VAR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVAR:
|
||||
def test_constant_is_zero(self):
|
||||
result = VAR(CONSTDATA, timeperiod=5)
|
||||
@@ -77,6 +86,7 @@ class TestVAR:
|
||||
# LINEARREG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLINEARREG:
|
||||
def test_perfect_line(self):
|
||||
# For [1,2,3,4,5] over window 5, forecast = 5.0
|
||||
@@ -95,6 +105,7 @@ class TestLINEARREG:
|
||||
# LINEARREG_SLOPE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLINEARREG_SLOPE:
|
||||
def test_perfect_line_slope_one(self):
|
||||
result = LINEARREG_SLOPE(LINDATA, timeperiod=5)
|
||||
@@ -113,6 +124,7 @@ class TestLINEARREG_SLOPE:
|
||||
# LINEARREG_INTERCEPT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLINEARREG_INTERCEPT:
|
||||
def test_perfect_line_intercept_one(self):
|
||||
# y = [1,2,3,4,5] with x=[0,1,2,3,4] → y = 1 + 1*x → intercept = 1.0
|
||||
@@ -127,6 +139,7 @@ class TestLINEARREG_INTERCEPT:
|
||||
# LINEARREG_ANGLE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLINEARREG_ANGLE:
|
||||
def test_slope_one_gives_45_degrees(self):
|
||||
result = LINEARREG_ANGLE(LINDATA, timeperiod=5)
|
||||
@@ -146,6 +159,7 @@ class TestLINEARREG_ANGLE:
|
||||
# BETA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBETA:
|
||||
def test_nan_warmup(self):
|
||||
result = BETA(_A, _B, timeperiod=5)
|
||||
@@ -170,6 +184,7 @@ class TestBETA:
|
||||
# CORREL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCOREL:
|
||||
def test_self_correlation_is_one(self):
|
||||
result = CORREL(_A, _A, timeperiod=10)
|
||||
@@ -195,6 +210,7 @@ class TestCOREL:
|
||||
# TSF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTSF:
|
||||
def test_perfect_line(self):
|
||||
arr = np.arange(1.0, 10.0)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for ferro_ta.indicators.volatility"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta.indicators.volatility import ATR, NATR, TRANGE
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -23,6 +24,7 @@ SMALL_C = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
|
||||
# TRANGE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTRANGE:
|
||||
def test_known_values_constant_range(self):
|
||||
result = TRANGE(SMALL_H, SMALL_L, SMALL_C)
|
||||
@@ -66,6 +68,7 @@ class TestTRANGE:
|
||||
# ATR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestATR:
|
||||
def test_timeperiod_1_equals_trange(self):
|
||||
atr = ATR(SMALL_H, SMALL_L, SMALL_C, timeperiod=1)
|
||||
@@ -99,6 +102,7 @@ class TestATR:
|
||||
# NATR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNATR:
|
||||
def test_nan_warmup(self):
|
||||
result = NATR(_HIGH, _LOW, _CLOSE, timeperiod=14)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for ferro_ta.indicators.volume"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta.indicators.volume import AD, ADOSC, OBV
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -24,6 +25,7 @@ SMALL_V = np.array([1000.0, 2000.0, 3000.0, 4000.0, 5000.0])
|
||||
# OBV
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOBV:
|
||||
def test_known_values_rising(self):
|
||||
# Rising close: OBV accumulates all volume
|
||||
@@ -64,6 +66,7 @@ class TestOBV:
|
||||
# AD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAD:
|
||||
def test_known_formula(self):
|
||||
# AD = cumsum(CLV * volume)
|
||||
@@ -72,7 +75,7 @@ class TestAD:
|
||||
l = np.array([10.0])
|
||||
c = np.array([12.0])
|
||||
v = np.array([1000.0])
|
||||
clv = ((12 - 10) - (15 - 12)) / (15 - 10) # (2 - 3) / 5 = -0.2
|
||||
clv = ((12 - 10) - (15 - 12)) / (15 - 10) # (2 - 3) / 5 = -0.2
|
||||
expected = clv * 1000.0
|
||||
result = AD(h, l, c, v)
|
||||
np.testing.assert_allclose(result[0], expected, rtol=1e-10)
|
||||
@@ -94,6 +97,7 @@ class TestAD:
|
||||
# ADOSC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestADOSC:
|
||||
def test_nan_warmup(self):
|
||||
result = ADOSC(_HIGH, _LOW, _CLOSE, _VOL, fastperiod=3, slowperiod=10)
|
||||
|
||||
+132
-132
File diff suppressed because it is too large
Load Diff
@@ -640,13 +640,17 @@ class TestVersionConsistency:
|
||||
|
||||
def test_release_md_exists(self):
|
||||
"""RELEASE.md must exist in the repository root."""
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
root = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
release_md = os.path.join(root, "RELEASE.md")
|
||||
assert os.path.isfile(release_md), "RELEASE.md not found"
|
||||
|
||||
def test_release_md_has_key_sections(self):
|
||||
"""RELEASE.md must mention tagging and PyPI."""
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
root = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
release_md = os.path.join(root, "RELEASE.md")
|
||||
if not os.path.isfile(release_md):
|
||||
pytest.skip("RELEASE.md not found")
|
||||
|
||||
@@ -11,7 +11,6 @@ All tests use NO optional dependencies - they run in every CI environment.
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import ferro_ta
|
||||
|
||||
@@ -81,7 +80,7 @@ class TestEMAKnownValues:
|
||||
# After warmup, EMA should be strictly increasing
|
||||
for i in range(20, len(result) - 1):
|
||||
assert result[i + 1] > result[i], (
|
||||
f"EMA not increasing at index {i}: {result[i]} >= {result[i+1]}"
|
||||
f"EMA not increasing at index {i}: {result[i]} >= {result[i + 1]}"
|
||||
)
|
||||
|
||||
|
||||
@@ -195,9 +194,9 @@ class TestRSIKnownValues:
|
||||
valid_values = result[~np.isnan(result)]
|
||||
if len(valid_values) > 0:
|
||||
# Should be either NaN everywhere or 100 everywhere
|
||||
assert np.all(np.abs(valid_values - 100.0) < 1e-10) or np.all(np.abs(valid_values - 50.0) < 5.0), (
|
||||
"RSI of constant series should be 100 (no down movement) or close to 50"
|
||||
)
|
||||
assert np.all(np.abs(valid_values - 100.0) < 1e-10) or np.all(
|
||||
np.abs(valid_values - 50.0) < 5.0
|
||||
), "RSI of constant series should be 100 (no down movement) or close to 50"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -229,12 +228,63 @@ class TestATRKnownValues:
|
||||
# Bar 0: H=11, L=9, C=10
|
||||
# Bar 1: H=13, L=10, C=12 → TR = max(13-10, |13-10|, |10-10|) = 3
|
||||
# Bar 2: H=14, L=11, C=13 → TR = max(14-11, |14-12|, |11-12|) = 3
|
||||
high = np.array([11.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0,
|
||||
22.0, 23.0, 24.0, 25.0, 26.0])
|
||||
low = np.array([9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0,
|
||||
19.0, 20.0, 21.0, 22.0, 23.0])
|
||||
close = np.array([10.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0,
|
||||
21.0, 22.0, 23.0, 24.0, 25.0])
|
||||
high = np.array(
|
||||
[
|
||||
11.0,
|
||||
13.0,
|
||||
14.0,
|
||||
15.0,
|
||||
16.0,
|
||||
17.0,
|
||||
18.0,
|
||||
19.0,
|
||||
20.0,
|
||||
21.0,
|
||||
22.0,
|
||||
23.0,
|
||||
24.0,
|
||||
25.0,
|
||||
26.0,
|
||||
]
|
||||
)
|
||||
low = np.array(
|
||||
[
|
||||
9.0,
|
||||
10.0,
|
||||
11.0,
|
||||
12.0,
|
||||
13.0,
|
||||
14.0,
|
||||
15.0,
|
||||
16.0,
|
||||
17.0,
|
||||
18.0,
|
||||
19.0,
|
||||
20.0,
|
||||
21.0,
|
||||
22.0,
|
||||
23.0,
|
||||
]
|
||||
)
|
||||
close = np.array(
|
||||
[
|
||||
10.0,
|
||||
12.0,
|
||||
13.0,
|
||||
14.0,
|
||||
15.0,
|
||||
16.0,
|
||||
17.0,
|
||||
18.0,
|
||||
19.0,
|
||||
20.0,
|
||||
21.0,
|
||||
22.0,
|
||||
23.0,
|
||||
24.0,
|
||||
25.0,
|
||||
]
|
||||
)
|
||||
|
||||
# For period=1, ATR still has warmup. Use TRANGE to check TR values directly
|
||||
tr = ferro_ta.TRANGE(high, low, close)
|
||||
@@ -263,7 +313,7 @@ class TestMOMKnownValues:
|
||||
|
||||
assert np.isnan(result[0])
|
||||
assert np.isnan(result[1])
|
||||
assert np.abs(result[2] - 5.0) < 1e-10 # 15 - 10 = 5
|
||||
assert np.abs(result[2] - 5.0) < 1e-10 # 15 - 10 = 5
|
||||
assert np.abs(result[3] - (-1.0)) < 1e-10 # 11 - 12 = -1
|
||||
|
||||
|
||||
@@ -295,7 +345,9 @@ class TestMACDKnownValues:
|
||||
def test_macd_histogram_identity(self):
|
||||
"""histogram should always equal macd - signal."""
|
||||
data = np.arange(1.0, 51.0)
|
||||
macd, signal, histogram = ferro_ta.MACD(data, fastperiod=12, slowperiod=26, signalperiod=9)
|
||||
macd, signal, histogram = ferro_ta.MACD(
|
||||
data, fastperiod=12, slowperiod=26, signalperiod=9
|
||||
)
|
||||
|
||||
# histogram = macd - signal (within floating-point tolerance)
|
||||
expected_histogram = macd - signal
|
||||
@@ -338,9 +390,9 @@ class TestVWAPKnownValues:
|
||||
|
||||
expected_0 = typ[0]
|
||||
expected_1 = (typ[0] * volume[0] + typ[1] * volume[1]) / (volume[0] + volume[1])
|
||||
expected_2 = (
|
||||
typ[0] * volume[0] + typ[1] * volume[1] + typ[2] * volume[2]
|
||||
) / (volume[0] + volume[1] + volume[2])
|
||||
expected_2 = (typ[0] * volume[0] + typ[1] * volume[1] + typ[2] * volume[2]) / (
|
||||
volume[0] + volume[1] + volume[2]
|
||||
)
|
||||
|
||||
assert np.abs(result[0] - expected_0) < 1e-10
|
||||
assert np.abs(result[1] - expected_1) < 1e-10
|
||||
@@ -359,7 +411,6 @@ class TestDONCHIANKnownValues:
|
||||
"""upper == MAX(high), lower == MIN(low), middle == (upper+lower)/2."""
|
||||
high = np.array([11.0, 13.0, 14.0, 12.0, 15.0])
|
||||
low = np.array([9.0, 10.0, 11.0, 10.0, 12.0])
|
||||
close = np.array([10.0, 12.0, 13.0, 11.0, 14.0])
|
||||
|
||||
period = 3
|
||||
upper, middle, lower = ferro_ta.DONCHIAN(high, low, timeperiod=period)
|
||||
@@ -394,7 +445,9 @@ class TestPIVOT_POINTSKnownValues:
|
||||
low = np.array([90.0, 90.0])
|
||||
close = np.array([100.0, 100.0])
|
||||
|
||||
pivot, r1, s1, r2, s2 = ferro_ta.PIVOT_POINTS(high, low, close, method="classic")
|
||||
pivot, r1, s1, r2, s2 = ferro_ta.PIVOT_POINTS(
|
||||
high, low, close, method="classic"
|
||||
)
|
||||
|
||||
# Check last bar (index 1) which has full history
|
||||
# P = (110 + 90 + 100) / 3 = 100
|
||||
@@ -458,7 +511,6 @@ class TestPatternKnownValues:
|
||||
def test_doji_known_sequence(self):
|
||||
"""Construct a perfect doji: open == close, small body."""
|
||||
# Doji: open == close (or very close), H and L have range
|
||||
n = 5
|
||||
high = np.array([11.0, 11.0, 11.0, 11.0, 11.0])
|
||||
low = np.array([9.0, 9.0, 9.0, 9.0, 9.0])
|
||||
close = np.array([10.0, 10.0, 10.0, 10.0, 10.0])
|
||||
@@ -507,3 +559,4 @@ class TestPatternKnownValues:
|
||||
|
||||
# Last bar has hammer characteristics
|
||||
# (actual detection may vary based on implementation)
|
||||
assert result.shape == close.shape
|
||||
|
||||
@@ -655,7 +655,9 @@ class TestWebAPI:
|
||||
import sys
|
||||
|
||||
# Insert project root so that `api.main` is importable
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
project_root = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user