1年後に今日のクオンツ研究をもう一度実行したいとき、何を復元できれば十分なのだろう。

GitHubに当時の戦略コードが残り、研究DBにもrun_idがある。それでもデータベンダーが過去の価格を訂正し、当時受け取ったAPIレスポンスを削除していたら、再実行できても同じ結果とは限らない。PythonとSQLでシグナルやファクタースナップショットを蓄積する小さな研究システムでは、判断に使ったデータ、その時点で入手可能だった情報、コードを一緒に再現できることが重要だ。

当初はPostgreSQLに研究メタデータとシグナル、outcomeを残し、大きなraw_jsonだけCloudflare R2に移して、検証後にDBの古いデータをpruneする設計を考えた。ところが論点はgzipやS3 SDKの使い方より深い。いつ追加の運用コストが正当化されるか。なぜオブジェクトのアップロードより先にmanifestを作るのか。そしてデータを消した後、本当に過去の研究を戻せるのか。

Python標準ライブラリでSQLiteのインメモリDBとMemoryObjectStoreを組み合わせたオフライン実験も行った。いくつかの誤削除防止条件はテストできたが、実際のPostgreSQL/R2間の障害や複数workerの競合は検証していない。以下では、実験で確認したことと公式ドキュメントに基づく設計判断を分けて書く。

PostgreSQLだけで足りる場合を先に考える

個人開発でデータ量が少なく、過去のrunを頻繁に復元しないなら、最初からPostgreSQL、R2、manifest、reconciliation workerを全部保守する必要はない。定期バックアップ、実際のリストア訓練、コードとパラメーターのバージョン記録を優先したほうが現実的だ。

既存の選択肢として PostgreSQLのPITR(Point-in-Time Recovery) がある。base backupと保存したWALを使い、保護対象の期間内の時点にデータベースクラスタを復元する仕組みだ。研究入力が当時PostgreSQLにあり、必要な期間だけバックアップが残っていれば、独立した復元環境でそのデータを取り出せる。

ただしPITRが復元するのは基本的にDBクラスタ単位。研究者が欲しいのは「run_id=35が実際に見た市場データ」だ。1件のrunのために古いクラスタを復元し、年単位のWALを維持し、今の研究コードと合わせるのは、変更不可のデータオブジェクトを1つ取り出すより面倒になりうる。PITRは、DBに書かれなかったAPIレスポンスやPythonの依存関係、取引所カレンダーまで保存してくれるわけでもない。

役割は分けて考える。PITRはDBの災害復旧、run単位のデータアーカイブは研究の追跡・再検証に使う。両方を使ってもいいし、後者が不要な規模なら採用しなくていい。

「100GBを超えたらR2」のような閾値を根拠なく置くつもりはない。まずheap、TOAST、インデックス、日々のraw_json増加、バックアップ/WAL量、参照頻度、復旧時間を計測する。次は実DBに対して未実行の読み取り専用SQL例だ。

SELECT
  pg_size_pretty(pg_relation_size('quant_research.signal_snapshot')) AS heap,
  pg_size_pretty(pg_table_size('quant_research.signal_snapshot')) AS table_including_toast,
  pg_size_pretty(pg_total_relation_size('quant_research.signal_snapshot')) AS total_with_indexes;

コールドデータがDB保守を実際に圧迫し、runごとの復元が必要になった時点で、はじめてR2を足す判断ができる。

TOASTとMVCCを考慮すると、raw_jsonのpruneは単純ではない

PostgreSQLには大きな値を扱う TOAST がある。行全体が通常およそ2KBの閾値を超えると、条件に応じて可変長データを圧縮し、必要なら別のTOASTテーブルに移す。ディスク上の外部ポインターは公式文書で18バイトと説明されている。

とはいえ「2KBを超えるJSONBは必ず行外に移る」は不正確だ。行全体のサイズ、圧縮結果、カラムのstorage strategyによって変わる。通常のクエリがraw_jsonを読まないなら、TOASTで既に十分なこともある。

