Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/tradingagent/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func newAPIServer(ctx context.Context, cfg config.Config, logger *slog.Logger) (
optionsScanRepo := pgrepo.NewOptionsScanRepo(db.Pool)
newsFeedRepo := pgrepo.NewNewsFeedRepo(db.Pool)
polymarketAccountRepo := pgrepo.NewPolymarketAccountRepo(db.Pool)
reportArtifactRepo := pgrepo.NewReportArtifactRepo(db.Pool)
runRegistry := agent.NewRunContextRegistry()

riskEngine := risk.NewRiskEngine(
Expand Down Expand Up @@ -185,6 +186,7 @@ func newAPIServer(ctx context.Context, cfg config.Config, logger *slog.Logger) (
BacktestRuns: pgrepo.NewBacktestRunRepo(db.Pool),
NewsFeedRepo: newsFeedRepo,
DiscoveryRunRepo: pgrepo.NewDiscoveryRunRepo(db.Pool),
ReportArtifacts: reportArtifactRepo,
}
notificationManager := newNotificationManager(cfg)

Expand Down Expand Up @@ -335,6 +337,9 @@ func newAPIServer(ctx context.Context, cfg config.Config, logger *slog.Logger) (
NewsFeedRepo: newsFeedRepo,
PolymarketAccountRepo: polymarketAccountRepo,
PolymarketCLOBURL: cfg.Brokers.Polymarket.CLOBURL,
ReportArtifactRepo: reportArtifactRepo,
BacktestConfigRepo: pgrepo.NewBacktestConfigRepo(db.Pool),
BacktestRunRepo: pgrepo.NewBacktestRunRepo(db.Pool),
StrategyTrigger: sched,
Logger: logger,
})
Expand Down
92 changes: 92 additions & 0 deletions internal/api/report_handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package api

import (
"math"
"net/http"
"time"

pgrepo "github.com/PatrickFanella/get-rich-quick/internal/repository/postgres"
)

// reportLatestResponse wraps the latest report artifact with a stale_seconds
// field showing how old the report is.
type reportLatestResponse struct {
pgrepo.ReportArtifact
StaleSeconds float64 `json:"stale_seconds"`
}

// handleGetLatestReport returns the most recently completed report artifact
// for a given strategy.
//
// GET /api/v1/strategies/{id}/reports/latest
func (s *Server) handleGetLatestReport(w http.ResponseWriter, r *http.Request) {
id, err := parseUUID(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, err.Error(), ErrCodeBadRequest)
return
}
if s.reportArtifacts == nil {
respondError(w, http.StatusNotImplemented, "report artifacts not configured", ErrCodeNotImplemented)
return
}

reportType := r.URL.Query().Get("report_type")
if reportType == "" {
reportType = "paper_validation"
}

artifact, err := s.reportArtifacts.GetLatest(r.Context(), id, reportType)
if err != nil {
if isNotFound(err) {
respondError(w, http.StatusNotFound, "no completed report found", ErrCodeNotFound)
return
}
respondError(w, http.StatusInternalServerError, "failed to get latest report", ErrCodeInternal)
return
}

stale := 0.0
if artifact.CompletedAt != nil {
stale = math.Max(0, math.Round(time.Since(*artifact.CompletedAt).Seconds()))
}

respondJSON(w, http.StatusOK, reportLatestResponse{
ReportArtifact: *artifact,
StaleSeconds: stale,
})
}

// handleListReports returns a paginated list of report artifacts for a strategy.
//
// GET /api/v1/strategies/{id}/reports
func (s *Server) handleListReports(w http.ResponseWriter, r *http.Request) {
id, err := parseUUID(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, err.Error(), ErrCodeBadRequest)
return
}
if s.reportArtifacts == nil {
respondError(w, http.StatusNotImplemented, "report artifacts not configured", ErrCodeNotImplemented)
return
}

limit, offset := parsePagination(r)

filter := pgrepo.ReportArtifactFilter{
StrategyID: &id,
}
if rt := r.URL.Query().Get("report_type"); rt != "" {
filter.ReportType = rt
}
if st := r.URL.Query().Get("status"); st != "" {
filter.Status = st
}

artifacts, err := s.reportArtifacts.List(r.Context(), filter, limit, offset)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list reports", ErrCodeInternal)
return
}

respondList(w, artifacts, limit, offset)
}
82 changes: 82 additions & 0 deletions internal/api/report_handlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package api

import (
"encoding/json"
"net/http"
"testing"
"time"

"github.com/google/uuid"

pgrepo "github.com/PatrickFanella/get-rich-quick/internal/repository/postgres"
)

// These tests exercise the "not configured" handler path by using the
// default test server setup, where Server.reportArtifacts is left nil.

func TestHandleGetLatestReport_NotConfigured(t *testing.T) {
t.Parallel()

srv := newTestServer(t)
// reportArtifacts is nil by default → 501
rr := doRequest(t, srv, http.MethodGet, "/api/v1/strategies/"+stratA.ID.String()+"/reports/latest", nil)
if rr.Code != http.StatusNotImplemented {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotImplemented)
}
}

