Date: 2026-06-14
Component: persistence/ — TimescaleDB schema, JDBC repositories, Flyway migrations
Files reviewed: 35 SQL migrations (V1-V35), 17 Java entities, 18 repositories, 1 config
Reviewer: Automated code review
This persistence layer is a deliberately JPA-free TimescaleDB-backed data access module using Java records for entities and NamedParameterJdbcTemplate for data access. The architecture is sound in its separation of concerns. However, all financial values are stored as DOUBLE PRECISION with zero DECIMAL/BigDecimal usage, zero @Transactional annotations exist across all 18 repositories, and compression/retention policies are missing on the majority of hypertables.
Severity distribution:
- 🔴 Critical: 4
- 🟠 High: 7
- 🟡 Medium: 7
- 🟢 Low: 3
Files affected: ALL entity Java records AND ALL SQL CREATE TABLE statements Severity: 🔴 Critical Category: Financial Precision
Every entity uses Java double and SQL DOUBLE PRECISION for financial values. Zero use of BigDecimal or NUMERIC/DECIMAL anywhere.
Examples:
TickData.price(market price),RateSnapshot.value(interest rate)VirtualPortfolioPosition.entryPrice,currentPrice,unrealizedPnlVirtualPortfolioTrade.fillPrice,commission,slippage,realizedPnl- Option chain:
strike,bid,ask,last_price,delta,gamma,theta,vega,rho,implied_vol - ALL columns in
factor_returns,macro_shock_irfs,risk_evt_parameters,quantile_coefficients
Risk:
- Cumulative PnL error: million trades × 1-cent rounding = $10,000 error
- Equality comparisons of prices (
WHERE price = 100.50) unreliable - Decimal fractions (0.01) cannot be represented exactly
- Greek calculations require high precision
Recommendation:
- Trade financials →
NUMERIC(20,8)/BigDecimal - Prices →
NUMERIC(16,4)for equities,NUMERIC(20,8)for FX - Option strike →
NUMERIC(16,4)— must be exact - Interest rates →
NUMERIC(10,6) - Computed statistics (Sharpe, win rate) →
DOUBLE PRECISIONis acceptable
Files affected: ALL 18 repository classes Severity: 🔴 Critical
Zero occurrences of @Transactional on any method or class. All save(), saveAll(), update*(), deactivate*() execute without transaction boundaries.
Affected operations:
ModelArtifactRepository.save()+deactivateOlderVersions()should be atomicTickDataRepository.saveAll()/RateSnapshotRepository.saveAll(): batch updates auto-committed individuallyVirtualPortfolioPosition.close()+VirtualPortfolioTradeRepository.save()in caller are non-atomic
Fix: Add @Transactional(readOnly = true) at class level, override with @Transactional on write methods.
Severity: 🔴 Critical
Compression enabled ONLY on: tick_data (V4), factor_returns (V29), index_snapshots (V31)
Missing from 20+ hypertables: rate_snapshots, ili_history, zscore_series, correlation_outputs, alpha_signals, market_events, option_chain_snapshots, market_gamma_history, phantom_liquidity_metrics, liquidity_comovement_snapshots, toxicity_scores, behavioural_risk_index, sentiment_history, greeks_sensitivity, risk_premium_residuals, macro_shock_irfs, risk_evt_parameters, quantile_coefficients, synergy_entropy_matrix, universe_stats_history, volatility_forecasts
Risk: Uncompressed time-series data is 5-10x larger, causing higher storage costs and slower full-scan queries.
Severity: 🔴 Critical
Retention set ONLY on: tick_data (90 days, V4), volatility_forecasts (365 days, V33)
Risk: All other hypertables grow unbounded, eventually causing disk-full failures.
Files: CorrelationOutputRepository.java:45-48, ZscoreSeriesRepository.java
Severity: 🟠 High
rs.getDouble("correlation"), // NULL → 0.0 silently!
rs.getDouble("p_value"), // NULL → 0.0 — most significant possible p-value!
rs.getInt("sample_size"), // NULL → 0A NULL p_value (computation failed) becomes 0.0 — the most significant possible value. A NULL correlation is indistinguishable from true zero.
Contrast with correct pattern used elsewhere:
rs.getObject("expected_move") != null ? rs.getDouble("expected_move") : nullFile: TimescaleDbConfig.java
Severity: 🟠 High
@Bean
public FlywayMigrationStrategy flywayMigrationStrategy() {
return flyway -> {
Flyway.configure().baselineOnMigrate(true).load(); // Throwaway instance!
flyway.migrate(); // Auto-configured bean, baselineOnMigrate never set
};
}baselineOnMigrate(true) is set on a throwaway Flyway instance. The auto-configured bean defaults to false.
Fix: flyway.setBaselineOnMigrate(true) before flyway.migrate().
File: BacktestResultRepository.java:76
Severity: 🟠 High
WHERE strategy_config::text LIKE :namePattern -- namePattern = "%" + strategyName + "%"::textincludes JSON braces/quotes — matches within JSON representation, likely unintended- Leading
%prevents any B-tree index usage - Full table scan on hypertable with potentially thousands of results
Fix: Use strategy_config->>'name' = :namePattern or strategy_config @> '{"name": "..."}'::jsonb with GIN index.
File: V21__create_option_chain_snapshots.sql
Severity: 🟠 High
PRIMARY KEY (time, symbol, strike, expiry, option_type)strike is DOUBLE PRECISION. Floating-point equality in PK is unreliable — same strike (150.00) could produce duplicate rows.
Fix: Change to NUMERIC(16,4).
Files: SignalLogRepository.java, BacktestResultRepository.java, VirtualPortfolioPositionRepository.java, VirtualPortfolioTradeRepository.java
Severity: 🟠 High
return keyHolder.getKey().longValue(); // NPE if getKey() returns nullIf INSERT fails silently (constraint violation, trigger rejection), getKey() returns null.
Fix: Add null check with descriptive error message.
File: V28__create_evt_risk_metrics_view.sql
Severity: 🟠 High
CREATE VIEW evt_risk_metrics AS ...Should be CREATE OR REPLACE VIEW. Will fail on migration re-apply.
Files: All findBy*AndTimeBetween methods across all repositories
Severity: 🟠 High
For hypertables with millions of rows (tick data at sub-second granularity), unbounded List<T> returns risk OOM.
Severity: 🟡 Medium
11-line row-mapping lambda duplicated across findByTimeBetween, findLatest, findLatestN.
option_chain_snapshotsat 1 day: full chains with 100+ strikes × 10+ expiries → very large daily chunksindex_snapshotsat 10 years: ~120 rows/chunk for monthly data — too smallfactor_returnsat 1 year: ~250 rows/chunk — too small Target 1-100 MB per chunk per TimescaleDB best practices.
PRIMARY KEY (id, run_at) where id alone is already unique. Time-range queries would benefit from (run_at, id).
Later migrations (V13, V16, V25, V27) use plain CREATE INDEX which blocks reads/writes. Should use CREATE INDEX CONCURRENTLY.
Returns Optional<Map<String, Object>> instead of typed entity record. Bypasses compile-time safety.
No validation that symbol conforms to ticker, ISIN, or CUSIP format.
Both drop and recreate chk_rate_type_cdm to add new rate types. Functionally correct but indicates the constraint should have been more inclusive from the start.
Three different patterns for nullable Double columns. Standardize on rs.getObject("col") != null ? rs.getDouble("col") : null.
Insufficient for strategies requiring multi-year lookback. Make configurable.
SQL: INTEGER[], Java: int[]. getArray().getArray() cast to int[] is unchecked.
- No-JPA design:
NamedParameterJdbcTemplatewith Java records avoids Hibernate complexity. Correct choice for time-series with TimescaleDB custom aggregates. - Java records for entities: Immutable, value-based with compact constructor validation.
- Proper hypertable design: Composite PKs including partition column, continuous aggregates for pre-computed views.
- V35 compression toggle pattern: Correctly drops compression before DDL, re-enables after.
- Batch operations:
saveAll()andsaveAllIdempotent()usebatchUpdate. - CDM-aligned CHECK constraints: Rate types and metrics align with common data model.
| # | Severity | Category | Finding |
|---|---|---|---|
| 1 | 🔴 Critical | Financial Precision | All values DOUBLE PRECISION, no DECIMAL/BigDecimal |
| 2 | 🔴 Critical | Transactions | Zero @Transactional annotations |
| 3 | 🔴 Critical | SQL Performance | Compression missing on 20+ hypertables |
| 4 | 🔴 Critical | SQL Operations | Retention missing on 20+ hypertables |
| 5 | 🟠 High | Data Integrity | NULL-to-zero in CorrelationOutput/ZscoreSeries mappers |
| 6 | 🟠 High | Migration Safety | Flyway baselineOnMigrate is no-op |
| 7 | 🟠 High | SQL Performance | LIKE with leading % on JSONB::text |
| 8 | 🟠 High | Financial Precision | Option strike in PK as DOUBLE PRECISION |
| 9 | 🟠 High | Stability | getKey() NPE in 4 repositories |
| 10 | 🟠 High | Migration Idempotency | V28 view not idempotent |
| 11 | 🟠 High | Stability | No pagination on time-range queries |
| 12 | 🟡 Medium | Clean Code | Duplicate row-mapper in IliHistoryRepository |
| 13 | 🟡 Medium | SQL Performance | Chunk intervals unchecked |
| 14 | 🟡 Medium | SQL Design | backtest_results PK ordering |
| 15 | 🟡 Medium | SQL Operations | Non-concurrent indexes |
| 16 | 🟡 Medium | Type Safety | SignalQualityReport untyped maps |
| 17 | 🟡 Medium | Data Quality | No ticker/ISIN validation |
| 18 | 🟡 Medium | Migration Quality | V30/V32 constraint churn |
| 19 | 🟢 Low | Consistency | Null-handling divergence |
| 20 | 🟢 Low | Configuration | 90-day retention hardcoded |
| 21 | 🟢 Low | Type Safety | TickData array cast unsafe |
Immediate (data integrity):
- Fix NULL-to-zero in CorrelationOutputRepository and ZscoreSeriesRepository
- Fix FlywayMigrationStrategy bean
- Add
@Transactionalto all write methods - Fix V28 view to use
CREATE OR REPLACE
Short term (financial precision):
5. Audit monetary columns to NUMERIC/BigDecimal
6. Fix option chain strike to NUMERIC
Medium term (operations): 7. Add compression and retention policies to all hypertables 8. Add pagination to time-range queries 9. Fix BacktestResultRepository LIKE query 10. Add integration tests with TimescaleDB Testcontainers
Long term:
11. Standardize null-handling patterns
12. Consider dedicated @Service layer for cross-repository transactions