7.4 KiB
Rust-First Architecture Policy
Rule: All non-trivial computation and processing logic MUST be implemented in Rust and exposed to Python via PyO3. Python is the interface layer only.
Rationale
ferro-ta is built on the insight that Python is excellent as a glue layer (validation, type dispatch, pandas/polars wrapping) but poor as a compute engine (GIL, interpreter overhead, per-call allocation). Every Python loop over data is a performance regression.
This policy formalises what the codebase already does for standard TA-Lib indicators and extends it to all new and existing indicators.
The Boundary
Python layer (thin) Rust layer (thick)
───────────────────────────── ────────────────────────────────────
ferro_ta/overlap.py ────▶ src/overlap/mod.rs
ferro_ta/momentum.py ────▶ src/momentum/mod.rs
ferro_ta/streaming.py ────▶ src/streaming/mod.rs (PyO3 classes)
ferro_ta/extended.py ────▶ src/extended/mod.rs
ferro_ta/math_ops.py ────▶ src/math_ops/mod.rs
ferro_ta/batch.py ────▶ src/batch/mod.rs
ferro_ta/pattern.py ────▶ src/pattern/mod.rs
... ────▶ ...
Python layer responsibilities (ONLY):
- Input validation (
check_equal_length,check_timeperiod) _to_f64()conversion (already has fast path for contiguous float64)- pandas/polars wrapping (via
pandas_wrap/polars_wrapdecorators) - Re-exporting and documentation
Rust layer responsibilities (EVERYTHING ELSE):
- All loops over data
- All rolling window computations
- All stateful streaming state machines
- All mathematical transformations applied bar-by-bar
- All batch operations
Implementation Rules
Rule 1: New indicators go in Rust first
When adding a new indicator:
- Implement the algorithm in
src/<category>/mod.rs(or a new category module if the category does not exist). - Register the function in
src/lib.rsvia<category>::register(m)?. - Write a thin Python wrapper in
python/ferro_ta/<category>.pythat:- Validates inputs
- Calls
_to_f64()on array arguments - Calls the Rust function
- Wraps the result for pandas/polars if the output is a
np.ndarray
- Export from
python/ferro_ta/__init__.pyvia the usual__all__+pandas_wrap/polars_wrappattern.
Do not write the algorithm in Python first and port it later. Porting is expensive; getting it right in Rust first is cheaper.
Rule 2: Porting Python algorithms to Rust
If you find a Python loop that iterates over data (e.g., for i in range(n):)
or a pure-Python rolling window computation, it is a porting candidate.
Priority order:
- Hot paths called from batch or streaming contexts.
- Any loop where
ncan be 10,000+. - Loops inside extended indicators.
When porting:
- The Python function becomes a thin wrapper that calls the Rust function.
- There is no Python fallback; the extension must be built. If the Rust call fails, the function is allowed to fail (no silent fallback to Python).
Rule 3: No raw NumPy loops in indicator logic
The following patterns are forbidden in indicator implementation code:
# ❌ Forbidden: Python loop over data
for i in range(n):
result[i] = compute(data[i - period : i])
# ❌ Forbidden: nested Python loop in rolling window
for i in range(timeperiod - 1, n):
result[i] = data[i + 1 - timeperiod : i + 1].max()
The following are allowed in Python wrappers only:
# ✓ Allowed: vectorised NumPy (no loop)
result = np.cumsum(data)
# ✓ Allowed: scalar operations (no loop over n)
tp = (high + low + close) / 3.0
Rule 4: Streaming classes are Rust PyO3 classes
Streaming (bar-by-bar stateful) classes must be #[pyclass] types
implemented in src/streaming/mod.rs. Python should import and re-export
them — never re-implement them.
Template for a new streaming class:
#[pyclass(module = "ferro_ta._ferro_ta")]
pub struct StreamingMyIndicator {
period: usize,
// ... state fields
}
#[pymethods]
impl StreamingMyIndicator {
#[new]
pub fn new(period: usize) -> PyResult<Self> { ... }
pub fn update(&mut self, value: f64) -> f64 { ... }
pub fn reset(&mut self) { ... }
#[getter]
pub fn period(&self) -> usize { self.period }
}
Then in src/streaming/mod.rs::register():
m.add_class::<StreamingMyIndicator>()?;
And in python/ferro_ta/streaming.py:
from ferro_ta._ferro_ta import StreamingMyIndicator # noqa: F401
Rule 5: Batch operations are Rust functions
Batch functions that process multiple time-series at once must be implemented
in src/batch/mod.rs. They accept 2-D numpy arrays and loop over columns
entirely in Rust (one GIL release covers all columns).
Rule 6: Document the Rust location
Every Python wrapper docstring must note that the algorithm is in Rust:
def MY_INDICATOR(close, timeperiod=14):
"""My Indicator.
...
Notes
-----
Implemented in Rust — see ``src/my_category/my_indicator.rs``.
"""
What Belongs in Python Only
Some things are intentionally in Python and should stay there:
| Thing | Why it stays in Python |
|---|---|
pandas_wrap / polars_wrap decorators |
Pandas/polars are Python libraries; zero-copy Rust wrappers are not practical here |
_to_f64 fast path check |
One Python branch beats a PyO3 round-trip for the already-valid case |
check_equal_length, check_timeperiod |
Negligible overhead vs indicator computation; keeps Rust functions focused |
Pipeline, Config |
Orchestration logic — Python is appropriate |
gpu.py (CuPy PoC) |
CuPy is Python-native; Rust cannot talk to GPU without CUDA bindings |
backtest.py helpers |
High-level orchestration |
Current Status (as of 2026-03-08)
| Module | Logic location |
|---|---|
overlap.py |
✅ Rust (src/overlap/) |
momentum.py |
✅ Rust (src/momentum/) |
volatility.py |
✅ Rust (src/volatility/) |
statistic.py |
✅ Rust (src/statistic/) |
volume.py |
✅ Rust (src/volume/) |
price_transform.py |
✅ Rust (src/price_transform/) |
pattern.py |
✅ Rust (src/pattern/) |
cycle.py |
✅ Rust (src/cycle/) |
batch.py |
✅ Rust (src/batch/) |
streaming.py |
✅ Rust (src/streaming/) — all 9 classes |
extended.py |
✅ Rust (src/extended/) — all 10 indicators |
math_ops.py (rolling) |
✅ Rust (src/math_ops/) — SUM/MAX/MIN/MAXINDEX/MININDEX |
math_ops.py (element-wise) |
✅ NumPy wrappers (no loops — vectorised by NumPy's C core) |
gpu.py |
⚠️ CuPy (Python/CUDA — intentional, see above) |
pipeline.py |
✅ Orchestration only (no indicator loops) |
config.py |
✅ Configuration only |
backtest.py |
✅ Orchestration only |
Checklist for New Indicator PRs
- Algorithm implemented in
src/<category>/mod.rs cargo fmt --checkpassescargo clippy --release -- -D warningspasses- Python wrapper is thin (validation +
_to_f64+ Rust call) - No Python loops over data
- Docstring notes "Implemented in Rust"
- Registered in
src/lib.rsand exported from__init__.py - Tests added in
tests/