From e26f71d8e607626591ddebe98c4ba58d570dde53 Mon Sep 17 00:00:00 2001 From: initial-d Date: Fri, 7 Aug 2026 10:22:05 +0800 Subject: [PATCH] feat: export synthetic Hugging Face artifacts --- CHANGELOG.md | 8 + README.md | 4 + docs/huggingface_artifacts.md | 93 +++++ scripts/export_huggingface_artifacts.py | 398 +++++++++++++++++++++ tests/test_export_huggingface_artifacts.py | 82 +++++ 5 files changed, 585 insertions(+) create mode 100644 docs/huggingface_artifacts.md create mode 100644 scripts/export_huggingface_artifacts.py create mode 100644 tests/test_export_huggingface_artifacts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index eb76549..92de81e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Hugging Face artifacts + +- Added a deterministic exporter for a viewer-ready synthetic OHLCV dataset and + synthetic MLP smoke-test checkpoint. +- Added dataset/model cards, checksums, factor-order metadata, and an explicit + safety boundary that rejects non-synthetic configs. +- Documented the authenticated upload and post-upload verification workflow. + ## 0.2.4 - Metric Clarity and Contributor-Led Review This patch release turns an external review finding into a clearer reporting diff --git a/README.md b/README.md index 8190704..f5c528c 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ Languages: [English](README.md) | [简体中文](README.zh-CN.md) | [繁體中 | **Evidence, including failures** | Costs, turnover, baselines, caveats, and negative results stay visible | | **A contribution path** | CI, tests, report templates, Colab, and newcomer-sized research tasks | +Hugging Face publishing is prepared through the +[synthetic dataset/model export](docs/huggingface_artifacts.md); the export path +explicitly excludes real and proprietary market data. + ## Quick Start ```bash diff --git a/docs/huggingface_artifacts.md b/docs/huggingface_artifacts.md new file mode 100644 index 0000000..f4fabb3 --- /dev/null +++ b/docs/huggingface_artifacts.md @@ -0,0 +1,93 @@ +# Hugging Face Artifact Export + +The project can publish a small dataset and model checkpoint without +redistributing proprietary or public-provider market data. Both artifacts come +from the deterministic synthetic quick start. + +## Artifact repositories + +| Hugging Face repository | Contents | Intended use | +|---|---|---| +| `dddyym/ml-quant-trading-synthetic` | Viewer-ready compressed CSV, generator config, checksum manifest, dataset card | Installation, CI, teaching, and pipeline smoke tests | +| `dddyym/ml-quant-trading-synthetic-mlp` | PyTorch checkpoint, factor order, architecture config, metrics, model card | Checkpoint loading and inference smoke tests | + +Neither artifact represents real instruments, proprietary data, deployable +alpha, or evidence of live performance. + +## Build locally + +Run the deterministic pipeline, then export the two repository directories: + +```bash +mlquant demo +python scripts/export_huggingface_artifacts.py \ + --artifacts-dir artifacts/small \ + --config configs/small.yaml \ + --output-dir artifacts/huggingface \ + --namespace dddyym +``` + +The exporter writes: + +```text +artifacts/huggingface/ +├── bundle_manifest.json +├── ml-quant-trading-synthetic/ +│ ├── README.md +│ ├── artifact_manifest.json +│ ├── source_config.yaml +│ └── data/synthetic_ohlcv.csv.gz +└── ml-quant-trading-synthetic-mlp/ + ├── README.md + ├── artifact_manifest.json + ├── config.json + ├── feature_names.json + ├── metrics.json + ├── pytorch_model.bin + └── source_config.yaml +``` + +The dataset gzip stream uses a fixed timestamp so the same panel produces the +same SHA-256 digest. Manifests record the source commit and file checksums. + +## Upload after authentication + +Install and authenticate the Hugging Face CLI locally. Never paste a full token +into an issue, PR, notebook, or chat transcript. + +```bash +python -m pip install --upgrade huggingface_hub +hf auth login +``` + +Create and upload the dataset repository: + +```bash +hf repo create dddyym/ml-quant-trading-synthetic --repo-type dataset --exist-ok +hf upload dddyym/ml-quant-trading-synthetic \ + artifacts/huggingface/ml-quant-trading-synthetic . \ + --repo-type dataset +``` + +Create and upload the model repository: + +```bash +hf repo create dddyym/ml-quant-trading-synthetic-mlp --exist-ok +hf upload dddyym/ml-quant-trading-synthetic-mlp \ + artifacts/huggingface/ml-quant-trading-synthetic-mlp . +``` + +After upload: + +1. Confirm the dataset viewer renders rows and column types. +2. Run the model-card loading snippet in a clean environment. +3. Link both repositories from the + [Hugging Face paper page](https://huggingface.co/papers/2507.07107). +4. Update [Issue #1](https://github.com/initial-d/ml-quant-trading/issues/1) + with the live URLs and close it only after both smoke checks pass. + +## Safety boundary + +The exporter refuses configs without a `synthetic` section. It accepts only the +local demo artifacts supplied on the command line; it does not contain any +market-data downloader or Hub credential handling. diff --git a/scripts/export_huggingface_artifacts.py b/scripts/export_huggingface_artifacts.py new file mode 100644 index 0000000..171adb6 --- /dev/null +++ b/scripts/export_huggingface_artifacts.py @@ -0,0 +1,398 @@ +"""Export the deterministic demo as Hugging Face-ready dataset and model repos. + +This exporter intentionally accepts only the synthetic quick-start artifacts. It +does not download, package, or redistribute market data. + +Example +------- +python scripts/export_huggingface_artifacts.py \ + --artifacts-dir artifacts/small \ + --config configs/small.yaml \ + --output-dir artifacts/huggingface +""" +from __future__ import annotations + +import argparse +import csv +import gzip +import hashlib +import io +import json +import shutil +import subprocess +from pathlib import Path +from typing import Any, Sequence + +import torch +import yaml + + +DATASET_REPO_NAME = "ml-quant-trading-synthetic" +MODEL_REPO_NAME = "ml-quant-trading-synthetic-mlp" + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _write_json(path: Path, payload: Any) -> None: + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _source_commit() -> str: + try: + return subprocess.check_output( + ["git", "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL + ).strip() + except (OSError, subprocess.CalledProcessError): + return "unknown" + + +def _require_empty_or_forced(path: Path, *, force: bool) -> None: + if path.exists() and any(path.iterdir()) and not force: + raise FileExistsError(f"{path} is not empty; pass --force to replace generated files") + path.mkdir(parents=True, exist_ok=True) + + +def _date_text(value: Any) -> str: + if hasattr(value, "isoformat"): + return value.isoformat() + return str(value) + + +def _write_dataset_csv(panel: dict[str, Any], path: Path) -> int: + required = ("dates", "stocks", "open", "high", "low", "close", "volume", "vwap", "mask") + missing = [key for key in required if key not in panel] + if missing: + raise ValueError(f"panel artifact is missing keys: {', '.join(missing)}") + + dates = panel["dates"] + stocks = panel["stocks"] + tensors = {key: torch.as_tensor(panel[key]).cpu() for key in required[2:]} + shape = tensors["close"].shape + if len(shape) != 2 or shape != (len(dates), len(stocks)): + raise ValueError("panel tensors must have shape [n_dates, n_assets]") + if any(tensor.shape != shape for tensor in tensors.values()): + raise ValueError("all panel tensors must share the close tensor shape") + + path.parent.mkdir(parents=True, exist_ok=True) + rows = 0 + with path.open("wb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as zipped: + with io.TextIOWrapper(zipped, encoding="utf-8", newline="") as text: + writer = csv.writer(text, lineterminator="\n") + writer.writerow( + [ + "date", + "asset_id", + "open", + "high", + "low", + "close", + "volume", + "vwap", + "tradable", + ] + ) + for date_index, date in enumerate(dates): + for asset_index, asset in enumerate(stocks): + writer.writerow( + [ + _date_text(date), + str(asset), + *(f"{float(tensors[key][date_index, asset_index]):.9g}" for key in required[2:8]), + int(bool(tensors["mask"][date_index, asset_index])), + ] + ) + rows += 1 + return rows + + +def _dataset_card( + *, namespace: str, rows: int, n_dates: int, n_assets: int, commit: str, checksum: str +) -> str: + size_category = "10K str: + return f"""--- +license: mit +library_name: pytorch +pipeline_tag: tabular-regression +datasets: +- {namespace}/{DATASET_REPO_NAME} +tags: +- finance +- synthetic +- quantitative-trading +- reproducibility +- pytorch +--- + +# ml-quant-trading synthetic MLP smoke-test checkpoint + +This is the small `MLPRegressor` checkpoint produced by the deterministic +`mlquant demo` pipeline. It is published to make installation and inference +paths reproducible — **not as a market model or investment signal**. + +## Model details + +- Architecture: three-layer MLP with GELU and dropout +- Input dimensions: `{in_dim}` factor values +- Hidden width: `{hidden}` +- Output: one synthetic next-period return score per asset-date +- Training data: [`{namespace}/{DATASET_REPO_NAME}`](https://huggingface.co/datasets/{namespace}/{DATASET_REPO_NAME}) +- Source commit: [`{commit}`](https://github.com/initial-d/ml-quant-trading/commit/{commit}) +- Checkpoint SHA-256: `{checkpoint_sha}` + +The companion `feature_names.json`, `config.json`, `source_config.yaml`, and +`metrics.json` files define the input order, architecture, generator settings, +and synthetic smoke-test output. + +## Load + +```python +import json +import torch +from huggingface_hub import hf_hub_download +from mlquant.models.nets import MLPRegressor + +repo_id = "{namespace}/{MODEL_REPO_NAME}" +config_path = hf_hub_download(repo_id, "config.json") +weights_path = hf_hub_download(repo_id, "pytorch_model.bin") + +config = json.load(open(config_path)) +model = MLPRegressor( + in_dim=config["in_dim"], + hidden=config["hidden"], + dropout=config["dropout"], +) +model.load_state_dict(torch.load(weights_path, map_location="cpu", weights_only=True)) +model.eval() +``` + +## Limitations + +- The checkpoint was trained only on deterministic synthetic data. +- Synthetic smoke-test metrics are not evidence of out-of-sample alpha. +- Factor values must use the exact order in `feature_names.json`. +- The package is research software, not a live-trading system. + +See the project's [Research Card](https://github.com/initial-d/ml-quant-trading/blob/main/docs/research_card.md) +and [Reality Check](https://github.com/initial-d/ml-quant-trading/blob/main/docs/reality_check.md). +""" + + +def export_bundle( + *, + artifacts_dir: Path, + config_path: Path, + output_dir: Path, + namespace: str = "dddyym", + source_commit: str | None = None, + force: bool = False, +) -> dict[str, Any]: + """Create two self-contained directories ready for Hugging Face upload.""" + panel_path = artifacts_dir / "panel.pt" + features_path = artifacts_dir / "features.pt" + checkpoint_path = artifacts_dir / "checkpoints" / "best.pt" + summary_path = artifacts_dir / "summary.json" + for required_path in (panel_path, features_path, checkpoint_path, summary_path, config_path): + if not required_path.exists(): + raise FileNotFoundError(f"required demo artifact not found: {required_path}") + + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + if "synthetic" not in config: + raise ValueError("only configs with a synthetic section can be exported") + + dataset_dir = output_dir / DATASET_REPO_NAME + model_dir = output_dir / MODEL_REPO_NAME + _require_empty_or_forced(dataset_dir, force=force) + _require_empty_or_forced(model_dir, force=force) + + panel = torch.load(panel_path, map_location="cpu", weights_only=False) + features = torch.load(features_path, map_location="cpu", weights_only=False) + names: Sequence[str] = features.get("names", []) + if not names: + raise ValueError("features artifact does not contain factor names") + + commit = source_commit or _source_commit() + data_path = dataset_dir / "data" / "synthetic_ohlcv.csv.gz" + rows = _write_dataset_csv(panel, data_path) + data_sha = _sha256(data_path) + dataset_manifest = { + "artifact_type": "synthetic_ohlcv_panel", + "data_file": str(data_path.relative_to(dataset_dir)).replace("\\", "/"), + "data_sha256": data_sha, + "generator_commit": commit, + "n_assets": len(panel["stocks"]), + "n_dates": len(panel["dates"]), + "rows": rows, + "source": "deterministic synthetic generator; no market data", + } + _write_json(dataset_dir / "artifact_manifest.json", dataset_manifest) + shutil.copy2(config_path, dataset_dir / "source_config.yaml") + (dataset_dir / "README.md").write_text( + _dataset_card( + namespace=namespace, + rows=rows, + n_dates=len(panel["dates"]), + n_assets=len(panel["stocks"]), + commit=commit, + checksum=data_sha, + ), + encoding="utf-8", + ) + + model_path = model_dir / "pytorch_model.bin" + shutil.copy2(checkpoint_path, model_path) + checkpoint_sha = _sha256(model_path) + shutil.copy2(config_path, model_dir / "source_config.yaml") + shutil.copy2(summary_path, model_dir / "metrics.json") + _write_json(model_dir / "feature_names.json", list(names)) + model_config = { + "architecture": "MLPRegressor", + "dataset": f"{namespace}/{DATASET_REPO_NAME}", + "dropout": 0.1, + "hidden": int(config.get("hidden", 128)), + "in_dim": len(names), + "output_dim": 1, + "source_commit": commit, + } + _write_json(model_dir / "config.json", model_config) + _write_json( + model_dir / "artifact_manifest.json", + { + "artifact_type": "synthetic_mlp_smoke_test", + "checkpoint_file": "pytorch_model.bin", + "checkpoint_sha256": checkpoint_sha, + **model_config, + }, + ) + (model_dir / "README.md").write_text( + _model_card( + namespace=namespace, + commit=commit, + checkpoint_sha=checkpoint_sha, + in_dim=len(names), + hidden=int(config.get("hidden", 128)), + ), + encoding="utf-8", + ) + + bundle_manifest = { + "dataset_repo": f"{namespace}/{DATASET_REPO_NAME}", + "dataset_rows": rows, + "model_repo": f"{namespace}/{MODEL_REPO_NAME}", + "output_dir": str(output_dir), + "source_commit": commit, + } + output_dir.mkdir(parents=True, exist_ok=True) + _write_json(output_dir / "bundle_manifest.json", bundle_manifest) + return bundle_manifest + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifacts-dir", type=Path, default=Path("artifacts/small")) + parser.add_argument("--config", type=Path, default=Path("configs/small.yaml")) + parser.add_argument("--output-dir", type=Path, default=Path("artifacts/huggingface")) + parser.add_argument("--namespace", default="dddyym") + parser.add_argument("--source-commit") + parser.add_argument("--force", action="store_true") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + manifest = export_bundle( + artifacts_dir=args.artifacts_dir, + config_path=args.config, + output_dir=args.output_dir, + namespace=args.namespace, + source_commit=args.source_commit, + force=args.force, + ) + print(json.dumps(manifest, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_export_huggingface_artifacts.py b/tests/test_export_huggingface_artifacts.py new file mode 100644 index 0000000..9b24a39 --- /dev/null +++ b/tests/test_export_huggingface_artifacts.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import csv +import gzip +import hashlib +import json +from pathlib import Path + +import torch + +from scripts.export_huggingface_artifacts import export_bundle + + +def _write_demo_artifacts(tmp_path: Path) -> tuple[Path, Path]: + artifacts = tmp_path / "artifacts" + (artifacts / "checkpoints").mkdir(parents=True) + panel = { + "dates": ["2026-01-01", "2026-01-02"], + "stocks": ["SYN000", "SYN001"], + "open": torch.tensor([[1.0, 2.0], [1.1, 2.1]]), + "high": torch.tensor([[1.2, 2.2], [1.3, 2.3]]), + "low": torch.tensor([[0.9, 1.9], [1.0, 2.0]]), + "close": torch.tensor([[1.1, 2.1], [1.2, 2.2]]), + "volume": torch.tensor([[100.0, 200.0], [110.0, 210.0]]), + "vwap": torch.tensor([[1.05, 2.05], [1.15, 2.15]]), + "mask": torch.tensor([[True, True], [True, False]]), + } + torch.save(panel, artifacts / "panel.pt") + torch.save( + {"factors": torch.zeros(2, 2, 2), "mask": panel["mask"], "names": ["f1", "f2"]}, + artifacts / "features.pt", + ) + torch.save({"net.0.weight": torch.ones(2, 2)}, artifacts / "checkpoints" / "best.pt") + (artifacts / "summary.json").write_text( + json.dumps({"workflow": "synthetic", "metrics": {"sharpe": 0.0}}), encoding="utf-8" + ) + config = tmp_path / "small.yaml" + config.write_text("seed: 42\nhidden: 2\nsynthetic:\n n_dates: 2\n n_stocks: 2\n", encoding="utf-8") + return artifacts, config + + +def test_export_bundle_is_viewer_ready_and_explicitly_synthetic(tmp_path: Path): + artifacts, config = _write_demo_artifacts(tmp_path) + output = tmp_path / "huggingface" + + manifest = export_bundle( + artifacts_dir=artifacts, + config_path=config, + output_dir=output, + namespace="example", + source_commit="abc123", + ) + + dataset_dir = output / "ml-quant-trading-synthetic" + model_dir = output / "ml-quant-trading-synthetic-mlp" + data_path = dataset_dir / "data" / "synthetic_ohlcv.csv.gz" + + with gzip.open(data_path, "rt", encoding="utf-8", newline="") as handle: + rows = list(csv.DictReader(handle)) + + assert len(rows) == 4 + assert rows[-1]["tradable"] == "0" + assert manifest["dataset_rows"] == 4 + assert "no real instruments" in (dataset_dir / "README.md").read_text(encoding="utf-8") + assert "not as a market model" in (model_dir / "README.md").read_text(encoding="utf-8") + assert json.loads((model_dir / "config.json").read_text())["in_dim"] == 2 + assert json.loads((model_dir / "feature_names.json").read_text()) == ["f1", "f2"] + + dataset_manifest = json.loads((dataset_dir / "artifact_manifest.json").read_text()) + assert dataset_manifest["data_sha256"] == hashlib.sha256(data_path.read_bytes()).hexdigest() + + +def test_export_bundle_refuses_non_synthetic_config(tmp_path: Path): + artifacts, config = _write_demo_artifacts(tmp_path) + config.write_text("seed: 42\n", encoding="utf-8") + + try: + export_bundle(artifacts_dir=artifacts, config_path=config, output_dir=tmp_path / "out") + except ValueError as exc: + assert "synthetic" in str(exc) + else: + raise AssertionError("non-synthetic config should not be exportable")