Spaces:
Running
Running
Commit ·
cbde68f
1
Parent(s): cad4bab
feat(C1): add fracdiff features — FAILED validation, not added to FEATURE_COLUMNS
Browse filesfracdiff_close_35 and fracdiff_close_40 (z-scored log-price fracdiff at d=0.35/0.40)
show r=0.82 correlation with bb_pct_b/rsi/MA-ratios and hurt dir_accuracy by -1.6pp
(baseline 31.5% → fracdiff 29.9%) across 5 stocks. Feature kept in technical.py
but excluded from FEATURE_COLUMNS per pass criterion (≥31.5% required).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- docs/c1_fracdiff_result.json +98 -0
- docs/improvements_log.md +5 -0
- indicators/technical.py +36 -0
- requirements.txt +1 -0
- scripts/backtest_c1.py +140 -0
docs/c1_fracdiff_result.json
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"results": {
|
| 3 |
+
"2330": {
|
| 4 |
+
"baseline": {
|
| 5 |
+
"accuracy": 42.4,
|
| 6 |
+
"dir_accuracy": 38.0,
|
| 7 |
+
"up_precision": 56.8,
|
| 8 |
+
"n_predictions": 231,
|
| 9 |
+
"n_features": 40
|
| 10 |
+
},
|
| 11 |
+
"fracdiff": {
|
| 12 |
+
"accuracy": 40.7,
|
| 13 |
+
"dir_accuracy": 33.8,
|
| 14 |
+
"up_precision": 53.5,
|
| 15 |
+
"n_predictions": 231,
|
| 16 |
+
"n_features": 42
|
| 17 |
+
}
|
| 18 |
+
},
|
| 19 |
+
"0050": {
|
| 20 |
+
"baseline": {
|
| 21 |
+
"accuracy": 48.1,
|
| 22 |
+
"dir_accuracy": 41.8,
|
| 23 |
+
"up_precision": 55.3,
|
| 24 |
+
"n_predictions": 231,
|
| 25 |
+
"n_features": 40
|
| 26 |
+
},
|
| 27 |
+
"fracdiff": {
|
| 28 |
+
"accuracy": 46.8,
|
| 29 |
+
"dir_accuracy": 37.5,
|
| 30 |
+
"up_precision": 50.0,
|
| 31 |
+
"n_predictions": 231,
|
| 32 |
+
"n_features": 42
|
| 33 |
+
}
|
| 34 |
+
},
|
| 35 |
+
"2317": {
|
| 36 |
+
"baseline": {
|
| 37 |
+
"accuracy": 31.6,
|
| 38 |
+
"dir_accuracy": 28.3,
|
| 39 |
+
"up_precision": 37.1,
|
| 40 |
+
"n_predictions": 231,
|
| 41 |
+
"n_features": 40
|
| 42 |
+
},
|
| 43 |
+
"fracdiff": {
|
| 44 |
+
"accuracy": 26.8,
|
| 45 |
+
"dir_accuracy": 24.2,
|
| 46 |
+
"up_precision": 30.3,
|
| 47 |
+
"n_predictions": 231,
|
| 48 |
+
"n_features": 42
|
| 49 |
+
}
|
| 50 |
+
},
|
| 51 |
+
"2454": {
|
| 52 |
+
"baseline": {
|
| 53 |
+
"accuracy": 30.3,
|
| 54 |
+
"dir_accuracy": 27.4,
|
| 55 |
+
"up_precision": 31.0,
|
| 56 |
+
"n_predictions": 231,
|
| 57 |
+
"n_features": 40
|
| 58 |
+
},
|
| 59 |
+
"fracdiff": {
|
| 60 |
+
"accuracy": 32.9,
|
| 61 |
+
"dir_accuracy": 31.5,
|
| 62 |
+
"up_precision": 33.7,
|
| 63 |
+
"n_predictions": 231,
|
| 64 |
+
"n_features": 42
|
| 65 |
+
}
|
| 66 |
+
},
|
| 67 |
+
"2881": {
|
| 68 |
+
"baseline": {
|
| 69 |
+
"accuracy": 46.8,
|
| 70 |
+
"dir_accuracy": 22.0,
|
| 71 |
+
"up_precision": 35.3,
|
| 72 |
+
"n_predictions": 231,
|
| 73 |
+
"n_features": 40
|
| 74 |
+
},
|
| 75 |
+
"fracdiff": {
|
| 76 |
+
"accuracy": 47.6,
|
| 77 |
+
"dir_accuracy": 22.4,
|
| 78 |
+
"up_precision": 34.5,
|
| 79 |
+
"n_predictions": 231,
|
| 80 |
+
"n_features": 42
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
},
|
| 84 |
+
"aggregate": {
|
| 85 |
+
"baseline": {
|
| 86 |
+
"accuracy": 39.8,
|
| 87 |
+
"dir_accuracy": 31.5,
|
| 88 |
+
"up_precision": 43.1
|
| 89 |
+
},
|
| 90 |
+
"fracdiff": {
|
| 91 |
+
"accuracy": 39.0,
|
| 92 |
+
"dir_accuracy": 29.9,
|
| 93 |
+
"up_precision": 40.4
|
| 94 |
+
}
|
| 95 |
+
},
|
| 96 |
+
"winner": "baseline",
|
| 97 |
+
"pass_threshold": 31.5,
|
| 98 |
+
"passed":
|
docs/improvements_log.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Feature Improvement Log
|
| 2 |
+
|
| 3 |
+
| ID | Features | dir_accuracy | Status |
|
| 4 |
+
|----|----------|-------------|--------|
|
| 5 |
+
| C1 | fracdiff_close_35, fracdiff_close_40 | dir_acc: 31.5 → 29.9 | FAILED — no improvement |
|
indicators/technical.py
CHANGED
|
@@ -261,6 +261,41 @@ def add_amihud_illiquidity(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 261 |
return df
|
| 262 |
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
def add_all_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
| 265 |
"""
|
| 266 |
Apply all technical indicators to the DataFrame.
|
|
@@ -321,4 +356,5 @@ def add_all_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 321 |
df = add_amihud_illiquidity(df)
|
| 322 |
except Exception as exc:
|
| 323 |
logger.warning("add_amihud_illiquidity failed: %s", exc)
|
|
|
|
| 324 |
return df
|
|
|
|
| 261 |
return df
|
| 262 |
|
| 263 |
|
| 264 |
+
def add_fracdiff_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 265 |
+
"""
|
| 266 |
+
Add fractionally differenced log(close) features at d=0.35 and d=0.40.
|
| 267 |
+
Standard AFML sign convention: w_0=1, w_k = -w_{k-1}*(d-k+1)/k
|
| 268 |
+
(d=1 recovers first difference). Applied to log(close) for scale-invariance.
|
| 269 |
+
Output is z-scored with a 60-day rolling window to remove price-level trend
|
| 270 |
+
and make the feature stationary and cross-stock comparable.
|
| 271 |
+
window=30 lags; first window-1 rows padded with 0.0.
|
| 272 |
+
Pure numpy — no external fracdiff package required.
|
| 273 |
+
"""
|
| 274 |
+
try:
|
| 275 |
+
log_close = np.log(df["close"].values.astype(float))
|
| 276 |
+
n = len(log_close)
|
| 277 |
+
window = 30
|
| 278 |
+
zscore_window = 60
|
| 279 |
+
for d, col in [(0.35, "fracdiff_close_35"), (0.40, "fracdiff_close_40")]:
|
| 280 |
+
weights = np.empty(window)
|
| 281 |
+
weights[0] = 1.0
|
| 282 |
+
for k in range(1, window):
|
| 283 |
+
weights[k] = -weights[k - 1] * (d - k + 1) / k
|
| 284 |
+
full = np.convolve(log_close, weights)
|
| 285 |
+
result = full[0:n].copy()
|
| 286 |
+
result[:window - 1] = 0.0
|
| 287 |
+
# Rolling z-score to remove price-level trend
|
| 288 |
+
series = pd.Series(result)
|
| 289 |
+
roll_mean = series.rolling(zscore_window, min_periods=zscore_window).mean()
|
| 290 |
+
roll_std = series.rolling(zscore_window, min_periods=zscore_window).std().replace(0, np.nan)
|
| 291 |
+
zscored = ((series - roll_mean) / roll_std).fillna(0.0).clip(-3, 3).values
|
| 292 |
+
df[col] = zscored
|
| 293 |
+
except Exception:
|
| 294 |
+
df["fracdiff_close_35"] = 0.0
|
| 295 |
+
df["fracdiff_close_40"] = 0.0
|
| 296 |
+
return df
|
| 297 |
+
|
| 298 |
+
|
| 299 |
def add_all_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
| 300 |
"""
|
| 301 |
Apply all technical indicators to the DataFrame.
|
|
|
|
| 356 |
df = add_amihud_illiquidity(df)
|
| 357 |
except Exception as exc:
|
| 358 |
logger.warning("add_amihud_illiquidity failed: %s", exc)
|
| 359 |
+
df = add_fracdiff_features(df)
|
| 360 |
return df
|
requirements.txt
CHANGED
|
@@ -22,3 +22,4 @@ catboost>=1.2.0
|
|
| 22 |
optuna>=3.6.0
|
| 23 |
matplotlib>=3.8.0
|
| 24 |
python-dotenv>=1.0.0
|
|
|
|
|
|
| 22 |
optuna>=3.6.0
|
| 23 |
matplotlib>=3.8.0
|
| 24 |
python-dotenv>=1.0.0
|
| 25 |
+
fracdiff>=0.9.0
|
scripts/backtest_c1.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""C1 validation: 40f baseline vs 40f + fracdiff features."""
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 6 |
+
sys.path.insert(0, str(ROOT))
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
from dotenv import load_dotenv; load_dotenv(ROOT / ".env")
|
| 10 |
+
except ImportError: pass
|
| 11 |
+
|
| 12 |
+
import warnings; warnings.filterwarnings("ignore")
|
| 13 |
+
import numpy as np
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import json
|
| 16 |
+
from sklearn.ensemble import RandomForestClassifier
|
| 17 |
+
|
| 18 |
+
from models.predictor import FEATURE_COLUMNS
|
| 19 |
+
|
| 20 |
+
BASELINE = FEATURE_COLUMNS # 40 features
|
| 21 |
+
FRACDIFF = FEATURE_COLUMNS + ["fracdiff_close_35", "fracdiff_close_40"]
|
| 22 |
+
|
| 23 |
+
STOCKS = ["2330", "0050", "2317", "2454", "2881"]
|
| 24 |
+
LABEL_HORIZON = 5
|
| 25 |
+
LABEL_THRESH = 0.02
|
| 26 |
+
MIN_TRAIN = 240
|
| 27 |
+
STEP = 21
|
| 28 |
+
RF = dict(n_estimators=200, max_depth=6, min_samples_leaf=10,
|
| 29 |
+
class_weight="balanced", random_state=42, n_jobs=-1)
|
| 30 |
+
|
| 31 |
+
def fetch_df(stock_no):
|
| 32 |
+
from services.predictor_service import _fetch_with_cache
|
| 33 |
+
from indicators.technical import add_all_indicators, add_cross_asset_tw
|
| 34 |
+
from data.institutional_flow import add_institutional_flow
|
| 35 |
+
from data.margin_flow import add_margin_flow
|
| 36 |
+
from data.fetcher import fetch_cross_asset_tw
|
| 37 |
+
df = _fetch_with_cache(stock_no, months=24)
|
| 38 |
+
if df is None or df.empty: return None
|
| 39 |
+
df = add_all_indicators(df)
|
| 40 |
+
df = add_institutional_flow(df, stock_no)
|
| 41 |
+
df = add_margin_flow(df, stock_no)
|
| 42 |
+
start, end = str(df["date"].min()), str(df["date"].max())
|
| 43 |
+
result = fetch_cross_asset_tw(start, end)
|
| 44 |
+
taiex, usdtwd = result[0], result[1]
|
| 45 |
+
sox = result[2] if len(result) > 2 else None
|
| 46 |
+
tnx = result[3] if len(result) > 3 else None
|
| 47 |
+
df = add_cross_asset_tw(df, taiex, usdtwd, sox_close=sox, tnx_close=tnx)
|
| 48 |
+
return df
|
| 49 |
+
|
| 50 |
+
def make_labels(close):
|
| 51 |
+
fwd = close.shift(-LABEL_HORIZON)
|
| 52 |
+
ret = (fwd - close) / close
|
| 53 |
+
return np.where(ret > LABEL_THRESH, 1, np.where(ret < -LABEL_THRESH, -1, 0))
|
| 54 |
+
|
| 55 |
+
def walk_forward(feat, cols):
|
| 56 |
+
avail = [c for c in cols if c in feat.columns]
|
| 57 |
+
labels = feat["_label"].values
|
| 58 |
+
X_all = feat[avail].fillna(0).values
|
| 59 |
+
n = len(feat)
|
| 60 |
+
y_true_all, y_pred_all = [], []
|
| 61 |
+
cutoff = MIN_TRAIN
|
| 62 |
+
while cutoff + STEP + LABEL_HORIZON <= n:
|
| 63 |
+
X_tr, y_tr = X_all[:cutoff], labels[:cutoff]
|
| 64 |
+
test_end = min(cutoff + STEP, n - LABEL_HORIZON)
|
| 65 |
+
X_te, y_te = X_all[cutoff:test_end], labels[cutoff:test_end]
|
| 66 |
+
if len(np.unique(y_tr)) < 2 or len(X_te) == 0:
|
| 67 |
+
cutoff += STEP; continue
|
| 68 |
+
clf = RandomForestClassifier(**RF)
|
| 69 |
+
clf.fit(X_tr, y_tr)
|
| 70 |
+
y_pred = clf.predict(X_te)
|
| 71 |
+
y_true_all.extend(y_te.tolist())
|
| 72 |
+
y_pred_all.extend(y_pred.tolist())
|
| 73 |
+
cutoff += STEP
|
| 74 |
+
if not y_true_all: return {}
|
| 75 |
+
y_true = np.array(y_true_all)
|
| 76 |
+
y_pred = np.array(y_pred_all)
|
| 77 |
+
dir_mask = y_pred != 0
|
| 78 |
+
acc = (y_true == y_pred).mean() * 100
|
| 79 |
+
dir_acc = (y_true[dir_mask] == y_pred[dir_mask]).mean() * 100 if dir_mask.sum() > 0 else float("nan")
|
| 80 |
+
up_mask = y_pred == 1
|
| 81 |
+
up_prec = (y_true[up_mask] == 1).mean() * 100 if up_mask.sum() > 0 else float("nan")
|
| 82 |
+
return {"accuracy": round(acc, 1), "dir_accuracy": round(dir_acc, 1),
|
| 83 |
+
"up_precision": round(up_prec, 1), "n_predictions": len(y_true),
|
| 84 |
+
"n_features": len(avail)}
|
| 85 |
+
|
| 86 |
+
def main():
|
| 87 |
+
from models.predictor import _build_features
|
| 88 |
+
results = {}
|
| 89 |
+
hdr = f"{'Stock':>6} {'Set':>10} {'Acc%':>5} {'Dir%':>5} {'UpPrec%':>7} {'Feat':>4}"
|
| 90 |
+
print(f"\n{hdr}\n{'-'*len(hdr)}")
|
| 91 |
+
agg = {"baseline": [], "fracdiff": []}
|
| 92 |
+
for stock_no in STOCKS:
|
| 93 |
+
print(f" computing {stock_no}...", end="\r", flush=True)
|
| 94 |
+
df = fetch_df(stock_no)
|
| 95 |
+
if df is None or df.empty: continue
|
| 96 |
+
feat = _build_features(df)
|
| 97 |
+
# Pass fracdiff columns through from df (not built by _build_features)
|
| 98 |
+
for col in ("fracdiff_close_35", "fracdiff_close_40"):
|
| 99 |
+
if col in df.columns:
|
| 100 |
+
feat[col] = df[col].values
|
| 101 |
+
close = df.set_index("date")["close"] if "date" in df.columns else df["close"]
|
| 102 |
+
feat["_label"] = make_labels(close)
|
| 103 |
+
results[stock_no] = {}
|
| 104 |
+
for name, cols in [("baseline", BASELINE), ("fracdiff", FRACDIFF)]:
|
| 105 |
+
r = walk_forward(feat, cols)
|
| 106 |
+
results[stock_no][name] = r
|
| 107 |
+
if r:
|
| 108 |
+
print(f"{stock_no:>6} {name:>10} {r['accuracy']:>5.1f} {r['dir_accuracy']:>5.1f} {r['up_precision']:>7.1f} {r['n_features']:>4}")
|
| 109 |
+
agg[name].append(r)
|
| 110 |
+
if results[stock_no].get("baseline") and results[stock_no].get("fracdiff"):
|
| 111 |
+
rb, rf = results[stock_no]["baseline"], results[stock_no]["fracdiff"]
|
| 112 |
+
dd = rf["dir_accuracy"] - rb["dir_accuracy"]
|
| 113 |
+
print(f"{'':>6} {'delta':>10} {'':>5} {dd:>+5.1f}")
|
| 114 |
+
print()
|
| 115 |
+
|
| 116 |
+
def mean_field(rows, f):
|
| 117 |
+
vals = [r[f] for r in rows if not (isinstance(r.get(f), float) and np.isnan(r.get(f, float("nan"))))]
|
| 118 |
+
return round(sum(vals)/len(vals), 1) if vals else float("nan")
|
| 119 |
+
|
| 120 |
+
print("=== AGGREGATE ===")
|
| 121 |
+
agg_summary = {}
|
| 122 |
+
for name in ("baseline", "fracdiff"):
|
| 123 |
+
rows = agg[name]
|
| 124 |
+
s = {k: mean_field(rows, k) for k in ("accuracy", "dir_accuracy", "up_precision")}
|
| 125 |
+
agg_summary[name] = s
|
| 126 |
+
print(f" {name:>10}: acc={s['accuracy']}% dir={s['dir_accuracy']}% up_prec={s['up_precision']}%")
|
| 127 |
+
|
| 128 |
+
winner = "fracdiff" if agg_summary["fracdiff"]["dir_accuracy"] > agg_summary["baseline"]["dir_accuracy"] else "baseline"
|
| 129 |
+
passed = agg_summary["fracdiff"]["dir_accuracy"] >= 31.5
|
| 130 |
+
print(f"\n Winner: {winner} | passed={passed}")
|
| 131 |
+
|
| 132 |
+
out = {"results": results, "aggregate": agg_summary, "winner": winner,
|
| 133 |
+
"pass_threshold": 31.5, "passed": passed}
|
| 134 |
+
Path("docs").mkdir(exist_ok=True)
|
| 135 |
+
with open("docs/c1_fracdiff_result.json", "w") as f:
|
| 136 |
+
json.dump(out, f, indent=2)
|
| 137 |
+
return out
|
| 138 |
+
|
| 139 |
+
if __name__ == "__main__":
|
| 140 |
+
main()
|