From 3ffefee0920ae494cbe303eb0a74c6df1ed1702d Mon Sep 17 00:00:00 2001 From: Patrick Fanella Date: Tue, 21 Apr 2026 22:01:03 -0500 Subject: [PATCH 1/5] feat: add options scaffold backtests and realtime triage updates --- .../2026-04-20_165747-hermes-integration.md | 336 ++++++++++++++++ README.md | 2 +- cmd/tradingagent/docker_compose_prod_test.go | 4 +- cmd/tradingagent/runtime_test.go | 8 +- docker-compose.yml | 2 + docs/design/api-design.md | 3 +- docs/design/backend/websocket-server.md | 6 +- docs/reference/api.md | 1 - internal/api/backtest_handlers_test.go | 308 +++++++++++++++ internal/api/server_test.go | 112 ++++++ internal/api/strategy_handlers.go | 21 +- internal/cli/tui/model.go | 2 + internal/cli/tui/model_test.go | 25 ++ internal/data/rss/triage.go | 19 +- internal/data/rss/triage_test.go | 68 ++++ internal/discovery/options/sweep.go | 367 +++++++++++++----- internal/discovery/options/sweep_test.go | 169 ++++++++ internal/discovery/options/validator.go | 4 +- internal/domain/trade.go | 1 + .../repository/postgres/polymarket_account.go | 18 +- .../postgres/polymarket_account_test.go | 34 +- internal/service/backtest.go | 223 ++++++++--- internal/service/backtest_scaffold_test.go | 349 +++++++++++++++++ internal/strategyscaffold/README.md | 26 ++ internal/strategyscaffold/strategyscaffold.go | 255 ++++++++++++ .../strategyscaffold/strategyscaffold_test.go | 177 +++++++++ .../dashboard/activity-feed.test.tsx | 24 +- .../components/dashboard/activity-feed.tsx | 1 + web/src/hooks/use-websocket-client.test.tsx | 36 ++ web/src/pages/pipeline-run-page.test.tsx | 84 +++- web/src/pages/realtime-page.test.tsx | 62 ++- 31 files changed, 2556 insertions(+), 191 deletions(-) create mode 100644 .hermes/plans/2026-04-20_165747-hermes-integration.md create mode 100644 internal/api/backtest_handlers_test.go create mode 100644 internal/data/rss/triage_test.go create mode 100644 internal/discovery/options/sweep_test.go create mode 100644 internal/service/backtest_scaffold_test.go create mode 100644 internal/strategyscaffold/README.md create mode 100644 internal/strategyscaffold/strategyscaffold.go create mode 100644 internal/strategyscaffold/strategyscaffold_test.go diff --git a/.hermes/plans/2026-04-20_165747-hermes-integration.md b/.hermes/plans/2026-04-20_165747-hermes-integration.md new file mode 100644 index 00000000..df9d11f5 --- /dev/null +++ b/.hermes/plans/2026-04-20_165747-hermes-integration.md @@ -0,0 +1,336 @@ +# Hermes integration proposal for augr + +## Goal + +Integrate Hermes with the existing `~/.agents` hub and the `augr` repo in a way that is low-risk, operationally useful, and consistent with the hub's documented boundary: + +- shared, human-managed assets live in `~/.agents` +- mutable runtime state stays native to each tool +- augr consumes the hub for workspace bootstrapping rather than inventing a parallel agent layout + +## What I found + +### In `augr` + +- The repo already expects the shared agent hub and documents a `~/.agents`-based workflow in `README.md`. +- `Taskfile.yml` defines: + - `task workspace` + - `task workspace:research` + - `task workspace:review` + - `task workspace:ops` +- But those tasks call `./scripts/workspace.sh`, and that file is missing. +- The actual hub workspace launcher exists in `~/.agents/scripts/bootstrap_tmux_workspace.sh`. +- The augr backend already exposes the right operator-facing surfaces for Hermes: + - `GET /api/v1/runs/{id}` + - `GET /api/v1/runs/{id}/decisions` + - `GET /api/v1/runs/{id}/snapshot` + - `GET /api/v1/events` + - `GET /api/v1/memories` + - `POST /api/v1/memories/search` + - `GET/POST /api/v1/conversations` + - `GET/POST /api/v1/conversations/{id}/messages` +- `internal/service/conversation.go` already builds LLM context from decisions, snapshots, and memories, which means augr already has a good "ask the agent why it did that" surface. + +### In `~/.agents` + +- The hub has a clear pattern for adding managed harnesses via `agents//agent.yaml`. +- The documented strategies are: + - `symlink-subpaths` + - `template-or-copy` + - `docs-only` + - `native-only` +- The hub script currently opens tmux windows for: + - `edit` + - `deck` + - `claude` + - `opencode` + - `db` + - `ops` +- Agent metadata is validated by schema and hub doctor tooling. +- The hub explicitly warns against centralizing mutable runtime state. + +## Recommendation + +Use a two-layer integration: + +1. `~/.agents` integration for developer workflow +2. augr API integration for operator workflow + +Do **not** put Hermes directly into augr's trading runtime first. + +That would be the wrong first move because it touches execution-critical code (`internal/agent`, runner wiring, risk flow, provider routing) when augr already exposes a safer control-plane interface for investigation and operations. + +## Proposed architecture + +### Layer 1: Add Hermes as a managed harness in `~/.agents` + +Add a new registry entry: + +- `~/.agents/agents/hermes/agent.yaml` + +Recommended initial posture: + +- `hub_strategy: native-only` or `docs-only` +- keep Hermes runtime state outside the hub +- use the hub only for: + - docs + - prompts + - launcher integration + - discoverability in hub doctor / bootstrap reporting + +Why this posture: + +- it matches the hub rule: centralize durable assets, not runtime state +- it avoids guessing at Hermes config semantics too early +- it gets Hermes into the standard tooling surface immediately + +Suggested initial metadata shape: + +```yaml +name: hermes +cli: hermes +config: ~/.hermes +runtime_roots: + - ~/.hermes + - ~/Documents/hermes +config_link: configs/hermes +binary: hermes +binary_type: cli +description: Hermes CLI agent and automation runtime +install_hint: Install Hermes CLI and ensure `hermes` is on PATH. +maturity: experimental +supports_mcp: false +optional: true +hub_strategy: native-only +config_required: true +shared_assets: + - docs + - prompts +bootstrap: + check_paths: + - ~/.hermes + - ~/Documents/hermes + notes: + - Keep Hermes runtime state native; do not centralize sessions, caches, or credentials in the hub. +validated_platforms: + - linux +``` + +Notes: + +- I would start with `supports_mcp: false` unless Hermes has a documented MCP contract you want the hub to validate. +- If Hermes later grows a stable rendered-config surface, you can move from `native-only` to `template-or-copy`. + +### Layer 2: Make augr launch Hermes from the shared workspace + +Fix the current broken workspace path in augr. + +Current problem: + +- `Taskfile.yml` points to `./scripts/workspace.sh` +- that file does not exist + +Proposed fix: + +- add `Code/projects/augr/scripts/workspace.sh` as a thin wrapper over the hub launcher +- wrapper should call: + +```bash +~/.agents/scripts/bootstrap_tmux_workspace.sh "$(pwd)" "${1:-$(basename "$(pwd)")}" +``` + +Then extend the shared hub launcher to support Hermes as an optional extra window: + +- add env vars like: + - `HERMES_BIN=${HERMES_BIN:-hermes}` + - `ENABLE_HERMES_WINDOW=${ENABLE_HERMES_WINDOW:-1}` +- if Hermes is installed, open a `hermes` tmux window after `opencode` + +Result: + +- augr keeps using the shared hub model +- Hermes becomes part of the standard repo workspace +- no augr-specific hardcoding of Hermes internals is needed + +## How Hermes should talk to augr + +Hermes should integrate with augr as an operator/control-plane client first, not as a runtime trading role. + +### Phase A: operator assistant over augr APIs + +Create a small Hermes-side augr integration surface that can: + +- authenticate with augr using API key or login flow +- list and inspect strategy runs +- fetch run decisions and snapshots +- search memories +- read events +- open or continue agent conversations +- trigger manual actions: + - run strategy + - cancel run + - inspect risk status + - toggle kill switch only with explicit operator confirmation + +This can be delivered as either: + +1. a Hermes skill/documented workflow, or +2. a lightweight augr client script/CLI wrapper consumed by Hermes + +I recommend starting with a thin client wrapper because augr already has stable HTTP surfaces. + +### Minimum useful endpoints for Hermes + +Read-only: + +- `GET /api/v1/strategies` +- `GET /api/v1/runs` +- `GET /api/v1/runs/{id}` +- `GET /api/v1/runs/{id}/decisions` +- `GET /api/v1/runs/{id}/snapshot` +- `GET /api/v1/events` +- `GET /api/v1/memories` +- `POST /api/v1/memories/search` +- `GET /api/v1/conversations` +- `GET /api/v1/conversations/{id}/messages` + +Write actions: + +- `POST /api/v1/conversations` +- `POST /api/v1/conversations/{id}/messages` +- `POST /api/v1/strategies/{id}/run` +- `POST /api/v1/runs/{id}/cancel` +- `POST /api/v1/risk/killswitch` + +### Why this is the right first integration + +Because augr already stores and exposes the exact artifacts Hermes needs to be useful: + +- decisions +- snapshots +- events +- memories +- conversations + +That means Hermes can act as: + +- operator copilot +- run investigator +- postmortem assistant +- risk review assistant + +without changing augr's execution pipeline. + +## Repo changes I would make first + +### In `~/.agents` + +1. Add `agents/hermes/agent.yaml` +2. Add `docs/HERMES.md` or similar usage doc +3. Update `scripts/bootstrap_tmux_workspace.sh` to optionally open a `hermes` window +4. Update validation/docs if needed + +### In `augr` + +1. Add `scripts/workspace.sh` wrapper so existing `task workspace*` commands actually work +2. Add `docs/hermes-integration.md` with: + - auth setup + - common Hermes workflows + - safe actions vs dangerous actions +3. Optionally add a small helper script, e.g.: + - `scripts/hermes-augr.sh` + - or `scripts/hermes_api.py` + +### Optional UI addition later + +In augr web UI, add a "Open in Hermes" affordance from: + +- run detail page +- decision timeline +- conversation page + +That action could: + +- copy a run-focused prompt +- open a local URL/command bridge if you later build one +- or simply render a ready-to-paste Hermes command block + +## Concrete first milestone + +If I were implementing this in the least risky order, I would do: + +1. Fix augr workspace launcher by adding `scripts/workspace.sh` +2. Register Hermes in `~/.agents/agents/hermes/agent.yaml` +3. Extend the hub tmux launcher with an optional `hermes` window +4. Add an augr helper script for read-only inspection: + - get run + - get decisions + - get snapshot + - search memories + - list conversations +5. Add a short repo doc showing example Hermes operator workflows + +That gives immediate value with minimal blast radius. + +## What I would not do first + +I would not start by: + +- adding Hermes as a new trading/runtime role inside `internal/agent` +- replacing augr's LLM provider layer with Hermes +- routing execution decisions through Hermes +- storing Hermes runtime state inside `~/.agents` + +Those are higher-risk and solve the wrong problem first. + +## Validation + +### Hub validation + +After adding Hermes to `~/.agents`: + +```bash +cd ~/.agents +python3 scripts/validate_agent_yaml.py +python3 scripts/validate_hub_metadata.py +python3 scripts/hub_doctor.py +bash scripts/validate-hub.sh +``` + +### augr validation + +After adding the launcher wrapper: + +```bash +cd ~/Code/projects/augr +task workspace +``` + +Expected result: + +- tmux session opens successfully +- standard windows appear +- Hermes window appears when enabled and installed + +### augr API integration validation + +Verify Hermes-side read-only flows against: + +- runs +- decisions +- snapshot +- memories search +- conversations + +before enabling write actions. + +## Final recommendation + +Best path: + +- integrate Hermes into `~/.agents` as a first-class harness +- fix augr to use the shared hub launcher it already expects +- use augr's existing API/conversation/memory surfaces so Hermes acts as an operator assistant first +- defer deep runtime integration until the workflow proves valuable + +This gives you a practical Hermes-on-augr workflow quickly, while staying aligned with both augr's current architecture and the hub's documented boundaries. diff --git a/README.md b/README.md index 709c788e..fa002c37 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ See [`.env.example`](.env.example) for the full list of variables including all ## API Overview -The REST API is served under `/api/v1`. Public HTTP endpoints are `GET /healthz`, `GET /health`, `GET /metrics`, `POST /api/v1/auth/login`, and `POST /api/v1/auth/refresh`. The WebSocket endpoint is `GET /ws`; it is not behind auth middleware in the current server. Backend root `/` is not the frontend SPA in the current Compose or production stack. +The REST API is served under `/api/v1`. Public HTTP endpoints are `GET /healthz`, `GET /health`, `GET /metrics`, `POST /api/v1/auth/login`, and `POST /api/v1/auth/refresh`. The WebSocket endpoint is `GET /ws`; it authenticates the upgrade request before switching protocols and accepts `Authorization: Bearer`, `X-API-Key`, `?token=`, or `?api_key=` credentials. Backend root `/` is not the frontend SPA in the current Compose or production stack. All other `/api/v1/*` routes require either `Authorization: Bearer ` or `X-API-Key: `. Implemented route groups include strategies, runs, portfolio, orders, trades, memories, risk, settings, events, conversations, audit log, and automation health/status. diff --git a/cmd/tradingagent/docker_compose_prod_test.go b/cmd/tradingagent/docker_compose_prod_test.go index f04a054d..60fa12ef 100644 --- a/cmd/tradingagent/docker_compose_prod_test.go +++ b/cmd/tradingagent/docker_compose_prod_test.go @@ -17,13 +17,13 @@ func TestProductionDockerComposeContainsRequiredConfiguration(t *testing.T) { compose := string(contents) for _, want := range []string{ "services:", - "image: postgres:17", + "image: pgvector/pgvector:pg17", "postgres_data:/var/lib/postgresql/data", "POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-tradingagent}", "image: redis:7-alpine", "redis_data:/data", - "[\"CMD\", \"redis-cli\", \"ping\"]", + "['CMD', 'redis-cli', 'ping']", "dockerfile: Dockerfile", "target: production", "APP_ENV: production", diff --git a/cmd/tradingagent/runtime_test.go b/cmd/tradingagent/runtime_test.go index a69c4966..e09ec406 100644 --- a/cmd/tradingagent/runtime_test.go +++ b/cmd/tradingagent/runtime_test.go @@ -29,7 +29,6 @@ import ( ) func TestNewAPIServerSchemaBehindFailsFast(t *testing.T) { - t.Parallel() origNewDB := runtimeNewDB origCurrentSchemaVersion := runtimeCurrentSchemaVersion @@ -70,7 +69,7 @@ func TestNewAPIServerSchemaBehindFailsFast(t *testing.T) { if mismatchErr.Required != pgrepo.RequiredSchemaVersion { t.Fatalf("mismatchErr.Required = %d, want %d", mismatchErr.Required, pgrepo.RequiredSchemaVersion) } - for _, want := range []string{"current version 28", "required version 29", "run migrations, then restart the process", "fresh process restart"} { + for _, want := range []string{"current version 29", "required version 30", "run migrations, then restart the process", "fresh process restart"} { if !strings.Contains(err.Error(), want) { t.Fatalf("error %q missing %q", err.Error(), want) } @@ -84,7 +83,6 @@ func TestNewAPIServerSchemaBehindFailsFast(t *testing.T) { } func TestNewAPIServerSchemaAheadFailsFast(t *testing.T) { - t.Parallel() origNewDB := runtimeNewDB origCurrentSchemaVersion := runtimeCurrentSchemaVersion @@ -125,7 +123,7 @@ func TestNewAPIServerSchemaAheadFailsFast(t *testing.T) { if mismatchErr.Required != pgrepo.RequiredSchemaVersion { t.Fatalf("mismatchErr.Required = %d, want %d", mismatchErr.Required, pgrepo.RequiredSchemaVersion) } - for _, want := range []string{"current version 30", "required version 29", "run migrations, then restart the process", "fresh process restart"} { + for _, want := range []string{"current version 31", "required version 30", "run migrations, then restart the process", "fresh process restart"} { if !strings.Contains(err.Error(), want) { t.Fatalf("error %q missing %q", err.Error(), want) } @@ -139,7 +137,6 @@ func TestNewAPIServerSchemaAheadFailsFast(t *testing.T) { } func TestNewAPIServerSchemaMatchSucceeds(t *testing.T) { - t.Parallel() origNewDB := runtimeNewDB origCurrentSchemaVersion := runtimeCurrentSchemaVersion @@ -206,7 +203,6 @@ func TestNewAPIServerSchemaMatchSucceeds(t *testing.T) { } func TestNewAPIServerSchemaDBUnreachableFailsBeforeSchemaGate(t *testing.T) { - t.Parallel() origNewDB := runtimeNewDB origCurrentSchemaVersion := runtimeCurrentSchemaVersion diff --git a/docker-compose.yml b/docker-compose.yml index 0dce0619..65d2de78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,8 @@ services: environment: DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-tradingagent}?sslmode=disable REDIS_URL: redis://redis:6379/0 + extra_hosts: + - 'host.docker.internal:host-gateway' volumes: - .:/app - go_cache:/go/pkg/mod diff --git a/docs/design/api-design.md b/docs/design/api-design.md index e40f7be8..5c71e2ba 100644 --- a/docs/design/api-design.md +++ b/docs/design/api-design.md @@ -149,7 +149,8 @@ These endpoints appeared in older design docs but are not registered by `interna ### Endpoint - `GET /ws` -- Public upgrade route in current code +- Authenticated upgrade route in current code +- Accepts `Authorization: Bearer`, `X-API-Key`, `?token=`, or `?api_key=` credentials - Subscription scope is client-side, not path-based ### Client commands diff --git a/docs/design/backend/websocket-server.md b/docs/design/backend/websocket-server.md index c3a41295..84a13bb3 100644 --- a/docs/design/backend/websocket-server.md +++ b/docs/design/backend/websocket-server.md @@ -163,10 +163,10 @@ See [[api-design]] for the full list of WebSocket event types. Key events: | `order_filled` | `{order_id, fill_price, fill_qty}` | Move to filled, update P&L | | `circuit_breaker` | `{reason, breaker_type}` | Show alert banner | -## Connection Lifecycle +### Connection Lifecycle -1. Client connects to `ws://localhost:8080/ws?token=` -2. Server validates JWT, creates `Client`, registers with Hub +1. Client connects to `ws://localhost:8080/ws` with `Authorization: Bearer`, `X-API-Key`, or browser-friendly `?token=` / `?api_key=` credentials +2. Server validates the credential, creates `Client`, registers with Hub 3. Client sends subscription messages: `{"action": "subscribe", "strategy_ids": [...]}` 4. Server pushes matching events 5. Client disconnects → `unregister` from Hub → goroutines exit diff --git a/docs/reference/api.md b/docs/reference/api.md index d9f1d8fe..9429a36f 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -36,7 +36,6 @@ Ops: http://localhost:8080/healthz - `POST /api/v1/auth/login` - `POST /api/v1/auth/refresh` - `POST /api/v1/auth/register` -- `GET /ws` ### Protected endpoints diff --git a/internal/api/backtest_handlers_test.go b/internal/api/backtest_handlers_test.go new file mode 100644 index 00000000..c8505897 --- /dev/null +++ b/internal/api/backtest_handlers_test.go @@ -0,0 +1,308 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/PatrickFanella/get-rich-quick/internal/backtest" + "github.com/PatrickFanella/get-rich-quick/internal/config" + "github.com/PatrickFanella/get-rich-quick/internal/data" + "github.com/PatrickFanella/get-rich-quick/internal/domain" + "github.com/PatrickFanella/get-rich-quick/internal/repository" + "github.com/PatrickFanella/get-rich-quick/internal/strategyscaffold" +) + +func TestGetBacktestRunReturnsPersistedOptionsArtifacts(t *testing.T) { + t.Parallel() + + runID := uuid.New() + configID := uuid.New() + tradeLog, _ := json.Marshal([]domain.Trade{{ + ID: uuid.New(), + Ticker: "QQQ240621P00450000", + Side: domain.OrderSideSell, + Quantity: 1, + Price: 2.15, + ExecutedAt: time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC), + AssetClass: domain.AssetClassOption, + OpenClose: "open", + ContractMultiplier: 100, + Premium: 2.15, + }}) + equityCurve, _ := json.Marshal([]backtest.EquityPoint{{ + Timestamp: time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC), + Cash: 99500, + Equity: 100000, + }, { + Timestamp: time.Date(2024, 3, 2, 0, 0, 0, 0, time.UTC), + Cash: 100050, + Equity: 100050, + }}) + metrics, _ := json.Marshal(map[string]any{"total_bars": 2, "sharpe_ratio": 1.2}) + + deps := testDeps() + deps.BacktestRuns = &stubBacktestRunRepo{items: map[uuid.UUID]*domain.BacktestRun{ + runID: { + ID: runID, + BacktestConfigID: configID, + Metrics: metrics, + TradeLog: tradeLog, + EquityCurve: equityCurve, + RunTimestamp: time.Date(2024, 3, 3, 0, 0, 0, 0, time.UTC), + PromptVersion: "options-rules-v1", + PromptVersionHash: "hash", + }, + }} + srv := newTestServerWithDeps(t, deps) + + rr := doRequest(t, srv, http.MethodGet, "/api/v1/backtests/runs/"+runID.String(), nil) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", rr.Code, http.StatusOK, rr.Body.String()) + } + + var got domain.BacktestRun + if err := json.NewDecoder(rr.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(got.TradeLog) == 0 { + t.Fatal("TradeLog empty, want persisted options trades") + } + if len(got.EquityCurve) == 0 { + t.Fatal("EquityCurve empty, want persisted options equity points") + } + + var trades []domain.Trade + if err := json.Unmarshal(got.TradeLog, &trades); err != nil { + t.Fatalf("json.Unmarshal(trade_log) error = %v", err) + } + if len(trades) == 0 || trades[0].AssetClass != domain.AssetClassOption { + t.Fatalf("trades = %#v, want first trade to be option trade", trades) + } + + var curve []backtest.EquityPoint + if err := json.Unmarshal(got.EquityCurve, &curve); err != nil { + t.Fatalf("json.Unmarshal(equity_curve) error = %v", err) + } + if len(curve) != 2 { + t.Fatalf("equity curve len = %d, want 2", len(curve)) + } +} + +func TestRunBacktestConfigReturnsAndPersistsOptionsArtifactsWithExitReasons(t *testing.T) { + optionsStrategy, err := strategyscaffold.OptionsPaperBullPutSpread("QQQ") + if err != nil { + t.Fatalf("OptionsPaperBullPutSpread() error = %v", err) + } + optionsStrategy.Status = domain.StrategyStatusInactive + + cfg := &domain.BacktestConfig{ + ID: uuid.New(), + StrategyID: optionsStrategy.ID, + Name: "options-paper-validation", + StartDate: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), + EndDate: time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC), + Simulation: domain.BacktestSimulationParameters{InitialCapital: 100_000}, + } + + bars := syntheticOHLCVSeries(cfg.StartDate.AddDate(-1, 0, 0), 520) + marketDataRepo := &stubMarketDataRepo{bars: bars} + provider := &stubDataProvider{bars: bars} + dataSvc := data.NewDataService(config.Config{}, &data.ProviderRegistry{ + Yahoo: func(data.ProviderConfig) data.DataProvider { return provider }, + }, marketDataRepo, slog.Default(), nil) + dataSvc.SetNowFunc(func() time.Time { return cfg.EndDate }) + + runRepo := &stubBacktestRunRepo{items: map[uuid.UUID]*domain.BacktestRun{}} + auditRepo := &stubAuditLogRepo{} + deps := testDeps() + deps.Strategies = &stubStrategyRepo{items: map[uuid.UUID]domain.Strategy{optionsStrategy.ID: optionsStrategy}} + deps.BacktestConfigs = &stubBacktestConfigRepo{items: map[uuid.UUID]*domain.BacktestConfig{cfg.ID: cfg}} + deps.BacktestRuns = runRepo + deps.AuditLog = auditRepo + deps.DataService = dataSvc + + srv := newTestServerWithDeps(t, deps) + rr := doRequest(t, srv, http.MethodPost, "/api/v1/backtests/configs/"+cfg.ID.String()+"/run", nil) + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body: %s", rr.Code, http.StatusCreated, rr.Body.String()) + } + + var got domain.BacktestRun + if err := json.NewDecoder(rr.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.BacktestConfigID != cfg.ID { + t.Fatalf("BacktestConfigID = %s, want %s", got.BacktestConfigID, cfg.ID) + } + if len(got.TradeLog) == 0 || len(got.EquityCurve) == 0 || len(got.Metrics) == 0 { + t.Fatalf("returned artifacts missing: metrics=%d trade_log=%d equity_curve=%d", len(got.Metrics), len(got.TradeLog), len(got.EquityCurve)) + } + + persisted, ok := runRepo.items[got.ID] + if !ok { + t.Fatalf("persisted run %s not found in repo", got.ID) + } + if string(persisted.TradeLog) != string(got.TradeLog) { + t.Fatal("persisted trade_log differs from response body") + } + if string(persisted.EquityCurve) != string(got.EquityCurve) { + t.Fatal("persisted equity_curve differs from response body") + } + + var trades []map[string]any + if err := json.Unmarshal(got.TradeLog, &trades); err != nil { + t.Fatalf("json.Unmarshal(trade_log) error = %v", err) + } + if len(trades) < 4 { + t.Fatalf("trade_log len = %d, want >= 4", len(trades)) + } + foundCloseReason := false + for _, trade := range trades { + if trade["open_close"] == "close" { + reason, _ := trade["exit_reason"].(string) + if reason != "" { + foundCloseReason = true + break + } + } + } + if !foundCloseReason { + t.Fatal("expected at least one closing options trade with exit_reason metadata") + } + + var curve []backtest.EquityPoint + if err := json.Unmarshal(got.EquityCurve, &curve); err != nil { + t.Fatalf("json.Unmarshal(equity_curve) error = %v", err) + } + if len(curve) < 10 { + t.Fatalf("equity_curve len = %d, want >= 10", len(curve)) + } + if len(auditRepo.entries) != 1 { + t.Fatalf("audit entries = %d, want 1", len(auditRepo.entries)) + } +} + +type stubBacktestRunRepo struct { + items map[uuid.UUID]*domain.BacktestRun +} + +func (s *stubBacktestRunRepo) Create(_ context.Context, run *domain.BacktestRun) error { + if s.items == nil { + s.items = map[uuid.UUID]*domain.BacktestRun{} + } + copyRun := *run + s.items[run.ID] = ©Run + return nil +} + +func (s *stubBacktestRunRepo) Get(_ context.Context, id uuid.UUID) (*domain.BacktestRun, error) { + run, ok := s.items[id] + if !ok { + return nil, fmt.Errorf("backtest run %v: %w", id, repository.ErrNotFound) + } + copyRun := *run + return ©Run, nil +} + +func (s *stubBacktestRunRepo) List(_ context.Context, filter repository.BacktestRunFilter, _, _ int) ([]domain.BacktestRun, error) { + out := make([]domain.BacktestRun, 0, len(s.items)) + for _, run := range s.items { + if filter.BacktestConfigID != nil && run.BacktestConfigID != *filter.BacktestConfigID { + continue + } + out = append(out, *run) + } + return out, nil +} + +func (s *stubBacktestRunRepo) Count(ctx context.Context, filter repository.BacktestRunFilter) (int, error) { + items, err := s.List(ctx, filter, 0, 0) + if err != nil { + return 0, err + } + return len(items), nil +} + +type stubDataProvider struct { + bars []domain.OHLCV +} + +func (s *stubDataProvider) GetOHLCV(context.Context, string, data.Timeframe, time.Time, time.Time) ([]domain.OHLCV, error) { + return append([]domain.OHLCV(nil), s.bars...), nil +} +func (s *stubDataProvider) GetFundamentals(context.Context, string) (data.Fundamentals, error) { + return data.Fundamentals{}, data.ErrNotImplemented +} +func (s *stubDataProvider) GetNews(context.Context, string, time.Time, time.Time) ([]data.NewsArticle, error) { + return nil, data.ErrNotImplemented +} +func (s *stubDataProvider) GetSocialSentiment(context.Context, string, time.Time, time.Time) ([]data.SocialSentiment, error) { + return nil, data.ErrNotImplemented +} + +type stubMarketDataRepo struct { + bars []domain.OHLCV +} + +func (s *stubMarketDataRepo) Get(context.Context, repository.MarketDataCacheKey) (*domain.MarketData, error) { + return nil, nil +} +func (s *stubMarketDataRepo) Set(context.Context, *domain.MarketData) error { return nil } +func (s *stubMarketDataRepo) Expire(context.Context, repository.MarketDataCacheExpireFilter) error { + return nil +} +func (s *stubMarketDataRepo) UpsertHistoricalOHLCV(context.Context, []domain.HistoricalOHLCV) error { + return nil +} +func (s *stubMarketDataRepo) ListHistoricalOHLCV(context.Context, repository.HistoricalOHLCVFilter) ([]domain.HistoricalOHLCV, error) { + result := make([]domain.HistoricalOHLCV, 0, len(s.bars)) + for _, bar := range s.bars { + result = append(result, domain.HistoricalOHLCV{ + Ticker: "QQQ", + Provider: "stock-chain", + Timeframe: data.Timeframe1d.String(), + Timestamp: bar.Timestamp, + Open: bar.Open, + High: bar.High, + Low: bar.Low, + Close: bar.Close, + Volume: bar.Volume, + }) + } + return result, nil +} +func (s *stubMarketDataRepo) UpsertHistoricalOHLCVCoverage(context.Context, domain.HistoricalOHLCVCoverage) error { + return nil +} +func (s *stubMarketDataRepo) ListHistoricalOHLCVCoverage(context.Context, repository.HistoricalOHLCVCoverageFilter) ([]domain.HistoricalOHLCVCoverage, error) { + return nil, nil +} + +func syntheticOHLCVSeries(start time.Time, count int) []domain.OHLCV { + bars := make([]domain.OHLCV, 0, count) + price := 100.0 + for i := 0; i < count; i++ { + if i%29 == 0 { + price -= 1.1 + } else { + price += 0.38 + } + bars = append(bars, domain.OHLCV{ + Timestamp: start.AddDate(0, 0, i), + Open: price - 0.4, + High: price + 0.7, + Low: price - 0.9, + Close: price, + Volume: 2_000_000 + float64(i*1000), + }) + } + return bars +} diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 1f98baea..5aed27e0 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -164,6 +164,118 @@ func TestValidateStrategyConfigPayloadWrapsJSONError(t *testing.T) { } } +func TestValidateStrategyConfigPayloadAcceptsRulesAndOptionsScaffolds(t *testing.T) { + t.Parallel() + + stockCfg := domain.StrategyConfig(`{ + "rules_engine": { + "version": 1, + "name": "paper-sma-crossover", + "entry": { + "operator": "AND", + "conditions": [ + {"field": "sma_20", "op": "gt", "ref": "sma_50"}, + {"field": "close", "op": "gt", "ref": "sma_200"} + ] + }, + "exit": { + "operator": "OR", + "conditions": [ + {"field": "sma_20", "op": "lt", "ref": "sma_50"}, + {"field": "close", "op": "lt", "ref": "sma_200"} + ] + }, + "position_sizing": {"method": "fixed_fraction", "fraction_pct": 10}, + "stop_loss": {"method": "atr_multiple", "atr_multiplier": 2}, + "take_profit": {"method": "risk_reward", "ratio": 3} + } + }`) + if err := validateStrategyConfigPayload(stockCfg); err != nil { + t.Fatalf("validateStrategyConfigPayload(stock rules) error = %v", err) + } + + optionsCfg := domain.StrategyConfig(`{ + "options_rules": { + "version": 1, + "strategy_type": "bull_put_spread", + "underlying": "QQQ", + "entry": { + "operator": "AND", + "conditions": [ + {"field": "close", "op": "gt", "ref": "sma_50"}, + {"field": "iv_rank", "op": "gt", "value": 50} + ] + }, + "exit": { + "operator": "OR", + "conditions": [ + {"field": "close", "op": "lt", "ref": "sma_50"}, + {"field": "pnl_pct", "op": "gte", "value": 50} + ] + }, + "leg_selection": { + "short_put": { + "option_type": "put", + "delta_target": 0.25, + "dte_min": 30, + "dte_max": 45, + "side": "sell", + "position_intent": "sell_to_open", + "ratio": 1 + }, + "long_put": { + "option_type": "put", + "delta_target": 0.1, + "dte_min": 30, + "dte_max": 45, + "side": "buy", + "position_intent": "buy_to_open", + "ratio": 1 + } + }, + "position_sizing": {"method": "max_risk", "max_risk_usd": 1000}, + "management": {"close_at_profit_pct": 50, "close_at_dte": 7, "stop_loss_pct": 100} + } + }`) + if err := validateStrategyConfigPayload(optionsCfg); err != nil { + t.Fatalf("validateStrategyConfigPayload(options rules) error = %v", err) + } +} + +func TestValidateStrategyConfigPayloadRejectsInvalidRulesAndOptionsScaffolds(t *testing.T) { + t.Parallel() + + stockCfg := domain.StrategyConfig(`{ + "rules_engine": { + "version": 1, + "entry": {"operator": "AND", "conditions": [{"field": "not_a_field", "op": "gt", "value": 1}]}, + "exit": {"operator": "AND", "conditions": [{"field": "close", "op": "lt", "value": 1}]}, + "position_sizing": {"method": "fixed_fraction", "fraction_pct": 10}, + "stop_loss": {"method": "fixed_pct", "pct": 2}, + "take_profit": {"method": "risk_reward", "ratio": 2} + } + }`) + if err := validateStrategyConfigPayload(stockCfg); err == nil || !strings.Contains(err.Error(), "rules_engine") { + t.Fatalf("validateStrategyConfigPayload(invalid stock rules) error = %v, want rules_engine error", err) + } + + optionsCfg := domain.StrategyConfig(`{ + "options_rules": { + "version": 1, + "strategy_type": "bull_put_spread", + "underlying": "QQQ", + "entry": {"operator": "AND", "conditions": [{"field": "iv_rank", "op": "gt", "value": 50}]}, + "exit": {"operator": "AND", "conditions": [{"field": "pnl_pct", "op": "gte", "value": 50}]}, + "leg_selection": {}, + "position_sizing": {"method": "max_risk", "max_risk_usd": 1000}, + "management": {"close_at_profit_pct": 50} + } + }`) + if err := validateStrategyConfigPayload(optionsCfg); err == nil || !strings.Contains(err.Error(), "options_rules") { + t.Fatalf("validateStrategyConfigPayload(invalid options rules) error = %v, want options_rules error", err) + } +} + func doUnauthenticatedRequest(t *testing.T, srv *Server, method, path string, body any) *httptest.ResponseRecorder { t.Helper() diff --git a/internal/api/strategy_handlers.go b/internal/api/strategy_handlers.go index b94bd70e..d4ab61dc 100644 --- a/internal/api/strategy_handlers.go +++ b/internal/api/strategy_handlers.go @@ -12,6 +12,7 @@ import ( "github.com/robfig/cron/v3" "github.com/PatrickFanella/get-rich-quick/internal/agent" + "github.com/PatrickFanella/get-rich-quick/internal/agent/rules" "github.com/PatrickFanella/get-rich-quick/internal/domain" "github.com/PatrickFanella/get-rich-quick/internal/repository" ) @@ -280,6 +281,24 @@ func validateStrategyConfigPayload(raw domain.StrategyConfig) error { if err := json.Unmarshal(raw, &cfg); err != nil { return fmt.Errorf("invalid config: %w", err) } + if err := agent.ValidateStrategyConfig(cfg); err != nil { + return err + } + if len(cfg.RulesEngine) > 0 { + if _, err := rules.Parse(cfg.RulesEngine); err != nil { + return fmt.Errorf("rules_engine: %w", err) + } + } - return agent.ValidateStrategyConfig(cfg) + var rawSections map[string]json.RawMessage + if err := json.Unmarshal(raw, &rawSections); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + if optionsRaw := rawSections["options_rules"]; len(optionsRaw) > 0 { + if _, err := rules.ParseOptions(optionsRaw); err != nil { + return err + } + } + + return nil } diff --git a/internal/cli/tui/model.go b/internal/cli/tui/model.go index 24d3d0cf..daf183db 100644 --- a/internal/cli/tui/model.go +++ b/internal/cli/tui/model.go @@ -495,6 +495,8 @@ func formatEventType(eventType internalapi.EventType) string { return "Circuit breaker" case internalapi.EventError: return "Pipeline error" + case internalapi.EventPipelineHealth: + return "Pipeline health" default: return string(eventType) } diff --git a/internal/cli/tui/model_test.go b/internal/cli/tui/model_test.go index 2b2f0400..1c128c5f 100644 --- a/internal/cli/tui/model_test.go +++ b/internal/cli/tui/model_test.go @@ -124,3 +124,28 @@ func TestModelAppliesWebSocketEventsToActivityAndProgress(t *testing.T) { t.Fatalf("activeTab = %d, want %d", shifted.activeTab, 1) } } + +func TestModelFormatsPipelineHealthEventsWithHumanLabel(t *testing.T) { + t.Parallel() + + runID := uuid.New() + model := NewModel(Snapshot{}, 120, 34) + + next, _ := model.Update(wsEventMsg{event: internalapi.WSMessage{ + Type: internalapi.EventPipelineHealth, + RunID: runID, + StrategyID: uuid.New(), + Timestamp: time.Now().UTC(), + Data: map[string]any{ + "ticker": "AAPL", + "used_fallback": true, + }, + }}) + updated := next.(Model) + if len(updated.snapshot.Activity) == 0 { + t.Fatal("expected activity feed to receive websocket event") + } + if got := updated.snapshot.Activity[0].Title; got != "Pipeline health" { + t.Fatalf("activity title = %q, want %q", got, "Pipeline health") + } +} diff --git a/internal/data/rss/triage.go b/internal/data/rss/triage.go index 18e09060..ced2c83d 100644 --- a/internal/data/rss/triage.go +++ b/internal/data/rss/triage.go @@ -19,17 +19,22 @@ type TriageResult struct { Summary string `json:"summary"` // one-line summary } -const triageSystemPrompt = `You are a financial news classifier. For each news headline and description, output a JSON object: +const triageSystemPrompt = `You are a financial news classifier. For the provided batch of news headlines and descriptions, output a JSON object in this exact shape: { - "tickers": ["AAPL", "MSFT"], - "category": "company", - "sentiment": "bearish", - "relevance": 0.8, - "summary": "Apple faces antitrust ruling" + "results": [ + { + "tickers": ["AAPL", "MSFT"], + "category": "company", + "sentiment": "bearish", + "relevance": 0.8, + "summary": "Apple faces antitrust ruling" + } + ] } Rules: +- Return one object in results for each headline, in the same order as the input headlines. - tickers: extract any stock tickers mentioned or clearly affected. Use standard symbols (AAPL not Apple). Empty array if none. - category: one of "earnings", "macro", "sector", "company", "geopolitical", "other" - sentiment: "bullish", "bearish", or "neutral" for the market/mentioned stocks @@ -73,7 +78,7 @@ func triageBatch(ctx context.Context, provider llm.Provider, model string, batch // Build a numbered list of headlines for the LLM. var sb strings.Builder - sb.WriteString("Classify each headline. Return a JSON array with one object per headline, in order.\n\n") + sb.WriteString("Classify each headline. Return a JSON object with a top-level \"results\" array containing one object per headline, in order.\n\n") for i, art := range batch { fmt.Fprintf(&sb, "%d. [%s] %s\n", i+1, art.Source, art.Title) if art.Description != "" { diff --git a/internal/data/rss/triage_test.go b/internal/data/rss/triage_test.go new file mode 100644 index 00000000..bf317e12 --- /dev/null +++ b/internal/data/rss/triage_test.go @@ -0,0 +1,68 @@ +package rss + +import ( + "context" + "strings" + "testing" + + "github.com/PatrickFanella/get-rich-quick/internal/llm" +) + +type stubTriageProvider struct { + lastRequest llm.CompletionRequest +} + +func (s *stubTriageProvider) Complete(_ context.Context, request llm.CompletionRequest) (*llm.CompletionResponse, error) { + s.lastRequest = request + + prompt := request.Messages[len(request.Messages)-1].Content + if strings.Contains(prompt, `"results"`) { + return &llm.CompletionResponse{Content: `{"results":[{"tickers":["AAPL"],"category":"company","sentiment":"bullish","relevance":0.9,"summary":"Apple demand stays strong"},{"tickers":["MSFT"],"category":"company","sentiment":"neutral","relevance":0.6,"summary":"Microsoft updates product plans"}]}`}, nil + } + + // Simulate the observed Ollama behavior when JSON-object mode is requested + // but the prompt asks for an array: the model returns a single object, which + // triage cannot map back to the batch. + return &llm.CompletionResponse{Content: `{"category":"company"}`}, nil +} + +func TestTriageRequestsResultsWrapperAndParsesBatch(t *testing.T) { + t.Parallel() + + provider := &stubTriageProvider{} + articles := []Article{ + { + GUID: "guid-1", + Source: "Reuters", + Title: "Apple launches new product", + Description: "Demand remains strong.", + }, + { + GUID: "guid-2", + Source: "Bloomberg", + Title: "Microsoft updates product plans", + Description: "Analysts expect limited near-term impact.", + }, + } + + results := Triage(context.Background(), provider, "", articles, nil) + if len(results) != 2 { + t.Fatalf("len(results) = %d, want 2", len(results)) + } + + if got := results["guid-1"]; got == nil || got.Summary != "Apple demand stays strong" { + t.Fatalf("results[guid-1] = %#v, want parsed wrapper result", got) + } + if got := results["guid-2"]; got == nil || got.Summary != "Microsoft updates product plans" { + t.Fatalf("results[guid-2] = %#v, want parsed wrapper result", got) + } + + if provider.lastRequest.ResponseFormat == nil || provider.lastRequest.ResponseFormat.Type != llm.ResponseFormatJSONObject { + t.Fatalf("ResponseFormat = %#v, want json_object", provider.lastRequest.ResponseFormat) + } + + prompt := provider.lastRequest.Messages[len(provider.lastRequest.Messages)-1].Content + if !strings.Contains(prompt, `"results"`) { + t.Fatalf("prompt = %q, want results wrapper instructions", prompt) + } +} diff --git a/internal/discovery/options/sweep.go b/internal/discovery/options/sweep.go index acb72f84..63ad623b 100644 --- a/internal/discovery/options/sweep.go +++ b/internal/discovery/options/sweep.go @@ -8,8 +8,11 @@ import ( "sort" "time" + "github.com/google/uuid" + "github.com/PatrickFanella/get-rich-quick/internal/agent/rules" "github.com/PatrickFanella/get-rich-quick/internal/backtest" + "github.com/PatrickFanella/get-rich-quick/internal/data" "github.com/PatrickFanella/get-rich-quick/internal/discovery" "github.com/PatrickFanella/get-rich-quick/internal/domain" ) @@ -25,12 +28,21 @@ type OptionsSweepConfig struct { FillConfig backtest.OptionsFillConfig } +// OptionsBacktestArtifacts captures the full outputs of a single synthetic options backtest. +type OptionsBacktestArtifacts struct { + Metrics backtest.Metrics + Trades []domain.Trade + EquityCurve []backtest.EquityPoint +} + // OptionsSweepResult pairs an options config with backtest metrics. type OptionsSweepResult struct { - Label string - Config rules.OptionsRulesConfig - Metrics backtest.Metrics - Score float64 + Label string + Config rules.OptionsRulesConfig + Metrics backtest.Metrics + Score float64 + Trades []domain.Trade + EquityCurve []backtest.EquityPoint } // RunOptionsSweep backtests multiple parameter variants and returns scored results. @@ -53,14 +65,12 @@ func RunOptionsSweep( rng := rand.New(rand.NewSource(time.Now().UnixNano())) - // Generate variants. variants := make([]rules.OptionsRulesConfig, 0, cfg.Variations+1) - variants = append(variants, baseConfig) // base is always first + variants = append(variants, baseConfig) for i := 0; i < cfg.Variations; i++ { variants = append(variants, mutateOptionsConfig(baseConfig, rng)) } - // Filter bars to date range. var bars []domain.OHLCV for _, b := range cfg.Bars { if !b.Timestamp.Before(cfg.StartDate) && !b.Timestamp.After(cfg.EndDate) { @@ -71,10 +81,9 @@ func RunOptionsSweep( return nil, nil } - // Compute realized vol for synthetic chain generation. - rv := realizedVol(cfg.Bars, 60) // 60-day realized vol + rv := realizedVol(cfg.Bars, 60) if rv < 0.05 { - rv = 0.20 // floor at 20% + rv = 0.20 } var results []OptionsSweepResult @@ -89,84 +98,81 @@ func RunOptionsSweep( label = "variant" } - metrics := runOptionsBacktest(variant, bars, rv, cfg) - score := discovery.ScoreMetrics(metrics, scoring) + artifacts := runOptionsBacktest(variant, bars, rv, cfg) + score := discovery.ScoreMetrics(artifacts.Metrics, scoring) + if len(artifacts.Trades) > 0 { + score += 0.001 + } results = append(results, OptionsSweepResult{ - Label: label, - Config: variant, - Metrics: metrics, - Score: score, + Label: label, + Config: variant, + Metrics: artifacts.Metrics, + Score: score, + Trades: artifacts.Trades, + EquityCurve: artifacts.EquityCurve, }) } sort.Slice(results, func(i, j int) bool { + if results[i].Score == results[j].Score { + return len(results[i].Trades) > len(results[j].Trades) + } return results[i].Score > results[j].Score }) return results, nil } -// optionsPosition tracks an open options position during backtest. type optionsPosition struct { spread *domain.OptionSpread entryBar domain.OHLCV - entryMid float64 // net premium received (credit) or paid (debit) - maxProfit float64 // max potential profit - maxRisk float64 // max potential loss + entryMid float64 + maxProfit float64 + maxRisk float64 } -// runOptionsBacktest executes a single options backtest variant. func runOptionsBacktest( config rules.OptionsRulesConfig, bars []domain.OHLCV, realizedVol float64, cfg OptionsSweepConfig, -) backtest.Metrics { +) OptionsBacktestArtifacts { cash := cfg.InitialCash var position *optionsPosition chainCfg := backtest.DefaultSyntheticChainConfig() equityCurve := make([]backtest.EquityPoint, 0, len(bars)) + trades := make([]domain.Trade, 0, 16) var prevSnap *rules.Snapshot - for _, bar := range bars { - // Build snapshot from bar. - snap := rules.Snapshot{ - Values: map[string]float64{ - "close": bar.Close, - "open": bar.Open, - "high": bar.High, - "low": bar.Low, - "volume": bar.Volume, - }, - } - - // Compute simple indicators from recent bars context. - // (For sweep, we rely on the condition fields that are in the snapshot.) + for i, bar := range bars { + window := bars[:i+1] + snap := rules.Snapshot{Values: buildOptionSignalValues(window, bar, position, realizedVol, chainCfg)} - // Synthesize options chain. dte := avgDTE(config.LegSelection) chain := backtest.SynthesizeChain(bar.Close, realizedVol, dte, bar.Timestamp, chainCfg) - - // Build options snapshot. optSnap := rules.NewOptionsSnapshot(snap, chain, nil, bar.Timestamp) marketValue := 0.0 + unrealizedPnL := 0.0 if position != nil { - // Mark-to-market: estimate current spread value from synthetic chain. marketValue = estimateSpreadValue(position, bar.Close, realizedVol, bar.Timestamp, chainCfg) + unrealizedPnL = position.entryMid + marketValue } + totalPnL := cash + marketValue - cfg.InitialCash equityCurve = append(equityCurve, backtest.EquityPoint{ - Timestamp: bar.Timestamp, - Cash: cash, - MarketValue: marketValue, - Equity: cash + marketValue, + Timestamp: bar.Timestamp, + Cash: cash, + MarketValue: marketValue, + Equity: cash + marketValue, + UnrealizedPnL: unrealizedPnL, + RealizedPnL: totalPnL - unrealizedPnL, + TotalPnL: totalPnL, }) if position == nil { - // Evaluate entry. if rules.EvaluateGroup(config.Entry, optSnap.Snapshot, prevSnap) { spread, entryMid := buildSyntheticSpread(config, chain, bar) if spread != nil { @@ -178,35 +184,54 @@ func runOptionsBacktest( maxProfit: maxProfit, maxRisk: maxRisk, } - cash -= maxRisk // reserve max risk as collateral + cash -= maxRisk + trades = append(trades, buildOptionsTrades(position, bar, true)...) } } } else { - // Check management rules. shouldClose, reason := checkManagement(position, config.Management, bar, realizedVol, chainCfg) - if !shouldClose { - // Evaluate exit conditions. - if rules.EvaluateGroup(config.Exit, optSnap.Snapshot, prevSnap) { - shouldClose = true - reason = "exit_signal" - } + if !shouldClose && rules.EvaluateGroup(config.Exit, optSnap.Snapshot, prevSnap) { + shouldClose = true + reason = "exit_signal" } if shouldClose { - pnl := closePosition(position, bar, realizedVol, chainCfg) - cash += position.maxRisk + pnl // release collateral + P&L + closeValue, pnl := closePosition(position, bar, realizedVol, chainCfg) + cash += position.maxRisk + pnl + trades = append(trades, buildOptionsCloseTrades(position, bar, closeValue, reason)...) position = nil - _ = reason } } - prevSnap = &snap + clone := cloneSnapshot(optSnap.Snapshot) + prevSnap = &clone } - return backtest.ComputeMetrics(equityCurve, bars) + if position != nil && len(bars) > 0 { + lastBar := bars[len(bars)-1] + closeValue, pnl := closePosition(position, lastBar, realizedVol, chainCfg) + cash += position.maxRisk + pnl + trades = append(trades, buildOptionsCloseTrades(position, lastBar, closeValue, "final_bar")...) + position = nil + equityCurve[len(equityCurve)-1] = backtest.EquityPoint{ + Timestamp: lastBar.Timestamp, + Cash: cash, + MarketValue: 0, + Equity: cash, + UnrealizedPnL: 0, + RealizedPnL: cash - cfg.InitialCash, + TotalPnL: cash - cfg.InitialCash, + } + } + + metrics := backtest.ComputeMetrics(equityCurve, bars) + return OptionsBacktestArtifacts{ + Metrics: metrics, + Trades: trades, + EquityCurve: equityCurve, + } } -// buildSyntheticSpread selects legs from the synthetic chain and builds a spread. func buildSyntheticSpread(config rules.OptionsRulesConfig, chain []domain.OptionSnapshot, bar domain.OHLCV) (*domain.OptionSpread, float64) { now := bar.Timestamp selectedLegs, err := rules.SelectSpreadLegs(chain, config.LegSelection, now) @@ -219,7 +244,6 @@ func buildSyntheticSpread(config rules.OptionsRulesConfig, chain []domain.Option return nil, 0 } - // Calculate net premium (positive = credit received). var netPremium float64 for legName, snap := range selectedLegs { sel := config.LegSelection[legName] @@ -233,28 +257,23 @@ func buildSyntheticSpread(config rules.OptionsRulesConfig, chain []domain.Option return spread, netPremium } -// spreadRiskReward estimates max profit and max risk for a spread. func spreadRiskReward(spread *domain.OptionSpread, netPremium float64) (maxProfit, maxRisk float64) { if len(spread.Legs) < 2 { - // Single leg (e.g. covered call) — risk is unlimited, cap at premium * 3. return math.Abs(netPremium), math.Abs(netPremium) * 3 } - // Vertical spread: max risk = width between strikes - net premium. var strikes []float64 for _, leg := range spread.Legs { strikes = append(strikes, leg.Contract.Strike) } sort.Float64s(strikes) - width := (strikes[len(strikes)-1] - strikes[0]) * 100 // * multiplier + width := (strikes[len(strikes)-1] - strikes[0]) * 100 if netPremium > 0 { - // Credit spread. maxProfit = netPremium maxRisk = width - netPremium } else { - // Debit spread. - maxProfit = width + netPremium // netPremium is negative + maxProfit = width + netPremium maxRisk = -netPremium } @@ -264,19 +283,20 @@ func spreadRiskReward(spread *domain.OptionSpread, netPremium float64) (maxProfi return maxProfit, maxRisk } -// checkManagement evaluates automated management rules. func checkManagement(pos *optionsPosition, mgmt rules.OptionsManagement, bar domain.OHLCV, vol float64, chainCfg backtest.SyntheticChainConfig) (bool, string) { currentValue := estimateSpreadValue(pos, bar.Close, vol, bar.Timestamp, chainCfg) - pnl := currentValue + pos.entryMid // for credit spreads: entry is positive, current should decay + pnl := currentValue + pos.entryMid - // Close at profit target. if mgmt.CloseAtProfitPct > 0 && pos.maxProfit > 0 { - if pnl >= pos.maxProfit*mgmt.CloseAtProfitPct { + profitTargetRatio := mgmt.CloseAtProfitPct + if profitTargetRatio > 1 { + profitTargetRatio /= 100 + } + if pnl >= pos.maxProfit*profitTargetRatio { return true, "profit_target" } } - // Close at DTE. if mgmt.CloseAtDTE > 0 && len(pos.spread.Legs) > 0 { expiry := pos.spread.Legs[0].Contract.Expiry dte := int(expiry.Sub(bar.Timestamp).Hours() / 24) @@ -285,10 +305,13 @@ func checkManagement(pos *optionsPosition, mgmt rules.OptionsManagement, bar dom } } - // Stop loss. if mgmt.StopLossPct > 0 && pos.maxRisk > 0 { + stopLossRatio := mgmt.StopLossPct + if stopLossRatio > 1 { + stopLossRatio /= 100 + } loss := -pnl - if loss >= pos.maxRisk*mgmt.StopLossPct { + if loss >= pos.maxRisk*stopLossRatio { return true, "stop_loss" } } @@ -296,7 +319,6 @@ func checkManagement(pos *optionsPosition, mgmt rules.OptionsManagement, bar dom return false, "" } -// estimateSpreadValue estimates the current mark-to-market value of an open spread. func estimateSpreadValue(pos *optionsPosition, underlying, vol float64, now time.Time, chainCfg backtest.SyntheticChainConfig) float64 { if pos == nil || pos.spread == nil { return 0 @@ -310,7 +332,6 @@ func estimateSpreadValue(pos *optionsPosition, underlying, vol float64, now time } chain := backtest.SynthesizeChain(underlying, vol, dte, now, chainCfg) - // Find the contract in the synthetic chain closest to our strike. bestDist := math.Inf(1) var legValue float64 for _, snap := range chain { @@ -325,22 +346,112 @@ func estimateSpreadValue(pos *optionsPosition, underlying, vol float64, now time } if leg.Side == "sell" { - value -= legValue // we owe this + value -= legValue } else { - value += legValue // we own this + value += legValue } } return value } -// closePosition calculates P&L when closing a spread. -func closePosition(pos *optionsPosition, bar domain.OHLCV, vol float64, chainCfg backtest.SyntheticChainConfig) float64 { +func closePosition(pos *optionsPosition, bar domain.OHLCV, vol float64, chainCfg backtest.SyntheticChainConfig) (float64, float64) { currentValue := estimateSpreadValue(pos, bar.Close, vol, bar.Timestamp, chainCfg) - return pos.entryMid + currentValue + return currentValue, pos.entryMid + currentValue +} + +func buildOptionsTrades(pos *optionsPosition, bar domain.OHLCV, opening bool) []domain.Trade { + if pos == nil || pos.spread == nil { + return nil + } + trades := make([]domain.Trade, 0, len(pos.spread.Legs)) + for _, leg := range pos.spread.Legs { + premium := legMarkPremium(leg, pos.entryBar.Close, bar.Close, pos.entryMid, len(pos.spread.Legs), true) + trades = append(trades, domain.Trade{ + ID: uuid.New(), + Ticker: leg.Contract.OCCSymbol, + Side: leg.Side, + Quantity: leg.Quantity, + Price: premium, + ExecutedAt: bar.Timestamp, + CreatedAt: bar.Timestamp, + AssetClass: domain.AssetClassOption, + OpenClose: openCloseValue(opening), + ContractMultiplier: leg.Contract.Multiplier, + Premium: premium, + Fee: 0, + }) + } + return trades +} + +func buildOptionsCloseTrades(pos *optionsPosition, bar domain.OHLCV, closeValue float64, reason string) []domain.Trade { + if pos == nil || pos.spread == nil { + return nil + } + trades := make([]domain.Trade, 0, len(pos.spread.Legs)) + for _, leg := range pos.spread.Legs { + closeSide := domain.OrderSideBuy + if leg.Side == domain.OrderSideBuy { + closeSide = domain.OrderSideSell + } + premium := legMarkPremium(leg, pos.entryBar.Close, bar.Close, closeValue, len(pos.spread.Legs), false) + trades = append(trades, domain.Trade{ + ID: uuid.New(), + Ticker: leg.Contract.OCCSymbol, + Side: closeSide, + Quantity: leg.Quantity, + Price: premium, + ExecutedAt: bar.Timestamp, + CreatedAt: bar.Timestamp, + AssetClass: domain.AssetClassOption, + OpenClose: openCloseValue(false), + ContractMultiplier: leg.Contract.Multiplier, + Premium: premium, + ExitReason: reason, + Fee: 0, + }) + } + return trades +} + +func legMarkPremium(leg domain.SpreadLeg, entryUnderlying, currentUnderlying, netValue float64, legCount int, opening bool) float64 { + multiplier := leg.Contract.Multiplier + if multiplier <= 0 { + multiplier = 100 + } + if legCount < 1 { + legCount = 1 + } + base := math.Abs(netValue) / float64(legCount) / multiplier + if base > 0 { + return base + } + intrinsic := 0.0 + switch leg.Contract.OptionType { + case domain.OptionTypeCall: + if currentUnderlying > leg.Contract.Strike { + intrinsic = currentUnderlying - leg.Contract.Strike + } + case domain.OptionTypePut: + if leg.Contract.Strike > currentUnderlying { + intrinsic = leg.Contract.Strike - currentUnderlying + } + } + timeValue := math.Max(0.1, math.Abs(currentUnderlying-entryUnderlying)*0.02) + if !opening { + timeValue = math.Max(0.05, timeValue*0.5) + } + return intrinsic + timeValue +} + +func openCloseValue(opening bool) string { + if opening { + return "open" + } + return "close" } -// avgDTE returns the average target DTE from leg selectors. func avgDTE(legs map[string]rules.LegSelector) int { if len(legs) == 0 { return 30 @@ -356,39 +467,32 @@ func avgDTE(legs map[string]rules.LegSelector) int { return avg } -// mutateOptionsConfig creates a random variant of an options config. func mutateOptionsConfig(base rules.OptionsRulesConfig, rng *rand.Rand) rules.OptionsRulesConfig { - cfg := base // shallow copy + cfg := base - // Deep copy leg selection. cfg.LegSelection = make(map[string]rules.LegSelector, len(base.LegSelection)) for k, v := range base.LegSelection { - // Mutate delta target. - v.DeltaTarget = clamp(v.DeltaTarget+rng.Float64()*0.10-0.05, 0.05, 0.50) - // Mutate DTE range. + v.DeltaTarget = clampFloat(v.DeltaTarget+rng.Float64()*0.10-0.05, 0.05, 0.50) shift := rng.Intn(11) - 5 - v.DTEMin = max(7, v.DTEMin+shift) - v.DTEMax = max(v.DTEMin+7, v.DTEMax+shift) + v.DTEMin = maxInt(7, v.DTEMin+shift) + v.DTEMax = maxInt(v.DTEMin+7, v.DTEMax+shift) cfg.LegSelection[k] = v } - // Mutate management. if cfg.Management.CloseAtProfitPct > 0 { - cfg.Management.CloseAtProfitPct = clamp(cfg.Management.CloseAtProfitPct*(0.8+rng.Float64()*0.4), 0.20, 0.90) + cfg.Management.CloseAtProfitPct = clampFloat(cfg.Management.CloseAtProfitPct*(0.8+rng.Float64()*0.4), 0.20, 90.0) } if cfg.Management.CloseAtDTE > 0 { - cfg.Management.CloseAtDTE = max(1, cfg.Management.CloseAtDTE+rng.Intn(5)-2) + cfg.Management.CloseAtDTE = maxInt(1, cfg.Management.CloseAtDTE+rng.Intn(5)-2) } if cfg.Management.StopLossPct > 0 { - cfg.Management.StopLossPct = clamp(cfg.Management.StopLossPct*(0.7+rng.Float64()*0.6), 0.5, 3.0) + cfg.Management.StopLossPct = clampFloat(cfg.Management.StopLossPct*(0.7+rng.Float64()*0.6), 0.5, 300.0) } - // Mutate sizing. if cfg.PositionSizing.MaxRiskUSD > 0 { cfg.PositionSizing.MaxRiskUSD = cfg.PositionSizing.MaxRiskUSD * (0.7 + rng.Float64()*0.6) } - // Deep copy conditions (mutate values). cfg.Entry = mutateConditionGroup(base.Entry, rng) cfg.Exit = mutateConditionGroup(base.Exit, rng) @@ -407,3 +511,74 @@ func mutateConditionGroup(group rules.ConditionGroup, rng *rand.Rand) rules.Cond } return out } + +func buildOptionSignalValues(bars []domain.OHLCV, bar domain.OHLCV, pos *optionsPosition, rv float64, chainCfg backtest.SyntheticChainConfig) map[string]float64 { + values := map[string]float64{ + "close": bar.Close, + "open": bar.Open, + "high": bar.High, + "low": bar.Low, + "volume": bar.Volume, + } + for _, indicator := range data.IndicatorSnapshotFromBars(bars) { + values[indicator.Name] = indicator.Value + } + + dte := 30 + if pos != nil && pos.spread != nil && len(pos.spread.Legs) > 0 { + expiry := pos.spread.Legs[0].Contract.Expiry + dte = int(expiry.Sub(bar.Timestamp).Hours() / 24) + if dte < 0 { + dte = 0 + } + currentValue := estimateSpreadValue(pos, bar.Close, rv, bar.Timestamp, chainCfg) + if pos.maxRisk > 0 { + values["pnl_pct"] = ((pos.entryMid + currentValue) / pos.maxRisk) * 100 + } else { + values["pnl_pct"] = 0 + } + values["dte"] = float64(dte) + } + + chain := backtest.SynthesizeChain(bar.Close, rv, dte, bar.Timestamp, chainCfg) + atmIV, putCallRatio := chainMetrics(chain, bar.Close) + values["atm_iv"] = atmIV + values["put_call_ratio"] = putCallRatio + + ivRank := 0.0 + if rv > 0 && atmIV > 0 { + minVol := rv * 0.7 + maxVol := rv * 1.5 + if maxVol > minVol { + ivRank = clampFloat((atmIV-minVol)/(maxVol-minVol)*100, 0, 100) + } + } + values["iv_rank"] = ivRank + values["iv_percentile"] = ivRank + return values +} + +func cloneSnapshot(s rules.Snapshot) rules.Snapshot { + values := make(map[string]float64, len(s.Values)) + for k, v := range s.Values { + values[k] = v + } + return rules.Snapshot{Values: values} +} + +func clampFloat(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/discovery/options/sweep_test.go b/internal/discovery/options/sweep_test.go new file mode 100644 index 00000000..382f0f60 --- /dev/null +++ b/internal/discovery/options/sweep_test.go @@ -0,0 +1,169 @@ +package options + +import ( + "context" + "testing" + "time" + + "github.com/PatrickFanella/get-rich-quick/internal/agent/rules" + "github.com/PatrickFanella/get-rich-quick/internal/backtest" + "github.com/PatrickFanella/get-rich-quick/internal/discovery" + "github.com/PatrickFanella/get-rich-quick/internal/domain" +) + +func TestRunOptionsSweepReturnsTradesAndEquityCurve(t *testing.T) { + t.Parallel() + + bars := syntheticBars(340) + cfg := rules.OptionsRulesConfig{ + Version: 1, + StrategyType: domain.StrategyBullPutSpread, + Underlying: "QQQ", + Entry: rules.ConditionGroup{ + Operator: "AND", + Conditions: []rules.Condition{ + {Field: "close", Op: "gt", Ref: "sma_50"}, + {Field: "iv_rank", Op: "gt", Value: fp(30)}, + }, + }, + Exit: rules.ConditionGroup{ + Operator: "OR", + Conditions: []rules.Condition{ + {Field: "close", Op: "lt", Ref: "sma_50"}, + {Field: "pnl_pct", Op: "gte", Value: fp(50)}, + }, + }, + LegSelection: map[string]rules.LegSelector{ + "short_put": { + OptionType: domain.OptionTypePut, + DeltaTarget: 0.25, + DTEMin: 30, + DTEMax: 45, + Side: domain.OrderSideSell, + Intent: domain.PositionIntentSellToOpen, + Ratio: 1, + }, + "long_put": { + OptionType: domain.OptionTypePut, + DeltaTarget: 0.10, + DTEMin: 30, + DTEMax: 45, + Side: domain.OrderSideBuy, + Intent: domain.PositionIntentBuyToOpen, + Ratio: 1, + }, + }, + PositionSizing: rules.OptionsSizingConfig{Method: "max_risk", MaxRiskUSD: 1000}, + Management: rules.OptionsManagement{CloseAtProfitPct: 50, CloseAtDTE: 7, StopLossPct: 100}, + } + + results, err := RunOptionsSweep(context.Background(), cfg, OptionsSweepConfig{ + Ticker: "QQQ", + Bars: bars, + StartDate: bars[40].Timestamp, + EndDate: bars[len(bars)-1].Timestamp, + InitialCash: 100_000, + Variations: 0, + }, discovery.DefaultScoringConfig(), nil) + if err != nil { + t.Fatalf("RunOptionsSweep() error = %v", err) + } + if len(results) == 0 { + t.Fatal("RunOptionsSweep() returned no results") + } + + best := results[0] + if len(best.Trades) == 0 { + t.Fatal("best.Trades empty, want full trade log") + } + if len(best.EquityCurve) < 10 { + t.Fatalf("best.EquityCurve len = %d, want >= 10", len(best.EquityCurve)) + } + if best.Trades[0].AssetClass != domain.AssetClassOption { + t.Fatalf("first trade asset_class = %q, want %q", best.Trades[0].AssetClass, domain.AssetClassOption) + } + if best.EquityCurve[0].Timestamp.IsZero() || best.EquityCurve[len(best.EquityCurve)-1].Timestamp.IsZero() { + t.Fatal("equity curve timestamps must be populated") + } +} + +func TestRunOptionsSweepForceClosesOpenPositionOnFinalBar(t *testing.T) { + t.Parallel() + + bars := syntheticBars(260) + cfg := rules.OptionsRulesConfig{ + Version: 1, + StrategyType: domain.StrategyBullPutSpread, + Underlying: "QQQ", + Entry: rules.ConditionGroup{ + Operator: "AND", + Conditions: []rules.Condition{ + {Field: "close", Op: "gt", Ref: "sma_50"}, + {Field: "iv_rank", Op: "gt", Value: fp(30)}, + }, + }, + Exit: rules.ConditionGroup{ + Operator: "AND", + Conditions: []rules.Condition{{Field: "pnl_pct", Op: "gt", Value: fp(5000)}}, + }, + LegSelection: map[string]rules.LegSelector{ + "short_put": {OptionType: domain.OptionTypePut, DeltaTarget: 0.25, DTEMin: 30, DTEMax: 45, Side: domain.OrderSideSell, Intent: domain.PositionIntentSellToOpen, Ratio: 1}, + "long_put": {OptionType: domain.OptionTypePut, DeltaTarget: 0.10, DTEMin: 30, DTEMax: 45, Side: domain.OrderSideBuy, Intent: domain.PositionIntentBuyToOpen, Ratio: 1}, + }, + PositionSizing: rules.OptionsSizingConfig{Method: "max_risk", MaxRiskUSD: 1000}, + Management: rules.OptionsManagement{CloseAtProfitPct: 0, CloseAtDTE: 0, StopLossPct: 0}, + } + + results, err := RunOptionsSweep(context.Background(), cfg, OptionsSweepConfig{ + Ticker: "QQQ", + Bars: bars, + StartDate: bars[40].Timestamp, + EndDate: bars[len(bars)-1].Timestamp, + InitialCash: 100_000, + Variations: 0, + }, discovery.DefaultScoringConfig(), nil) + if err != nil { + t.Fatalf("RunOptionsSweep() error = %v", err) + } + if len(results) == 0 { + t.Fatal("RunOptionsSweep() returned no results") + } + + trades := results[0].Trades + if len(trades) < 4 { + t.Fatalf("trades len = %d, want >= 4", len(trades)) + } + last := trades[len(trades)-1] + if last.OpenClose != "close" { + t.Fatalf("last trade open_close = %q, want close", last.OpenClose) + } + if last.ExitReason != "final_bar" { + t.Fatalf("last trade exit_reason = %q, want %q", last.ExitReason, "final_bar") + } +} + +func syntheticBars(count int) []domain.OHLCV { + bars := make([]domain.OHLCV, 0, count) + base := time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC) + price := 100.0 + for i := 0; i < count; i++ { + if i%23 == 0 { + price -= 1.2 + } else { + price += 0.45 + } + bars = append(bars, domain.OHLCV{ + Timestamp: base.AddDate(0, 0, i), + Open: price - 0.3, + High: price + 0.8, + Low: price - 0.9, + Close: price, + Volume: 1_500_000 + float64(i*1000), + }) + } + return bars +} + +func fp(v float64) *float64 { return &v } + +var _ = backtest.EquityPoint{} diff --git a/internal/discovery/options/validator.go b/internal/discovery/options/validator.go index 0cc87125..9651d39d 100644 --- a/internal/discovery/options/validator.go +++ b/internal/discovery/options/validator.go @@ -65,13 +65,13 @@ func ValidateOptionsOutOfSample( sweepCfg.StartDate = w.calStart sweepCfg.EndDate = w.calEnd calMetrics := runOptionsBacktest(optionsConfig, filterBars(bars, w.calStart, w.calEnd), rv, sweepCfg) - inSampleMetrics = append(inSampleMetrics, calMetrics) + inSampleMetrics = append(inSampleMetrics, calMetrics.Metrics) // Out-of-sample (test). sweepCfg.StartDate = w.testStart sweepCfg.EndDate = w.testEnd testMetrics := runOptionsBacktest(optionsConfig, filterBars(bars, w.testStart, w.testEnd), rv, sweepCfg) - oosMetrics = append(oosMetrics, testMetrics) + oosMetrics = append(oosMetrics, testMetrics.Metrics) } // Aggregate. diff --git a/internal/domain/trade.go b/internal/domain/trade.go index 24135534..74655d31 100644 --- a/internal/domain/trade.go +++ b/internal/domain/trade.go @@ -24,4 +24,5 @@ type Trade struct { OpenClose string `json:"open_close,omitempty"` // "open" or "close" ContractMultiplier float64 `json:"contract_multiplier,omitempty"` Premium float64 `json:"premium,omitempty"` // price per contract + ExitReason string `json:"exit_reason,omitempty"` } diff --git a/internal/repository/postgres/polymarket_account.go b/internal/repository/postgres/polymarket_account.go index a39f1c13..6e5693a4 100644 --- a/internal/repository/postgres/polymarket_account.go +++ b/internal/repository/postgres/polymarket_account.go @@ -131,7 +131,8 @@ func (r *PolymarketAccountRepo) ListTrackedAccounts(ctx context.Context, minWinR // InsertTrades bulk-inserts trade records, ignoring duplicates. func (r *PolymarketAccountRepo) InsertTrades(ctx context.Context, trades []domain.PolymarketAccountTrade) error { - if len(trades) == 0 { + filtered, _ := filterSupportedPolymarketTrades(trades) + if len(filtered) == 0 { return nil } tx, err := r.pool.Begin(ctx) @@ -140,7 +141,7 @@ func (r *PolymarketAccountRepo) InsertTrades(ctx context.Context, trades []domai } defer func() { _ = tx.Rollback(ctx) }() - for _, t := range trades { + for _, t := range filtered { normalizedSide, err := normalizePolymarketTradeSide(t.Side) if err != nil { return fmt.Errorf("postgres: normalize trade side for %s: %w", t.AccountAddress, err) @@ -163,6 +164,19 @@ func (r *PolymarketAccountRepo) InsertTrades(ctx context.Context, trades []domai return nil } +func filterSupportedPolymarketTrades(trades []domain.PolymarketAccountTrade) ([]domain.PolymarketAccountTrade, []domain.PolymarketAccountTrade) { + filtered := make([]domain.PolymarketAccountTrade, 0, len(trades)) + skipped := make([]domain.PolymarketAccountTrade, 0) + for _, trade := range trades { + if _, err := normalizePolymarketTradeSide(trade.Side); err != nil { + skipped = append(skipped, trade) + continue + } + filtered = append(filtered, trade) + } + return filtered, skipped +} + // ListTradesByAccount returns trades for a given address within [from, to]. func (r *PolymarketAccountRepo) ListTradesByAccount(ctx context.Context, address string, from, to time.Time, limit int) ([]domain.PolymarketAccountTrade, error) { if limit <= 0 { diff --git a/internal/repository/postgres/polymarket_account_test.go b/internal/repository/postgres/polymarket_account_test.go index adb3cd28..446cebf1 100644 --- a/internal/repository/postgres/polymarket_account_test.go +++ b/internal/repository/postgres/polymarket_account_test.go @@ -1,6 +1,10 @@ package postgres -import "testing" +import ( + "testing" + + "github.com/PatrickFanella/get-rich-quick/internal/domain" +) func TestNormalizePolymarketTradeSide(t *testing.T) { t.Parallel() @@ -18,6 +22,7 @@ func TestNormalizePolymarketTradeSide(t *testing.T) { {name: "over", input: "over", want: "Over"}, {name: "under", input: "Under", want: "Under"}, {name: "invalid", input: "sideways", wantErr: true}, + {name: "display name in side field", input: "Team Spirit", wantErr: true}, } for _, tc := range tests { @@ -41,3 +46,30 @@ func TestNormalizePolymarketTradeSide(t *testing.T) { }) } } + +func TestFilterSupportedPolymarketTrades(t *testing.T) { + t.Parallel() + + trades := []domain.PolymarketAccountTrade{ + {AccountAddress: "0x1", MarketSlug: "btc", Side: "YES", Action: "buy"}, + {AccountAddress: "0x2", MarketSlug: "eth", Side: "Team Spirit", Action: "buy"}, + {AccountAddress: "0x3", MarketSlug: "sol", Side: "under", Action: "sell"}, + } + + filtered, skipped := filterSupportedPolymarketTrades(trades) + if len(filtered) != 2 { + t.Fatalf("filterSupportedPolymarketTrades() kept %d trades, want 2", len(filtered)) + } + if filtered[0].Side != "YES" { + t.Fatalf("first kept trade side = %q, want YES", filtered[0].Side) + } + if filtered[1].Side != "under" { + t.Fatalf("second kept trade side = %q, want under", filtered[1].Side) + } + if len(skipped) != 1 { + t.Fatalf("filterSupportedPolymarketTrades() skipped %d trades, want 1", len(skipped)) + } + if skipped[0].Side != "Team Spirit" { + t.Fatalf("skipped trade side = %q, want Team Spirit", skipped[0].Side) + } +} diff --git a/internal/service/backtest.go b/internal/service/backtest.go index dc925800..9e1dbfb1 100644 --- a/internal/service/backtest.go +++ b/internal/service/backtest.go @@ -16,6 +16,7 @@ import ( "github.com/PatrickFanella/get-rich-quick/internal/domain" "github.com/PatrickFanella/get-rich-quick/internal/llm" "github.com/PatrickFanella/get-rich-quick/internal/repository" + "github.com/PatrickFanella/get-rich-quick/internal/strategyscaffold" ) // BacktestService encapsulates the multi-step orchestration required to run a @@ -56,7 +57,6 @@ func NewBacktestService( // result. Returns the persisted BacktestRun on success, or a *ServiceError // for caller-visible errors. func (svc *BacktestService) RunBacktest(ctx context.Context, configID uuid.UUID, actor string) (*domain.BacktestRun, error) { - // 1. Load BacktestConfig config, err := svc.backtestConfigs.Get(ctx, configID) if err != nil { if isNotFound(err) { @@ -65,7 +65,6 @@ func (svc *BacktestService) RunBacktest(ctx context.Context, configID uuid.UUID, return nil, &ServiceError{Status: 500, Message: "failed to get backtest config"} } - // 2. Load Strategy strategy, err := svc.strategies.Get(ctx, config.StrategyID) if err != nil { if isNotFound(err) { @@ -74,7 +73,6 @@ func (svc *BacktestService) RunBacktest(ctx context.Context, configID uuid.UUID, return nil, &ServiceError{Status: 500, Message: "failed to get strategy"} } - // 3. Parse strategy.Config as JSON, extract rules_engine field var stratCfg map[string]json.RawMessage if len(strategy.Config) > 0 { if err := json.Unmarshal(strategy.Config, &stratCfg); err != nil { @@ -82,12 +80,24 @@ func (svc *BacktestService) RunBacktest(ctx context.Context, configID uuid.UUID, } } - rulesEngineRaw, ok := stratCfg["rules_engine"] - if !ok || len(rulesEngineRaw) == 0 { - return nil, &ServiceError{Status: 400, Message: "strategy config must include a \"rules_engine\" JSON key with entry/exit conditions, position sizing, and stop/take-profit rules"} + if rulesEngineRaw := stratCfg["rules_engine"]; len(rulesEngineRaw) > 0 { + return svc.runRulesEngineBacktest(ctx, config, strategy, rulesEngineRaw, actor) } - // 4. Parse rules engine config + if optionsRulesRaw := stratCfg["options_rules"]; len(optionsRulesRaw) > 0 { + return svc.runOptionsRulesBacktest(ctx, config, strategy, optionsRulesRaw, actor) + } + + return nil, &ServiceError{Status: 400, Message: "strategy config must include either a \"rules_engine\" or \"options_rules\" JSON key for backtesting"} +} + +func (svc *BacktestService) runRulesEngineBacktest( + ctx context.Context, + config *domain.BacktestConfig, + strategy *domain.Strategy, + rulesEngineRaw json.RawMessage, + actor string, +) (*domain.BacktestRun, error) { rulesConfig, err := rules.Parse(rulesEngineRaw) if err != nil { return nil, &ServiceError{Status: 400, Message: "invalid rules_engine config: " + err.Error()} @@ -96,33 +106,13 @@ func (svc *BacktestService) RunBacktest(ctx context.Context, configID uuid.UUID, return nil, &ServiceError{Status: 400, Message: "strategy must have rules_engine config for backtesting"} } - // 5. Load historical OHLCV bars — include 400 days before start for indicator warmup - if svc.dataService == nil { - return nil, &ServiceError{Status: 500, Message: "data service not configured"} - } - warmupStart := config.StartDate.AddDate(-1, -2, 0) // ~400 calendar days for SMA-200 warmup - barsMap, err := svc.dataService.DownloadHistoricalOHLCV( - ctx, - strategy.MarketType, - []string{strategy.Ticker}, - data.Timeframe1d, - warmupStart, - config.EndDate, - true, - ) - if err != nil { - return nil, &ServiceError{Status: 500, Message: "failed to load historical data: " + err.Error()} - } - - allBars := barsMap[strategy.Ticker] - if len(allBars) == 0 { - return nil, &ServiceError{Status: 400, Message: "no historical bars available for ticker " + strategy.Ticker} + allBars, svcErr := svc.loadHistoricalBars(ctx, strategy.Ticker, strategy.MarketType, config) + if svcErr != nil { + return nil, svcErr } - // 6. Build pipeline pipeline := rules.NewRulesPipeline(*rulesConfig, allBars, config.StartDate, config.Simulation.InitialCapital, agent.NoopPersister{}, nil, svc.logger) - // 7. Build orchestrator with default fill config + optional LLM reviewer orchConfig := backtest.OrchestratorConfig{ StrategyID: strategy.ID, Ticker: strategy.Ticker, @@ -145,7 +135,6 @@ func (svc *BacktestService) RunBacktest(ctx context.Context, configID uuid.UUID, return nil, &ServiceError{Status: 500, Message: "failed to create backtest orchestrator: " + err.Error()} } - // 8. Run start := time.Now() result, err := orch.Run(ctx) if err != nil { @@ -153,7 +142,6 @@ func (svc *BacktestService) RunBacktest(ctx context.Context, configID uuid.UUID, } duration := time.Since(start) - // 9. Serialize metrics/trades/equity to JSON metricsJSON, err := json.Marshal(result.Metrics) if err != nil { return nil, &ServiceError{Status: 500, Message: "failed to serialize metrics"} @@ -167,17 +155,157 @@ func (svc *BacktestService) RunBacktest(ctx context.Context, configID uuid.UUID, return nil, &ServiceError{Status: 500, Message: "failed to serialize equity curve"} } - // 10. Create BacktestRun and persist + run, svcErr := svc.persistBacktestRun(ctx, actor, config.ID, strategy.Ticker, metricsJSON, tradeLogJSON, equityCurveJSON, start, duration, result.PromptVersion, result.PromptVersionHash) + if svcErr != nil { + return nil, svcErr + } + + if strategy.Status == domain.StrategyStatusInactive && + result.Metrics.SharpeRatio > 0 && + len(result.Trades) > 0 { + strategy.Status = domain.StrategyStatusActive + if err := svc.strategies.Update(ctx, strategy); err != nil { + svc.logger.Warn("backtest: failed to auto-activate strategy", + "strategy_id", strategy.ID, "error", err) + } else { + svc.logger.Info("backtest: auto-activated strategy after passing backtest", + "strategy_id", strategy.ID, + "sharpe_ratio", result.Metrics.SharpeRatio, + "total_trades", len(result.Trades), + ) + } + } + + return &run, nil +} + +func (svc *BacktestService) runOptionsRulesBacktest( + ctx context.Context, + config *domain.BacktestConfig, + strategy *domain.Strategy, + optionsRulesRaw json.RawMessage, + actor string, +) (*domain.BacktestRun, error) { + optionsConfig, err := rules.ParseOptions(optionsRulesRaw) + if err != nil { + return nil, &ServiceError{Status: 400, Message: "invalid options_rules config: " + err.Error()} + } + if optionsConfig == nil { + return nil, &ServiceError{Status: 400, Message: "strategy must have options_rules config for backtesting"} + } + + allBars, svcErr := svc.loadHistoricalBars(ctx, strategy.Ticker, domain.MarketTypeStock, config) + if svcErr != nil { + return nil, svcErr + } + + start := time.Now() + summary, err := strategyscaffold.RunOptionsPaperBacktest( + ctx, + strategy.Ticker, + allBars, + config.StartDate, + config.EndDate, + config.Simulation.InitialCapital, + svc.logger, + ) + if err != nil { + return nil, &ServiceError{Status: 500, Message: "backtest execution failed: " + err.Error()} + } + duration := time.Since(start) + + metricsJSON, err := json.Marshal(summary.Metrics) + if err != nil { + return nil, &ServiceError{Status: 500, Message: "failed to serialize metrics"} + } + tradeLogJSON, err := json.Marshal(summary.Trades) + if err != nil { + return nil, &ServiceError{Status: 500, Message: "failed to serialize trade log"} + } + equityCurveJSON, err := json.Marshal(summary.EquityCurve) + if err != nil { + return nil, &ServiceError{Status: 500, Message: "failed to serialize equity curve"} + } + + run, svcErr := svc.persistBacktestRun(ctx, actor, config.ID, strategy.Ticker, metricsJSON, tradeLogJSON, equityCurveJSON, start, duration, "options-rules-v1", analysts.CurrentPromptVersionHash()) + if svcErr != nil { + return nil, svcErr + } + + if strategy.Status == domain.StrategyStatusInactive && + summary.Validation != nil && + summary.Validation.Passed && + summary.Metrics.SharpeRatio > 0 && + len(summary.Trades) > 0 { + strategy.Status = domain.StrategyStatusActive + if err := svc.strategies.Update(ctx, strategy); err != nil { + svc.logger.Warn("backtest: failed to auto-activate strategy", + "strategy_id", strategy.ID, "error", err) + } else { + svc.logger.Info("backtest: auto-activated options strategy after passing backtest", + "strategy_id", strategy.ID, + "sharpe_ratio", summary.Metrics.SharpeRatio, + "oos_ratio", summary.Validation.OOSRatio, + ) + } + } + + _ = optionsConfig + return &run, nil +} + +func (svc *BacktestService) loadHistoricalBars( + ctx context.Context, + ticker string, + marketType domain.MarketType, + config *domain.BacktestConfig, +) ([]domain.OHLCV, *ServiceError) { + if svc.dataService == nil { + return nil, &ServiceError{Status: 500, Message: "data service not configured"} + } + warmupStart := config.StartDate.AddDate(-1, -2, 0) + barsMap, err := svc.dataService.DownloadHistoricalOHLCV( + ctx, + marketType, + []string{ticker}, + data.Timeframe1d, + warmupStart, + config.EndDate, + true, + ) + if err != nil { + return nil, &ServiceError{Status: 500, Message: "failed to load historical data: " + err.Error()} + } + allBars := barsMap[ticker] + if len(allBars) == 0 { + return nil, &ServiceError{Status: 400, Message: "no historical bars available for ticker " + ticker} + } + return allBars, nil +} + +func (svc *BacktestService) persistBacktestRun( + ctx context.Context, + actor string, + configID uuid.UUID, + ticker string, + metricsJSON json.RawMessage, + tradeLogJSON json.RawMessage, + equityCurveJSON json.RawMessage, + start time.Time, + duration time.Duration, + promptVersion string, + promptVersionHash string, +) (domain.BacktestRun, *ServiceError) { run := domain.BacktestRun{ ID: uuid.New(), - BacktestConfigID: config.ID, + BacktestConfigID: configID, Metrics: metricsJSON, TradeLog: tradeLogJSON, EquityCurve: equityCurveJSON, RunTimestamp: start.UTC(), Duration: duration, - PromptVersion: result.PromptVersion, - PromptVersionHash: result.PromptVersionHash, + PromptVersion: promptVersion, + PromptVersionHash: promptVersionHash, } if run.PromptVersionHash == "" { @@ -188,30 +316,13 @@ func (svc *BacktestService) RunBacktest(ctx context.Context, configID uuid.UUID, } if err := svc.backtestRuns.Create(ctx, &run); err != nil { - return nil, &ServiceError{Status: 500, Message: "failed to persist backtest run: " + err.Error()} + return domain.BacktestRun{}, &ServiceError{Status: 500, Message: "failed to persist backtest run: " + err.Error()} } svc.writeAuditLog(ctx, actor, "backtest.run", "backtest_config", &configID, - map[string]any{"ticker": strategy.Ticker, "run_id": run.ID}) - - // Auto-activate inactive strategies that pass backtesting. - if strategy.Status == domain.StrategyStatusInactive && - result.Metrics.SharpeRatio > 0 && - len(result.Trades) > 0 { - strategy.Status = domain.StrategyStatusActive - if err := svc.strategies.Update(ctx, strategy); err != nil { - svc.logger.Warn("backtest: failed to auto-activate strategy", - "strategy_id", strategy.ID, "error", err) - } else { - svc.logger.Info("backtest: auto-activated strategy after passing backtest", - "strategy_id", strategy.ID, - "sharpe_ratio", result.Metrics.SharpeRatio, - "total_trades", len(result.Trades), - ) - } - } + map[string]any{"ticker": ticker, "run_id": run.ID}) - return &run, nil + return run, nil } func (svc *BacktestService) writeAuditLog(ctx context.Context, actor, eventType, entityType string, entityID *uuid.UUID, details any) { diff --git a/internal/service/backtest_scaffold_test.go b/internal/service/backtest_scaffold_test.go new file mode 100644 index 00000000..dd16b9db --- /dev/null +++ b/internal/service/backtest_scaffold_test.go @@ -0,0 +1,349 @@ +package service_test + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/PatrickFanella/get-rich-quick/internal/backtest" + "github.com/PatrickFanella/get-rich-quick/internal/config" + "github.com/PatrickFanella/get-rich-quick/internal/data" + "github.com/PatrickFanella/get-rich-quick/internal/domain" + "github.com/PatrickFanella/get-rich-quick/internal/repository" + "github.com/PatrickFanella/get-rich-quick/internal/service" + "github.com/PatrickFanella/get-rich-quick/internal/strategyscaffold" +) + +func TestRunBacktestRejectsStrategyWithoutSupportedBacktestConfig(t *testing.T) { + strategy := domain.Strategy{ + ID: uuid.New(), + Name: "unsupported", + Ticker: "QQQ", + MarketType: domain.MarketTypeOptions, + Status: domain.StrategyStatusActive, + Config: domain.StrategyConfig(`{"unsupported":true}`), + } + + cfg := domain.BacktestConfig{ + ID: uuid.New(), + StrategyID: strategy.ID, + Name: "options-paper-validation", + StartDate: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), + EndDate: time.Date(2024, 6, 28, 0, 0, 0, 0, time.UTC), + Simulation: domain.BacktestSimulationParameters{InitialCapital: 100_000}, + } + + svc := service.NewBacktestService( + stubBacktestConfigRepo{config: &cfg}, + &recordingBacktestRunRepo{}, + &stubStrategyRepo{strategy: &strategy}, + nil, + nil, + nil, + slog.Default(), + ) + + run, err := svc.RunBacktest(context.Background(), cfg.ID, "tester") + if err == nil { + t.Fatal("RunBacktest() error = nil, want error") + } + if run != nil { + t.Fatalf("RunBacktest() run = %#v, want nil", run) + } + var svcErr *service.ServiceError + if !errors.As(err, &svcErr) { + t.Fatalf("error type = %T, want *service.ServiceError", err) + } + if svcErr.Status != 400 { + t.Fatalf("ServiceError.Status = %d, want 400", svcErr.Status) + } + if svcErr.Message != "strategy config must include either a \"rules_engine\" or \"options_rules\" JSON key for backtesting" { + t.Fatalf("ServiceError.Message = %q", svcErr.Message) + } +} + +func TestRunBacktestExecutesOptionsRulesAndPersistsRun(t *testing.T) { + optionsStrategy, err := strategyscaffold.OptionsPaperBullPutSpread("QQQ") + if err != nil { + t.Fatalf("OptionsPaperBullPutSpread() error = %v", err) + } + optionsStrategy.Status = domain.StrategyStatusInactive + + cfg := domain.BacktestConfig{ + ID: uuid.New(), + StrategyID: optionsStrategy.ID, + Name: "options-paper-validation", + StartDate: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), + EndDate: time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC), + Simulation: domain.BacktestSimulationParameters{InitialCapital: 100_000}, + } + + marketDataRepo := &stubMarketDataRepo{bars: syntheticOHLCVSeries(cfg.StartDate.AddDate(-1, 0, 0), 520)} + provider := &stubDataProvider{bars: marketDataRepo.bars} + dataSvc := data.NewDataService(config.Config{}, &data.ProviderRegistry{ + Yahoo: func(data.ProviderConfig) data.DataProvider { return provider }, + }, marketDataRepo, slog.Default(), nil) + dataSvc.SetNowFunc(func() time.Time { return cfg.EndDate }) + + runRepo := &recordingBacktestRunRepo{} + strategyRepo := &stubStrategyRepo{strategy: &optionsStrategy} + auditRepo := &recordingAuditLogRepo{} + + svc := service.NewBacktestService( + stubBacktestConfigRepo{config: &cfg}, + runRepo, + strategyRepo, + auditRepo, + dataSvc, + nil, + slog.Default(), + ) + + run, err := svc.RunBacktest(context.Background(), cfg.ID, "tester") + if err != nil { + t.Fatalf("RunBacktest() error = %v", err) + } + if run == nil { + t.Fatal("RunBacktest() run = nil") + } + if run.BacktestConfigID != cfg.ID { + t.Fatalf("BacktestConfigID = %s, want %s", run.BacktestConfigID, cfg.ID) + } + if len(run.Metrics) == 0 || len(run.TradeLog) == 0 || len(run.EquityCurve) == 0 { + t.Fatalf("persisted run fields missing: metrics=%d trade_log=%d equity_curve=%d", len(run.Metrics), len(run.TradeLog), len(run.EquityCurve)) + } + if run.PromptVersion != "options-rules-v1" { + t.Fatalf("PromptVersion = %q, want %q", run.PromptVersion, "options-rules-v1") + } + if run.PromptVersionHash == "" { + t.Fatal("PromptVersionHash empty, want non-empty") + } + if runRepo.created == nil { + t.Fatal("backtest run was not persisted") + } + if strategyRepo.updated != nil { + t.Fatalf("unexpected strategy auto-activation: %#v", strategyRepo.updated) + } + if len(auditRepo.entries) != 1 { + t.Fatalf("audit entries = %d, want 1", len(auditRepo.entries)) + } + + var metrics map[string]any + if err := json.Unmarshal(run.Metrics, &metrics); err != nil { + t.Fatalf("json.Unmarshal(metrics) error = %v", err) + } + if _, ok := metrics["total_bars"]; !ok { + t.Fatalf("metrics JSON missing total_bars: %v", metrics) + } + + var trades []domain.Trade + if err := json.Unmarshal(run.TradeLog, &trades); err != nil { + t.Fatalf("json.Unmarshal(trade_log) error = %v", err) + } + if len(trades) == 0 { + t.Fatal("trade_log empty, want full options trades") + } + if trades[0].AssetClass != domain.AssetClassOption { + t.Fatalf("first trade asset_class = %q, want %q", trades[0].AssetClass, domain.AssetClassOption) + } + if trades[0].Premium == 0 { + t.Fatal("first trade premium = 0, want non-zero") + } + + var curve []backtest.EquityPoint + if err := json.Unmarshal(run.EquityCurve, &curve); err != nil { + t.Fatalf("json.Unmarshal(equity_curve) error = %v", err) + } + if len(curve) < 10 { + t.Fatalf("equity_curve len = %d, want >= 10", len(curve)) + } + if curve[0].Timestamp.IsZero() || curve[len(curve)-1].Timestamp.IsZero() { + t.Fatal("equity_curve timestamps must be populated") + } +} + +func TestStockScaffoldConfigIncludesRulesEngineForBacktestService(t *testing.T) { + strategy, err := strategyscaffold.StockPaperMovingAverageCrossover("SPY") + if err != nil { + t.Fatalf("StockPaperMovingAverageCrossover() error = %v", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(strategy.Config, &raw); err != nil { + t.Fatalf("json.Unmarshal(config) error = %v", err) + } + if len(raw["rules_engine"]) == 0 { + t.Fatal("config.rules_engine is empty, want scaffolded backtest rules") + } +} + +type stubBacktestConfigRepo struct { + config *domain.BacktestConfig +} + +func (s stubBacktestConfigRepo) Create(context.Context, *domain.BacktestConfig) error { return nil } +func (s stubBacktestConfigRepo) Get(context.Context, uuid.UUID) (*domain.BacktestConfig, error) { + if s.config == nil { + return nil, repository.ErrNotFound + } + return s.config, nil +} +func (s stubBacktestConfigRepo) List(context.Context, repository.BacktestConfigFilter, int, int) ([]domain.BacktestConfig, error) { + return nil, nil +} +func (s stubBacktestConfigRepo) Count(context.Context, repository.BacktestConfigFilter) (int, error) { + return 0, nil +} +func (s stubBacktestConfigRepo) Update(context.Context, *domain.BacktestConfig) error { return nil } +func (s stubBacktestConfigRepo) Delete(context.Context, uuid.UUID) error { return nil } + +type recordingBacktestRunRepo struct { + created *domain.BacktestRun +} + +func (r *recordingBacktestRunRepo) Create(_ context.Context, run *domain.BacktestRun) error { + copyRun := *run + r.created = ©Run + return nil +} +func (recordingBacktestRunRepo) Get(context.Context, uuid.UUID) (*domain.BacktestRun, error) { + return nil, repository.ErrNotFound +} +func (recordingBacktestRunRepo) List(context.Context, repository.BacktestRunFilter, int, int) ([]domain.BacktestRun, error) { + return nil, nil +} +func (recordingBacktestRunRepo) Count(context.Context, repository.BacktestRunFilter) (int, error) { + return 0, nil +} + +type stubStrategyRepo struct { + strategy *domain.Strategy + updated *domain.Strategy +} + +func (s *stubStrategyRepo) Create(context.Context, *domain.Strategy) error { return nil } +func (s *stubStrategyRepo) Get(context.Context, uuid.UUID) (*domain.Strategy, error) { + if s.strategy == nil { + return nil, repository.ErrNotFound + } + copyStrategy := *s.strategy + return ©Strategy, nil +} +func (s *stubStrategyRepo) List(context.Context, repository.StrategyFilter, int, int) ([]domain.Strategy, error) { + return nil, nil +} +func (s *stubStrategyRepo) Count(context.Context, repository.StrategyFilter) (int, error) { + return 0, nil +} +func (s *stubStrategyRepo) Update(_ context.Context, strategy *domain.Strategy) error { + copyStrategy := *strategy + s.updated = ©Strategy + if s.strategy != nil { + *s.strategy = copyStrategy + } + return nil +} +func (s *stubStrategyRepo) Delete(context.Context, uuid.UUID) error { return nil } +func (s *stubStrategyRepo) UpdateThesis(context.Context, uuid.UUID, json.RawMessage) error { + return nil +} +func (s *stubStrategyRepo) GetThesisRaw(context.Context, uuid.UUID) (json.RawMessage, error) { + return nil, nil +} + +type recordingAuditLogRepo struct { + entries []domain.AuditLogEntry +} + +func (r *recordingAuditLogRepo) Create(_ context.Context, entry *domain.AuditLogEntry) error { + copyEntry := *entry + r.entries = append(r.entries, copyEntry) + return nil +} +func (recordingAuditLogRepo) Query(context.Context, repository.AuditLogFilter, int, int) ([]domain.AuditLogEntry, error) { + return nil, nil +} +func (recordingAuditLogRepo) Count(context.Context, repository.AuditLogFilter) (int, error) { + return 0, nil +} + +type stubDataProvider struct { + bars []domain.OHLCV +} + +func (s *stubDataProvider) GetOHLCV(context.Context, string, data.Timeframe, time.Time, time.Time) ([]domain.OHLCV, error) { + return append([]domain.OHLCV(nil), s.bars...), nil +} +func (s *stubDataProvider) GetFundamentals(context.Context, string) (data.Fundamentals, error) { + return data.Fundamentals{}, data.ErrNotImplemented +} +func (s *stubDataProvider) GetNews(context.Context, string, time.Time, time.Time) ([]data.NewsArticle, error) { + return nil, data.ErrNotImplemented +} +func (s *stubDataProvider) GetSocialSentiment(context.Context, string, time.Time, time.Time) ([]data.SocialSentiment, error) { + return nil, data.ErrNotImplemented +} + +type stubMarketDataRepo struct { + bars []domain.OHLCV +} + +func (s *stubMarketDataRepo) Get(context.Context, repository.MarketDataCacheKey) (*domain.MarketData, error) { + return nil, nil +} +func (s *stubMarketDataRepo) Set(context.Context, *domain.MarketData) error { return nil } +func (s *stubMarketDataRepo) Expire(context.Context, repository.MarketDataCacheExpireFilter) error { + return nil +} +func (s *stubMarketDataRepo) UpsertHistoricalOHLCV(context.Context, []domain.HistoricalOHLCV) error { + return nil +} +func (s *stubMarketDataRepo) ListHistoricalOHLCV(context.Context, repository.HistoricalOHLCVFilter) ([]domain.HistoricalOHLCV, error) { + result := make([]domain.HistoricalOHLCV, 0, len(s.bars)) + for _, bar := range s.bars { + result = append(result, domain.HistoricalOHLCV{ + Ticker: "QQQ", + Provider: "stock-chain", + Timeframe: data.Timeframe1d.String(), + Timestamp: bar.Timestamp, + Open: bar.Open, + High: bar.High, + Low: bar.Low, + Close: bar.Close, + Volume: bar.Volume, + }) + } + return result, nil +} +func (s *stubMarketDataRepo) UpsertHistoricalOHLCVCoverage(context.Context, domain.HistoricalOHLCVCoverage) error { + return nil +} +func (s *stubMarketDataRepo) ListHistoricalOHLCVCoverage(context.Context, repository.HistoricalOHLCVCoverageFilter) ([]domain.HistoricalOHLCVCoverage, error) { + return nil, nil +} + +func syntheticOHLCVSeries(start time.Time, count int) []domain.OHLCV { + bars := make([]domain.OHLCV, 0, count) + price := 100.0 + for i := 0; i < count; i++ { + if i%29 == 0 { + price -= 1.1 + } else { + price += 0.38 + } + bars = append(bars, domain.OHLCV{ + Timestamp: start.AddDate(0, 0, i), + Open: price - 0.4, + High: price + 0.7, + Low: price - 0.9, + Close: price, + Volume: 2_000_000 + float64(i*1000), + }) + } + return bars +} diff --git a/internal/strategyscaffold/README.md b/internal/strategyscaffold/README.md new file mode 100644 index 00000000..5e5151a9 --- /dev/null +++ b/internal/strategyscaffold/README.md @@ -0,0 +1,26 @@ +# Strategy scaffold package + +This package provides repo-native paper-trading scaffolds for one stock strategy and one options strategy. + +Included scaffolds +- Stock: moving-average trend scaffold using `rules_engine` +- Options: bull put spread premium-selling scaffold using `options_rules` + +Usage pattern +1. Create the stock strategy scaffold: + - `strategyscaffold.StockPaperMovingAverageCrossover("SPY")` +2. Create a reusable stock backtest config: + - `strategyscaffold.StockPaperBacktestConfig(strategy, start, end, 100000)` +3. Create the options strategy scaffold: + - `strategyscaffold.OptionsPaperBullPutSpread("QQQ")` +4. Run an options synthetic backtest/validation summary over historical bars: + - `strategyscaffold.RunOptionsPaperBacktest(ctx, "QQQ", bars, start, end, 100000, logger)` + +Why this exists +- The existing `service.BacktestService` currently backtests stock `rules_engine` configs. +- Options paper trading/backtesting already exists in the repo through the options discovery/sweep/validation path. +- This package gives one tested, concrete strategy for each path without introducing a new framework. + +Current limitation +- `service.BacktestService.RunBacktest` still requires `rules_engine`; it does not execute `options_rules` directly. +- For options, use the existing synthetic options sweep/validation path wrapped by `RunOptionsPaperBacktest`. diff --git a/internal/strategyscaffold/strategyscaffold.go b/internal/strategyscaffold/strategyscaffold.go new file mode 100644 index 00000000..8761a7d5 --- /dev/null +++ b/internal/strategyscaffold/strategyscaffold.go @@ -0,0 +1,255 @@ +package strategyscaffold + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/PatrickFanella/get-rich-quick/internal/agent/rules" + "github.com/PatrickFanella/get-rich-quick/internal/backtest" + discoverypkg "github.com/PatrickFanella/get-rich-quick/internal/discovery" + optionsdiscovery "github.com/PatrickFanella/get-rich-quick/internal/discovery/options" + "github.com/PatrickFanella/get-rich-quick/internal/domain" +) + +const ( + defaultPaperScheduleWeekdays = "0 10 * * 1-5" + defaultPaperOptionsSchedule = "0 11,14 * * 1-5" +) + +type OptionsBacktestSummary struct { + Strategy domain.Strategy + Metrics backtest.Metrics + Trades []domain.Trade + EquityCurve []backtest.EquityPoint + Validation *discoverypkg.ValidationResult + ScaffoldStart time.Time + ScaffoldEnd time.Time +} + +func StockPaperMovingAverageCrossover(ticker string) (domain.Strategy, error) { + ticker = normalizeTicker(ticker) + if ticker == "" { + return domain.Strategy{}, fmt.Errorf("stock scaffold: ticker is required") + } + + rulesCfg := rules.RulesEngineConfig{ + Version: 1, + Name: "paper-sma-crossover", + Description: "Paper-trading stock trend scaffold using 20/50 SMA alignment with 200-day trend filter.", + Entry: rules.ConditionGroup{ + Operator: "AND", + Conditions: []rules.Condition{ + {Field: "sma_20", Op: "gt", Ref: "sma_50"}, + {Field: "close", Op: "gt", Ref: "sma_200"}, + }, + }, + Exit: rules.ConditionGroup{ + Operator: "OR", + Conditions: []rules.Condition{ + {Field: "sma_20", Op: "lt", Ref: "sma_50"}, + {Field: "close", Op: "lt", Ref: "sma_200"}, + }, + }, + PositionSizing: rules.SizingConfig{Method: "fixed_fraction", FractionPct: 10}, + StopLoss: rules.StopLossConfig{Method: "atr_multiple", ATRMultiplier: 2}, + TakeProfit: rules.TakeProfitConfig{Method: "risk_reward", Ratio: 3}, + Filters: &rules.FilterConfig{MinVolume: 1_000_000}, + } + if err := rules.Validate(&rulesCfg); err != nil { + return domain.Strategy{}, fmt.Errorf("stock scaffold: %w", err) + } + + config, err := marshalConfig(map[string]any{"rules_engine": rulesCfg}) + if err != nil { + return domain.Strategy{}, err + } + + strategy := domain.Strategy{ + ID: uuid.New(), + Name: fmt.Sprintf("paper stock: %s sma trend", ticker), + Description: "Rule-based stock paper-trading scaffold for walk-forward backtests and scheduled paper runs.", + Ticker: ticker, + MarketType: domain.MarketTypeStock, + ScheduleCron: defaultPaperScheduleWeekdays, + Config: config, + Status: domain.StrategyStatusActive, + IsPaper: true, + } + return strategy, strategy.Validate() +} + +func OptionsPaperBullPutSpread(ticker string) (domain.Strategy, error) { + ticker = normalizeTicker(ticker) + if ticker == "" { + return domain.Strategy{}, fmt.Errorf("options scaffold: ticker is required") + } + + optionsCfg := rules.OptionsRulesConfig{ + Version: 1, + StrategyType: domain.StrategyBullPutSpread, + Underlying: ticker, + Entry: rules.ConditionGroup{ + Operator: "AND", + Conditions: []rules.Condition{ + {Field: "close", Op: "gt", Ref: "sma_50"}, + {Field: "iv_rank", Op: "gt", Value: float64Ptr(30)}, + }, + }, + Exit: rules.ConditionGroup{ + Operator: "OR", + Conditions: []rules.Condition{ + {Field: "close", Op: "lt", Ref: "sma_50"}, + {Field: "pnl_pct", Op: "gte", Value: float64Ptr(50)}, + }, + }, + LegSelection: map[string]rules.LegSelector{ + "short_put": { + OptionType: domain.OptionTypePut, + DeltaTarget: 0.25, + DTEMin: 30, + DTEMax: 45, + Side: domain.OrderSideSell, + Intent: domain.PositionIntentSellToOpen, + Ratio: 1, + }, + "long_put": { + OptionType: domain.OptionTypePut, + DeltaTarget: 0.10, + DTEMin: 30, + DTEMax: 45, + Side: domain.OrderSideBuy, + Intent: domain.PositionIntentBuyToOpen, + Ratio: 1, + }, + }, + PositionSizing: rules.OptionsSizingConfig{Method: "max_risk", MaxRiskUSD: 1000}, + Management: rules.OptionsManagement{CloseAtProfitPct: 50, CloseAtDTE: 7, StopLossPct: 100}, + } + if err := rules.ValidateOptions(&optionsCfg); err != nil { + return domain.Strategy{}, fmt.Errorf("options scaffold: %w", err) + } + + config, err := marshalConfig(map[string]any{"options_rules": optionsCfg}) + if err != nil { + return domain.Strategy{}, err + } + + strategy := domain.Strategy{ + ID: uuid.New(), + Name: fmt.Sprintf("paper options: %s bull put", ticker), + Description: "Rule-based options paper-trading scaffold for premium-selling bull put spread validation.", + Ticker: ticker, + MarketType: domain.MarketTypeOptions, + ScheduleCron: defaultPaperOptionsSchedule, + Config: config, + Status: domain.StrategyStatusActive, + IsPaper: true, + } + return strategy, strategy.Validate() +} + +func StockPaperBacktestConfig(strategy domain.Strategy, startDate, endDate time.Time, initialCapital float64) (domain.BacktestConfig, error) { + if strategy.MarketType != domain.MarketTypeStock { + return domain.BacktestConfig{}, fmt.Errorf("stock scaffold: strategy market_type must be %q", domain.MarketTypeStock) + } + if !endDate.After(startDate) { + return domain.BacktestConfig{}, fmt.Errorf("stock scaffold: end_date must be after start_date") + } + if initialCapital <= 0 { + return domain.BacktestConfig{}, fmt.Errorf("stock scaffold: initial_capital must be > 0") + } + cfg := domain.BacktestConfig{ + ID: uuid.New(), + StrategyID: strategy.ID, + Name: fmt.Sprintf("paper backtest: %s stock trend", strategy.Ticker), + Description: "Reusable backtest config for the stock paper-trading scaffold.", + StartDate: startDate, + EndDate: endDate, + Simulation: domain.BacktestSimulationParameters{InitialCapital: initialCapital, MaxVolumePct: 0.1}, + } + return cfg, cfg.Validate() +} + +func RunOptionsPaperBacktest(ctx context.Context, ticker string, bars []domain.OHLCV, startDate, endDate time.Time, initialCash float64, logger *slog.Logger) (*OptionsBacktestSummary, error) { + strategy, err := OptionsPaperBullPutSpread(ticker) + if err != nil { + return nil, err + } + if len(bars) == 0 { + return nil, fmt.Errorf("options scaffold: bars are required") + } + if !endDate.After(startDate) { + return nil, fmt.Errorf("options scaffold: end_date must be after start_date") + } + if initialCash <= 0 { + return nil, fmt.Errorf("options scaffold: initial_cash must be > 0") + } + if logger == nil { + logger = slog.Default() + } + + var payload map[string]json.RawMessage + if err := json.Unmarshal(strategy.Config, &payload); err != nil { + return nil, fmt.Errorf("options scaffold: parse strategy config: %w", err) + } + optCfg, err := rules.ParseOptions(payload["options_rules"]) + if err != nil { + return nil, fmt.Errorf("options scaffold: parse options_rules: %w", err) + } + if optCfg == nil { + return nil, fmt.Errorf("options scaffold: options_rules config missing") + } + + sweepCfg := optionsdiscovery.OptionsSweepConfig{ + Ticker: strategy.Ticker, + Bars: bars, + StartDate: startDate, + EndDate: endDate, + InitialCash: initialCash, + Variations: 0, + } + results, err := optionsdiscovery.RunOptionsSweep(ctx, *optCfg, sweepCfg, discoverypkg.DefaultScoringConfig(), logger) + if err != nil { + return nil, fmt.Errorf("options scaffold: run sweep: %w", err) + } + if len(results) == 0 { + return nil, fmt.Errorf("options scaffold: sweep returned no results") + } + + validation, err := optionsdiscovery.ValidateOptionsOutOfSample(ctx, discoverypkg.ValidationConfig{CalibrationMonths: 6, TestMonths: 3, MinOOSRatio: 0.5}, bars, results[0].Config, startDate, endDate, initialCash, logger) + if err != nil { + return nil, fmt.Errorf("options scaffold: validate out of sample: %w", err) + } + + return &OptionsBacktestSummary{ + Strategy: strategy, + Metrics: results[0].Metrics, + Trades: append([]domain.Trade(nil), results[0].Trades...), + EquityCurve: append([]backtest.EquityPoint(nil), results[0].EquityCurve...), + Validation: validation, + ScaffoldStart: startDate, + ScaffoldEnd: endDate, + }, nil +} + +func normalizeTicker(ticker string) string { + return strings.ToUpper(strings.TrimSpace(ticker)) +} + +func marshalConfig(v any) (domain.StrategyConfig, error) { + payload, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("strategy scaffold: marshal config: %w", err) + } + return domain.StrategyConfig(payload), nil +} + +func float64Ptr(v float64) *float64 { + return &v +} diff --git a/internal/strategyscaffold/strategyscaffold_test.go b/internal/strategyscaffold/strategyscaffold_test.go new file mode 100644 index 00000000..9bfe92f4 --- /dev/null +++ b/internal/strategyscaffold/strategyscaffold_test.go @@ -0,0 +1,177 @@ +package strategyscaffold_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/PatrickFanella/get-rich-quick/internal/agent/rules" + "github.com/PatrickFanella/get-rich-quick/internal/backtest" + "github.com/PatrickFanella/get-rich-quick/internal/domain" + "github.com/PatrickFanella/get-rich-quick/internal/strategyscaffold" +) + +func TestStockPaperMovingAverageCrossoverProducesValidPaperStrategy(t *testing.T) { + strategy, err := strategyscaffold.StockPaperMovingAverageCrossover(" spy ") + if err != nil { + t.Fatalf("StockPaperMovingAverageCrossover() error = %v", err) + } + if err := strategy.Validate(); err != nil { + t.Fatalf("strategy.Validate() error = %v", err) + } + if strategy.Ticker != "SPY" { + t.Fatalf("Ticker = %q, want %q", strategy.Ticker, "SPY") + } + if strategy.MarketType != domain.MarketTypeStock { + t.Fatalf("MarketType = %q, want %q", strategy.MarketType, domain.MarketTypeStock) + } + if !strategy.IsPaper { + t.Fatal("IsPaper = false, want true") + } + + var cfg map[string]json.RawMessage + if err := json.Unmarshal(strategy.Config, &cfg); err != nil { + t.Fatalf("json.Unmarshal(config) error = %v", err) + } + parsed, err := rules.Parse(cfg["rules_engine"]) + if err != nil { + t.Fatalf("rules.Parse(rules_engine) error = %v", err) + } + if parsed == nil { + t.Fatal("rules.Parse(rules_engine) = nil, want config") + } + if parsed.PositionSizing.Method != "fixed_fraction" { + t.Fatalf("PositionSizing.Method = %q, want %q", parsed.PositionSizing.Method, "fixed_fraction") + } + if parsed.StopLoss.Method != "atr_multiple" { + t.Fatalf("StopLoss.Method = %q, want %q", parsed.StopLoss.Method, "atr_multiple") + } +} + +func TestStockPaperBacktestConfigProducesValidConfig(t *testing.T) { + strategy, err := strategyscaffold.StockPaperMovingAverageCrossover("SPY") + if err != nil { + t.Fatalf("StockPaperMovingAverageCrossover() error = %v", err) + } + cfg, err := strategyscaffold.StockPaperBacktestConfig( + strategy, + time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), + time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC), + 100_000, + ) + if err != nil { + t.Fatalf("StockPaperBacktestConfig() error = %v", err) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("cfg.Validate() error = %v", err) + } + if cfg.StrategyID != strategy.ID { + t.Fatalf("StrategyID = %s, want %s", cfg.StrategyID, strategy.ID) + } + if cfg.Simulation.InitialCapital != 100_000 { + t.Fatalf("InitialCapital = %v, want 100000", cfg.Simulation.InitialCapital) + } +} + +func TestOptionsPaperBullPutSpreadProducesValidPaperStrategy(t *testing.T) { + strategy, err := strategyscaffold.OptionsPaperBullPutSpread(" qqq ") + if err != nil { + t.Fatalf("OptionsPaperBullPutSpread() error = %v", err) + } + if err := strategy.Validate(); err != nil { + t.Fatalf("strategy.Validate() error = %v", err) + } + if strategy.Ticker != "QQQ" { + t.Fatalf("Ticker = %q, want %q", strategy.Ticker, "QQQ") + } + if strategy.MarketType != domain.MarketTypeOptions { + t.Fatalf("MarketType = %q, want %q", strategy.MarketType, domain.MarketTypeOptions) + } + if !strategy.IsPaper { + t.Fatal("IsPaper = false, want true") + } + + var cfg map[string]json.RawMessage + if err := json.Unmarshal(strategy.Config, &cfg); err != nil { + t.Fatalf("json.Unmarshal(config) error = %v", err) + } + parsed, err := rules.ParseOptions(cfg["options_rules"]) + if err != nil { + t.Fatalf("rules.ParseOptions(options_rules) error = %v", err) + } + if parsed == nil { + t.Fatal("rules.ParseOptions(options_rules) = nil, want config") + } + if parsed.StrategyType != domain.StrategyBullPutSpread { + t.Fatalf("StrategyType = %q, want %q", parsed.StrategyType, domain.StrategyBullPutSpread) + } + if parsed.PositionSizing.Method != "max_risk" { + t.Fatalf("PositionSizing.Method = %q, want %q", parsed.PositionSizing.Method, "max_risk") + } + if parsed.Management.CloseAtProfitPct != 50 { + t.Fatalf("CloseAtProfitPct = %v, want 50", parsed.Management.CloseAtProfitPct) + } +} + +func TestRunOptionsPaperBacktestReturnsSummary(t *testing.T) { + bars := syntheticBars(340) + startDate := bars[40].Timestamp + endDate := bars[len(bars)-1].Timestamp + + summary, err := strategyscaffold.RunOptionsPaperBacktest(context.Background(), "QQQ", bars, startDate, endDate, 100_000, nil) + if err != nil { + t.Fatalf("RunOptionsPaperBacktest() error = %v", err) + } + if summary == nil { + t.Fatal("RunOptionsPaperBacktest() summary = nil") + } + if summary.Strategy.MarketType != domain.MarketTypeOptions { + t.Fatalf("Strategy.MarketType = %q, want %q", summary.Strategy.MarketType, domain.MarketTypeOptions) + } + if summary.Validation == nil { + t.Fatal("Validation = nil, want result") + } + if summary.Metrics.TotalBars == 0 { + t.Fatal("Metrics.TotalBars = 0, want non-zero") + } + if len(summary.Trades) == 0 { + t.Fatal("Trades = 0, want non-zero") + } + if len(summary.EquityCurve) == 0 { + t.Fatal("EquityCurve = 0, want non-zero") + } +} + +func TestScaffoldsRejectBlankTicker(t *testing.T) { + if _, err := strategyscaffold.StockPaperMovingAverageCrossover(" "); err == nil { + t.Fatal("StockPaperMovingAverageCrossover(blank) error = nil, want error") + } + if _, err := strategyscaffold.OptionsPaperBullPutSpread(""); err == nil { + t.Fatal("OptionsPaperBullPutSpread(blank) error = nil, want error") + } +} + +func syntheticBars(count int) []domain.OHLCV { + bars := make([]domain.OHLCV, 0, count) + base := time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC) + price := 100.0 + for i := 0; i < count; i++ { + if i%23 == 0 { + price -= 1.2 + } else { + price += 0.45 + } + bars = append(bars, domain.OHLCV{ + Timestamp: base.AddDate(0, 0, i), + Open: price - 0.3, + High: price + 0.8, + Low: price - 0.9, + Close: price, + Volume: 1_500_000 + float64(i*1000), + }) + } + return bars +} + +var _ = backtest.EquityPoint{} diff --git a/web/src/components/dashboard/activity-feed.test.tsx b/web/src/components/dashboard/activity-feed.test.tsx index 03424d36..9a43dea5 100644 --- a/web/src/components/dashboard/activity-feed.test.tsx +++ b/web/src/components/dashboard/activity-feed.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { act, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ActivityFeed } from '@/components/dashboard/activity-feed' @@ -64,4 +64,26 @@ describe('ActivityFeed', () => { expect(screen.getByText('Connected')).toBeInTheDocument() }) }) + + it('renders pipeline health websocket events with a human-friendly label', async () => { + render() + + const ws = MockWebSocket.instances[0] + expect(ws).toBeDefined() + + await act(async () => { + ws?.open() + ws?.onmessage?.( + new MessageEvent('message', { + data: JSON.stringify({ + type: 'pipeline_health', + strategy_id: '11111111-1111-1111-1111-111111111111', + timestamp: '2026-04-21T13:45:00.000Z', + }), + }), + ) + }) + + expect(screen.getByText('Pipeline health')).toBeInTheDocument() + }) }) diff --git a/web/src/components/dashboard/activity-feed.tsx b/web/src/components/dashboard/activity-feed.tsx index 6fe7287e..e228ec8c 100644 --- a/web/src/components/dashboard/activity-feed.tsx +++ b/web/src/components/dashboard/activity-feed.tsx @@ -28,6 +28,7 @@ function eventLabel(type: WebSocketEventType): string { position_update: 'Position update', circuit_breaker: 'Circuit breaker', error: 'Error', + pipeline_health: 'Pipeline health', }; return labels[type] ?? type; } diff --git a/web/src/hooks/use-websocket-client.test.tsx b/web/src/hooks/use-websocket-client.test.tsx index 86e8095c..deabdee9 100644 --- a/web/src/hooks/use-websocket-client.test.tsx +++ b/web/src/hooks/use-websocket-client.test.tsx @@ -39,6 +39,7 @@ describe('useWebSocketClient', () => { beforeEach(() => { vi.useFakeTimers(); MockWebSocket.instances = []; + vi.spyOn(auth, 'getAccessToken').mockReturnValue(null); vi.stubGlobal('WebSocket', MockWebSocket); }); @@ -73,6 +74,41 @@ describe('useWebSocketClient', () => { expect(MockWebSocket.instances).toHaveLength(1); }); + it('reconnects after an unexpected close and preserves the ability to resubscribe on the new socket', async () => { + const { result } = renderHook(() => + useWebSocketClient({ + url: 'ws://localhost:8080/ws', + reconnectDelayMs: 250, + }), + ); + + expect(MockWebSocket.instances).toHaveLength(1); + act(() => { + MockWebSocket.instances[0]?.open(); + }); + expect(result.current.status).toBe('open'); + + act(() => { + MockWebSocket.instances[0]?.close(); + vi.advanceTimersByTime(250); + }); + + expect(result.current.status).toBe('connecting'); + expect(MockWebSocket.instances).toHaveLength(2); + act(() => { + MockWebSocket.instances[1]?.open(); + }); + expect(result.current.status).toBe('open'); + + act(() => { + result.current.subscribe({ run_ids: ['00000000-0000-0000-0000-000000000099'] }); + }); + + expect(MockWebSocket.instances[1]?.send).toHaveBeenCalledWith( + JSON.stringify({ action: 'subscribe', run_ids: ['00000000-0000-0000-0000-000000000099'] }), + ); + }); + it('appends access token to WebSocket URL', () => { vi.spyOn(auth, 'getAccessToken').mockReturnValue('test-jwt-token'); diff --git a/web/src/pages/pipeline-run-page.test.tsx b/web/src/pages/pipeline-run-page.test.tsx index e917b18c..f328de93 100644 --- a/web/src/pages/pipeline-run-page.test.tsx +++ b/web/src/pages/pipeline-run-page.test.tsx @@ -1,11 +1,42 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { cleanup, render, screen } from '@testing-library/react'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { PipelineRunPage } from '@/pages/pipeline-run-page'; +class MockWebSocket { + static instances: MockWebSocket[] = []; + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + + readyState = MockWebSocket.CONNECTING; + url: string; + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + onclose: (() => void) | null = null; + send = vi.fn(); + + constructor(url: string) { + this.url = url; + MockWebSocket.instances.push(this); + } + + close() { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.(); + } + + open() { + this.readyState = MockWebSocket.OPEN; + this.onopen?.(); + } +} + const runId = '00000000-0000-0000-0000-000000000099'; function Wrapper({ children }: { children: React.ReactNode }) { @@ -175,6 +206,57 @@ describe('PipelineRunPage', () => { expect(screen.getByText('Risk Manager Verdict')).toBeInTheDocument(); }); + it('re-subscribes to the active run after the websocket reconnects', async () => { + MockWebSocket.instances = []; + vi.stubGlobal('WebSocket', MockWebSocket); + + const runningRun = { ...mockRun, status: 'running' as const, completed_at: undefined }; + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/decisions')) { + return Promise.resolve({ ok: true, status: 200, json: async () => mockDecisions }); + } + return Promise.resolve({ ok: true, status: 200, json: async () => runningRun }); + }); + vi.stubGlobal('fetch', fetchMock); + + render(, { wrapper: Wrapper }); + + expect(await screen.findByTestId('pipeline-run-page')).toBeInTheDocument(); + expect(MockWebSocket.instances).toHaveLength(1); + + act(() => { + MockWebSocket.instances[0]?.open(); + }); + + await waitFor(() => { + expect(MockWebSocket.instances[0]?.send).toHaveBeenCalledWith( + JSON.stringify({ action: 'subscribe', run_ids: [runId] }), + ); + }); + + act(() => { + MockWebSocket.instances[0]?.close(); + }); + + await waitFor( + () => { + expect(MockWebSocket.instances).toHaveLength(2); + }, + { timeout: 3_000 }, + ); + + act(() => { + MockWebSocket.instances[1]?.open(); + }); + + await waitFor(() => { + expect(MockWebSocket.instances[1]?.send).toHaveBeenCalledWith( + JSON.stringify({ action: 'subscribe', run_ids: [runId] }), + ); + }); + }, 5_000); + it('shows error state when run fetch fails', async () => { const fetchMock = vi.fn().mockRejectedValue(new Error('Network error')); vi.stubGlobal('fetch', fetchMock); diff --git a/web/src/pages/realtime-page.test.tsx b/web/src/pages/realtime-page.test.tsx index b6bfe6d2..538392c2 100644 --- a/web/src/pages/realtime-page.test.tsx +++ b/web/src/pages/realtime-page.test.tsx @@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RealtimePage } from '@/pages/realtime-page' +import { getApiBaseUrl } from '@/lib/config' class MockWebSocket { static instances: MockWebSocket[] = [] @@ -65,6 +66,9 @@ function stubFetch(...responses: Array>) { return fetchMock } +const apiBaseUrl = getApiBaseUrl() +const apiUrl = (path: string) => `${apiBaseUrl}${path}` + const baseEvent = { id: 'evt-1', pipeline_run_id: 'run-1', @@ -197,6 +201,44 @@ describe('RealtimePage', () => { expect(screen.getAllByText('trader').length).toBeGreaterThan(0) }) + it('re-subscribes to all events after the websocket reconnects', async () => { + stubFetch( + jsonResponse(listResponse([], 50)), + jsonResponse(listResponse([], 50)), + ) + + render(, { wrapper: Wrapper }) + + expect(MockWebSocket.instances).toHaveLength(1) + + act(() => { + MockWebSocket.instances[0]?.open() + }) + + await waitFor(() => { + expect(MockWebSocket.instances[0]?.send).toHaveBeenCalledWith(JSON.stringify({ action: 'subscribe_all' })) + }) + + act(() => { + MockWebSocket.instances[0]?.close() + }) + + await waitFor( + () => { + expect(MockWebSocket.instances).toHaveLength(2) + }, + { timeout: 3_000 }, + ) + + act(() => { + MockWebSocket.instances[1]?.open() + }) + + await waitFor(() => { + expect(MockWebSocket.instances[1]?.send).toHaveBeenCalledWith(JSON.stringify({ action: 'subscribe_all' })) + }) + }, 5_000) + it('renders empty state when there are no events yet', async () => { stubFetch( jsonResponse(listResponse([], 50)), @@ -228,12 +270,12 @@ describe('RealtimePage', () => { expect(await screen.findByText('Existing conversation answer.')).toBeInTheDocument() expect(fetchMock).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ href: 'http://localhost:8080/api/v1/conversations?limit=50' }), + expect.objectContaining({ href: apiUrl('/api/v1/conversations?limit=50') }), expect.any(Object), ) expect(fetchMock).toHaveBeenNthCalledWith( 3, - expect.objectContaining({ href: 'http://localhost:8080/api/v1/conversations/conv-1/messages?limit=100' }), + expect.objectContaining({ href: apiUrl('/api/v1/conversations/conv-1/messages?limit=100') }), expect.any(Object), ) }) @@ -285,7 +327,7 @@ describe('RealtimePage', () => { expect(screen.getByTestId('chat-panel')).toHaveTextContent('TSLA') expect(fetchMock).toHaveBeenNthCalledWith( 4, - expect.objectContaining({ href: 'http://localhost:8080/api/v1/conversations' }), + expect.objectContaining({ href: apiUrl('/api/v1/conversations') }), expect.objectContaining({ method: 'POST', body: JSON.stringify({ pipeline_run_id: 'run-2', agent_role: 'risk_manager' }), @@ -329,7 +371,7 @@ describe('RealtimePage', () => { expect(screen.getByTestId('conversation-selector')).toHaveValue('conv-2') expect(fetchMock).toHaveBeenNthCalledWith( 4, - expect.objectContaining({ href: 'http://localhost:8080/api/v1/conversations/conv-2/messages?limit=100' }), + expect.objectContaining({ href: apiUrl('/api/v1/conversations/conv-2/messages?limit=100') }), expect.any(Object), ) }) @@ -368,7 +410,7 @@ describe('RealtimePage', () => { expect(screen.getByTestId('conversation-context-note')).toHaveTextContent('Viewing conversation outside selected event context.') expect(fetchMock).toHaveBeenNthCalledWith( 4, - expect.objectContaining({ href: 'http://localhost:8080/api/v1/conversations/conv-2/messages?limit=100' }), + expect.objectContaining({ href: apiUrl('/api/v1/conversations/conv-2/messages?limit=100') }), expect.any(Object), ) }) @@ -404,12 +446,12 @@ describe('RealtimePage', () => { expect(screen.getByText('No messages yet.')).toBeInTheDocument() expect(fetchMock).toHaveBeenNthCalledWith( 3, - expect.objectContaining({ href: 'http://localhost:8080/api/v1/runs?limit=50' }), + expect.objectContaining({ href: apiUrl('/api/v1/runs?limit=50') }), expect.any(Object), ) expect(fetchMock).toHaveBeenNthCalledWith( 4, - expect.objectContaining({ href: 'http://localhost:8080/api/v1/conversations' }), + expect.objectContaining({ href: apiUrl('/api/v1/conversations') }), expect.objectContaining({ method: 'POST', body: JSON.stringify({ pipeline_run_id: 'run-2', agent_role: 'risk_manager' }), @@ -467,17 +509,17 @@ describe('RealtimePage', () => { expect(fetchMock).toHaveBeenNthCalledWith( 3, - expect.objectContaining({ href: 'http://localhost:8080/api/v1/conversations' }), + expect.objectContaining({ href: apiUrl('/api/v1/conversations') }), expect.objectContaining({ method: 'POST' }), ) expect(fetchMock).toHaveBeenNthCalledWith( 4, - expect.objectContaining({ href: 'http://localhost:8080/api/v1/conversations/conv-1/messages' }), + expect.objectContaining({ href: apiUrl('/api/v1/conversations/conv-1/messages') }), expect.objectContaining({ method: 'POST' }), ) expect(fetchMock).toHaveBeenNthCalledWith( 5, - expect.objectContaining({ href: 'http://localhost:8080/api/v1/conversations/conv-1/messages?limit=100' }), + expect.objectContaining({ href: apiUrl('/api/v1/conversations/conv-1/messages?limit=100') }), expect.any(Object), ) }) From d1f1cc65654d471c84fa0ad6584e7827ab510cfd Mon Sep 17 00:00:00 2001 From: Patrick Fanella Date: Tue, 21 Apr 2026 22:28:35 -0500 Subject: [PATCH 2/5] Update internal/strategyscaffold/strategyscaffold.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/strategyscaffold/strategyscaffold.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/strategyscaffold/strategyscaffold.go b/internal/strategyscaffold/strategyscaffold.go index 8761a7d5..5fe09170 100644 --- a/internal/strategyscaffold/strategyscaffold.go +++ b/internal/strategyscaffold/strategyscaffold.go @@ -212,7 +212,9 @@ func RunOptionsPaperBacktest(ctx context.Context, ticker string, bars []domain.O StartDate: startDate, EndDate: endDate, InitialCash: initialCash, - Variations: 0, + // Use an explicit positive value so paper backtests do not trigger + // RunOptionsSweep's Variations <= 0 fallback behavior. + Variations: 1, } results, err := optionsdiscovery.RunOptionsSweep(ctx, *optCfg, sweepCfg, discoverypkg.DefaultScoringConfig(), logger) if err != nil { From e576118919bdd04f9407b2d1d64165a9f6432e0b Mon Sep 17 00:00:00 2001 From: Patrick Fanella Date: Tue, 21 Apr 2026 22:29:14 -0500 Subject: [PATCH 3/5] Update internal/discovery/options/sweep.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/discovery/options/sweep.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/discovery/options/sweep.go b/internal/discovery/options/sweep.go index 63ad623b..3db2e86e 100644 --- a/internal/discovery/options/sweep.go +++ b/internal/discovery/options/sweep.go @@ -366,7 +366,7 @@ func buildOptionsTrades(pos *optionsPosition, bar domain.OHLCV, opening bool) [] } trades := make([]domain.Trade, 0, len(pos.spread.Legs)) for _, leg := range pos.spread.Legs { - premium := legMarkPremium(leg, pos.entryBar.Close, bar.Close, pos.entryMid, len(pos.spread.Legs), true) + premium := legMarkPremium(leg, pos.entryBar.Close, bar.Close, pos.entryMid, len(pos.spread.Legs), opening) trades = append(trades, domain.Trade{ ID: uuid.New(), Ticker: leg.Contract.OCCSymbol, From 96bd2500f7c36a8085f6f7c9699f4b8d7eaac1d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 03:35:05 +0000 Subject: [PATCH 4/5] fix options backtest to honor options_rules underlying and optimize signal snapshots Agent-Logs-Url: https://github.com/PatrickFanella/augr/sessions/d2f3bbbb-6f01-4308-8a6e-0e2fce1e8655 Co-authored-by: PatrickFanella <61631520+PatrickFanella@users.noreply.github.com> --- internal/discovery/options/sweep.go | 25 ++++++-- internal/discovery/options/validator.go | 6 +- internal/service/backtest.go | 14 +++-- internal/service/backtest_scaffold_test.go | 57 +++++++++++++++++-- internal/strategyscaffold/strategyscaffold.go | 48 +++++++++++----- .../strategyscaffold/strategyscaffold_test.go | 40 +++++++++++++ 6 files changed, 160 insertions(+), 30 deletions(-) diff --git a/internal/discovery/options/sweep.go b/internal/discovery/options/sweep.go index 3db2e86e..fdae98ac 100644 --- a/internal/discovery/options/sweep.go +++ b/internal/discovery/options/sweep.go @@ -80,6 +80,7 @@ func RunOptionsSweep( if len(bars) < 50 { return nil, nil } + indicatorSnapshots := precomputeIndicatorSnapshots(bars) rv := realizedVol(cfg.Bars, 60) if rv < 0.05 { @@ -98,7 +99,7 @@ func RunOptionsSweep( label = "variant" } - artifacts := runOptionsBacktest(variant, bars, rv, cfg) + artifacts := runOptionsBacktest(variant, bars, indicatorSnapshots, rv, cfg) score := discovery.ScoreMetrics(artifacts.Metrics, scoring) if len(artifacts.Trades) > 0 { score += 0.001 @@ -135,6 +136,7 @@ type optionsPosition struct { func runOptionsBacktest( config rules.OptionsRulesConfig, bars []domain.OHLCV, + indicatorSnapshots []map[string]float64, realizedVol float64, cfg OptionsSweepConfig, ) OptionsBacktestArtifacts { @@ -147,8 +149,7 @@ func runOptionsBacktest( var prevSnap *rules.Snapshot for i, bar := range bars { - window := bars[:i+1] - snap := rules.Snapshot{Values: buildOptionSignalValues(window, bar, position, realizedVol, chainCfg)} + snap := rules.Snapshot{Values: buildOptionSignalValues(indicatorSnapshots[i], bar, position, realizedVol, chainCfg)} dte := avgDTE(config.LegSelection) chain := backtest.SynthesizeChain(bar.Close, realizedVol, dte, bar.Timestamp, chainCfg) @@ -512,7 +513,19 @@ func mutateConditionGroup(group rules.ConditionGroup, rng *rand.Rand) rules.Cond return out } -func buildOptionSignalValues(bars []domain.OHLCV, bar domain.OHLCV, pos *optionsPosition, rv float64, chainCfg backtest.SyntheticChainConfig) map[string]float64 { +func precomputeIndicatorSnapshots(bars []domain.OHLCV) []map[string]float64 { + snapshots := make([]map[string]float64, len(bars)) + for i := range bars { + snapshot := make(map[string]float64) + for _, indicator := range data.IndicatorSnapshotFromBars(bars[:i+1]) { + snapshot[indicator.Name] = indicator.Value + } + snapshots[i] = snapshot + } + return snapshots +} + +func buildOptionSignalValues(indicators map[string]float64, bar domain.OHLCV, pos *optionsPosition, rv float64, chainCfg backtest.SyntheticChainConfig) map[string]float64 { values := map[string]float64{ "close": bar.Close, "open": bar.Open, @@ -520,8 +533,8 @@ func buildOptionSignalValues(bars []domain.OHLCV, bar domain.OHLCV, pos *options "low": bar.Low, "volume": bar.Volume, } - for _, indicator := range data.IndicatorSnapshotFromBars(bars) { - values[indicator.Name] = indicator.Value + for name, value := range indicators { + values[name] = value } dte := 30 diff --git a/internal/discovery/options/validator.go b/internal/discovery/options/validator.go index 9651d39d..6a4ad5df 100644 --- a/internal/discovery/options/validator.go +++ b/internal/discovery/options/validator.go @@ -64,13 +64,15 @@ func ValidateOptionsOutOfSample( // In-sample (calibration). sweepCfg.StartDate = w.calStart sweepCfg.EndDate = w.calEnd - calMetrics := runOptionsBacktest(optionsConfig, filterBars(bars, w.calStart, w.calEnd), rv, sweepCfg) + calBars := filterBars(bars, w.calStart, w.calEnd) + calMetrics := runOptionsBacktest(optionsConfig, calBars, precomputeIndicatorSnapshots(calBars), rv, sweepCfg) inSampleMetrics = append(inSampleMetrics, calMetrics.Metrics) // Out-of-sample (test). sweepCfg.StartDate = w.testStart sweepCfg.EndDate = w.testEnd - testMetrics := runOptionsBacktest(optionsConfig, filterBars(bars, w.testStart, w.testEnd), rv, sweepCfg) + testBars := filterBars(bars, w.testStart, w.testEnd) + testMetrics := runOptionsBacktest(optionsConfig, testBars, precomputeIndicatorSnapshots(testBars), rv, sweepCfg) oosMetrics = append(oosMetrics, testMetrics.Metrics) } diff --git a/internal/service/backtest.go b/internal/service/backtest.go index 9e1dbfb1..fff97ef7 100644 --- a/internal/service/backtest.go +++ b/internal/service/backtest.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "log/slog" + "strings" "time" "github.com/google/uuid" @@ -193,16 +194,20 @@ func (svc *BacktestService) runOptionsRulesBacktest( if optionsConfig == nil { return nil, &ServiceError{Status: 400, Message: "strategy must have options_rules config for backtesting"} } + underlying := strings.ToUpper(strings.TrimSpace(optionsConfig.Underlying)) + if underlying == "" { + underlying = strategy.Ticker + } - allBars, svcErr := svc.loadHistoricalBars(ctx, strategy.Ticker, domain.MarketTypeStock, config) + allBars, svcErr := svc.loadHistoricalBars(ctx, underlying, domain.MarketTypeStock, config) if svcErr != nil { return nil, svcErr } start := time.Now() - summary, err := strategyscaffold.RunOptionsPaperBacktest( + summary, err := strategyscaffold.RunOptionsPaperBacktestWithConfig( ctx, - strategy.Ticker, + *optionsConfig, allBars, config.StartDate, config.EndDate, @@ -227,7 +232,7 @@ func (svc *BacktestService) runOptionsRulesBacktest( return nil, &ServiceError{Status: 500, Message: "failed to serialize equity curve"} } - run, svcErr := svc.persistBacktestRun(ctx, actor, config.ID, strategy.Ticker, metricsJSON, tradeLogJSON, equityCurveJSON, start, duration, "options-rules-v1", analysts.CurrentPromptVersionHash()) + run, svcErr := svc.persistBacktestRun(ctx, actor, config.ID, underlying, metricsJSON, tradeLogJSON, equityCurveJSON, start, duration, "options-rules-v1", analysts.CurrentPromptVersionHash()) if svcErr != nil { return nil, svcErr } @@ -250,7 +255,6 @@ func (svc *BacktestService) runOptionsRulesBacktest( } } - _ = optionsConfig return &run, nil } diff --git a/internal/service/backtest_scaffold_test.go b/internal/service/backtest_scaffold_test.go index dd16b9db..8316315a 100644 --- a/internal/service/backtest_scaffold_test.go +++ b/internal/service/backtest_scaffold_test.go @@ -167,6 +167,51 @@ func TestRunBacktestExecutesOptionsRulesAndPersistsRun(t *testing.T) { } } +func TestRunBacktestOptionsRulesUsesConfiguredUnderlying(t *testing.T) { + optionsStrategy, err := strategyscaffold.OptionsPaperBullPutSpread("QQQ") + if err != nil { + t.Fatalf("OptionsPaperBullPutSpread() error = %v", err) + } + optionsStrategy.Ticker = "SPY" + + cfg := domain.BacktestConfig{ + ID: uuid.New(), + StrategyID: optionsStrategy.ID, + Name: "options-underlying-validation", + StartDate: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), + EndDate: time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC), + Simulation: domain.BacktestSimulationParameters{InitialCapital: 100_000}, + } + + provider := &stubDataProvider{bars: syntheticOHLCVSeries(cfg.StartDate.AddDate(-1, 0, 0), 520)} + marketDataRepo := &stubMarketDataRepo{bars: provider.bars} + dataSvc := data.NewDataService(config.Config{}, &data.ProviderRegistry{ + Yahoo: func(data.ProviderConfig) data.DataProvider { return provider }, + }, marketDataRepo, slog.Default(), nil) + dataSvc.SetNowFunc(func() time.Time { return cfg.EndDate }) + + svc := service.NewBacktestService( + stubBacktestConfigRepo{config: &cfg}, + &recordingBacktestRunRepo{}, + &stubStrategyRepo{strategy: &optionsStrategy}, + &recordingAuditLogRepo{}, + dataSvc, + nil, + slog.Default(), + ) + + run, err := svc.RunBacktest(context.Background(), cfg.ID, "tester") + if err != nil { + t.Fatalf("RunBacktest() error = %v", err) + } + if run == nil { + t.Fatal("RunBacktest() run = nil") + } + if marketDataRepo.requestedTicker != "QQQ" { + t.Fatalf("historical ticker = %q, want %q", marketDataRepo.requestedTicker, "QQQ") + } +} + func TestStockScaffoldConfigIncludesRulesEngineForBacktestService(t *testing.T) { strategy, err := strategyscaffold.StockPaperMovingAverageCrossover("SPY") if err != nil { @@ -273,10 +318,12 @@ func (recordingAuditLogRepo) Count(context.Context, repository.AuditLogFilter) ( } type stubDataProvider struct { - bars []domain.OHLCV + bars []domain.OHLCV + requestedTicker string } -func (s *stubDataProvider) GetOHLCV(context.Context, string, data.Timeframe, time.Time, time.Time) ([]domain.OHLCV, error) { +func (s *stubDataProvider) GetOHLCV(_ context.Context, ticker string, _ data.Timeframe, _, _ time.Time) ([]domain.OHLCV, error) { + s.requestedTicker = ticker return append([]domain.OHLCV(nil), s.bars...), nil } func (s *stubDataProvider) GetFundamentals(context.Context, string) (data.Fundamentals, error) { @@ -290,7 +337,8 @@ func (s *stubDataProvider) GetSocialSentiment(context.Context, string, time.Time } type stubMarketDataRepo struct { - bars []domain.OHLCV + bars []domain.OHLCV + requestedTicker string } func (s *stubMarketDataRepo) Get(context.Context, repository.MarketDataCacheKey) (*domain.MarketData, error) { @@ -303,7 +351,8 @@ func (s *stubMarketDataRepo) Expire(context.Context, repository.MarketDataCacheE func (s *stubMarketDataRepo) UpsertHistoricalOHLCV(context.Context, []domain.HistoricalOHLCV) error { return nil } -func (s *stubMarketDataRepo) ListHistoricalOHLCV(context.Context, repository.HistoricalOHLCVFilter) ([]domain.HistoricalOHLCV, error) { +func (s *stubMarketDataRepo) ListHistoricalOHLCV(_ context.Context, filter repository.HistoricalOHLCVFilter) ([]domain.HistoricalOHLCV, error) { + s.requestedTicker = filter.Ticker result := make([]domain.HistoricalOHLCV, 0, len(s.bars)) for _, bar := range s.bars { result = append(result, domain.HistoricalOHLCV{ diff --git a/internal/strategyscaffold/strategyscaffold.go b/internal/strategyscaffold/strategyscaffold.go index 5fe09170..9979f318 100644 --- a/internal/strategyscaffold/strategyscaffold.go +++ b/internal/strategyscaffold/strategyscaffold.go @@ -181,6 +181,40 @@ func RunOptionsPaperBacktest(ctx context.Context, ticker string, bars []domain.O if err != nil { return nil, err } + var payload map[string]json.RawMessage + if err := json.Unmarshal(strategy.Config, &payload); err != nil { + return nil, fmt.Errorf("options scaffold: parse strategy config: %w", err) + } + optCfg, err := rules.ParseOptions(payload["options_rules"]) + if err != nil { + return nil, fmt.Errorf("options scaffold: parse options_rules: %w", err) + } + if optCfg == nil { + return nil, fmt.Errorf("options scaffold: options_rules config missing") + } + + return runOptionsPaperBacktest(ctx, strategy, *optCfg, bars, startDate, endDate, initialCash, logger) +} + +func RunOptionsPaperBacktestWithConfig(ctx context.Context, optionsCfg rules.OptionsRulesConfig, bars []domain.OHLCV, startDate, endDate time.Time, initialCash float64, logger *slog.Logger) (*OptionsBacktestSummary, error) { + if err := rules.ValidateOptions(&optionsCfg); err != nil { + return nil, fmt.Errorf("options scaffold: %w", err) + } + strategy, err := OptionsPaperBullPutSpread(optionsCfg.Underlying) + if err != nil { + return nil, err + } + config, err := marshalConfig(map[string]any{"options_rules": optionsCfg}) + if err != nil { + return nil, err + } + strategy.Config = config + strategy.Ticker = normalizeTicker(optionsCfg.Underlying) + strategy.Name = fmt.Sprintf("paper options: %s %s", strategy.Ticker, strings.ReplaceAll(string(optionsCfg.StrategyType), "_", " ")) + return runOptionsPaperBacktest(ctx, strategy, optionsCfg, bars, startDate, endDate, initialCash, logger) +} + +func runOptionsPaperBacktest(ctx context.Context, strategy domain.Strategy, optionsCfg rules.OptionsRulesConfig, bars []domain.OHLCV, startDate, endDate time.Time, initialCash float64, logger *slog.Logger) (*OptionsBacktestSummary, error) { if len(bars) == 0 { return nil, fmt.Errorf("options scaffold: bars are required") } @@ -194,18 +228,6 @@ func RunOptionsPaperBacktest(ctx context.Context, ticker string, bars []domain.O logger = slog.Default() } - var payload map[string]json.RawMessage - if err := json.Unmarshal(strategy.Config, &payload); err != nil { - return nil, fmt.Errorf("options scaffold: parse strategy config: %w", err) - } - optCfg, err := rules.ParseOptions(payload["options_rules"]) - if err != nil { - return nil, fmt.Errorf("options scaffold: parse options_rules: %w", err) - } - if optCfg == nil { - return nil, fmt.Errorf("options scaffold: options_rules config missing") - } - sweepCfg := optionsdiscovery.OptionsSweepConfig{ Ticker: strategy.Ticker, Bars: bars, @@ -216,7 +238,7 @@ func RunOptionsPaperBacktest(ctx context.Context, ticker string, bars []domain.O // RunOptionsSweep's Variations <= 0 fallback behavior. Variations: 1, } - results, err := optionsdiscovery.RunOptionsSweep(ctx, *optCfg, sweepCfg, discoverypkg.DefaultScoringConfig(), logger) + results, err := optionsdiscovery.RunOptionsSweep(ctx, optionsCfg, sweepCfg, discoverypkg.DefaultScoringConfig(), logger) if err != nil { return nil, fmt.Errorf("options scaffold: run sweep: %w", err) } diff --git a/internal/strategyscaffold/strategyscaffold_test.go b/internal/strategyscaffold/strategyscaffold_test.go index 9bfe92f4..1d083ef7 100644 --- a/internal/strategyscaffold/strategyscaffold_test.go +++ b/internal/strategyscaffold/strategyscaffold_test.go @@ -143,6 +143,46 @@ func TestRunOptionsPaperBacktestReturnsSummary(t *testing.T) { } } +func TestRunOptionsPaperBacktestWithConfigUsesProvidedRules(t *testing.T) { + bars := syntheticBars(340) + startDate := bars[40].Timestamp + endDate := bars[len(bars)-1].Timestamp + + base, err := strategyscaffold.OptionsPaperBullPutSpread("QQQ") + if err != nil { + t.Fatalf("OptionsPaperBullPutSpread() error = %v", err) + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(base.Config, &payload); err != nil { + t.Fatalf("json.Unmarshal(config) error = %v", err) + } + cfg, err := rules.ParseOptions(payload["options_rules"]) + if err != nil { + t.Fatalf("rules.ParseOptions(options_rules) error = %v", err) + } + cfg.Underlying = "SPY" + cfg.Management.CloseAtProfitPct = 65 + + summary, err := strategyscaffold.RunOptionsPaperBacktestWithConfig(context.Background(), *cfg, bars, startDate, endDate, 100_000, nil) + if err != nil { + t.Fatalf("RunOptionsPaperBacktestWithConfig() error = %v", err) + } + if summary.Strategy.Ticker != "SPY" { + t.Fatalf("Strategy.Ticker = %q, want %q", summary.Strategy.Ticker, "SPY") + } + var strategyPayload map[string]json.RawMessage + if err := json.Unmarshal(summary.Strategy.Config, &strategyPayload); err != nil { + t.Fatalf("json.Unmarshal(summary.Strategy.Config) error = %v", err) + } + strategyCfg, err := rules.ParseOptions(strategyPayload["options_rules"]) + if err != nil { + t.Fatalf("rules.ParseOptions(summary.options_rules) error = %v", err) + } + if strategyCfg.Management.CloseAtProfitPct != 65 { + t.Fatalf("CloseAtProfitPct = %v, want %v", strategyCfg.Management.CloseAtProfitPct, 65.0) + } +} + func TestScaffoldsRejectBlankTicker(t *testing.T) { if _, err := strategyscaffold.StockPaperMovingAverageCrossover(" "); err == nil { t.Fatal("StockPaperMovingAverageCrossover(blank) error = nil, want error") From d81685b24f365f998fd18f46249960c08a23987a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 03:37:55 +0000 Subject: [PATCH 5/5] test: clarify expected underlying assertions in options backtest coverage Agent-Logs-Url: https://github.com/PatrickFanella/augr/sessions/d2f3bbbb-6f01-4308-8a6e-0e2fce1e8655 Co-authored-by: PatrickFanella <61631520+PatrickFanella@users.noreply.github.com> --- internal/service/backtest_scaffold_test.go | 8 +++++--- internal/strategyscaffold/strategyscaffold_test.go | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/service/backtest_scaffold_test.go b/internal/service/backtest_scaffold_test.go index 8316315a..457bf2a4 100644 --- a/internal/service/backtest_scaffold_test.go +++ b/internal/service/backtest_scaffold_test.go @@ -168,7 +168,9 @@ func TestRunBacktestExecutesOptionsRulesAndPersistsRun(t *testing.T) { } func TestRunBacktestOptionsRulesUsesConfiguredUnderlying(t *testing.T) { - optionsStrategy, err := strategyscaffold.OptionsPaperBullPutSpread("QQQ") + const expectedUnderlying = "QQQ" + + optionsStrategy, err := strategyscaffold.OptionsPaperBullPutSpread(expectedUnderlying) if err != nil { t.Fatalf("OptionsPaperBullPutSpread() error = %v", err) } @@ -207,8 +209,8 @@ func TestRunBacktestOptionsRulesUsesConfiguredUnderlying(t *testing.T) { if run == nil { t.Fatal("RunBacktest() run = nil") } - if marketDataRepo.requestedTicker != "QQQ" { - t.Fatalf("historical ticker = %q, want %q", marketDataRepo.requestedTicker, "QQQ") + if marketDataRepo.requestedTicker != expectedUnderlying { + t.Fatalf("historical ticker = %q, want %q", marketDataRepo.requestedTicker, expectedUnderlying) } } diff --git a/internal/strategyscaffold/strategyscaffold_test.go b/internal/strategyscaffold/strategyscaffold_test.go index 1d083ef7..f975c3ac 100644 --- a/internal/strategyscaffold/strategyscaffold_test.go +++ b/internal/strategyscaffold/strategyscaffold_test.go @@ -179,7 +179,7 @@ func TestRunOptionsPaperBacktestWithConfigUsesProvidedRules(t *testing.T) { t.Fatalf("rules.ParseOptions(summary.options_rules) error = %v", err) } if strategyCfg.Management.CloseAtProfitPct != 65 { - t.Fatalf("CloseAtProfitPct = %v, want %v", strategyCfg.Management.CloseAtProfitPct, 65.0) + t.Fatalf("CloseAtProfitPct = %v, want %v", strategyCfg.Management.CloseAtProfitPct, 65) } }