From f5ba5263ea9dd1753daee04a76ef6461158c6a36 Mon Sep 17 00:00:00 2001 From: PureSaber Date: Mon, 12 Oct 2026 00:53:38 +0800 Subject: [PATCH 1/2] Expose frozen continuous capital studies and paired revision replay --- .github/workflows/ci.yml | 3 + README.md | 3 + docs/CONTINUOUS_CAPITAL.md | 72 ++++ examples/continuous/market-new.json | 35 ++ examples/continuous/market-old.json | 35 ++ examples/continuous/plan.json | 113 ++++++ pyproject.toml | 4 +- requirements.lock | 4 +- scripts/run_continuous_demo.py | 125 +++++++ src/quant_portfolio/cli.py | 3 + src/quant_portfolio/continuous_study.py | 461 ++++++++++++++++++++++++ tests/test_continuous_study.py | 196 ++++++++++ tests/test_governance.py | 8 +- 13 files changed, 1054 insertions(+), 8 deletions(-) create mode 100644 docs/CONTINUOUS_CAPITAL.md create mode 100644 examples/continuous/market-new.json create mode 100644 examples/continuous/market-old.json create mode 100644 examples/continuous/plan.json create mode 100644 scripts/run_continuous_demo.py create mode 100644 src/quant_portfolio/continuous_study.py create mode 100644 tests/test_continuous_study.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 705c483..f598495 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,3 +69,6 @@ jobs: run: | python scripts/run_synthetic_demo.py --out "$RUNNER_TEMP/synthetic-demo" grep -q "SYNTHETIC DEMONSTRATION" "$RUNNER_TEMP/synthetic-demo/metrics.json" + + - name: Continuous capital and immutable revision CLI acceptance + run: python scripts/run_continuous_demo.py --out "$RUNNER_TEMP/continuous-demo" diff --git a/README.md b/README.md index 872f6d0..e3c4359 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Quant Research Portfolio +[Continuous shared capital and paired revisions](docs/CONTINUOUS_CAPITAL.md) add +a runnable frozen-policy study on QExec's shared account and QDK's immutable lineage. + A small, reviewable portfolio of quantitative-research engineering patterns: - deterministic synthetic data generation; diff --git a/docs/CONTINUOUS_CAPITAL.md b/docs/CONTINUOUS_CAPITAL.md new file mode 100644 index 0000000..2445e6b --- /dev/null +++ b/docs/CONTINUOUS_CAPITAL.md @@ -0,0 +1,72 @@ +# Continuous shared capital and paired revisions + +Install this repository with its pinned QDK/QExec dependencies. The new commands +are exposed by the existing `quant-portfolio` CLI. The deterministic example is +entirely synthetic; it establishes accounting and revision plumbing only. + +```bash +python scripts/run_continuous_demo.py --out /new/empty/path/capital-demo +``` + +This executes the real freeze, continuous-study and revision-study CLIs, pins both +market versions and result derivations in QDK's `ArtifactStore`, and writes +`acceptance.json`, `README.md`, both account reports, and a revision report. The +independent scalar expectation is +34 USD old PnL, +26 new PnL, and -8 difference. +No data, accounts, models, credentials or real orders are acquired or modified. + +Run individual steps with your own explicitly declared research inputs: + +```bash +quant-portfolio freeze-capital-plan --plan examples/continuous/plan.json --out plan-frozen.json +quant-portfolio continuous-study --plan plan-frozen.json --market examples/continuous/market-old.json --out old.json +quant-portfolio revision-study --request request.json --out revision.json +``` + +The draft `quant.capital-plan/v1` carries full QDK `InstrumentSpec` payloads, +instrument-to-sleeve ownership, opening currency allocations and FX, a fee-per-unit +cost policy, valuation policy, decisions, and optional explicit cash events. +Each decision fixes a notional target, signal observation, later execution +observation and settlement instant. It rounds the prior-observation notional to +quantity steps, then submits the target delta through QExec at the explicit later +price. It does not adapt or optimize the policy to evaluation outcomes. Missing +prices, unavailable observations, ambiguous order, insufficient funds, unsupported +assets or silently changed controls fail the run. Executions are declared research +fills, with explicit fees; this adapter does not claim order-book matching. + +Market input `quant.capital-market/v1` has observations with unique IDs, UTC `at`, +`available_at <= at`, price mapping and FX mapping. Cash-event sequences occupy +0–99, observation marks 100–999, decisions 1000 upward. The QExec account retains +all ordered events, settlements, sleeves, journal postings and cash/margin state +continuously. See QExec `docs/SHARED_CAPITAL.md` for the exact event contract and +limitations. B's forecast replay may call that additive API with its own fills. + +Freezing copies the controls and binds actual installed package Python source +hashes and runtime versions. Editing the plan, source or runtime requires a new +explicit freeze and cannot be reported as a data-only comparison. Freezing is +performed now: historical fixture dates do not confer historical/OOS provenance. +The adapter supports `synthetic/deterministic_fixture` or explicitly declared +retrospective studies, and exposes features/forecasts as unavailable. + +For revision requests, publish old/new market JSON using `ArtifactStore.put_bytes` +and put their complete `ArtifactRef.to_dict()` objects in `old_input` and +`new_input`. `request.json` also contains absolute `store`, frozen `plan` path and +durable `owner`. `paired_study` checks the same frozen executable and controls, +recalculates both outputs, and registers exact dependencies. The report includes +transitive affected objects, signals/orders/history/NAV/return changes, unchanged +stage results, original references and explicit `intervention=data_revision`. +It never replaces earlier decisions. Dependency reachability is not causal proof. + +Reports emit the common `quant.measurement/v1`. Return observations group all +same-instant events after respecting their explicit sequence; their return is the +product of each flow-adjusted event return. The timestamp grid includes opening +and final boundaries, is UTC and strictly increasing. Event returns are never +annualized. Cash is in base-currency major units, returns are decimal fractions, +and exact source, market, instrument, FX and economic-policy hashes are retained. +Old/new revisions intentionally have different input identities and therefore +must fail ordinary same-input economic comparison. The revision report is the +explicit intervention analysis. + +Every CLI output uses exclusive creation. Keep frozen plans, raw references and +artifact-store pins with the reports. Re-running into a different new directory +is safe; overwriting past research is rejected. Roll back through Git revert while +preserving output history. No deployment or default-branch merge is performed. diff --git a/examples/continuous/market-new.json b/examples/continuous/market-new.json new file mode 100644 index 0000000..1487dc4 --- /dev/null +++ b/examples/continuous/market-new.json @@ -0,0 +1,35 @@ +{ + "schema": "quant.capital-market/v1", + "observations": [ + { + "observation_id": "o1", + "at": "2026-01-02T00:00:00Z", + "available_at": "2026-01-02T00:00:00Z", + "prices": { + "A": "125", + "B": "50" + }, + "fx": {} + }, + { + "observation_id": "o2", + "at": "2026-01-03T00:00:00Z", + "available_at": "2026-01-03T00:00:00Z", + "prices": { + "A": "101", + "B": "50" + }, + "fx": {} + }, + { + "observation_id": "o3", + "at": "2026-01-04T00:00:00Z", + "available_at": "2026-01-04T00:00:00Z", + "prices": { + "A": "110", + "B": "50" + }, + "fx": {} + } + ] +} diff --git a/examples/continuous/market-old.json b/examples/continuous/market-old.json new file mode 100644 index 0000000..6d2a532 --- /dev/null +++ b/examples/continuous/market-old.json @@ -0,0 +1,35 @@ +{ + "schema": "quant.capital-market/v1", + "observations": [ + { + "observation_id": "o1", + "at": "2026-01-02T00:00:00Z", + "available_at": "2026-01-02T00:00:00Z", + "prices": { + "A": "100", + "B": "50" + }, + "fx": {} + }, + { + "observation_id": "o2", + "at": "2026-01-03T00:00:00Z", + "available_at": "2026-01-03T00:00:00Z", + "prices": { + "A": "101", + "B": "50" + }, + "fx": {} + }, + { + "observation_id": "o3", + "at": "2026-01-04T00:00:00Z", + "available_at": "2026-01-04T00:00:00Z", + "prices": { + "A": "110", + "B": "50" + }, + "fx": {} + } + ] +} diff --git a/examples/continuous/plan.json b/examples/continuous/plan.json new file mode 100644 index 0000000..03fe9c8 --- /dev/null +++ b/examples/continuous/plan.json @@ -0,0 +1,113 @@ +{ + "schema": "quant.capital-plan/v1", + "evidence_kind": "synthetic", + "sample_kind": "deterministic_fixture", + "instruments": [ + { + "instrument_id": "A", + "asset_class": "etf", + "product_type": "cash_etf", + "venue": "SYNTHETIC", + "native_symbol": "A", + "base_currency": null, + "quote_currency": null, + "settlement_currency": "USD", + "price_tick": { + "units": 1, + "scale": 2 + }, + "quantity_step": { + "units": 1, + "scale": 0 + }, + "contract_multiplier": { + "units": 1, + "scale": 0 + }, + "calendar_id": "explicit_fixture", + "margin_mode": "none", + "inverse": false, + "effective_from": "2026-01-01T00:00:00Z", + "effective_to": null, + "available_at": "2026-01-01T00:00:00Z", + "superseded_at": null, + "underlying_id": null, + "expiry_date": null, + "metadata": {} + }, + { + "instrument_id": "B", + "asset_class": "etf", + "product_type": "cash_etf", + "venue": "SYNTHETIC", + "native_symbol": "B", + "base_currency": null, + "quote_currency": null, + "settlement_currency": "USD", + "price_tick": { + "units": 1, + "scale": 2 + }, + "quantity_step": { + "units": 1, + "scale": 0 + }, + "contract_multiplier": { + "units": 1, + "scale": 0 + }, + "calendar_id": "explicit_fixture", + "margin_mode": "none", + "inverse": false, + "effective_from": "2026-01-01T00:00:00Z", + "effective_to": null, + "available_at": "2026-01-01T00:00:00Z", + "superseded_at": null, + "underlying_id": null, + "expiry_date": null, + "metadata": {} + } + ], + "owners": { + "A": "alpha", + "B": "beta" + }, + "allocations": { + "alpha": { + "USD": "600" + }, + "beta": { + "USD": "400" + } + }, + "base_currency": "USD", + "opened_at": "2026-01-01T00:00:00Z", + "opening_fx": {}, + "cost_policy": { + "model": "explicit_fee_per_unit", + "fee_per_unit": "1" + }, + "valuation_policy": { + "model": "last_available_mark_and_fx", + "positive_futures_pnl_spendable": false + }, + "decisions": [ + { + "decision_id": "buy-a", + "signal_observation_id": "o1", + "execution_observation_id": "o2", + "instrument_id": "A", + "target_notional": "500", + "settles_at": "2026-01-03T00:00:00Z" + }, + { + "decision_id": "buy-b", + "signal_observation_id": "o1", + "execution_observation_id": "o2", + "instrument_id": "B", + "target_notional": "300", + "settles_at": "2026-01-03T00:00:00Z" + } + ], + "cash_events": [] +} diff --git a/pyproject.toml b/pyproject.toml index 36304c0..61760c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,8 @@ readme = "README.md" license = { text = "MIT" } requires-python = ">=3.10" dependencies = [ - "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@ba136c2fa2eea121bfb2ad7887b536c3952586f7", - "quant-execution @ git+https://github.com/PureSaber/quant-execution.git@21aace45d2dde6458db8fcda4829f252f8c640f1", + "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@f85128307dd0e3407d24350282fe7b679b591309", + "quant-execution @ git+https://github.com/PureSaber/quant-execution.git@42e0854b1988d4d056d3597a92b343f8a046f8e3", "pandas>=2.0", "pyarrow>=14.0", "pyyaml>=6.0", diff --git a/requirements.lock b/requirements.lock index a326232..63872fb 100644 --- a/requirements.lock +++ b/requirements.lock @@ -66,11 +66,11 @@ pyyaml==6.0.3 # via # quant-data-kit # quant-portfolio (pyproject.toml) -quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@ba136c2fa2eea121bfb2ad7887b536c3952586f7 +quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@f85128307dd0e3407d24350282fe7b679b591309 # via # quant-execution # quant-portfolio (pyproject.toml) -quant-execution @ git+https://github.com/PureSaber/quant-execution.git@21aace45d2dde6458db8fcda4829f252f8c640f1 +quant-execution @ git+https://github.com/PureSaber/quant-execution.git@42e0854b1988d4d056d3597a92b343f8a046f8e3 # via quant-portfolio (pyproject.toml) referencing==0.37.0 # via diff --git a/scripts/run_continuous_demo.py b/scripts/run_continuous_demo.py new file mode 100644 index 0000000..73df94c --- /dev/null +++ b/scripts/run_continuous_demo.py @@ -0,0 +1,125 @@ +"""Actual offline CLI acceptance with independent scalar expected values.""" + +import argparse +import json +import subprocess +import sys +from decimal import Decimal +from pathlib import Path + +from quant_data_kit.artifacts import ArtifactStore, canonical_json + + +def run(out): + out.mkdir(parents=True, exist_ok=False) + fixture = Path(__file__).resolve().parents[1] / "examples/continuous" + commands = [ + [ + "freeze-capital-plan", + "--plan", + str(fixture / "plan.json"), + "--out", + str(out / "plan.json"), + ], + [ + "continuous-study", + "--plan", + str(out / "plan.json"), + "--market", + str(fixture / "market-old.json"), + "--out", + str(out / "old.json"), + ], + [ + "continuous-study", + "--plan", + str(out / "plan.json"), + "--market", + str(fixture / "market-new.json"), + "--out", + str(out / "new.json"), + ], + ] + store = ArtifactStore(out / "store") + refs = [ + store.put_bytes( + canonical_json(json.loads((fixture / name).read_bytes())), owner="synthetic-demo" + ) + for name in ("market-old.json", "market-new.json") + ] + request = { + "store": str(store.root.resolve()), + "plan": str((out / "plan.json").resolve()), + "old_input": refs[0].to_dict(), + "new_input": refs[1].to_dict(), + "owner": "synthetic-demo", + } + (out / "request.json").write_text(json.dumps(request, indent=2), encoding="utf-8") + for name, ref in zip(("old-ref.json", "new-ref.json"), refs): + (out / name).write_text(json.dumps(ref.to_dict(), indent=2), encoding="utf-8") + commands.append( + [ + "revision-study", + "--request", + str(out / "request.json"), + "--out", + str(out / "revision.json"), + ] + ) + evidence = [] + for args in commands: + command = [sys.executable, "-m", "quant_portfolio.cli", *args] + completed = subprocess.run(command, check=True, capture_output=True, text=True) + evidence.append( + { + "command": command, + "returncode": completed.returncode, + "stdout": completed.stdout, + "stderr": completed.stderr, + } + ) + old, new = [json.loads((out / name).read_bytes()) for name in ("old.json", "new.json")] + # Independent arithmetic, deliberately not calling ledger, policy or metric helpers: + # old A: 5*(110-101)-5=40; revised A: 4*(110-101)-4=32; + # both B: 6*(50-50)-6=-6; opening capital=1000. + expected_old = Decimal(5) * (Decimal(110) - Decimal(101)) - Decimal(5) - Decimal(6) + expected_new = Decimal(4) * (Decimal(110) - Decimal(101)) - Decimal(4) - Decimal(6) + assert Decimal(old["net_pnl"]) == expected_old == 34 + assert Decimal(new["net_pnl"]) == expected_new == 26 + assert Decimal(old["final_account"]["nav"]["units"]) / 10**8 == 1034 + assert Decimal(new["final_account"]["nav"]["units"]) / 10**8 == 1026 + pair = json.loads((out / "revision.json").read_bytes()) + assert pair["economic_changes"]["orders"] + assert Decimal(pair["economic_changes"]["net_pnl"][0]["delta"]) == -8 + summary = { + "evidence_kind": "synthetic", + "independent_expected_pnl": {"old": "34", "new": "26", "delta": "-8"}, + "actual_pnl": {"old": old["net_pnl"], "new": new["net_pnl"]}, + "code_identity": old["code_identity"], + "commands": evidence, + "claims": [ + "CLI/API plumbing", + "independent scalar cash/PnL", + "frozen data revision sensitivity", + ], + "does_not_establish": ["market validity", "forecast skill", "live readiness"], + } + (out / "acceptance.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") + (out / "README.md").write_text( + "# Synthetic continuous-capital acceptance\n\n" + "Old NAV 1034 USD / PnL +34; revised NAV 1026 USD / PnL +26; delta -8.\n\n" + "The changed first signal price reduces A's target from 5 to 4 units. " + "Later execution and terminal prices are held fixed. Both runs charge explicit fees. " + "Inputs, frozen plan and both complete outputs remain pinned in the artifact store.\n\n" + "This validates deterministic accounting and revision plumbing, not real-market performance. " + "See acceptance.json for exact commands and independent scalar expectations, revision.json " + "for affected objects and stage changes, and old.json/new.json for all ledger postings.\n", + encoding="utf-8", + ) + print(json.dumps({"old_nav": "1034", "new_nav": "1026", "delta_pnl": "-8", "out": str(out)})) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, required=True) + run(parser.parse_args().out) diff --git a/src/quant_portfolio/cli.py b/src/quant_portfolio/cli.py index 71542c2..ead490a 100644 --- a/src/quant_portfolio/cli.py +++ b/src/quant_portfolio/cli.py @@ -111,6 +111,9 @@ def cmd_optimize(args: argparse.Namespace) -> int: def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="quant-portfolio") sub = p.add_subparsers(dest="command", required=True) + from quant_portfolio.continuous_study import add_parsers + + add_parsers(sub) status = sub.add_parser("status", help="Combine strategy books into portfolio view") status.add_argument("--config", required=True) status.add_argument("--out", default="") diff --git a/src/quant_portfolio/continuous_study.py b/src/quant_portfolio/continuous_study.py new file mode 100644 index 0000000..37f9a6d --- /dev/null +++ b/src/quant_portfolio/continuous_study.py @@ -0,0 +1,461 @@ +"""Frozen continuous capital and old/new revision studies; offline research only.""" + +from __future__ import annotations + +import argparse +import importlib +import importlib.metadata +import json +import platform +from copy import deepcopy +from datetime import datetime, timezone +from decimal import ROUND_DOWN, Decimal, localcontext +from pathlib import Path + +from quant_data_kit import AssetClass, FixedPoint, InstrumentSpec, MarginMode +from quant_data_kit.artifacts import ArtifactRef, ArtifactStore, canonical_json +from quant_data_kit.revision_impact import RevisionRegistry, json_changes, sha256 +from quant_execution.continuous import SharedCapitalAccount, number, utc + + +def code_identity(): + """Hash actual installed source bytes and runtime versions, including dirty edits.""" + import hashlib + + identity = {} + for name in ("quant_data_kit", "quant_execution", "quant_portfolio"): + root = Path(importlib.import_module(name).__file__).parent + files = { + p.relative_to(root).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(root.rglob("*.py")) + } + if not files: + raise ValueError("source identity unavailable") + identity[name] = sha256(files) + versions = { + name: importlib.metadata.version(name) + for name in ( + "numpy", + "pandas", + "pyarrow", + "jsonschema", + "quant-data-kit", + "quant-execution", + "quant-portfolio", + ) + } + identity["runtime"] = sha256({"python": platform.python_version(), "packages": versions}) + return identity + + +def freeze_plan(definition): + definition = json.loads(canonical_json(definition)) + if definition.get("schema") != "quant.capital-plan/v1": + raise ValueError("capital plan v1 required") + if any(k in definition for k in ("code_identity", "frozen_at", "controls_sha256")): + raise ValueError("already frozen; cannot silently refreeze historical controls") + return { + **definition, + "code_identity": code_identity(), + "controls_sha256": sha256(definition), + "frozen_at": datetime.now(timezone.utc).isoformat(), + } + + +def _validate_plan(plan): + definition = { + k: v for k, v in plan.items() if k not in {"code_identity", "controls_sha256", "frozen_at"} + } + if plan.get("schema") != "quant.capital-plan/v1" or sha256(definition) != plan.get( + "controls_sha256" + ): + raise ValueError("frozen controls changed or missing") + if plan.get("code_identity") != code_identity(): + raise ValueError("frozen executable code/runtime changed") + utc(plan["frozen_at"]) + if plan["evidence_kind"] not in {"synthetic", "retrospective"}: + raise ValueError("this adapter only supports synthetic or retrospective evidence") + if plan["sample_kind"] not in {"deterministic_fixture", "in_sample"}: + raise ValueError("this fixed-policy adapter does not certify OOS predictions") + if number(plan["cost_policy"]["fee_per_unit"]) < 0: + raise ValueError("nonnegative explicit fee required") + if plan["cost_policy"].get("model") != "explicit_fee_per_unit": + raise ValueError("unsupported cost policy") + if plan["valuation_policy"] != { + "model": "last_available_mark_and_fx", + "positive_futures_pnl_spendable": False, + }: + raise ValueError("unsupported valuation policy") + return definition + + +def _account(plan): + specs = [] + for raw in plan["instruments"]: + item = deepcopy(raw) + item["asset_class"] = AssetClass(item["asset_class"]) + item["margin_mode"] = MarginMode(item.get("margin_mode", "none")) + for key in ("price_tick", "quantity_step", "contract_multiplier"): + item[key] = FixedPoint(**item[key]) + for key in ("effective_from", "available_at", "effective_to", "superseded_at"): + if item.get(key) is not None: + item[key] = utc(item[key]) + if item.get("expiry_date"): + from datetime import date + + item["expiry_date"] = date.fromisoformat(item["expiry_date"]) + specs.append(InstrumentSpec(**item)) + return SharedCapitalAccount( + specs=specs, + owners=plan["owners"], + allocations=plan["allocations"], + base_currency=plan["base_currency"], + opened_at=plan["opened_at"], + fx_rates=plan["opening_fx"], + ) + + +def _schedule(plan, market): + if market.get("schema") != "quant.capital-market/v1": + raise ValueError("capital market v1 required") + observations = {} + schedule = [] + for row in market["observations"]: + name, at = row["observation_id"], utc(row["at"]) + if name in observations or utc(row["available_at"]) > at: + raise ValueError("duplicate observation or unavailable market data") + observations[name] = row + sequence = 100 + for currency, rate in sorted(row["fx"].items()): + schedule.append( + { + "event_id": f"{name}:fx:{currency}", + "at": row["at"], + "available_at": row["available_at"], + "sequence": sequence, + "kind": "fx_mark", + "currency": currency, + "rate": rate, + } + ) + sequence += 1 + for instrument, price in sorted(row["prices"].items()): + schedule.append( + { + "event_id": f"{name}:mark:{instrument}", + "at": row["at"], + "available_at": row["available_at"], + "sequence": sequence, + "kind": "mark", + "instrument_id": instrument, + "price": price, + } + ) + sequence += 1 + if sequence >= 1000: + raise ValueError("at most 899 marks per observation") + decision_ids = set() + for n, decision in enumerate(plan["decisions"]): + if decision["decision_id"] in decision_ids: + raise ValueError("duplicate decision ID") + decision_ids.add(decision["decision_id"]) + signal = observations[decision["signal_observation_id"]] + execution = observations[decision["execution_observation_id"]] + if utc(signal["at"]) >= utc(execution["at"]): + raise ValueError("signal observation must strictly precede execution") + schedule.append( + { + "event_id": "decision:" + decision["decision_id"], + "kind": "decision", + "at": execution["at"], + "sequence": 1000 + n, + "decision": decision, + } + ) + for event in plan.get("cash_events", []): + if not 0 <= event["sequence"] < 100: + raise ValueError("cash event sequences must be in [0,100)") + if event["kind"] not in { + "transfer", + "external_cash", + "fx_convert", + "settle", + "charge", + "variation_margin", + }: + raise ValueError("unsupported scheduled cash event") + schedule.append(deepcopy(event)) + ordered = sorted(schedule, key=lambda e: (utc(e["at"]), e["sequence"])) + keys = [(utc(e["at"]), e["sequence"]) for e in ordered] + ids = [e["event_id"] for e in ordered] + if len(keys) != len(set(keys)) or len(ids) != len(set(ids)): + raise ValueError("ambiguous event schedule or duplicate ID") + if not ordered: + raise ValueError("nonempty observation schedule required") + return observations, ordered + + +def run_study(plan, market): + with localcontext() as context: + context.prec = 64 + return _run_study(plan, market) + + +def _run_study(plan, market): + _validate_plan(plan) + plan, market = json.loads(canonical_json(plan)), json.loads(canonical_json(market)) + account = _account(plan) + observations, ordered = _schedule(plan, market) + orders, signals = [], [] + for event in ordered: + if event["kind"] != "decision": + account.apply(event) + continue + decision = event["decision"] + instrument = decision["instrument_id"] + spec = account.specs[instrument] + signal = observations[decision["signal_observation_id"]] + execution = observations[decision["execution_observation_id"]] + price = number(signal["prices"][instrument]) + if price <= 0: + raise ValueError("positive signal price required") + notional = number(decision["target_notional"]) + step = spec.quantity_step.to_decimal() + target = ( + notional / (price * spec.contract_multiplier.to_decimal()) / step + ).to_integral_value(rounding=ROUND_DOWN) * step + held = ( + account.ledger.snapshot(account.last_key[0]) + .positions.get(instrument, FixedPoint(0, 0)) + .to_decimal() + ) + quantity = target - held + signals.append( + { + "decision_id": decision["decision_id"], + "at": signal["at"], + "instrument_id": instrument, + "target_notional": str(notional), + "target_quantity": str(target), + "policy": "frozen_notional_to_lots", + } + ) + order = { + "event_id": event["event_id"], + "order_id": decision["decision_id"], + "at": event["at"], + "sequence": event["sequence"], + "available_at": execution["available_at"], + "kind": "fill", + "sleeve": account.owners[instrument], + "instrument_id": instrument, + "quantity": str(quantity), + "price": execution["prices"][instrument], + "fee": str(abs(quantity) * number(plan["cost_policy"]["fee_per_unit"])), + "settles_at": decision["settles_at"], + } + orders.append({**order, "status": "no_change" if quantity == 0 else "declared_fill"}) + if quantity: + account.apply(order) + else: + account.apply( + { + k: v + for k, v in {**order, "kind": "settle"}.items() + if k in {"event_id", "at", "sequence", "available_at", "kind"} + } + ) + result = account.report() + if code_identity() != plan["code_identity"]: + raise ValueError("code changed during evaluation") + # Same-instant asynchronous events retain their sequence in history. Publish + # one observation after all events at that instant for the common measurement + # grid; its return compounds those event returns (including flow timing). + opening = utc(plan["opened_at"]).isoformat().replace("+00:00", "Z") + grouped = {} + for row in result["history"]: + at = utc(row["at"]).isoformat().replace("+00:00", "Z") + if utc(at) <= utc(opening): + raise ValueError("study observations must follow the opening boundary") + prior = grouped.setdefault( + at, {"at": at, "growth": Decimal(1), "external_flow": Decimal(0)} + ) + if prior["growth"] is not None: + prior["growth"] = ( + None + if row["net_return"] is None + else prior["growth"] * (1 + number(row["net_return"])) + ) + prior["external_flow"] += number(row["external_flow"]) + prior["nav"] = row["nav"] + observations = [ + { + "at": at, + "nav": row["nav"], + "external_flow": str(row["external_flow"]), + "net_return": None if row["growth"] is None else str(row["growth"] - 1), + } + for at, row in grouped.items() + ] + times = [opening] + [row["at"] for row in observations] + inputs = { + "market": "sha256:" + sha256(market), + "opening_fx": "sha256:" + sha256(plan["opening_fx"]), + "instruments": "sha256:" + sha256(plan["instruments"]), + } + actual_cost_policy = { + **plan["cost_policy"], + "scheduled_charges": [e for e in plan.get("cash_events", []) if e["kind"] == "charge"], + } + basis = { + "currency": plan["base_currency"], + "monetary_unit": "major_currency", + "return_unit": "decimal", + "return_basis": "net_simple_time_weighted", + "period_start": times[0], + "period_end": times[-1], + "frequency": "event", + "calendar": "explicit_research_events", + "timezone": "UTC", + "annualization_periods": None, + "annualization_policy": "not_annualized", + "risk_free_rate": "0", + "risk_free_compounding": "simple", + "risk_free_unit": "annual_decimal", + "flow_policy": "external_flows_excluded_internal_transfers_neutral", + "cost_policy": "actual_ledger_fees_and_charges", + "cost_policy_id": "sha256:" + sha256(actual_cost_policy), + "valuation_policy": "event_close_mark", + "valuation_policy_id": "sha256:" + sha256(plan["valuation_policy"]), + "benchmark_id": "none", + "universe_id": "sha256:" + sha256(plan["instruments"]), + "timing_policy": "prior_observation_signal_next_observation_declared_execution", + "denominator_policy": "sequential_event_nav_compounded_within_timestamp", + "observation_schedule_id": "sha256:" + sha256(times), + "capital_basis": "net_assets_including_cash_and_receivables_less_liabilities", + "initial_capital": result["initial_nav"], + } + return { + **result, + "schema": "quant.continuous-study/v1", + "evidence_kind": plan["evidence_kind"], + "sample_kind": plan["sample_kind"], + "code_identity": plan["code_identity"], + "input_identity": inputs, + "observations": observations, + "observation_timestamps": times, + "measurement_configs": { + "cost_policy": actual_cost_policy, + "valuation_policy": plan["valuation_policy"], + }, + "plan_sha256": sha256(plan), + "controls_sha256": plan["controls_sha256"], + "signals": signals, + "orders": orders, + "unavailable_stages": ["features", "forecasts"], + "measurement": { + "schema": "quant.measurement/v1", + "evidence_kind": plan["evidence_kind"], + "sample_kind": plan["sample_kind"], + "basis": basis, + "provenance": { + "code_identity": plan["code_identity"], + "input_identity": inputs, + }, + "unsupported_scopes": result["unsupported_scopes"], + }, + } + + +def paired_study(store, plan, old_input, new_input, *, owner): + """Execute the identical frozen plan twice against verified immutable bytes.""" + _validate_plan(plan) + registry = RevisionRegistry(store) + plan_ref = store.put_bytes(canonical_json(plan), owner=owner) + outputs, derivations = [], [] + for ref in (old_input, new_input): + market = json.loads(store.read_bytes(ref)) + result = run_study(plan, market) + output = store.put_bytes(canonical_json(result), owner=owner) + descriptor = registry.register( + output=output, + inputs={"market": ref, "frozen_plan": plan_ref}, + stage="continuous_study", + code_identity=plan["code_identity"], + controls={"frozen_plan_sha256": plan_ref.sha256}, + owner=owner, + ) + outputs.append(result) + derivations.append(descriptor) + pair = registry.paired(*derivations, old_input=old_input, new_input=new_input) + stages = ("signals", "orders", "history", "net_pnl", "cumulative_return", "final_account") + report = { + **pair, + "schema": "quant.revision-study/v1", + "frozen_plan": plan_ref.to_dict(), + "evidence_kind": plan["evidence_kind"], + "sample_kind": plan["sample_kind"], + "interpretation": "retrospective sensitivity under frozen controls; no historical decision replacement", + "affected_objects": registry.impact(old_input, new_input)["affected_objects"], + "unavailable_stages": ["features", "forecasts"], + "economic_changes": {s: json_changes(outputs[0][s], outputs[1][s]) for s in stages}, + } + report_ref = store.put_bytes(canonical_json(report), owner=owner) + registry.register( + output=report_ref, + inputs={"old": derivations[0], "new": derivations[1]}, + stage="revision_report", + code_identity=plan["code_identity"], + controls={"paired": True}, + owner=owner, + ) + return {**report, "report_artifact": report_ref.to_dict()} + + +def _read(path): + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def command(args): + if args.command == "freeze-capital-plan": + result = freeze_plan(_read(args.plan)) + elif args.command == "continuous-study": + result = run_study(_read(args.plan), _read(args.market)) + else: + request = _read(args.request) + store = ArtifactStore(Path(request["store"])) + result = paired_study( + store, + _read(request["plan"]), + ArtifactRef.from_dict(request["old_input"]), + ArtifactRef.from_dict(request["new_input"]), + owner=request["owner"], + ) + # Never overwrite a prior plan, run, or revision decision. + with Path(args.out).open("x", encoding="utf-8") as stream: + json.dump(result, stream, indent=2, allow_nan=False) + return 0 + + +def add_parsers(sub): + for name in ("freeze-capital-plan", "continuous-study", "revision-study"): + parser = sub.add_parser(name, help=__doc__) + if name == "revision-study": + parser.add_argument("--request", required=True) + else: + parser.add_argument("--plan", required=True) + if name == "continuous-study": + parser.add_argument("--market", required=True) + parser.add_argument("--out", required=True) + parser.set_defaults(func=command) + + +def main(argv=None): + parser = argparse.ArgumentParser() + add_parsers(parser.add_subparsers(dest="command", required=True)) + args = parser.parse_args(argv) + return command(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_continuous_study.py b/tests/test_continuous_study.py new file mode 100644 index 0000000..f5722eb --- /dev/null +++ b/tests/test_continuous_study.py @@ -0,0 +1,196 @@ +import json +from copy import deepcopy +from decimal import Decimal + +import pytest +from quant_data_kit import AssetClass, FixedPoint, InstrumentSpec +from quant_data_kit.artifacts import ArtifactStore, canonical_json +from quant_data_kit.domain_v2 import dataclass_payload +from quant_execution.continuous import utc + +from quant_portfolio.cli import main +from quant_portfolio.continuous_study import freeze_plan, paired_study, run_study + + +def definition(): + specs = [ + InstrumentSpec( + instrument_id=i, + asset_class=AssetClass.ETF, + product_type="cash_etf", + venue="SYNTHETIC", + native_symbol=i, + settlement_currency="USD", + price_tick=FixedPoint(1, 2), + quantity_step=FixedPoint(1, 0), + contract_multiplier=FixedPoint(1, 0), + calendar_id="explicit_fixture", + effective_from=utc("2026-01-01T00:00:00Z"), + available_at=utc("2026-01-01T00:00:00Z"), + ) + for i in ("A", "B") + ] + return { + "schema": "quant.capital-plan/v1", + "evidence_kind": "synthetic", + "sample_kind": "deterministic_fixture", + "instruments": [dataclass_payload(s) for s in specs], + "owners": {"A": "alpha", "B": "beta"}, + "allocations": {"alpha": {"USD": "600"}, "beta": {"USD": "400"}}, + "base_currency": "USD", + "opened_at": "2026-01-01T00:00:00Z", + "opening_fx": {}, + "cost_policy": {"model": "explicit_fee_per_unit", "fee_per_unit": "1"}, + "valuation_policy": { + "model": "last_available_mark_and_fx", + "positive_futures_pnl_spendable": False, + }, + "decisions": [ + { + "decision_id": "buy-a", + "signal_observation_id": "o1", + "execution_observation_id": "o2", + "instrument_id": "A", + "target_notional": "500", + "settles_at": "2026-01-03T00:00:00Z", + }, + { + "decision_id": "buy-b", + "signal_observation_id": "o1", + "execution_observation_id": "o2", + "instrument_id": "B", + "target_notional": "300", + "settles_at": "2026-01-03T00:00:00Z", + }, + ], + "cash_events": [], + } + + +def market(): + return { + "schema": "quant.capital-market/v1", + "observations": [ + { + "observation_id": f"o{i}", + "at": f"2026-01-0{i + 1}T00:00:00Z", + "available_at": f"2026-01-0{i + 1}T00:00:00Z", + "prices": {"A": p, "B": "50"}, + "fx": {}, + } + for i, p in [(1, "100"), (2, "101"), (3, "110")] + ], + } + + +def test_actual_policy_execution_and_independent_expected_result(): + result = run_study(freeze_plan(definition()), market()) + # A: floor(500/100)=5, execution 101, fee 5, final mark 110 => +40. + # B: floor(300/50)=6, execution 50, fee 6, final mark 50 => -6. + assert Decimal(result["net_pnl"]) == 34 + assert Decimal(result["cumulative_return"]) == pytest.approx(Decimal("0.034")) + assert [o["quantity"] for o in result["orders"]] == ["5", "6"] + assert result["measurement"]["schema"] == "quant.measurement/v1" + assert result["reconciliation"]["ledger_balanced"] + + +def test_revision_pair_recomputes_orders_and_cash_preserves_old(tmp_path): + store = ArtifactStore(tmp_path / "store") + old_data, new_data = market(), market() + new_data["observations"][0]["prices"]["A"] = "125" + old, new = [store.put_bytes(canonical_json(data)) for data in (old_data, new_data)] + plan = freeze_plan(definition()) + pair = paired_study(store, plan, old, new, owner="test") + assert pair["intervention"] == "data_revision" + assert pair["economic_changes"]["signals"] + assert pair["economic_changes"]["orders"] + assert pair["economic_changes"]["net_pnl"][0]["delta"] == "-8.00000000" + assert pair["unavailable_stages"] == ["features", "forecasts"] + assert store.read_bytes(old) == canonical_json(old_data) + same = paired_study(store, plan, old, old, owner="same") + assert not same["changes"] and not same["affected_objects"] + with pytest.raises(FileExistsError): + request = tmp_path / "request.json" + request.write_text("{}", encoding="utf-8") + # Output preservation is checked through a real successful continuous command below. + paths = [tmp_path / name for name in ("plan.json", "market.json")] + for path, doc in zip(paths, (plan, old_data)): + path.write_text(json.dumps(doc), encoding="utf-8") + main( + [ + "continuous-study", + "--plan", + str(paths[0]), + "--market", + str(paths[1]), + "--out", + str(request), + ] + ) + + +def test_freeze_run_and_revision_cli(tmp_path): + draft, frozen, market_path, output = [ + tmp_path / p for p in ("draft.json", "frozen.json", "market.json", "run.json") + ] + draft.write_text(json.dumps(definition()), encoding="utf-8") + market_path.write_text(json.dumps(market()), encoding="utf-8") + assert main(["freeze-capital-plan", "--plan", str(draft), "--out", str(frozen)]) == 0 + assert ( + main( + [ + "continuous-study", + "--plan", + str(frozen), + "--market", + str(market_path), + "--out", + str(output), + ] + ) + == 0 + ) + assert Decimal(json.loads(output.read_bytes())["net_pnl"]) == 34 + store = ArtifactStore(tmp_path / "store") + old = store.put_bytes(canonical_json(market())) + revised = market() + revised["observations"][-1]["prices"]["A"] = "111" + new = store.put_bytes(canonical_json(revised)) + request = tmp_path / "request.json" + request.write_text( + json.dumps( + { + "store": str(store.root), + "plan": str(frozen), + "old_input": old.to_dict(), + "new_input": new.to_dict(), + "owner": "cli", + } + ), + encoding="utf-8", + ) + assert ( + main(["revision-study", "--request", str(request), "--out", str(tmp_path / "pair.json")]) + == 0 + ) + + +def test_changed_code_controls_future_prices_and_leakage_fail(monkeypatch): + plan = freeze_plan(definition()) + changed = deepcopy(plan) + changed["cost_policy"]["fee_per_unit"] = "2" + with pytest.raises(ValueError, match="controls"): + run_study(changed, market()) + future = market() + future["observations"][0]["available_at"] = "2027-01-01T00:00:00Z" + with pytest.raises(ValueError, match="unavailable"): + run_study(plan, future) + invalid = definition() + invalid["decisions"][0]["signal_observation_id"] = "o2" + with pytest.raises(ValueError, match="strictly precede"): + run_study(freeze_plan(invalid), market()) + from quant_portfolio import continuous_study + + monkeypatch.setattr(continuous_study, "code_identity", lambda: {"producer": "b" * 64}) + with pytest.raises(ValueError, match="code/runtime"): + run_study(plan, market()) diff --git a/tests/test_governance.py b/tests/test_governance.py index 4d7ad9d..f47419b 100644 --- a/tests/test_governance.py +++ b/tests/test_governance.py @@ -26,21 +26,21 @@ def test_workspace_declaration_and_internal_release_tags_are_locked() -> None: dependencies = project["project"]["dependencies"] assert ( - "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@ba136c2fa2eea121bfb2ad7887b536c3952586f7" + "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@f85128307dd0e3407d24350282fe7b679b591309" in dependencies ) assert ( - "quant-execution @ git+https://github.com/PureSaber/quant-execution.git@21aace45d2dde6458db8fcda4829f252f8c640f1" + "quant-execution @ git+https://github.com/PureSaber/quant-execution.git@42e0854b1988d4d056d3597a92b343f8a046f8e3" in dependencies ) lock = (ROOT / "requirements.lock").read_text(encoding="utf-8") assert ( - "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@ba136c2fa2eea121bfb2ad7887b536c3952586f7" + "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@f85128307dd0e3407d24350282fe7b679b591309" in lock ) assert ( - "quant-execution @ git+https://github.com/PureSaber/quant-execution.git@21aace45d2dde6458db8fcda4829f252f8c640f1" + "quant-execution @ git+https://github.com/PureSaber/quant-execution.git@42e0854b1988d4d056d3597a92b343f8a046f8e3" in lock ) assert 'tomli==2.4.1 ; python_version < "3.11"' in lock From ec9fff26d43b263e1378cd281db6da99477755b1 Mon Sep 17 00:00:00 2001 From: PureSaber Date: Mon, 12 Oct 2026 01:01:15 +0800 Subject: [PATCH 2/2] Pin native-currency conservative margin correction --- pyproject.toml | 2 +- requirements.lock | 2 +- tests/test_governance.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 61760c5..40c86da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ license = { text = "MIT" } requires-python = ">=3.10" dependencies = [ "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@f85128307dd0e3407d24350282fe7b679b591309", - "quant-execution @ git+https://github.com/PureSaber/quant-execution.git@42e0854b1988d4d056d3597a92b343f8a046f8e3", + "quant-execution @ git+https://github.com/PureSaber/quant-execution.git@49ef53d2c502ad6faad6818f853dadd1f8b8b182", "pandas>=2.0", "pyarrow>=14.0", "pyyaml>=6.0", diff --git a/requirements.lock b/requirements.lock index 63872fb..9c5b18a 100644 --- a/requirements.lock +++ b/requirements.lock @@ -70,7 +70,7 @@ quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@f85128307dd # via # quant-execution # quant-portfolio (pyproject.toml) -quant-execution @ git+https://github.com/PureSaber/quant-execution.git@42e0854b1988d4d056d3597a92b343f8a046f8e3 +quant-execution @ git+https://github.com/PureSaber/quant-execution.git@49ef53d2c502ad6faad6818f853dadd1f8b8b182 # via quant-portfolio (pyproject.toml) referencing==0.37.0 # via diff --git a/tests/test_governance.py b/tests/test_governance.py index f47419b..2ae9615 100644 --- a/tests/test_governance.py +++ b/tests/test_governance.py @@ -30,7 +30,7 @@ def test_workspace_declaration_and_internal_release_tags_are_locked() -> None: in dependencies ) assert ( - "quant-execution @ git+https://github.com/PureSaber/quant-execution.git@42e0854b1988d4d056d3597a92b343f8a046f8e3" + "quant-execution @ git+https://github.com/PureSaber/quant-execution.git@49ef53d2c502ad6faad6818f853dadd1f8b8b182" in dependencies ) @@ -40,7 +40,7 @@ def test_workspace_declaration_and_internal_release_tags_are_locked() -> None: in lock ) assert ( - "quant-execution @ git+https://github.com/PureSaber/quant-execution.git@42e0854b1988d4d056d3597a92b343f8a046f8e3" + "quant-execution @ git+https://github.com/PureSaber/quant-execution.git@49ef53d2c502ad6faad6818f853dadd1f8b8b182" in lock ) assert 'tomli==2.4.1 ; python_version < "3.11"' in lock