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
12 changes: 10 additions & 2 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ on:
push:
branches: [main]

permissions:
contents: write

concurrency:
group: microalpha-docs
cancel-in-progress: true

jobs:
deploy:
runs-on: ubuntu-latest
Expand All @@ -22,12 +29,13 @@ jobs:
pip install mkdocs mkdocs-material

- name: Build documentation
run: mkdocs build
run: mkdocs build --strict

- name: Deploy documentation
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
mkdocs gh-deploy --force --remote-name origin --config-file mkdocs.yml
mkdocs gh-deploy --force --remote-name origin --config-file mkdocs.yml \
--message "docs: deploy ${GITHUB_SHA}"
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ that is actually ready to publish.
> licensed-data campaign remains pre-holdout: its 2023–2025 final holdout is
> sealed, and no alpha or live-performance claim is made.

| Completed evidence | Scope | Claim boundary |
| --- | --- | --- |
| Six frozen mechanisms | 2017–2022 validation | Every candidate was rejected by at least one preregistered gate |
| Immutable run manifests | Config, data identity, code state, outputs | Aggregate public receipts; licensed rows remain local |
| Final holdout | 2023–2025 | Sealed and not used in the reported economic evidence |

## What it makes auditable

| Research risk | microalpha control |
Expand Down Expand Up @@ -69,9 +75,11 @@ execution as separate steps so their timing assumptions can be tested directly.

## Evidence, including negative results

The latest aggregate-only research ledger is intentionally more useful than a
single best backtest. Six frozen mechanisms were evaluated on a 2017–2022
validation window while the 2023–2025 final holdout remained sealed.
The latest completed economic ledger (as of **2026-07-11**) is intentionally
more useful than a single best backtest. Six frozen mechanisms were evaluated
on a 2017–2022 validation window while the 2023–2025 final holdout remained
sealed. Newer SEC 13F pipeline work is infrastructure progress, not newer
economic evidence.

![Validation HAC Sharpe for six preregistered mechanisms; only the SEC cash-earnings candidate approaches the 0.50 promotion gate and it still fails the full gate set](docs/assets/portfolio/validation_frontier.svg)

Expand Down
10 changes: 8 additions & 2 deletions benchmarks/bench_multi_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
from microalpha.events import MarketEvent


def _write_panel(csv_dir: Path, symbols: List[str], base_dates: pd.DatetimeIndex) -> None:
def _write_panel(
csv_dir: Path, symbols: List[str], base_dates: pd.DatetimeIndex
) -> None:
rng = np.random.default_rng(2025)
csv_dir.mkdir(parents=True, exist_ok=True)
for symbol in symbols:
Expand Down Expand Up @@ -48,7 +50,11 @@ def _baseline_stream(handler: MultiCsvDataHandler) -> Iterator[MarketEvent]:
value = df.loc[ts, "close"] # type: ignore[index]
except KeyError:
continue
price = float(value.iloc[0]) if isinstance(value, pd.Series) else float(value)
price = (
float(value.iloc[0])
if isinstance(value, pd.Series)
else float(value)
)
else:
idx = df.index.searchsorted(ts, side="right") - 1
if idx < 0:
Expand Down
17 changes: 14 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,20 @@ Microalpha is an event-driven research platform for reproducible quantitative st

## Quickstart

1. **Install the package**
1. **Install this repository from source**

```bash
pip install microalpha
# or, for local development
git clone https://github.com/MateoBodon/microalpha.git
cd microalpha
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```

> Do not use `pip install microalpha`: that PyPI name belongs to an
> unrelated third-party project. This repository has no public package
> release; the supported installation path is the source checkout above.

2. **Run the bundled mean-reversion backtest**

