Skip to content

Commit 759c08a

Browse files
committed
feat: add slogan SVG to README
1 parent 1295322 commit 759c08a

8 files changed

Lines changed: 397 additions & 33 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
# ⚡Elvers
1+
<div align="center">
2+
3+
<img src="https://raw.githubusercontent.com/quantbai/elvers/main/assets/elvers.svg" alt="Elvers" width="500">
24

35
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
46
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
57
[![Polars](https://img.shields.io/badge/Polars-1.37.1-green.svg)](https://pola.rs/)
68

9+
</div>
10+
711
High-performance multi-factor quantitative framework built on Polars.
812

913
Named after **ELVES**, the atmospheric lightning phenomenon that occurs at extreme speed.

assets/elvers.svg

Lines changed: 325 additions & 0 deletions
Loading

elvers/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@
2121
ts_zscore, ts_corr, ts_covariance, ts_product,
2222
ts_arg_max, ts_arg_min, ts_decay_linear, ts_av_diff, ts_scale,
2323
ts_quantile, ts_cv, ts_autocorr,
24-
ts_count_nans,
24+
ts_count_nans, ts_backfill,
2525

2626
rank, zscore, mean, median,
27-
scale, normalize, quantile, spread, signal,
27+
scale, normalize, quantile, signal,
2828

2929
log, ln, sqrt, sign, power, signed_power,
3030
inverse, s_log_1p, maximum, minimum, where,
@@ -47,10 +47,10 @@
4747
"ts_zscore", "ts_corr", "ts_covariance", "ts_product",
4848
"ts_arg_max", "ts_arg_min", "ts_decay_linear", "ts_av_diff", "ts_scale",
4949
"ts_quantile", "ts_cv", "ts_autocorr",
50-
"ts_count_nans",
50+
"ts_count_nans", "ts_backfill",
5151

5252
"rank", "zscore", "mean", "median",
53-
"scale", "normalize", "quantile", "spread", "signal",
53+
"scale", "normalize", "quantile", "signal",
5454

5555
"log", "ln", "sqrt", "sign", "power", "signed_power",
5656
"inverse", "s_log_1p", "maximum", "minimum", "where",

elvers/io/loader.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ def load(source: Union[str, Path, pl.LazyFrame, pl.DataFrame], balance: bool = T
1212
"""Load data and return a managed Panel object."""
1313
lf = _read_source(source)
1414

15-
if "timestamp" not in lf.columns or "symbol" not in lf.columns:
15+
schema = lf.collect_schema()
16+
if "timestamp" not in schema.names() or "symbol" not in schema.names():
1617
raise ValueError("Source must contain 'timestamp' and 'symbol' columns.")
1718

1819
if balance:

elvers/io/panel.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def balance_panel(lf: pl.LazyFrame) -> Tuple[pl.LazyFrame, PanelInfo]:
7979
if start_date is None or end_date is None:
8080
raise ValueError("Data must have 'timestamp' column with valid dates.")
8181

82-
ts_type = lf.schema["timestamp"]
82+
ts_type = lf.collect_schema()["timestamp"]
8383

8484
skeleton = (
8585
pl.datetime_range(start=start_date, end=end_date, interval="1d", eager=True)
@@ -99,7 +99,7 @@ def balance_panel(lf: pl.LazyFrame) -> Tuple[pl.LazyFrame, PanelInfo]:
9999
n_days = (end_date - start_date).days + 1
100100
n_rows = len(symbols) * n_days
101101

102-
original_cols = [c for c in lf.columns if c not in ("timestamp", "symbol")]
102+
original_cols = [c for c in lf.collect_schema().names() if c not in ("timestamp", "symbol")]
103103
null_counts_row = balanced_lf.select([
104104
pl.col(c).null_count() for c in original_cols
105105
]).collect().row(0)

elvers/ops/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
ts_cv,
2828
ts_autocorr,
2929
ts_count_nans,
30+
ts_backfill,
3031
)
3132

3233
from .cross_sectional import (
@@ -37,7 +38,6 @@
3738
scale,
3839
normalize,
3940
quantile,
40-
spread,
4141
signal,
4242
)
4343

@@ -73,10 +73,10 @@
7373
"ts_zscore", "ts_corr", "ts_covariance", "ts_product",
7474
"ts_arg_max", "ts_arg_min", "ts_decay_linear", "ts_av_diff", "ts_scale",
7575
"ts_quantile", "ts_cv", "ts_autocorr",
76-
"ts_count_nans",
76+
"ts_count_nans", "ts_backfill",
7777

7878
"rank", "zscore", "mean", "median",
79-
"scale", "normalize", "quantile", "spread", "signal",
79+
"scale", "normalize", "quantile", "signal",
8080

8181
"log", "ln", "sqrt", "sign", "power", "signed_power",
8282
"inverse", "s_log_1p", "maximum", "minimum", "where",

elvers/ops/cross_sectional.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,6 @@ def quantile(f: Factor, driver: str = "gaussian", sigma: float = 1.0) -> Factor:
7676
)
7777

7878

79-
def spread(f: Factor, pct: float = 0.5) -> Factor:
80-
"""Simple long/short: top pct% gets +0.5, bottom pct% gets -0.5."""
81-
rank_expr = f.expr.rank(method="average") / f.expr.count()
82-
long_short = pl.when(rank_expr >= (1 - pct)).then(0.5).when(rank_expr <= pct).then(-0.5).otherwise(0.0)
83-
return Factor(
84-
long_short.over("timestamp"),
85-
f"spread({f.name},{pct})"
86-
)
87-
8879

8980
def signal(f: Factor) -> Factor:
9081
"""Convert to trading weights (long +0.5, short -0.5, net zero)."""

elvers/ops/timeseries.py

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,38 @@ def ts_median(f: Factor, window: int) -> Factor:
7272

7373

7474
def ts_rank(f: Factor, window: int, constant: float = 0) -> Factor:
75-
"""Percentile rank of current value within rolling window (WQ-style)."""
76-
rank_expr = f.expr.rolling_rank(window_size=window, min_samples=window) / (window + 1) + constant
75+
"""Percentile rank of current value within rolling window."""
76+
def rank_window(s):
77+
if s.is_nan().any() or s.n_unique() == 1:
78+
return None
79+
vals = s.to_numpy()
80+
sorted_idx = vals.argsort()
81+
rank_array = sorted_idx.argsort() + 1
82+
return float(rank_array[-1]) / len(vals)
83+
84+
rank_expr = f.expr.rolling_map(rank_window, window_size=window, min_periods=window)
7785
return Factor(
78-
rank_expr.over("symbol"),
86+
rank_expr.over("symbol") + constant,
7987
f"ts_rank({f.name},{window})"
8088
)
8189

8290

8391
def ts_skewness(f: Factor, window: int) -> Factor:
84-
"""Rolling skewness over N periods."""
92+
"""Rolling skewness over N periods (sample skewness with bias correction)."""
93+
n = window
94+
mean_expr = f.expr.rolling_mean(window_size=window, min_periods=window)
95+
diff = f.expr - mean_expr
96+
97+
sum_cube = (diff ** 3).rolling_sum(window_size=window, min_periods=window)
98+
sum_sq = (diff ** 2).rolling_sum(window_size=window, min_periods=window)
99+
100+
numerator = sum_cube * n
101+
denominator = (sum_sq ** 1.5) * ((n - 1) * (n - 2))
102+
103+
skew_expr = numerator / denominator
104+
85105
return Factor(
86-
f.expr.rolling_skew(window_size=window).over("symbol"),
106+
skew_expr.over("symbol"),
87107
f"ts_skewness({f.name},{window})"
88108
)
89109

@@ -198,14 +218,9 @@ def ts_cv(f: Factor, window: int) -> Factor:
198218
def ts_autocorr(a: Factor, window: int, lag: int = 1) -> Factor:
199219
"""Rolling autocorrelation with specified lag."""
200220
lagged = a.expr.shift(lag)
201-
# Manual rolling corr between a and lagged version
202-
cov_expr = (a.expr * lagged).rolling_mean(window_size=window, min_periods=window) - \
203-
a.expr.rolling_mean(window_size=window, min_periods=window) * \
204-
lagged.rolling_mean(window_size=window, min_periods=window)
205-
std_a = a.expr.rolling_std(window_size=window, min_periods=window)
206-
std_lag = lagged.rolling_std(window_size=window, min_periods=window)
207-
return Factor(
208-
(cov_expr / (std_a * std_lag)).over("symbol"),
221+
corr_expr = pl.rolling_corr(a.expr, lagged, window_size=window, min_periods=window, ddof=1)
222+
return Factor(
223+
corr_expr.over("symbol"),
209224
f"ts_autocorr({a.name},{window},{lag})"
210225
)
211226

@@ -216,3 +231,31 @@ def ts_count_nans(f: Factor, window: int) -> Factor:
216231
f.expr.is_null().cast(pl.Int32).rolling_sum(window_size=window, min_periods=1).over("symbol"),
217232
f"ts_count_nans({f.name},{window})"
218233
)
234+
235+
236+
def ts_backfill(f: Factor, window: int, k: int = 1) -> Factor:
237+
"""Backfill NaN with k-th most recent non-NaN in window.
238+
239+
Parameters
240+
----------
241+
f : Factor
242+
Input factor
243+
window : int
244+
Maximum lookback window for filling
245+
k : int, default 1
246+
Which recent non-NaN to use (1=most recent)
247+
248+
Returns
249+
-------
250+
Factor
251+
Factor with NaN backfilled
252+
"""
253+
if k == 1:
254+
filled_expr = f.expr.forward_fill(limit=window).over("symbol")
255+
else:
256+
filled_expr = f.expr.shift(k - 1).forward_fill(limit=window).over("symbol")
257+
258+
return Factor(
259+
filled_expr,
260+
f"ts_backfill({f.name},{window},{k})"
261+
)

0 commit comments

Comments
 (0)