元の案で再考すべきだったのは、大量のUPDATEだ。巨大なraw_jsonを短い要約に置き換えても、MVCCは新しい行バージョンを作り、WALを生成する。不要になった行や関連TOASTデータは後で回収する必要がある。大量の過去データを一気に更新すると、書き込み負荷、bloat、ロック競合が問題になりうる。ただし「すべてのUPDATEで深刻な肥大化が起きる」と断定するのも違う。

PostgreSQL VACUUMの仕様では、通常のVACUUMが主としてDB内で再利用できる領域を増やすのに対し、VACUUM FULLはテーブルを書き直してOSに領域を返せるが、より強いロックが必要だと説明されている。通常VACUUMでもファイル末尾の切り詰めが可能な場合はあるため、物理サイズが絶対に減らないわけではない。

新規設計なら垂直分割を検討したい。

signal_snapshot
  snapshot_id | run_id | signal_time | score | raw_hash | archive_key

signal_raw_payload
  snapshot_id | payload_version | raw_json | captured_at

検索頻度の高いシグナルはsignal_snapshotに、元のJSONはsignal_raw_payloadに置く。検証後のpruneはpayload側に限定すれば、主テーブルを大量更新せずに済む。ただし分割したからといってDELETEがMVCCやVACUUMから解放されるわけではない。

月やrunのバッチで保存期間が揃うなら、パーティションも有力だ。公式のpartitioningガイドでは、期限切れパーティションをまとめて取り除くほうが行単位DELETEより適しているケースを説明している。ただしパーティション内の全レコードについて、アーカイブ検証、保存期間、削除権限を確認する必要がある。大半が期限切れというだけでDROPしてはいけない。

既存の巨大テーブルを直ちに移行するべきでもない。heap/TOAST、WAL、クエリコストを測り、TOAST維持・垂直分割・保持期間別パーティションから選ぶ。

なぜmanifest-firstを選んだのか

オブジェクトの書き込み順には少なくとも2案ある。

Upload-first:
package → PUT object → GET/verify → commit PG manifest

Manifest-first:
commit PENDING manifest + expected digest → PUT object
→ GET/verify → mark VERIFIED in PG

Upload-firstなら、まだ存在しないオブジェクトを指すPENDINGレコードは生まれない。ただしPUT成功後、PGへ記録する前にプロセスが落ちると孤立したオブジェクトが残る。これを回収するには、キーの命名、所有者、garbage collectionのルールが必要だ。

オフライン試作ではmanifest-firstを選んだ。run_id / revision / expected_sha256を先にDBに固定すれば、失敗したアップロードについても、何を保存しようとしていたのか追跡できるからだ。しかしPENDINGは分散ロックではない。複数プロセスの競合まで防げるわけではない。

そしてこの順序は、試作に実際の穴を作った。アップロード拒否でPENDINGだけが残り、オブジェクトがない。最初のreconciliation実装は「オブジェクトがあれば検証する」だけだったので、その状態から回復できなかった。

修正後は、元データがまだ完全に残っている場合に限り再パッケージし、元のmanifestとSHA-256とバイト数が一致したときだけ再送する。

recovered = pack(self._source_rows(manifest["run_id"]))
if digest(recovered) != manifest["sha256"] or len(recovered) != manifest["byte_size"]:
    raise ArchiveError("pending manifest source changed; cannot re-upload")
self.store.put_if_absent(manifest["object_key"], recovered)

ここは合成実験で実行したローカルな修正であり、本番障害の再現ではない。

本番でworkerを並列運用するならleaseだけでも不十分だ。Aがleaseを取得してネットワークで停止し、期限後にBが作業を引き継いだとする。Aが復帰して古い権限で状態を書き戻してはいけない。PGの条件付きUPDATE ... RETURNINGと単調増加するlease_epoch(fencing token)を組み合わせる設計を考えている。以下のSQLはPostgreSQL上では未実行の案。