```bash
Expand All @@ -36,3 +42,8 @@ Microalpha is an event-driven research platform for reproducible quantitative st
- Try the scenarios in [Examples](examples.md).

Use the navigation to dive into leakage guarantees, reproducibility tooling, API surfaces, and runnable examples.

---

These docs are deployed from the public `main` branch. The deployment commit is
recorded in the repository's [Docs workflow](https://github.com/MateoBodon/microalpha/actions/workflows/docs.yml).
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ dev = [
[tool.black]
line-length = 88

[tool.isort]
profile = "black"
combine_as_imports = true

[tool.ruff]
line-length = 88
exclude = [
Expand All @@ -51,6 +55,9 @@ ignore = [
"E501",
]

[tool.ruff.lint.isort]
combine-as-imports = true

[tool.mypy]
python_version = "3.12"
packages = ["microalpha"]
Expand Down
4 changes: 3 additions & 1 deletion reports/factors_ff.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ def _format(results, meta) -> str:

def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("artifact_dir", type=Path, help="Artifact directory with equity_curve.csv")
parser.add_argument(
"artifact_dir", type=Path, help="Artifact directory with equity_curve.csv"
)
parser.add_argument(
"--factors",
type=Path,
Expand Down
73 changes: 58 additions & 15 deletions reports/html_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,32 @@ def main() -> None:
args = ap.parse_args()

eq = pd.read_csv(args.equity_csv)
eq_ts = pd.to_datetime(eq["timestamp"]) if "timestamp" in eq else pd.RangeIndex(len(eq))
eq_ts = (
pd.to_datetime(eq["timestamp"]) if "timestamp" in eq else pd.RangeIndex(len(eq))
)

# Figure with subplots: equity + rolling Sharpe + PnL hist (if available)
from plotly.subplots import make_subplots
fig = make_subplots(rows=3, cols=1, shared_xaxes=False, specs=[[{"secondary_y": True}], [{}], [{}]],
row_heights=[0.6, 0.2, 0.2], vertical_spacing=0.06)

fig = make_subplots(
rows=3,
cols=1,
shared_xaxes=False,
specs=[[{"secondary_y": True}], [{}], [{}]],
row_heights=[0.6, 0.2, 0.2],
vertical_spacing=0.06,
)
fig.add_trace(
go.Scatter(x=eq_ts, y=eq["equity"], mode="lines", name="Equity", line=dict(color="#1f77b4")),
row=1, col=1, secondary_y=False
go.Scatter(
x=eq_ts,
y=eq["equity"],
mode="lines",
name="Equity",
line=dict(color="#1f77b4"),
),
row=1,
col=1,
secondary_y=False,
)

trades = read_trades_jsonl(args.trades)
Expand All @@ -64,7 +81,10 @@ def main() -> None:
mode="markers",
name="Buys",
marker=dict(symbol="triangle-up", color="#2ca02c"),
), row=1, col=1, secondary_y=True
),
row=1,
col=1,
secondary_y=True,
)
fig.add_trace(
go.Scatter(
Expand All @@ -73,25 +93,48 @@ def main() -> None:
mode="markers",
name="Sells",
marker=dict(symbol="triangle-down", color="#d62728"),
), row=1, col=1, secondary_y=True
),
row=1,
col=1,
secondary_y=True,
)

# Rolling Sharpe on equity returns
if len(eq) > 2:
ret = pd.Series(eq["equity"]).pct_change().fillna(0.0)
window = min(63, max(2, len(ret)//5))
window = min(63, max(2, len(ret) // 5))
rolling_mean = ret.rolling(window).mean()
rolling_std = ret.rolling(window).std(ddof=0)
sharpe = (rolling_mean / (rolling_std.replace(0, pd.NA))).fillna(0.0) * (252 ** 0.5)
fig.add_trace(go.Scatter(x=eq_ts, y=sharpe, mode="lines", name="Rolling Sharpe (63d)", line=dict(color="#9467bd")),
row=2, col=1)
sharpe = (rolling_mean / (rolling_std.replace(0, pd.NA))).fillna(
0.0
) * (252**0.5)
fig.add_trace(
go.Scatter(
x=eq_ts,
y=sharpe,
mode="lines",
name="Rolling Sharpe (63d)",
line=dict(color="#9467bd"),
),
row=2,
col=1,
)

# Per-trade realized PnL histogram
if "realized_pnl" in trades:
fig.add_trace(go.Histogram(x=trades["realized_pnl"], name="Trade PnL", marker_color="#8c564b"),
row=3, col=1)

fig.update_layout(title="Microalpha Report", legend=dict(orientation="h"), template="plotly_white")
fig.add_trace(
go.Histogram(
x=trades["realized_pnl"],
name="Trade PnL",
marker_color="#8c564b",
),
row=3,
col=1,
)

fig.update_layout(
title="Microalpha Report", legend=dict(orientation="h"), template="plotly_white"
)
fig.update_yaxes(title_text="Equity", row=1, col=1, secondary_y=False)
fig.update_yaxes(title_text="Price", row=1, col=1, secondary_y=True)
fig.update_xaxes(title_text="Time", row=1, col=1)
Expand Down
10 changes: 6 additions & 4 deletions reports/wfv_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,12 @@ def build_wfv_report(folds_path: str) -> plt.Figure:
# Bar plot of Sharpe per fold (train vs test)
x = np.arange(len(folds))
width = 0.35
axes[0].bar(x - width / 2, train_sharpes, width, label="Train", color="#1f77b4", alpha=0.7)
axes[0].bar(x + width / 2, test_sharpes, width, label="Test", color="#d62728", alpha=0.7)
axes[0].bar(
x - width / 2, train_sharpes, width, label="Train", color="#1f77b4", alpha=0.7
)
axes[0].bar(
x + width / 2, test_sharpes, width, label="Test", color="#d62728", alpha=0.7
)
axes[0].set_xticks(x, labels)
axes[0].set_ylabel("Sharpe")
axes[0].set_title("Per-Fold Sharpe (Train vs Test)")
Expand Down Expand Up @@ -72,5 +76,3 @@ def main() -> None:

if __name__ == "__main__":
main()


1 change: 0 additions & 1 deletion scripts/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
"""Utility scripts for Microalpha."""

6 changes: 3 additions & 3 deletions scripts/build_flagship_universe.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,9 @@ def main() -> None:

summary = {
"rebalance_dates": len(universe_sizes),
"average_size": float(np.mean(list(universe_sizes.values())))
if universe_sizes
else 0,
"average_size": (
float(np.mean(list(universe_sizes.values()))) if universe_sizes else 0
),
"min_size": min(universe_sizes.values()) if universe_sizes else 0,
"max_size": max(universe_sizes.values()) if universe_sizes else 0,
"parameters": {
Expand Down
10 changes: 7 additions & 3 deletions scripts/build_runs_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
if str(SRC_ROOT) not in sys.path:
sys.path.insert(0, str(SRC_ROOT))

from microalpha.manifest import ManifestLoadError, load_manifest_path
from microalpha.manifest import ManifestLoadError, load_manifest_path # noqa: E402

DEFAULT_ARTIFACTS_ROOT = Path("artifacts")
DEFAULT_OUTPUT = Path("reports/summaries/runs_index.csv")
Expand Down Expand Up @@ -133,7 +133,9 @@ def _collect_manifests(artifacts_root: Path) -> list[Path]:
for path in candidates:
key = path.resolve().as_posix()
unique[key] = path
return sorted(unique.values(), key=lambda p: _relative_to(p, artifacts_root).as_posix())
return sorted(
unique.values(), key=lambda p: _relative_to(p, artifacts_root).as_posix()
)


def _extract_walkforward(payload: Mapping[str, Any]) -> Mapping[str, Any]:
Expand Down Expand Up @@ -200,7 +202,9 @@ def build_runs_index(artifacts_root: Path, repo_root: Path) -> list[dict[str, st
def write_csv(rows: list[dict[str, str]], output_path: Path) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=RUNS_INDEX_COLUMNS, lineterminator="\n")
writer = csv.DictWriter(
handle, fieldnames=RUNS_INDEX_COLUMNS, lineterminator="\n"
)
writer.writeheader()
writer.writerows(rows)

Expand Down
18 changes: 13 additions & 5 deletions scripts/build_wrds_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ def _wrds_universe_path(path: str | None) -> Path:
root = os.environ.get("WRDS_DATA_ROOT")
if not root:
raise SystemExit("Set WRDS_DATA_ROOT or pass --universe explicitly")
candidate = Path(root).expanduser().resolve() / "universes" / "flagship_sector_neutral.csv"
candidate = (
Path(root).expanduser().resolve()
/ "universes"
/ "flagship_sector_neutral.csv"
)
if not candidate.exists():
raise SystemExit(f"Universe CSV not found: {candidate}")
return candidate
Expand Down Expand Up @@ -92,16 +96,20 @@ def _build_signals(
df["adv"] = float("nan")

mask = df["score"].notna() & df["forward_return"].notna()
mask &= (~df["score"].isin([float("inf"), float("-inf")]))
mask &= (~df["forward_return"].isin([float("inf"), float("-inf")]))
mask &= ~df["score"].isin([float("inf"), float("-inf")])
mask &= ~df["forward_return"].isin([float("inf"), float("-inf")])
adv_filter = df["adv"].fillna(min_adv) >= min_adv
mask &= adv_filter

signals = df.loc[mask, ["date", "symbol", "score", "forward_return", "adv", "sector"]].copy()
signals = df.loc[
mask, ["date", "symbol", "score", "forward_return", "adv", "sector"]
].copy()
signals = signals.rename(columns={"date": "as_of"})
signals["as_of"] = signals["as_of"].dt.strftime("%Y-%m-%d")
if signals.empty:
raise SystemExit("No signals survived filtering; check lookback/min-adv parameters")
raise SystemExit(
"No signals survived filtering; check lookback/min-adv parameters"
)

output_path = output_path.expanduser().resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
Expand Down
12 changes: 10 additions & 2 deletions scripts/diagnose_artifact_integrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ def _count_trades(path: Path | None) -> int:
if path is None or not path.exists():
return 0
if path.suffix.lower() == ".jsonl":
return sum(1 for line in path.read_text(encoding="utf-8").splitlines() if line.strip())
return sum(
1 for line in path.read_text(encoding="utf-8").splitlines() if line.strip()
)
if path.suffix.lower() == ".csv":
df = pd.read_csv(path)
return int(len(df))
Expand Down Expand Up @@ -89,7 +91,13 @@ def main() -> int:
print("Returns std:", stats["returns_std"])
print("Trades (metrics/trades file):", num_trades_metric, "/", trades_count)
print("Turnover:", turnover)
print("Costs (commission/slippage/borrow/total):", commission, slippage, borrow, total_costs)
print(
"Costs (commission/slippage/borrow/total):",
commission,
slippage,
borrow,
total_costs,
)

checks = []
if turnover > 0 and trades_count == 0:
Expand Down
Loading
Loading