331 lines
9.8 KiB
Python
331 lines
9.8 KiB
Python
"""
|
|
Fast polars-native drop-in replacements for vnpy's slow rolling operators.
|
|
|
|
This module provides native polars implementations that override vnpy's
|
|
rolling_map-based operators through the EXPRESSION_FUNCTIONS extension point.
|
|
Optimized for 10M+ row datasets on 2-core NAS infrastructure.
|
|
|
|
Registration: Call register_fast_ops() once before batch evaluation.
|
|
Idempotent and thread-safe.
|
|
"""
|
|
|
|
import sys
|
|
from typing import cast
|
|
|
|
import polars as pl
|
|
import numpy as np
|
|
|
|
# Inject vnpy source path (follow sanguo_factor/universe.py pattern)
|
|
_VNPY_SRC = "/Users/chufeng/.openclaw/sanguo_projects/sanguo_vnpy_v2/vnpy_v4.4.0"
|
|
if _VNPY_SRC not in sys.path:
|
|
sys.path.insert(0, _VNPY_SRC)
|
|
|
|
from vnpy.alpha.dataset.utility import DataProxy, EXPRESSION_FUNCTIONS
|
|
|
|
|
|
def fast_ts_rank(feature: DataProxy, window: int) -> DataProxy:
|
|
"""Percentile rank of current value within window [0,1] (polars native).
|
|
|
|
Replicates: scipy.stats.percentileofscore(s, s[-1]) / 100
|
|
Strategy: Count values <= current, divide by window size.
|
|
Handles NaN by treating NaN > everything (consistent with scipy).
|
|
Returns None for partial windows (like original).
|
|
"""
|
|
current = pl.col("data")
|
|
|
|
# Count values <= current for each shift k in 0..window-1
|
|
# Fill null with False to treat null comparisons as not <=
|
|
le_count = pl.sum_horizontal([
|
|
pl.when(current.shift(k).over("vt_symbol").le(current).fill_null(False))
|
|
.then(pl.lit(1))
|
|
.otherwise(pl.lit(0))
|
|
for k in range(window)
|
|
])
|
|
|
|
# Rank = count / window
|
|
result = pl.when(pl.int_range(pl.len()).over("vt_symbol") >= window - 1) \
|
|
.then(le_count / pl.lit(window)) \
|
|
.otherwise(None)
|
|
|
|
df = feature.df.select(
|
|
pl.col("datetime"),
|
|
pl.col("vt_symbol"),
|
|
result.alias("data")
|
|
)
|
|
|
|
return DataProxy(df)
|
|
|
|
|
|
def fast_ts_corr(feature1: DataProxy, feature2: DataProxy, window: int) -> DataProxy:
|
|
"""Correlation between two features over rolling window (optimized native).
|
|
|
|
Uses correlation identity: corr(x,y) = cov(x,y) / (std_x * std_y)
|
|
"""
|
|
df_merged = feature1.df.join(feature2.df, on=["datetime", "vt_symbol"])
|
|
|
|
# Use native polars rolling correlation for exact match
|
|
corr_result = pl.rolling_corr(
|
|
pl.col("data"),
|
|
pl.col("data_right"),
|
|
window_size=window,
|
|
min_samples=1
|
|
).over("vt_symbol")
|
|
|
|
df = df_merged.select(
|
|
pl.col("datetime"),
|
|
pl.col("vt_symbol"),
|
|
pl.when(corr_result.is_infinite() | corr_result.is_nan())
|
|
.then(None)
|
|
.otherwise(corr_result)
|
|
.alias("data")
|
|
)
|
|
|
|
return DataProxy(df)
|
|
|
|
|
|
def fast_ts_cov(feature1: DataProxy, feature2: DataProxy, window: int) -> DataProxy:
|
|
"""Covariance between two features over rolling window (native).
|
|
|
|
Uses identity: cov(x,y) = corr(x,y) * std_x * std_y
|
|
"""
|
|
# Get correlation and standard deviations, then compute covariance
|
|
corr_result = fast_ts_corr(feature1, feature2, window)
|
|
|
|
# Extract std deviations from the merged dataframe
|
|
df_merged = feature1.df.join(feature2.df, on=["datetime", "vt_symbol"])
|
|
|
|
std_x = pl.col("data").rolling_std(window, min_samples=1, ddof=0).over("vt_symbol")
|
|
std_y = pl.col("data_right").rolling_std(window, min_samples=1, ddof=0).over("vt_symbol")
|
|
|
|
# Covariance = corr * std_x * std_y
|
|
df = df_merged.with_columns([
|
|
std_x.alias("std_x"),
|
|
std_y.alias("std_y"),
|
|
corr_result.df["data"].alias("corr")
|
|
])
|
|
|
|
df = df.select(
|
|
pl.col("datetime"),
|
|
pl.col("vt_symbol"),
|
|
(pl.col("corr") * pl.col("std_x") * pl.col("std_y")).alias("data")
|
|
)
|
|
|
|
# Handle infinite/NaN values
|
|
df = df.select(
|
|
pl.col("datetime"),
|
|
pl.col("vt_symbol"),
|
|
pl.when(pl.col("data").is_infinite() | pl.col("data").is_nan())
|
|
.then(None)
|
|
.otherwise(pl.col("data"))
|
|
.alias("data")
|
|
)
|
|
|
|
return DataProxy(df)
|
|
|
|
|
|
def fast_ts_decay_linear(feature: DataProxy, window: int) -> DataProxy:
|
|
"""Linear decay weighted average: weights (w, w-1, ..., 1) / sum (polars native).
|
|
|
|
Optimized by expanding the weighted sum as:
|
|
Σ_k (k+1) * shift(k) for k in 0..window-1
|
|
where shift(0) gets weight 1 (last element), shift(window-1) gets weight w (first element)
|
|
Returns None for partial windows (like original).
|
|
"""
|
|
current = pl.col("data")
|
|
|
|
# Calculate weighted sum: Σ_k (k+1) * shift(k) for k in 0..window-1
|
|
# shift(k) goes backward in time, so higher k = earlier element = higher weight
|
|
weighted_sum = pl.sum_horizontal([
|
|
pl.lit(k + 1) * current.shift(k).over("vt_symbol")
|
|
for k in range(window)
|
|
])
|
|
|
|
denominator = window * (window + 1) // 2
|
|
|
|
result = pl.when(pl.int_range(pl.len()).over("vt_symbol") >= window - 1) \
|
|
.then(weighted_sum / pl.lit(denominator)) \
|
|
.otherwise(None)
|
|
|
|
df = feature.df.select(
|
|
pl.col("datetime"),
|
|
pl.col("vt_symbol"),
|
|
result.alias("data")
|
|
)
|
|
|
|
return DataProxy(df)
|
|
|
|
|
|
def fast_ts_slope(feature: DataProxy, window: int) -> DataProxy:
|
|
"""OLS slope over rolling window: cov(x,t) / var(t) where t = 0..w-1 (native).
|
|
|
|
Replicates the optimized original formula but with cleaner native polars syntax.
|
|
x = time index (0, 1, 2, ..., w-1)
|
|
slope = cov(y, x) / var(x)
|
|
"""
|
|
n = window
|
|
mean_x = (n - 1) / 2.0
|
|
var_x = (n**2 - 1) / 12.0 # Variance of [0, 1, ..., n-1]
|
|
|
|
# E[y*x] using weighted sum
|
|
mean_yx_expr = pl.sum_horizontal([
|
|
i * pl.col("data").shift(window - 1 - i).over("vt_symbol")
|
|
for i in range(n)
|
|
]) / n
|
|
|
|
mean_y = pl.col("data").rolling_mean(window, min_samples=window).over("vt_symbol")
|
|
|
|
# cov(y, x) = E[yx] - E[y]E[x]
|
|
cov_yx = mean_yx_expr - mean_y * mean_x
|
|
|
|
slope = cov_yx / var_x
|
|
|
|
df = feature.df.select(
|
|
pl.col("datetime"),
|
|
pl.col("vt_symbol"),
|
|
slope.alias("data")
|
|
)
|
|
|
|
return DataProxy(df)
|
|
|
|
|
|
def fast_ts_rsquare(feature: DataProxy, window: int) -> DataProxy:
|
|
"""R-squared of linear regression over rolling window (native).
|
|
|
|
r² = slope² * var(x) / var(y)
|
|
where var(x) is constant for window size.
|
|
"""
|
|
n = window
|
|
mean_x = (n - 1) / 2.0
|
|
var_x = (n**2 - 1) / 12.0 # Variance of [0, 1, ..., n-1]
|
|
|
|
# E[y*x]
|
|
mean_yx_expr = pl.sum_horizontal([
|
|
i * pl.col("data").shift(window - 1 - i).over("vt_symbol")
|
|
for i in range(n)
|
|
]) / n
|
|
|
|
mean_y = pl.col("data").rolling_mean(window, min_samples=window).over("vt_symbol")
|
|
|
|
cov_yx = mean_yx_expr - mean_y * mean_x
|
|
var_y = pl.col("data").rolling_var(window, min_samples=window, ddof=0).over("vt_symbol")
|
|
|
|
# r² = cov²(x,y) / (var(x) * var(y))
|
|
rsquare = (cov_yx.pow(2)) / (var_x * var_y)
|
|
|
|
df = feature.df.select(
|
|
pl.col("datetime"),
|
|
pl.col("vt_symbol"),
|
|
pl.when(rsquare.is_infinite() | rsquare.is_nan())
|
|
.then(None)
|
|
.otherwise(rsquare)
|
|
.alias("data")
|
|
)
|
|
|
|
return DataProxy(df)
|
|
|
|
|
|
def fast_ts_resi(feature: DataProxy, window: int) -> DataProxy:
|
|
"""Residuals from linear regression over rolling window (native).
|
|
|
|
residual = y - (intercept + slope * x_last)
|
|
where x_last = window - 1 (time index of last point)
|
|
"""
|
|
n = window
|
|
mean_x = (n - 1) / 2.0
|
|
var_x = (n**2 - 1) / 12.0
|
|
x_last = n - 1
|
|
|
|
# E[y*x]
|
|
mean_yx_expr = pl.sum_horizontal([
|
|
i * pl.col("data").shift(window - 1 - i).over("vt_symbol")
|
|
for i in range(n)
|
|
]) / n
|
|
|
|
mean_y = pl.col("data").rolling_mean(window, min_samples=window).over("vt_symbol")
|
|
|
|
cov_yx = mean_yx_expr - mean_y * mean_x
|
|
slope = cov_yx / var_x
|
|
intercept = mean_y - slope * mean_x
|
|
|
|
# residual = y - (intercept + slope * x_last)
|
|
residual = pl.col("data") - (intercept + slope * x_last)
|
|
|
|
df = feature.df.select(
|
|
pl.col("datetime"),
|
|
pl.col("vt_symbol"),
|
|
residual.alias("data")
|
|
)
|
|
|
|
return DataProxy(df)
|
|
|
|
|
|
def fast_ts_quantile(feature: DataProxy, window: int, quantile: float) -> DataProxy:
|
|
"""Quantile value over rolling window (native polars).
|
|
|
|
Uses rolling_quantile with linear interpolation to match original behavior.
|
|
Returns None for partial windows (like original).
|
|
"""
|
|
current = pl.col("data")
|
|
|
|
# Use native rolling_quantile with linear interpolation
|
|
quantile_result = current.rolling_quantile(
|
|
quantile=quantile,
|
|
interpolation="linear",
|
|
window_size=window
|
|
).over("vt_symbol")
|
|
|
|
# Apply window gating - null for partial windows
|
|
result = pl.when(pl.int_range(pl.len()).over("vt_symbol") >= window - 1) \
|
|
.then(quantile_result) \
|
|
.otherwise(None)
|
|
|
|
df = feature.df.select(
|
|
pl.col("datetime"),
|
|
pl.col("vt_symbol"),
|
|
result.alias("data")
|
|
)
|
|
|
|
return DataProxy(df)
|
|
|
|
|
|
def register_fast_ops() -> list[str]:
|
|
"""Register fast polars operators into vnpy's EXPRESSION_FUNCTIONS.
|
|
|
|
Returns list of overridden operator names for verification.
|
|
Idempotent: safe to call multiple times.
|
|
|
|
Usage:
|
|
overrides = register_fast_ops()
|
|
print(f"Registered {len(overrides)} fast operators")
|
|
"""
|
|
operators = {
|
|
"ts_rank": fast_ts_rank,
|
|
"ts_corr": fast_ts_corr,
|
|
"ts_cov": fast_ts_cov,
|
|
"ts_decay_linear": fast_ts_decay_linear,
|
|
"ts_slope": fast_ts_slope,
|
|
"ts_rsquare": fast_ts_rsquare,
|
|
"ts_resi": fast_ts_resi,
|
|
"ts_quantile": fast_ts_quantile,
|
|
}
|
|
|
|
# Register all operators
|
|
for name, func in operators.items():
|
|
EXPRESSION_FUNCTIONS[name] = func
|
|
|
|
return list(operators.keys())
|
|
|
|
|
|
# Auto-registration on import is intentional for batch_eval usage
|
|
# But also expose explicit registration for testing
|
|
__all__ = [
|
|
"register_fast_ops",
|
|
"fast_ts_rank",
|
|
"fast_ts_corr",
|
|
"fast_ts_cov",
|
|
"fast_ts_decay_linear",
|
|
"fast_ts_slope",
|
|
"fast_ts_rsquare",
|
|
"fast_ts_resi",
|
|
"fast_ts_quantile",
|
|
] |