UPDATE archive_manifest
SET lease_owner = $2,
    lease_epoch = lease_epoch + 1,
    locked_until = clock_timestamp() + interval '5 minutes',
    status = 'RECONCILING'
WHERE identity = $1
  AND status IN ('PENDING', 'RECONCILING')
  AND (locked_until IS NULL OR locked_until < clock_timestamp())
RETURNING lease_epoch, expected_sha256, object_key;

遠隔PUT/GETの間ずっとPGトランザクションを保持しない。検証後の更新には取得時のlease_owner + lease_epochを必須にし、古いepochの更新を拒否する。5分は説明用であり、すべての環境に適したlease時間ではない。

さらにPGのleaseは、既に飛んでいるリモートPUTまで止められない。内容に基づくimmutable key、同じidentityへの異なるバイト列の上書きを拒否する条件付き書き込み、競合後の再検証が必要だ。実際の並列・ネットワーク障害試験が済むまでは設計仮説にとどまる。

R2でのバージョン、ETag、Metadata

Cloudflareの R2 S3互換性表ではPutObject、HeadObject、GetObjectは利用できる一方、GetBucketVersioningとPutBucketVersioningは未実装と記載されている。AWS S3のbucket versioningを有効にすれば古いオブジェクト版をいつでも取得できる、と想定してはいけない。

そこでraw/v1/run=35/revision=2/sha256=...jsonl.gzのような明示的な不変キーを使う。変更があれば新revisionに保存し、既存キーは上書きしない。Bucket LocksとObject Lifecycleも保持ポリシーに合わせる。ただしR2のBucket LocksをAWS S3 Object Lock APIと完全互換だと扱わない。

R2 S3 extensionsはx-amz-meta-*とユーザー定義metadataの対応を説明している。圧縮バイトのSHA-256、行数、schemaをmetadataに重複保存すればHEADで安価な照合ができる。次は実際のR2では動かしていない、単一PutObjectのboto3例。

# gzip_bytes and expected_sha256 must refer to the same immutable input
s3.put_object(
    Bucket=bucket,
    Key=object_key,
    Body=gzip_bytes,
    Metadata={
        "sha256": expected_sha256,
        "row-count": str(row_count),
        "schema-version": "1",
    },
    IfNoneMatch="*",
)
head = s3.head_object(Bucket=bucket, Key=object_key)
assert head["Metadata"]["sha256"] == expected_sha256

HEADで見えるSHAはアップロード側が宣言した値にすぎない。オブジェクトの中身が正しい保証にはならない。元データのpruneを許可する前に、実際に全量GETし、圧縮後のSHA-256、解凍、schema、行数、レコードIDを検証する必要がある。合成テストではHEADは成功するがGETが壊れているケースを注入し、prune拒否を確認した。

MultipartのETagもファイル全体のSHA-256ではない。単一PutObjectの条件付き書き込みルールがCompleteMultipartUploadに自動的に当てはまるとも限らない。実SDKのしきい値、abort/retry、上書き防止は承認済みテストbucketで別途確認したい。

履歴データの訂正にはas-ofと取引カレンダーが必要

古いオブジェクトを保管するだけでは足りない。ある意思決定時点で、その訂正版が本当に利用可能だったかが重要だ。

これはbitemporal(双時態)モデリングの一部に当たる。市場のevent/valid timeはバーの所属するセッションを表す。observed_atやingested_atはシステムがその版を受け取った時点だ。ベンダー側のsource_published_atも別に持つ。ベンダーが公開済みでも、自分のシステムが既に取得できたとは限らない。

合成データでは同じ市場日に対して、10月2日に終値100を初めて受信し、10月5日に104への訂正を受信した。

as_of(UTC)その時点で見えるclose
2026-10-01 09:00まだ存在しない
2026-10-03 00:00100
2026-10-06 00:00104

