Files
zhutoutoutousan 605faf5310 Prepare source-only public release for develop.
Add cluster audit pipeline, united EA updates, brochure generators, and publication hygiene (gitignore, MT5 path desensitization, pre-upload scan). Remove tracked reports, models, and binary artifacts from the repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 15:03:43 +02:00

92 lines
3.0 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import pandas as pd
from .expressions import compile_expression
@dataclass
class EngineConfig:
factor_expression: str
rebalance_frequency: str = "M" # D/W/M/Q
long_quantile: float = 0.2
short_quantile: float = 0.2
long_short: bool = True
def run_factor_engine(df: pd.DataFrame, cfg: EngineConfig) -> tuple[pd.DataFrame, pd.DataFrame]:
data = df.copy()
data = data.sort_values(["date", "asset"]).reset_index(drop=True)
data["ret_1d"] = data.groupby("asset")["close"].pct_change().fillna(0.0)
expr = compile_expression(cfg.factor_expression)
data["score"] = expr.eval(data).replace([np.inf, -np.inf], np.nan)
rebalance_key = data["date"].dt.to_period(cfg.rebalance_frequency).astype(str)
data["rebalance_key"] = rebalance_key
weights = []
for _, bucket in data.groupby("rebalance_key"):
last_day = bucket["date"].max()
snap = bucket[bucket["date"] == last_day].copy()
snap = snap.dropna(subset=["score"])
if snap.empty:
continue
q_long = snap["score"].quantile(1.0 - cfg.long_quantile)
longs = snap[snap["score"] >= q_long][["asset"]].copy()
longs["w"] = 1.0 / max(len(longs), 1)
if cfg.long_short and cfg.short_quantile > 0:
q_short = snap["score"].quantile(cfg.short_quantile)
shorts = snap[snap["score"] <= q_short][["asset"]].copy()
shorts["w"] = -1.0 / max(len(shorts), 1)
snap_w = pd.concat([longs, shorts], ignore_index=True)
else:
snap_w = longs
snap_w["effective_date"] = last_day
weights.append(snap_w)
if not weights:
empty = pd.DataFrame(columns=["date", "portfolio_ret", "equity"])
return empty, data
wdf = pd.concat(weights, ignore_index=True)
data = data.merge(wdf, how="left", left_on=["date", "asset"], right_on=["effective_date", "asset"])
data["w"] = data.groupby("asset")["w"].ffill().fillna(0.0)
data["contrib"] = data["w"] * data["ret_1d"]
daily = data.groupby("date", as_index=False)["contrib"].sum().rename(columns={"contrib": "portfolio_ret"})
daily["equity"] = (1.0 + daily["portfolio_ret"]).cumprod()
return daily, data
def summary_metrics(equity_curve: pd.DataFrame) -> dict:
if equity_curve.empty:
return {"cagr": 0.0, "sharpe": 0.0, "max_dd": 0.0, "total_return": 0.0}
rets = equity_curve["portfolio_ret"]
total_return = equity_curve["equity"].iloc[-1] - 1.0
n = len(rets)
ann = 252
cagr = (equity_curve["equity"].iloc[-1] ** (ann / max(n, 1))) - 1.0
vol = rets.std(ddof=0) * np.sqrt(ann)
sharpe = (rets.mean() * ann) / vol if vol > 1e-12 else 0.0
rolling_max = equity_curve["equity"].cummax()
dd = equity_curve["equity"] / rolling_max - 1.0
max_dd = dd.min()
return {
"cagr": float(cagr),
"sharpe": float(sharpe),
"max_dd": float(max_dd),
"total_return": float(total_return),
}