From 086c1cddd146ebacacb9e5499f26088ef5b106a2 Mon Sep 17 00:00:00 2001 From: dev-belly Date: Sun, 11 Oct 2026 12:21:41 +0800 Subject: [PATCH] Include initial wealth in drawdowns and ship default configuration in wheels --- .github/workflows/ci.yml | 8 +- README.md | 18 +++++ pyproject.toml | 2 +- src/alphaforge/backtest/metrics.py | 4 +- src/alphaforge/reporting/charts.py | 8 +- src/alphaforge/reporting/report.py | 6 +- src/alphaforge/resources/default.yaml | 110 ++++++++++++++++++++++++++ src/alphaforge/utils/config.py | 13 ++- src/alphaforge/utils/math_utils.py | 10 ++- tests/unit/test_backtest_metrics.py | 13 +++ tests/unit/test_math_utils.py | 11 +++ tests/unit/test_reporting_charts.py | 7 ++ tests/unit/test_utils_config.py | 21 +++++ 13 files changed, 216 insertions(+), 15 deletions(-) create mode 100644 src/alphaforge/resources/default.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2e32cb..88a97ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 76d3d13..85a2459 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 928e7de..6ba7e0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/alphaforge/backtest/metrics.py b/src/alphaforge/backtest/metrics.py index 756fdaf..c27369f 100644 --- a/src/alphaforge/backtest/metrics.py +++ b/src/alphaforge/backtest/metrics.py @@ -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 diff --git a/src/alphaforge/reporting/charts.py b/src/alphaforge/reporting/charts.py index b5a452b..748ea18 100644 --- a/src/alphaforge/reporting/charts.py +++ b/src/alphaforge/reporting/charts.py @@ -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) diff --git a/src/alphaforge/reporting/report.py b/src/alphaforge/reporting/report.py index 5d8ccab..026aab2 100644 --- a/src/alphaforge/reporting/report.py +++ b/src/alphaforge/reporting/report.py @@ -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: diff --git a/src/alphaforge/resources/default.yaml b/src/alphaforge/resources/default.yaml new file mode 100644 index 0000000..931199f --- /dev/null +++ b/src/alphaforge/resources/default.yaml @@ -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 diff --git a/src/alphaforge/utils/config.py b/src/alphaforge/utils/config.py index ab3a14d..e8c7051 100644 --- a/src/alphaforge/utils/config.py +++ b/src/alphaforge/utils/config.py @@ -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: @@ -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()) diff --git a/src/alphaforge/utils/math_utils.py b/src/alphaforge/utils/math_utils.py index 80813da..5e2704b 100644 --- a/src/alphaforge/utils/math_utils.py +++ b/src/alphaforge/utils/math_utils.py @@ -229,9 +229,7 @@ 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() @@ -239,8 +237,12 @@ def max_drawdown(returns: pd.Series) -> tuple[float, pd.Timestamp | None]: 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 # -------------------------------------------------------------------------- diff --git a/tests/unit/test_backtest_metrics.py b/tests/unit/test_backtest_metrics.py index fba00ea..df74b07 100644 --- a/tests/unit/test_backtest_metrics.py +++ b/tests/unit/test_backtest_metrics.py @@ -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 # -------------------------------------------------------------------------- diff --git a/tests/unit/test_math_utils.py b/tests/unit/test_math_utils.py index 2f3b6d1..7090627 100644 --- a/tests/unit/test_math_utils.py +++ b/tests/unit/test_math_utils.py @@ -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 # -------------------------------------------------------------------------- diff --git a/tests/unit/test_reporting_charts.py b/tests/unit/test_reporting_charts.py index 1574947..3e50515 100644 --- a/tests/unit/test_reporting_charts.py +++ b/tests/unit/test_reporting_charts.py @@ -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 # ---------------------------------------------------------------------- diff --git a/tests/unit/test_utils_config.py b/tests/unit/test_utils_config.py index 5e20487..2bc818e 100644 --- a/tests/unit/test_utils_config.py +++ b/tests/unit/test_utils_config.py @@ -29,6 +29,7 @@ import pytest import yaml +import alphaforge.utils.config as config_module from alphaforge.utils.config import ( DEFAULT_CONFIG_PATH, Config, @@ -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",