このテストは簡単なセレクターが後日の訂正を過去へ漏らさないことを示すだけだ。実ベンダーの撤回、訂正の優先順位、株式分割や過去の指数構成銘柄までは検証していない。

履歴研究APIではas_of_utc、dataset_revision、または不変のsnapshot_idを必須にしたい。cutoffのないバックテストが暗黙に「最新データ」を使う設計は避ける。リアルタイム画面のlatest表示は別のAPIにする。同じevent/observation時刻で版が衝突したら、ベンダーの訂正番号や権威ルールで選択し、判定できなければ隔離する。最後にINSERTされた行を正としない。

UTCに統一するだけでは取引終了時刻の問題は解決しない。米国市場の通常終了時刻は現地16:00だが、America/New_YorkはEDT/ESTで切り替わる。UTC時刻を年中固定してしまうと夏時間の期間でずれる。IANAタイムゾーンと版を固定した取引所カレンダーからセッションと判断cutoffを生成し、それをUTCへ変換する。実際にデータが到着した時刻、ingestion watermarkも確認する。

16:00:00に意思決定するのに、正式な終値が16:00:02に届いたなら、その2秒後の情報を借りてはいけない。早期終了、売買停止、引け後の訂正も同じ問題を起こす。調整後株価、corporate action、指数構成履歴には必要に応じて別々の版がいる。前回のファクター検証でもsignal-time snapshotの境界に触れた。

オフライン28ケースと「1年後に完全再実行」は別物

実際に走ったのは合成データを使った28個のオフライン回帰テストで、全件PASSだった。重複アーカイブ、意図的なアップロード拒否、オブジェクト欠落や破損時のprune拒否、PENDING復旧、保存期限とconfirmチェック、簡単なas_of訂正を扱う。自分が実装した状態遷移と模擬故障を検証したのであり、ネットワーク分断、実PostgreSQLのロック、worker競合、R2 multipartの正しさを保証するものではない。

終値10.5と15.5の2行から得た13.0 → 13.0 → 13.0も、単純なgzip/hash/復元のsmoke testにすぎない。複雑なバックテストの再現性やディスク節約効果は示さない。

実行ログ末尾の記録:

$ python3 verify.py --output results
Ran 28 tests in 0.017s

OK
CASE_SUMMARY {"ran": 28, "passes": 28, "failures": 0, "errors": 0}

完全なテストfixtureは非公開のContent Lab研究リポジトリにあり、公開コードとして誰でも取得できるリンクではない。

今回のreproduction_manifestにも大きな不足がある。玩具実験のオブジェクトハッシュ、schema、計算関数、パラメーター、Pythonバージョン、UTCは記録した。しかし別のマシンで古い戦略commitをチェックアウトし、依存パッケージを再構築し、過去のPostgreSQLデータと取引所カレンダーを復元して、factor/outcomeのパイプライン全体を実行してはいない。記事冒頭の「1年後に完全再実行」はまだ実証されていない。

本当にやるべき次の受け入れ試験は次の4つだ。

  1. 生データの版、オブジェクトSHA-256、ベンダー訂正/as_of、当時のsignal snapshotを固定する。
  2. 戦略のGit commit、設定ハッシュ、依存ロックファイルかコンテナdigest、取引所カレンダーの版、タイムゾーン、outcome定義を固定する。
  3. 隔離PostgreSQLでトランザクション、TOAST、並列lease、条件付き削除を試す。認可されたR2 sandboxで通常/Multipart、失敗、条件付きPUT、完全GET照合を試す。
  4. 別環境に過去のrunを復元し、固定したコードで再計算し、シグナル・判断・outcomeを一つずつ比較する。exit codeが0というだけでは足りない。

実際のPG/R2復元とアプリの読み取り経路が検証されるまでは、本番のraw_json自動pruneは無効にしておく。今日少し容量を使うより、数か月後に過去の意思決定の入力を説明できなくなるほうが、ずっと高くつく。