Quant-Research-Daten langfristig archivieren: PostgreSQL PITR oder R2?
Was muss noch vorhanden sein, wenn ich eine quantitative Analyse von heute in einem Jahr erneut ausführen möchte?
Der Strategiecode liegt vielleicht noch auf GitHub, und in der Forschungsdatenbank gibt es eine run_id. Trotzdem kann ein Marktdatenanbieter historische Kurse inzwischen korrigiert haben, während die ursprüngliche API-Antwort längst gelöscht ist. Ein erfolgreich gestarteter Backtest muss deshalb nicht dasselbe Ergebnis liefern. Bei einem kleineren Python-/SQL-Research-System geht es mir darum, die tatsächlich verwendeten Eingaben, den Code und den damaligen Informationsstand gemeinsam wiederherstellen zu können.
Mein erster Entwurf war recht naheliegend: PostgreSQL behält Metadaten, Signale und Outcomes; große raw_json-Payloads wandern nach Cloudflare R2; nach erfolgreicher Archivierung werden alte JSON-Felder in der Datenbank gekürzt. Schwieriger als gzip oder ein S3-SDK sind aber die Architekturentscheidungen: Wann rechtfertigt der Nutzen diese zusätzliche Komplexität? Warum soll der Manifest-Eintrag vor dem Upload entstehen? Und wie beweist man später, dass die alte Forschung wirklich dieselben Daten verwendet?
Ich habe dafür einen kleinen Offline-Prototyp mit Python-Standardbibliothek, SQLite im Arbeitsspeicher und einem simulierten MemoryObjectStore getestet. Er prüft einige Schutzbedingungen gegen versehentliches Pruning. Er testet weder echte PostgreSQL-/R2-Ausfälle noch konkurrierende Worker. Im Folgenden trenne ich tatsächliche Fixture-Beobachtungen von Entwurfsentscheidungen, die auf Herstellerdokumentation beruhen.
Wann PostgreSQL allein ausreicht
Wer einen überschaubaren privaten Research-Stack betreibt, wenig Daten hinzufügt und selten alte Runs wiederherstellt, braucht vermutlich kein System aus PostgreSQL, R2, Manifest und Reconciliation-Worker. Ich würde zunächst reguläre Sicherungen, tatsächlich geübte Restores sowie versionierte Strategieparameter und Commits in Ordnung bringen.
Eine wichtige vorhandene Lösung ist PostgreSQL PITR (Point-in-Time Recovery). Mit Base Backup und kontinuierlich archiviertem WAL lässt sich ein Datenbankcluster zu einem Zeitpunkt innerhalb des abgedeckten Zeitraums wiederherstellen. Wenn die Eingaben damals in PostgreSQL lagen und die Sicherungen lang genug aufbewahrt wurden, kann man sie auf einem separaten Restore-System auslesen.
PITR arbeitet aber im Wesentlichen auf Clusterebene. Die Anforderung an eine Forschungsplattform lautet oft: „Gib mir genau die Marktdaten, die run_id=35 damals gelesen hat.“ Für einen einzigen Run einen historischen Cluster wiederherzustellen, jahrelang WAL aufzubewahren und ihn mit dem heutigen Forschungscode zu kombinieren, kann deutlich aufwendiger sein, als ein unveränderliches Archivobjekt abzurufen. Außerdem konserviert PITR weder Python-Abhängigkeiten noch Börsenkalender oder externe API-Antworten, die nie in PostgreSQL gespeichert wurden.
Die Aufgaben ergänzen sich: PITR dient der Wiederherstellung einer Datenbank nach einem Ausfall; ein versioniertes Archiv pro Run dient der Nachvollziehbarkeit einer konkreten Untersuchung. Wenn diese zweite Anforderung nicht existiert, kann man sich die zusätzliche Infrastruktur sparen.
Ein pauschales „ab 100 GB R2“ wäre reine Spekulation. Zuerst würde ich Heap, TOAST, Indizes, tägliches Payload-Wachstum, Backup-/WAL-Zuwachs, Zugriffshäufigkeit und Restore-Ziele messen. Die folgende schreibgeschützte Beispielabfrage wurde nicht auf der echten PostgreSQL-Instanz ausgeführt:
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;
Erst wenn kalte Nutzdaten Wartung und Backups spürbar belasten und die Wiederherstellung einzelner Runs echten Wert hat, lohnt sich die zusätzliche Objektspeicherschicht.
TOAST, MVCC und die Kosten des nachträglichen Prunings
PostgreSQL besitzt mit TOAST bereits einen Mechanismus für große Werte. Wird eine ganze Zeile zu breit, typischerweise ab ungefähr 2 KB, können geeignete variable Felder komprimiert und gegebenenfalls in einer separaten TOAST-Tabelle abgelegt werden. Die Dokumentation beschreibt einen externen Disk-Pointer von 18 Byte.
Daraus folgt nicht, dass jedes JSONB-Feld über 2 KB automatisch außerhalb der Zeile liegt. Entscheidend sind die gesamte Zeilenlänge, der Kompressionserfolg und die Storage-Strategie der Spalte. Wenn normale Abfragen raw_json gar nicht laden, kann TOAST bereits einen beträchtlichen Teil der Arbeit erledigen.
Die unangenehme Seite meines ursprünglichen Entwurfs ist der Schreibvorgang. raw_json nachträglich durch eine Zusammenfassung zu ersetzen, bleibt ein UPDATE. Durch MVCC entsteht eine neue Tupelversion; WAL wird geschrieben, und alte Versionen sowie möglicherweise zugehörige TOAST-Daten müssen später bereinigt werden. Eine große Welle historischer Updates kann Schreiblast, Bloat und Lock-Konflikte erzeugen. Nicht jedes UPDATE verursacht jedoch zwangsläufig schwere Tabellenaufblähung.
Die VACUUM-Dokumentation unterscheidet wiederverwendbaren Platz innerhalb von PostgreSQL und tatsächliche Freigabe an das Betriebssystem. Normales VACUUM macht Platz meistens wieder nutzbar. VACUUM FULL schreibt die Relation neu und kann mehr Speicherplatz zurückgeben, benötigt aber stärkere Sperren. Auch das Kürzen eines freien Dateiendes ist unter Umständen möglich: „Der physische Speicher schrumpft niemals“ wäre ebenfalls falsch.
Bei einer neuen Anwendung würde ich eine vertikale Trennung prüfen:
signal_snapshot
snapshot_id | run_id | signal_time | score | raw_hash | archive_key
signal_raw_payload
snapshot_id | payload_version | raw_json | captured_at
Häufig genutzte Signalspalten liegen in signal_snapshot; vollständige Rohdaten werden über signal_raw_payload zugeordnet. Nach der Archivverifikation muss nur die Payload-Tabelle bereinigt werden, nicht die Haupttabelle mit allen Signalen. Eine separate Tabelle hebt aber weder MVCC noch die Folgen massenhafter DELETE-Anweisungen auf.
Wenn die Aufbewahrungsfrist sauber zu Monaten oder Run-Batches passt, können Partitionen nützlicher sein. Das Partitioning-Handbuch beschreibt Vorteile beim Entfernen einer gesamten abgelaufenen Partition gegenüber dem zeilenweisen Löschen. Die entscheidende Bedingung: Alle Einträge dieser Partition müssen erfolgreich archiviert sein, ihre Aufbewahrungsfrist erfüllt haben und gelöscht werden dürfen. Eine fast vollständig abgelaufene Partition darf nicht einfach per DROP verschwinden.
Eine bestehende große Tabelle würde ich nicht sofort umbauen. Ich würde zuerst Heap/TOAST, WAL, Query-Last und Locks messen und dann zwischen TOAST, vertikaler Aufteilung und Partitionierung nach Löschlebenszyklus entscheiden.
Warum Manifest-first und nicht Upload-first?
Für die Reihenfolge zwischen Datenbank und Objektspeicher gibt es mindestens zwei vertretbare Varianten:
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 verhindert einen PENDING-Datensatz ohne existierendes Objekt. Dafür entsteht ein anderes Fehlerfenster: PUT klappt, der Prozess stürzt vor dem PG-Commit ab, und ein verwaistes Objekt bleibt zurück. Um es wiederzufinden, braucht man klare Schlüssel, Zuständigkeiten und Regeln zur Garbage Collection.
Im Offline-Prototyp habe ich Manifest-first gewählt, um vor jeder Remote-Aktion die beabsichtigte Archivierung dauerhaft erkennbar zu machen. run_id / revision / expected_sha256 stehen von Anfang an fest. Bei einem fehlgeschlagenen Upload weiß der Abgleich-Worker wenigstens, welche Bytes ursprünglich versprochen wurden. Diese Entscheidung ist ein Trade-off, keine verteilte Transaktion. Ein PENDING-Datensatz ist noch kein verteilter Lock.
Die Entscheidung führte sogar zu einem echten Defekt im synthetischen Testcode. Wurde der Upload verweigert, blieb das Manifest auf PENDING, während das Objekt fehlte. Mein erster Reconciliation-Pfad kontrollierte nur bereits existierende Objekte und konnte diesen Zustand nicht beheben. Danach erlaubte ich einen erneuten Upload nur aus unveränderten, vollständig vorhandenen Quelldaten: SHA-256 und Bytezahl müssen zum ursprünglichen Manifest passen.
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)
Dieser Pfad wurde im lokalen Fixture tatsächlich korrigiert und geprüft. Er ist kein Bericht über einen Produktionsausfall.
Unter Last reicht das noch lange nicht. Worker A kann eine Lease übernehmen, im Netzwerk hängen bleiben und nach Ablauf wieder auftauchen, während Worker B inzwischen weiterarbeitet. A darf mit der alten Berechtigung keinen neuen Zustand schreiben. Dafür würde ich einen bedingten PG-UPDATE ... RETURNING mit einer monoton steigenden lease_epoch kombinieren: einem Fencing Token. Das folgende SQL ist ein nicht in PostgreSQL ausgeführter Produktionsentwurf:
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;
Während PUT/GET sollte keine langlaufende PG-Transaktion offenbleiben. Nach der Validierung ist der Status-Commit nur mit der ursprünglich erworbenen Kombination lease_owner + lease_epoch erlaubt. Fünf Minuten illustrieren lediglich das Lease-Feld und sind kein universeller Standard.
Auch ein Fencing Token verhindert keine Remote-PUT-Anfrage, die ein alter Worker bereits abgeschickt hat. Der Objektspeicher braucht unveränderliche, inhaltsadressierte Keys, bedingte Erstellung ohne Überschreiben abweichender Bytes und erneute Prüfung bei Konflikten. Ohne echte Konkurrenz- und Fehlertests ist das eine Zielarchitektur, kein nachgewiesener Schutz.
R2-Versionen, ETag und benutzerdefinierte Metadaten
Die aktuelle R2-S3-Kompatibilitätstabelle führt PutObject, HeadObject und GetObject als verfügbar, GetBucketVersioning und PutBucketVersioning jedoch als nicht implementiert. Native AWS-S3-Bucket-Versionierung darf daher nicht stillschweigend vorausgesetzt werden.
Ich würde unveränderliche Keys wie raw/v1/run=35/revision=2/sha256=...jsonl.gz verwenden. Eine Änderung erzeugt eine neue Revision, keine Überschreibung des alten Objekts. Bucket Locks und Lifecycle-Regeln müssen zum Forschungs-Aufbewahrungsmodell passen. R2 Bucket Locks sind nicht mit sämtlichen AWS-S3-Object-Lock-APIs gleichzusetzen.
Cloudflares S3 Extensions dokumentieren die Zuordnung von x-amz-meta-* zu Custom Metadata. Ein SHA-256 der komprimierten Bytes, Zeilenzahl und Schema-Version helfen beim günstigen Vorab-Abgleich per HEAD. Das folgende boto3-Beispiel zeigt einen nicht auf echtem R2 ausgeführten Single-PutObject-Aufruf:
# 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
Die SHA aus HEAD ist nur eine Behauptung des Uploaders. Sie beweist nicht, dass die tatsächlichen Objektbytes noch stimmen. Bevor raw_json gelöscht oder gekürzt wird, muss die Datei vollständig per GET heruntergeladen, ihr SHA-256 neu berechnet, dekomprimiert und anhand von Schema, Zeilenzahl und Identitäten geprüft werden. Im simulierten Test konnte HEAD erfolgreich sein und GET beschädigte Daten zurückgeben; der Pruning-Guard lehnte dann ab.
Bei Multipart Uploads ist ETag nicht der vollständige SHA-256 der Datei. Die Konditionalität eines einzelnen PutObject darf man auch nicht ungeprüft auf CompleteMultipartUpload übertragen. SDK-Schwellenwerte, Abbruch, Retry und konkurrierende Writes müssen auf einem autorisierten R2-Test-Bucket separat geprüft werden.
Historische Kurskorrekturen sind ein bitemporales Problem
Alte Bytes aufzubewahren reicht nicht. Ein Backtest muss außerdem wissen, welcher Datenstand zum Zeitpunkt der Entscheidung wirklich verfügbar war.
Das ist ein Teil der bitemporalen Modellierung. Event/Valid Time sagt, zu welcher Handelssitzung ein Kurs gehört; observed_at oder ingested_at geben an, wann das eigene System eine bestimmte Revision tatsächlich erhalten hat. Auch source_published_at des Datenanbieters sollte getrennt gespeichert werden. Dass der Anbieter etwas veröffentlicht hat, bedeutet nicht automatisch, dass der eigene Prozess es schon empfangen hatte.
Im synthetischen Beispiel erhielt das System am 2. Oktober erstmals einen Schlusskurs von 100. Am 5. Oktober folgte die Korrektur auf 104:
| as_of (UTC) | Sichtbarer Schlusskurs |
|---|---|
| 2026-10-01 09:00 | Noch keine Beobachtung |
| 2026-10-03 00:00 | 100 |
| 2026-10-06 00:00 | 104 |
Der Test belegt nur, dass dieser einfache Offline-as_of-Selektor spätere Korrekturen nicht rückwirkend sichtbar macht. Die Autorität widersprüchlicher Anbieterrevisionen, Rücknahmen, Adjustments, Delistings und historische Indexmitglieder wurden nicht getestet.
Eine historische Research-API sollte daher ein explizites as_of_utc, eine dataset_revision oder einen unveränderlichen snapshot_id verlangen. Fehlt ein Cutoff, muss die Backtest-Anfrage fehlschlagen, statt heimlich die aktuellste Kursversion zu verwenden. Eine Latest-Ansicht bleibt für Live-Dashboards sinnvoll. Bei kollidierenden Revisionen zählen Anbieter-Sequenznummern und definierte Regeln oder Quarantäne, nicht schlicht das letzte INSERT.
UTC allein löst auch keine Börsenschluss-Probleme. Regulärer US-Handel endet um 16:00 Uhr lokaler Börsenzeit. America/New_York wechselt zwischen EDT und EST; eine ganzjährig fest hinterlegte UTC-Uhrzeit wäre deshalb teilweise falsch. Ein versionierter Börsenkalender samt IANA-Zeitzone muss tatsächliche trading_session-Grenzen und Entscheidungs-Cutoffs erzeugen, die erst danach in UTC umgerechnet werden. Zusätzlich ist entscheidend, ob ein Kurs den Ingestion-Watermark bereits erreicht hat.
Wenn um 16:00:00 eine Entscheidung fällt, der offizielle Schlusskurs aber erst um 16:00:02 eingeht, darf der Backtest ihn nicht vorzeitig kennen. Verkürzte Handelstage, Aussetzungen, nachbörsliche Korrekturen und getrennte Versionen für Corporate Actions und Indexzusammensetzungen machen das relevant. Mein früherer Beitrag zur Faktorvalidierung betrachtet dieselbe Grenze über Signal-Time-Snapshots.
28 Offline-Tests beweisen keine vollständige Wiederholung nach einem Jahr
Ich habe 28 synthetische Offline-Regressionstests ausgeführt; alle bestanden. Sie prüfen doppelte Archive, absichtlich verweigerte Uploads, Pruning-Stopp bei fehlenden oder beschädigten Objekten, PENDING-Reconciliation, Retention/Confirm-Guards und einfache as_of-Korrekturen. Das ist eine Prüfung meiner implementierten Zustandsübergänge und injizierten Fehler – keine Aussage über Netzpartitionen, echte PG-Locks, Worker-Races oder R2 Multipart.
Auch 13.0 → 13.0 → 13.0 aus zwei fiktiven Kursen 10.5 und 15.5 ist nur ein Smoke Test für gzip, Hashes und eine einfache Wiederherstellung. Weder komplexe Backtest-Reproduzierbarkeit noch Speicherersparnis wurden damit nachgewiesen.
Der tatsächlich aufgezeichnete Testabschluss:
$ python3 verify.py --output results
Ran 28 tests in 0.017s
OK
CASE_SUMMARY {"ran": 28, "passes": 28, "failures": 0, "errors": 0}
Das komplette Test-Fixture liegt im privaten Content-Lab-Repository und ist deshalb kein öffentlich erreichbares Beispielprojekt.
Die wichtigere Lücke betrifft das reproduction_manifest. Die Demo speichert Hash, Schema, Beispielfunktion, Parameter, Python-Version und UTC. Nicht durchgeführt wurde ein frischer Check-out eines alten Strategie-Commits auf einer anderen Maschine samt Dependency-Rebuild, Wiederherstellung echter PostgreSQL-Daten, eingefrorenem Börsenkalender und erneutem Lauf der gesamten Faktor-/Outcome-Pipeline. Der Anspruch aus dem Einstieg – nach einem Jahr denselben Research-Run zu rekonstruieren – ist bislang unerfüllt.
Eine belastbare nächste Abnahme sollte deshalb:
- Datenversion, SHA-256 des Objekts, Anbieterrevision/
as_ofund ursprünglichen Signal-Snapshot festschreiben. - Strategie-Git-Commit, Konfigurationshash, Dependency-Lock oder Container-Digest, Kalenderversion, Zeitzone und Outcome-Definition fixieren.
- Auf einem isolierten PostgreSQL echte Transaktionen, TOAST, konkurrierende Leases und kontrollierte Löschung testen; in einem autorisierten R2-Sandbox-Bucket Single/Multi-Part, Abbruch, bedingte Writes und vollständige
GET-Validierung prüfen. - Einen archivierten Run in einer frischen Umgebung wiederherstellen, mit eingefrorenem Code ausführen und Signale, Entscheidungen und Outcomes einzeln vergleichen. Exit-Code 0 allein reicht nicht.
Solange die echte PG/R2-Restore-Strecke und die Anwendungsläufe nicht bewiesen sind, würde ich automatisches produktives raw_json-Pruning deaktiviert lassen. Ein paar gespeicherte Bytes mehr sind weniger schmerzhaft als eine erfolgreiche Löschung, nach der sich Monate später niemand mehr erklären kann, welche Daten eine alte Entscheidung geprägt haben.