Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ on:

jobs:
test:
name: Test (py${{ matrix.python-version }})
runs-on: ubuntu-latest
name: Test (py${{ matrix.python-version }}, ${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
python-version: ["3.10", "3.11", "3.12"]
include:
- os: windows-latest
python-version: "3.12"
steps:
- uses: actions/checkout@v4

Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,20 @@ python -m venv .venv && source .venv/bin/activate
pip install -e ".[api,dashboard,viz,dev]"
```

On Windows PowerShell, use the environment directly (activation is optional):

```powershell
py -3.12 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -e ".[api,dashboard,viz,dev]"
.\.venv\Scripts\alphaforge.exe --help
```

Use a short checkout path such as `C:\src\alphaforge` when Windows long-path
support is disabled; recent Streamlit packages contain deeply nested resources.
Wheel installs include the default settings. Editable checkouts continue to use
`configs/default.yaml`; `--config` selects an explicit file. CI checks Ubuntu
Python 3.10–3.12 and Windows Python 3.12; the full pipeline/API job runs on Ubuntu.

## Quick start

Run the full pipeline from the CLI:
Expand Down Expand Up @@ -566,6 +580,10 @@ Anything that cannot be exercised for real is exercised against fakes instead:
**undefined** Calmar rather than an infinite one, a zero-variance series does
not divide by zero, an empty series reports an error instead of raising, and
relative stats need three overlapping points before beta means anything.
Drawdowns include the initial capital: a first-period loss of 20% followed
by a 10% gain still has a 20% maximum drawdown and ends 12% below its starting
wealth. Summary statistics, rolling metrics, episode tables and report charts
share that baseline; an immediate wipeout remains a 100% drawdown.
The gross-vs-net reconciliation gets its own identity test - the docstring
claims the additive cost add-back recovers the gross series *exactly*, and it
does, but only because the engine compounds the pre-cost NAV from the
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ where = ["src"]
alphaforge = "alphaforge.cli:main"

[tool.setuptools.package-data]
alphaforge = ["py.typed"]
alphaforge = ["py.typed", "resources/default.yaml"]

[tool.pytest.ini_options]
testpaths = ["tests"]
Expand Down
4 changes: 1 addition & 3 deletions src/alphaforge/backtest/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,9 +282,7 @@ def drawdown_table(returns: pd.Series, top: int = 5) -> pd.DataFrame:
r = returns.dropna()
if r.empty:
return pd.DataFrame()
curve = compound(r)
peak = curve.cummax()
dd = curve / peak - 1.0
dd = drawdown_series(r)

episodes = []
in_dd = False
Expand Down
8 changes: 6 additions & 2 deletions src/alphaforge/reporting/charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,12 @@ def equity_curve(
return _fig_to_b64(fig)


def drawdown(equity: pd.Series) -> str:
curve = equity / equity.cummax() - 1.0
def drawdown(equity: pd.Series, *, initial_capital: float | None = None) -> str:
"""Plot drawdowns, including pre-first-session capital when supplied."""
peak = equity.cummax()
if initial_capital is not None:
peak = peak.clip(lower=initial_capital)
curve = equity / peak - 1.0
fig, ax = plt.subplots(figsize=(8, 2.4))
ax.fill_between(curve.index, curve.to_numpy() * 100, 0, color=RED, alpha=0.35)
ax.plot(curve.index, curve.to_numpy() * 100, color=RED, lw=0.8)
Expand Down
6 changes: 5 additions & 1 deletion src/alphaforge/reporting/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,11 @@ def build_html(inputs: ReportInputs) -> str:
initial_capital=getattr(bt, "config", {}).get("initial_capital"),
)
)
drawdown_b64 = _img(charts.drawdown(bt.equity))
drawdown_b64 = _img(
charts.drawdown(
bt.equity, initial_capital=getattr(bt, "config", {}).get("initial_capital")
)
)
monthly = _safe_monthly(bt.returns)
monthly_b64 = _img(charts.monthly_heatmap(monthly))
if bt.metrics:
Expand Down
110 changes: 110 additions & 0 deletions src/alphaforge/resources/default.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# =============================================================================
# AlphaForge - Core configuration
# All strategy / pipeline parameters live here. Nothing is hard-coded in code.
# =============================================================================

project:
name: AlphaForge
seed: 42
timezone: UTC

data:
# sample | local | yahoo | akshare | tushare
provider: sample
universe: sp500_sample
start_date: "2015-01-01"
end_date: "2024-12-31"
cache_dir: data/processed
raw_dir: data/raw
storage_backend: parquet # parquet | duckdb
# If the provider cannot supply a full historical index-membership history,
# survivorship bias is unavoidable. Flagged in README & DataQualityReport.
survivorship_bias_disclaimer: true
# Fundamental data enters the system only after the public release date.
fundamental_lag_days: 90

universe:
benchmark: SP500
min_price: 5.0
min_adv_usd: 1_000_000
exclude_financials_for_value: false

factor:
winsorize: true
winsorization: 0.01
standardize: true
rank_transform: false
industry_neutralize: true
size_neutralize: true
# forward return horizon used for IC / ML labels (in trading days)
horizon: 21

model:
type: ridge # ridge | elasticnet | random_forest | lightgbm
params:
alpha: 1.0
target: forward_return # forward_return | forward_rank
# Walk-forward validation
walk_forward:
train_years: 4
test_years: 1
step_years: 1
embargo_days: 21
purge_days: 21
expanding: true

portfolio:
method: mean_variance # equal_weight | mean_variance | min_variance | max_sharpe | risk_parity
assumed_ic: 0.03 # fixed before the run; OOS evaluation IC is reporting only
long_only: true
fully_invested: true
max_weight: 0.05
min_weight: 0.0
target_volatility: 0.12
turnover_limit: 0.20
max_holdings: 50
cash_buffer: 0.0
max_industry_deviation: 0.03
risk_aversion: 5.0
tc_penalty: 0.5

risk:
covariance_method: ledoit_wolf # sample | ledoit_wolf | ewma
ewma_halflife: 90
style_factors: [size, value, momentum, volatility, liquidity, quality]
shrinkage_intensity: null

backtest:
start_date: "2019-01-01"
end_date: "2024-12-31"
rebalance: monthly # daily | weekly | monthly
initial_capital: 10_000_000
execution_lag_days: 1 # signal date -> execution date (prevents look-ahead)
benchmark: SP500
allow_short: false

cost:
commission_bps: 2.0
slippage_bps: 5.0
# market impact: impact = impact_coeff * sqrt(participation_rate) * value
impact_coeff_bps: 10.0
participation_cap: 0.10
min_commission: 0.0

reporting:
output_dir: research/reports
figure_dpi: 130
figure_format: png

agent:
enabled: true
# If no LLM credential is configured the copilot degrades to a deterministic
# rule-based analyst that reads the same real tool outputs. No fabrication.
provider: none # none | openai | anthropic
model: null
max_tool_calls: 8

logging:
level: INFO
json: false
file: logs/alphaforge.log
13 changes: 11 additions & 2 deletions src/alphaforge/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@
log = get_logger("config")

PROJECT_ROOT = Path(__file__).resolve().parents[3]
DEFAULT_CONFIG_PATH = PROJECT_ROOT / "configs" / "default.yaml"
PACKAGED_CONFIG_PATH = Path(__file__).resolve().parents[1] / "resources" / "default.yaml"


def _default_config_path() -> Path:
"""Prefer editable checkout settings, with bundled defaults for wheels."""
checkout = PROJECT_ROOT / "configs" / "default.yaml"
return checkout if checkout.is_file() else PACKAGED_CONFIG_PATH


DEFAULT_CONFIG_PATH = _default_config_path()


def config_integer(value: Any, name: str) -> int:
Expand Down Expand Up @@ -92,7 +101,7 @@ class Config:
def load(
cls, path: str | Path | None = None, overrides: dict[str, Any] | None = None
) -> Config:
path = Path(path) if path else DEFAULT_CONFIG_PATH
path = Path(path) if path else _default_config_path()
raw = load_yaml(path)
raw = deep_merge(raw, overrides or {})
raw = deep_merge(raw, _env_overrides())
Expand Down
10 changes: 6 additions & 4 deletions src/alphaforge/utils/math_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,18 +229,20 @@ def compound(returns: pd.Series) -> pd.Series:


def max_drawdown(returns: pd.Series) -> tuple[float, pd.Timestamp | None]:
curve = compound(returns)
running_max = curve.cummax()
dd = curve / running_max - 1.0
dd = drawdown_series(returns)
if dd.empty:
return 0.0, None
trough = dd.idxmin()
return float(dd.min()), trough


def drawdown_series(returns: pd.Series) -> pd.Series:
"""Drawdown from prior peaks, including the initial unit of capital."""
curve = compound(returns)
return curve / curve.cummax() - 1.0
# Without the initial wealth of 1, a first-period loss becomes the first
# peak and vanishes from every drawdown statistic. The floor also makes an
# immediate wipeout a -100% drawdown instead of 0/0.
return curve / curve.cummax().clip(lower=1.0) - 1.0


# --------------------------------------------------------------------------
Expand Down
13 changes: 13 additions & 0 deletions tests/unit/test_backtest_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,19 @@ def test_the_drawdown_table_is_empty_for_an_empty_series():
assert drawdown_table(pd.Series(dtype=float)).empty


def test_first_period_loss_reconciles_summary_rolling_series_and_episodes():
r = pd.Series([-0.2, 0.1, 0.25], index=pd.date_range("2020-01-31", periods=3, freq="ME"))
stats = performance_stats(r)
assert stats["max_drawdown"] == pytest.approx(-0.2)
assert stats["max_drawdown_date"] == "2020-01-31"
assert stats["max_drawdown_duration_days"] == 2
assert rolling_metrics(r, window=2)["drawdown"].tolist() == pytest.approx([-0.2, -0.12, 0.0])
episode = drawdown_table(r).iloc[0]
assert episode["depth"] == pytest.approx(stats["max_drawdown"])
assert episode["start"] == episode["trough"] == "2020-01-31"
assert episode["end"] == "2020-03-31"


# --------------------------------------------------------------------------
# summarise
# --------------------------------------------------------------------------
Expand Down
11 changes: 11 additions & 0 deletions tests/unit/test_math_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,17 @@ def test_drawdown_series_is_non_positive_and_ends_at_zero_peak():
assert dd.iloc[-1] == pytest.approx(0.0) # new high -> no drawdown


@pytest.mark.parametrize(
"returns, expected", [([-0.2, 0.1], [-0.2, -0.12]), ([-1.0, 0.0], [-1.0, -1.0])]
)
def test_drawdowns_include_initial_capital_and_first_period_wipeout(returns, expected):
r = pd.Series(returns)
assert drawdown_series(r).tolist() == pytest.approx(expected)
depth, trough = max_drawdown(r)
assert depth == pytest.approx(expected[0])
assert trough == 0


# --------------------------------------------------------------------------
# expanding_window_index() - walk-forward leakage guard
# --------------------------------------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/test_reporting_charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@ def test_drawdown_is_zero_at_a_new_high(monkeypatch) -> None:
np.testing.assert_allclose(captured["y"], np.zeros(len(DATES)), atol=1e-12)


def test_drawdown_chart_includes_first_session_loss_from_initial_capital(monkeypatch) -> None:
equity = pd.Series([80.0, 88.0, 110.0], index=DATES[:3])
captured = _capture(monkeypatch, "fill_between")
drawdown(equity, initial_capital=100.0)
np.testing.assert_allclose(captured["y"], [-20.0, -12.0, 0.0], atol=1e-12)


# ----------------------------------------------------------------------
# ic_series
# ----------------------------------------------------------------------
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/test_utils_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import pytest
import yaml

import alphaforge.utils.config as config_module
from alphaforge.utils.config import (
DEFAULT_CONFIG_PATH,
Config,
Expand All @@ -40,6 +41,26 @@
set_global_seed,
)


def test_bundled_defaults_match_the_authoritative_checkout_config():
assert config_module.PACKAGED_CONFIG_PATH.read_bytes() == DEFAULT_CONFIG_PATH.read_bytes()


def test_default_settings_work_without_checkout_and_explicit_paths_still_fail(
tmp_path, monkeypatch
):
monkeypatch.setattr(config_module, "PROJECT_ROOT", tmp_path)
bundled = Config.load()
assert bundled.get("project.seed") == 42
assert bundled.get("data.provider") == "sample"
with pytest.raises(FileNotFoundError, match="Config file not found"):
Config.load(tmp_path / "missing.yaml")
checkout = tmp_path / "configs" / "default.yaml"
checkout.parent.mkdir()
checkout.write_text("project:\n seed: 7\n", encoding="utf-8")
assert Config.load().get("project.seed") == 7


ENV_MAPPING = {
"ALPHAFORGE_DATA_PROVIDER": "data.provider",
"ALPHAFORGE_MODEL_TYPE": "model.type",
Expand Down
Loading