Python toolkit for factor research, portfolio construction, and backtesting
AlphaForge is an end-to-end research stack that takes you from raw market data to a fully attributed, reproducible strategy report — without leaving Python.
flowchart LR
A[Data / Providers] --> B[Panel & Quality]
B --> C[Factors 42]
C --> D[ML walk-forward CV]
D --> E[Risk Model Σ=B F Bᵀ+D]
E --> F[Portfolio Optimizer]
F --> G[Backtest engine]
G --> H[Attribution]
H --> I[HTML Report]
I --> J[Research Copilot]
The CLI, API and dashboard share a research pipeline, and the report reads its outputs. The default research copilot summarizes tool outputs with fixed rules. Matching data, configuration, seed and dependency versions are needed to compare results across runs.
Documentation · Sample report · Reproduction record
| Layer | What it does |
|---|---|
| Data | Pluggable providers (sample, local, yahoo, akshare). tushare is a reserved adapter slot — the wiring point exists but no live adapter ships yet (you must supply a token + implement the fetch). Point-in-time universe, survivorship flags, ETL quality gates. |
| Factors | 40+ cross-sectional factors across momentum, value, quality, risk, liquidity, size; winsorize / standardize / neutralize. |
| Models | Ridge / ElasticNet / RandomForest / LightGBM under walk-forward CV with purge + embargo; Rank-IC diagnostics. |
| Risk | Fundamental multi-factor model Σ = B F Bᵀ + D; vol targeting, Euler risk decomposition. |
| Portfolio | equal-weight, min-variance, mean-variance, max-sharpe (Charnes-Cooper), risk-parity; constraint-relaxation ladder. |
| Execution | Commission + slippage + square-root market impact; look-ahead-guarded broker simulation. |
| Backtest | Event-driven accounting loop; execution lag, delist handling, mark-to-market. Reports gross vs net Sharpe/CAGR so the cost drag is explicit. |
| Attribution | Brinson-Fachler (sectors) + returns-based factor attribution (styles). |
| Market Regime | Bull/Bear x High/Low-Vol classification from trailing info only; per-regime factor IC + portfolio stats. |
| Stress Testing | Scenario P&L on risk-model factor shocks (e.g. market → -10%, momentum → -2σ) + sector shocks. |
| Report | Self-contained HTML (base64 figures) + a deterministic copilot briefing. |
| Apps | FastAPI research service + Streamlit dashboard. |
AlphaForge is a pipeline of pure-ish modules. The CLI, FastAPI service and
Streamlit dashboard use alphaforge.pipeline.ResearchPipeline.run to run the
same research stages:
data → panel → factors → ML (walk-forward) → risk model → portfolio → backtest → attribution → report → copilot
- Data layer turns a provider's raw pull into a long-format panel, runs ETL quality gates, and attaches point-in-time membership + a survivorship flag.
- Factor layer computes, preprocesses and evaluates 42 cross-sectional signals with information-coefficient discipline.
- Model layer trains an alpha under walk-forward CV (purge + embargo) and emits an out-of-sample Rank-IC.
- Risk model decomposes covariance into
Σ = B F Bᵀ + Dand Euler risk contributions. - Portfolio turns scores into constrained target weights.
- Backtest is a pure accounting loop that charges real costs and guards look-ahead.
- Attribution + Report + Copilot explain the result and emit a briefing grounded in real numbers.
git clone https://github.com/dev-belly/alphaforge.git
cd alphaforge
python -m venv .venv && source .venv/bin/activate
pip install -e ".[api,dashboard,viz,dev]"Run the full pipeline from the CLI:
alphaforge --start 2016-01-01 --end 2024-12-31 --report-dir research/reportsOr programmatically:
from alphaforge.pipeline import run_research
state = run_research(start="2016-01-01", end="2024-12-31")
print(state.backtest.summary())
print(state.report_path) # research/reports/research_report.htmlServe the research API:
alphaforge --serve-api # uvicorn on :8000
# curl -X POST localhost:8000/research/run -H 'content-type: application/json' \
# -d '{"start":"2019-01-01","end":"2024-12-31"}'Launch the dashboard:
streamlit run apps/dashboard/streamlit_app.pyEvery figure below is rendered from the shipped synthetic sample dataset by
python scripts/publish_sample.py (seed 42) — they demonstrate the pipeline, not a
tradeable edge. Reports from other dates, configurations or dependency versions
can differ from these checked-in figures.
Net equity curve & drawdown (backtest window 2019–2024, monthly rebalance):
Top factors by Rank-IC (of 42 evaluated; positive Rank-IC indicates a positive rank association with forward returns on the synthetic sample):
Annualized return by market regime (Bull/Bear × High/Low-Vol):
alphaforge.data.providers exposes one interface (fetch_prices,
fetch_fundamentals, fetch_macro, fetch_constituents, fetch_industry,
benchmark_prices, symbols). The bundled sample provider is fully synthetic
but point-in-time; local reads Parquet; yahoo / akshare are live adapters.
Their parsing, column-mapping and failure handling are unit-tested offline
against injected fakes (tests/unit/test_vendors.py, 100% on vendors.py); only
the live HTTP path is unvalidated, because CI has no egress. tushare is a
reserved slot. Every
ETL pass produces a quality report (coverage, staleness, survivorship flag) that
is persisted next to the artefacts.
FactorLibrary computes 42 factors across 7 categories (momentum, reversal,
value, quality, risk, liquidity, size), then winsorises, standardises and
neutralises them (market-cap / industry / book). evaluate_factor reports
per-date Pearson + Rank-IC with distributional stats (ic_mean, icir,
overlapping-window-corrected t-stat, positive-IC ratio, quantile long-short
spread, year-by-year stability, IC decay). Screening 42 factors at 5% yields ~2
false positives, so a Benjamini-Hochberg FDR flag rides alongside naive p-values.
See docs/modules/factor_research.md.
models/split.py builds folds with purge (drop training labels that overlap
the validation window) and embargo (a gap after each train fold). Ridge /
ElasticNet / RandomForest / LightGBM are supported; the IC that reaches the
portfolio is always the walk-forward out-of-sample IC, never an in-sample fit.
A fundamental multi-factor model Σ = B F Bᵀ + D with 17 factors (market, size,
value, momentum, volatility, liquidity, quality + 10 sector dummies) explains
R² ≈ 0.50 of cross-sectional variance on the sample run; Euler risk
contributions are exact (euler_identity_gap = 0). The covariance used by the
portfolio is estimated on the trailing window only, default ledoit_wolf
(constant-correlation shrinkage), also sample / ewma / shrinkage /
factor.
PortfolioConstructor converts a score into an expected return
(mu = shrunk_ic · z · Σ, cash-neutral, IC shrunk toward zero), estimates the
trailing-window covariance, then solves via PortfolioOptimizer: equal-weight,
min-variance, mean-variance (QP), max-sharpe (Charnes-Cooper), or risk-parity
(SLSQP). Constraints are explicit: long-only, position cap, turnover limit,
industry-deviation (penalised slack), vol target. An infeasibility ladder
(drop vol ceiling → relax budget) protects against an unsolvable QP; a still-failed
solve holds the current book rather than returning an invalid one. See
docs/modules/portfolio_optimization.md.
execution/costs.py models commission + slippage (linear in notional) + square-root
market impact; execution/broker.py deducts them per trade and rebalances to
target weights. The simulator deducts modeled costs from portfolio cash and
reports their effect on net returns. Execution and market-impact assumptions
remain approximations; timing risk from unfilled orders is discussed in
Limitations.
The engine is a pure accounting loop with four guards: signals use only
data available on the rebalance date; orders execute execution_lag_days
sessions later (look-ahead guard); the day's return is earned on the pre-trade
book with costs deducted at session end; untradeable names keep their position
and dark names are force-liquidated only after a grace window (no undeclared
survivorship bias). Gross (pre-cost) metrics are reconstructed exactly by adding
the per-day cost drag back into the net series, so the drag is explicit. See
docs/modules/backtesting.md.
performance_stats computes Sharpe (excess over the period's rf accrual),
Sortino (downside-deviation, all observations), MaxDD, Jensen's alpha/beta/IR,
VaR/CVaR — annualisation inferred from the index, never assumed. Attribution is
Brinson-Fachler by sector plus returns-based factor attribution; the copilot
states the allocation/selection split explicitly.
risk/regime.py labels each day Bull/Bear × High/Low-Vol from trailing
information only (no future data) and reports per-regime factor IC + portfolio
stats. risk/stress.py shocks the current book's factor exposures
(market_drawdown_10pct, momentum_crash_2sigma, sector shocks) for scenario P&L.
Both are research aids that label the past and shock the present — they do not
forecast the next regime.
reporting/report.py emits a self-contained HTML (base64 figures: equity,
drawdown, monthly heatmap, risk contribution, quantile bar, Brinson, regime,
stress) plus a deterministic copilot briefing. The copilot (agents/) reads only
a tool layer that wraps real upstream outputs and returns plain objects; fixed
rules turn those into findings/warnings/repro-checks. The default none mode has
no LLM call at all; openai/anthropic modes fall back to the deterministic brief if
the call fails. See docs/modules/ai_agent.md.
alphaforge.utils.config.set_global_seed seeds every RNG. The same config + seed
is intended to reproduce numeric results with matching data and dependencies;
the report includes a generation timestamp. The copilot's findings are rule-driven, so a reviewer can
trace every sentence to a metric.
pytest uses a slow marker: fast unit + regression runs gate every push; the
slow suite runs the full pipeline + live API. ruff (lint + format) and mypy
(0 findings) gate too. CI is green on Python 3.10 / 3.11 / 3.12; the full
suite (incl. the slow pipeline + API run) is additionally verified locally on
Python 3.13 (pandas 3.0 / NumPy 2.5). Full-suite line coverage is ~86%
(measured with pytest-cov in the Integration job), and no module sits at 0%.
Anything that cannot be exercised for real is exercised against fakes instead:
yahoo/akshare— parsing, column mapping and failure handling are tested offline (100% onvendors.py) with fakes injected intosys.modules; only the live HTTP path is unvalidated, because CI has no egress.cli— argument parsing, symbol normalisation and the API-server branch are tested in-process with a fake pipeline (97%); the real end-to-end run is covered by the slow integration test.local— the Parquet backend is tested against atmp_pathstore (100%), including the universe it reports to the ETL from persisted artefacts.pipeline— the ETL entry point is driven through a fake provider (100%): universe resolution, the empty-panel hard failure, benchmark-as-returns, the persisted-bundle round trip, the quality-gate warning, and the persist / reload paths for non-empty fundamentals, macro and constituents.storage— the Parquet store and its DuckDB surface are exercised against atmp_path(100%), including the incremental upsert that must correct a(date, symbol)bar in place rather than duplicate it.providers— the config-driven factory is covered for every alias (100%), so a typo in the provider setting raises an actionable error instead of silently running against the wrong backend.features— the wide panel that every factor, model, optimiser and backtest consumes is locked offline (100% onpanel.py): returns come from adjusted prices and a price gap is never filled, a missing source column yields an all-NaN float panel rather than an object one that silently breaks every downstream comparison and rolling window, a substituted market cap is disclosed inmetadata["market_cap_source"](and admitted asunavailablewhen even the dollar-volume proxy is empty), the universe is reindexed onto the panel and masked by having a price, and a price table withoutadj_closefails loudly instead of producing a zero-breadth panel. The point-in-time fundamentals layer is locked too (100% onfundamentals.py): a statement is invisible before itsreport_dateand visible from it on, andfiscal_periodis never a join key, so a February filing cannot leak into January; the staleness guard retires a release once it is older thanmax_staleness_dayswhilestaleness()keeps reporting its true age as a diagnostic; a ratio divides a point-in-time numerator by a point-in-time denominator (or by the market cap of the signal date), and a zero or overflowing denominator becomes NaN instead of poisoning the cross-section. The market-cap alignment returns values in the caller's row order -merge_asofneeds a report-date sort that the caller needs undone, and skipping the restore gave every statement the market cap of whichever symbol happened to report nearby. Enterprise value is derived from debt alone: gating it ontotal_equityas well madeebit_to_ev(which declaresfundamental:ebit,total_debt,market_cap) silently all-NaN for any provider reporting debt without equity.factors— the cross-sectional preprocessing chain is locked offline (100% onpreprocessing.py): per-date winsorization / z-scoring / ranking, and Frisch-Waugh-Lovell industry + size neutralisation asserted by orthogonality rather than by "it ran". Unscored names must keep the neutral fill value, never a residual invented from their size.portfolio— the expected-returns bridge (Grinoldmu = shrunk_IC * z * sigma) is locked offline (100% onexpected_returns.py): cash-neutral alphas, linear IC/volatility scaling, score de-meaning, outlier clipping, volatility-median fill, annualisation bysqrt(periods), and the L1-normalised alpha blend. The score -> target-weights bridge is locked too (100% onconstructor.py): the covariance at a rebalance date is estimated on returns strictly before that date - corrupting every return on or after it leaves the matrix bit-identical - eligibility is a history test (a 60-observation floor) intersected with a tradability test, volatility is the annualised diagonal with names the covariance dropped filled at the cross-sectional median rather than NaN, and the volatility-target fallback de-levers a hot book into cash while leaving a book that already fits the budget untouched. A partialportfolio:section no longer switches those constraints off: an absent key now falls back to the dataclass default and only an explicitnulldisables one, soOptimizerConfig.from_dict({}) == OptimizerConfig(). Previously a section that merely omittedtarget_volatility(orturnover_limit,max_holdings,max_industry_deviation) silently ran with no volatility budget, no turnover cap, unlimited holdings and no industry cap - the four constraints the module exists to enforce.cost_bpsanddust_thresholdwere also unreachable from config; they are read now.models— the walk-forward and purged-K-fold splitters are locked offline (100% onsplit.py): training always ends before the test block opens, no surviving training label is still forming when it opens (purge), and no test observation sits withinembargo_daysof a training observation on either edge of the block. Purge and embargo are anchored on the train/test seam rather than on the last training date - anchoring on the latter silently emptied 4 of the 5PurgedKFoldsplits, turning cross-validation into a single split. The evaluation layer is locked too (100% onevaluation.py): the vectoriseddaily_rank_icis asserted against a brute-force per-date Spearman correlation, quantile buckets are monotone for a signal that really does order the realised return and a date too thin to fill every bucket is NaN rather than a bucket quietly absorbing its neighbours,prediction_turnoveris checked against a hand-computed value, andt_statapplies then / horizonoverlap correction rather than a naivesqrt(n): on the test fixture (500 periods, horizon 21) that is t=9.52 corrected against t=43.62 naive, a 4.6x overstatement of significance.fold_metricsnow carries exactly onen_dayscolumn: the per-fold observation count used to be renamed onto the existingn_days, so the frame held two columns of the same name andfolds["n_days"]returned a DataFrame whose value was the prediction-row count (n_dates x n_symbols) rather than the number of IC observations. The dataset builder is locked as well (100% ondataset.py), and there the property under test is alignment: the long frame is built byravel()-ing each(dates x symbols)panel into a pre-sizedMultiIndex.from_product([dates, symbols]), so a disagreement between those two orders would attach every feature to the wrong name with no error and no shape mismatch. A factor panel encoded ast * 100 + sis therefore asserted to come back ast * 100 + s, and the label is recomputed from the panel and merged on(date, symbol)instead of being trusted. The two ranked target modes are pinned to their documented ranges -forward_rankin(0, 1]andforward_returnin(-0.5, 0.5]- because both used to emit the centred version, which handedforward_ranka[-0.5, 0.5]label the docstring says is[0, 1]and madetargeta parameter with no effect. A vestigialnotna().all(axis=1)mask was also computed over the whole feature matrix only to be summed back to a row count: the features are filled two lines earlier, so it was True by construction.
pytest -m "not slow" # fast unit + regression (no heavy pipeline)
pytest # everything, including the slow pipeline/API runsCI coverage (.github/workflows/ci.yml): the test matrix runs ruff check,
ruff format --check and pytest -m "not slow" on py3.10/3.11/3.12; the
integration job runs the full pytest suite, which drives the FastAPI service
through fastapi.testclient.TestClient (starts the pipeline, then serves
/backtest, /attribution, /report, /briefing, /optimize, /backtests,
/risk and every copilot /agent/query tool).
Verified by actually executing (not just claimed) the three delivery surfaces:
- Demo —
python -m alphaforge.cli --start 2016-01-01 --end 2024-12-31runs the whole stack end-to-end and writesresearch/reports/research_report.html(42 factors, walk-forward Rank-IC ≈ +0.045, risk-model R² ≈ 0.50, backtest CAGR +0.79% / Sharpe 0.13 / MaxDD −22.8%). - API —
uvicorn alphaforge_api.main:appwas launched and exercised with a real run:POST /research/runplusGET/factors /backtest /risk /briefing /attribution /regime /stress /portfolio/* /report,POST/optimize /backtests /agent/query— all 22 endpoints returned 200 with real data. - Dashboard —
streamlit run apps/dashboard/streamlit_app.pylaunches and serves (health + main page 200); the pipeline it runs on the Run button is the same engine the demo and API already proved.
The full gate (mypy 0 findings, ruff clean, pytest -m "not slow" green) is run by
CI on every push; the slow integration run is part of the same workflow.
All strategy parameters live in configs/default.yaml. The CLI/API/SDK only
override the knobs you change most often. Nothing is hard-coded in the engine.
AlphaForge is an engineering-quality research harness, not a production trading system or an investment product. Be explicit about what it is and is not:
- Synthetic / sampled data is not real. The bundled
sampleprovider is fully synthetic but point-in-time. Any numbers it produces demonstrate the pipeline, not a tradeable edge. The survivorship-bias disclaimer in the data layer is there for a reason. - Survivorship handling is honest but not perfect. Delisted names are force-liquidated only after a grace window rather than dropped, which avoids an undeclared survivorship bias — but the universe itself still reflects point-in-time membership and is only as good as the upstream provider.
- Costs are a model. Commission + slippage are linear in notional; market impact follows the square-root law. The multi-day work of a large order is charged at the capped-day impact but its timing risk (the market moving against the unfilled remainder) is not modelled, so very large orders are mildly optimistic. Gross vs net metrics are reported precisely so this drag is visible, not hidden.
- Historical ≠ future. Walk-forward CV, purge/embargo and FDR screening exist to fight overfitting; they do not guarantee out-of-sample performance. ICIR and the Benjamini-Hochberg pass rate are the honest bars, not the in-sample IC.
tushareis reserved, not live. The provider slot exists; no live adapter ships. Plug in your own token + fetch before claiming live-data coverage.- Regime & stress are research aids, not signals. They label the past and shock the current book; they do not forecast the next regime.
- Single-node, in-process cache. The API caches the last run in memory; a multi-user deployment needs a job queue + object store in front of it.
- Docker build needs a running daemon + registry egress.
docker compose configvalidates the stack, but building the image pullspython:3.11-slimfrom Docker Hub; in a daemon-less or network-isolated environment the image cannot be built (documented, not a code defect).
Running the full pipeline on the synthetic sample provider
(alphaforge --start 2016-01-01 --end 2024-12-31) produces, by construction:
- a risk-model R² around 0.5 — the style factors explain roughly half of
cross-sectional variance (the rest is specific risk
D); - a factor-attribution R² around 0.80 — most of the portfolio's excess return is explained by its style exposures;
- a Brinson active return within ~1e-3 of the three-term allocation + selection + interaction split (the known approximation gap);
- gross vs net Sharpe/CAGR that diverge by exactly the charged cost drag, confirming costs are accounted for end-to-end;
- a regime split (Bull/Bear × High/Low-Vol) and a stress book
(e.g.
market_drawdown_10pct,momentum_crash_2sigma) showing the portfolio's factor-driven loss under named adverse paths.
These figures are intended to be reproducible with the same data, config, seed
and dependencies, and are meant to validate the plumbing. Replace sample with
a real provider before reading
them as market insight. Full walk-through: research/case_study.md.
- Module guides (mkdocs):
docs/— Factor Research, Portfolio Optimization, Backtesting, Risk, Research Copilot, Data & Quality, API. - Case study (real engine output on synthetic data): research/case_study.md.
- Interview Q&A (20 grounded questions): research/interview_qa.md.
- Final engineering report: docs/FINAL_ENGINEERING_REPORT.md.
research/interview_qa.md answers 20 likely
interview questions — look-ahead guards, walk-forward CV, gross-vs-net costs,
FDR factor screening, Ledoit-Wolf shrinkage, the infeasibility ladder, and how
the copilot stays non-hallucinating — each mapped to the source file that
implements it.
See CONTRIBUTING.md. The repo ships a .pre-commit-config.yaml
(ruff + the same gates CI runs) and a Makefile with make lint, make test,
make type.
- Real-time / pooled data vendor behind the existing adapter interface.
- Job queue + object store in front of the API for multi-user deployments.
- Timing-risk modelling for large orders in the execution simulator.
- Experiment/params store for walk-forward sweep comparison.
MIT


