Archiviare i dati di ricerca quantitativa: PostgreSQL PITR o Cloudflare R2?
Se tra un anno volessi ripetere una ricerca quantitativa eseguita oggi, quali elementi dovrei poter recuperare?
Il codice della strategia potrebbe essere ancora su GitHub e nel database potrebbe essere rimasto un run_id. Nel frattempo, però, il provider potrebbe aver corretto i prezzi storici e la risposta API originaria potrebbe essere stata eliminata. Un programma che parte senza errori può quindi produrre risultati diversi. Per un piccolo sistema di ricerca Python/SQL, voglio poter ricostruire gli input, il codice e le informazioni effettivamente disponibili al momento della decisione.
Il progetto iniziale prevedeva PostgreSQL per metadati, segnali e outcome; Cloudflare R2 per i grandi payload raw_json; e infine il pruning dei JSON storici nel database dopo la verifica degli archivi. Le domande difficili sono emerse dopo: quando vale la pena sostenere questa complessità? Perché creare un manifest prima di caricare l’oggetto? E come dimostrare che uno studio vecchio è ancora ricostruibile dopo aver ridotto i dati originali?
Ho creato un piccolo esperimento offline con la libreria standard di Python, SQLite in memoria e un MemoryObjectStore simulato. Ho verificato alcuni vincoli che impediscono eliminazioni premature, ma non ho testato PostgreSQL o R2 reali, né la concorrenza fra più worker. Tengo separati i risultati effettivi dai ragionamenti architetturali basati sulla documentazione.
Quando PostgreSQL da solo è sufficiente
Per un progetto personale con crescita moderata e pochi replay storici, non aggiungerei subito PostgreSQL, R2, manifest e worker di riconciliazione. Prima sistemerei backup regolari, vere prove di ripristino e registrazione delle versioni di codice e parametri.
C’è già una soluzione consolidata: PostgreSQL PITR (Point-in-Time Recovery). Un base backup con i WAL archiviati permette di recuperare un cluster in un istante coperto dal periodo di conservazione. Se gli input della ricerca erano effettivamente nel database e i backup arrivano fino a quel momento, possono essere recuperati in un’istanza separata.
La differenza sta nella granularità. PITR recupera principalmente un intero cluster, mentre la richiesta del ricercatore è «dammi i dati di mercato realmente letti da run_id=35». Ripristinare un cluster storico per un solo run, mantenere anni di WAL e collegare il vecchio database al codice di oggi può essere più oneroso che recuperare un oggetto immutabile. Inoltre PITR non preserva automaticamente dipendenze Python, calendari di borsa o risposte API mai scritte nel database.
Le responsabilità sono complementari: PITR serve al disaster recovery del database; un archivio versionato per run serve alla tracciabilità della ricerca. Se quest’ultima non è necessaria, si può evitare tutta la seconda infrastruttura.
Non sceglierei una soglia arbitraria come «oltre 100 GB bisogna usare R2». Prima misurerei heap, TOAST, indici, crescita giornaliera dei payload, backup/WAL, frequenza delle letture e obiettivi di ripristino. Questa è una query di sola lettura proposta, non eseguita sul PostgreSQL reale del progetto:
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;
Valuterei R2 solo quando i dati freddi pesano davvero sulla manutenzione e recuperare un singolo run è un requisito concreto.
TOAST, MVCC e il costo di modificare raw_json
PostgreSQL include già TOAST per i valori grandi. Quando una tupla supera normalmente una soglia vicina a 2 KB, il sistema può comprimere i valori idonei e, se necessario, spostarli in una tabella TOAST separata. La documentazione descrive un puntatore esterno su disco lungo 18 byte.
Questo non implica che ogni campo JSONB oltre 2 KB finisca automaticamente fuori riga. Contano la dimensione della tupla intera, l’efficacia della compressione e la storage strategy della colonna. Se le query comuni non leggono raw_json, TOAST potrebbe già risolvere buona parte del problema.
Il vero dubbio sul piano originale riguarda gli UPDATE. Sostituire un grande raw_json con un riassunto crea comunque una nuova versione MVCC della riga, genera WAL e lascia versioni vecchie da ripulire, eventualmente con i relativi dati TOAST. Migliaia di aggiornamenti storici possono aumentare il carico in scrittura, il bloat e la contesa sui lock. Non è però corretto dire che ogni UPDATE generi sempre un bloat grave.
La documentazione di VACUUM distingue lo spazio riutilizzabile all’interno di PostgreSQL da quello restituito al file system. VACUUM normale rende generalmente riutilizzabile lo spazio; VACUUM FULL riscrive la relazione e può liberare più spazio fisico, con lock molto più invasivi. In alcuni casi si possono anche troncare pagine libere alla fine del file. Né «si libera sempre spazio su disco» né «non se ne libera mai» sono descrizioni precise.
Per un sistema nuovo prenderei in considerazione la separazione verticale:
signal_snapshot
snapshot_id | run_id | signal_time | score | raw_hash | archive_key
signal_raw_payload
snapshot_id | payload_version | raw_json | captured_at
I campi interrogati spesso restano in signal_snapshot; il contenuto originale va in signal_raw_payload. Dopo l’archiviazione si pulisce solo quest’ultima, evitando di aggiornare massivamente la tabella principale. Separare le tabelle non rende però il DELETE immune da MVCC e VACUUM.
Se i tempi di conservazione coincidono con mesi o batch interi, può convenire il partizionamento. La guida ufficiale spiega perché rimuovere una partizione scaduta può essere preferibile a cancellare le righe una per una. Prima, però, ogni riga della partizione deve risultare archiviata e verificata, aver superato il periodo di retention ed essere autorizzata alla cancellazione. Non farei DROP di un mese solo perché quasi tutti i suoi dati sono idonei.
Su un database esistente misurerei prima heap/TOAST, WAL, lock e query. Solo dopo sceglierei fra TOAST, separazione verticale e partizioni basate sul ciclo di eliminazione.
Perché manifest-first invece di upload-first
L’ordine delle operazioni può seguire due strade ragionevoli:
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 non crea un manifest PENDING che punti a un oggetto inesistente. Il suo punto debole è diverso: il PUT riesce, poi il processo si interrompe prima del commit nel database, lasciando un oggetto orfano. Recuperarlo richiede regole di naming, ownership e garbage collection.
Nel prototipo offline ho scelto manifest-first per registrare prima l’intenzione di archiviazione: run_id / revision / expected_sha256 vengono fissati prima del trasferimento. In caso di errore il worker di riconciliazione sa ancora quali byte dovevano essere conservati. È un compromesso, non una transazione distribuita; un record PENDING non costituisce da solo un lock tra processi.
Questa scelta ha esposto un bug reale nel codice del simulatore. Quando l’upload veniva rifiutato, il manifest restava PENDING e l’oggetto mancava. La prima riconciliazione controllava solo oggetti già presenti e non poteva riparare quella situazione.
Ho cambiato la procedura: ritenta la creazione soltanto dai dati sorgente ancora integri e solo se SHA-256 e dimensione coincidono con il manifest originario.
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)
Questo percorso è stato effettivamente verificato nel test sintetico. Non è il racconto di un incidente di produzione.
Con più worker reali servono anche lease e fencing token. Immagina che il worker A ottenga il lease, resti bloccato sulla rete e scada; il worker B prende il controllo. Quando A si risveglia non deve poter salvare uno stato con un’autorizzazione ormai vecchia. Un UPDATE ... RETURNING condizionale e un lease_epoch crescente possono limitare questo rischio. Il seguente SQL è un’ipotesi di implementazione, non eseguita su 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;
Non terrei aperta una lunga transazione PG durante i PUT/GET remoti. A verifica conclusa, l’aggiornamento di stato deve controllare gli stessi lease_owner + lease_epoch ottenuti all’inizio, respingendo il worker scaduto. Cinque minuti sono solo un valore illustrativo.
Il lease protegge lo stato di PG, non annulla un PUT già inviato da un worker obsoleto. Servono ancora chiavi immutabili basate sul contenuto, creazione condizionale per impedire sovrascritture con byte diversi e riconciliazione dei conflitti. Senza test reali di concorrenza e guasti di rete, resta un progetto da validare.
Versioni R2, ETag e Metadata personalizzati
La tabella di compatibilità S3 di R2 include PutObject, HeadObject e GetObject ma elenca GetBucketVersioning e PutBucketVersioning fra le API non implementate. Non si può presumere che R2 mantenga la storia nativa delle versioni di un bucket AWS S3.
Preferirei chiavi immutabili esplicite, per esempio raw/v1/run=35/revision=2/sha256=...jsonl.gz. Una modifica crea una nuova revisione, senza sovrascrivere l’oggetto vecchio. Bucket Locks e Object Lifecycle vanno coordinati con la retention dei dati di ricerca. Le Bucket Locks di R2 non sono la piena compatibilità con l’API AWS S3 Object Lock.
Le estensioni S3 di R2 documentano la corrispondenza di x-amz-meta-* con i Metadata personalizzati. Si possono salvare SHA-256 dei byte compressi, numero di righe e schema, e confrontarli a basso costo tramite HEAD. Questo è un esempio boto3 per un singolo PutObject non testato su R2 reale:
# 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
La stringa SHA ottenuta da HEAD è una dichiarazione di chi ha caricato il file. Non dimostra che i byte effettivi siano integri. Prima di permettere il pruning, bisogna scaricare tutti i byte via GET, ricalcolare SHA-256, decomprimere e verificare schema, righe e identità dei record. Nel simulatore ho fatto restituire un HEAD valido e un GET corrotto: il pruning è stato rifiutato.
Anche l’ETag di un Multipart Upload non è lo SHA-256 dell’intero oggetto. Non darei per scontato che la condizione di un PutObject semplice si applichi anche a CompleteMultipartUpload. Soglie dell’SDK, abort, retry e protezione dalle sovrascritture richiedono prove su un bucket R2 sandbox autorizzato.
Le correzioni ai prezzi richiedono due dimensioni temporali
Conservare i vecchi byte non è sufficiente. Un backtest deve stabilire quale versione fosse effettivamente disponibile quando si è presa la decisione.
È un problema in parte bitemporale. L’event/valid time descrive la sessione di mercato a cui appartiene un dato; observed_at e ingested_at descrivono quando il sistema di ricerca ha ricevuto quella specifica revisione. Terrei separato anche source_published_at del provider: la pubblicazione da parte del fornitore non implica che il tuo sistema avesse già letto il messaggio.
Nell’esempio sintetico, per una stessa data di mercato il close 100 è arrivato il 2 ottobre, mentre la correzione a 104 è stata osservata il 5 ottobre:
| as_of (UTC) | Close visibile |
|---|---|
| 2026-10-01 09:00 | Non ancora presente |
| 2026-10-03 00:00 | 100 |
| 2026-10-06 00:00 | 104 |
Questa prova mostra soltanto che il selettore as_of offline non lascia filtrare una correzione futura nelle viste precedenti. Non risolve revoche del provider, precedenze fra correzioni, split, delisting o composizioni storiche degli indici.
Per i replay storici imporrei as_of_utc, dataset_revision oppure uno snapshot_id immutabile. Se manca il cutoff, preferisco rifiutare la richiesta anziché usare silenziosamente i dati più recenti. Una vista latest separata può servire alle dashboard live. Se due versioni sono in conflitto sullo stesso evento e tempo di osservazione, bisogna seguire i numeri di revisione e le regole del provider o isolarle, non scegliere semplicemente l’ultima INSERT.
Salvare tutto in UTC non risolve da solo il limite della sessione. Il mercato USA chiude normalmente alle 16:00 ora locale, mentre America/New_York cambia fra EDT e EST. Usare una sola ora UTC fissa per tutto l’anno produrrebbe errori con l’ora legale. Deriverei trading_session e cutoff da un calendario di borsa versionato e dalla zona IANA, convertendo in UTC solo dopo. Va controllato anche il watermark dell’effettiva ingestione.
Se la strategia decide alle 16:00:00 ma il prezzo di chiusura ufficiale arriva alle 16:00:02, il backtest non può usare in anticipo quel prezzo. Sedute accorciate, sospensioni e correzioni after-hours rendono concreta questa regola. Fattori di aggiustamento, corporate action e componenti storici degli indici necessitano a loro volta di versioni. Nell’articolo precedente sulla validazione dei fattori ho affrontato lo stesso confine attraverso gli snapshot al momento del segnale.
Ventotto test offline non dimostrano un replay completo dopo un anno
Ho eseguito 28 test sintetici offline: tutti superati. Coprivano archivi duplicati, errori di upload iniettati, rifiuto del pruning con oggetti mancanti o corrotti, riparazione di PENDING, limiti di retention/confirm e semplici correzioni as_of. Sono test della mia macchina a stati e delle anomalie che ho scelto di simulare, non una prova contro network partition, veri lock PostgreSQL, race tra worker o errori multipart di R2.
Anche la sequenza 13.0 → 13.0 → 13.0 calcolata da due valori fittizi 10.5 e 15.5 è un semplice smoke test di gzip, hash e ripristino. Non dimostra la riproducibilità di un backtest complesso né un risparmio su disco.
Il registro originale terminava così:
$ python3 verify.py --output results
Ran 28 tests in 0.017s
OK
CASE_SUMMARY {"ran": 28, "passes": 28, "failures": 0, "errors": 0}
Il fixture completo è nel repository privato del Content Lab: non lo presenterò come un progetto pubblico accessibile.
Manca soprattutto un reproduction_manifest completo. La demo registra digest dell’oggetto, schema, funzione, parametri, versione Python e UTC, ma non recupera su una seconda macchina un commit storico della strategia, le dipendenze, i veri dati PostgreSQL e il calendario di borsa dell’epoca per rieseguire l’intera pipeline factor/outcome. La promessa iniziale — ripetere esattamente la stessa ricerca tra un anno — non è ancora dimostrata.
Una vera prova d’accettazione dovrebbe:
- Fissare versione dei dati grezzi, SHA-256 dell’oggetto, correzione/
as_ofdel provider e snapshot del segnale originale. - Bloccare Git commit della strategia, hash di configurazione, lockfile o digest del container, versione del calendario di mercato, fuso orario e definizione di outcome.
- Usare PostgreSQL isolato per transazioni, TOAST, lease concorrenti e cancellazioni controllate; un bucket R2 sandbox autorizzato per trasferimenti normali/multipart, interruzioni, PUT condizionali e convalida dell’intero
GET. - Ripristinare il run in un ambiente pulito, eseguire il codice congelato e confrontare segnali, decisioni e outcome uno per uno. Un exit code 0 non basta.
Finché la vera catena di restore PG/R2 e la lettura applicativa non avranno superato questi controlli, terrei disattivato il pruning automatico di raw_json in produzione. Occupare oggi qualche byte in più fa meno danni che scoprire, dopo mesi e una pulizia riuscita, di non saper più spiegare quali input abbiano determinato una vecchia decisione.