func TestHandleListReports_NotConfigured(t *testing.T) {
t.Parallel()

srv := newTestServer(t)
rr := doRequest(t, srv, http.MethodGet, "/api/v1/strategies/"+stratA.ID.String()+"/reports", nil)
if rr.Code != http.StatusNotImplemented {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotImplemented)
}
}

func TestHandleGetLatestReport_InvalidUUID(t *testing.T) {
t.Parallel()

srv := newTestServer(t)
rr := doRequest(t, srv, http.MethodGet, "/api/v1/strategies/not-a-uuid/reports/latest", nil)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest)
}
}

func TestReportLatestResponse_StaleSeconds(t *testing.T) {
t.Parallel()

completed := time.Now().Add(-5 * time.Minute)
resp := reportLatestResponse{
ReportArtifact: pgrepo.ReportArtifact{
ID: uuid.New(),
StrategyID: stratA.ID,
ReportType: "paper_validation",
TimeBucket: time.Now().Truncate(24 * time.Hour),
Status: "completed",
ReportJSON: json.RawMessage(`{"decision":"GO"}`),
CompletedAt: &completed,
},
StaleSeconds: 300,
}

data, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}

var got map[string]any
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}

stale, ok := got["stale_seconds"].(float64)
if !ok {
t.Fatal("stale_seconds not present in response")
}
if stale != 300 {
t.Fatalf("stale_seconds = %f, want 300", stale)
}
}
11 changes: 11 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ type Server struct {
signalStore *signal.EventStore
watchIndex *signal.WatchIndex

// Report artifacts (optional; nil = feature not enabled).
reportArtifacts *pgrepo.ReportArtifactRepo

// Services — constructed from deps in NewServer.
backtestSvc *service.BacktestService
conversationSvc *service.ConversationService
Expand Down Expand Up @@ -189,6 +192,9 @@ type Deps struct {
// Signal intelligence (optional; nil = feature not enabled).
SignalStore *signal.EventStore
WatchIndex *signal.WatchIndex

// Report artifacts (optional; nil = feature not enabled).
ReportArtifacts *pgrepo.ReportArtifactRepo
}

// NewServer creates a new API server with all routes and middleware registered.
Expand Down Expand Up @@ -290,6 +296,7 @@ func NewServer(cfg ServerConfig, deps Deps, logger *slog.Logger) (*Server, error
metricsHandler: deps.MetricsHandler,
signalStore: deps.SignalStore,
watchIndex: deps.WatchIndex,
reportArtifacts: deps.ReportArtifacts,
}

// Construct services from the assembled deps.
Expand Down Expand Up @@ -351,6 +358,10 @@ func NewServer(cfg ServerConfig, deps Deps, logger *slog.Logger) (*Server, error
sr.Post("/{id}/pause", s.handlePauseStrategy)
sr.Post("/{id}/resume", s.handleResumeStrategy)
sr.Post("/{id}/skip-next", s.handleSkipNextStrategy)

// Report artifacts (nested under strategy)
sr.Get("/{id}/reports/latest", s.handleGetLatestReport)
sr.Get("/{id}/reports", s.handleListReports)
})

// Pipeline runs
Expand Down
16 changes: 8 additions & 8 deletions internal/api/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ func TestGetSettings(t *testing.T) {
},
Environment: "test",
Version: "v1.2.3",
CurrentSchemaVersion: 28,
RequiredSchemaVersion: 28,
CurrentSchemaVersion: 29,
RequiredSchemaVersion: 29,
SchemaStatus: "match",
ConnectedBrokers: []BrokerConnection{
{Name: "alpaca", PaperMode: true, Configured: true},
Expand Down Expand Up @@ -87,11 +87,11 @@ func TestGetSettings(t *testing.T) {
if body.System.Version != "v1.2.3" {
t.Fatalf("version = %q, want %q", body.System.Version, "v1.2.3")
}
if body.System.CurrentSchemaVersion != 28 {
t.Fatalf("current_schema_version = %d, want 28", body.System.CurrentSchemaVersion)
if body.System.CurrentSchemaVersion != 29 {
t.Fatalf("current_schema_version = %d, want 29", body.System.CurrentSchemaVersion)
}
if body.System.RequiredSchemaVersion != 28 {
t.Fatalf("required_schema_version = %d, want 28", body.System.RequiredSchemaVersion)
if body.System.RequiredSchemaVersion != 29 {
t.Fatalf("required_schema_version = %d, want 29", body.System.RequiredSchemaVersion)
}
if body.System.SchemaStatus != "ok" {
t.Fatalf("schema_status = %q, want %q", body.System.SchemaStatus, "ok")
Expand Down Expand Up @@ -139,8 +139,8 @@ func TestUpdateSettings(t *testing.T) {
CircuitBreakerThresholdPct: 5,
CircuitBreakerCooldownMin: 15,
},
CurrentSchemaVersion: 28,
RequiredSchemaVersion: 28,
CurrentSchemaVersion: 29,
RequiredSchemaVersion: 29,
SchemaStatus: "ok",
})

Expand Down
Loading
Loading