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:
Pratik Bhadane
2026-03-23 23:57:30 +05:30
parent 7a5a220dfe
commit 307beeca02
47 changed files with 1822 additions and 573 deletions
+4 -5
View File
@@ -11,7 +11,6 @@ on:
permissions: permissions:
contents: read contents: read
pages: write pages: write
id-token: write
jobs: jobs:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -386,6 +385,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- audit - audit
- changelog-check
- version-check - version-check
- rust - rust
- rust-core - rust-core
@@ -452,6 +452,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-wheels needs: build-wheels
if: github.event_name == 'release' && github.event.action == 'published' if: github.event_name == 'release' && github.event.action == 'published'
environment:
name: pypi
url: https://pypi.org/p/ferro-ta
permissions: permissions:
id-token: write id-token: write
steps: steps:
@@ -464,10 +467,6 @@ jobs:
- name: Publish to PyPI - name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1 uses: pypa/gh-action-pypi-publish@release/v1
with:
# Use token if set; otherwise the action uses OIDC trusted publishing
username: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Publish ferro_ta_core to crates.io (requires CARGO_REGISTRY_TOKEN secret) # Publish ferro_ta_core to crates.io (requires CARGO_REGISTRY_TOKEN secret)
+4 -22
View File
@@ -9,6 +9,8 @@ and the project uses [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [1.0.0] — 2026-03-23 *(initial stable release)*
### Performance ### Performance
- **SMA/EMA** (`src/overlap/sma.rs`, `src/overlap/ema.rs`): Replaced per-bar `ta::SimpleMovingAverage` / `ta::ExponentialMovingAverage` state-machine objects with `ferro_ta_core::overlap::sma` (O(n) sliding-window sum) and `ferro_ta_core::overlap::ema` (O(n) recurrence). SMA/EMA now run at **200600 M bars/s** on 1 M input. - **SMA/EMA** (`src/overlap/sma.rs`, `src/overlap/ema.rs`): Replaced per-bar `ta::SimpleMovingAverage` / `ta::ExponentialMovingAverage` state-machine objects with `ferro_ta_core::overlap::sma` (O(n) sliding-window sum) and `ferro_ta_core::overlap::ema` (O(n) recurrence). SMA/EMA now run at **200600 M bars/s** on 1 M input.
@@ -250,25 +252,5 @@ and the project uses [Semantic Versioning](https://semver.org/).
--- ---
## [0.1.0] — 2025-xx-xx *(initial release)* [Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/pratikbhadane24/ferro-ta/releases/tag/v1.0.0
### Added
- Rust + PyO3 core with 155+ TA-Lib-compatible indicators.
- Overlap studies (SMA, EMA, BBANDS, MACD, …).
- Momentum indicators (RSI, STOCH, ADX, CCI, …).
- Volume indicators (AD, ADOSC, OBV).
- Volatility indicators (ATR, NATR, TRANGE).
- Statistic functions (STDDEV, VAR, LINEARREG, BETA, CORREL).
- Price transforms (AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE).
- 61 candlestick pattern recognition functions.
- Cycle indicators (HT_TRENDLINE, HT_DCPERIOD, HT_DCPHASE, HT_PHASOR, HT_SINE, HT_TRENDMODE).
- Math operators and transforms (ADD, SUB, SUM, MAX, ACOS, SIN, …).
- Extended indicators (VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS).
- Streaming / incremental API for live trading (StreamingSMA, StreamingRSI, …).
- Transparent pandas Series / DataFrame support.
- Type stubs (`.pyi`) for IDE auto-completion.
- Sphinx documentation in `docs/`.
- Pre-compiled manylinux wheels for Linux, Windows, macOS (Intel & Apple Silicon).
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/pratikbhadane24/ferro-ta/releases/tag/v0.1.0
Generated
+28 -28
View File
@@ -28,15 +28,15 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]] [[package]]
name = "anstyle" name = "anstyle"
version = "1.0.13" version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.8.2" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
dependencies = [ dependencies = [
"rustversion", "rustversion",
] ]
@@ -67,9 +67,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.56" version = "1.2.57"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -110,18 +110,18 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.60" version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
] ]
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.60" version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"clap_lex", "clap_lex",
@@ -129,9 +129,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_lex" name = "clap_lex"
version = "1.0.0" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]] [[package]]
name = "criterion" name = "criterion"
@@ -207,7 +207,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "ferro_ta" name = "ferro_ta"
version = "0.1.0" version = "1.0.0"
dependencies = [ dependencies = [
"criterion", "criterion",
"ferro_ta_core", "ferro_ta_core",
@@ -222,7 +222,7 @@ dependencies = [
[[package]] [[package]]
name = "ferro_ta_core" name = "ferro_ta_core"
version = "0.1.0" version = "1.0.0"
dependencies = [ dependencies = [
"criterion", "criterion",
"wide", "wide",
@@ -271,9 +271,9 @@ dependencies = [
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.17" version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
@@ -287,9 +287,9 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.182" version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]] [[package]]
name = "log" name = "log"
@@ -382,9 +382,9 @@ dependencies = [
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "oorandom" name = "oorandom"
@@ -438,9 +438,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]] [[package]]
name = "portable-atomic-util" name = "portable-atomic-util"
version = "0.2.5" version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
dependencies = [ dependencies = [
"portable-atomic", "portable-atomic",
] ]
@@ -782,9 +782,9 @@ dependencies = [
[[package]] [[package]]
name = "wide" name = "wide"
version = "1.1.1" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac11b009ebeae802ed758530b6496784ebfee7a87b9abfbcaf3bbe25b814eb25" checksum = "198f6abc41fab83526d10880fa5c17e2b4ee44e763949b4bb34e2fd1e8ca48e4"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"safe_arch", "safe_arch",
@@ -838,18 +838,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.40" version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.40" version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+2 -2
View File
@@ -5,7 +5,7 @@ resolver = "2"
[package] [package]
name = "ferro_ta" name = "ferro_ta"
version = "0.1.0" version = "1.0.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
publish = false publish = false
@@ -23,7 +23,7 @@ ndarray = "0.16"
rayon = "1.10" rayon = "1.10"
log = "0.4" log = "0.4"
pyo3-log = "0.12" pyo3-log = "0.12"
ferro_ta_core = { path = "crates/ferro_ta_core", version = "0.1.0" } ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.0.0" }
[dev-dependencies] [dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] } criterion = { version = "0.8", features = ["html_reports"] }
+2
View File
@@ -5,6 +5,8 @@ This document describes how ferro-ta is packaged and published.
## PyPI (pip) ## PyPI (pip)
Wheels are built by CI on release (see [RELEASE.md](RELEASE.md)). Wheels are built by CI on release (see [RELEASE.md](RELEASE.md)).
Publishing uses PyPI Trusted Publishing via GitHub OIDC; no long-lived PyPI API
token is required.
Supported platforms and Python versions are documented in [PLATFORMS.md](PLATFORMS.md). Supported platforms and Python versions are documented in [PLATFORMS.md](PLATFORMS.md).
+4 -3
View File
@@ -3,7 +3,7 @@
This document describes the step-by-step process for cutting a new **ferro-ta** release. This document describes the step-by-step process for cutting a new **ferro-ta** release.
Follow every step in order to produce a consistent, reproducible release. Follow every step in order to produce a consistent, reproducible release.
For **automated publishing** (secrets and what runs on release), see [PUBLISHING.md](PUBLISHING.md). For the packaging and release overview, see [PACKAGING.md](PACKAGING.md).
--- ---
@@ -11,7 +11,7 @@ For **automated publishing** (secrets and what runs on release), see [PUBLISHING
| Artifact | How | | Artifact | How |
|-------------|-----| |-------------|-----|
| **PyPI** | CI job `publish| | **PyPI** | CI job `publish` using PyPI Trusted Publishing (OIDC) |
| **npm (WASM)** | Workflow `wasm-publish`| | **npm (WASM)** | Workflow `wasm-publish`|
| **crates.io** | CI job `publish-cratesio` | | **crates.io** | CI job `publish-cratesio` |
@@ -129,7 +129,8 @@ git push origin v0.2.0
5. Click **Publish release**. 5. Click **Publish release**.
Publishing the release triggers the CI `build-wheels` and `publish` jobs Publishing the release triggers the CI `build-wheels` and `publish` jobs
automatically (the workflow responds to `release: published`). automatically (the workflow responds to `release: published`). The PyPI upload
uses Trusted Publishing via GitHub OIDC, so no `PYPI_API_TOKEN` secret is used.
--- ---
+13 -14
View File
@@ -22,13 +22,13 @@ Currently supported: **3.10, 3.11, 3.12, 3.13** (see `pyproject.toml`).
## Release playbook ## Release playbook
1. **Bump the version** in `Cargo.toml` and `pyproject.toml` to the new version 1. **Bump the version** in `Cargo.toml` and `pyproject.toml` to the new version
(e.g. `0.2.0`). (e.g. `1.0.1`).
2. **Update `CHANGELOG.md`**: move the `[Unreleased]` block to a new dated section 2. **Update `CHANGELOG.md`**: move the `[Unreleased]` block to a new dated section
`[0.2.0] — YYYY-MM-DD` and open a fresh `[Unreleased]` block. `[1.0.1] — YYYY-MM-DD` and open a fresh `[Unreleased]` block.
3. **Commit** the version bump and changelog update with message 3. **Commit** the version bump and changelog update with message
`chore: release v0.2.0`. `chore: release v1.0.1`.
4. **Create a tag**: `git tag v0.2.0 && git push origin v0.2.0`. 4. **Create a tag**: `git tag v1.0.1 && git push origin v1.0.1`.
5. **Create a GitHub Release** for tag `v0.2.0` — the CI `build-wheels` and 5. **Create a GitHub Release** for tag `v1.0.1` — the CI `build-wheels` and
`publish` jobs trigger automatically on `release: published`. `publish` jobs trigger automatically on `release: published`.
## Breaking-change policy ## Breaking-change policy
@@ -47,10 +47,10 @@ touch `src/`, `python/`, or `wasm/`.
--- ---
## API Stability Guarantees (v1.0 preparation) ## API Stability Guarantees
The following modules are considered **stable API** as of the v0.1.x series and The following modules are considered **stable API** as of `1.0.0` and will not
will not have breaking changes in minor releases: have breaking changes in minor releases:
| Module | Stability | | Module | Stability |
|---|---| |---|---|
@@ -90,13 +90,12 @@ will not have breaking changes in minor releases:
- [x] Configuration defaults API - [x] Configuration defaults API
- [x] Jupyter notebook examples - [x] Jupyter notebook examples
### Path to v1.0 ### Post-1.0 notes
When the v1.0 release is cut: With `1.0.0` released:
1. Remove any `Beta` classifiers from `pyproject.toml`. 1. The package now uses the `Development Status :: 5 - Production/Stable` classifier.
2. Update `CHANGELOG.md` with the `[1.0.0]` release section. 2. `CHANGELOG.md` now contains the `[1.0.0]` release section.
3. Update this file to reflect stable status. 3. This file now reflects the stable-series SemVer contract.
4. Consider adding `Stable :: Stable` PyPI classifier.
### Compatibility matrix ### Compatibility matrix
+1 -1
View File
@@ -106,7 +106,7 @@ MAX_SERIES_LENGTH = int(os.environ.get("MAX_SERIES_LENGTH", "100000"))
app = FastAPI( app = FastAPI(
title="ferro-ta API", title="ferro-ta API",
description="REST API for ferro-ta technical analysis indicators and backtesting.", description="REST API for ferro-ta technical analysis indicators and backtesting.",
version="0.1.0", version="1.0.0",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
) )
+1 -1
View File
@@ -1,5 +1,5 @@
# Runtime dependencies for ferro-ta API # Runtime dependencies for ferro-ta API
ferro_ta>=0.1.0 ferro_ta>=1.0.0
fastapi>=0.110.0 fastapi>=0.110.0
uvicorn[standard]>=0.27.0 uvicorn[standard]>=0.27.0
pydantic>=2.0.0 pydantic>=2.0.0
+55 -3
View File
@@ -23,6 +23,23 @@ def _parse_threshold_items(items: list[str]) -> dict[int, float]:
return thresholds return thresholds
def _percentile(values: list[float], q: float) -> float:
"""Return the q percentile using linear interpolation."""
if not values:
raise ValueError("Cannot compute percentile of empty sequence")
if q <= 0:
return min(values)
if q >= 100:
return max(values)
values = sorted(values)
rank = (len(values) - 1) * (q / 100.0)
lower = int(rank)
upper = min(lower + 1, len(values) - 1)
weight = rank - lower
return values[lower] * (1.0 - weight) + values[upper] * weight
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Check TA-Lib benchmark JSON against regression thresholds." description="Check TA-Lib benchmark JSON against regression thresholds."
@@ -47,8 +64,20 @@ def main() -> int:
parser.add_argument( parser.add_argument(
"--min-speedup-floor", "--min-speedup-floor",
action="append", action="append",
default=["10000=0.10", "100000=0.10"],
help="Hard minimum per-row speedup floor per size, e.g. 100000=0.1 (repeatable)",
)
parser.add_argument(
"--tail-percentile",
type=float,
default=10.0,
help="Tail percentile used for distribution-based slowdown checks (default: 10)",
)
parser.add_argument(
"--tail-speedup-floor",
action="append",
default=["10000=0.20", "100000=0.20"], default=["10000=0.20", "100000=0.20"],
help="Required minimum per-row speedup floor per size, e.g. 100000=0.2 (repeatable)", help="Required minimum tail percentile speedup per size, e.g. 100000=0.2 (repeatable)",
) )
args = parser.parse_args() args = parser.parse_args()
@@ -67,10 +96,19 @@ def main() -> int:
for entry in data.get("summary", {}).get("by_size", []) for entry in data.get("summary", {}).get("by_size", [])
if entry.get("size") is not None if entry.get("size") is not None
} }
results_by_size: dict[int, list[dict[str, object]]] = {}
for row in data.get("results", []):
if "speedup" not in row or row.get("size") is None:
continue
size = int(row["size"])
results_by_size.setdefault(size, []).append(row)
median_floor = _parse_threshold_items(args.median_floor) median_floor = _parse_threshold_items(args.median_floor)
min_speedup_floor = _parse_threshold_items(args.min_speedup_floor) min_speedup_floor = _parse_threshold_items(args.min_speedup_floor)
required_sizes = sorted(set(median_floor) | set(min_speedup_floor)) tail_speedup_floor = _parse_threshold_items(args.tail_speedup_floor)
required_sizes = sorted(
set(median_floor) | set(min_speedup_floor) | set(tail_speedup_floor)
)
failures: list[str] = [] failures: list[str] = []
for size in required_sizes: for size in required_sizes:
@@ -78,12 +116,20 @@ def main() -> int:
if entry is None: if entry is None:
failures.append(f"missing summary for size={size}") failures.append(f"missing summary for size={size}")
continue continue
rows_for_size = results_by_size.get(size, [])
if not rows_for_size:
failures.append(f"missing detailed rows for size={size}")
continue
rows = int(entry.get("rows", 0)) rows = int(entry.get("rows", 0))
med = float(entry.get("median_speedup", 0.0)) med = float(entry.get("median_speedup", 0.0))
min_s = float(entry.get("min_speedup", 0.0)) min_s = float(entry.get("min_speedup", 0.0))
speedups = [float(row["speedup"]) for row in rows_for_size]
tail_s = _percentile(speedups, args.tail_percentile)
print( print(
f"size={size}: rows={rows}, median_speedup={med:.4f}, min_speedup={min_s:.4f}" "size="
f"{size}: rows={rows}, median_speedup={med:.4f}, "
f"p{args.tail_percentile:g}_speedup={tail_s:.4f}, min_speedup={min_s:.4f}"
) )
if rows < args.min_rows: if rows < args.min_rows:
@@ -94,6 +140,12 @@ def main() -> int:
failures.append( failures.append(
f"size={size} median_speedup {med:.4f} < floor {median_floor[size]:.4f}" f"size={size} median_speedup {med:.4f} < floor {median_floor[size]:.4f}"
) )
if tail_s < tail_speedup_floor.get(size, float("-inf")):
failures.append(
"size="
f"{size} p{args.tail_percentile:g}_speedup {tail_s:.4f} "
f"< floor {tail_speedup_floor[size]:.4f}"
)
if min_s < min_speedup_floor.get(size, float("-inf")): if min_s < min_speedup_floor.get(size, float("-inf")):
failures.append( failures.append(
f"size={size} min_speedup {min_s:.4f} < floor {min_speedup_floor[size]:.4f}" f"size={size} min_speedup {min_s:.4f} < floor {min_speedup_floor[size]:.4f}"
+1 -1
View File
@@ -1,5 +1,5 @@
{% set name = "ferro-ta" %} {% set name = "ferro-ta" %}
{% set version = "0.1.0" %} {% set version = "1.0.0" %}
package: package:
name: {{ name|lower }} name: {{ name|lower }}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ferro_ta_core" name = "ferro_ta_core"
version = "0.1.0" version = "1.0.0"
edition = "2021" edition = "2021"
description = "Pure Rust core indicator library — no PyO3, no numpy dependency" description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
license = "MIT" license = "MIT"
+1 -1
View File
@@ -1,7 +1,7 @@
Changelog Changelog
========= =========
0.1.0 (2024) 1.0.0 (2026)
------------ ------------
**Candlestick Pattern Parity (61/61)** **Candlestick Pattern Parity (61/61)**
+1 -1
View File
@@ -18,7 +18,7 @@ project = "ferro-ta"
copyright = "2024, pratikbhadane24" copyright = "2024, pratikbhadane24"
author = "pratikbhadane24" author = "pratikbhadane24"
# Version from env (e.g. set in CI from git tag) or default # Version from env (e.g. set in CI from git tag) or default
release = os.environ.get("FERRO_TA_VERSION", "0.1.0") release = os.environ.get("FERRO_TA_VERSION", "1.0.0")
version = release version = release
# -- General configuration ---------------------------------------------------- # -- General configuration ----------------------------------------------------
+1 -1
View File
@@ -1,5 +1,5 @@
ferro-ta Documentation ferro-ta Documentation
===================== ======================
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2
+2 -2
View File
@@ -60,8 +60,8 @@ register("SMOOTH_RSI", SMOOTH_RSI)
```toml ```toml
[project] [project]
name = "ferro-ta-myplugin" name = "ferro-ta-myplugin"
version = "0.1.0" version = "1.0.0"
dependencies = ["ferro_ta>=0.1.0"] dependencies = ["ferro_ta>=1.0.0"]
[project.entry-points."ferro_ta.plugins"] [project.entry-points."ferro_ta.plugins"]
auto_register = "ferro_ta_myplugin:register_all" auto_register = "ferro_ta_myplugin:register_all"
+5 -6
View File
@@ -61,9 +61,8 @@ ferro-ta follows [Semantic Versioning 2.0.0]:
| New indicators, new sub-modules, new features (backward-compatible) | **MINOR** | | New indicators, new sub-modules, new features (backward-compatible) | **MINOR** |
| Bug fixes, performance improvements, docs, dependency bumps | **PATCH** | | Bug fixes, performance improvements, docs, dependency bumps | **PATCH** |
The current version (`0.1.x`) is pre-stable — **breaking changes are possible The current version (`1.x`) is stable. Breaking changes to stable APIs are
in minor releases**. When the project reaches 1.0.0, the full SemVer reserved for future **major** releases.
contract kicks in.
--- ---
@@ -71,7 +70,7 @@ contract kicks in.
Before removing or renaming any **stable** API: Before removing or renaming any **stable** API:
1. The deprecated name/function is kept for at least **one minor release** after 1. The deprecated name/function is kept until the next **major release** after
the deprecation notice. the deprecation notice.
2. A `DeprecationWarning` is raised when the deprecated API is used. 2. A `DeprecationWarning` is raised when the deprecated API is used.
3. The deprecation and removal are documented in `CHANGELOG.md` under 3. The deprecation and removal are documented in `CHANGELOG.md` under
@@ -79,8 +78,8 @@ Before removing or renaming any **stable** API:
Example timeline: Example timeline:
- `0.2.0``OLD_NAME` deprecated, `DeprecationWarning` added; `NEW_NAME` available. - `1.1.0``OLD_NAME` deprecated, `DeprecationWarning` added; `NEW_NAME` available.
- `0.3.0``OLD_NAME` removed. - `2.0.0``OLD_NAME` removed.
--- ---
+11 -5
View File
@@ -4,17 +4,17 @@ build-backend = "maturin"
[project] [project]
name = "ferro-ta" name = "ferro-ta"
version = "0.1.0" version = "1.0.0"
description = "A fast Technical Analysis library — TA-Lib alternative powered by Rust and PyO3" description = "A fast Technical Analysis library — TA-Lib alternative powered by Rust and PyO3"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
requires-python = ">=3.12" requires-python = ">=3.10"
keywords = [ keywords = [
"technical-analysis", "trading", "finance", "rust", "pyo3", "technical-analysis", "trading", "finance", "rust", "pyo3",
"ta-lib", "indicators", "candlestick", "pandas", "numpy", "ta-lib", "indicators", "candlestick", "pandas", "numpy",
] ]
classifiers = [ classifiers = [
"Development Status :: 4 - Beta", "Development Status :: 5 - Production/Stable",
"Intended Audience :: Financial and Insurance Industry", "Intended Audience :: Financial and Insurance Industry",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
@@ -37,7 +37,13 @@ benchmark = ["pytest>=7.0", "pytest-benchmark>=4.0"]
pandas = ["pandas>=1.0"] pandas = ["pandas>=1.0"]
polars = ["polars>=0.19"] polars = ["polars>=0.19"]
docs = ["sphinx>=7.0", "sphinx-rtd-theme>=1.3"] docs = ["sphinx>=7.0", "sphinx-rtd-theme>=1.3"]
comparison = ["pytest>=7.0", "ta-lib>=0.4", "pandas-ta>=0.3", "ta>=0.10", "pandas>=1.0"] comparison = [
"pytest>=7.0",
"ta-lib>=0.4",
"pandas-ta>=0.3; python_version >= '3.12'",
"ta>=0.10",
"pandas>=1.0",
]
gpu = ["torch>=2.0"] gpu = ["torch>=2.0"]
options = [] options = []
mcp = ["mcp>=1.0"] mcp = ["mcp>=1.0"]
@@ -128,6 +134,6 @@ dev = [
"pyright>=1.1", "pyright>=1.1",
"maturin>=1.0,<2.0", "maturin>=1.0,<2.0",
"pyyaml>=6.0", "pyyaml>=6.0",
"pandas-ta>=0.3", "pandas-ta>=0.3; python_version >= '3.12'",
"ta>=0.10", "ta>=0.10",
] ]
+99 -64
View File
@@ -57,6 +57,17 @@ array([ nan, nan, 11. , 12. , 13. , 13.5, 13.33...])
from __future__ import annotations from __future__ import annotations
import sys as _sys
# ---------------------------------------------------------------------------
# Exceptions — exported at the top level for convenient catching
# ---------------------------------------------------------------------------
from ferro_ta.core.exceptions import ( # noqa: F401
FerroTAError,
FerroTAInputError,
FerroTAValueError,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Cycle Indicators # Cycle Indicators
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -69,15 +80,6 @@ from ferro_ta.indicators.cycle import ( # noqa: F401
HT_TRENDMODE, HT_TRENDMODE,
) )
# ---------------------------------------------------------------------------
# Exceptions — exported at the top level for convenient catching
# ---------------------------------------------------------------------------
from ferro_ta.core.exceptions import ( # noqa: F401
FerroTAError,
FerroTAInputError,
FerroTAValueError,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Math Operators & Math Transforms # Math Operators & Math Transforms
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -476,21 +478,6 @@ __all__ = [
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from ferro_ta._utils import pandas_wrap as _pandas_wrap # noqa: E402 from ferro_ta._utils import pandas_wrap as _pandas_wrap # noqa: E402
from ferro_ta._utils import polars_wrap as _polars_wrap # noqa: E402 from ferro_ta._utils import polars_wrap as _polars_wrap # noqa: E402
# ---------------------------------------------------------------------------
# Additional modules (not in __all__ — access via submodule)
# ---------------------------------------------------------------------------
from ferro_ta.tools.alerts import ( # noqa: F401, E402
AlertManager,
check_cross,
check_threshold,
collect_alert_bars,
)
# ---------------------------------------------------------------------------
# API discovery helpers — ferro_ta.indicators() and ferro_ta.info()
# ---------------------------------------------------------------------------
from ferro_ta.tools.api_info import indicators, info # noqa: F401, E402
from ferro_ta.analysis.attribution import ( # noqa: F401, E402 from ferro_ta.analysis.attribution import ( # noqa: F401, E402
TradeStats, TradeStats,
attribution_by_month, attribution_by_month,
@@ -498,6 +485,36 @@ from ferro_ta.analysis.attribution import ( # noqa: F401, E402
from_backtest, from_backtest,
trade_stats, trade_stats,
) )
from ferro_ta.analysis.crypto import ( # noqa: F401, E402
continuous_bar_labels,
funding_pnl,
resample_continuous,
session_boundaries,
)
from ferro_ta.analysis.regime import ( # noqa: F401, E402
detect_breaks_cusum,
regime,
regime_adx,
regime_combined,
rolling_variance_break,
structural_breaks,
)
from ferro_ta.core import exceptions as exceptions # noqa: F401, E402
# ---------------------------------------------------------------------------
# Logging utilities — ferro_ta.enable_debug() / ferro_ta.benchmark()
# ---------------------------------------------------------------------------
from ferro_ta.core.logging_utils import ( # noqa: F401, E402
benchmark,
debug_mode,
disable_debug,
enable_debug,
get_logger,
log_call,
traced,
)
from ferro_ta.data import batch as batch # noqa: F401, E402
from ferro_ta.data import streaming as streaming # noqa: F401, E402
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Batch API (not in __all__ — use directly from ferro_ta.batch) # Batch API (not in __all__ — use directly from ferro_ta.batch)
@@ -515,45 +532,6 @@ from ferro_ta.data.chunked import ( # noqa: F401, E402
stitch_chunks, stitch_chunks,
trim_overlap, trim_overlap,
) )
from ferro_ta.analysis.crypto import ( # noqa: F401, E402
continuous_bar_labels,
funding_pnl,
resample_continuous,
session_boundaries,
)
from ferro_ta.indicators.extended import ( # noqa: F401, E402
CHANDELIER_EXIT,
CHOPPINESS_INDEX,
DONCHIAN,
HULL_MA,
ICHIMOKU,
KELTNER_CHANNELS,
PIVOT_POINTS,
SUPERTREND,
VWAP,
VWMA,
)
# ---------------------------------------------------------------------------
# Logging utilities — ferro_ta.enable_debug() / ferro_ta.benchmark()
# ---------------------------------------------------------------------------
from ferro_ta.core.logging_utils import ( # noqa: F401, E402
benchmark,
debug_mode,
disable_debug,
enable_debug,
get_logger,
log_call,
traced,
)
from ferro_ta.analysis.regime import ( # noqa: F401, E402
detect_breaks_cusum,
regime,
regime_adx,
regime_combined,
rolling_variance_break,
structural_breaks,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Streaming / Incremental API (not in __all__ — these are classes, not funcs) # Streaming / Incremental API (not in __all__ — these are classes, not funcs)
@@ -570,6 +548,63 @@ from ferro_ta.data.streaming import ( # noqa: F401, E402 # type: ignore[assign
StreamingSupertrend, # type: ignore[attr-defined] StreamingSupertrend, # type: ignore[attr-defined]
StreamingVWAP, # type: ignore[attr-defined] StreamingVWAP, # type: ignore[attr-defined]
) )
from ferro_ta.indicators import cycle as cycle # noqa: F401, E402
from ferro_ta.indicators import extended as extended # noqa: F401, E402
from ferro_ta.indicators import math_ops as math_ops # noqa: F401, E402
from ferro_ta.indicators import momentum as momentum # noqa: F401, E402
from ferro_ta.indicators import overlap as overlap # noqa: F401, E402
from ferro_ta.indicators import pattern as pattern # noqa: F401, E402
from ferro_ta.indicators import price_transform as price_transform # noqa: F401, E402
from ferro_ta.indicators import statistic as statistic # noqa: F401, E402
from ferro_ta.indicators import volatility as volatility # noqa: F401, E402
from ferro_ta.indicators import volume as volume # noqa: F401, E402
from ferro_ta.indicators.extended import ( # noqa: F401, E402
CHANDELIER_EXIT,
CHOPPINESS_INDEX,
DONCHIAN,
HULL_MA,
ICHIMOKU,
KELTNER_CHANNELS,
PIVOT_POINTS,
SUPERTREND,
VWAP,
VWMA,
)
# ---------------------------------------------------------------------------
# Additional modules (not in __all__ — access via submodule)
# ---------------------------------------------------------------------------
from ferro_ta.tools.alerts import ( # noqa: F401, E402
AlertManager,
check_cross,
check_threshold,
collect_alert_bars,
)
# ---------------------------------------------------------------------------
# API discovery helpers — ferro_ta.indicators() and ferro_ta.info()
# ---------------------------------------------------------------------------
from ferro_ta.tools.api_info import indicators, info # noqa: F401, E402
_ALIASED_SUBMODULES = {
"batch": batch,
"cycle": cycle,
"exceptions": exceptions,
"extended": extended,
"math_ops": math_ops,
"momentum": momentum,
"overlap": overlap,
"pattern": pattern,
"price_transform": price_transform,
"statistic": statistic,
"streaming": streaming,
"volatility": volatility,
"volume": volume,
}
for _module_name, _module in _ALIASED_SUBMODULES.items():
setattr(_sys.modules[__name__], _module_name, _module)
_sys.modules[f"{__name__}.{_module_name}"] = _module
_g = globals() _g = globals()
for _name in __all__: for _name in __all__:
@@ -579,4 +614,4 @@ for _name in __all__:
_fn = _g.get(_name) _fn = _g.get(_name)
if callable(_fn) and not getattr(_fn, "_polars_wrapped", False): if callable(_fn) and not getattr(_fn, "_polars_wrapped", False):
_g[_name] = _polars_wrap(_fn) _g[_name] = _polars_wrap(_fn)
del _g, _name, _fn del _ALIASED_SUBMODULES, _g, _module, _module_name, _name, _fn, _sys
+1 -1
View File
@@ -28,8 +28,8 @@ from collections.abc import Callable
from typing import Any, Optional from typing import Any, Optional
from ferro_ta._utils import _to_f64 from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import _normalize_rust_error
from ferro_ta.core.exceptions import ( from ferro_ta.core.exceptions import (
_normalize_rust_error,
check_equal_length, check_equal_length,
check_timeperiod, check_timeperiod,
) )
+2
View File
@@ -1,5 +1,7 @@
"""Backward-compat stub — moved to ``ferro_ta.core.logging_utils``.""" """Backward-compat stub — moved to ``ferro_ta.core.logging_utils``."""
from ferro_ta.core.logging_utils import * # noqa: F401, F403 from ferro_ta.core.logging_utils import * # noqa: F401, F403
try: try:
from ferro_ta.core.logging_utils import __all__ # noqa: F401 from ferro_ta.core.logging_utils import __all__ # noqa: F401
except ImportError: except ImportError:
+1 -1
View File
@@ -392,7 +392,7 @@ def _run_stdio_fallback() -> None: # pragma: no cover
"result": { "result": {
"protocolVersion": "2024-11-05", "protocolVersion": "2024-11-05",
"capabilities": {"tools": {}}, "capabilities": {"tools": {}},
"serverInfo": {"name": "ferro-ta", "version": "0.1.0"}, "serverInfo": {"name": "ferro-ta", "version": "1.0.0"},
}, },
} }
elif method == "tools/list": elif method == "tools/list":
-1
View File
@@ -27,4 +27,3 @@ from ferro_ta.tools.tools import ( # noqa: F401
list_indicators, list_indicators,
run_backtest, run_backtest,
) )
+1 -1
View File
@@ -40,10 +40,10 @@ True
from __future__ import annotations from __future__ import annotations
import warnings
from typing import Any, Optional from typing import Any, Optional
import numpy as np import numpy as np
import warnings
from numpy.typing import ArrayLike, NDArray from numpy.typing import ArrayLike, NDArray
__all__ = [ __all__ = [
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Validate that CHANGELOG.md keeps a single top-level [Unreleased] section."""
from __future__ import annotations
import re
from pathlib import Path
def main() -> int:
changelog = Path("CHANGELOG.md")
if not changelog.exists():
print("ERROR: CHANGELOG.md not found.")
return 1
text = changelog.read_text(encoding="utf-8")
headings = list(re.finditer(r"^## \[(.+?)\]\s*$", text, flags=re.MULTILINE))
unreleased = [m for m in headings if m.group(1) == "Unreleased"]
if not unreleased:
print("ERROR: CHANGELOG.md is missing a '## [Unreleased]' heading.")
return 1
if len(unreleased) > 1:
print("ERROR: CHANGELOG.md contains multiple '## [Unreleased]' headings.")
return 1
if headings and headings[0].group(1) != "Unreleased":
print("ERROR: '## [Unreleased]' must be the first top-level changelog section.")
return 1
print("OK: CHANGELOG.md contains a single top-level [Unreleased] section.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+66 -56
View File
@@ -197,9 +197,9 @@ class TestStreamingATR:
# Streaming # Streaming
streamer = StreamingATR(period=period) streamer = StreamingATR(period=period)
stream_out = np.array([ stream_out = np.array(
streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE) [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)]
]) )
# Compare only the overlap region where both arrays are valid # Compare only the overlap region where both arrays are valid
mask = np.isfinite(batch_out) & np.isfinite(stream_out) mask = np.isfinite(batch_out) & np.isfinite(stream_out)
@@ -207,9 +207,9 @@ class TestStreamingATR:
"""ATR values should be non-negative.""" """ATR values should be non-negative."""
period = 14 period = 14
streamer = StreamingATR(period=period) streamer = StreamingATR(period=period)
stream_out = np.array([ stream_out = np.array(
streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE) [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)]
]) )
# Filter out NaN values # Filter out NaN values
valid = stream_out[~np.isnan(stream_out)] valid = stream_out[~np.isnan(stream_out)]
@@ -222,15 +222,21 @@ class TestStreamingATR:
streamer = StreamingATR(period=period) streamer = StreamingATR(period=period)
# First pass # First pass
first_pass = np.array([ first_pass = np.array(
streamer.update(h, l, c) for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50]) [
]) streamer.update(h, l, c)
for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50])
]
)
# Reset and second pass # Reset and second pass
streamer.reset() streamer.reset()
second_pass = np.array([ second_pass = np.array(
streamer.update(h, l, c) for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50]) [
]) 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) 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. verify proximity with atol=0.2 and confirm internal consistency separately.
""" """
# Batch # 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 # Streaming
streamer = StreamingBBands(period=period, nbdevup=2.0, nbdevdn=2.0) streamer = StreamingBBands(period=period, nbdevup=2.0, nbdevdn=2.0)
@@ -265,8 +273,9 @@ class TestStreamingBBands:
# Compare only overlapping valid region # Compare only overlapping valid region
mask = np.isfinite(batch_middle) mask = np.isfinite(batch_middle)
# Middle band (SMA) must match exactly # 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" "BBands middle (SMA) must match batch exactly"
)
# Upper/lower: streaming uses sample std; batch uses population std — use atol=0.2 # 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_upper[mask], batch_upper[mask], atol=0.2)
assert np.allclose(stream_lower[mask], batch_lower[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 # Compare all three bands
for i in range(len(first_pass)): 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 # Compare all three outputs
for i in range(len(first_pass)): 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.""" """Streaming Stochastic should match batch Stochastic."""
# Batch # Batch
batch_slowk, batch_slowd = ferro_ta.STOCH( batch_slowk, batch_slowd = ferro_ta.STOCH(
HIGH, LOW, CLOSE, HIGH, LOW, CLOSE, fastk_period=5, slowk_period=3, slowd_period=3
fastk_period=5, slowk_period=3,
slowd_period=3
) )
# Streaming # Streaming
streamer = StreamingStoch( streamer = StreamingStoch(fastk_period=5, slowk_period=3, slowd_period=3)
fastk_period=5, slowk_period=3, stream_results = [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)]
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_slowk = np.array([r[0] for r in stream_results])
stream_slowd = np.array([r[1] 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): def test_stoch_range_zero_to_hundred(self):
"""Stochastic values should be in range [0, 100].""" """Stochastic values should be in range [0, 100]."""
streamer = StreamingStoch( streamer = StreamingStoch(fastk_period=5, slowk_period=3, slowd_period=3)
fastk_period=5, slowk_period=3, stream_results = [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)]
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_slowk = np.array([r[0] for r in stream_results])
stream_slowd = np.array([r[1] 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): def test_reset_gives_same_result(self):
"""Reset and re-feed should give identical output.""" """Reset and re-feed should give identical output."""
streamer = StreamingStoch( streamer = StreamingStoch(fastk_period=5, slowk_period=3, slowd_period=3)
fastk_period=5, slowk_period=3,
slowd_period=3
)
# First pass # First pass
first_pass = [ first_pass = [
@@ -419,7 +417,9 @@ class TestStreamingStoch:
# Compare # Compare
for i in range(len(first_pass)): 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) # Streaming (cumulative)
streamer = StreamingVWAP() streamer = StreamingVWAP()
stream_out = np.array([ stream_out = np.array(
streamer.update(h, l, c, v) for h, l, c, v in zip(HIGH, LOW, CLOSE, VOLUME) [
]) streamer.update(h, l, c, v)
for h, l, c, v in zip(HIGH, LOW, CLOSE, VOLUME)
]
)
# Compare # Compare
assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-10) assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-10)
@@ -451,9 +454,12 @@ class TestStreamingVWAP:
# Streaming (cumulative) # Streaming (cumulative)
streamer = StreamingVWAP() streamer = StreamingVWAP()
stream_out = np.array([ stream_out = np.array(
streamer.update(h, l, c, v) for h, l, c, v in zip(HIGH, LOW, CLOSE, VOLUME) [
]) streamer.update(h, l, c, v)
for h, l, c, v in zip(HIGH, LOW, CLOSE, VOLUME)
]
)
# Compare # Compare
assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-10) assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-10)
@@ -463,17 +469,21 @@ class TestStreamingVWAP:
streamer = StreamingVWAP() streamer = StreamingVWAP()
# First pass # First pass
first_pass = np.array([ 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]) 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 # Reset and second pass
streamer.reset() streamer.reset()
second_pass = np.array([ 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]) 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) assert np.allclose(first_pass, second_pass, equal_nan=True, atol=1e-14)
@@ -498,9 +508,7 @@ class TestStreamingSupertrend:
# Streaming # Streaming
streamer = StreamingSupertrend(period=period, multiplier=multiplier) streamer = StreamingSupertrend(period=period, multiplier=multiplier)
stream_results = [ stream_results = [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)]
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_line = np.array([r[0] for r in stream_results])
stream_dir = np.array([r[1] for r in stream_results]) stream_dir = np.array([r[1] for r in stream_results])
@@ -527,4 +535,6 @@ class TestStreamingSupertrend:
# Compare # Compare
for i in range(len(first_pass)): 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
)
+31 -15
View File
@@ -181,7 +181,9 @@ class TestBBANDSVsPandasTA:
pt_upper = pt_bbands[upper_col].to_numpy() pt_upper = pt_bbands[upper_col].to_numpy()
# Middle band (SMA) must be identical # 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 # Upper/lower: differ due to ddof=0 vs ddof=1
assert _allclose(ft_upper, pt_upper, atol=0.1) assert _allclose(ft_upper, pt_upper, atol=0.1)
assert _allclose(ft_lower, pt_lower, atol=0.1) assert _allclose(ft_lower, pt_lower, atol=0.1)
@@ -259,18 +261,15 @@ class TestSTOCHVsPandasTA:
close = ohlcv_500["close"] close = ohlcv_500["close"]
ft_slowk, ft_slowd = ferro_ta.STOCH( ft_slowk, ft_slowd = ferro_ta.STOCH(
high, low, close, high, low, close, fastk_period=14, slowk_period=3, slowd_period=3
fastk_period=14, slowk_period=3,
slowd_period=3
) )
# pandas-ta returns DataFrame # pandas-ta returns DataFrame
pt_stoch = pandas_ta.stoch( pt_stoch = pandas_ta.stoch(
pd.Series(high), pd.Series(low), pd.Series(close), pd.Series(high), pd.Series(low), pd.Series(close), k=14, d=3, smooth_k=3
k=14, d=3, smooth_k=3
) )
pt_slowk = pt_stoch[f"STOCHk_14_3_3"].to_numpy() pt_slowk = pt_stoch["STOCHk_14_3_3"].to_numpy()
pt_slowd = pt_stoch[f"STOCHd_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_slowk, pt_slowk, atol=1e-2, tail_fraction=0.3)
assert _allclose(ft_slowd, pt_slowd, 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)) # Compute CCI manually: (TP - SMA(TP)) / (0.015 * MeanAbsDev(TP))
tp = (pd.Series(high) + pd.Series(low) + pd.Series(close)) / 3.0 tp = (pd.Series(high) + pd.Series(low) + pd.Series(close)) / 3.0
mean_tp = tp.rolling(period).mean() 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() pt = ((tp - mean_tp) / (0.015 * mad_tp)).to_numpy()
assert _allclose(ft, pt, atol=1e-8) assert _allclose(ft, pt, atol=1e-8)
@@ -469,8 +470,8 @@ class TestVWAPVsPandasTA:
n = len(tp) n = len(tp)
ref = np.full(n, np.nan) ref = np.full(n, np.nan)
for i in range(period - 1, n): for i in range(period - 1, n):
w = tp[i - period + 1: i + 1] w = tp[i - period + 1 : i + 1]
v = vol[i - period + 1: i + 1] v = vol[i - period + 1 : i + 1]
ref[i] = np.dot(w, v) / v.sum() ref[i] = np.dot(w, v) / v.sum()
assert _allclose(ft, ref, atol=1e-8) assert _allclose(ft, ref, atol=1e-8)
@@ -522,7 +523,13 @@ class TestICHIMOKUVsPandasTA:
close = ohlcv_500["close"] close = ohlcv_500["close"]
ft_tenkan, ft_kijun, ft_senkou_a, ft_senkou_b, ft_chikou = ferro_ta.ICHIMOKU( 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}) df = pd.DataFrame({"high": high, "low": low, "close": close})
@@ -547,12 +554,19 @@ class TestKELTNER_CHANNELSVsPandasTA:
multiplier = 2.0 multiplier = 2.0
ft_upper, ft_middle, ft_lower = ferro_ta.KELTNER_CHANNELS( 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 # 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_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_upper = pt_ema + multiplier * pt_atr
pt_middle = pt_ema pt_middle = pt_ema
pt_lower = pt_ema - multiplier * pt_atr 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 # 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_high = pd.Series(high).rolling(period).max().to_numpy()
rolling_low = pd.Series(low).rolling(period).min().to_numpy() rolling_low = pd.Series(low).rolling(period).min().to_numpy()
pt_long = rolling_high - multiplier * pt_atr pt_long = rolling_high - multiplier * pt_atr
+10 -7
View File
@@ -190,9 +190,7 @@ class TestSTOCHVsTA:
close = ohlcv_500["close"] close = ohlcv_500["close"]
ft_slowk, ft_slowd = ferro_ta.STOCH( ft_slowk, ft_slowd = ferro_ta.STOCH(
high, low, close, high, low, close, fastk_period=14, slowk_period=3, slowd_period=3
fastk_period=14, slowk_period=3,
slowd_period=3
) )
# Values in valid region must be within [0, 100] # Values in valid region must be within [0, 100]
@@ -200,16 +198,21 @@ class TestSTOCHVsTA:
valid_d = ft_slowd[np.isfinite(ft_slowd)] valid_d = ft_slowd[np.isfinite(ft_slowd)]
assert len(valid_k) > 0, "STOCH slowk should have valid values" assert len(valid_k) > 0, "STOCH slowk should have valid values"
assert len(valid_d) > 0, "STOCH slowd 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]" "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]" "STOCH slowd must be in [0, 100]"
)
# Warm-up: TA-Lib STOCH NaN count = fastk_period + slowk_period - 1 # 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))) 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}" f"STOCH slowk NaN warmup: expected {expected_nan}, got {actual_nan_k}"
)
class TestWILLRVsTA: class TestWILLRVsTA:
+75 -28
View File
@@ -71,11 +71,11 @@ SIGN_AGREEMENT_THRESHOLD = 0.8
# use lower thresholds with a documented reason. # use lower thresholds with a documented reason.
CDL_AGREEMENT_THRESHOLDS: dict[str, float] = { CDL_AGREEMENT_THRESHOLDS: dict[str, float] = {
# Body/shadow ratio thresholds differ between ferro_ta and TA-Lib # 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 "CDLLONGLEGGEDDOJI": 0.70, # Long-leg threshold differs; 75% observed
"CDLSHORTLINE": 0.20, # Body-size cutoff definition completely differs; 25% observed "CDLSHORTLINE": 0.20, # Body-size cutoff definition completely differs; 25% observed
"CDLSPINNINGTOP": 0.75, # Body ratio threshold differs; 78% observed "CDLSPINNINGTOP": 0.75, # Body ratio threshold differs; 78% observed
"CDLDOJI": 0.85, # Shadow ratio precision differs; 86% observed "CDLDOJI": 0.85, # Shadow ratio precision differs; 86% observed
} }
@@ -150,7 +150,9 @@ class TestEMA:
ta = talib.EMA(CLOSE, timeperiod=5) ta = talib.EMA(CLOSE, timeperiod=5)
# With 500 bars, compare last 30% with tighter tolerance # With 500 bars, compare last 30% with tighter tolerance
tail_start = int(N * 0.7) 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): def test_values_finite_and_reasonable(self):
ft = ferro_ta.EMA(CLOSE, timeperiod=5) ft = ferro_ta.EMA(CLOSE, timeperiod=5)
@@ -266,7 +268,9 @@ class TestT3:
ta = talib.T3(CLOSE, timeperiod=5) ta = talib.T3(CLOSE, timeperiod=5)
# With 500 bars, use last 30% with tighter tolerance # With 500 bars, use last 30% with tighter tolerance
tail_start = int(N * 0.7) 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: class TestBBANDS:
@@ -781,7 +785,9 @@ class TestSTOCHRSI:
assert abs(_nan_count(ft_k) - _nan_count(ta_k)) <= 2 assert abs(_nan_count(ft_k) - _nan_count(ta_k)) <= 2
def test_range_0_to_100(self): 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)] finite = ft_k[~np.isnan(ft_k)]
# Allow small numerical tolerance for float boundaries # Allow small numerical tolerance for float boundaries
assert all(-1e-9 <= v <= 100.0 + 1e-9 for v in finite) assert all(-1e-9 <= v <= 100.0 + 1e-9 for v in finite)
@@ -834,6 +840,7 @@ class TestPPO:
mask = _valid_mask(ppo, ta) mask = _valid_mask(ppo, ta)
corr = np.corrcoef(ppo[mask], ta[mask])[0, 1] corr = np.corrcoef(ppo[mask], ta[mask])[0, 1]
assert corr > 0.85 assert corr > 0.85
"""CMO — same NaN count and shape; values may differ slightly. """CMO — same NaN count and shape; values may differ slightly.
Both libraries compute the Chande Momentum Oscillator as Both libraries compute the Chande Momentum Oscillator as
@@ -2034,9 +2041,7 @@ class TestHTTrendMode:
mask = _valid_mask(ft, ta) mask = _valid_mask(ft, ta)
if mask.sum() >= 5: if mask.sum() >= 5:
agree = np.mean(ft[mask] == ta[mask]) agree = np.mean(ft[mask] == ta[mask])
assert agree >= 0.50, ( assert agree >= 0.50, f"HT_TRENDMODE agreement {agree:.2f} < 0.50"
f"HT_TRENDMODE agreement {agree:.2f} < 0.50"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -2046,24 +2051,66 @@ class TestHTTrendMode:
# List of all candlestick patterns to test # List of all candlestick patterns to test
ALL_CDL_PATTERNS = [ ALL_CDL_PATTERNS = [
"CDL2CROWS", "CDL3BLACKCROWS", "CDL3INSIDE", "CDL3LINESTRIKE", "CDL2CROWS",
"CDL3OUTSIDE", "CDL3STARSINSOUTH", "CDL3WHITESOLDIERS", "CDL3BLACKCROWS",
"CDLABANDONEDBABY", "CDLADVANCEBLOCK", "CDLBELTHOLD", "CDLBREAKAWAY", "CDL3INSIDE",
"CDLCLOSINGMARUBOZU", "CDLCONCEALBABYSWALL", "CDLCOUNTERATTACK", "CDL3LINESTRIKE",
"CDLDARKCLOUDCOVER", "CDLDOJI", "CDLDOJISTAR", "CDLDRAGONFLYDOJI", "CDL3OUTSIDE",
"CDLENGULFING", "CDLEVENINGDOJISTAR", "CDLEVENINGSTAR", "CDL3STARSINSOUTH",
"CDLGAPSIDESIDEWHITE", "CDLGRAVESTONEDOJI", "CDLHAMMER", "CDL3WHITESOLDIERS",
"CDLHANGINGMAN", "CDLHARAMI", "CDLHARAMICROSS", "CDLHIGHWAVE", "CDLABANDONEDBABY",
"CDLHIKKAKE", "CDLHIKKAKEMOD", "CDLHOMINGPIGEON", "CDLADVANCEBLOCK",
"CDLIDENTICAL3CROWS", "CDLINNECK", "CDLINVERTEDHAMMER", "CDLBELTHOLD",
"CDLKICKING", "CDLKICKINGBYLENGTH", "CDLLADDERBOTTOM", "CDLBREAKAWAY",
"CDLLONGLEGGEDDOJI", "CDLLONGLINE", "CDLMARUBOZU", "CDLCLOSINGMARUBOZU",
"CDLMATCHINGLOW", "CDLMATHOLD", "CDLMORNINGDOJISTAR", "CDLCONCEALBABYSWALL",
"CDLMORNINGSTAR", "CDLONNECK", "CDLPIERCING", "CDLRICKSHAWMAN", "CDLCOUNTERATTACK",
"CDLRISEFALL3METHODS", "CDLSEPARATINGLINES", "CDLSHOOTINGSTAR", "CDLDARKCLOUDCOVER",
"CDLSHORTLINE", "CDLSPINNINGTOP", "CDLSTALLEDPATTERN", "CDLDOJI",
"CDLSTICKSANDWICH", "CDLTAKURI", "CDLTASUKIGAP", "CDLTHRUSTING", "CDLDOJISTAR",
"CDLTRISTAR", "CDLUNIQUE3RIVER", "CDLUPSIDEGAP2CROWS", "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", "CDLXSIDEGAP3METHODS",
] ]
+15 -3
View File
@@ -1,8 +1,14 @@
"""Unit tests for ferro_ta.indicators.cycle""" """Unit tests for ferro_ta.indicators.cycle"""
import numpy as np import numpy as np
import pytest
from ferro_ta.indicators.cycle import ( 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 N = 200
t = np.linspace(0, 10 * np.pi, N) 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): def _warmup_end(arr):
@@ -24,6 +30,7 @@ def _warmup_end(arr):
# HT_DCPERIOD # HT_DCPERIOD
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestHT_DCPERIOD: class TestHT_DCPERIOD:
def test_length(self): def test_length(self):
result = HT_DCPERIOD(SINE_CLOSE) result = HT_DCPERIOD(SINE_CLOSE)
@@ -53,6 +60,7 @@ class TestHT_DCPERIOD:
# HT_DCPHASE # HT_DCPHASE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestHT_DCPHASE: class TestHT_DCPHASE:
def test_length(self): def test_length(self):
assert len(HT_DCPHASE(SINE_CLOSE)) == N assert len(HT_DCPHASE(SINE_CLOSE)) == N
@@ -72,6 +80,7 @@ class TestHT_DCPHASE:
# HT_PHASOR # HT_PHASOR
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestHT_PHASOR: class TestHT_PHASOR:
def test_returns_two_arrays(self): def test_returns_two_arrays(self):
result = HT_PHASOR(SINE_CLOSE) result = HT_PHASOR(SINE_CLOSE)
@@ -98,6 +107,7 @@ class TestHT_PHASOR:
# HT_SINE # HT_SINE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestHT_SINE: class TestHT_SINE:
def test_returns_two_arrays(self): def test_returns_two_arrays(self):
result = HT_SINE(SINE_CLOSE) result = HT_SINE(SINE_CLOSE)
@@ -130,6 +140,7 @@ class TestHT_SINE:
# HT_TRENDLINE # HT_TRENDLINE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestHT_TRENDLINE: class TestHT_TRENDLINE:
def test_length(self): def test_length(self):
assert len(HT_TRENDLINE(SINE_CLOSE)) == N assert len(HT_TRENDLINE(SINE_CLOSE)) == N
@@ -157,6 +168,7 @@ class TestHT_TRENDLINE:
# HT_TRENDMODE # HT_TRENDMODE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestHT_TRENDMODE: class TestHT_TRENDMODE:
def test_length(self): def test_length(self):
assert len(HT_TRENDMODE(SINE_CLOSE)) == N assert len(HT_TRENDMODE(SINE_CLOSE)) == N
+27 -5
View File
@@ -1,9 +1,18 @@
"""Unit tests for ferro_ta.indicators.extended""" """Unit tests for ferro_ta.indicators.extended"""
import numpy as np import numpy as np
import pytest
from ferro_ta.indicators.extended import ( from ferro_ta.indicators.extended import (
VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS, CHANDELIER_EXIT,
KELTNER_CHANNELS, HULL_MA, CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX, CHOPPINESS_INDEX,
DONCHIAN,
HULL_MA,
ICHIMOKU,
KELTNER_CHANNELS,
PIVOT_POINTS,
SUPERTREND,
VWAP,
VWMA,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -23,6 +32,7 @@ _VOL = RNG.uniform(1000, 5000, N)
# VWAP # VWAP
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestVWAP: class TestVWAP:
def test_length(self): def test_length(self):
result = VWAP(_H, _L, _C, _VOL) result = VWAP(_H, _L, _C, _VOL)
@@ -46,6 +56,7 @@ class TestVWAP:
# SUPERTREND # SUPERTREND
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSUPERTREND: class TestSUPERTREND:
def test_returns_two_arrays(self): def test_returns_two_arrays(self):
result = SUPERTREND(_H, _L, _C) result = SUPERTREND(_H, _L, _C)
@@ -69,6 +80,7 @@ class TestSUPERTREND:
# ICHIMOKU # ICHIMOKU
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestICHIMOKU: class TestICHIMOKU:
def test_returns_five_arrays(self): def test_returns_five_arrays(self):
result = ICHIMOKU(_H, _L, _C) result = ICHIMOKU(_H, _L, _C)
@@ -80,7 +92,9 @@ class TestICHIMOKU:
assert len(arr) == N assert len(arr) == N
def test_tenkan_warmup(self): 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])) assert np.all(np.isnan(tenkan[:8]))
def test_finite_after_warmup(self): def test_finite_after_warmup(self):
@@ -94,6 +108,7 @@ class TestICHIMOKU:
# DONCHIAN # DONCHIAN
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDONCHIAN: class TestDONCHIAN:
def test_returns_three_arrays(self): def test_returns_three_arrays(self):
result = DONCHIAN(_H, _L) result = DONCHIAN(_H, _L)
@@ -126,6 +141,7 @@ class TestDONCHIAN:
# PIVOT_POINTS # PIVOT_POINTS
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestPIVOT_POINTS: class TestPIVOT_POINTS:
def test_returns_five_arrays(self): def test_returns_five_arrays(self):
result = PIVOT_POINTS(_H, _L, _C) result = PIVOT_POINTS(_H, _L, _C)
@@ -138,7 +154,7 @@ class TestPIVOT_POINTS:
def test_classic_pivot_formula(self): def test_classic_pivot_formula(self):
# PP = (H + L + C) / 3 # 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) valid = ~np.isnan(pp)
expected_pp = (_H[:-1] + _L[:-1] + _C[:-1]) / 3.0 expected_pp = (_H[:-1] + _L[:-1] + _C[:-1]) / 3.0
np.testing.assert_allclose(pp[valid], expected_pp[valid[1:]], rtol=1e-6) np.testing.assert_allclose(pp[valid], expected_pp[valid[1:]], rtol=1e-6)
@@ -152,6 +168,7 @@ class TestPIVOT_POINTS:
# KELTNER_CHANNELS # KELTNER_CHANNELS
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestKELTNER_CHANNELS: class TestKELTNER_CHANNELS:
def test_returns_three_arrays(self): def test_returns_three_arrays(self):
result = KELTNER_CHANNELS(_H, _L, _C) result = KELTNER_CHANNELS(_H, _L, _C)
@@ -175,6 +192,7 @@ class TestKELTNER_CHANNELS:
# HULL_MA # HULL_MA
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestHULL_MA: class TestHULL_MA:
def test_length(self): def test_length(self):
assert len(HULL_MA(_C, timeperiod=16)) == N assert len(HULL_MA(_C, timeperiod=16)) == N
@@ -199,6 +217,7 @@ class TestHULL_MA:
# CHANDELIER_EXIT # CHANDELIER_EXIT
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCHANDELIER_EXIT: class TestCHANDELIER_EXIT:
def test_returns_two_arrays(self): def test_returns_two_arrays(self):
result = CHANDELIER_EXIT(_H, _L, _C) result = CHANDELIER_EXIT(_H, _L, _C)
@@ -223,6 +242,7 @@ class TestCHANDELIER_EXIT:
# VWMA # VWMA
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestVWMA: class TestVWMA:
def test_length(self): def test_length(self):
assert len(VWMA(_C, _VOL, timeperiod=20)) == N assert len(VWMA(_C, _VOL, timeperiod=20)) == N
@@ -241,6 +261,7 @@ class TestVWMA:
vol = np.ones(N) * 1000.0 vol = np.ones(N) * 1000.0
vwma = VWMA(_C, vol, timeperiod=20) vwma = VWMA(_C, vol, timeperiod=20)
from ferro_ta.indicators.overlap import SMA from ferro_ta.indicators.overlap import SMA
sma = SMA(_C, timeperiod=20) sma = SMA(_C, timeperiod=20)
valid = ~np.isnan(vwma) & ~np.isnan(sma) valid = ~np.isnan(vwma) & ~np.isnan(sma)
np.testing.assert_allclose(vwma[valid], sma[valid], rtol=1e-8) np.testing.assert_allclose(vwma[valid], sma[valid], rtol=1e-8)
@@ -250,6 +271,7 @@ class TestVWMA:
# CHOPPINESS_INDEX # CHOPPINESS_INDEX
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCHOPPINESS_INDEX: class TestCHOPPINESS_INDEX:
def test_length(self): def test_length(self):
assert len(CHOPPINESS_INDEX(_H, _L, _C, timeperiod=14)) == N assert len(CHOPPINESS_INDEX(_H, _L, _C, timeperiod=14)) == N
+39 -6
View File
@@ -1,10 +1,32 @@
"""Unit tests for ferro_ta.indicators.math_ops""" """Unit tests for ferro_ta.indicators.math_ops"""
import numpy as np import numpy as np
import pytest
from ferro_ta.indicators.math_ops import ( from ferro_ta.indicators.math_ops import (
ADD, SUB, MULT, DIV, SUM, MAX, MIN, MAXINDEX, MININDEX, ACOS,
ACOS, ASIN, ATAN, CEIL, COS, COSH, EXP, FLOOR, ADD,
LN, LOG10, SIN, SINH, SQRT, TAN, TANH, 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]) A3 = np.array([1.0, 2.0, 3.0])
B3 = np.array([4.0, 5.0, 6.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]) 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) RNG = np.random.default_rng(17)
N = 100 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 # ADD
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestADD: class TestADD:
def test_known_values(self): def test_known_values(self):
result = ADD(A3, B3) result = ADD(A3, B3)
@@ -41,6 +64,7 @@ class TestADD:
# SUB # SUB
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSUB: class TestSUB:
def test_known_values(self): def test_known_values(self):
result = SUB(B3, A3) result = SUB(B3, A3)
@@ -54,6 +78,7 @@ class TestSUB:
# MULT # MULT
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMULT: class TestMULT:
def test_known_values(self): def test_known_values(self):
result = MULT(A3, B3) result = MULT(A3, B3)
@@ -70,6 +95,7 @@ class TestMULT:
# DIV # DIV
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDIV: class TestDIV:
def test_known_values(self): def test_known_values(self):
result = DIV(B3, A3) result = DIV(B3, A3)
@@ -86,6 +112,7 @@ class TestDIV:
# SUM # SUM
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSUM: class TestSUM:
def test_known_values(self): def test_known_values(self):
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
@@ -106,6 +133,7 @@ class TestSUM:
# MAX # MAX
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMAX: class TestMAX:
def test_known_values(self): def test_known_values(self):
arr = np.array([1.0, 3.0, 2.0, 5.0, 4.0]) arr = np.array([1.0, 3.0, 2.0, 5.0, 4.0])
@@ -127,6 +155,7 @@ class TestMAX:
# MIN # MIN
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMIN: class TestMIN:
def test_known_values(self): def test_known_values(self):
arr = np.array([5.0, 3.0, 4.0, 1.0, 2.0]) arr = np.array([5.0, 3.0, 4.0, 1.0, 2.0])
@@ -143,6 +172,7 @@ class TestMIN:
# MAXINDEX # MAXINDEX
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMAXINDEX: class TestMAXINDEX:
def test_known_values(self): def test_known_values(self):
arr = np.array([1.0, 5.0, 3.0, 2.0, 4.0]) arr = np.array([1.0, 5.0, 3.0, 2.0, 4.0])
@@ -162,6 +192,7 @@ class TestMAXINDEX:
# MININDEX # MININDEX
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMININDEX: class TestMININDEX:
def test_known_values(self): def test_known_values(self):
arr = np.array([5.0, 1.0, 3.0, 2.0, 4.0]) arr = np.array([5.0, 1.0, 3.0, 2.0, 4.0])
@@ -181,6 +212,7 @@ class TestMININDEX:
# Trig functions # Trig functions
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSIN: class TestSIN:
def test_known_values(self): def test_known_values(self):
angles = np.array([0.0, np.pi / 2, np.pi]) angles = np.array([0.0, np.pi / 2, np.pi])
@@ -236,6 +268,7 @@ class TestTANH:
# Rounding/exponential # Rounding/exponential
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCEIL: class TestCEIL:
def test_known_values(self): def test_known_values(self):
arr = np.array([1.1, 2.5, 3.9, -0.5]) arr = np.array([1.1, 2.5, 3.9, -0.5])
+54 -6
View File
@@ -1,12 +1,35 @@
"""Unit tests for ferro_ta.indicators.momentum""" """Unit tests for ferro_ta.indicators.momentum"""
import numpy as np import numpy as np
import pytest
from ferro_ta.indicators.momentum import ( from ferro_ta.indicators.momentum import (
RSI, STOCH, STOCHF, STOCHRSI, ADX,
ADX, ADXR, CCI, WILLR, AROON, AROONOSC, ADXR,
MFI, MOM, ROC, ROCP, ROCR, ROCR100, APO,
CMO, DX, MINUS_DI, MINUS_DM, PLUS_DI, PLUS_DM, AROON,
PPO, APO, TRIX, ULTOSC, BOP, 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 # RSI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestRSI: class TestRSI:
def test_nan_warmup(self): def test_nan_warmup(self):
result = RSI(_CLOSE, timeperiod=14) result = RSI(_CLOSE, timeperiod=14)
@@ -50,6 +74,7 @@ class TestRSI:
# STOCH # STOCH
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSTOCH: class TestSTOCH:
def test_returns_two_arrays(self): def test_returns_two_arrays(self):
result = STOCH(_HIGH, _LOW, _CLOSE) result = STOCH(_HIGH, _LOW, _CLOSE)
@@ -70,6 +95,7 @@ class TestSTOCH:
# STOCHF # STOCHF
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSTOCHF: class TestSTOCHF:
def test_returns_two_arrays(self): def test_returns_two_arrays(self):
result = STOCHF(_HIGH, _LOW, _CLOSE) result = STOCHF(_HIGH, _LOW, _CLOSE)
@@ -99,6 +125,7 @@ class TestSTOCHF:
# STOCHRSI # STOCHRSI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSTOCHRSI: class TestSTOCHRSI:
def test_returns_two_arrays(self): def test_returns_two_arrays(self):
result = STOCHRSI(_CLOSE) result = STOCHRSI(_CLOSE)
@@ -119,6 +146,7 @@ class TestSTOCHRSI:
# ADX # ADX
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestADX: class TestADX:
def test_nan_warmup(self): def test_nan_warmup(self):
result = ADX(_HIGH, _LOW, _CLOSE, timeperiod=14) result = ADX(_HIGH, _LOW, _CLOSE, timeperiod=14)
@@ -137,6 +165,7 @@ class TestADX:
# ADXR # ADXR
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestADXR: class TestADXR:
def test_length(self): def test_length(self):
assert len(ADXR(_HIGH, _LOW, _CLOSE, 14)) == N assert len(ADXR(_HIGH, _LOW, _CLOSE, 14)) == N
@@ -151,6 +180,7 @@ class TestADXR:
# CCI # CCI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCCI: class TestCCI:
def test_known_constant_mean_dev(self): def test_known_constant_mean_dev(self):
# Constant typical price → CCI = 0 after warmup # Constant typical price → CCI = 0 after warmup
@@ -181,6 +211,7 @@ class TestCCI:
# WILLR # WILLR
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestWILLR: class TestWILLR:
def test_range(self): def test_range(self):
result = WILLR(_HIGH, _LOW, _CLOSE, 14) result = WILLR(_HIGH, _LOW, _CLOSE, 14)
@@ -195,6 +226,7 @@ class TestWILLR:
# AROON # AROON
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAROON: class TestAROON:
def test_returns_two_arrays(self): def test_returns_two_arrays(self):
result = AROON(_HIGH, _LOW, 14) result = AROON(_HIGH, _LOW, 14)
@@ -215,6 +247,7 @@ class TestAROON:
# AROONOSC # AROONOSC
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAROONOSC: class TestAROONOSC:
def test_known_values(self): def test_known_values(self):
h = np.array([12.0, 13.0, 14.0, 15.0, 16.0]) h = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
@@ -242,6 +275,7 @@ class TestAROONOSC:
# MFI # MFI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMFI: class TestMFI:
def test_range(self): def test_range(self):
result = MFI(_HIGH, _LOW, _CLOSE, _VOL, 14) result = MFI(_HIGH, _LOW, _CLOSE, _VOL, 14)
@@ -271,6 +305,7 @@ class TestMFI:
# MOM # MOM
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMOM: class TestMOM:
def test_known_values(self): def test_known_values(self):
result = MOM(SMALL5, timeperiod=2) result = MOM(SMALL5, timeperiod=2)
@@ -286,6 +321,7 @@ class TestMOM:
# ROC # ROC
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestROC: class TestROC:
def test_known_values(self): def test_known_values(self):
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
@@ -301,6 +337,7 @@ class TestROC:
# ROCP # ROCP
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestROCP: class TestROCP:
def test_known_values(self): def test_known_values(self):
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
@@ -316,6 +353,7 @@ class TestROCP:
# ROCR # ROCR
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestROCR: class TestROCR:
def test_known_values(self): def test_known_values(self):
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
@@ -336,6 +374,7 @@ class TestROCR:
# ROCR100 # ROCR100
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestROCR100: class TestROCR100:
def test_known_values(self): def test_known_values(self):
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
@@ -357,6 +396,7 @@ class TestROCR100:
# CMO # CMO
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCMO: class TestCMO:
def test_range(self): def test_range(self):
result = CMO(_CLOSE, 14) result = CMO(_CLOSE, 14)
@@ -371,6 +411,7 @@ class TestCMO:
# DX # DX
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDX: class TestDX:
def test_range(self): def test_range(self):
result = DX(_HIGH, _LOW, _CLOSE, 14) result = DX(_HIGH, _LOW, _CLOSE, 14)
@@ -385,6 +426,7 @@ class TestDX:
# MINUS_DI / MINUS_DM # MINUS_DI / MINUS_DM
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMINUS: class TestMINUS:
def test_minus_di_range(self): def test_minus_di_range(self):
result = MINUS_DI(_HIGH, _LOW, _CLOSE, 14) result = MINUS_DI(_HIGH, _LOW, _CLOSE, 14)
@@ -405,6 +447,7 @@ class TestMINUS:
# PLUS_DI / PLUS_DM # PLUS_DI / PLUS_DM
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestPLUS: class TestPLUS:
def test_plus_di_range(self): def test_plus_di_range(self):
result = PLUS_DI(_HIGH, _LOW, _CLOSE, 14) result = PLUS_DI(_HIGH, _LOW, _CLOSE, 14)
@@ -425,6 +468,7 @@ class TestPLUS:
# PPO # PPO
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestPPO: class TestPPO:
def test_returns_three_arrays(self): def test_returns_three_arrays(self):
result = PPO(_CLOSE, fastperiod=12, slowperiod=26) result = PPO(_CLOSE, fastperiod=12, slowperiod=26)
@@ -448,6 +492,7 @@ class TestPPO:
# APO # APO
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAPO: class TestAPO:
def test_known_direction(self): def test_known_direction(self):
# Rising close → fast EMA > slow EMA → APO > 0 after warmup # Rising close → fast EMA > slow EMA → APO > 0 after warmup
@@ -468,6 +513,7 @@ class TestAPO:
# TRIX # TRIX
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestTRIX: class TestTRIX:
def test_length(self): def test_length(self):
assert len(TRIX(_CLOSE, 10)) == N assert len(TRIX(_CLOSE, 10)) == N
@@ -494,6 +540,7 @@ class TestTRIX:
# BOP # BOP
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestBOP: class TestBOP:
def test_known_values(self): def test_known_values(self):
o = np.array([10.0, 11.0]) o = np.array([10.0, 11.0])
@@ -526,6 +573,7 @@ class TestBOP:
# ULTOSC # ULTOSC
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestULTOSC: class TestULTOSC:
def test_range(self): def test_range(self):
result = ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28) result = ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28)
+43 -7
View File
@@ -1,10 +1,27 @@
"""Unit tests for ferro_ta.indicators.overlap""" """Unit tests for ferro_ta.indicators.overlap"""
import numpy as np import numpy as np
import pytest
from ferro_ta.indicators.overlap import ( from ferro_ta.indicators.overlap import (
SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, MA, BBANDS,
MACD, MACDFIX, MACDEXT, BBANDS, SAR, SAREXT, DEMA,
MAMA, MAVP, MIDPOINT, MIDPRICE, 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 # SMA
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSMA: class TestSMA:
def test_known_values(self): def test_known_values(self):
result = SMA(SMALL5, timeperiod=3) result = SMA(SMALL5, timeperiod=3)
@@ -50,6 +68,7 @@ class TestSMA:
# EMA # EMA
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestEMA: class TestEMA:
def test_known_values(self): def test_known_values(self):
# k = 2/(3+1) = 0.5; seed = SMA(3) = 11.0 # k = 2/(3+1) = 0.5; seed = SMA(3) = 11.0
@@ -80,13 +99,14 @@ class TestEMA:
# WMA # WMA
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestWMA: class TestWMA:
def test_known_values(self): def test_known_values(self):
arr = np.arange(1.0, 6.0) arr = np.arange(1.0, 6.0)
result = WMA(arr, timeperiod=3) result = WMA(arr, timeperiod=3)
# weights 1,2,3 / 6 # weights 1,2,3 / 6
expected_2 = (1*1 + 2*2 + 3*3) / 6.0 # 14/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_3 = (1 * 2 + 2 * 3 + 3 * 4) / 6.0 # 20/6
assert np.isnan(result[0]) and np.isnan(result[1]) 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[2], expected_2, rtol=1e-10)
np.testing.assert_allclose(result[3], expected_3, rtol=1e-10) np.testing.assert_allclose(result[3], expected_3, rtol=1e-10)
@@ -103,10 +123,11 @@ class TestWMA:
# DEMA # DEMA
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDEMA: class TestDEMA:
def test_nan_warmup(self): def test_nan_warmup(self):
result = DEMA(_CLOSE, timeperiod=5) 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): def test_length(self):
assert len(DEMA(_CLOSE, 5)) == N assert len(DEMA(_CLOSE, 5)) == N
@@ -131,6 +152,7 @@ class TestDEMA:
# TEMA # TEMA
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestTEMA: class TestTEMA:
def test_nan_warmup(self): def test_nan_warmup(self):
result = TEMA(_CLOSE, timeperiod=5) result = TEMA(_CLOSE, timeperiod=5)
@@ -150,6 +172,7 @@ class TestTEMA:
# TRIMA # TRIMA
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestTRIMA: class TestTRIMA:
def test_known_values(self): def test_known_values(self):
arr = np.arange(1.0, 11.0) arr = np.arange(1.0, 11.0)
@@ -171,6 +194,7 @@ class TestTRIMA:
# KAMA # KAMA
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestKAMA: class TestKAMA:
def test_nan_warmup(self): def test_nan_warmup(self):
result = KAMA(_CLOSE, timeperiod=10) result = KAMA(_CLOSE, timeperiod=10)
@@ -195,6 +219,7 @@ class TestKAMA:
# T3 # T3
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestT3: class TestT3:
def test_nan_warmup(self): def test_nan_warmup(self):
arr = np.linspace(10.0, 30.0, 100) arr = np.linspace(10.0, 30.0, 100)
@@ -223,6 +248,7 @@ class TestT3:
# MA # MA
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMA: class TestMA:
def test_default_is_sma(self): def test_default_is_sma(self):
result_ma = MA(_CLOSE, timeperiod=10, matype=0) result_ma = MA(_CLOSE, timeperiod=10, matype=0)
@@ -242,6 +268,7 @@ class TestMA:
# MACD # MACD
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMACD: class TestMACD:
def test_returns_three_arrays(self): def test_returns_three_arrays(self):
result = MACD(_CLOSE, 12, 26, 9) result = MACD(_CLOSE, 12, 26, 9)
@@ -266,6 +293,7 @@ class TestMACD:
# MACDFIX # MACDFIX
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMACDFIX: class TestMACDFIX:
def test_returns_three_arrays(self): def test_returns_three_arrays(self):
result = MACDFIX(_CLOSE) result = MACDFIX(_CLOSE)
@@ -285,6 +313,7 @@ class TestMACDFIX:
# MACDEXT # MACDEXT
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMACDEXT: class TestMACDEXT:
def test_returns_three_arrays(self): def test_returns_three_arrays(self):
result = MACDEXT(_CLOSE) result = MACDEXT(_CLOSE)
@@ -303,6 +332,7 @@ class TestMACDEXT:
# BBANDS # BBANDS
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestBBANDS: class TestBBANDS:
def test_returns_three_arrays(self): def test_returns_three_arrays(self):
result = BBANDS(_CLOSE, 20) result = BBANDS(_CLOSE, 20)
@@ -331,6 +361,7 @@ class TestBBANDS:
# SAR # SAR
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSAR: class TestSAR:
def test_length(self): def test_length(self):
result = SAR(_HIGH, _LOW) result = SAR(_HIGH, _LOW)
@@ -349,6 +380,7 @@ class TestSAR:
# SAREXT # SAREXT
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSAREXT: class TestSAREXT:
def test_length(self): def test_length(self):
result = SAREXT(_HIGH, _LOW) result = SAREXT(_HIGH, _LOW)
@@ -367,6 +399,7 @@ class TestSAREXT:
# MAMA # MAMA
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMAMA: class TestMAMA:
def test_returns_two_arrays(self): def test_returns_two_arrays(self):
result = MAMA(_CLOSE) result = MAMA(_CLOSE)
@@ -393,6 +426,7 @@ class TestMAMA:
# MAVP # MAVP
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMAVP: class TestMAVP:
def test_length(self): def test_length(self):
arr = np.linspace(10.0, 30.0, 50) arr = np.linspace(10.0, 30.0, 50)
@@ -412,6 +446,7 @@ class TestMAVP:
# MIDPOINT # MIDPOINT
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMIDPOINT: class TestMIDPOINT:
def test_known_values(self): def test_known_values(self):
arr = np.array([10.0, 12.0, 14.0, 16.0, 18.0]) arr = np.array([10.0, 12.0, 14.0, 16.0, 18.0])
@@ -433,6 +468,7 @@ class TestMIDPOINT:
# MIDPRICE # MIDPRICE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMIDPRICE: class TestMIDPRICE:
def test_known_values(self): def test_known_values(self):
result = MIDPRICE(SMALL5_HIGH, SMALL5_LOW, timeperiod=3) result = MIDPRICE(SMALL5_HIGH, SMALL5_LOW, timeperiod=3)
+67 -14
View File
@@ -1,20 +1,70 @@
"""Unit tests for ferro_ta.indicators.pattern (CDL* functions)""" """Unit tests for ferro_ta.indicators.pattern (CDL* functions)"""
import numpy as np import numpy as np
import pytest import pytest
from ferro_ta.indicators.pattern import ( from ferro_ta.indicators.pattern import (
CDL2CROWS, CDL3BLACKCROWS, CDL3INSIDE, CDL3LINESTRIKE, CDL3OUTSIDE, CDL2CROWS,
CDL3STARSINSOUTH, CDL3WHITESOLDIERS, CDLABANDONEDBABY, CDLADVANCEBLOCK, CDL3BLACKCROWS,
CDLBELTHOLD, CDLBREAKAWAY, CDLCLOSINGMARUBOZU, CDLCONCEALBABYSWALL, CDL3INSIDE,
CDLCOUNTERATTACK, CDLDARKCLOUDCOVER, CDLDOJI, CDLDOJISTAR, CDLDRAGONFLYDOJI, CDL3LINESTRIKE,
CDLENGULFING, CDLEVENINGDOJISTAR, CDLEVENINGSTAR, CDLGAPSIDESIDEWHITE, CDL3OUTSIDE,
CDLGRAVESTONEDOJI, CDLHAMMER, CDLHANGINGMAN, CDLHARAMI, CDLHARAMICROSS, CDL3STARSINSOUTH,
CDLHIGHWAVE, CDLHIKKAKE, CDLHIKKAKEMOD, CDLHOMINGPIGEON, CDLIDENTICAL3CROWS, CDL3WHITESOLDIERS,
CDLINNECK, CDLINVERTEDHAMMER, CDLKICKING, CDLKICKINGBYLENGTH, CDLLADDERBOTTOM, CDLABANDONEDBABY,
CDLLONGLEGGEDDOJI, CDLLONGLINE, CDLMARUBOZU, CDLMATCHINGLOW, CDLMATHOLD, CDLADVANCEBLOCK,
CDLMORNINGDOJISTAR, CDLMORNINGSTAR, CDLONNECK, CDLPIERCING, CDLRICKSHAWMAN, CDLBELTHOLD,
CDLRISEFALL3METHODS, CDLSEPARATINGLINES, CDLSHOOTINGSTAR, CDLSHORTLINE, CDLBREAKAWAY,
CDLSPINNINGTOP, CDLSTALLEDPATTERN, CDLSTICKSANDWICH, CDLTAKURI, CDLTASUKIGAP, CDLCLOSINGMARUBOZU,
CDLTHRUSTING, CDLTRISTAR, CDLUNIQUE3RIVER, CDLUPSIDEGAP2CROWS, CDLXSIDEGAP3METHODS, 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 # Parametrised tests: all CDL patterns
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.parametrize("name,fn", ALL_CDL) @pytest.mark.parametrize("name,fn", ALL_CDL)
def test_cdl_output_length(name, fn): def test_cdl_output_length(name, fn):
result = fn(_O, _H, _L, _C) result = fn(_O, _H, _L, _C)
@@ -107,8 +158,9 @@ def test_cdl_output_length(name, fn):
@pytest.mark.parametrize("name,fn", ALL_CDL) @pytest.mark.parametrize("name,fn", ALL_CDL)
def test_cdl_values_in_valid_set(name, fn): def test_cdl_values_in_valid_set(name, fn):
result = fn(_O, _H, _L, _C) 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)}" f"{name}: unexpected values {np.unique(result)}"
)
@pytest.mark.parametrize("name,fn", ALL_CDL) @pytest.mark.parametrize("name,fn", ALL_CDL)
@@ -121,6 +173,7 @@ def test_cdl_no_nan(name, fn):
# Specific tests for previously untested patterns # Specific tests for previously untested patterns
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCDLSPINNINGTOP: class TestCDLSPINNINGTOP:
def test_detects_pattern(self): def test_detects_pattern(self):
# Spinning top: small body, long upper and lower shadows # Spinning top: small body, long upper and lower shadows
@@ -1,6 +1,7 @@
"""Unit tests for ferro_ta.indicators.price_transform""" """Unit tests for ferro_ta.indicators.price_transform"""
import numpy as np import numpy as np
import pytest
from ferro_ta.indicators.price_transform import AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE 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 # AVGPRICE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAVGPRICE: class TestAVGPRICE:
def test_known_formula(self): def test_known_formula(self):
result = AVGPRICE(O, H, L, C) result = AVGPRICE(O, H, L, C)
@@ -39,6 +41,7 @@ class TestAVGPRICE:
# MEDPRICE # MEDPRICE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMEDPRICE: class TestMEDPRICE:
def test_known_formula(self): def test_known_formula(self):
result = MEDPRICE(H, L) result = MEDPRICE(H, L)
@@ -61,6 +64,7 @@ class TestMEDPRICE:
# TYPPRICE # TYPPRICE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestTYPPRICE: class TestTYPPRICE:
def test_known_formula(self): def test_known_formula(self):
result = TYPPRICE(H, L, C) result = TYPPRICE(H, L, C)
@@ -83,6 +87,7 @@ class TestTYPPRICE:
# WCLPRICE # WCLPRICE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestWCLPRICE: class TestWCLPRICE:
def test_known_formula(self): def test_known_formula(self):
result = WCLPRICE(H, L, C) result = WCLPRICE(H, L, C)
@@ -99,7 +104,6 @@ class TestWCLPRICE:
def test_close_weight_double(self): def test_close_weight_double(self):
# WCLPRICE weights close twice vs TYPPRICE # WCLPRICE weights close twice vs TYPPRICE
typ = TYPPRICE(H, L, C)
wcl = WCLPRICE(H, L, C) wcl = WCLPRICE(H, L, C)
# On a rising series (H > L > 0), WCLPRICE > TYPPRICE when C > (H+L)/2 # On a rising series (H > L > 0), WCLPRICE > TYPPRICE when C > (H+L)/2
# Just verify formula correctness already done above # Just verify formula correctness already done above
+21 -5
View File
@@ -1,10 +1,17 @@
"""Unit tests for ferro_ta.indicators.statistic""" """Unit tests for ferro_ta.indicators.statistic"""
import numpy as np import numpy as np
import pytest
from ferro_ta.indicators.statistic import ( from ferro_ta.indicators.statistic import (
STDDEV, VAR, BETA, CORREL, BETA,
LINEARREG, LINEARREG_ANGLE, LINEARREG_INTERCEPT, LINEARREG_SLOPE, CORREL,
LINEARREG,
LINEARREG_ANGLE,
LINEARREG_INTERCEPT,
LINEARREG_SLOPE,
STDDEV,
TSF, TSF,
VAR,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -16,14 +23,15 @@ N = 100
_A = 100 + np.cumsum(RNG.normal(0, 0.5, N)) _A = 100 + np.cumsum(RNG.normal(0, 0.5, N))
_B = 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] LINDATA = np.arange(1.0, 6.0) # [1,2,3,4,5]
CONSTDATA = np.ones(10) # all 1.0 CONSTDATA = np.ones(10) # all 1.0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# STDDEV # STDDEV
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSTDDEV: class TestSTDDEV:
def test_constant_is_zero(self): def test_constant_is_zero(self):
result = STDDEV(CONSTDATA, timeperiod=5) result = STDDEV(CONSTDATA, timeperiod=5)
@@ -52,6 +60,7 @@ class TestSTDDEV:
# VAR # VAR
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestVAR: class TestVAR:
def test_constant_is_zero(self): def test_constant_is_zero(self):
result = VAR(CONSTDATA, timeperiod=5) result = VAR(CONSTDATA, timeperiod=5)
@@ -77,6 +86,7 @@ class TestVAR:
# LINEARREG # LINEARREG
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestLINEARREG: class TestLINEARREG:
def test_perfect_line(self): def test_perfect_line(self):
# For [1,2,3,4,5] over window 5, forecast = 5.0 # For [1,2,3,4,5] over window 5, forecast = 5.0
@@ -95,6 +105,7 @@ class TestLINEARREG:
# LINEARREG_SLOPE # LINEARREG_SLOPE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestLINEARREG_SLOPE: class TestLINEARREG_SLOPE:
def test_perfect_line_slope_one(self): def test_perfect_line_slope_one(self):
result = LINEARREG_SLOPE(LINDATA, timeperiod=5) result = LINEARREG_SLOPE(LINDATA, timeperiod=5)
@@ -113,6 +124,7 @@ class TestLINEARREG_SLOPE:
# LINEARREG_INTERCEPT # LINEARREG_INTERCEPT
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestLINEARREG_INTERCEPT: class TestLINEARREG_INTERCEPT:
def test_perfect_line_intercept_one(self): 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 # 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 # LINEARREG_ANGLE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestLINEARREG_ANGLE: class TestLINEARREG_ANGLE:
def test_slope_one_gives_45_degrees(self): def test_slope_one_gives_45_degrees(self):
result = LINEARREG_ANGLE(LINDATA, timeperiod=5) result = LINEARREG_ANGLE(LINDATA, timeperiod=5)
@@ -146,6 +159,7 @@ class TestLINEARREG_ANGLE:
# BETA # BETA
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestBETA: class TestBETA:
def test_nan_warmup(self): def test_nan_warmup(self):
result = BETA(_A, _B, timeperiod=5) result = BETA(_A, _B, timeperiod=5)
@@ -170,6 +184,7 @@ class TestBETA:
# CORREL # CORREL
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCOREL: class TestCOREL:
def test_self_correlation_is_one(self): def test_self_correlation_is_one(self):
result = CORREL(_A, _A, timeperiod=10) result = CORREL(_A, _A, timeperiod=10)
@@ -195,6 +210,7 @@ class TestCOREL:
# TSF # TSF
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestTSF: class TestTSF:
def test_perfect_line(self): def test_perfect_line(self):
arr = np.arange(1.0, 10.0) arr = np.arange(1.0, 10.0)
+5 -1
View File
@@ -1,6 +1,7 @@
"""Unit tests for ferro_ta.indicators.volatility""" """Unit tests for ferro_ta.indicators.volatility"""
import numpy as np import numpy as np
import pytest
from ferro_ta.indicators.volatility import ATR, NATR, TRANGE 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 # TRANGE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestTRANGE: class TestTRANGE:
def test_known_values_constant_range(self): def test_known_values_constant_range(self):
result = TRANGE(SMALL_H, SMALL_L, SMALL_C) result = TRANGE(SMALL_H, SMALL_L, SMALL_C)
@@ -66,6 +68,7 @@ class TestTRANGE:
# ATR # ATR
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestATR: class TestATR:
def test_timeperiod_1_equals_trange(self): def test_timeperiod_1_equals_trange(self):
atr = ATR(SMALL_H, SMALL_L, SMALL_C, timeperiod=1) atr = ATR(SMALL_H, SMALL_L, SMALL_C, timeperiod=1)
@@ -99,6 +102,7 @@ class TestATR:
# NATR # NATR
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestNATR: class TestNATR:
def test_nan_warmup(self): def test_nan_warmup(self):
result = NATR(_HIGH, _LOW, _CLOSE, timeperiod=14) result = NATR(_HIGH, _LOW, _CLOSE, timeperiod=14)
+6 -2
View File
@@ -1,6 +1,7 @@
"""Unit tests for ferro_ta.indicators.volume""" """Unit tests for ferro_ta.indicators.volume"""
import numpy as np import numpy as np
import pytest
from ferro_ta.indicators.volume import AD, ADOSC, OBV 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 # OBV
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestOBV: class TestOBV:
def test_known_values_rising(self): def test_known_values_rising(self):
# Rising close: OBV accumulates all volume # Rising close: OBV accumulates all volume
@@ -64,6 +66,7 @@ class TestOBV:
# AD # AD
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAD: class TestAD:
def test_known_formula(self): def test_known_formula(self):
# AD = cumsum(CLV * volume) # AD = cumsum(CLV * volume)
@@ -72,7 +75,7 @@ class TestAD:
l = np.array([10.0]) l = np.array([10.0])
c = np.array([12.0]) c = np.array([12.0])
v = np.array([1000.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 expected = clv * 1000.0
result = AD(h, l, c, v) result = AD(h, l, c, v)
np.testing.assert_allclose(result[0], expected, rtol=1e-10) np.testing.assert_allclose(result[0], expected, rtol=1e-10)
@@ -94,6 +97,7 @@ class TestAD:
# ADOSC # ADOSC
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestADOSC: class TestADOSC:
def test_nan_warmup(self): def test_nan_warmup(self):
result = ADOSC(_HIGH, _LOW, _CLOSE, _VOL, fastperiod=3, slowperiod=10) result = ADOSC(_HIGH, _LOW, _CLOSE, _VOL, fastperiod=3, slowperiod=10)
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -640,13 +640,17 @@ class TestVersionConsistency:
def test_release_md_exists(self): def test_release_md_exists(self):
"""RELEASE.md must exist in the repository root.""" """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") release_md = os.path.join(root, "RELEASE.md")
assert os.path.isfile(release_md), "RELEASE.md not found" assert os.path.isfile(release_md), "RELEASE.md not found"
def test_release_md_has_key_sections(self): def test_release_md_has_key_sections(self):
"""RELEASE.md must mention tagging and PyPI.""" """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") release_md = os.path.join(root, "RELEASE.md")
if not os.path.isfile(release_md): if not os.path.isfile(release_md):
pytest.skip("RELEASE.md not found") pytest.skip("RELEASE.md not found")
+72 -19
View File
@@ -11,7 +11,6 @@ All tests use NO optional dependencies - they run in every CI environment.
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
import pytest
import ferro_ta import ferro_ta
@@ -81,7 +80,7 @@ class TestEMAKnownValues:
# After warmup, EMA should be strictly increasing # After warmup, EMA should be strictly increasing
for i in range(20, len(result) - 1): for i in range(20, len(result) - 1):
assert result[i + 1] > result[i], ( 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)] valid_values = result[~np.isnan(result)]
if len(valid_values) > 0: if len(valid_values) > 0:
# Should be either NaN everywhere or 100 everywhere # 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), ( assert np.all(np.abs(valid_values - 100.0) < 1e-10) or np.all(
"RSI of constant series should be 100 (no down movement) or close to 50" 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 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 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 # 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, high = np.array(
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, 11.0,
19.0, 20.0, 21.0, 22.0, 23.0]) 13.0,
close = np.array([10.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 14.0,
21.0, 22.0, 23.0, 24.0, 25.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 # For period=1, ATR still has warmup. Use TRANGE to check TR values directly
tr = ferro_ta.TRANGE(high, low, close) tr = ferro_ta.TRANGE(high, low, close)
@@ -263,7 +313,7 @@ class TestMOMKnownValues:
assert np.isnan(result[0]) assert np.isnan(result[0])
assert np.isnan(result[1]) 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 assert np.abs(result[3] - (-1.0)) < 1e-10 # 11 - 12 = -1
@@ -295,7 +345,9 @@ class TestMACDKnownValues:
def test_macd_histogram_identity(self): def test_macd_histogram_identity(self):
"""histogram should always equal macd - signal.""" """histogram should always equal macd - signal."""
data = np.arange(1.0, 51.0) 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) # histogram = macd - signal (within floating-point tolerance)
expected_histogram = macd - signal expected_histogram = macd - signal
@@ -338,9 +390,9 @@ class TestVWAPKnownValues:
expected_0 = typ[0] expected_0 = typ[0]
expected_1 = (typ[0] * volume[0] + typ[1] * volume[1]) / (volume[0] + volume[1]) expected_1 = (typ[0] * volume[0] + typ[1] * volume[1]) / (volume[0] + volume[1])
expected_2 = ( expected_2 = (typ[0] * volume[0] + typ[1] * volume[1] + typ[2] * volume[2]) / (
typ[0] * volume[0] + typ[1] * volume[1] + typ[2] * volume[2] volume[0] + volume[1] + volume[2]
) / (volume[0] + volume[1] + volume[2]) )
assert np.abs(result[0] - expected_0) < 1e-10 assert np.abs(result[0] - expected_0) < 1e-10
assert np.abs(result[1] - expected_1) < 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.""" """upper == MAX(high), lower == MIN(low), middle == (upper+lower)/2."""
high = np.array([11.0, 13.0, 14.0, 12.0, 15.0]) 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]) 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 period = 3
upper, middle, lower = ferro_ta.DONCHIAN(high, low, timeperiod=period) upper, middle, lower = ferro_ta.DONCHIAN(high, low, timeperiod=period)
@@ -394,7 +445,9 @@ class TestPIVOT_POINTSKnownValues:
low = np.array([90.0, 90.0]) low = np.array([90.0, 90.0])
close = np.array([100.0, 100.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 # Check last bar (index 1) which has full history
# P = (110 + 90 + 100) / 3 = 100 # P = (110 + 90 + 100) / 3 = 100
@@ -458,7 +511,6 @@ class TestPatternKnownValues:
def test_doji_known_sequence(self): def test_doji_known_sequence(self):
"""Construct a perfect doji: open == close, small body.""" """Construct a perfect doji: open == close, small body."""
# Doji: open == close (or very close), H and L have range # 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]) 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]) 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]) close = np.array([10.0, 10.0, 10.0, 10.0, 10.0])
@@ -507,3 +559,4 @@ class TestPatternKnownValues:
# Last bar has hammer characteristics # Last bar has hammer characteristics
# (actual detection may vary based on implementation) # (actual detection may vary based on implementation)
assert result.shape == close.shape
+3 -1
View File
@@ -655,7 +655,9 @@ class TestWebAPI:
import sys import sys
# Insert project root so that `api.main` is importable # 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: if project_root not in sys.path:
sys.path.insert(0, project_root) sys.path.insert(0, project_root)
try: try:
Generated
+858 -88
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -33,9 +33,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.56" version = "1.2.57"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -49,7 +49,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "ferro_ta_wasm" name = "ferro_ta_wasm"
version = "0.1.0" version = "1.0.0"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -88,9 +88,9 @@ dependencies = [
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.17" version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
@@ -145,9 +145,9 @@ dependencies = [
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "oorandom" name = "oorandom"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ferro_ta_wasm" name = "ferro_ta_wasm"
version = "0.1.0" version = "1.0.0"
edition = "2021" edition = "2021"
description = "WebAssembly bindings for ferro-ta technical analysis indicators" description = "WebAssembly bindings for ferro-ta technical analysis indicators"
license = "MIT" license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ferro-ta-wasm", "name": "ferro-ta-wasm",
"version": "0.1.0", "version": "1.0.0",
"description": "WebAssembly bindings for ferro-ta technical analysis indicators", "description": "WebAssembly bindings for ferro-ta technical analysis indicators",
"main": "pkg/ferro_ta_wasm.js", "main": "pkg/ferro_ta_wasm.js",
"types": "pkg/ferro_ta_wasm.d.ts", "types": "pkg/ferro_ta_wasm.d.ts",