chore: release v1.0.2
This commit is contained in:
@@ -360,10 +360,10 @@ def backtest(
|
||||
bar_returns[1:] = np.diff(c) / c[:-1]
|
||||
|
||||
strategy_returns = positions * bar_returns
|
||||
position_changed = np.concatenate([[False], positions[1:] != positions[:-1]])
|
||||
|
||||
# Slippage: on each position change, reduce return by slippage_bps/10000 (one-way)
|
||||
if slippage_bps > 0:
|
||||
position_changed = np.concatenate([[False], positions[1:] != positions[:-1]])
|
||||
strategy_returns = strategy_returns.copy()
|
||||
strategy_returns[position_changed] -= slippage_bps / 10_000.0
|
||||
|
||||
@@ -371,13 +371,18 @@ def backtest(
|
||||
if commission_per_trade <= 0:
|
||||
equity = np.cumprod(1.0 + strategy_returns)
|
||||
else:
|
||||
equity = np.empty(len(c), dtype=np.float64)
|
||||
equity[0] = 1.0
|
||||
position_changed = np.concatenate([[False], positions[1:] != positions[:-1]])
|
||||
for i in range(1, len(c)):
|
||||
equity[i] = equity[i - 1] * (1.0 + strategy_returns[i])
|
||||
if position_changed[i]:
|
||||
equity[i] -= commission_per_trade
|
||||
gross_equity = np.cumprod(1.0 + strategy_returns)
|
||||
if np.any(gross_equity == 0.0):
|
||||
equity = np.empty(len(c), dtype=np.float64)
|
||||
equity[0] = 1.0
|
||||
for i in range(1, len(c)):
|
||||
equity[i] = equity[i - 1] * (1.0 + strategy_returns[i])
|
||||
if position_changed[i]:
|
||||
equity[i] -= commission_per_trade
|
||||
else:
|
||||
commissions = position_changed.astype(np.float64) * commission_per_trade
|
||||
discounted_commissions = np.cumsum(commissions / gross_equity)
|
||||
equity = gross_equity * (1.0 - discounted_commissions)
|
||||
|
||||
return BacktestResult(
|
||||
signals=signals,
|
||||
|
||||
@@ -24,12 +24,23 @@ import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.registry import run as _registry_run
|
||||
from ferro_ta.data.batch import compute_many
|
||||
|
||||
__all__ = [
|
||||
"feature_matrix",
|
||||
]
|
||||
|
||||
|
||||
def _forward_fill_nan(arr: NDArray[np.float64]) -> NDArray[np.float64]:
|
||||
mask = np.isnan(arr)
|
||||
if not mask.any():
|
||||
return arr
|
||||
|
||||
last_valid = np.where(~mask, np.arange(len(arr)), 0)
|
||||
np.maximum.accumulate(last_valid, out=last_valid)
|
||||
return arr[last_valid]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# feature_matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -117,67 +128,23 @@ def feature_matrix(
|
||||
n = len(close)
|
||||
columns: dict[str, NDArray[np.float64]] = {}
|
||||
|
||||
# --- Indicators needing HLCV ---
|
||||
_multi_input = {
|
||||
"ATR",
|
||||
"NATR",
|
||||
"TRANGE",
|
||||
"ADX",
|
||||
"ADXR",
|
||||
"PLUS_DI",
|
||||
"MINUS_DI",
|
||||
"PLUS_DM",
|
||||
"MINUS_DM",
|
||||
"DX",
|
||||
"AROON",
|
||||
"AROONOSC",
|
||||
"CCI",
|
||||
"MFI",
|
||||
"STOCH",
|
||||
"STOCHF",
|
||||
"STOCHRSI",
|
||||
"WILLR",
|
||||
"AD",
|
||||
"ADOSC",
|
||||
"OBV",
|
||||
"VWAP",
|
||||
"DONCHIAN",
|
||||
"ICHIMOKU",
|
||||
}
|
||||
results = compute_many(
|
||||
indicators,
|
||||
close=close,
|
||||
high=high if high is not None else None,
|
||||
low=low if low is not None else None,
|
||||
volume=volume if volume is not None else None,
|
||||
)
|
||||
|
||||
def _call_indicator(name: str, kwargs: dict[str, Any]) -> Any:
|
||||
# Try with close only first; if that fails try with hlcv
|
||||
try:
|
||||
return _registry_run(name, close, **kwargs)
|
||||
except (TypeError, Exception):
|
||||
pass
|
||||
# Build appropriate positional args from available arrays
|
||||
if name in _multi_input and high is not None and low is not None:
|
||||
try:
|
||||
return _registry_run(name, high, low, close, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
if volume is not None:
|
||||
try:
|
||||
return _registry_run(name, high, low, close, volume, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
raise ValueError(
|
||||
f"Cannot call indicator '{name}': insufficient data columns or incompatible parameters."
|
||||
)
|
||||
|
||||
for spec in indicators:
|
||||
for spec, result in zip(indicators, results):
|
||||
if isinstance(spec, str):
|
||||
name = spec
|
||||
kwargs: dict[str, Any] = {}
|
||||
out_key: Optional[Any] = None
|
||||
elif len(spec) == 2:
|
||||
name, kwargs = spec # type: ignore[misc]
|
||||
name, _ = spec # type: ignore[misc]
|
||||
out_key = None
|
||||
else:
|
||||
name, kwargs, out_key = spec # type: ignore[misc]
|
||||
|
||||
result = _call_indicator(name, kwargs)
|
||||
name, _, out_key = spec # type: ignore[misc]
|
||||
|
||||
if isinstance(result, tuple):
|
||||
if out_key is not None:
|
||||
@@ -215,8 +182,6 @@ def feature_matrix(
|
||||
mask &= ~np.isnan(arr)
|
||||
return {k: v[mask] for k, v in columns.items()}
|
||||
elif nan_policy == "fill":
|
||||
for k, arr in columns.items():
|
||||
for i in range(1, len(arr)):
|
||||
if np.isnan(arr[i]):
|
||||
arr[i] = arr[i - 1]
|
||||
for key, arr in columns.items():
|
||||
columns[key] = _forward_fill_nan(arr)
|
||||
return columns
|
||||
|
||||
@@ -45,6 +45,7 @@ iv_zscore(iv_series, window)
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.lib.stride_tricks import sliding_window_view
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
|
||||
@@ -103,15 +104,15 @@ def iv_rank(
|
||||
arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window)
|
||||
n = len(arr)
|
||||
out = np.full(n, np.nan, dtype=np.float64)
|
||||
if window > n:
|
||||
return out
|
||||
|
||||
for i in range(window - 1, n):
|
||||
window_slice = arr[i - window + 1 : i + 1]
|
||||
lo = float(np.nanmin(window_slice))
|
||||
hi = float(np.nanmax(window_slice))
|
||||
if hi == lo:
|
||||
out[i] = 0.0
|
||||
else:
|
||||
out[i] = (arr[i] - lo) / (hi - lo)
|
||||
windows = sliding_window_view(arr, window_shape=window)
|
||||
lower = np.nanmin(windows, axis=1)
|
||||
upper = np.nanmax(windows, axis=1)
|
||||
current = arr[window - 1 :]
|
||||
spread = upper - lower
|
||||
out[window - 1 :] = np.where(spread == 0.0, 0.0, (current - lower) / spread)
|
||||
|
||||
return out
|
||||
|
||||
@@ -149,11 +150,12 @@ def iv_percentile(
|
||||
arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window)
|
||||
n = len(arr)
|
||||
out = np.full(n, np.nan, dtype=np.float64)
|
||||
if window > n:
|
||||
return out
|
||||
|
||||
for i in range(window - 1, n):
|
||||
window_slice = arr[i - window + 1 : i + 1]
|
||||
current = arr[i]
|
||||
out[i] = float(np.sum(window_slice <= current)) / window
|
||||
windows = sliding_window_view(arr, window_shape=window)
|
||||
current = arr[window - 1 :, None]
|
||||
out[window - 1 :] = np.sum(windows <= current, axis=1, dtype=np.int64) / window
|
||||
|
||||
return out
|
||||
|
||||
@@ -192,14 +194,13 @@ def iv_zscore(
|
||||
arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window)
|
||||
n = len(arr)
|
||||
out = np.full(n, np.nan, dtype=np.float64)
|
||||
if window > n:
|
||||
return out
|
||||
|
||||
for i in range(window - 1, n):
|
||||
window_slice = arr[i - window + 1 : i + 1]
|
||||
mu = float(np.nanmean(window_slice))
|
||||
sigma = float(np.nanstd(window_slice, ddof=0))
|
||||
if sigma == 0.0:
|
||||
out[i] = np.nan
|
||||
else:
|
||||
out[i] = (arr[i] - mu) / sigma
|
||||
windows = sliding_window_view(arr, window_shape=window)
|
||||
mean = np.nanmean(windows, axis=1)
|
||||
std = np.nanstd(windows, axis=1, ddof=0)
|
||||
current = arr[window - 1 :]
|
||||
out[window - 1 :] = np.where(std == 0.0, np.nan, (current - mean) / std)
|
||||
|
||||
return out
|
||||
|
||||
@@ -33,6 +33,7 @@ import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import bottom_n_indices as _rust_bottom_n
|
||||
from ferro_ta._ferro_ta import compose_rank as _rust_compose_rank
|
||||
from ferro_ta._ferro_ta import compose_weighted as _rust_compose_weighted
|
||||
from ferro_ta._ferro_ta import rank_series as _rust_rank_series
|
||||
from ferro_ta._ferro_ta import top_n_indices as _rust_top_n
|
||||
@@ -131,13 +132,7 @@ def compose(
|
||||
w = np.full(n_sigs, 1.0 / n_sigs)
|
||||
return _rust_compose_weighted(arr, w)
|
||||
elif method == "rank":
|
||||
# Replace each column with its rank, then sum (ensure contiguous slices)
|
||||
ranked = np.column_stack(
|
||||
[_rust_rank_series(np.ascontiguousarray(arr[:, j])) for j in range(n_sigs)]
|
||||
)
|
||||
ranked = np.ascontiguousarray(ranked)
|
||||
w = np.full(n_sigs, 1.0)
|
||||
return _rust_compose_weighted(ranked, w)
|
||||
return _rust_compose_rank(arr)
|
||||
else:
|
||||
# weighted (default)
|
||||
if weights is None:
|
||||
|
||||
Reference in New Issue
Block a user