feat: report artifacts table, paper validation job, and REST endpoints#627
Conversation
There was a problem hiding this comment.
Pull request overview
Adds persistent storage and API access for generated strategy reports, plus a scheduled automation job that produces daily paper-validation reports and saves them as artifacts.
Changes:
- Introduces
report_artifactstable (migration 000029) and bumpsRequiredSchemaVersionto 29. - Adds a PostgreSQL
ReportArtifactRepo(upsert, latest lookup, paginated listing) and wires it into the runtime, orchestrator, and API server. - Implements the
paper_validation_reportdaily job and new REST endpoints for latest report + report history.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| migrations/000029_report_artifacts.up.sql | Creates report_artifacts table + index for latest-report lookups |
| migrations/000029_report_artifacts.down.sql | Drops report_artifacts table |
| internal/repository/postgres/schema_version.go | Bumps RequiredSchemaVersion to 29 |
| internal/repository/postgres/schema_version_test.go | Updates schema version tests for new required version |
| internal/repository/postgres/report_artifact.go | Adds repo + model for persisting and querying report artifacts |
| internal/repository/postgres/report_artifact_test.go | Adds unit tests for JSON round-trip and filter defaults |
| internal/config/validate.go | Removes duplicate validateFallbackProvider definition |
| internal/automation/orchestrator.go | Adds report-related deps and registers report jobs |
| internal/automation/jobs_reports.go | Implements daily paper-validation report generation + persistence |
| internal/automation/jobs_reports_test.go | Adds tests covering report job behavior and generation flow |
| internal/api/server.go | Wires repo into API server and registers report routes |
| internal/api/report_handlers.go | Implements latest and list report endpoints |
| internal/api/report_handlers_test.go | Adds handler tests for unconfigured repo, invalid UUID, and stale seconds serialization |
| internal/api/settings_test.go | Updates expected schema versions to 29 |
| cmd/tradingagent/runtime.go | Constructs ReportArtifactRepo and wires it into API + orchestrator |
| cmd/tradingagent/runtime_test.go | Updates schema mismatch error expectations for version 29 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func TestGenerateOneReport_Success(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| stratID := uuid.New() | ||
| configID := uuid.New() | ||
| metricsJSON := mustMarshal(t, backtest.Metrics{ | ||
| TotalReturn: 0.15, | ||
| SharpeRatio: 1.5, | ||
| MaxDrawdown: 0.08, | ||
| WinRate: 0.55, | ||
| StartTime: time.Now().Add(-30 * 24 * time.Hour), | ||
| EndTime: time.Now(), | ||
| StartEquity: 10000, | ||
| EndEquity: 11500, | ||
| TotalBars: 30, | ||
| Volatility: 0.20, | ||
| ProfitFactor: 2.0, | ||
| AvgWinLossRatio: 1.5, | ||
| CalmarRatio: 1.8, | ||
| SortinoRatio: 1.2, | ||
| }) | ||
| // Empty trade log so ComputeTradeAnalytics is skipped (no +Inf). | ||
| tradeLogJSON := json.RawMessage(`[]`) | ||
|
|
||
| orch := newReportTestOrchestrator( | ||
| []domain.Strategy{{ID: stratID, Name: "test", Status: "active", IsPaper: true, CreatedAt: time.Now().Add(-90 * 24 * time.Hour)}}, | ||
| []domain.BacktestConfig{{ID: configID, StrategyID: stratID}}, | ||
| []domain.BacktestRun{{ID: uuid.New(), BacktestConfigID: configID, Metrics: metricsJSON, TradeLog: tradeLogJSON}}, | ||
| ) | ||
|
|
||
| // ReportArtifactRepo is nil → will fail at persist, but NOT at report generation. | ||
| err := orch.generateOneReport(context.Background(), stratID, "test", time.Now().Truncate(24*time.Hour), time.Now()) | ||
| if err == nil { | ||
| t.Fatal("expected error (nil repo), got nil") | ||
| } |
There was a problem hiding this comment.
TestGenerateOneReport_Success currently asserts that generateOneReport returns an error (nil repo) and does not verify any “success” outcome. Renaming the test to reflect what it actually validates (e.g., generation succeeds up to persist, then persist fails) would make failures easier to interpret.
| // stubReportArtifactRepo satisfies the report handler's usage of | ||
| // *pgrepo.ReportArtifactRepo via duck-typing at the Server field level. | ||
| // Since the handlers reference the concrete repo type directly, the test | ||
| // overrides the Server.reportArtifacts field with a real (nil-pool) repo | ||
| // and exercises the handler path that checks nil. |
There was a problem hiding this comment.
The file-level comment claims there is a stubReportArtifactRepo and that the test overrides Server.reportArtifacts with a “real (nil-pool) repo”, but the tests don’t do either (they rely on reportArtifacts being nil). Updating/removing this comment would avoid misleading future readers.
| // stubReportArtifactRepo satisfies the report handler's usage of | |
| // *pgrepo.ReportArtifactRepo via duck-typing at the Server field level. | |
| // Since the handlers reference the concrete repo type directly, the test | |
| // overrides the Server.reportArtifacts field with a real (nil-pool) repo | |
| // and exercises the handler path that checks nil. | |
| // These tests exercise the "not configured" handler path by using the | |
| // default test server setup, where Server.reportArtifacts is left nil. |
| } | ||
| artifacts = append(artifacts, *a) | ||
| } | ||
| return artifacts, rows.Err() |
There was a problem hiding this comment.
List returns rows.Err() directly without wrapping, unlike other postgres repos in this codebase that add context (e.g., postgres: list ... rows: %w). Wrapping here would make debugging production DB/scan issues much easier.
| return artifacts, rows.Err() | |
| if err := rows.Err(); err != nil { | |
| return nil, fmt.Errorf("postgres: list report artifacts rows: %w", err) | |
| } | |
| return artifacts, nil |
|
|
||
| stale := 0.0 | ||
| if artifact.CompletedAt != nil { | ||
| stale = math.Round(time.Since(*artifact.CompletedAt).Seconds()) |
There was a problem hiding this comment.
stale_seconds can become negative if completed_at is in the future (clock skew, bad data). Consider clamping to >= 0 (e.g., max(0, ...)) so clients don’t see confusing negative “staleness” values.
| stale = math.Round(time.Since(*artifact.CompletedAt).Seconds()) | |
| stale = math.Max(0, math.Round(time.Since(*artifact.CompletedAt).Seconds())) |
| func containsStr(s, substr string) bool { | ||
| return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr)) | ||
| } | ||
|
|
||
| func containsSubstring(s, sub string) bool { | ||
| for i := 0; i+len(sub) <= len(s); i++ { | ||
| if s[i:i+len(sub)] == sub { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } |
There was a problem hiding this comment.
This test helper reimplements substring search (containsStr/containsSubstring) rather than using strings.Contains, which makes the intent harder to read and maintain. Replacing it with strings.Contains would simplify the test and reduce custom code.
| report_type TEXT NOT NULL DEFAULT 'paper_validation', | ||
| time_bucket TIMESTAMPTZ NOT NULL, | ||
| status TEXT NOT NULL DEFAULT 'pending', | ||
| report_json JSONB, |
There was a problem hiding this comment.
The migration defines report_type and status as free-form TEXT. Since the runtime and PR description treat these as a closed set (e.g., pending|completed|error), it would be safer to add a CHECK constraint (or enum type) so invalid values can’t be persisted (including via ad-hoc SQL), and optionally enforce completed_at presence when status='completed'.
| prompt_tokens INT DEFAULT 0, | ||
| completion_tokens INT DEFAULT 0, | ||
| latency_ms INT DEFAULT 0, |
There was a problem hiding this comment.
prompt_tokens, completion_tokens, and latency_ms are nullable (they only have DEFAULT 0). In Go these scan into non-pointer int fields, so a NULL in the DB would cause scan errors. Consider making these columns NOT NULL DEFAULT 0 to match the struct shape.
| prompt_tokens INT DEFAULT 0, | |
| completion_tokens INT DEFAULT 0, | |
| latency_ms INT DEFAULT 0, | |
| prompt_tokens INT NOT NULL DEFAULT 0, | |
| completion_tokens INT NOT NULL DEFAULT 0, | |
| latency_ms INT NOT NULL DEFAULT 0, |
| ); | ||
|
|
||
| CREATE INDEX idx_report_artifacts_strategy_type | ||
| ON report_artifacts (strategy_id, report_type, completed_at DESC); |
There was a problem hiding this comment.
GetLatest filters on status='completed' and orders by completed_at DESC, but the index is on (strategy_id, report_type, completed_at DESC) without status. Consider making this a partial index WHERE status = 'completed' (and optionally completed_at IS NOT NULL) to reduce index size and improve latest-report lookups.
| ON report_artifacts (strategy_id, report_type, completed_at DESC); | |
| ON report_artifacts (strategy_id, report_type, completed_at DESC) | |
| WHERE status = 'completed'; |
| // scannable abstracts pgx.Row and pgx.Rows. | ||
| type scannable interface { | ||
| Scan(dest ...any) error | ||
| } | ||
|
|
||
| func scanReportArtifact(row pgx.Row) (*ReportArtifact, error) { |
There was a problem hiding this comment.
This file introduces a new scannable interface and two near-identical scan helpers (scanReportArtifact / scanReportArtifactRow). The postgres package already has a scanner interface used to share scan logic across pgx.Row and pgx.Rows (see internal/repository/postgres/strategy.go), so this can be simplified to a single scan helper taking that existing interface and the extra interface/duplication removed.
|
@copilot apply changes based on the comments in this thread |
Implemented the requested review-thread updates in commit |
|
@copilot resolve the merge conflicts in this pull request |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@copilot apply changes based on the comments in this thread |
- Add paper_validation_report automation job (runs 17:00 ET Mon-Fri)
that iterates active paper strategies, loads latest backtest run,
calls papervalidation.GenerateReport, and persists a ReportArtifact
- Guard nil ReportArtifactRepo in registerReportJobs/persistErrorArtifact
- Add BacktestConfigRepo and BacktestRunRepo to OrchestratorDeps
- Add GET /api/v1/strategies/{id}/reports/latest (stale_seconds field)
- Add GET /api/v1/strategies/{id}/reports (paginated, filterable)
- Wire ReportArtifactRepo into api.Deps and OrchestratorDeps in runtime
- Update reviewed migration SQL and repo implementation from PR feedback
23e3725 to
1e73558
Compare
Summary
Implements persistent report artifacts and the automated daily paper-trading validation report job.
Changes
Migration 000029 —
report_artifactstable(strategy_id, report_type, time_bucket)(strategy_id, report_type, completed_at)for fast latest-report lookupspending|completed|errorReportArtifactRepo(internal/repository/postgres/report_artifact.go)Upsert— insert or update-on-conflict; idempotent per (strategy, type, bucket)GetLatest— returns most recentstatus=completedartifact for a strategy+typeList— paginated list withReportArtifactFilter(strategy_id, report_type, status)paper_validation_reportautomation job (internal/automation/jobs_reports.go)backtest.Metrics→ callspapervalidation.GenerateReportReportArtifactwithstatus=completed; on error writesstatus=errorartifactReportArtifactRepois nil (e.g. smoke env)REST endpoints (
internal/api/report_handlers.go)GET /api/v1/strategies/{id}/reports/latest— latest completed report; includesstale_secondsGET /api/v1/strategies/{id}/reports— paginated history; supports?report_type=and?status=filters501 Not Implementedwhen repo not wired (graceful degradation)Wiring
OrchestratorDepsgainsReportArtifactRepo,BacktestConfigRepo,BacktestRunRepoapi.Deps/api.ServergainReportArtifacts *pgrepo.ReportArtifactReporuntime.goconstructs and distributes the repo to both orchestrator and API serverPre-existing fix
validateFallbackProviderdeclaration ininternal/config/validate.go(caused build failure before this PR)Schema bump
RequiredSchemaVersion: 28 → 29Run
task migratebefore deploying.Tests
internal/repository/postgres/report_artifact_test.go— struct JSON round-trip, filter defaultsinternal/automation/jobs_reports_test.go— nil repo guard, no paper strategies, no backtest configs, generation flowinternal/api/report_handlers_test.go— 501 when unconfigured, 400 on invalid UUID,stale_secondsserialization