# Archiviare i dati di ricerca quantitativa: PostgreSQL PITR o Cloudflare R2?

> Canonical: https://vxi.me/it/posts/quant-research-data-archive-postgres-r2/
> Locale: it-it
> Published: 2026-10-08
> Updated: 2026-10-08
> Markdown: https://vxi.me/it/posts/quant-research-data-archive-postgres-r2.md

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)](https://www.postgresql.org/docs/current/continuous-archiving.html). 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**:

~~~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;
~~~

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](https://www.postgresql.org/docs/current/storage-toast.html) 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](https://www.postgresql.org/docs/current/sql-vacuum.html) 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:

~~~text
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](https://www.postgresql.org/docs/current/ddl-partitioning.html) 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:

~~~text
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.

~~~python
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**:

~~~sql
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](https://developers.cloudflare.com/r2/api/s3/api/) 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](https://developers.cloudflare.com/r2/buckets/bucket-locks/) e [Object Lifecycle](https://developers.cloudflare.com/r2/buckets/object-lifecycles/) 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](https://developers.cloudflare.com/r2/api/s3/extensions/) 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**:

~~~python
# 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](/posts/quant-factor-validation-bucket-spread-rank-ic/) 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ì:

~~~console
$ 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:

1. Fissare versione dei dati grezzi, SHA-256 dell'oggetto, correzione/`as_of` del provider e snapshot del segnale originale.
2. Bloccare Git commit della strategia, hash di configurazione, lockfile o digest del container, versione del calendario di mercato, fuso orario e definizione di outcome.
3. 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`.
4. 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.

## Verification context

- Level: first-party
- Verified: 2026-10-08T00:00:00.000Z
- Environment: Python 3.13.5, sqlite3 in-memory
- Environment: Synthetic MemoryObjectStore
- Environment: 28 offline state-machine regression cases
- Evidence mode: mixed
- Evidence receipt: https://vxi.me/evidence/quant-research-data-archive-postgres-r2.json
