From 8278f93a34d5b4fb9511ebcbdb1b7c6db31c7452 Mon Sep 17 00:00:00 2001 From: Nathan Anderson Date: Tue, 26 May 2026 19:18:59 -0500 Subject: [PATCH] latest step after first alpaca --- .gitignore | 1 - TODO.md | 238 --------- TRADING_DEVELOPMENT_PLAN.md | 4 +- TRADING_TDD_PLAN.md | 453 ++++++++++++++++++ lib/screens/home_screen.dart | 3 +- lib/services/auth_service_firebase.dart | 16 +- scripts/test-server-alpaca.sh | 16 + scripts/test-server.sh | 17 + scripts/web_static_server.dart | 146 ++++++ server/README.md | 21 + server/bin/server.dart | 92 ++++ server/dart_test.yaml | 7 + server/lib/alpaca/alpaca_env.dart | 78 +++ .../lib/alpaca/alpaca_market_data_client.dart | 82 ++++ server/lib/alpaca/alpaca_models.dart | 220 +++++++++ server/lib/alpaca/alpaca_trading_client.dart | 108 +++++ server/lib/env.dart | 45 ++ server/lib/handlers/trading_dev_handler.dart | 64 +++ server/lib/pipeline/question_pipeline.dart | 30 +- server/lib/trading/guardrails.dart | 149 ++++++ server/lib/trading/market_data_db.dart | 134 ++++++ server/lib/trading/market_data_ingest.dart | 160 +++++++ server/lib/trading/rule_engine.dart | 178 +++++++ server/lib/trading/trade_actuator.dart | 305 ++++++++++++ server/lib/trading/trade_orders_db.dart | 239 +++++++++ server/lib/trading/trading_config.dart | 221 +++++++++ server/lib/trading/trading_config_db.dart | 101 ++++ server/lib/trading/trading_dev_actions.dart | 202 ++++++++ server/lib/trading/trading_orchestrator.dart | 148 ++++++ server/lib/trading/trading_pipeline.dart | 291 +++++++++++ server/lib/trading/user_trading_state_db.dart | 239 +++++++++ .../workers/question_background_worker.dart | 23 +- server/migrations/004_trading.sql | 97 ++++ server/pubspec.lock | 224 +++++++++ server/pubspec.yaml | 3 + server/test/alpaca/alpaca_env_test.dart | 75 +++ .../alpaca_market_data_client_test.dart | 56 +++ .../alpaca/alpaca_market_data_live_test.dart | 48 ++ server/test/alpaca/alpaca_models_test.dart | 58 +++ .../alpaca/alpaca_trading_client_test.dart | 174 +++++++ .../test/alpaca/alpaca_trading_live_test.dart | 77 +++ server/test/fixtures/alpaca_daily_bars.json | 27 ++ server/test/fixtures/alpaca_latest_trade.json | 12 + .../test/fixtures/alpaca_order_accepted.json | 29 ++ .../alpaca_order_duplicate_client_id.json | 4 + .../fixtures/market_snapshots_spy_dip.json | 20 + .../test/fixtures/trading_config_default.json | 37 ++ server/test/helpers/fixture_loader.dart | 22 + server/test/helpers/mock_http_client.dart | 92 ++++ server/test/helpers/test_db.dart | 155 ++++++ server/test/helpers/test_env.dart | 26 + .../test/integration/market_data_db_test.dart | 59 +++ .../integration/market_data_ingest_test.dart | 180 +++++++ server/test/integration/migration_test.dart | 54 +++ .../test/integration/trade_actuator_test.dart | 323 +++++++++++++ .../integration/trade_orders_db_test.dart | 122 +++++ .../integration/trading_config_db_test.dart | 79 +++ .../integration/trading_dev_actions_test.dart | 189 ++++++++ .../trading_orchestrator_gate_a_test.dart | 182 +++++++ .../integration/trading_pipeline_test.dart | 306 ++++++++++++ .../test/integration/trading_schema_test.dart | 90 ++++ server/test/smoke_test.dart | 23 + server/test/trading/guardrails_test.dart | 176 +++++++ server/test/trading/rule_engine_test.dart | 176 +++++++ server/test/trading/trading_config_test.dart | 68 +++ startup.sh | 21 +- 66 files changed, 7060 insertions(+), 255 deletions(-) delete mode 100644 TODO.md create mode 100644 TRADING_TDD_PLAN.md create mode 100755 scripts/test-server-alpaca.sh create mode 100755 scripts/test-server.sh create mode 100644 scripts/web_static_server.dart create mode 100644 server/dart_test.yaml create mode 100644 server/lib/alpaca/alpaca_env.dart create mode 100644 server/lib/alpaca/alpaca_market_data_client.dart create mode 100644 server/lib/alpaca/alpaca_models.dart create mode 100644 server/lib/alpaca/alpaca_trading_client.dart create mode 100644 server/lib/handlers/trading_dev_handler.dart create mode 100644 server/lib/trading/guardrails.dart create mode 100644 server/lib/trading/market_data_db.dart create mode 100644 server/lib/trading/market_data_ingest.dart create mode 100644 server/lib/trading/rule_engine.dart create mode 100644 server/lib/trading/trade_actuator.dart create mode 100644 server/lib/trading/trade_orders_db.dart create mode 100644 server/lib/trading/trading_config.dart create mode 100644 server/lib/trading/trading_config_db.dart create mode 100644 server/lib/trading/trading_dev_actions.dart create mode 100644 server/lib/trading/trading_orchestrator.dart create mode 100644 server/lib/trading/trading_pipeline.dart create mode 100644 server/lib/trading/user_trading_state_db.dart create mode 100644 server/migrations/004_trading.sql create mode 100644 server/test/alpaca/alpaca_env_test.dart create mode 100644 server/test/alpaca/alpaca_market_data_client_test.dart create mode 100644 server/test/alpaca/alpaca_market_data_live_test.dart create mode 100644 server/test/alpaca/alpaca_models_test.dart create mode 100644 server/test/alpaca/alpaca_trading_client_test.dart create mode 100644 server/test/alpaca/alpaca_trading_live_test.dart create mode 100644 server/test/fixtures/alpaca_daily_bars.json create mode 100644 server/test/fixtures/alpaca_latest_trade.json create mode 100644 server/test/fixtures/alpaca_order_accepted.json create mode 100644 server/test/fixtures/alpaca_order_duplicate_client_id.json create mode 100644 server/test/fixtures/market_snapshots_spy_dip.json create mode 100644 server/test/fixtures/trading_config_default.json create mode 100644 server/test/helpers/fixture_loader.dart create mode 100644 server/test/helpers/mock_http_client.dart create mode 100644 server/test/helpers/test_db.dart create mode 100644 server/test/helpers/test_env.dart create mode 100644 server/test/integration/market_data_db_test.dart create mode 100644 server/test/integration/market_data_ingest_test.dart create mode 100644 server/test/integration/migration_test.dart create mode 100644 server/test/integration/trade_actuator_test.dart create mode 100644 server/test/integration/trade_orders_db_test.dart create mode 100644 server/test/integration/trading_config_db_test.dart create mode 100644 server/test/integration/trading_dev_actions_test.dart create mode 100644 server/test/integration/trading_orchestrator_gate_a_test.dart create mode 100644 server/test/integration/trading_pipeline_test.dart create mode 100644 server/test/integration/trading_schema_test.dart create mode 100644 server/test/smoke_test.dart create mode 100644 server/test/trading/guardrails_test.dart create mode 100644 server/test/trading/rule_engine_test.dart create mode 100644 server/test/trading/trading_config_test.dart diff --git a/.gitignore b/.gitignore index 99a1f0a..2158584 100644 --- a/.gitignore +++ b/.gitignore @@ -58,7 +58,6 @@ unlinked_spec.ds **/ios/Runner/GeneratedPluginRegistrant.* # Web -lib/generated_plugin_registrant.dart # Coverage coverage/ diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 2d76460..0000000 --- a/TODO.md +++ /dev/null @@ -1,238 +0,0 @@ -# Firebase Google Sign-In setup - -## Quick setup (Ubuntu CLI) - -Reload your shell after the first run (or open a new terminal): - -```bash -source ~/.bashrc -``` - -One-shot setup (installs tools, logs in, configures FlutterFire, registers debug SHA-1): - -```bash -cd ~/cyberhybridhub.com -chmod +x scripts/setup-firebase-google-auth.sh scripts/get-android-sha.sh - -# Optional: set your Firebase project id to skip the interactive picker -export FIREBASE_PROJECT_ID=your-firebase-project-id - -./scripts/setup-firebase-google-auth.sh -``` - -Or run step by step: - -```bash -# 1. Tools (if not already installed) -npm install -g firebase-tools -dart pub global activate flutterfire_cli - -# 2. Login (FlutterFire uses the Firebase CLI session) -firebase login - -# 3. Register apps + download config (android, ios, web) -cd ~/cyberhybridhub.com -flutterfire configure \ - --project=YOUR_FIREBASE_PROJECT_ID \ - --platforms=android,ios,web \ - --android-package-name=com.cyberhybridhub.cyberhybridhub \ - --ios-bundle-id=com.cyberhybridhub.cyberhybridhub \ - --out=lib/firebase_options.dart - -# 4. Debug SHA-1 (Android Google Sign-In) -./scripts/get-android-sha.sh -firebase apps:list --project=YOUR_FIREBASE_PROJECT_ID -firebase apps:android:sha:create ANDROID_APP_ID YOUR_SHA1 --project=YOUR_FIREBASE_PROJECT_ID -flutterfire configure --project=YOUR_FIREBASE_PROJECT_ID --platforms=android,ios,web --yes - -# 5. Enable Google provider (browser — required) -# https://console.firebase.google.com/ → Authentication → Sign-in method → Google → Enable - -# 6. Run -flutter pub get && flutter run -``` - -`~/.bashrc` includes `~/.pub-cache/bin` (for `flutterfire`) and `CYBERHYBRIDHUB_ROOT`. - ---- - -## Linux desktop development (`flutter run -d linux`) - -Official FlutterFire does not register native plugins on Linux, so this app uses: - -- `google_sign_in_all_platforms` (system browser OAuth) -- Firebase Auth REST (`signInWithIdp`) with web API key from `lib/firebase_options.dart` - -### One-time Linux OAuth setup - -1. [Google Cloud Console](https://console.cloud.google.com/) → project **cyberhybridhub** → **APIs & Services** → **Credentials** -2. Open your **Web application** OAuth 2.0 client (or create one) -3. Add authorized redirect URI: `http://127.0.0.1` -4. Copy **Client ID** and **Client secret** into `lib/config/auth_config.dart`: - -```dart -const String? googleWebOAuthClientId = 'YOUR_CLIENT_ID.apps.googleusercontent.com'; -const String? googleOAuthDesktopClientSecret = 'YOUR_CLIENT_SECRET'; -``` - -### Web browser (`flutter run -d web-server` or Chrome) - -Uses the same `googleWebOAuthClientId`. In Google Cloud, on that Web OAuth client, add **Authorized JavaScript origins**: - -- `http://localhost` -- `http://127.0.0.1` - -(Uncomment the `google-signin-client_id` meta tag in `web/index.html` only if you prefer HTML config over Dart.) - -5. Enable Google sign-in in Firebase Console (if not already) -6. Run: - -```bash -flutter run -d linux -``` - ---- - -## Manual checklist - -Complete these steps to replace placeholder config files and enable Google authentication. - -App identifiers used by this project: - -| Platform | Identifier | -|----------|------------| -| Android package | `com.cyberhybridhub.cyberhybridhub` | -| iOS bundle ID | `com.cyberhybridhub.cyberhybridhub` | - ---- - -## 1. Create a Firebase project - -- [ ] Open [Firebase Console](https://console.firebase.google.com/). -- [ ] Create a project (or select an existing one). -- [ ] Enable **Google Analytics** only if you need it (optional for auth). - ---- - -## 2. Register the Flutter app in Firebase - -- [ ] In Firebase Console → **Project settings** → **Your apps**, add: - - [ ] **Android** app with package name `com.cyberhybridhub.cyberhybridhub` - - [ ] **iOS** app with bundle ID `com.cyberhybridhub.cyberhybridhub` - - [ ] **Web** app (required for the web OAuth client used by Google Sign-In on mobile) -- [ ] Download config files when prompted: - - [ ] `google-services.json` → replace `android/app/google-services.json` - - [ ] `GoogleService-Info.plist` → replace `ios/Runner/GoogleService-Info.plist` - -### Recommended: FlutterFire CLI - -```bash -dart pub global activate flutterfire_cli -flutterfire configure -``` - -This regenerates `lib/firebase_options.dart` and downloads the platform config files. - ---- - -## 3. Enable Google sign-in in Firebase - -- [ ] Firebase Console → **Build** → **Authentication** → **Sign-in method** -- [ ] Enable **Google** -- [ ] Set a support email and save - ---- - -## 4. Google Cloud Console (OAuth & credentials) - -Firebase links to a Google Cloud project. Open it from Firebase → **Project settings** → **General** → **Google Cloud Platform resource location** → link to GCP, or go to [Google Cloud Console](https://console.cloud.google.com/) and select the same project. - -### 4.1 OAuth consent screen - -- [ ] **APIs & Services** → **OAuth consent screen** -- [ ] Choose **External** (or Internal for Workspace-only) -- [ ] Fill app name, user support email, developer contact -- [ ] Add scopes if needed (default `email`, `profile`, `openid` are usually enough) -- [ ] Add test users while the app is in **Testing** mode - -### 4.2 OAuth 2.0 Client IDs - -- [ ] **APIs & Services** → **Credentials** -- [ ] Confirm these clients exist (Firebase often creates them automatically): - -| Client type | Purpose | -|-------------|---------| -| **Web application** | `serverClientId` / ID token for Firebase Auth on Android | -| **Android** | Package name + SHA-1 certificate fingerprints | -| **iOS** | Bundle ID | - -#### Android SHA-1 fingerprints - -Debug keystore (local development): - -```bash -keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android -``` - -Release keystore (production): - -```bash -keytool -list -v -keystore /path/to/your-release.keystore -alias your-alias -``` - -- [ ] Add **SHA-1** (and **SHA-256** if offered) to the Android OAuth client in Google Cloud Console -- [ ] Re-download `google-services.json` after adding fingerprints - -#### iOS URL scheme - -- [ ] Open `ios/Runner/GoogleService-Info.plist` and copy `REVERSED_CLIENT_ID` -- [ ] In Xcode (or `ios/Runner/Info.plist`), add a URL type: - - **URL Schemes**: value of `REVERSED_CLIENT_ID` - - Example: `com.googleusercontent.apps.123456789-abcdef` - ---- - -## 5. App code configuration - -- [ ] Replace placeholders in `lib/firebase_options.dart` (or run `flutterfire configure`) -- [ ] If sign-in fails on Android with a `serverClientId` error, set the Web client ID in `lib/config/auth_config.dart`: - -```dart -const String? googleSignInServerClientId = 'YOUR_WEB_CLIENT_ID.apps.googleusercontent.com'; -``` - -Find the Web client ID in Firebase → Project settings → Your apps → Web app, or in `google-services.json` under `oauth_client` with `"client_type": 3`. - ---- - -## 6. Verify - -```bash -flutter pub get -flutter run -``` - -- [ ] Tap **Continue with Google** on the login screen -- [ ] Complete Google account selection -- [ ] Confirm the home screen shows your name and email -- [ ] Sign out and sign in again - ---- - -## Troubleshooting - -| Symptom | Likely fix | -|---------|------------| -| `clientConfigurationError` | Wrong package/bundle ID, missing SHA-1, or invalid `google-services.json` | -| Sign-in cancels immediately after account pick | Missing web OAuth client in `google-services.json` or wrong SHA-1 | -| `serverClientId must be provided on Android` | Add Web app in Firebase, re-download `google-services.json`, or set `googleSignInServerClientId` | -| iOS sign-in fails after Google UI | Add `REVERSED_CLIENT_ID` URL scheme to `Info.plist` | -| `REPLACE_ME` / invalid API key at runtime | Run `flutterfire configure` or paste real values from Firebase | - ---- - -## Reference - -- [FlutterFire setup](https://firebase.flutter.dev/docs/overview) -- [Firebase Auth – Google](https://firebase.google.com/docs/auth/flutter/federated-auth#google) -- [google_sign_in plugin](https://pub.dev/packages/google_sign_in) diff --git a/TRADING_DEVELOPMENT_PLAN.md b/TRADING_DEVELOPMENT_PLAN.md index eca7b7e..2f3a6ac 100644 --- a/TRADING_DEVELOPMENT_PLAN.md +++ b/TRADING_DEVELOPMENT_PLAN.md @@ -143,7 +143,7 @@ Store in Postgres as `JSONB`; validate on write via API or seed migrations. ], "guardrails": { "max_orders_per_day": 3, - "max_notional_usd_per_day": 100, + "max_notional_usd_per_4h": 100, "require_question_before_order": true, "symbols_blocklist": [] } @@ -342,7 +342,7 @@ On each `QuestionBackgroundWorker._tick()` when `TRADING_ENABLED=true`: ### 8.3 Guardrails (always before Alpaca POST) - `require_question_before_order` and unanswered question must be resolved. -- `max_orders_per_day` / `max_notional_usd_per_day` from config. +- `max_orders_per_day` (calendar-day) and `max_notional_usd_per_4h` (rolling 4-hour window) from config. The 4-hour notional window bounds runaway-rule blast radius while still allowing intraday "double-down" sizing as a thesis strengthens. - `mode == paper` unless `ALPACA_ALLOW_LIVE=true`. - Symbol in user watchlist and not in `symbols_blocklist`. - Idempotent `client_order_id` = `uuid` or `{uid}-{rule_id}-{question_id}`. diff --git a/TRADING_TDD_PLAN.md b/TRADING_TDD_PLAN.md new file mode 100644 index 0000000..fafa7d0 --- /dev/null +++ b/TRADING_TDD_PLAN.md @@ -0,0 +1,453 @@ +# Trading TDD Plan — Progress Tracker + +Companion to [TRADING_DEVELOPMENT_PLAN.md](./TRADING_DEVELOPMENT_PLAN.md). Each step follows **Red → Green → Confirm** before moving on. + +**How agents should use this file:** + +1. Pick the first unchecked step in order. +2. Write the failing test (Red), implement minimal code (Green), run the Confirm gate. +3. Check off boxes and add a one-line note under **Progress log** with date and result. +4. Do not skip Confirm gates or start Phase 2 until Gate B passes. + +--- + +## Overall status + +| Milestone | Status | Notes | +|-----------|--------|-------| +| Step 0 — Test harness | ✅ Done | 2026-05-23 | +| Steps 1–11 — Phase 1 MVP | 🔄 In progress | Day 7: Steps 9–10 done; Gate A scripted test green | +| Gate A — Local loop (test mode) | ✅ Done | 2026-05-26 — `trading_orchestrator_gate_a_test.dart` | +| Gate B — Paper Alpaca E2E | ⬜ Not started | | +| Steps 12–15 — Phase 2 | ⬜ Not started | | +| Steps 16–19 — Phase 3 | ⬜ Deferred | | + +Legend: ⬜ Not started · 🔄 In progress · ✅ Done · ⏸ Blocked + +--- + +## Progress log + + + +| Date | Step | Result | +|------|------|--------| +| 2026-05-26 | 10 + Gate A | `dart test` green (57 tests); `TradingOrchestrator` (ingest → evaluate → actuate per user) + `QuestionBackgroundWorker` integration; Gate A scripted test (seed → tick → question → +10 → tick → trade_orders row → tick → cooldown) passing in test mode | +| 2026-05-26 | 9 | `dart test` green (56 tests); AlpacaTradingClient (POST /v2/orders, GET by_client_order_id, dup detection) + TradeActuator (test mode + mocked Alpaca, guardrails, idempotency); tagged `alpaca` live roundtrip script | +| 2026-05-25 | 7–8 | `dart test` green (47 tests); TradingPipeline evaluate/handleAnswer + QuestionPipeline branch | +| 2026-05-25 | 5–6 | `dart test` green (39 tests); rule engine + guardrails (pure logic) | +| 2026-05-23 | 3–4 | `dart test` green (21 tests); Alpaca MD client, ingest, poll interval; live Alpaca OK | +| 2026-05-23 | 1.3–1.4, 2 | `dart test` green (17 tests); config merge, trade orders, Alpaca env/models | +| 2026-05-23 | 0, 1.1–1.2 | `dart test` green (4 tests); migration 004 + MarketDataDb | + +--- + +## Principles + +| Principle | How it applies | +|-----------|----------------| +| **One behavior per cycle** | One failing test → minimal code → gate check | +| **No Alpaca in unit tests** | Mock HTTP clients; inject fixture JSON | +| **DB tests are integration** | Real Postgres test DB (`cyberhybridhub_test`) | +| **E2E gates are manual/scripted** | Flutter swipe + Alpaca paper dashboard at defined checkpoints | +| **Feature flags off until wired** | `TRADING_ENABLED=false` until Step 10 complete | + +--- + +## Step 0 — Test harness + +### 0.1 Server test setup + +- [x] Add `dev_dependencies: test: ^1.25.0` to `server/pubspec.yaml` +- [x] Create `server/test/` smoke test +- [x] Create `server/test/helpers/fixture_loader.dart` +- [x] Create `server/test/helpers/mock_http_client.dart` +- [x] Create `server/test/helpers/test_env.dart` +- [x] Create fixture files: + - [x] `server/test/fixtures/alpaca_latest_trade.json` + - [x] `server/test/fixtures/alpaca_daily_bars.json` + - [x] `server/test/fixtures/trading_config_default.json` + - [x] `server/test/fixtures/market_snapshots_spy_dip.json` + +**Red:** Smoke test fails until layout exists. + +**Green:** Minimal test directory and helpers. + +**Confirm:** `cd server && dart test` passes with 1 smoke test. + +--- + +### 0.2 Integration test DB + +- [x] Test helper applies migrations 001–004 on `cyberhybridhub_test` +- [x] `server/test/integration/migration_test.dart` — migration applies cleanly + +**Red:** Migration test fails (no `004_trading.sql` yet). + +**Green:** Migration runner + empty-schema apply. + +**Confirm:** `dart test test/integration/migration_test.dart` passes locally and in CI. + +--- + +### 0.3 Flutter (minimal) + +- [ ] No new Flutter tests until Step 9 — existing `SwipeQuestionTile` + SignalR contract unchanged + +--- + +## Phase 1 — Foundation (MVP) + +Maps to TRADING_DEVELOPMENT_PLAN §5, §6, §7, §8, §9, §10, §14 Phase 1. + +--- + +### Step 1 — Schema & DB accessors + +**Refs:** §5 Postgres schema · `market_data_db.dart` · `trading_config_db.dart` · `trade_orders_db.dart` + +#### 1.1 Migration + +- [x] **Red:** Integration test — INSERT into `market_data_snapshots`, `user_trading_config`, `trade_orders`; FK to `users` enforced +- [x] **Green:** `server/migrations/004_trading.sql` +- [x] **Green:** Seed `trading_config_templates` row `default_paper_watchlist` +- [x] **Confirm:** `\d market_data_snapshots` in psql; seed template queryable + +#### 1.2 MarketDataDb + +- [x] **Red:** `insertSnapshot()` then `latestForSymbol(symbol, metric)` returns newest row by `as_of` +- [x] **Green:** `server/lib/trading/market_data_db.dart` +- [x] **Confirm:** Two SPY/`last_trade` inserts → latest returns newer price + +#### 1.3 TradingConfigDb + +- [x] **Red:** `resolveEffectiveConfig(uid)` merges template + user JSONB; user override wins +- [x] **Green:** `server/lib/trading/trading_config_db.dart` +- [x] **Green:** `server/lib/trading/trading_config.dart` parser +- [x] **Confirm:** Partial user override yields merged `enabled`, `data_inputs`, `rules` + +#### 1.4 TradeOrdersDb + +- [x] **Red:** Duplicate `client_order_id` raises unique violation; `findByClientOrderId` returns existing row +- [x] **Green:** `server/lib/trading/trade_orders_db.dart` +- [x] **Confirm:** Idempotency check prevents double-insert + +--- + +### Step 2 — Alpaca env & models + +**Refs:** §3 · §11 · `alpaca_env.dart` · `alpaca_models.dart` + +- [x] **Red:** `AlpacaEnv.fromMap()` reads keys; `assertPaperOnly()` throws when live URL + `ALPACA_ALLOW_LIVE=false` +- [x] **Green:** `server/lib/alpaca/alpaca_env.dart` +- [x] **Green:** `server/lib/alpaca/alpaca_models.dart` (Trade, Bar, OrderRequest) +- [x] **Green:** Extend `server/lib/env.dart` with trading flags (after env tests pass) +- [x] **Confirm:** Unit tests — paper default, live blocked, missing keys + +--- + +### Step 3 — Alpaca Market Data client (REST) + +**Refs:** §9.1 · `alpaca_market_data_client.dart` + +- [x] **Red:** Mock HTTP — `getLatestTrade('SPY')` parses fixture; sends `APCA-API-KEY-ID` headers +- [x] **Green:** `server/lib/alpaca/alpaca_market_data_client.dart` with injectable `http.Client` +- [x] **Confirm:** Optional tagged test `@Tags(['alpaca'])` — one real call (skipped in CI) + +--- + +### Step 4 — Snapshot normalization & ingest + +**Refs:** §9.3 · `market_data_ingest.dart` + +- [x] **Red:** Config `data_inputs` with `[last_trade, daily_bar, prev_close]` → 3 snapshot rows with correct `metric`, `price`, `as_of` +- [x] **Green:** `server/lib/trading/market_data_ingest.dart` — `runIfDue()` writes via `MarketDataDb` +- [x] **Confirm:** Integration — ingest for seeded user; `SELECT * FROM market_data_snapshots WHERE symbol='SPY'` + +#### 4.1 Poll interval + +- [x] **Red:** Second call within `poll_interval_seconds` does not fetch (uses `user_trading_state.context`) +- [x] **Green:** Update `user_trading_state` on ingest +- [x] **Confirm:** Two rapid `runIfDue` calls → mock HTTP call count = 1 + +--- + +### Step 5 — Rule engine (pure logic) + +**Refs:** §4.2 · §8 · `rule_engine.dart` + +- [x] **Red:** `price_below_pct_of_ref` — SPY `last_trade=492`, `prev_close=500`, threshold `-1.5` → fires with `pct ≈ -1.6` +- [x] **Green:** `server/lib/trading/rule_engine.dart` — `RuleEngine.evaluate(rule, snapshots) → RuleEvaluation?` +- [x] **Confirm:** Table-driven unit tests: + +| Case | Expected | +|------|----------| +| Above threshold (-0.5%) | No fire | +| Missing metric | No fire | +| Stale `as_of` (> max_staleness) | No fire | +| Cooldown: rule fired today | No fire | +| Template `{{pct}}`, `{{symbol}}`, `{{price}}` | Substituted | + +--- + +### Step 6 — Guardrails + +**Refs:** §8.3 + +- [x] **Red:** `Guardrails.check()` rejects when `max_orders_per_day` exceeded +- [x] **Red:** Rejects blocklisted symbol +- [x] **Red:** Rejects when `require_question_before_order` violated +- [x] **Green:** `server/lib/trading/guardrails.dart` +- [x] **Confirm:** Server-side max notional ceiling enforced even if config allows more + +--- + +### Step 7 — Trading pipeline — question creation + +**Refs:** §8.1 · `trading_pipeline.dart` · `QuestionService` + +- [x] **Red:** Rule fires + guardrails pass + queue room → `createAndDeliverQuestion` with `pipeline_key=trading`, `pipeline_step=dip_confirm:await_confirm`, `correct_answer=10`, substituted text +- [x] **Green:** `server/lib/trading/trading_pipeline.dart` — `evaluate()` with mocked deps +- [x] **Confirm:** Integration — seeded snapshots + config → row in `questions` with correct tags +- [x] **Green:** `user_trading_state.context` records pending rule id and phase + +--- + +### Step 8 — Answer handling → order proposal + +**Refs:** §8.2 · extend `QuestionPipeline.onAnswerSubmitted` + +- [x] **Red:** `pipeline_key=trading`, user `+10`, `BranchDecision.yesNo` → match → stages order (`submit_order`), no Alpaca yet +- [x] **Red:** User `-10` → skip logged, no order +- [x] **Green:** `PipelineKeys.trading` in `question_pipeline.dart` +- [x] **Green:** `_handleTradingAnswer` in `question_pipeline.dart` (delegates to `TradingPipeline.handleAnswer`) +- [x] **Confirm:** Integration test exercises the `QuestionPipeline.onAnswerSubmitted` switch for `pipeline_key=trading` + +--- + +### Step 9 — Trade actuator + +**Refs:** §10 · `alpaca_trading_client.dart` · `TradeActuator` + +- [x] **Red:** Mock HTTP — `submitOrder(notional: 10, side: buy)` POSTs paper URL with `client_order_id`; `trade_orders` row `status=accepted` +- [x] **Red:** Duplicate `client_order_id` → 422 → resolved via `getOrderByClientOrderId` +- [x] **Green:** `server/lib/alpaca/alpaca_trading_client.dart` +- [x] **Green:** `server/lib/trading/trade_actuator.dart` (test mode short-circuit + Alpaca path) +- [x] **Confirm:** Tagged integration test (`server/test/alpaca/alpaca_trading_live_test.dart`) — paper roundtrip, skipped without credentials +- [x] **Confirm:** `client_order_id` format `{firebase_uid}-{rule_id}-{question_id}` unique (set by `TradingPipeline.handleAnswer`, enforced by `trade_orders.client_order_id UNIQUE`) + +--- + +### Step 10 — Worker integration + +**Refs:** §7 · `question_background_worker.dart` · `trading_orchestrator.dart` + +- [x] **Red:** `TRADING_ENABLED=true`, test mode (no Alpaca client) — one orchestrator tick runs evaluate + actuate against fixture snapshots +- [x] **Green:** `server/lib/trading/trading_orchestrator.dart` (ingest → evaluate → actuate, per-stage failure isolation) +- [x] **Green:** `QuestionBackgroundWorker` accepts optional `TradingOrchestrator`, runs it after `QuestionPipeline.runMaintenanceCycle` +- [x] **Green:** `bin/server.dart` builds the trading stack only when `TRADING_ENABLED=true`; uses real Alpaca clients only when `QUESTION_PIPELINE_TEST_MODE=false` and credentials present +- [x] **Confirm:** **Gate A** below — `test/integration/trading_orchestrator_gate_a_test.dart` green + +--- + +### Step 11 — End-to-end with real Alpaca paper + +**Refs:** §17 · §15 integration + +- [ ] **Red:** Manual/scripted E2E checklist (or tagged automated test) +- [ ] **Green:** Real keys in `.env`, `QUESTION_PIPELINE_TEST_MODE=false` +- [ ] **Confirm:** **Gate B** (see below) + +--- + +## Gate A — Local loop (test mode) + +**Prerequisite:** Steps 0–10 complete. + +Automated as `server/test/integration/trading_orchestrator_gate_a_test.dart`. + +- [x] Seed user with `user_trading_config.enabled=true` and dip rule +- [x] Insert fixture snapshots (or test-mode ingest) +- [x] Run one worker tick → question queued +- [x] Submit answer +10 via API +- [x] Run next tick → `trade_orders` row (fake Alpaca id in test mode) +- [x] Third tick → rule does not re-fire (cooldown) +- [x] Set `TRADING_ENABLED=true` in dev `.env` only after Gate A passes (manual — flip the flag when ready to start using the live stack against paper Alpaca) + +--- + +## Gate B — Paper Alpaca E2E + +**Prerequisite:** Gate A complete. + +- [ ] Ingest real SPY prices +- [ ] Rule fires (threshold or market conditions) +- [ ] Flutter: question via SignalR +- [ ] User swipes +10 +- [ ] Order visible in Alpaca paper UI +- [ ] `trade_orders.question_id` and `rule_id` populated + +--- + +## Phase 2 — Configuration API & hardening + +**Start only after Gate B passes.** Maps to TRADING_DEVELOPMENT_PLAN §13, §14 Phase 2. + +--- + +### Step 12 — Config validation + +- [ ] **Red:** Validator rejects >30 symbols, missing `rules[].id`, invalid `mode` +- [ ] **Green:** `TradingConfigValidator` +- [ ] **Confirm:** Invalid JSON → 400 on PUT + +--- + +### Step 13 — HTTP endpoints + +- [ ] **Red:** Handler tests — `GET/PUT /v1/me/trading/config` +- [ ] **Red:** `GET /v1/me/trading/orders` +- [ ] **Red:** `GET /v1/me/trading/snapshots?symbol=SPY` +- [ ] **Green:** `server/lib/handlers/trading_handler.dart` +- [ ] **Confirm:** curl with Firebase token; sanitized config (no secrets) + +--- + +### Step 14 — Daily guardrail counters + +- [ ] **Red:** After order submit, `context.daily_order_count` increments; resets UTC midnight +- [ ] **Green:** Counter in `user_trading_state.context` +- [ ] **Confirm:** Third order same day blocked when `max_orders_per_day=3` + +--- + +### Step 15 — Order status polling + +- [ ] **Red:** Mock Alpaca status `filled` → `trade_orders.status` and `filled_at` updated +- [ ] **Green:** Poll on worker tick or post-submit +- [ ] **Confirm:** Failed order → follow-up question (optional) + +--- + +## Phase 3 — Streaming & observability + +**Deferred until Phase 2 stable.** Maps to TRADING_DEVELOPMENT_PLAN §9.2, §14 Phase 3. + +| Step | Red test | Green impl | Done | +|------|----------|------------|------| +| 16 WS ingest | Mock WS message → snapshot upsert | `alpaca_ws_ingest.dart` | ⬜ | +| 17 Symbol cap | 31st symbol rejected | Config validator + WS manager | ⬜ | +| 18 SignalR `ReceiveTradeUpdate` | Hub mock receives on fill | Optional Flutter listener | ⬜ | +| 19 Metrics | Log ingest lag, rule fires, order latency | Structured logging | ⬜ | + +--- + +## Test pyramid (target) + +```text + ┌─────────────────┐ + │ Gate B (manual) │ 1 scenario / release + ├─────────────────┤ + │ Gate A (worker) │ 1 scripted integration + ├─────────────────┤ + │ HTTP handlers │ ~8 tests + ├─────────────────┤ + │ DB integration │ ~12 tests + ├─────────────────┤ + │ Unit (engine, │ ~40 tests + │ clients, config)│ + └─────────────────┘ +``` + +--- + +## Suggested schedule (first 2 weeks) + +| Day | Steps | Deliverable | +|-----|-------|-------------| +| 1 | 0, 1.1–1.2 | Test harness + migration + snapshot DB | +| 2 | 1.3–1.4, 2 | Config merge + env | +| 3 | 3–4 | Alpaca MD client + ingest | +| 4 | 5–6 | Rule engine + guardrails | +| 5 | 7–8 | Questions from rules + answer branch | +| 6 | 9 | Trade actuator | +| 7 | 10 | Worker wire-up → Gate A | +| 8 | 11 | Gate B with paper account | + +--- + +## CI configuration + +```yaml +# Suggested CI jobs +- dart test # unit + DB integration (no Alpaca) +- dart test --tags=alpaca # optional; secrets required; nightly/manual +``` + +**CI env:** + +```bash +TRADING_ENABLED=true +QUESTION_PIPELINE_TEST_MODE=true +DATABASE_URL=postgres://.../cyberhybridhub_test +# No ALPACA_* keys in default CI job +``` + +--- + +## Safety tests (must pass before live) + +These should **fail the build** if removed: + +- [ ] `AlpacaEnv` refuses live URL when `ALPACA_ALLOW_LIVE=false` +- [ ] Server-side max notional ceiling enforced regardless of config +- [ ] Every order has non-null `question_id` when `require_question_before_order=true` +- [ ] `client_order_id` UNIQUE prevents duplicate Alpaca POST + +--- + +## Mapping to existing code + +| New piece | Extends | +|-----------|---------| +| `TradingPipeline.evaluate` | `QuestionPipeline._canEnqueue` queue limits | +| `onAnswerSubmitted` trading branch | geography/weather switch in `question_pipeline.dart` | +| `BranchDecision.yesNo` | +10/-10 swipe (already used for weather) | +| `ExternalDataFetcher` pattern | Alpaca clients with injectable `http.Client` | +| `QUESTION_PIPELINE_TEST_MODE` | Skip Alpaca; fixture snapshots | + +**Existing touchpoints:** + +| Component | Path | +|-----------|------| +| Interval worker | `server/lib/workers/question_background_worker.dart` | +| Question pipeline | `server/lib/pipeline/question_pipeline.dart` | +| Branch decisions | `server/lib/pipeline/branch_decision.dart` | +| External fetcher | `server/lib/pipeline/external_data_fetcher.dart` | +| Env | `server/lib/env.dart` | +| Flutter hub | `lib/services/questions_hub_service.dart` | +| Flutter swipe UI | `lib/widgets/swipe_question_tile.dart` | + +--- + +## MVP definition of done + +- [ ] `dart test` in `server/` green (~40+ tests, no network in default job) +- [ ] Gate A passes with test mode +- [ ] Gate B passes on Alpaca paper +- [ ] TRADING_DEVELOPMENT_PLAN §17 scenario reproducible from seed SQL +- [ ] `TRADING_ENABLED=false` default; no secrets in Flutter +- [ ] Every order traceable via `trade_orders.question_id` + `rule_id` + +--- + +## References + +- [TRADING_DEVELOPMENT_PLAN.md](./TRADING_DEVELOPMENT_PLAN.md) +- [server/README.md](./server/README.md) +- [Alpaca Market Data API](https://docs.alpaca.markets/docs/market-data-api) +- [Alpaca Trading API](https://docs.alpaca.markets/docs/trading-api) + +--- + +*Document version: 1.0 — TDD progress tracker for Alpaca trading MVP.* diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index b934d1e..8f588c7 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -138,9 +138,10 @@ class HomeScreen extends StatelessWidget { ), if (!showQuestionPanel) Expanded( - child: Padding( + child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( diff --git a/lib/services/auth_service_firebase.dart b/lib/services/auth_service_firebase.dart index b6cedb7..ee5ce78 100644 --- a/lib/services/auth_service_firebase.dart +++ b/lib/services/auth_service_firebase.dart @@ -22,10 +22,11 @@ class AuthServiceFirebase { Future initialize() async { if (kIsWeb) { - if (googleWebOAuthClientId != null) { - await _googleSignIn.initialize(clientId: googleWebOAuthClientId); - _googleSignInReady = true; - } + // Skip eager google_sign_in init on web. Firebase Auth's signInWithPopup + // already loads Google Identity Services, and double-initializing logs + // a "google.accounts.id.initialize() is called multiple times" warning. + // We lazy-init GIS in _signInWithGoogleWeb() only if the popup fails and + // we need the GIS fallback path. return; } @@ -69,7 +70,7 @@ class AuthServiceFirebase { } } - if (!_googleSignInReady) { + if (googleWebOAuthClientId == null) { throw StateError( 'Google sign-in failed in this browser (often Firefox with strict ' 'privacy). Set googleWebOAuthClientId in lib/config/auth_config.dart ' @@ -79,6 +80,11 @@ class AuthServiceFirebase { ); } + if (!_googleSignInReady) { + await _googleSignIn.initialize(clientId: googleWebOAuthClientId); + _googleSignInReady = true; + } + final GoogleSignInAccount account = await _googleSignIn.authenticate(); final String? idToken = account.authentication.idToken; if (idToken == null) { diff --git a/scripts/test-server-alpaca.sh b/scripts/test-server-alpaca.sh new file mode 100755 index 0000000..2040327 --- /dev/null +++ b/scripts/test-server-alpaca.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Run live Alpaca market-data tests (requires keys in server/.env) +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT/server" + +if [[ -f .env ]]; then + set -a + # shellcheck source=/dev/null + source .env + set +a +fi + +dart pub get +exec dart test --tags=alpaca "$@" diff --git a/scripts/test-server.sh b/scripts/test-server.sh new file mode 100755 index 0000000..9d9e24e --- /dev/null +++ b/scripts/test-server.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Run server unit + integration tests with DATABASE_URL from server/.env +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT/server" + +if [[ -f .env ]]; then + set -a + # shellcheck source=/dev/null + source .env + set +a +fi + +dart pub get +# Exclude live Alpaca calls by default; use scripts/test-server-alpaca.sh +exec dart test --exclude-tags=alpaca "$@" diff --git a/scripts/web_static_server.dart b/scripts/web_static_server.dart new file mode 100644 index 0000000..00e97a6 --- /dev/null +++ b/scripts/web_static_server.dart @@ -0,0 +1,146 @@ +// Tiny pure-Dart static file server for serving `flutter build web` output. +// +// Designed for local development as a replacement for `flutter run -d +// web-server`, which pins itself to a single browser session via DWDS and +// breaks when the browser is closed and reopened. +// +// Behavior: +// * Serves files from a root directory passed on the command line. +// * SPA fallback: requests with no file extension that don't match a real +// file fall back to index.html so client-side routing works. +// * No-cache headers on every response so a fresh `flutter build web` +// always reaches the browser without a hard refresh. +// * Rejects path traversal (`..` segments). +// +// Usage: +// dart run scripts/web_static_server.dart + +import 'dart:async'; +import 'dart:io'; + +Future main(List args) async { + final String rootArg = args.isNotEmpty ? args[0] : 'build/web'; + final int port = args.length > 1 ? int.parse(args[1]) : 8080; + + final Directory rootDir = Directory(rootArg).absolute; + if (!rootDir.existsSync()) { + stderr.writeln('Static root not found: ${rootDir.path}'); + stderr.writeln( + 'Hint: run `flutter build web --debug` before starting this server.', + ); + exit(1); + } + + final HttpServer server = await HttpServer.bind('localhost', port); + stdout.writeln('Static server listening on http://localhost:$port'); + stdout.writeln('Serving: ${rootDir.path}'); + + await for (final HttpRequest req in server) { + unawaited(_handle(req, rootDir)); + } +} + +Future _handle(HttpRequest req, Directory root) async { + try { + String requestPath = req.uri.path; + if (requestPath.isEmpty || requestPath == '/') { + requestPath = '/index.html'; + } + if (requestPath.contains('..')) { + req.response.statusCode = HttpStatus.forbidden; + await req.response.close(); + return; + } + + final String relPath = requestPath.startsWith('/') + ? requestPath.substring(1) + : requestPath; + File target = File('${root.path}/$relPath'); + final bool hasExtension = relPath.contains('.'); + + if (!target.existsSync()) { + if (hasExtension) { + req.response + ..statusCode = HttpStatus.notFound + ..write('Not Found'); + await req.response.close(); + return; + } + // SPA fallback: extensionless path → index.html. + target = File('${root.path}/index.html'); + if (!target.existsSync()) { + req.response + ..statusCode = HttpStatus.notFound + ..write('index.html not found'); + await req.response.close(); + return; + } + } + + req.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.parse(_mimeFor(target.path)) + // Disable caching so dev rebuilds always reach the browser. + ..headers.set( + 'Cache-Control', + 'no-store, no-cache, must-revalidate, max-age=0', + ) + ..headers.set('Pragma', 'no-cache') + ..headers.set('Expires', '0'); + + await target.openRead().pipe(req.response); + } catch (e, st) { + stderr.writeln('static server error: $e\n$st'); + try { + req.response.statusCode = HttpStatus.internalServerError; + await req.response.close(); + } catch (_) { + // Ignore: response may already be closed. + } + } +} + +String _mimeFor(String filePath) { + final int dot = filePath.lastIndexOf('.'); + final String ext = dot < 0 ? '' : filePath.substring(dot + 1).toLowerCase(); + switch (ext) { + case 'html': + case 'htm': + return 'text/html; charset=utf-8'; + case 'js': + case 'mjs': + return 'application/javascript; charset=utf-8'; + case 'css': + return 'text/css; charset=utf-8'; + case 'json': + case 'map': + return 'application/json; charset=utf-8'; + case 'wasm': + return 'application/wasm'; + case 'svg': + return 'image/svg+xml'; + case 'png': + return 'image/png'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'gif': + return 'image/gif'; + case 'webp': + return 'image/webp'; + case 'ico': + return 'image/x-icon'; + case 'woff': + return 'font/woff'; + case 'woff2': + return 'font/woff2'; + case 'ttf': + return 'font/ttf'; + case 'otf': + return 'font/otf'; + case 'txt': + return 'text/plain; charset=utf-8'; + default: + return 'application/octet-stream'; + } +} diff --git a/server/README.md b/server/README.md index 27ff75a..b611acb 100644 --- a/server/README.md +++ b/server/README.md @@ -25,6 +25,27 @@ Postgres-backed profile API for the Flutter app. The API listens on `http://localhost:3000` by default (`PORT` in `.env`). +## Tests + +From the repo root (loads `server/.env` automatically): + +```bash +./scripts/test-server.sh # unit + DB integration (no live Alpaca) +./scripts/test-server-alpaca.sh # live SPY quote — requires keys in server/.env +``` + +Or from `server/`: + +```bash +# Uses DATABASE_URL from the environment or server/.env +export DATABASE_URL=postgresql://postgres:PASSWORD@localhost:5432/cyberhybridhub +dart pub get +dart test +``` + +Integration tests apply migrations `001`–`004` on `cyberhybridhub_test` and truncate +trading tables between cases. Optional override: `TEST_DATABASE_URL`. + ## Endpoints | Method | Path | Auth | diff --git a/server/bin/server.dart b/server/bin/server.dart index cda3ac4..d0b025b 100644 --- a/server/bin/server.dart +++ b/server/bin/server.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; +import '../lib/alpaca/alpaca_market_data_client.dart'; +import '../lib/alpaca/alpaca_trading_client.dart'; import '../lib/db.dart'; import '../lib/env.dart'; import '../lib/firebase_auth.dart'; @@ -10,9 +12,20 @@ import '../lib/handlers/incoming_question_handler.dart'; import '../lib/handlers/profile_handler.dart'; import '../lib/handlers/questions_handler.dart'; import '../lib/handlers/questions_hub_handler.dart'; +import '../lib/handlers/trading_dev_handler.dart'; import '../lib/pipeline/question_pipeline.dart'; import '../lib/question_service.dart'; import '../lib/questions_db.dart'; +import '../lib/trading/guardrails.dart'; +import '../lib/trading/market_data_db.dart'; +import '../lib/trading/market_data_ingest.dart'; +import '../lib/trading/trade_actuator.dart'; +import '../lib/trading/trade_orders_db.dart'; +import '../lib/trading/trading_config_db.dart'; +import '../lib/trading/trading_dev_actions.dart'; +import '../lib/trading/trading_orchestrator.dart'; +import '../lib/trading/trading_pipeline.dart'; +import '../lib/trading/user_trading_state_db.dart'; import '../lib/workers/question_background_worker.dart'; Future main() async { @@ -36,9 +49,81 @@ Future main() async { questionsDb: questionsDb, hubConnections: questionsHubConnections, ); + + // Trading wiring (gated by TRADING_ENABLED). The trading pipeline plugs + // into QuestionPipeline.onAnswerSubmitted so +10/-10 answers stage orders. + // When QUESTION_PIPELINE_TEST_MODE=true, the actuator runs without the + // Alpaca trading client (test_accepted rows) and ingest is skipped. + TradingPipeline? tradingPipeline; + TradingOrchestrator? tradingOrchestrator; + TradingDevActions? tradingDevActions; + AlpacaMarketDataClient? alpacaMarketDataClient; + AlpacaTradingClient? alpacaTradingClient; + if (env.tradingEnabled) { + final MarketDataDb marketDataDb = MarketDataDb(db.connection); + final TradingConfigDb tradingConfigDb = TradingConfigDb(db.connection); + final TradeOrdersDb tradeOrdersDb = TradeOrdersDb(db.connection); + final UserTradingStateDb tradingStateDb = + UserTradingStateDb(db.connection); + + tradingPipeline = TradingPipeline( + questionsDb: questionsDb, + questionService: questionService, + marketDataDb: marketDataDb, + tradingConfigDb: tradingConfigDb, + tradingStateDb: tradingStateDb, + guardrails: Guardrails(allowLive: env.alpaca.allowLive), + ); + + final bool useRealAlpaca = + !env.questionPipelineTestMode && env.alpaca.hasCredentials; + + MarketDataIngest? marketDataIngest; + if (useRealAlpaca && env.tradingWorkerIngestEnabled) { + alpacaMarketDataClient = AlpacaMarketDataClient(env: env.alpaca); + marketDataIngest = MarketDataIngest( + marketDataDb: marketDataDb, + tradingStateDb: tradingStateDb, + alpacaClient: alpacaMarketDataClient, + ); + } + + if (useRealAlpaca) { + alpacaTradingClient = AlpacaTradingClient(env: env.alpaca); + } + final TradeActuator tradeActuator = TradeActuator( + tradingConfigDb: tradingConfigDb, + tradingStateDb: tradingStateDb, + tradeOrdersDb: tradeOrdersDb, + questionsDb: questionsDb, + guardrails: Guardrails(allowLive: env.alpaca.allowLive), + alpacaClient: alpacaTradingClient, + ); + + tradingOrchestrator = TradingOrchestrator( + questionsDb: questionsDb, + tradingConfigDb: tradingConfigDb, + pipeline: tradingPipeline, + actuator: tradeActuator, + ingest: marketDataIngest, + ingestEnabled: env.tradingWorkerIngestEnabled, + evalEnabled: env.tradingWorkerEvalEnabled, + ); + + if (env.tradingDevEndpointsEnabled) { + tradingDevActions = TradingDevActions( + questionsDb: questionsDb, + marketDataDb: marketDataDb, + tradingConfigDb: tradingConfigDb, + tradingPipeline: tradingPipeline, + ); + } + } + final QuestionPipeline questionPipeline = QuestionPipeline( questionsDb: questionsDb, questionService: questionService, + tradingPipeline: tradingPipeline, testMode: env.questionPipelineTestMode, ); QuestionBackgroundWorker? backgroundWorker; @@ -46,6 +131,7 @@ Future main() async { backgroundWorker = QuestionBackgroundWorker( pipeline: questionPipeline, interval: Duration(seconds: env.questionWorkerIntervalSeconds), + tradingOrchestrator: tradingOrchestrator, ); backgroundWorker.start(); } @@ -64,6 +150,9 @@ Future main() async { questionService: questionService, questionPipeline: questionPipeline, ); + final Handler? tradingDev = tradingDevActions == null + ? null + : tradingDevHandler(auth: auth, devActions: tradingDevActions); final Handler handler = Pipeline() .addMiddleware(logRequests()) @@ -75,6 +164,9 @@ Future main() async { if (path == '/v1/me/incoming-question') { return incomingQuestion(request); } + if (tradingDev != null && path.startsWith(tradingDevBasePath)) { + return tradingDev(request); + } if (path.startsWith(questionsBasePath)) { return questions(request); } diff --git a/server/dart_test.yaml b/server/dart_test.yaml new file mode 100644 index 0000000..0619ec7 --- /dev/null +++ b/server/dart_test.yaml @@ -0,0 +1,7 @@ +# Integration suites share cyberhybridhub_test; run serially. +concurrency: 1 + +tags: + integration: + postgres: + alpaca: diff --git a/server/lib/alpaca/alpaca_env.dart b/server/lib/alpaca/alpaca_env.dart new file mode 100644 index 0000000..a0cdfb6 --- /dev/null +++ b/server/lib/alpaca/alpaca_env.dart @@ -0,0 +1,78 @@ +/// Alpaca API credentials and endpoints (server-only). +class AlpacaEnv { + AlpacaEnv({ + required this.apiKeyId, + required this.apiSecretKey, + required this.tradingBaseUrl, + required this.dataBaseUrl, + required this.dataFeed, + required this.allowLive, + }); + + static const String defaultPaperTradingUrl = + 'https://paper-api.alpaca.markets'; + static const String defaultDataUrl = 'https://data.alpaca.markets'; + static const String liveTradingHost = 'api.alpaca.markets'; + + final String apiKeyId; + final String apiSecretKey; + final String tradingBaseUrl; + final String dataBaseUrl; + final String dataFeed; + final bool allowLive; + + bool get hasCredentials => + apiKeyId.isNotEmpty && apiSecretKey.isNotEmpty; + + bool get isPaperUrl => + tradingBaseUrl.contains('paper-api') || + !tradingBaseUrl.contains(liveTradingHost); + + factory AlpacaEnv.fromMap(Map env) { + return AlpacaEnv( + apiKeyId: env['ALPACA_API_KEY_ID'] ?? '', + apiSecretKey: env['ALPACA_API_SECRET_KEY'] ?? '', + tradingBaseUrl: _normalizeBaseUrl( + env['ALPACA_TRADING_BASE_URL'] ?? defaultPaperTradingUrl, + ), + dataBaseUrl: _normalizeBaseUrl( + env['ALPACA_DATA_BASE_URL'] ?? defaultDataUrl, + ), + dataFeed: env['ALPACA_DATA_FEED'] ?? 'iex', + allowLive: (env['ALPACA_ALLOW_LIVE'] ?? 'false').toLowerCase() == 'true', + ); + } + + /// Strips a trailing `/v2` (or `/v2/`) and trailing slashes from a base URL + /// so the clients can append `/v2/...` without producing `/v2/v2/...`. + static String _normalizeBaseUrl(String raw) { + String url = raw.trim(); + while (url.endsWith('/')) { + url = url.substring(0, url.length - 1); + } + if (url.endsWith('/v2')) { + url = url.substring(0, url.length - 3); + } + return url; + } + + /// Refuses live trading host unless [allowLive] is true. + void assertPaperOnly() { + final Uri uri = Uri.parse(tradingBaseUrl); + final bool isLiveHost = uri.host == liveTradingHost; + if (isLiveHost && !allowLive) { + throw StateError( + 'Live Alpaca trading URL is not allowed when ALPACA_ALLOW_LIVE=false', + ); + } + } + + /// Requires non-empty API credentials before outbound Alpaca calls. + void requireCredentials() { + if (!hasCredentials) { + throw StateError( + 'ALPACA_API_KEY_ID and ALPACA_API_SECRET_KEY are required', + ); + } + } +} diff --git a/server/lib/alpaca/alpaca_market_data_client.dart b/server/lib/alpaca/alpaca_market_data_client.dart new file mode 100644 index 0000000..2cfc564 --- /dev/null +++ b/server/lib/alpaca/alpaca_market_data_client.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'alpaca_env.dart'; +import 'alpaca_models.dart'; + +/// REST client for Alpaca Market Data API v2 (IEX feed on Basic plan). +class AlpacaMarketDataClient { + AlpacaMarketDataClient({ + required AlpacaEnv env, + http.Client? httpClient, + }) : _env = env, + _client = httpClient ?? http.Client(); + + final AlpacaEnv _env; + final http.Client _client; + + Map get _headers => { + 'APCA-API-KEY-ID': _env.apiKeyId, + 'APCA-API-SECRET-KEY': _env.apiSecretKey, + }; + + /// `GET /v2/stocks/{symbol}/trades/latest` + Future getLatestTrade(String symbol) async { + _env.requireCredentials(); + final Uri uri = Uri.parse( + '${_env.dataBaseUrl}/v2/stocks/${Uri.encodeComponent(symbol)}/trades/latest', + ).replace(queryParameters: {'feed': _env.dataFeed}); + + final http.Response response = await _client.get(uri, headers: _headers); + if (response.statusCode != 200) { + throw AlpacaMarketDataException( + 'getLatestTrade($symbol) failed: ${response.statusCode} ${response.body}', + ); + } + return AlpacaLatestTradeResponse.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// `GET /v2/stocks/bars` — batched symbols, daily bars newest last. + Future getDailyBars( + List symbols, { + int limit = 2, + }) async { + _env.requireCredentials(); + if (symbols.isEmpty) { + return AlpacaBarsResponse(barsBySymbol: >{}); + } + + final Uri uri = Uri.parse('${_env.dataBaseUrl}/v2/stocks/bars').replace( + queryParameters: { + 'symbols': symbols.join(','), + 'timeframe': '1Day', + 'limit': limit.toString(), + 'feed': _env.dataFeed, + }, + ); + + final http.Response response = await _client.get(uri, headers: _headers); + if (response.statusCode != 200) { + throw AlpacaMarketDataException( + 'getDailyBars failed: ${response.statusCode} ${response.body}', + ); + } + return AlpacaBarsResponse.fromJson( + jsonDecode(response.body) as Map, + ); + } + + void close() => _client.close(); +} + +class AlpacaMarketDataException implements Exception { + AlpacaMarketDataException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/server/lib/alpaca/alpaca_models.dart b/server/lib/alpaca/alpaca_models.dart new file mode 100644 index 0000000..fea68c5 --- /dev/null +++ b/server/lib/alpaca/alpaca_models.dart @@ -0,0 +1,220 @@ +/// Alpaca v2 latest-trade response: `{ "symbol", "trade" }`. +class AlpacaLatestTradeResponse { + AlpacaLatestTradeResponse({required this.symbol, required this.trade}); + + final String symbol; + final AlpacaTrade trade; + + factory AlpacaLatestTradeResponse.fromJson(Map json) { + return AlpacaLatestTradeResponse( + symbol: json['symbol']! as String, + trade: AlpacaTrade.fromJson( + Map.from(json['trade'] as Map), + ), + ); + } +} + +/// Single trade tick from market data API. +class AlpacaTrade { + AlpacaTrade({ + required this.timestamp, + required this.price, + required this.size, + this.exchange, + this.tape, + }); + + final DateTime timestamp; + final num price; + final num size; + final String? exchange; + final String? tape; + + factory AlpacaTrade.fromJson(Map json) { + return AlpacaTrade( + timestamp: DateTime.parse(json['t']! as String).toUtc(), + price: json['p'] as num, + size: json['s'] as num, + exchange: json['x'] as String?, + tape: json['z'] as String?, + ); + } +} + +/// Daily (or intraday) OHLCV bar. +class AlpacaBar { + AlpacaBar({ + required this.timestamp, + required this.open, + required this.high, + required this.low, + required this.close, + required this.volume, + }); + + final DateTime timestamp; + final num open; + final num high; + final num low; + final num close; + final num volume; + + factory AlpacaBar.fromJson(Map json) { + return AlpacaBar( + timestamp: DateTime.parse(json['t']! as String).toUtc(), + open: json['o'] as num, + high: json['h'] as num, + low: json['l'] as num, + close: json['c'] as num, + volume: json['v'] as num, + ); + } +} + +/// Multi-symbol bars response: `{ "bars": { "SPY": [ ... ] } }`. +class AlpacaBarsResponse { + AlpacaBarsResponse({required this.barsBySymbol}); + + final Map> barsBySymbol; + + factory AlpacaBarsResponse.fromJson(Map json) { + final Map rawBars = + Map.from(json['bars'] as Map? ?? {}); + final Map> parsed = >{}; + for (final MapEntry entry in rawBars.entries) { + final List list = entry.value as List? ?? []; + parsed[entry.key] = list + .whereType() + .map((Map m) => + AlpacaBar.fromJson(Map.from(m))) + .toList(); + } + return AlpacaBarsResponse(barsBySymbol: parsed); + } + + AlpacaBar? latestBar(String symbol) { + final List? bars = barsBySymbol[symbol]; + if (bars == null || bars.isEmpty) { + return null; + } + return bars.last; + } + + /// Prior daily bar when [limit] ≥ 2 (used for `prev_close` metric). + AlpacaBar? previousDailyBar(String symbol) { + final List? bars = barsBySymbol[symbol]; + if (bars == null || bars.length < 2) { + return null; + } + return bars[bars.length - 2]; + } +} + +/// Parsed `POST /v2/orders` (or `GET /v2/orders/by_client_order_id`) response. +/// +/// Captures the fields the trade actuator uses to persist a `trade_orders` +/// row: Alpaca order id, client order id, status, symbol, side, type, +/// notional/qty, fill price, and timestamps. +class AlpacaOrderResponse { + AlpacaOrderResponse({ + required this.id, + required this.clientOrderId, + required this.symbol, + required this.side, + required this.type, + required this.status, + this.notional, + this.qty, + this.filledQty, + this.filledAvgPrice, + this.submittedAt, + this.filledAt, + this.raw, + }); + + final String id; + final String clientOrderId; + final String symbol; + final String side; + final String type; + final String status; + final num? notional; + final num? qty; + final num? filledQty; + final num? filledAvgPrice; + final DateTime? submittedAt; + final DateTime? filledAt; + final Map? raw; + + factory AlpacaOrderResponse.fromJson(Map json) { + return AlpacaOrderResponse( + id: json['id']! as String, + clientOrderId: json['client_order_id']! as String, + symbol: json['symbol']! as String, + side: json['side']! as String, + type: json['type']! as String, + status: json['status']! as String, + notional: _readOptionalNum(json['notional']), + qty: _readOptionalNum(json['qty']), + filledQty: _readOptionalNum(json['filled_qty']), + filledAvgPrice: _readOptionalNum(json['filled_avg_price']), + submittedAt: _readOptionalDateTime(json['submitted_at']), + filledAt: _readOptionalDateTime(json['filled_at']), + raw: json, + ); + } + + static num? _readOptionalNum(Object? value) { + if (value == null) return null; + if (value is num) return value; + if (value is String) { + if (value.isEmpty) return null; + return num.tryParse(value); + } + return null; + } + + static DateTime? _readOptionalDateTime(Object? value) { + if (value == null) return null; + if (value is DateTime) return value.toUtc(); + if (value is String) { + if (value.isEmpty) return null; + return DateTime.tryParse(value)?.toUtc(); + } + return null; + } +} + +/// Body for `POST /v2/orders` (market by notional). +class AlpacaOrderRequest { + AlpacaOrderRequest({ + required this.symbol, + required this.side, + required this.type, + required this.timeInForce, + required this.clientOrderId, + this.notional, + this.qty, + }); + + final String symbol; + final String side; + final String type; + final String timeInForce; + final String clientOrderId; + final num? notional; + final num? qty; + + Map toJson() { + return { + 'symbol': symbol, + 'side': side, + 'type': type, + 'time_in_force': timeInForce, + 'client_order_id': clientOrderId, + if (notional != null) 'notional': notional, + if (qty != null) 'qty': qty, + }; + } +} diff --git a/server/lib/alpaca/alpaca_trading_client.dart b/server/lib/alpaca/alpaca_trading_client.dart new file mode 100644 index 0000000..bc29516 --- /dev/null +++ b/server/lib/alpaca/alpaca_trading_client.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'alpaca_env.dart'; +import 'alpaca_models.dart'; + +/// REST client for Alpaca Trading API v2 (paper by default). +/// +/// Step 9 (`TRADING_TDD_PLAN.md`) — wraps `POST /v2/orders` and +/// `GET /v2/orders:by_client_order_id` with idempotent semantics. +class AlpacaTradingClient { + AlpacaTradingClient({ + required AlpacaEnv env, + http.Client? httpClient, + }) : _env = env, + _client = httpClient ?? http.Client(); + + final AlpacaEnv _env; + final http.Client _client; + + Map get _headers => { + 'APCA-API-KEY-ID': _env.apiKeyId, + 'APCA-API-SECRET-KEY': _env.apiSecretKey, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + /// `POST /v2/orders` — places a market order. + /// + /// Throws [AlpacaTradingDuplicateClientOrderIdException] when Alpaca rejects + /// the request because [request.clientOrderId] was already used. Callers + /// should resolve the existing order with [getOrderByClientOrderId]. + Future submitOrder(AlpacaOrderRequest request) async { + _env.requireCredentials(); + _env.assertPaperOnly(); + final Uri uri = Uri.parse('${_env.tradingBaseUrl}/v2/orders'); + + final http.Response response = await _client.post( + uri, + headers: _headers, + body: jsonEncode(request.toJson()), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return AlpacaOrderResponse.fromJson( + jsonDecode(response.body) as Map, + ); + } + + if (response.statusCode == 422 && + response.body.toLowerCase().contains('client_order_id')) { + throw AlpacaTradingDuplicateClientOrderIdException( + clientOrderId: request.clientOrderId, + body: response.body, + ); + } + + throw AlpacaTradingException( + 'submitOrder failed: ${response.statusCode} ${response.body}', + ); + } + + /// `GET /v2/orders:by_client_order_id?client_order_id=...` — returns null + /// when Alpaca has no order under that id (404). + Future getOrderByClientOrderId( + String clientOrderId, + ) async { + _env.requireCredentials(); + final Uri uri = + Uri.parse('${_env.tradingBaseUrl}/v2/orders:by_client_order_id').replace( + queryParameters: {'client_order_id': clientOrderId}, + ); + + final http.Response response = await _client.get(uri, headers: _headers); + if (response.statusCode == 200) { + return AlpacaOrderResponse.fromJson( + jsonDecode(response.body) as Map, + ); + } + if (response.statusCode == 404) { + return null; + } + throw AlpacaTradingException( + 'getOrderByClientOrderId failed: ${response.statusCode} ${response.body}', + ); + } + + void close() => _client.close(); +} + +class AlpacaTradingException implements Exception { + AlpacaTradingException(this.message); + final String message; + @override + String toString() => message; +} + +class AlpacaTradingDuplicateClientOrderIdException + extends AlpacaTradingException { + AlpacaTradingDuplicateClientOrderIdException({ + required this.clientOrderId, + required this.body, + }) : super('duplicate client_order_id=$clientOrderId: $body'); + + final String clientOrderId; + final String body; +} diff --git a/server/lib/env.dart b/server/lib/env.dart index d1a59c7..dd39040 100644 --- a/server/lib/env.dart +++ b/server/lib/env.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'alpaca/alpaca_env.dart'; import 'package:dotenv/dotenv.dart'; class ServerEnv { @@ -10,6 +11,11 @@ class ServerEnv { required this.questionWorkerEnabled, required this.questionWorkerIntervalSeconds, required this.questionPipelineTestMode, + required this.tradingEnabled, + required this.tradingWorkerIngestEnabled, + required this.tradingWorkerEvalEnabled, + required this.tradingDevEndpointsEnabled, + required this.alpaca, }); final String databaseUrl; @@ -18,11 +24,33 @@ class ServerEnv { final bool questionWorkerEnabled; final int questionWorkerIntervalSeconds; final bool questionPipelineTestMode; + final bool tradingEnabled; + final bool tradingWorkerIngestEnabled; + final bool tradingWorkerEvalEnabled; + + /// Mounts dev-only endpoints under `/v1/me/trading/dev/*` (e.g. `force-fire`). + /// Default false — never enable in production. + final bool tradingDevEndpointsEnabled; + final AlpacaEnv alpaca; static ServerEnv load() { final DotEnv env = DotEnv(includePlatformEnvironment: true) ..load(['.env']); + // Build a sanitized snapshot of the Alpaca-relevant keys for AlpacaEnv. + const List alpacaKeys = [ + 'ALPACA_API_KEY_ID', + 'ALPACA_API_SECRET_KEY', + 'ALPACA_TRADING_BASE_URL', + 'ALPACA_DATA_BASE_URL', + 'ALPACA_DATA_FEED', + 'ALPACA_ALLOW_LIVE', + ]; + final Map envMap = { + for (final String key in alpacaKeys) + if (env[key] != null && env[key]!.isNotEmpty) key: env[key]!, + }; + final String? databaseUrl = env['DATABASE_URL']; if (databaseUrl == null || databaseUrl.isEmpty) { stderr.writeln('DATABASE_URL is required in server/.env'); @@ -42,6 +70,18 @@ class ServerEnv { int.tryParse(env['QUESTION_WORKER_INTERVAL_SECONDS'] ?? '60') ?? 60; final bool pipelineTestMode = (env['QUESTION_PIPELINE_TEST_MODE'] ?? 'false').toLowerCase() == 'true'; + final bool tradingEnabled = + (env['TRADING_ENABLED'] ?? 'false').toLowerCase() == 'true'; + final bool tradingWorkerIngestEnabled = + (env['TRADING_WORKER_INGEST_ENABLED'] ?? 'true').toLowerCase() != + 'false'; + final bool tradingWorkerEvalEnabled = + (env['TRADING_WORKER_EVAL_ENABLED'] ?? 'true').toLowerCase() != 'false'; + final bool tradingDevEndpointsEnabled = + (env['TRADING_DEV_ENDPOINTS_ENABLED'] ?? 'false').toLowerCase() == + 'true'; + + final AlpacaEnv alpaca = AlpacaEnv.fromMap(envMap)..assertPaperOnly(); return ServerEnv._( databaseUrl: databaseUrl, @@ -50,6 +90,11 @@ class ServerEnv { questionWorkerEnabled: workerEnabled, questionWorkerIntervalSeconds: workerIntervalSeconds, questionPipelineTestMode: pipelineTestMode, + tradingEnabled: tradingEnabled, + tradingWorkerIngestEnabled: tradingWorkerIngestEnabled, + tradingWorkerEvalEnabled: tradingWorkerEvalEnabled, + tradingDevEndpointsEnabled: tradingDevEndpointsEnabled, + alpaca: alpaca, ); } } diff --git a/server/lib/handlers/trading_dev_handler.dart b/server/lib/handlers/trading_dev_handler.dart new file mode 100644 index 0000000..82c7cd9 --- /dev/null +++ b/server/lib/handlers/trading_dev_handler.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; + +import '../cors_headers.dart'; +import '../firebase_auth.dart'; +import '../trading/trading_dev_actions.dart'; + +const String tradingDevBasePath = '/v1/me/trading/dev'; + +/// Dev-only HTTP endpoints to exercise the trading flow without waiting for +/// real market conditions. Mounted only when `TRADING_DEV_ENDPOINTS_ENABLED=true`. +/// +/// Endpoints: +/// * `POST /v1/me/trading/dev/force-fire` — seeds dipped snapshots for the +/// caller and runs one [TradingPipeline.evaluate] cycle. The next worker +/// tick (or an immediate SignalR push from `createAndDeliverQuestion`) +/// surfaces the question in the Flutter UI. +Handler tradingDevHandler({ + required FirebaseAuthVerifier auth, + required TradingDevActions devActions, +}) { + final Router router = Router(); + + router.post('$tradingDevBasePath/force-fire', (Request request) async { + final String? firebaseUid = await _verify(auth, request); + if (firebaseUid == null) { + return _jsonResponse(401, {'error': 'Unauthorized'}); + } + try { + final ForceFireResult result = await devActions.forceFireDip(firebaseUid); + return _jsonResponse(200, result.toJson()); + } catch (e, st) { + stderr.writeln('trading dev force-fire failed for $firebaseUid: $e\n$st'); + return _jsonResponse(500, {'error': 'Internal error'}); + } + }); + + return (Request request) async { + if (request.method == 'OPTIONS') { + return Response.ok('', headers: apiCorsHeaders()); + } + return router.call(request); + }; +} + +Future _verify(FirebaseAuthVerifier auth, Request request) { + return auth.verifyBearerToken( + request.headers['Authorization'] ?? request.headers['authorization'], + ); +} + +Response _jsonResponse(int status, Map body) { + return Response( + status, + body: jsonEncode(body), + headers: { + ...apiCorsHeaders(), + 'Content-Type': 'application/json', + }, + ); +} diff --git a/server/lib/pipeline/question_pipeline.dart b/server/lib/pipeline/question_pipeline.dart index 2203210..fbc2f52 100644 --- a/server/lib/pipeline/question_pipeline.dart +++ b/server/lib/pipeline/question_pipeline.dart @@ -4,6 +4,7 @@ import 'dart:math'; import '../question_service.dart'; import '../questions_db.dart'; +import '../trading/trading_pipeline.dart'; import 'branch_decision.dart'; import 'external_data_fetcher.dart'; @@ -20,6 +21,7 @@ abstract final class PipelineKeys { static const String root = 'root'; static const String geography = 'geography'; static const String weather = 'weather'; + static const String trading = 'trading'; } /// Steps within each pipeline branch. @@ -33,21 +35,39 @@ abstract final class PipelineSteps { static const String idle = 'idle'; } +/// Per-rule phases stored in [user_trading_state.context.rules.{ruleId}.phase]. +abstract final class TradingPhases { + /// No active question; rule engine evaluates each tick. + static const String idle = 'idle'; + + /// Question delivered, awaiting +10/-10 from the user. + static const String awaitConfirm = 'await_confirm'; + + /// User said +10; order staged in pending_orders for the actuator. + static const String submitOrder = 'submit_order'; + + /// Outcome recorded; rule returns to [idle] after cooldown rolls off. + static const String done = 'done'; +} + /// Orchestrates API-driven question creation and branches on user answers. class QuestionPipeline { QuestionPipeline({ required QuestionsDb questionsDb, required QuestionService questionService, ExternalDataFetcher? fetcher, + TradingPipeline? tradingPipeline, this.maxQueuedQuestions = 3, this.testMode = false, }) : _questionsDb = questionsDb, _questionService = questionService, - _fetcher = fetcher ?? ExternalDataFetcher(); + _fetcher = fetcher ?? ExternalDataFetcher(), + _tradingPipeline = tradingPipeline; final QuestionsDb _questionsDb; final QuestionService _questionService; final ExternalDataFetcher _fetcher; + final TradingPipeline? _tradingPipeline; final int maxQueuedQuestions; final bool testMode; @@ -164,6 +184,14 @@ class QuestionPipeline { correctAnswer: correctAnswer, context: context, ); + case PipelineKeys.trading: + if (_tradingPipeline != null) { + await _tradingPipeline.handleAnswer( + firebaseUid: firebaseUid, + answeredQuestion: answeredQuestion, + userResponse: userResponse, + ); + } } } diff --git a/server/lib/trading/guardrails.dart b/server/lib/trading/guardrails.dart new file mode 100644 index 0000000..1ff52da --- /dev/null +++ b/server/lib/trading/guardrails.dart @@ -0,0 +1,149 @@ +import 'trading_config.dart'; + +/// Why a trade was rejected. `null` means the guardrails passed. +enum GuardrailRejectionReason { + tradingDisabled, + blocklistedSymbol, + symbolNotInWatchlist, + maxOrdersPerDayExceeded, + maxNotionalUsdPer4hExceeded, + serverMaxNotionalUsdExceeded, + questionRequired, + unansweredQuestion, + livePaperMismatch, +} + +/// Outcome of a guardrail check. +class GuardrailDecision { + GuardrailDecision._({required this.allowed, this.reason, this.detail}); + + factory GuardrailDecision.allow() => + GuardrailDecision._(allowed: true); + + factory GuardrailDecision.reject( + GuardrailRejectionReason reason, { + String? detail, + }) => + GuardrailDecision._(allowed: false, reason: reason, detail: detail); + + final bool allowed; + final GuardrailRejectionReason? reason; + final String? detail; + + @override + String toString() => allowed + ? 'GuardrailDecision.allow' + : 'GuardrailDecision.reject(${reason!.name}${detail == null ? '' : ': $detail'})'; +} + +/// Pre-trade safety checks executed before any Alpaca POST. +/// +/// Guardrails take precedence over user-supplied config: even if config sets +/// `max_notional_usd_per_4h` higher than [serverMaxNotionalUsd], the server +/// ceiling wins. +/// +/// The notional cap is a **rolling 4-hour window** — not a calendar-day cap — +/// so a strategy can "double down" later in the day when a thesis continues +/// to look attractive, while still bounding the blast radius of a runaway +/// rule. Order-count is still a calendar-day cap. +class Guardrails { + Guardrails({ + this.serverMaxNotionalUsd = 50, + this.allowLive = false, + this.windowDuration = const Duration(hours: 4), + }); + + /// Hard server-side ceiling per order regardless of user config. + final num serverMaxNotionalUsd; + + /// Server-wide live-trading allow flag (mirrors `ALPACA_ALLOW_LIVE`). + final bool allowLive; + + /// Width of the rolling window used for [maxNotionalUsdPer4h]. Callers must + /// compute [notionalUsdInWindow] using this same duration (typically a + /// `SELECT … WHERE submitted_at >= now() - INTERVAL '4 hours'` on + /// `trade_orders`). + final Duration windowDuration; + + GuardrailDecision check({ + required EffectiveTradingConfig config, + required String symbol, + required num notionalUsd, + required int dailyOrderCount, + required num notionalUsdInWindow, + required bool hasUnansweredQuestion, + required bool questionAnswered, + }) { + if (!config.enabled) { + return GuardrailDecision.reject( + GuardrailRejectionReason.tradingDisabled, + detail: 'user trading config is disabled', + ); + } + + if (config.mode == 'live' && !allowLive) { + return GuardrailDecision.reject( + GuardrailRejectionReason.livePaperMismatch, + detail: 'live mode requires ALPACA_ALLOW_LIVE=true', + ); + } + + if (config.guardrails.symbolsBlocklist.contains(symbol)) { + return GuardrailDecision.reject( + GuardrailRejectionReason.blocklistedSymbol, + detail: symbol, + ); + } + + final Set watchlist = { + for (final DataInputConfig input in config.dataInputs) ...input.symbols, + }; + if (watchlist.isNotEmpty && !watchlist.contains(symbol)) { + return GuardrailDecision.reject( + GuardrailRejectionReason.symbolNotInWatchlist, + detail: symbol, + ); + } + + if (notionalUsd > serverMaxNotionalUsd) { + return GuardrailDecision.reject( + GuardrailRejectionReason.serverMaxNotionalUsdExceeded, + detail: '$notionalUsd > server ceiling $serverMaxNotionalUsd', + ); + } + + if (dailyOrderCount >= config.guardrails.maxOrdersPerDay) { + return GuardrailDecision.reject( + GuardrailRejectionReason.maxOrdersPerDayExceeded, + detail: + '$dailyOrderCount >= ${config.guardrails.maxOrdersPerDay}', + ); + } + + if (notionalUsdInWindow + notionalUsd > + config.guardrails.maxNotionalUsdPer4h) { + return GuardrailDecision.reject( + GuardrailRejectionReason.maxNotionalUsdPer4hExceeded, + detail: + '${notionalUsdInWindow + notionalUsd} > ${config.guardrails.maxNotionalUsdPer4h} in ${windowDuration.inHours}h window', + ); + } + + if (config.guardrails.requireQuestionBeforeOrder) { + if (!questionAnswered) { + return GuardrailDecision.reject( + GuardrailRejectionReason.questionRequired, + detail: 'no confirming answer on file', + ); + } + if (hasUnansweredQuestion) { + return GuardrailDecision.reject( + GuardrailRejectionReason.unansweredQuestion, + detail: 'resolve open question before submitting order', + ); + } + } + + return GuardrailDecision.allow(); + } +} diff --git a/server/lib/trading/market_data_db.dart b/server/lib/trading/market_data_db.dart new file mode 100644 index 0000000..8837f85 --- /dev/null +++ b/server/lib/trading/market_data_db.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; + +import 'package:postgres/postgres.dart'; + +/// Normalized market data row persisted for rule evaluation. +class MarketDataSnapshot { + MarketDataSnapshot({ + required this.symbol, + required this.metric, + required this.asOf, + this.id, + this.assetClass = 'us_equity', + this.feed = 'iex', + this.price, + this.volume, + this.raw, + this.createdAt, + }); + + final int? id; + final String symbol; + final String assetClass; + final String feed; + final String metric; + final num? price; + final num? volume; + final DateTime asOf; + final Map? raw; + final DateTime? createdAt; +} + +/// Postgres access for [market_data_snapshots]. +class MarketDataDb { + MarketDataDb(this._connection); + + final Connection _connection; + + Future insertSnapshot({ + required String symbol, + required String metric, + required DateTime asOf, + String assetClass = 'us_equity', + String feed = 'iex', + num? price, + num? volume, + Map? raw, + }) async { + final Result result = await _connection.execute( + Sql.named( + ''' + INSERT INTO market_data_snapshots ( + symbol, asset_class, feed, metric, price, volume, as_of, raw + ) VALUES ( + @symbol, @asset_class, @feed, @metric, @price, @volume, @as_of, @raw::jsonb + ) + RETURNING id, symbol, asset_class, feed, metric, price, volume, as_of, raw, created_at + ''', + ), + parameters: { + 'symbol': symbol, + 'asset_class': assetClass, + 'feed': feed, + 'metric': metric, + 'price': price, + 'volume': volume, + 'as_of': asOf.toUtc(), + 'raw': raw == null ? null : jsonEncode(raw), + }, + ); + return _rowToSnapshot(result.first); + } + + /// Newest snapshot for [symbol] and [metric] by [as_of]. + Future latestForSymbol( + String symbol, + String metric, + ) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT id, symbol, asset_class, feed, metric, price, volume, as_of, raw, created_at + FROM market_data_snapshots + WHERE symbol = @symbol AND metric = @metric + ORDER BY as_of DESC + LIMIT 1 + ''', + ), + parameters: { + 'symbol': symbol, + 'metric': metric, + }, + ); + if (result.isEmpty) { + return null; + } + return _rowToSnapshot(result.first); + } + + MarketDataSnapshot _rowToSnapshot(ResultRow row) { + final Object? rawValue = row[8]; + Map? raw; + if (rawValue is Map) { + raw = rawValue; + } else if (rawValue != null) { + raw = jsonDecode(rawValue.toString()) as Map; + } + + return MarketDataSnapshot( + id: (row[0]! as num).toInt(), + symbol: row[1]! as String, + assetClass: row[2]! as String, + feed: row[3]! as String, + metric: row[4]! as String, + price: _readOptionalNumeric(row[5]), + volume: _readOptionalNumeric(row[6]), + asOf: (row[7]! as DateTime).toUtc(), + raw: raw, + createdAt: (row[9]! as DateTime).toUtc(), + ); + } + + static num? _readOptionalNumeric(Object? value) { + if (value == null) { + return null; + } + if (value is num) { + return value; + } + if (value is String) { + return num.parse(value); + } + return num.parse(value.toString()); + } +} diff --git a/server/lib/trading/market_data_ingest.dart b/server/lib/trading/market_data_ingest.dart new file mode 100644 index 0000000..7061d13 --- /dev/null +++ b/server/lib/trading/market_data_ingest.dart @@ -0,0 +1,160 @@ +import '../alpaca/alpaca_market_data_client.dart'; +import '../alpaca/alpaca_models.dart'; + +import 'market_data_db.dart'; +import 'trading_config.dart'; +import 'user_trading_state_db.dart'; + +/// Result of one [MarketDataIngest.runIfDue] cycle. +class MarketDataIngestResult { + MarketDataIngestResult({ + required this.snapshotsWritten, + required this.inputsFetched, + required this.inputsSkipped, + required this.httpRequests, + }); + + final int snapshotsWritten; + final int inputsFetched; + final int inputsSkipped; + final int httpRequests; +} + +/// Fetches Alpaca market data per [DataInputConfig] and writes snapshots. +class MarketDataIngest { + MarketDataIngest({ + required MarketDataDb marketDataDb, + required UserTradingStateDb tradingStateDb, + required AlpacaMarketDataClient alpacaClient, + }) : _marketDataDb = marketDataDb, + _tradingStateDb = tradingStateDb, + _alpacaClient = alpacaClient; + + final MarketDataDb _marketDataDb; + final UserTradingStateDb _tradingStateDb; + final AlpacaMarketDataClient _alpacaClient; + + int _httpRequests = 0; + + /// Exposed for tests (mock HTTP call count). + int get httpRequestCount => _httpRequests; + + /// Runs ingest for each [config.dataInputs] entry when poll interval has elapsed. + Future runIfDue({ + required String firebaseUid, + required EffectiveTradingConfig config, + DateTime? now, + }) async { + _httpRequests = 0; + final DateTime tick = (now ?? DateTime.now()).toUtc(); + int snapshotsWritten = 0; + int inputsFetched = 0; + int inputsSkipped = 0; + + for (final DataInputConfig input in config.dataInputs) { + final DateTime? lastFetch = + await _tradingStateDb.getInputLastFetch(firebaseUid, input.id); + if (lastFetch != null) { + final Duration elapsed = tick.difference(lastFetch); + if (elapsed.inSeconds < input.pollIntervalSeconds) { + inputsSkipped++; + continue; + } + } + + snapshotsWritten += await _ingestDataInput(input); + await _tradingStateDb.recordInputFetch(firebaseUid, input.id, tick); + inputsFetched++; + } + + return MarketDataIngestResult( + snapshotsWritten: snapshotsWritten, + inputsFetched: inputsFetched, + inputsSkipped: inputsSkipped, + httpRequests: _httpRequests, + ); + } + + Future _ingestDataInput(DataInputConfig input) async { + int written = 0; + final bool needsBars = input.metrics.contains('daily_bar') || + input.metrics.contains('prev_close'); + final bool needsTrade = input.metrics.contains('last_trade'); + + AlpacaBarsResponse? barsResponse; + if (needsBars) { + barsResponse = await _alpacaClient.getDailyBars(input.symbols, limit: 2); + _httpRequests++; + } + + if (needsTrade) { + for (final String symbol in input.symbols) { + final AlpacaLatestTradeResponse latest = + await _alpacaClient.getLatestTrade(symbol); + _httpRequests++; + await _marketDataDb.insertSnapshot( + symbol: symbol, + assetClass: input.assetClass, + feed: input.feed, + metric: 'last_trade', + price: latest.trade.price, + volume: latest.trade.size, + asOf: latest.trade.timestamp, + raw: { + 'p': latest.trade.price, + 's': latest.trade.size, + 't': latest.trade.timestamp.toIso8601String(), + }, + ); + written++; + } + } + + if (barsResponse != null) { + for (final String symbol in input.symbols) { + if (input.metrics.contains('daily_bar')) { + final AlpacaBar? bar = barsResponse.latestBar(symbol); + if (bar != null) { + await _marketDataDb.insertSnapshot( + symbol: symbol, + assetClass: input.assetClass, + feed: input.feed, + metric: 'daily_bar', + price: bar.close, + volume: bar.volume, + asOf: bar.timestamp, + raw: { + 'c': bar.close, + 'v': bar.volume, + 't': bar.timestamp.toIso8601String(), + }, + ); + written++; + } + } + if (input.metrics.contains('prev_close')) { + final AlpacaBar? prev = barsResponse.previousDailyBar(symbol); + if (prev != null) { + await _marketDataDb.insertSnapshot( + symbol: symbol, + assetClass: input.assetClass, + feed: input.feed, + metric: 'prev_close', + price: prev.close, + volume: prev.volume, + asOf: prev.timestamp, + raw: { + 'c': prev.close, + 'v': prev.volume, + 't': prev.timestamp.toIso8601String(), + }, + ); + written++; + } + } + } + } + + return written; + } +} diff --git a/server/lib/trading/rule_engine.dart b/server/lib/trading/rule_engine.dart new file mode 100644 index 0000000..556c413 --- /dev/null +++ b/server/lib/trading/rule_engine.dart @@ -0,0 +1,178 @@ +import 'market_data_db.dart'; +import 'trading_config.dart'; + +/// Why a rule did not fire. `null` means the rule fired. +enum RuleSkipReason { + unknownType, + missingMetric, + staleData, + aboveThreshold, + cooldown, + zeroReferencePrice, +} + +/// Result of evaluating a single [TradingRuleConfig] against snapshots. +class RuleEvaluation { + RuleEvaluation({ + required this.rule, + required this.fired, + this.skipReason, + this.pricePct, + this.refPrice, + this.observedPrice, + this.questionText, + this.asOf, + }); + + final TradingRuleConfig rule; + final bool fired; + final RuleSkipReason? skipReason; + + /// Signed pct change between observed and reference (e.g. -1.6 for a 1.6% dip). + final num? pricePct; + final num? refPrice; + final num? observedPrice; + + /// Template-substituted question text (only when [fired] is true). + final String? questionText; + + /// Most recent `as_of` across the snapshots used. + final DateTime? asOf; +} + +/// Pure evaluation of trading rules over [MarketDataSnapshot] inputs. +/// +/// Inputs are pre-fetched snapshots so this layer never touches the network +/// or DB. The caller (`TradingPipeline`) decides what to do with results. +class RuleEngine { + RuleEngine({DateTime Function()? clock}) : _clock = clock ?? DateTime.now; + + final DateTime Function() _clock; + + /// Evaluates [rule] against [snapshots] keyed by metric. + /// + /// [lastFiredAt] is the last time this rule fired for the user; pass `null` + /// when the rule has never fired (or in tests). [lastFiredAt] within the + /// same UTC date as `now` means the rule is on cooldown. + RuleEvaluation evaluate({ + required TradingRuleConfig rule, + required Map snapshots, + DateTime? lastFiredAt, + DateTime? now, + }) { + final DateTime evaluatedAt = (now ?? _clock()).toUtc(); + + if (rule.type != 'price_below_pct_of_ref') { + return RuleEvaluation( + rule: rule, + fired: false, + skipReason: RuleSkipReason.unknownType, + ); + } + + if (_isCooldown(lastFiredAt, evaluatedAt)) { + return RuleEvaluation( + rule: rule, + fired: false, + skipReason: RuleSkipReason.cooldown, + ); + } + + final MarketDataSnapshot? observed = snapshots['last_trade']; + final MarketDataSnapshot? reference = snapshots[rule.refMetric]; + if (observed == null || reference == null || + observed.price == null || reference.price == null) { + return RuleEvaluation( + rule: rule, + fired: false, + skipReason: RuleSkipReason.missingMetric, + ); + } + + if (reference.price == 0) { + return RuleEvaluation( + rule: rule, + fired: false, + skipReason: RuleSkipReason.zeroReferencePrice, + ); + } + + final num refPrice = reference.price!; + final num observedPrice = observed.price!; + final Duration age = evaluatedAt.difference(observed.asOf); + if (age.inSeconds > rule.maxStalenessSeconds || + age.isNegative && age.inSeconds.abs() > rule.maxStalenessSeconds) { + return RuleEvaluation( + rule: rule, + fired: false, + skipReason: RuleSkipReason.staleData, + refPrice: refPrice, + observedPrice: observedPrice, + asOf: observed.asOf, + ); + } + + final num pricePct = ((observedPrice - refPrice) / refPrice) * 100; + final bool fires = pricePct <= rule.thresholdPct; + if (!fires) { + return RuleEvaluation( + rule: rule, + fired: false, + skipReason: RuleSkipReason.aboveThreshold, + pricePct: pricePct, + refPrice: refPrice, + observedPrice: observedPrice, + asOf: observed.asOf, + ); + } + + final String questionText = _renderTemplate( + rule.questionTemplate, + symbol: rule.symbol, + price: observedPrice, + pct: pricePct, + refPrice: refPrice, + ); + + return RuleEvaluation( + rule: rule, + fired: true, + pricePct: pricePct, + refPrice: refPrice, + observedPrice: observedPrice, + questionText: questionText, + asOf: observed.asOf, + ); + } + + bool _isCooldown(DateTime? lastFiredAt, DateTime now) { + if (lastFiredAt == null) { + return false; + } + final DateTime last = lastFiredAt.toUtc(); + return last.year == now.year && + last.month == now.month && + last.day == now.day; + } + + String _renderTemplate( + String template, { + required String symbol, + required num price, + required num pct, + required num refPrice, + }) { + return template + .replaceAll('{{symbol}}', symbol) + .replaceAll('{{price}}', _formatPrice(price)) + .replaceAll('{{pct}}', _formatPct(pct)) + .replaceAll('{{ref_price}}', _formatPrice(refPrice)); + } + + String _formatPrice(num value) => value.toStringAsFixed(2); + + String _formatPct(num value) { + final num abs = value.abs(); + return abs.toStringAsFixed(abs < 10 ? 2 : 1); + } +} diff --git a/server/lib/trading/trade_actuator.dart b/server/lib/trading/trade_actuator.dart new file mode 100644 index 0000000..d366133 --- /dev/null +++ b/server/lib/trading/trade_actuator.dart @@ -0,0 +1,305 @@ +import 'dart:async'; +import 'dart:io'; + +import '../alpaca/alpaca_models.dart'; +import '../alpaca/alpaca_trading_client.dart'; +import '../questions_db.dart'; +import 'guardrails.dart'; +import 'trade_orders_db.dart'; +import 'trading_config.dart'; +import 'trading_config_db.dart'; +import 'user_trading_state_db.dart'; + +/// Outcome of one [TradeActuator.processPendingOrders] run for a user. +class TradeActuatorResult { + TradeActuatorResult({ + required this.submitted, + required this.rejected, + required this.errors, + }); + + /// `client_order_id`s for orders successfully POSTed (or test-mode shortcut). + final List submitted; + + /// Orders blocked by guardrails. Same `client_order_id` removed from pending. + final List rejected; + + /// Free-form error notes (Alpaca 5xx, DB issues, …). Pending row left in place + /// so the next worker tick can retry. + final List errors; +} + +class TradeActuatorRejection { + TradeActuatorRejection({ + required this.clientOrderId, + required this.reason, + this.detail, + }); + + final String clientOrderId; + final GuardrailRejectionReason reason; + final String? detail; +} + +/// Drains `pending_orders` from `user_trading_state.context`, applies pre-trade +/// [Guardrails], submits to Alpaca (paper) or short-circuits in test mode, and +/// persists the resulting `trade_orders` row. +/// +/// **Test mode**: when [alpacaClient] is null, no HTTP is performed; a row is +/// still inserted with `alpaca_order_id = 'test-'` and +/// `status = 'test_accepted'`. This is what the worker uses when +/// `QUESTION_PIPELINE_TEST_MODE=true`. +class TradeActuator { + TradeActuator({ + required TradingConfigDb tradingConfigDb, + required UserTradingStateDb tradingStateDb, + required TradeOrdersDb tradeOrdersDb, + required QuestionsDb questionsDb, + required Guardrails guardrails, + AlpacaTradingClient? alpacaClient, + DateTime Function()? clock, + }) : _tradingConfigDb = tradingConfigDb, + _tradingStateDb = tradingStateDb, + _tradeOrdersDb = tradeOrdersDb, + _questionsDb = questionsDb, + _guardrails = guardrails, + _alpacaClient = alpacaClient, + _clock = clock ?? DateTime.now; + + final TradingConfigDb _tradingConfigDb; + final UserTradingStateDb _tradingStateDb; + final TradeOrdersDb _tradeOrdersDb; + final QuestionsDb _questionsDb; + final Guardrails _guardrails; + final AlpacaTradingClient? _alpacaClient; + final DateTime Function() _clock; + + bool get isTestMode => _alpacaClient == null; + + Future processPendingOrders(String firebaseUid) async { + final List submitted = []; + final List rejected = []; + final List errors = []; + + final EffectiveTradingConfig? config = + await _tradingConfigDb.resolveEffectiveConfig(firebaseUid); + if (config == null) { + return TradeActuatorResult( + submitted: submitted, + rejected: rejected, + errors: errors, + ); + } + + final List> pending = + await _tradingStateDb.listPendingOrders(firebaseUid); + + for (final Map order in pending) { + final String clientOrderId = order['client_order_id']! as String; + try { + final _OrderProcessing decision = await _processOne( + firebaseUid: firebaseUid, + config: config, + order: order, + ); + if (decision.success) { + submitted.add(clientOrderId); + await _tradingStateDb.removePendingOrder( + firebaseUid: firebaseUid, + clientOrderId: clientOrderId, + ); + } else if (decision.rejection != null) { + rejected.add(decision.rejection!); + await _tradingStateDb.removePendingOrder( + firebaseUid: firebaseUid, + clientOrderId: clientOrderId, + ); + } else if (decision.error != null) { + errors.add('${clientOrderId}: ${decision.error}'); + } + } catch (e, st) { + errors.add('${clientOrderId}: $e'); + stderr.writeln( + 'TradeActuator.processPendingOrders uid=$firebaseUid ' + 'client_order_id=$clientOrderId: $e\n$st', + ); + } + } + + return TradeActuatorResult( + submitted: submitted, + rejected: rejected, + errors: errors, + ); + } + + Future<_OrderProcessing> _processOne({ + required String firebaseUid, + required EffectiveTradingConfig config, + required Map order, + }) async { + final String clientOrderId = order['client_order_id']! as String; + final String symbol = order['symbol']! as String; + final String side = order['side']! as String; + final String orderType = order['order_type'] as String? ?? 'market'; + final num notional = (order['notional_usd'] as num?) ?? 0; + final String? questionId = order['question_id'] as String?; + final String? ruleId = order['rule_id'] as String?; + + // Idempotency: existing trade_orders row → skip POST, count as submitted. + final TradeOrder? existing = + await _tradeOrdersDb.findByClientOrderId(clientOrderId); + if (existing != null) { + return _OrderProcessing.success(); + } + + // Guardrail aggregates from already-submitted orders. + final DateTime now = _clock().toUtc(); + final DateTime startOfDayUtc = DateTime.utc(now.year, now.month, now.day); + final DateTime windowStart = now.subtract(_guardrails.windowDuration); + final int dailyOrderCount = + await _tradeOrdersDb.countOrdersSince(firebaseUid, startOfDayUtc); + final num notionalInWindow = + await _tradeOrdersDb.notionalUsdInWindow(firebaseUid, windowStart); + + // Question gating: we treat this order as "answered" because TradingPipeline + // only stages an order after the user matches the confirming answer. The + // remaining check is whether any *other* trading question is still open. + final bool hasOtherUnanswered = questionId == null + ? false + : await _hasOtherUnansweredTradingQuestion(firebaseUid, questionId); + + final GuardrailDecision decision = _guardrails.check( + config: config, + symbol: symbol, + notionalUsd: notional, + dailyOrderCount: dailyOrderCount, + notionalUsdInWindow: notionalInWindow, + hasUnansweredQuestion: hasOtherUnanswered, + questionAnswered: questionId != null, + ); + if (!decision.allowed) { + return _OrderProcessing.rejected( + TradeActuatorRejection( + clientOrderId: clientOrderId, + reason: decision.reason!, + detail: decision.detail, + ), + ); + } + + if (isTestMode) { + await _tradeOrdersDb.insertOrder( + firebaseUid: firebaseUid, + clientOrderId: clientOrderId, + symbol: symbol, + side: side, + orderType: orderType, + status: 'test_accepted', + alpacaOrderId: 'test-$clientOrderId', + notionalUsd: notional, + questionId: questionId, + ruleId: ruleId, + raw: { + 'mode': 'test', + 'config_mode': config.mode, + 'submitted_at': now.toIso8601String(), + }, + ); + return _OrderProcessing.success(); + } + + return _submitToAlpaca( + firebaseUid: firebaseUid, + order: order, + config: config, + now: now, + ); + } + + Future<_OrderProcessing> _submitToAlpaca({ + required String firebaseUid, + required Map order, + required EffectiveTradingConfig config, + required DateTime now, + }) async { + final String clientOrderId = order['client_order_id']! as String; + final String symbol = order['symbol']! as String; + final String side = order['side']! as String; + final String orderType = order['order_type'] as String? ?? 'market'; + final num notional = (order['notional_usd'] as num?) ?? 0; + final String? questionId = order['question_id'] as String?; + final String? ruleId = order['rule_id'] as String?; + + final AlpacaOrderRequest request = AlpacaOrderRequest( + symbol: symbol, + side: side, + type: orderType, + timeInForce: 'day', + clientOrderId: clientOrderId, + notional: notional, + ); + + AlpacaOrderResponse? response; + try { + response = await _alpacaClient!.submitOrder(request); + } on AlpacaTradingDuplicateClientOrderIdException { + response = await _alpacaClient!.getOrderByClientOrderId(clientOrderId); + if (response == null) { + return _OrderProcessing.error('duplicate id but no order on Alpaca'); + } + } on AlpacaTradingException catch (e) { + return _OrderProcessing.error(e.message); + } + + await _tradeOrdersDb.insertOrder( + firebaseUid: firebaseUid, + clientOrderId: clientOrderId, + symbol: symbol, + side: side, + orderType: orderType, + status: response.status, + alpacaOrderId: response.id, + notionalUsd: notional, + questionId: questionId, + ruleId: ruleId, + raw: { + 'mode': config.mode, + 'alpaca': response.raw, + 'submitted_at': now.toIso8601String(), + }, + ); + return _OrderProcessing.success(); + } + + Future _hasOtherUnansweredTradingQuestion( + String firebaseUid, + String currentQuestionId, + ) async { + final List> open = + await _questionsDb.listUnansweredQuestions(firebaseUid); + for (final Map q in open) { + if (q['pipelineKey'] != 'trading') continue; + if (q['id'] == currentQuestionId) continue; + return true; + } + return false; + } +} + +class _OrderProcessing { + _OrderProcessing._({this.success_ = false, this.rejection, this.error}); + + factory _OrderProcessing.success() => + _OrderProcessing._(success_: true); + factory _OrderProcessing.rejected(TradeActuatorRejection rejection) => + _OrderProcessing._(rejection: rejection); + factory _OrderProcessing.error(String message) => + _OrderProcessing._(error: message); + + final bool success_; + final TradeActuatorRejection? rejection; + final String? error; + + bool get success => success_; +} diff --git a/server/lib/trading/trade_orders_db.dart b/server/lib/trading/trade_orders_db.dart new file mode 100644 index 0000000..e496dff --- /dev/null +++ b/server/lib/trading/trade_orders_db.dart @@ -0,0 +1,239 @@ +import 'dart:convert'; + +import 'package:postgres/postgres.dart'; +import 'package:uuid/uuid.dart'; + +/// Persisted trade order audit row. +class TradeOrder { + TradeOrder({ + required this.id, + required this.firebaseUid, + required this.clientOrderId, + required this.symbol, + required this.side, + required this.orderType, + required this.status, + this.alpacaOrderId, + this.notionalUsd, + this.qty, + this.questionId, + this.ruleId, + this.submittedAt, + this.filledAt, + this.raw, + this.createdAt, + }); + + final String id; + final String firebaseUid; + final String clientOrderId; + final String? alpacaOrderId; + final String symbol; + final String side; + final String orderType; + final num? notionalUsd; + final num? qty; + final String status; + final String? questionId; + final String? ruleId; + final DateTime? submittedAt; + final DateTime? filledAt; + final Map? raw; + final DateTime? createdAt; +} + +/// Postgres access for [trade_orders]. +class TradeOrdersDb { + TradeOrdersDb(this._connection); + + final Connection _connection; + static const Uuid _uuid = Uuid(); + + /// Counts non-terminal orders submitted at or after [since]. + /// + /// Used by guardrails for `max_orders_per_day` (pass UTC start-of-day) and + /// rolling-window order-count checks. + Future countOrdersSince(String firebaseUid, DateTime since) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT COUNT(*) + FROM trade_orders + WHERE firebase_uid = @uid + AND submitted_at IS NOT NULL + AND submitted_at >= @since + AND status NOT IN ('rejected', 'canceled', 'failed') + ''', + ), + parameters: { + 'uid': firebaseUid, + 'since': since.toUtc(), + }, + ); + if (result.isEmpty) return 0; + final Object? raw = result.first[0]; + if (raw is num) return raw.toInt(); + return num.parse(raw.toString()).toInt(); + } + + /// Sums `notional_usd` of non-terminal orders submitted at or after [since]. + /// + /// Used by guardrails for the rolling 4-hour notional cap. + Future notionalUsdInWindow(String firebaseUid, DateTime since) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT COALESCE(SUM(notional_usd), 0) + FROM trade_orders + WHERE firebase_uid = @uid + AND submitted_at IS NOT NULL + AND submitted_at >= @since + AND status NOT IN ('rejected', 'canceled', 'failed') + ''', + ), + parameters: { + 'uid': firebaseUid, + 'since': since.toUtc(), + }, + ); + if (result.isEmpty) return 0; + final Object? raw = result.first[0]; + if (raw is num) return raw; + return num.parse(raw.toString()); + } + + Future findByClientOrderId(String clientOrderId) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT id, firebase_uid, client_order_id, alpaca_order_id, symbol, side, + order_type, notional_usd, qty, status, question_id, rule_id, + submitted_at, filled_at, raw, created_at + FROM trade_orders + WHERE client_order_id = @client_order_id + ''', + ), + parameters: {'client_order_id': clientOrderId}, + ); + if (result.isEmpty) { + return null; + } + return _rowToOrder(result.first); + } + + /// Inserts a pending order. Returns existing row if [clientOrderId] already exists. + Future insertOrder({ + required String firebaseUid, + required String clientOrderId, + required String symbol, + required String side, + required String orderType, + required String status, + String? alpacaOrderId, + num? notionalUsd, + num? qty, + String? questionId, + String? ruleId, + Map? raw, + }) async { + final TradeOrder? existing = await findByClientOrderId(clientOrderId); + if (existing != null) { + return existing; + } + + final String id = _uuid.v4(); + try { + final Result result = await _connection.execute( + Sql.named( + ''' + INSERT INTO trade_orders ( + id, firebase_uid, client_order_id, alpaca_order_id, symbol, side, + order_type, notional_usd, qty, status, question_id, rule_id, raw, + submitted_at + ) VALUES ( + @id::uuid, @uid, @client_order_id, @alpaca_order_id, @symbol, @side, + @order_type, @notional_usd, @qty, @status, @question_id::uuid, + @rule_id, @raw::jsonb, @submitted_at + ) + RETURNING id, firebase_uid, client_order_id, alpaca_order_id, symbol, side, + order_type, notional_usd, qty, status, question_id, rule_id, + submitted_at, filled_at, raw, created_at + ''', + ), + parameters: { + 'id': id, + 'uid': firebaseUid, + 'client_order_id': clientOrderId, + 'alpaca_order_id': alpacaOrderId, + 'symbol': symbol, + 'side': side, + 'order_type': orderType, + 'notional_usd': notionalUsd, + 'qty': qty, + 'status': status, + 'question_id': questionId, + 'rule_id': ruleId, + 'raw': raw == null ? null : jsonEncode(raw), + 'submitted_at': DateTime.now().toUtc(), + }, + ); + return _rowToOrder(result.first); + } on ServerException catch (e) { + if (e.code == '23505') { + final TradeOrder? raced = await findByClientOrderId(clientOrderId); + if (raced != null) { + return raced; + } + } + rethrow; + } + } + + TradeOrder _rowToOrder(ResultRow row) { + final Object idValue = row[0]!; + final String id = idValue is String ? idValue : idValue.toString(); + final Object? questionRaw = row[10]; + final String? questionId = + questionRaw == null ? null : questionRaw.toString(); + + final Object? rawValue = row[14]; + Map? raw; + if (rawValue is Map) { + raw = rawValue; + } else if (rawValue != null) { + raw = jsonDecode(rawValue.toString()) as Map; + } + + return TradeOrder( + id: id, + firebaseUid: row[1]! as String, + clientOrderId: row[2]! as String, + alpacaOrderId: row[3] as String?, + symbol: row[4]! as String, + side: row[5]! as String, + orderType: row[6]! as String, + notionalUsd: _readOptionalNumeric(row[7]), + qty: _readOptionalNumeric(row[8]), + status: row[9]! as String, + questionId: questionId, + ruleId: row[11] as String?, + submittedAt: row[12] as DateTime?, + filledAt: row[13] as DateTime?, + raw: raw, + createdAt: row[15] as DateTime?, + ); + } + + static num? _readOptionalNumeric(Object? value) { + if (value == null) { + return null; + } + if (value is num) { + return value; + } + if (value is String) { + return num.parse(value); + } + return num.parse(value.toString()); + } +} diff --git a/server/lib/trading/trading_config.dart b/server/lib/trading/trading_config.dart new file mode 100644 index 0000000..e2208ff --- /dev/null +++ b/server/lib/trading/trading_config.dart @@ -0,0 +1,221 @@ +/// Parsed trading configuration (template + user override merged). +class EffectiveTradingConfig { + EffectiveTradingConfig({ + required this.version, + required this.enabled, + required this.mode, + required this.dataInputs, + required this.rules, + required this.guardrails, + this.templateName, + }); + + final int version; + final bool enabled; + final String mode; + final List dataInputs; + final List rules; + final GuardrailsConfig guardrails; + final String? templateName; + + factory EffectiveTradingConfig.fromJson( + Map json, { + String? templateName, + bool? userEnabled, + }) { + final bool configEnabled = json['enabled'] as bool? ?? false; + return EffectiveTradingConfig( + version: (json['version'] as num?)?.toInt() ?? 1, + enabled: userEnabled ?? configEnabled, + mode: json['mode'] as String? ?? 'paper', + dataInputs: _parseDataInputs(json['data_inputs']), + rules: _parseRules(json['rules']), + guardrails: GuardrailsConfig.fromJson( + json['guardrails'] as Map? ?? {}, + ), + templateName: templateName, + ); + } + + static List _parseDataInputs(Object? raw) { + if (raw is! List) { + return []; + } + return raw + .whereType() + .map((Map m) => + DataInputConfig.fromJson(Map.from(m))) + .toList(); + } + + static List _parseRules(Object? raw) { + if (raw is! List) { + return []; + } + return raw + .whereType() + .map((Map m) => + TradingRuleConfig.fromJson(Map.from(m))) + .toList(); + } + + /// Deep-merge [override] onto [base]. Lists with `id` fields merge by id. + static Map mergeJson( + Map base, + Map override, + ) { + final Map result = Map.from(base); + for (final MapEntry entry in override.entries) { + final Object? baseValue = base[entry.key]; + final Object? overrideValue = entry.value; + if (entry.key == 'data_inputs' || entry.key == 'rules') { + result[entry.key] = _mergeListById( + baseValue is List ? baseValue : [], + overrideValue is List ? overrideValue : [], + ); + } else if (baseValue is Map && + overrideValue is Map) { + result[entry.key] = mergeJson(baseValue, overrideValue); + } else { + result[entry.key] = overrideValue; + } + } + return result; + } + + static List _mergeListById(List base, List override) { + final Map> byId = >{}; + for (final Object? item in base) { + if (item is Map) { + final Map map = Map.from(item); + final String? id = map['id'] as String?; + if (id != null) { + byId[id] = map; + } + } + } + for (final Object? item in override) { + if (item is Map) { + final Map patch = Map.from(item); + final String? id = patch['id'] as String?; + if (id == null) { + continue; + } + byId[id] = byId.containsKey(id) ? mergeJson(byId[id]!, patch) : patch; + } + } + return byId.values.toList(); + } +} + +class DataInputConfig { + DataInputConfig({ + required this.id, + required this.source, + required this.assetClass, + required this.symbols, + required this.feed, + required this.pollIntervalSeconds, + required this.metrics, + }); + + final String id; + final String source; + final String assetClass; + final List symbols; + final String feed; + final int pollIntervalSeconds; + final List metrics; + + factory DataInputConfig.fromJson(Map json) { + return DataInputConfig( + id: json['id']! as String, + source: json['source'] as String? ?? 'alpaca', + assetClass: json['asset_class'] as String? ?? 'us_equity', + symbols: (json['symbols'] as List? ?? []) + .map((dynamic s) => s as String) + .toList(), + feed: json['feed'] as String? ?? 'iex', + pollIntervalSeconds: + (json['poll_interval_seconds'] as num?)?.toInt() ?? 60, + metrics: (json['metrics'] as List? ?? []) + .map((dynamic m) => m as String) + .toList(), + ); + } +} + +class TradingRuleConfig { + TradingRuleConfig({ + required this.id, + required this.type, + required this.symbol, + required this.refMetric, + required this.thresholdPct, + required this.questionTemplate, + required this.maxStalenessSeconds, + this.onAnswerMatch, + }); + + final String id; + final String type; + final String symbol; + final String refMetric; + final num thresholdPct; + final String questionTemplate; + final int maxStalenessSeconds; + final Map? onAnswerMatch; + + factory TradingRuleConfig.fromJson(Map json) { + return TradingRuleConfig( + id: json['id']! as String, + type: json['type']! as String, + symbol: json['symbol']! as String, + refMetric: json['ref_metric'] as String? ?? 'prev_close', + thresholdPct: json['threshold_pct'] as num? ?? 0, + questionTemplate: json['question_template'] as String? ?? '', + maxStalenessSeconds: + (json['max_staleness_seconds'] as num?)?.toInt() ?? 900, + onAnswerMatch: json['on_answer_match'] is Map + ? Map.from(json['on_answer_match'] as Map) + : null, + ); + } +} + +class GuardrailsConfig { + GuardrailsConfig({ + required this.maxOrdersPerDay, + required this.maxNotionalUsdPer4h, + required this.requireQuestionBeforeOrder, + required this.symbolsBlocklist, + }); + + final int maxOrdersPerDay; + + /// Cap on total order notional placed in any rolling 4-hour window. + /// + /// Phase 1 enforces this against [trade_orders.submitted_at]; the same value + /// is referenced via [Guardrails.windowDuration] when the caller computes the + /// running total. + final num maxNotionalUsdPer4h; + + final bool requireQuestionBeforeOrder; + final List symbolsBlocklist; + + factory GuardrailsConfig.fromJson(Map json) { + final num? per4h = json['max_notional_usd_per_4h'] as num?; + // Back-compat: read legacy key if present, but new configs should use _per_4h. + final num? legacyPerDay = json['max_notional_usd_per_day'] as num?; + return GuardrailsConfig( + maxOrdersPerDay: (json['max_orders_per_day'] as num?)?.toInt() ?? 3, + maxNotionalUsdPer4h: per4h ?? legacyPerDay ?? 100, + requireQuestionBeforeOrder: + json['require_question_before_order'] as bool? ?? true, + symbolsBlocklist: (json['symbols_blocklist'] as List? ?? + []) + .map((dynamic s) => s as String) + .toList(), + ); + } +} diff --git a/server/lib/trading/trading_config_db.dart b/server/lib/trading/trading_config_db.dart new file mode 100644 index 0000000..d77a2b7 --- /dev/null +++ b/server/lib/trading/trading_config_db.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; + +import 'package:postgres/postgres.dart'; + +import 'trading_config.dart'; + +/// Loads and merges [trading_config_templates] with [user_trading_config]. +class TradingConfigDb { + TradingConfigDb(this._connection); + + final Connection _connection; + + Future resolveEffectiveConfig(String firebaseUid) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT utc.template_name, utc.config, utc.enabled + FROM user_trading_config utc + WHERE utc.firebase_uid = @uid + ''', + ), + parameters: {'uid': firebaseUid}, + ); + if (result.isEmpty) { + return null; + } + + final ResultRow row = result.first; + final String? templateName = row[0] as String?; + final Map userConfig = _readJsonMap(row[1]); + final bool userEnabled = row[2]! as bool; + + Map merged = userConfig; + if (templateName != null && templateName.isNotEmpty) { + final Map? templateConfig = + await _loadTemplateConfig(templateName); + if (templateConfig != null) { + merged = EffectiveTradingConfig.mergeJson(templateConfig, userConfig); + } + } + + return EffectiveTradingConfig.fromJson( + merged, + templateName: templateName, + userEnabled: userEnabled, + ); + } + + Future upsertUserConfig({ + required String firebaseUid, + String? templateName, + Map config = const {}, + bool enabled = false, + }) async { + await _connection.execute( + Sql.named( + ''' + INSERT INTO user_trading_config (firebase_uid, template_name, config, enabled) + VALUES (@uid, @template_name, @config::jsonb, @enabled) + ON CONFLICT (firebase_uid) DO UPDATE SET + template_name = EXCLUDED.template_name, + config = EXCLUDED.config, + enabled = EXCLUDED.enabled, + updated_at = now() + ''', + ), + parameters: { + 'uid': firebaseUid, + 'template_name': templateName, + 'config': jsonEncode(config), + 'enabled': enabled, + }, + ); + } + + Future?> _loadTemplateConfig(String name) async { + final Result result = await _connection.execute( + Sql.named( + 'SELECT config FROM trading_config_templates WHERE name = @name', + ), + parameters: {'name': name}, + ); + if (result.isEmpty) { + return null; + } + return _readJsonMap(result.first[0]); + } + + Map _readJsonMap(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return Map.from(value); + } + if (value == null) { + return {}; + } + return jsonDecode(value.toString()) as Map; + } +} diff --git a/server/lib/trading/trading_dev_actions.dart b/server/lib/trading/trading_dev_actions.dart new file mode 100644 index 0000000..c619178 --- /dev/null +++ b/server/lib/trading/trading_dev_actions.dart @@ -0,0 +1,202 @@ +import '../questions_db.dart'; +import 'market_data_db.dart'; +import 'trading_config.dart'; +import 'trading_config_db.dart'; +import 'trading_pipeline.dart'; + +/// Snapshot row seeded by [TradingDevActions.forceFireDip], returned for UI/CLI feedback. +class SeededSnapshot { + SeededSnapshot({ + required this.symbol, + required this.metric, + required this.price, + required this.asOf, + required this.created, + }); + + final String symbol; + final String metric; + final num price; + final DateTime asOf; + + /// `true` if this snapshot was newly inserted; `false` if a fresh one already + /// existed and was reused. + final bool created; + + Map toJson() => { + 'symbol': symbol, + 'metric': metric, + 'price': price, + 'asOf': asOf.toIso8601String(), + 'created': created, + }; +} + +/// Outcome of [TradingDevActions.forceFireDip]. +class ForceFireResult { + ForceFireResult({ + required this.snapshots, + required this.evaluation, + this.skipReason, + }); + + /// Snapshots that were inserted or reused for the forced dip. + final List snapshots; + + /// `null` when the config was disabled or had no dip rule. + final TradingEvaluationResult? evaluation; + + /// Non-null when the action short-circuited (e.g. config disabled). + final String? skipReason; + + Map toJson() => { + 'snapshots': snapshots.map((SeededSnapshot s) => s.toJson()).toList(), + 'evaluation': evaluation == null + ? null + : { + 'questionsCreated': evaluation!.questionsCreated, + 'rulesFired': evaluation!.rulesFired, + 'rulesSkipped': evaluation!.rulesSkipped, + }, + if (skipReason != null) 'skipReason': skipReason, + }; +} + +/// Dev-only utilities that bypass market hours / real Alpaca data to exercise +/// the trading flow end-to-end through the UI. +/// +/// Used by [tradingDevHandler] when `TRADING_DEV_ENDPOINTS_ENABLED=true`. +class TradingDevActions { + TradingDevActions({ + required QuestionsDb questionsDb, + required MarketDataDb marketDataDb, + required TradingConfigDb tradingConfigDb, + required TradingPipeline tradingPipeline, + DateTime Function()? clock, + num syntheticRefPrice = 500, + num syntheticOvershootPct = 0.5, + }) : _questionsDb = questionsDb, + _marketDataDb = marketDataDb, + _tradingConfigDb = tradingConfigDb, + _tradingPipeline = tradingPipeline, + _clock = clock ?? DateTime.now, + _syntheticRefPrice = syntheticRefPrice, + _syntheticOvershootPct = syntheticOvershootPct; + + final QuestionsDb _questionsDb; + final MarketDataDb _marketDataDb; + final TradingConfigDb _tradingConfigDb; + final TradingPipeline _tradingPipeline; + final DateTime Function() _clock; + final num _syntheticRefPrice; + final num _syntheticOvershootPct; + + /// Seeds dipped snapshots for every `price_below_pct_of_ref` rule in the + /// user's effective config, then immediately runs [TradingPipeline.evaluate]. + /// + /// For each rule, the dipped `last_trade` is positioned + /// [syntheticOvershootPct] percentage points beyond the rule's threshold + /// so the rule unambiguously fires. The `ref_metric` snapshot is reused when + /// a fresh one already exists, otherwise a synthetic one is inserted. + Future forceFireDip(String firebaseUid) async { + final EffectiveTradingConfig? config = + await _tradingConfigDb.resolveEffectiveConfig(firebaseUid); + if (config == null) { + return ForceFireResult( + snapshots: [], + evaluation: null, + skipReason: 'no_config', + ); + } + if (!config.enabled) { + return ForceFireResult( + snapshots: [], + evaluation: null, + skipReason: 'disabled', + ); + } + + final List seeded = []; + final DateTime now = _clock().toUtc(); + + // Ensure no stale unanswered trading question is parked on the queue so + // the open-question guard in TradingPipeline doesn't suppress the dev fire. + await _cancelOpenTradingQuestions(firebaseUid); + + for (final TradingRuleConfig rule in config.rules) { + if (rule.type != 'price_below_pct_of_ref') continue; + + final MarketDataSnapshot? existingRef = + await _marketDataDb.latestForSymbol(rule.symbol, rule.refMetric); + late final num refPrice; + if (existingRef != null && existingRef.price != null) { + refPrice = existingRef.price!; + seeded.add(SeededSnapshot( + symbol: rule.symbol, + metric: rule.refMetric, + price: refPrice, + asOf: existingRef.asOf, + created: false, + )); + } else { + refPrice = _syntheticRefPrice; + final DateTime refAsOf = now.subtract(const Duration(days: 1)); + await _marketDataDb.insertSnapshot( + symbol: rule.symbol, + metric: rule.refMetric, + price: refPrice, + asOf: refAsOf, + ); + seeded.add(SeededSnapshot( + symbol: rule.symbol, + metric: rule.refMetric, + price: refPrice, + asOf: refAsOf, + created: true, + )); + } + + // Compute a last_trade price `overshoot` pct beyond the threshold so + // the rule fires unambiguously. Example: thresholdPct=-1.5, + // overshoot=0.5 → last_trade is 2.0% below ref. + final num targetPct = rule.thresholdPct - _syntheticOvershootPct; + final num lastTradePrice = refPrice * (1 + targetPct / 100); + final DateTime tradeAsOf = now.subtract(const Duration(seconds: 30)); + + await _marketDataDb.insertSnapshot( + symbol: rule.symbol, + metric: 'last_trade', + price: lastTradePrice, + asOf: tradeAsOf, + ); + seeded.add(SeededSnapshot( + symbol: rule.symbol, + metric: 'last_trade', + price: lastTradePrice, + asOf: tradeAsOf, + created: true, + )); + } + + final TradingEvaluationResult evaluation = + await _tradingPipeline.evaluate(firebaseUid); + + return ForceFireResult(snapshots: seeded, evaluation: evaluation); + } + + /// Marks any unanswered `pipeline_key=trading` question with `user_response=0` + /// so a re-fire isn't blocked by the open-question guard. (Skipped answers + /// don't stage orders.) + Future _cancelOpenTradingQuestions(String firebaseUid) async { + final List> open = + await _questionsDb.listUnansweredQuestions(firebaseUid); + for (final Map q in open) { + if (q['pipelineKey'] != 'trading') continue; + await _questionsDb.submitAnswer( + questionId: q['id']! as String, + assignedUserId: firebaseUid, + userResponse: 0, + ); + } + } +} diff --git a/server/lib/trading/trading_orchestrator.dart b/server/lib/trading/trading_orchestrator.dart new file mode 100644 index 0000000..83d0712 --- /dev/null +++ b/server/lib/trading/trading_orchestrator.dart @@ -0,0 +1,148 @@ +import 'dart:async'; +import 'dart:io'; + +import '../questions_db.dart'; +import 'market_data_ingest.dart'; +import 'trade_actuator.dart'; +import 'trading_config.dart'; +import 'trading_config_db.dart'; +import 'trading_pipeline.dart'; + +/// Per-user outcome of one orchestrator tick. +class TradingTickResult { + TradingTickResult({ + required this.firebaseUid, + required this.skippedReason, + this.ingest, + this.evaluation, + this.actuator, + }); + + final String firebaseUid; + + /// Non-null when the tick was a no-op (config disabled, missing, etc.). + final String? skippedReason; + + final MarketDataIngestResult? ingest; + final TradingEvaluationResult? evaluation; + final TradeActuatorResult? actuator; + + bool get skipped => skippedReason != null; +} + +/// Coordinates the per-tick trading work for a user. +/// +/// One tick: +/// 1. Resolve effective config (skip if missing or disabled). +/// 2. Ingest market data for each [DataInputConfig] (respecting poll +/// intervals via `user_trading_state.context.ingest_last_fetch`). +/// 3. Evaluate rules → create `pipeline_key=trading` questions. +/// 4. Drain `pending_orders` via [TradeActuator] (test mode or Alpaca). +/// +/// All three stages are best-effort: a failure in one logs and continues so +/// a transient Alpaca outage during ingest doesn't block actuation of +/// already-staged orders. +class TradingOrchestrator { + TradingOrchestrator({ + required QuestionsDb questionsDb, + required TradingConfigDb tradingConfigDb, + required TradingPipeline pipeline, + required TradeActuator actuator, + MarketDataIngest? ingest, + bool ingestEnabled = true, + bool evalEnabled = true, + DateTime Function()? clock, + }) : _questionsDb = questionsDb, + _tradingConfigDb = tradingConfigDb, + _pipeline = pipeline, + _actuator = actuator, + _ingest = ingest, + _ingestEnabled = ingestEnabled, + _evalEnabled = evalEnabled, + _clock = clock ?? DateTime.now; + + final QuestionsDb _questionsDb; + final TradingConfigDb _tradingConfigDb; + final TradingPipeline _pipeline; + final TradeActuator _actuator; + final MarketDataIngest? _ingest; + final bool _ingestEnabled; + final bool _evalEnabled; + final DateTime Function() _clock; + + bool get hasIngest => _ingest != null && _ingestEnabled; + + /// Runs [tickUser] for every Firebase UID known to [QuestionsDb]. + /// + /// Per-user errors are logged to stderr but do not abort the cycle. + Future runMaintenanceCycle() async { + final List uids = await _questionsDb.listAllUserFirebaseUids(); + for (final String uid in uids) { + try { + await tickUser(uid); + } catch (e, st) { + stderr.writeln('TradingOrchestrator tick failed for $uid: $e\n$st'); + } + } + } + + Future tickUser(String firebaseUid) async { + final EffectiveTradingConfig? config = + await _tradingConfigDb.resolveEffectiveConfig(firebaseUid); + if (config == null) { + return TradingTickResult( + firebaseUid: firebaseUid, + skippedReason: 'no_config', + ); + } + if (!config.enabled) { + return TradingTickResult( + firebaseUid: firebaseUid, + skippedReason: 'disabled', + ); + } + + MarketDataIngestResult? ingestResult; + if (hasIngest) { + try { + ingestResult = await _ingest!.runIfDue( + firebaseUid: firebaseUid, + config: config, + now: _clock(), + ); + } catch (e, st) { + stderr.writeln( + 'TradingOrchestrator ingest failed for $firebaseUid: $e\n$st', + ); + } + } + + TradingEvaluationResult? evaluationResult; + if (_evalEnabled) { + try { + evaluationResult = await _pipeline.evaluate(firebaseUid); + } catch (e, st) { + stderr.writeln( + 'TradingOrchestrator evaluate failed for $firebaseUid: $e\n$st', + ); + } + } + + TradeActuatorResult? actuatorResult; + try { + actuatorResult = await _actuator.processPendingOrders(firebaseUid); + } catch (e, st) { + stderr.writeln( + 'TradingOrchestrator actuate failed for $firebaseUid: $e\n$st', + ); + } + + return TradingTickResult( + firebaseUid: firebaseUid, + skippedReason: null, + ingest: ingestResult, + evaluation: evaluationResult, + actuator: actuatorResult, + ); + } +} diff --git a/server/lib/trading/trading_pipeline.dart b/server/lib/trading/trading_pipeline.dart new file mode 100644 index 0000000..7565cac --- /dev/null +++ b/server/lib/trading/trading_pipeline.dart @@ -0,0 +1,291 @@ +import 'dart:async'; +import 'dart:io'; + +import '../pipeline/branch_decision.dart'; +import '../pipeline/question_pipeline.dart' show PipelineKeys, TradingPhases; +import '../question_service.dart'; +import '../questions_db.dart'; +import 'guardrails.dart'; +import 'market_data_db.dart'; +import 'rule_engine.dart'; +import 'trading_config.dart'; +import 'trading_config_db.dart'; +import 'user_trading_state_db.dart'; + +/// Result of one [TradingPipeline.evaluate] cycle for a single user. +class TradingEvaluationResult { + TradingEvaluationResult({ + required this.questionsCreated, + required this.rulesFired, + required this.rulesSkipped, + }); + + final int questionsCreated; + final List rulesFired; + final List rulesSkipped; +} + +/// Bridges the rule engine, market-data snapshots, and the existing +/// question delivery pipeline. Lives next to other pipeline branches in +/// [QuestionPipeline.onAnswerSubmitted] under `pipeline_key=trading`. +class TradingPipeline { + TradingPipeline({ + required QuestionsDb questionsDb, + required QuestionService questionService, + required MarketDataDb marketDataDb, + required TradingConfigDb tradingConfigDb, + required UserTradingStateDb tradingStateDb, + RuleEngine? ruleEngine, + Guardrails? guardrails, + int maxQueuedQuestions = 3, + DateTime Function()? clock, + }) : _questionsDb = questionsDb, + _questionService = questionService, + _marketDataDb = marketDataDb, + _tradingConfigDb = tradingConfigDb, + _tradingStateDb = tradingStateDb, + _ruleEngine = ruleEngine ?? RuleEngine(), + _guardrails = guardrails ?? Guardrails(), + _maxQueuedQuestions = maxQueuedQuestions, + _clock = clock ?? DateTime.now; + + final QuestionsDb _questionsDb; + final QuestionService _questionService; + final MarketDataDb _marketDataDb; + final TradingConfigDb _tradingConfigDb; + final UserTradingStateDb _tradingStateDb; + final RuleEngine _ruleEngine; + final Guardrails _guardrails; + final int _maxQueuedQuestions; + final DateTime Function() _clock; + + /// Runs all enabled rules for [firebaseUid] against the latest snapshots. + /// + /// For each rule that fires and passes the "queue room + cooldown" checks, + /// creates a `pipeline_key=trading` question via [QuestionService] and + /// records the rule's `await_confirm` state in `user_trading_state.context`. + /// + /// Pre-trade [Guardrails] are NOT enforced here — those run in [handleAnswer] + /// and again in the actuator. The only "block" at this stage is the queue + /// limit and the per-rule daily cooldown. + Future evaluate(String firebaseUid) async { + final List fired = []; + final List skipped = []; + + final EffectiveTradingConfig? config = + await _tradingConfigDb.resolveEffectiveConfig(firebaseUid); + if (config == null || !config.enabled) { + return TradingEvaluationResult( + questionsCreated: 0, + rulesFired: fired, + rulesSkipped: skipped, + ); + } + + final DateTime now = _clock().toUtc(); + int questionsCreated = 0; + + for (final TradingRuleConfig rule in config.rules) { + try { + if (await _ruleHasOpenQuestion(firebaseUid, rule.id)) { + skipped.add('${rule.id}(open_question)'); + continue; + } + + final int queued = + await _questionsDb.countUnansweredQuestions(firebaseUid); + if (queued >= _maxQueuedQuestions) { + skipped.add('${rule.id}(queue_full)'); + continue; + } + + final Map snapshots = + await _loadSnapshotsForRule(rule); + final DateTime? lastFiredAt = + await _tradingStateDb.getRuleLastFiredAt(firebaseUid, rule.id); + + final RuleEvaluation result = _ruleEngine.evaluate( + rule: rule, + snapshots: snapshots, + lastFiredAt: lastFiredAt, + now: now, + ); + + if (!result.fired) { + skipped.add('${rule.id}(${result.skipReason?.name ?? 'no_fire'})'); + continue; + } + + final Map question = + await _questionService.createAndDeliverQuestion( + assignedUserId: firebaseUid, + questionText: result.questionText!, + correctAnswer: 10, + sourceTag: 'trading:rule:${rule.id}', + pipelineKey: PipelineKeys.trading, + pipelineStep: '${rule.id}:${TradingPhases.awaitConfirm}', + ); + questionsCreated++; + fired.add(rule.id); + + await _tradingStateDb.setRuleState( + firebaseUid: firebaseUid, + ruleId: rule.id, + state: { + 'phase': TradingPhases.awaitConfirm, + 'last_fired_at': now.toIso8601String(), + 'question_id': question['id'], + 'symbol': rule.symbol, + 'observed_price': result.observedPrice, + 'ref_price': result.refPrice, + 'pct': result.pricePct, + }, + ); + } catch (e, st) { + stderr.writeln( + 'TradingPipeline.evaluate rule=${rule.id} uid=$firebaseUid: $e\n$st', + ); + skipped.add('${rule.id}(error)'); + } + } + + return TradingEvaluationResult( + questionsCreated: questionsCreated, + rulesFired: fired, + rulesSkipped: skipped, + ); + } + + /// Handles an answered `pipeline_key=trading` question. + /// + /// `+10` (yes) → stages a pending order (no Alpaca POST yet — Step 9). + /// Anything else → logs a skip and clears the rule's `await_confirm` state. + Future handleAnswer({ + required String firebaseUid, + required Map answeredQuestion, + required num userResponse, + }) async { + final String? pipelineStep = + answeredQuestion['pipelineStep'] as String?; + if (pipelineStep == null) { + return; + } + final List parts = pipelineStep.split(':'); + if (parts.length < 2 || parts[1] != TradingPhases.awaitConfirm) { + return; + } + final String ruleId = parts.first; + final num correctAnswer = answeredQuestion['correctAnswer'] as num? ?? 10; + final String questionId = answeredQuestion['id']! as String; + final DateTime now = _clock().toUtc(); + + final EffectiveTradingConfig? config = + await _tradingConfigDb.resolveEffectiveConfig(firebaseUid); + if (config == null) { + return; + } + TradingRuleConfig? rule; + for (final TradingRuleConfig r in config.rules) { + if (r.id == ruleId) { + rule = r; + break; + } + } + if (rule == null) { + return; + } + + final BranchOutcome outcome = BranchDecision.yesNo( + userResponse: userResponse, + correctAnswer: correctAnswer, + ); + + final Map? priorState = + await _tradingStateDb.getRuleState(firebaseUid, ruleId); + final Map baseState = { + ...?priorState, + }; + + if (outcome == BranchOutcome.match) { + final Map? match = + rule.onAnswerMatch is Map + ? rule.onAnswerMatch + : null; + final String side = (match?['side'] as String?) ?? 'buy'; + final num notional = + (match?['notional_usd'] as num?) ?? 10; + final String clientOrderId = '$firebaseUid-$ruleId-$questionId'; + + await _tradingStateDb.addPendingOrder( + firebaseUid: firebaseUid, + order: { + 'rule_id': ruleId, + 'question_id': questionId, + 'symbol': rule.symbol, + 'side': side, + 'order_type': 'market', + 'notional_usd': notional, + 'client_order_id': clientOrderId, + 'staged_at': now.toIso8601String(), + }, + ); + + baseState['phase'] = TradingPhases.submitOrder; + baseState['question_id'] = questionId; + baseState['answer'] = 'yes'; + baseState['answered_at'] = now.toIso8601String(); + await _tradingStateDb.setRuleState( + firebaseUid: firebaseUid, + ruleId: ruleId, + state: baseState, + ); + } else { + await _tradingStateDb.recordSkip( + firebaseUid: firebaseUid, + ruleId: ruleId, + questionId: questionId, + at: now, + ); + + baseState['phase'] = TradingPhases.done; + baseState['question_id'] = questionId; + baseState['answer'] = 'no'; + baseState['answered_at'] = now.toIso8601String(); + await _tradingStateDb.setRuleState( + firebaseUid: firebaseUid, + ruleId: ruleId, + state: baseState, + ); + } + } + + Future> _loadSnapshotsForRule( + TradingRuleConfig rule, + ) async { + final List metrics = {'last_trade', rule.refMetric}.toList(); + final Map result = + {}; + for (final String metric in metrics) { + final MarketDataSnapshot? snap = + await _marketDataDb.latestForSymbol(rule.symbol, metric); + if (snap != null) { + result[metric] = snap; + } + } + return result; + } + + Future _ruleHasOpenQuestion( + String firebaseUid, + String ruleId, + ) async { + final List> open = + await _questionsDb.listUnansweredQuestions(firebaseUid); + return open.any((Map q) => + q['pipelineKey'] == PipelineKeys.trading && + (q['pipelineStep'] as String? ?? '').startsWith('$ruleId:')); + } + + /// Exposed for the actuator (Step 9) and tests. + Guardrails get guardrails => _guardrails; +} diff --git a/server/lib/trading/user_trading_state_db.dart b/server/lib/trading/user_trading_state_db.dart new file mode 100644 index 0000000..28fe617 --- /dev/null +++ b/server/lib/trading/user_trading_state_db.dart @@ -0,0 +1,239 @@ +import 'dart:convert'; + +import 'package:postgres/postgres.dart'; + +/// Per-user trading worker cursor ([user_trading_state]). +class UserTradingStateDb { + UserTradingStateDb(this._connection); + + final Connection _connection; + + static const String ingestContextKey = 'ingest_last_fetch'; + static const String rulesContextKey = 'rules'; + static const String pendingOrdersContextKey = 'pending_orders'; + static const String skippedContextKey = 'skipped'; + + Future ensureExists(String firebaseUid) async { + await _connection.execute( + Sql.named( + ''' + INSERT INTO user_trading_state (firebase_uid) + VALUES (@uid) + ON CONFLICT (firebase_uid) DO NOTHING + ''', + ), + parameters: {'uid': firebaseUid}, + ); + } + + Future> getContext(String firebaseUid) async { + await ensureExists(firebaseUid); + final Result result = await _connection.execute( + Sql.named( + 'SELECT context FROM user_trading_state WHERE firebase_uid = @uid', + ), + parameters: {'uid': firebaseUid}, + ); + if (result.isEmpty) { + return {}; + } + return _readJsonMap(result.first[0]); + } + + /// ISO-8601 timestamp of last successful fetch for [dataInputId], or null. + Future getInputLastFetch( + String firebaseUid, + String dataInputId, + ) async { + final Map context = await getContext(firebaseUid); + final Map ingest = Map.from( + context[ingestContextKey] as Map? ?? {}, + ); + final String? raw = ingest[dataInputId] as String?; + if (raw == null || raw.isEmpty) { + return null; + } + return DateTime.parse(raw).toUtc(); + } + + Future recordInputFetch( + String firebaseUid, + String dataInputId, + DateTime fetchedAt, + ) async { + await ensureExists(firebaseUid); + final Map context = await getContext(firebaseUid); + final Map ingest = Map.from( + context[ingestContextKey] as Map? ?? {}, + ); + ingest[dataInputId] = fetchedAt.toUtc().toIso8601String(); + context[ingestContextKey] = ingest; + + await _connection.execute( + Sql.named( + ''' + UPDATE user_trading_state + SET context = @context::jsonb, + last_ingest_at = @last_ingest_at, + updated_at = now() + WHERE firebase_uid = @uid + ''', + ), + parameters: { + 'uid': firebaseUid, + 'context': jsonEncode(context), + 'last_ingest_at': fetchedAt.toUtc(), + }, + ); + } + + Future?> getRuleState( + String firebaseUid, + String ruleId, + ) async { + final Map context = await getContext(firebaseUid); + final Map rules = Map.from( + context[rulesContextKey] as Map? ?? {}, + ); + final Map? raw = rules[ruleId] as Map?; + if (raw == null) { + return null; + } + return Map.from(raw); + } + + Future getRuleLastFiredAt( + String firebaseUid, + String ruleId, + ) async { + final Map? state = await getRuleState(firebaseUid, ruleId); + final String? raw = state?['last_fired_at'] as String?; + if (raw == null || raw.isEmpty) { + return null; + } + return DateTime.parse(raw).toUtc(); + } + + Future setRuleState({ + required String firebaseUid, + required String ruleId, + required Map state, + }) async { + await ensureExists(firebaseUid); + final Map context = await getContext(firebaseUid); + final Map rules = Map.from( + context[rulesContextKey] as Map? ?? {}, + ); + rules[ruleId] = state; + context[rulesContextKey] = rules; + await _writeContext(firebaseUid, context, touchEvalAt: true); + } + + Future>> listPendingOrders( + String firebaseUid, + ) async { + final Map context = await getContext(firebaseUid); + final List raw = + context[pendingOrdersContextKey] as List? ?? []; + return raw + .whereType() + .map((Map m) => Map.from(m)) + .toList(); + } + + Future addPendingOrder({ + required String firebaseUid, + required Map order, + }) async { + await ensureExists(firebaseUid); + final Map context = await getContext(firebaseUid); + final List existing = + context[pendingOrdersContextKey] as List? ?? []; + final List> orders = >[ + ...existing.whereType().map( + (Map m) => Map.from(m), + ), + order, + ]; + context[pendingOrdersContextKey] = orders; + await _writeContext(firebaseUid, context, touchEvalAt: true); + } + + Future removePendingOrder({ + required String firebaseUid, + required String clientOrderId, + }) async { + await ensureExists(firebaseUid); + final Map context = await getContext(firebaseUid); + final List existing = + context[pendingOrdersContextKey] as List? ?? []; + final List> kept = existing + .whereType() + .map((Map m) => Map.from(m)) + .where((Map m) => + m['client_order_id'] != clientOrderId) + .toList(); + context[pendingOrdersContextKey] = kept; + await _writeContext(firebaseUid, context, touchEvalAt: false); + } + + Future recordSkip({ + required String firebaseUid, + required String ruleId, + required String questionId, + required DateTime at, + }) async { + await ensureExists(firebaseUid); + final Map context = await getContext(firebaseUid); + final List existing = + context[skippedContextKey] as List? ?? []; + final List> skipped = >[ + ...existing.whereType().map( + (Map m) => Map.from(m), + ), + { + 'rule_id': ruleId, + 'question_id': questionId, + 'at': at.toUtc().toIso8601String(), + }, + ]; + context[skippedContextKey] = skipped; + await _writeContext(firebaseUid, context, touchEvalAt: false); + } + + Future _writeContext( + String firebaseUid, + Map context, { + required bool touchEvalAt, + }) async { + final String setEval = touchEvalAt ? ', last_eval_at = now()' : ''; + await _connection.execute( + Sql.named( + ''' + UPDATE user_trading_state + SET context = @context::jsonb, + updated_at = now() + $setEval + WHERE firebase_uid = @uid + ''', + ), + parameters: { + 'uid': firebaseUid, + 'context': jsonEncode(context), + }, + ); + } + + Map _readJsonMap(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return Map.from(value); + } + if (value == null) { + return {}; + } + return jsonDecode(value.toString()) as Map; + } +} diff --git a/server/lib/workers/question_background_worker.dart b/server/lib/workers/question_background_worker.dart index 49486a5..d1cdcda 100644 --- a/server/lib/workers/question_background_worker.dart +++ b/server/lib/workers/question_background_worker.dart @@ -2,16 +2,22 @@ import 'dart:async'; import 'dart:io'; import '../pipeline/question_pipeline.dart'; +import '../trading/trading_orchestrator.dart'; -/// Runs [QuestionPipeline.runMaintenanceCycle] on a fixed interval. +/// Runs [QuestionPipeline.runMaintenanceCycle] on a fixed interval, and +/// optionally [TradingOrchestrator.runMaintenanceCycle] right after when +/// trading is enabled. class QuestionBackgroundWorker { QuestionBackgroundWorker({ required QuestionPipeline pipeline, required Duration interval, + TradingOrchestrator? tradingOrchestrator, }) : _pipeline = pipeline, - _interval = interval; + _interval = interval, + _tradingOrchestrator = tradingOrchestrator; final QuestionPipeline _pipeline; + final TradingOrchestrator? _tradingOrchestrator; final Duration _interval; Timer? _timer; bool _running = false; @@ -21,7 +27,8 @@ class QuestionBackgroundWorker { return; } stdout.writeln( - 'Question background worker started (interval ${_interval.inSeconds}s)', + 'Question background worker started (interval ${_interval.inSeconds}s, ' + 'trading=${_tradingOrchestrator != null})', ); _timer = Timer.periodic(_interval, (_) => _tick()); unawaited(_tick()); @@ -42,8 +49,14 @@ class QuestionBackgroundWorker { await _pipeline.runMaintenanceCycle(); } catch (e, st) { stderr.writeln('Question background worker tick failed: $e\n$st'); - } finally { - _running = false; } + if (_tradingOrchestrator != null) { + try { + await _tradingOrchestrator.runMaintenanceCycle(); + } catch (e, st) { + stderr.writeln('Trading orchestrator tick failed: $e\n$st'); + } + } + _running = false; } } diff --git a/server/migrations/004_trading.sql b/server/migrations/004_trading.sql new file mode 100644 index 0000000..45297b0 --- /dev/null +++ b/server/migrations/004_trading.sql @@ -0,0 +1,97 @@ +CREATE TABLE IF NOT EXISTS market_data_snapshots ( + id BIGSERIAL PRIMARY KEY, + symbol TEXT NOT NULL, + asset_class TEXT NOT NULL DEFAULT 'us_equity', + feed TEXT NOT NULL DEFAULT 'iex', + metric TEXT NOT NULL, + price NUMERIC, + volume NUMERIC, + as_of TIMESTAMPTZ NOT NULL, + raw JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS market_data_snapshots_symbol_as_of_idx + ON market_data_snapshots (symbol, as_of DESC); + +CREATE TABLE IF NOT EXISTS trading_config_templates ( + name TEXT PRIMARY KEY, + config JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS user_trading_config ( + firebase_uid TEXT PRIMARY KEY REFERENCES users (firebase_uid) ON DELETE CASCADE, + template_name TEXT REFERENCES trading_config_templates (name), + config JSONB NOT NULL DEFAULT '{}', + enabled BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS user_trading_state ( + firebase_uid TEXT PRIMARY KEY REFERENCES users (firebase_uid) ON DELETE CASCADE, + last_ingest_at TIMESTAMPTZ, + last_eval_at TIMESTAMPTZ, + context JSONB NOT NULL DEFAULT '{}', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS trade_orders ( + id UUID PRIMARY KEY, + firebase_uid TEXT NOT NULL REFERENCES users (firebase_uid) ON DELETE CASCADE, + client_order_id TEXT NOT NULL UNIQUE, + alpaca_order_id TEXT, + symbol TEXT NOT NULL, + side TEXT NOT NULL, + order_type TEXT NOT NULL, + notional_usd NUMERIC, + qty NUMERIC, + status TEXT NOT NULL, + question_id UUID REFERENCES questions (id), + rule_id TEXT, + submitted_at TIMESTAMPTZ, + filled_at TIMESTAMPTZ, + raw JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO trading_config_templates (name, config) +VALUES ( + 'default_paper_watchlist', + '{ + "version": 1, + "enabled": true, + "mode": "paper", + "data_inputs": [ + { + "id": "primary_watchlist", + "source": "alpaca", + "asset_class": "us_equity", + "symbols": ["AAPL", "MSFT", "SPY"], + "feed": "iex", + "poll_interval_seconds": 60, + "metrics": ["last_trade", "daily_bar", "prev_close"] + } + ], + "rules": [ + { + "id": "dip_confirm", + "type": "price_below_pct_of_ref", + "symbol": "SPY", + "ref_metric": "prev_close", + "threshold_pct": -1.5, + "question_template": "SPY is down {{pct}}% vs yesterday. Swipe +10 to approve a small buy, -10 to skip.", + "on_answer_match": { "action": "propose_order", "side": "buy", "notional_usd": 10 } + } + ], + "guardrails": { + "max_orders_per_day": 3, + "max_notional_usd_per_4h": 100, + "require_question_before_order": true, + "symbols_blocklist": [] + } + }'::jsonb +) +ON CONFLICT (name) DO UPDATE SET + config = EXCLUDED.config, + updated_at = now(); diff --git a/server/pubspec.lock b/server/pubspec.lock index e6ffe5d..62cb251 100644 --- a/server/pubspec.lock +++ b/server/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b + url: "https://pub.dev" + source: hosted + version: "100.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" + url: "https://pub.dev" + source: hosted + version: "13.0.0" args: dependency: transitive description: @@ -17,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" buffer: dependency: transitive description: @@ -33,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" collection: dependency: transitive description: @@ -41,6 +73,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" crypto: dependency: transitive description: @@ -57,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" fixnum: dependency: transitive description: @@ -65,6 +121,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" http: dependency: "direct main" description: @@ -81,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -89,6 +169,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" meta: dependency: transitive description: @@ -97,6 +201,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.2" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -121,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.5.11" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" shelf: dependency: "direct main" description: @@ -137,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.5" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" shelf_router: dependency: "direct main" description: @@ -145,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.4" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: "direct main" description: @@ -153,6 +305,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: @@ -193,6 +361,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + url: "https://pub.dev" + source: hosted + version: "1.31.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + test_core: + dependency: transitive + description: + name: test_core + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + url: "https://pub.dev" + source: hosted + version: "0.6.18" typed_data: dependency: transitive description: @@ -209,6 +401,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.3" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -233,5 +441,21 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.12.0 <4.0.0" diff --git a/server/pubspec.yaml b/server/pubspec.yaml index a76a3ed..5ec4d5a 100644 --- a/server/pubspec.yaml +++ b/server/pubspec.yaml @@ -15,3 +15,6 @@ dependencies: http: ^1.6.0 uuid: ^4.5.3 web_socket_channel: ^3.0.0 + +dev_dependencies: + test: ^1.25.0 diff --git a/server/test/alpaca/alpaca_env_test.dart b/server/test/alpaca/alpaca_env_test.dart new file mode 100644 index 0000000..107bbe5 --- /dev/null +++ b/server/test/alpaca/alpaca_env_test.dart @@ -0,0 +1,75 @@ +import 'package:cyberhybridhub_server/alpaca/alpaca_env.dart'; +import 'package:test/test.dart'; + +void main() { + group('AlpacaEnv.fromMap', () { + test('defaults to paper trading and data URLs', () { + final AlpacaEnv env = AlpacaEnv.fromMap({}); + expect(env.tradingBaseUrl, AlpacaEnv.defaultPaperTradingUrl); + expect(env.dataBaseUrl, AlpacaEnv.defaultDataUrl); + expect(env.dataFeed, 'iex'); + expect(env.allowLive, isFalse); + expect(env.isPaperUrl, isTrue); + }); + + test('assertPaperOnly blocks live host when allowLive is false', () { + final AlpacaEnv env = AlpacaEnv.fromMap({ + 'ALPACA_TRADING_BASE_URL': 'https://api.alpaca.markets', + 'ALPACA_ALLOW_LIVE': 'false', + }); + expect(env.assertPaperOnly, throwsStateError); + }); + + test('assertPaperOnly allows live host when allowLive is true', () { + final AlpacaEnv env = AlpacaEnv.fromMap({ + 'ALPACA_TRADING_BASE_URL': 'https://api.alpaca.markets', + 'ALPACA_ALLOW_LIVE': 'true', + }); + expect(() => env.assertPaperOnly(), returnsNormally); + }); + + test('requireCredentials throws when keys missing', () { + final AlpacaEnv env = AlpacaEnv.fromMap({}); + expect(env.hasCredentials, isFalse); + expect(env.requireCredentials, throwsStateError); + }); + + test('requireCredentials passes when keys present', () { + final AlpacaEnv env = AlpacaEnv.fromMap({ + 'ALPACA_API_KEY_ID': 'key', + 'ALPACA_API_SECRET_KEY': 'secret', + }); + expect(() => env.requireCredentials(), returnsNormally); + }); + + test('strips trailing /v2 and trailing slashes from base URLs', () { + final List<({String input, String expected})> cases = + <({String input, String expected})>[ + ( + input: 'https://paper-api.alpaca.markets/v2', + expected: 'https://paper-api.alpaca.markets', + ), + ( + input: 'https://paper-api.alpaca.markets/v2/', + expected: 'https://paper-api.alpaca.markets', + ), + ( + input: 'https://paper-api.alpaca.markets/', + expected: 'https://paper-api.alpaca.markets', + ), + ( + input: 'https://paper-api.alpaca.markets', + expected: 'https://paper-api.alpaca.markets', + ), + ]; + for (final ({String input, String expected}) c in cases) { + final AlpacaEnv env = AlpacaEnv.fromMap({ + 'ALPACA_TRADING_BASE_URL': c.input, + 'ALPACA_DATA_BASE_URL': c.input, + }); + expect(env.tradingBaseUrl, c.expected, reason: 'input=${c.input}'); + expect(env.dataBaseUrl, c.expected, reason: 'input=${c.input}'); + } + }); + }); +} diff --git a/server/test/alpaca/alpaca_market_data_client_test.dart b/server/test/alpaca/alpaca_market_data_client_test.dart new file mode 100644 index 0000000..32bbd7f --- /dev/null +++ b/server/test/alpaca/alpaca_market_data_client_test.dart @@ -0,0 +1,56 @@ +import 'package:cyberhybridhub_server/alpaca/alpaca_env.dart'; +import 'package:cyberhybridhub_server/alpaca/alpaca_market_data_client.dart'; +import 'package:test/test.dart'; + +import '../helpers/fixture_loader.dart'; +import '../helpers/mock_http_client.dart'; + +void main() { + late FixtureLoader fixtures; + late AlpacaEnv env; + + setUp(() { + fixtures = FixtureLoader(); + env = AlpacaEnv.fromMap({ + 'ALPACA_API_KEY_ID': 'test-key', + 'ALPACA_API_SECRET_KEY': 'test-secret', + 'ALPACA_DATA_BASE_URL': 'https://data.alpaca.markets', + 'ALPACA_DATA_FEED': 'iex', + }); + }); + + test('getLatestTrade parses fixture and sends Alpaca auth headers', () async { + final Map tradeJson = + await fixtures.loadJson('alpaca_latest_trade.json'); + final MockHttpClient mock = MockHttpClient() + ..whenGetJson('/trades/latest', tradeJson); + + final AlpacaMarketDataClient client = + AlpacaMarketDataClient(env: env, httpClient: mock); + final response = await client.getLatestTrade('SPY'); + + expect(response.symbol, 'SPY'); + expect(response.trade.price, 492.15); + expect(mock.requests, hasLength(1)); + final Map headers = mock.requests.single.headers; + expect(headers['APCA-API-KEY-ID'], 'test-key'); + expect(headers['APCA-API-SECRET-KEY'], 'test-secret'); + expect(mock.requests.single.url.queryParameters['feed'], 'iex'); + }); + + test('getDailyBars parses multi-day fixture', () async { + final Map barsJson = + await fixtures.loadJson('alpaca_daily_bars.json'); + final MockHttpClient mock = MockHttpClient() + ..whenGetJson('/bars', barsJson); + + final AlpacaMarketDataClient client = + AlpacaMarketDataClient(env: env, httpClient: mock); + final bars = await client.getDailyBars(['SPY'], limit: 2); + + expect(bars.latestBar('SPY')!.close, 500.0); + expect(bars.previousDailyBar('SPY')!.close, 498.0); + expect(mock.requests.single.url.queryParameters['symbols'], 'SPY'); + expect(mock.requests.single.url.queryParameters['timeframe'], '1Day'); + }); +} diff --git a/server/test/alpaca/alpaca_market_data_live_test.dart b/server/test/alpaca/alpaca_market_data_live_test.dart new file mode 100644 index 0000000..803c6d8 --- /dev/null +++ b/server/test/alpaca/alpaca_market_data_live_test.dart @@ -0,0 +1,48 @@ +@Tags(['alpaca']) +library; + +import 'package:cyberhybridhub_server/alpaca/alpaca_env.dart'; +import 'package:cyberhybridhub_server/alpaca/alpaca_market_data_client.dart'; +import 'package:dotenv/dotenv.dart'; +import 'package:test/test.dart'; + +void main() { + late AlpacaEnv env; + AlpacaMarketDataClient? client; + + setUpAll(() { + final DotEnv dotenv = DotEnv(includePlatformEnvironment: true) + ..load(['.env']); + const List alpacaKeys = [ + 'ALPACA_API_KEY_ID', + 'ALPACA_API_SECRET_KEY', + 'ALPACA_TRADING_BASE_URL', + 'ALPACA_DATA_BASE_URL', + 'ALPACA_DATA_FEED', + 'ALPACA_ALLOW_LIVE', + ]; + final Map envMap = { + for (final String key in alpacaKeys) + if (dotenv[key] != null && dotenv[key]!.isNotEmpty) key: dotenv[key]!, + }; + env = AlpacaEnv.fromMap(envMap); + if (env.hasCredentials) { + client = AlpacaMarketDataClient(env: env); + } + }); + + tearDownAll(() { + client?.close(); + }); + + test('live getLatestTrade for SPY', () async { + if (!env.hasCredentials) { + markTestSkipped('Set ALPACA_API_KEY_ID and ALPACA_API_SECRET_KEY in server/.env'); + return; + } + + final response = await client!.getLatestTrade('SPY'); + expect(response.symbol, 'SPY'); + expect(response.trade.price, greaterThan(0)); + }); +} diff --git a/server/test/alpaca/alpaca_models_test.dart b/server/test/alpaca/alpaca_models_test.dart new file mode 100644 index 0000000..cdb61c8 --- /dev/null +++ b/server/test/alpaca/alpaca_models_test.dart @@ -0,0 +1,58 @@ +import 'package:cyberhybridhub_server/alpaca/alpaca_models.dart'; +import 'package:test/test.dart'; + +import '../helpers/fixture_loader.dart'; + +void main() { + late FixtureLoader fixtures; + + setUp(() { + fixtures = FixtureLoader(); + }); + + test('parses latest trade fixture', () async { + final Map json = + await fixtures.loadJson('alpaca_latest_trade.json'); + final AlpacaLatestTradeResponse response = + AlpacaLatestTradeResponse.fromJson(json); + + expect(response.symbol, 'SPY'); + expect(response.trade.price, 492.15); + expect(response.trade.size, 500); + }); + + test('parses daily bars fixture', () async { + final Map json = + await fixtures.loadJson('alpaca_daily_bars.json'); + final AlpacaBarsResponse response = AlpacaBarsResponse.fromJson(json); + final AlpacaBar? bar = response.latestBar('SPY'); + + expect(bar, isNotNull); + expect(bar!.close, 500.0); + expect(bar.volume, 45000000); + expect(response.previousDailyBar('SPY')!.close, 498.0); + }); + + test('AlpacaOrderRequest serializes market notional order', () { + final AlpacaOrderRequest request = AlpacaOrderRequest( + symbol: 'SPY', + side: 'buy', + type: 'market', + timeInForce: 'day', + clientOrderId: 'uid-dip_confirm-qid', + notional: 10, + ); + + expect( + request.toJson(), + { + 'symbol': 'SPY', + 'side': 'buy', + 'type': 'market', + 'time_in_force': 'day', + 'client_order_id': 'uid-dip_confirm-qid', + 'notional': 10, + }, + ); + }); +} diff --git a/server/test/alpaca/alpaca_trading_client_test.dart b/server/test/alpaca/alpaca_trading_client_test.dart new file mode 100644 index 0000000..b34eeef --- /dev/null +++ b/server/test/alpaca/alpaca_trading_client_test.dart @@ -0,0 +1,174 @@ +import 'dart:convert'; + +import 'package:cyberhybridhub_server/alpaca/alpaca_env.dart'; +import 'package:cyberhybridhub_server/alpaca/alpaca_models.dart'; +import 'package:cyberhybridhub_server/alpaca/alpaca_trading_client.dart'; +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; + +import '../helpers/fixture_loader.dart'; +import '../helpers/mock_http_client.dart'; + +void main() { + late AlpacaEnv env; + late FixtureLoader fixtures; + + setUp(() { + env = AlpacaEnv.fromMap({ + 'ALPACA_API_KEY_ID': 'test-key-id', + 'ALPACA_API_SECRET_KEY': 'test-secret', + 'ALPACA_TRADING_BASE_URL': AlpacaEnv.defaultPaperTradingUrl, + 'ALPACA_DATA_BASE_URL': AlpacaEnv.defaultDataUrl, + 'ALPACA_DATA_FEED': 'iex', + }); + fixtures = FixtureLoader(); + }); + + group('AlpacaTradingClient.submitOrder', () { + test('POSTs to /v2/orders with credentials and parses response', () async { + final MockHttpClient http = MockHttpClient(); + http.whenPost( + '/v2/orders', + Response.json( + await fixtures.loadJson('alpaca_order_accepted.json'), + statusCode: 201, + ), + ); + + final AlpacaTradingClient client = + AlpacaTradingClient(env: env, httpClient: http); + final AlpacaOrderRequest request = AlpacaOrderRequest( + symbol: 'SPY', + side: 'buy', + type: 'market', + timeInForce: 'day', + clientOrderId: 'test-uid-spy_dip-q123', + notional: 10, + ); + + final AlpacaOrderResponse response = await client.submitOrder(request); + + expect(response.id, '904837e3-3b76-47ec-b432-046db621571b'); + expect(response.clientOrderId, 'test-uid-spy_dip-q123'); + expect(response.status, 'accepted'); + expect(response.symbol, 'SPY'); + expect(response.side, 'buy'); + expect(response.type, 'market'); + expect(response.notional, 10); + + expect(http.requests, hasLength(1)); + final request_ = http.requests.first; + expect(request_.method, 'POST'); + expect(request_.url.path, '/v2/orders'); + expect(request_.headers['APCA-API-KEY-ID'], 'test-key-id'); + expect(request_.headers['APCA-API-SECRET-KEY'], 'test-secret'); + expect(request_.headers['content-type'], contains('application/json')); + + final Map sent = + jsonDecode(http.capturedBodies.first) as Map; + expect(sent['symbol'], 'SPY'); + expect(sent['side'], 'buy'); + expect(sent['type'], 'market'); + expect(sent['time_in_force'], 'day'); + expect(sent['client_order_id'], 'test-uid-spy_dip-q123'); + expect(sent['notional'], 10); + }); + + test('throws AlpacaTradingDuplicateClientOrderIdException on 422 dup', + () async { + final MockHttpClient http = MockHttpClient(); + http.whenPost( + '/v2/orders', + Response.json( + await fixtures.loadJson('alpaca_order_duplicate_client_id.json'), + statusCode: 422, + ), + ); + + final AlpacaTradingClient client = + AlpacaTradingClient(env: env, httpClient: http); + + expect( + () => client.submitOrder( + AlpacaOrderRequest( + symbol: 'SPY', + side: 'buy', + type: 'market', + timeInForce: 'day', + clientOrderId: 'duplicate', + notional: 10, + ), + ), + throwsA(isA()), + ); + }); + + test('refuses live URL when ALPACA_ALLOW_LIVE=false', () async { + final AlpacaEnv liveEnv = AlpacaEnv.fromMap({ + 'ALPACA_API_KEY_ID': 'k', + 'ALPACA_API_SECRET_KEY': 's', + 'ALPACA_TRADING_BASE_URL': 'https://api.alpaca.markets', + }); + final AlpacaTradingClient client = + AlpacaTradingClient(env: liveEnv, httpClient: MockHttpClient()); + + expect( + () => client.submitOrder( + AlpacaOrderRequest( + symbol: 'SPY', + side: 'buy', + type: 'market', + timeInForce: 'day', + clientOrderId: 'x', + notional: 10, + ), + ), + throwsStateError, + ); + }); + }); + + group('AlpacaTradingClient.getOrderByClientOrderId', () { + test('returns parsed response when found', () async { + final MockHttpClient http = MockHttpClient(); + http.whenGet( + '/v2/orders:by_client_order_id', + Response.json(await fixtures.loadJson('alpaca_order_accepted.json')), + ); + + final AlpacaTradingClient client = + AlpacaTradingClient(env: env, httpClient: http); + final AlpacaOrderResponse? order = + await client.getOrderByClientOrderId('test-uid-spy_dip-q123'); + + expect(order, isNotNull); + expect(order!.id, '904837e3-3b76-47ec-b432-046db621571b'); + expect(http.requests.single.url.queryParameters['client_order_id'], + 'test-uid-spy_dip-q123'); + }); + + test('returns null on 404', () async { + final MockHttpClient http = MockHttpClient(); + http.whenGet( + '/v2/orders:by_client_order_id', + Response.json({'error': 'not found'}, statusCode: 404), + ); + + final AlpacaTradingClient client = + AlpacaTradingClient(env: env, httpClient: http); + + expect(await client.getOrderByClientOrderId('missing'), isNull); + }); + }); +} + +/// Tiny helper for building canned `http.Response` values from JSON fixtures. +class Response { + static http.Response json(Map body, {int statusCode = 200}) { + return http.Response( + jsonEncode(body), + statusCode, + headers: {'content-type': 'application/json'}, + ); + } +} diff --git a/server/test/alpaca/alpaca_trading_live_test.dart b/server/test/alpaca/alpaca_trading_live_test.dart new file mode 100644 index 0000000..edb9d54 --- /dev/null +++ b/server/test/alpaca/alpaca_trading_live_test.dart @@ -0,0 +1,77 @@ +@Tags(['alpaca']) +library; + +import 'package:cyberhybridhub_server/alpaca/alpaca_env.dart'; +import 'package:cyberhybridhub_server/alpaca/alpaca_models.dart'; +import 'package:cyberhybridhub_server/alpaca/alpaca_trading_client.dart'; +import 'package:dotenv/dotenv.dart'; +import 'package:test/test.dart'; + +void main() { + late AlpacaEnv env; + AlpacaTradingClient? client; + + setUpAll(() { + final DotEnv dotenv = DotEnv(includePlatformEnvironment: true) + ..load(['.env']); + const List alpacaKeys = [ + 'ALPACA_API_KEY_ID', + 'ALPACA_API_SECRET_KEY', + 'ALPACA_TRADING_BASE_URL', + 'ALPACA_DATA_BASE_URL', + 'ALPACA_DATA_FEED', + 'ALPACA_ALLOW_LIVE', + ]; + final Map envMap = { + for (final String key in alpacaKeys) + if (dotenv[key] != null && dotenv[key]!.isNotEmpty) key: dotenv[key]!, + }; + env = AlpacaEnv.fromMap(envMap); + if (env.hasCredentials) { + client = AlpacaTradingClient(env: env); + } + }); + + tearDownAll(() { + client?.close(); + }); + + test('paper submitOrder + getOrderByClientOrderId roundtrip', () async { + if (!env.hasCredentials) { + markTestSkipped( + 'Set ALPACA_API_KEY_ID and ALPACA_API_SECRET_KEY in server/.env', + ); + return; + } + // Refuse only on the *exact* live host. Substring matching breaks for + // paper-api.alpaca.markets (which includes "api.alpaca.markets"). + final Uri tradingUri = Uri.parse(env.tradingBaseUrl); + if (tradingUri.host == AlpacaEnv.liveTradingHost && !env.allowLive) { + markTestSkipped( + 'Refusing to run live test on live trading host ' + '(${tradingUri.host}) without ALPACA_ALLOW_LIVE=true', + ); + return; + } + + final String clientOrderId = + 'live-test-${DateTime.now().toUtc().millisecondsSinceEpoch}'; + final AlpacaOrderResponse submitted = await client!.submitOrder( + AlpacaOrderRequest( + symbol: 'SPY', + side: 'buy', + type: 'market', + timeInForce: 'day', + clientOrderId: clientOrderId, + notional: 1, + ), + ); + expect(submitted.id, isNotEmpty); + expect(submitted.clientOrderId, clientOrderId); + + final AlpacaOrderResponse? fetched = + await client!.getOrderByClientOrderId(clientOrderId); + expect(fetched, isNotNull); + expect(fetched!.id, submitted.id); + }, timeout: const Timeout(Duration(seconds: 30))); +} diff --git a/server/test/fixtures/alpaca_daily_bars.json b/server/test/fixtures/alpaca_daily_bars.json new file mode 100644 index 0000000..4c3d281 --- /dev/null +++ b/server/test/fixtures/alpaca_daily_bars.json @@ -0,0 +1,27 @@ +{ + "bars": { + "SPY": [ + { + "t": "2026-05-21T04:00:00Z", + "o": 495.0, + "h": 499.0, + "l": 493.0, + "c": 498.0, + "v": 42000000, + "n": 110000, + "vw": 496.5 + }, + { + "t": "2026-05-22T04:00:00Z", + "o": 498.5, + "h": 501.2, + "l": 495.0, + "c": 500.0, + "v": 45000000, + "n": 120000, + "vw": 499.1 + } + ] + }, + "next_page_token": null +} diff --git a/server/test/fixtures/alpaca_latest_trade.json b/server/test/fixtures/alpaca_latest_trade.json new file mode 100644 index 0000000..b844853 --- /dev/null +++ b/server/test/fixtures/alpaca_latest_trade.json @@ -0,0 +1,12 @@ +{ + "symbol": "SPY", + "trade": { + "t": "2026-05-23T14:25:48.889796106Z", + "x": "V", + "p": 492.15, + "s": 500, + "c": [" "], + "i": 53297330284354, + "z": "B" + } +} diff --git a/server/test/fixtures/alpaca_order_accepted.json b/server/test/fixtures/alpaca_order_accepted.json new file mode 100644 index 0000000..2ceaec4 --- /dev/null +++ b/server/test/fixtures/alpaca_order_accepted.json @@ -0,0 +1,29 @@ +{ + "id": "904837e3-3b76-47ec-b432-046db621571b", + "client_order_id": "test-uid-spy_dip-q123", + "created_at": "2026-05-25T14:30:00.000000000Z", + "updated_at": "2026-05-25T14:30:00.000000000Z", + "submitted_at": "2026-05-25T14:30:00.000000000Z", + "filled_at": null, + "expired_at": null, + "canceled_at": null, + "failed_at": null, + "asset_class": "us_equity", + "asset_id": "b6d1aa75-5c9c-4353-a305-9e2caa1925ab", + "symbol": "SPY", + "qty": null, + "filled_qty": "0", + "type": "market", + "side": "buy", + "time_in_force": "day", + "limit_price": null, + "stop_price": null, + "filled_avg_price": null, + "status": "accepted", + "extended_hours": false, + "legs": null, + "trail_price": null, + "trail_percent": null, + "hwm": null, + "notional": "10" +} diff --git a/server/test/fixtures/alpaca_order_duplicate_client_id.json b/server/test/fixtures/alpaca_order_duplicate_client_id.json new file mode 100644 index 0000000..c4fb651 --- /dev/null +++ b/server/test/fixtures/alpaca_order_duplicate_client_id.json @@ -0,0 +1,4 @@ +{ + "code": 42210000, + "message": "client_order_id must be unique" +} diff --git a/server/test/fixtures/market_snapshots_spy_dip.json b/server/test/fixtures/market_snapshots_spy_dip.json new file mode 100644 index 0000000..35cd7a6 --- /dev/null +++ b/server/test/fixtures/market_snapshots_spy_dip.json @@ -0,0 +1,20 @@ +[ + { + "symbol": "SPY", + "metric": "prev_close", + "price": 500.0, + "as_of": "2026-05-22T20:00:00Z" + }, + { + "symbol": "SPY", + "metric": "last_trade", + "price": 492.0, + "as_of": "2026-05-23T14:30:00Z" + }, + { + "symbol": "SPY", + "metric": "daily_bar", + "price": 492.0, + "as_of": "2026-05-23T14:30:00Z" + } +] diff --git a/server/test/fixtures/trading_config_default.json b/server/test/fixtures/trading_config_default.json new file mode 100644 index 0000000..f53b7d9 --- /dev/null +++ b/server/test/fixtures/trading_config_default.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "enabled": true, + "mode": "paper", + "data_inputs": [ + { + "id": "primary_watchlist", + "source": "alpaca", + "asset_class": "us_equity", + "symbols": ["AAPL", "MSFT", "SPY"], + "feed": "iex", + "poll_interval_seconds": 60, + "metrics": ["last_trade", "daily_bar", "prev_close"] + } + ], + "rules": [ + { + "id": "dip_confirm", + "type": "price_below_pct_of_ref", + "symbol": "SPY", + "ref_metric": "prev_close", + "threshold_pct": -1.5, + "question_template": "SPY is down {{pct}}% vs yesterday. Swipe +10 to approve a small buy, -10 to skip.", + "on_answer_match": { + "action": "propose_order", + "side": "buy", + "notional_usd": 10 + } + } + ], + "guardrails": { + "max_orders_per_day": 3, + "max_notional_usd_per_4h": 100, + "require_question_before_order": true, + "symbols_blocklist": [] + } +} diff --git a/server/test/helpers/fixture_loader.dart b/server/test/helpers/fixture_loader.dart new file mode 100644 index 0000000..fbcd924 --- /dev/null +++ b/server/test/helpers/fixture_loader.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; +import 'dart:io'; + +/// Loads JSON fixture files from [server/test/fixtures/]. +class FixtureLoader { + FixtureLoader({String? basePath}) + : _basePath = basePath ?? + '${Directory.current.path}${Platform.pathSeparator}test${Platform.pathSeparator}fixtures'; + + final String _basePath; + + Future> loadJson(String name) async { + final String path = '$_basePath${Platform.pathSeparator}$name'; + final String contents = await File(path).readAsString(); + return jsonDecode(contents) as Map; + } + + Future loadString(String name) async { + final String path = '$_basePath${Platform.pathSeparator}$name'; + return File(path).readAsString(); + } +} diff --git a/server/test/helpers/mock_http_client.dart b/server/test/helpers/mock_http_client.dart new file mode 100644 index 0000000..cd60e88 --- /dev/null +++ b/server/test/helpers/mock_http_client.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// Records requests and returns canned responses for Alpaca client unit tests. +class MockHttpClient extends http.BaseClient { + MockHttpClient({Map? responses}) + : _responses = responses ?? {}; + + final Map _responses; + final List requests = []; + + /// Captured request bodies indexed by request order in [requests]. + final List capturedBodies = []; + + void whenGet(String pathSuffix, http.Response response) { + _responses[pathSuffix] = _MatchedResponse(method: 'GET', response: response); + } + + void whenGetJson(String pathSuffix, Map body, + {int statusCode = 200}) { + whenGet( + pathSuffix, + http.Response(jsonEncode(body), statusCode, headers: { + 'content-type': 'application/json', + }), + ); + } + + void whenPost(String pathSuffix, http.Response response) { + _responses['POST:$pathSuffix'] = + _MatchedResponse(method: 'POST', response: response); + } + + void whenPostJson(String pathSuffix, Map body, + {int statusCode = 200}) { + whenPost( + pathSuffix, + http.Response(jsonEncode(body), statusCode, headers: { + 'content-type': 'application/json', + }), + ); + } + + @override + Future send(http.BaseRequest request) async { + requests.add(request); + final String body = await _readBody(request); + capturedBodies.add(body); + + final String path = request.url.path; + final String method = request.method.toUpperCase(); + for (final MapEntry entry in _responses.entries) { + final _MatchedResponse match = entry.value; + if (match.method != method) continue; + final String suffix = entry.key.startsWith('$method:') + ? entry.key.substring(method.length + 1) + : entry.key; + if (path.endsWith(suffix) || request.url.toString().contains(suffix)) { + return http.StreamedResponse( + Stream>.value(utf8.encode(match.response.body)), + match.response.statusCode, + headers: match.response.headers, + request: request, + ); + } + } + return http.StreamedResponse( + Stream>.value(utf8.encode('{}')), + 404, + request: request, + ); + } + + Future _readBody(http.BaseRequest request) async { + if (request is http.Request) { + return request.body; + } + final List bytes = await request.finalize().toBytes(); + return utf8.decode(bytes); + } + + @override + void close() {} +} + +class _MatchedResponse { + _MatchedResponse({required this.method, required this.response}); + + final String method; + final http.Response response; +} diff --git a/server/test/helpers/test_db.dart b/server/test/helpers/test_db.dart new file mode 100644 index 0000000..375923a --- /dev/null +++ b/server/test/helpers/test_db.dart @@ -0,0 +1,155 @@ +import 'dart:io'; + +import 'package:cyberhybridhub_server/db.dart'; +import 'package:cyberhybridhub_server/question_service.dart'; +import 'package:cyberhybridhub_server/questions_db.dart'; +import 'package:cyberhybridhub_server/signalr/questions_hub_connections.dart'; +import 'package:cyberhybridhub_server/trading/market_data_db.dart'; +import 'package:cyberhybridhub_server/trading/trade_orders_db.dart'; +import 'package:cyberhybridhub_server/trading/trading_config_db.dart'; +import 'package:cyberhybridhub_server/trading/user_trading_state_db.dart'; +import 'package:dotenv/dotenv.dart'; +import 'package:postgres/postgres.dart'; + +/// Integration test Postgres: [cyberhybridhub_test] with migrations 001–004. +class TestDb { + TestDb._(this.db, this._connection, this.databaseUrl); + + final ProfileDb db; + final Connection _connection; + final String databaseUrl; + + Connection get connection => _connection; + + static const String testDatabaseName = 'cyberhybridhub_test'; + + static Future open() async { + String? baseUrl = Platform.environment['TEST_DATABASE_URL'] ?? + Platform.environment['DATABASE_URL']; + if (baseUrl == null || baseUrl.isEmpty) { + final DotEnv env = DotEnv(includePlatformEnvironment: true) + ..load(['.env']); + baseUrl = env['DATABASE_URL']; + } + if (baseUrl == null || baseUrl.isEmpty) { + return null; + } + + final Uri uri = Uri.parse(baseUrl); + final String testUrl = uri.replace(path: '/$testDatabaseName').toString(); + + await _ensureTestDatabaseExists(baseUrl); + + final ProfileDb profileDb = await ProfileDb.connect(testUrl); + final Directory migrationsDir = Directory('migrations'); + if (!migrationsDir.existsSync()) { + final Directory serverDir = Directory.current.path.endsWith('server') + ? Directory.current + : Directory('server'); + if (serverDir.existsSync()) { + Directory.current = serverDir.path; + } + } + + await profileDb.migrate(); + return TestDb._(profileDb, profileDb.connection, testUrl); + } + + static Future _ensureTestDatabaseExists(String databaseUrl) async { + final Uri uri = Uri.parse(databaseUrl); + final String adminDb = + uri.pathSegments.isNotEmpty && uri.pathSegments.first.isNotEmpty + ? uri.pathSegments.first + : 'postgres'; + final String adminUrl = uri.replace(path: '/$adminDb').toString(); + + final Connection admin = await Connection.open( + _endpointFromUrl(adminUrl), + settings: const ConnectionSettings(sslMode: SslMode.disable), + ); + try { + final Result exists = await admin.execute( + Sql.named( + 'SELECT 1 FROM pg_database WHERE datname = @name', + ), + parameters: {'name': testDatabaseName}, + ); + if (exists.isEmpty) { + try { + await admin.execute('CREATE DATABASE $testDatabaseName'); + } on ServerException catch (e) { + // Parallel test files may race on CREATE DATABASE. + if (e.code != '23505' && e.code != '42P04') { + rethrow; + } + } + } + } finally { + await admin.close(); + } + } + + static Endpoint _endpointFromUrl(String databaseUrl) { + final Uri uri = Uri.parse(databaseUrl); + return Endpoint( + host: uri.host.isEmpty ? 'localhost' : uri.host, + port: uri.hasPort ? uri.port : 5432, + database: uri.pathSegments.isNotEmpty ? uri.pathSegments.last : 'postgres', + username: + uri.userInfo.isNotEmpty ? uri.userInfo.split(':').first : null, + password: uri.userInfo.contains(':') + ? uri.userInfo.split(':').skip(1).join(':') + : null, + ); + } + + MarketDataDb get marketDataDb => MarketDataDb(_connection); + + TradingConfigDb get tradingConfigDb => TradingConfigDb(_connection); + + TradeOrdersDb get tradeOrdersDb => TradeOrdersDb(_connection); + + UserTradingStateDb get userTradingStateDb => UserTradingStateDb(_connection); + + QuestionsDb get questionsDb => QuestionsDb(_connection); + + /// QuestionService backed by a no-op hub (no live SignalR clients in tests). + QuestionService questionService() => QuestionService( + questionsDb: questionsDb, + hubConnections: QuestionsHubConnections(), + ); + + Future truncateTradingTables() async { + await _connection.execute( + ''' + TRUNCATE TABLE + trade_orders, + market_data_snapshots, + user_trading_state, + user_trading_config, + questions, + user_pipeline_state, + users + RESTART IDENTITY CASCADE + ''', + ); + } + + Future seedUser(String firebaseUid) async { + await _connection.execute( + Sql.named( + ''' + INSERT INTO users (firebase_uid, email) + VALUES (@uid, @email) + ON CONFLICT (firebase_uid) DO NOTHING + ''', + ), + parameters: { + 'uid': firebaseUid, + 'email': '$firebaseUid@test.local', + }, + ); + } + + Future close() => db.close(); +} diff --git a/server/test/helpers/test_env.dart b/server/test/helpers/test_env.dart new file mode 100644 index 0000000..df0c079 --- /dev/null +++ b/server/test/helpers/test_env.dart @@ -0,0 +1,26 @@ +import 'dart:io'; + +/// Test environment flags aligned with [server/lib/env.dart] trading gates. +class TestEnv { + TestEnv._(); + + static bool get tradingEnabled => + _bool('TRADING_ENABLED', defaultValue: false); + + static bool get questionPipelineTestMode => + _bool('QUESTION_PIPELINE_TEST_MODE', defaultValue: true); + + static String? get databaseUrl => + Platform.environment['TEST_DATABASE_URL'] ?? + Platform.environment['DATABASE_URL']; + + static bool get hasDatabase => databaseUrl != null && databaseUrl!.isNotEmpty; + + static bool _bool(String key, {required bool defaultValue}) { + final String? raw = Platform.environment[key]; + if (raw == null || raw.isEmpty) { + return defaultValue; + } + return raw == 'true' || raw == '1'; + } +} diff --git a/server/test/integration/market_data_db_test.dart b/server/test/integration/market_data_db_test.dart new file mode 100644 index 0000000..52f2c9d --- /dev/null +++ b/server/test/integration/market_data_db_test.dart @@ -0,0 +1,59 @@ +@Tags(['integration', 'postgres']) +library; + +import 'package:cyberhybridhub_server/trading/market_data_db.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_db.dart'; + +void main() { + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + test('insertSnapshot then latestForSymbol returns newest by as_of', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final MarketDataDb db = testDb!.marketDataDb; + final DateTime older = DateTime.utc(2026, 5, 23, 10); + final DateTime newer = DateTime.utc(2026, 5, 23, 11); + + await db.insertSnapshot( + symbol: 'SPY', + metric: 'last_trade', + price: 490, + asOf: older, + ); + await db.insertSnapshot( + symbol: 'SPY', + metric: 'last_trade', + price: 492, + asOf: newer, + ); + + final MarketDataSnapshot? latest = + await db.latestForSymbol('SPY', 'last_trade'); + + expect(latest, isNotNull); + expect(latest!.price, 492); + expect( + latest.asOf.toUtc().millisecondsSinceEpoch, + greaterThan(older.toUtc().millisecondsSinceEpoch), + ); + }); +} diff --git a/server/test/integration/market_data_ingest_test.dart b/server/test/integration/market_data_ingest_test.dart new file mode 100644 index 0000000..16ada12 --- /dev/null +++ b/server/test/integration/market_data_ingest_test.dart @@ -0,0 +1,180 @@ +@Tags(['integration', 'postgres']) +library; + +import 'package:cyberhybridhub_server/alpaca/alpaca_env.dart'; +import 'package:cyberhybridhub_server/alpaca/alpaca_market_data_client.dart'; +import 'package:cyberhybridhub_server/trading/market_data_db.dart'; +import 'package:cyberhybridhub_server/trading/market_data_ingest.dart'; +import 'package:cyberhybridhub_server/trading/trading_config.dart'; +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; + +import '../helpers/fixture_loader.dart'; +import '../helpers/mock_http_client.dart'; +import '../helpers/test_db.dart'; + +void main() { + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + group('MarketDataIngest', () { + test('writes last_trade, daily_bar, prev_close snapshots for SPY', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'market-ingest-metrics-uid'; + await testDb!.seedUser(uid); + await testDb!.tradingConfigDb.upsertUserConfig( + firebaseUid: uid, + templateName: 'default_paper_watchlist', + config: { + 'data_inputs': >[ + { + 'id': 'primary_watchlist', + 'symbols': ['SPY'], + }, + ], + }, + enabled: true, + ); + + final EffectiveTradingConfig? config = + await testDb!.tradingConfigDb.resolveEffectiveConfig(uid); + expect(config, isNotNull); + + final FixtureLoader fixtures = FixtureLoader(); + final MockHttpClient mock = MockHttpClient() + ..whenGetJson( + '/trades/latest', + await fixtures.loadJson('alpaca_latest_trade.json'), + ) + ..whenGetJson( + '/bars', + await fixtures.loadJson('alpaca_daily_bars.json'), + ); + + final AlpacaEnv env = AlpacaEnv.fromMap({ + 'ALPACA_API_KEY_ID': 'test-key', + 'ALPACA_API_SECRET_KEY': 'test-secret', + }); + final MarketDataIngest ingest = MarketDataIngest( + marketDataDb: testDb!.marketDataDb, + tradingStateDb: testDb!.userTradingStateDb, + alpacaClient: AlpacaMarketDataClient(env: env, httpClient: mock), + ); + + final MarketDataIngestResult result = await ingest.runIfDue( + firebaseUid: uid, + config: config!, + now: DateTime.utc(2026, 5, 23, 12), + ); + + expect(result.inputsFetched, 1); + expect(result.snapshotsWritten, 3); + + final MarketDataSnapshot? lastTrade = + await testDb!.marketDataDb.latestForSymbol('SPY', 'last_trade'); + final MarketDataSnapshot? dailyBar = + await testDb!.marketDataDb.latestForSymbol('SPY', 'daily_bar'); + final MarketDataSnapshot? prevClose = + await testDb!.marketDataDb.latestForSymbol('SPY', 'prev_close'); + + expect(lastTrade?.price, 492.15); + expect(dailyBar?.price, 500.0); + expect(prevClose?.price, 498.0); + + final Result rows = await testDb!.connection.execute( + Sql.named( + ''' + SELECT metric, price::float + FROM market_data_snapshots + WHERE symbol = 'SPY' + ORDER BY metric + ''', + ), + ); + expect(rows, hasLength(3)); + }); + + test('second runIfDue within poll_interval_seconds skips HTTP', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'market-ingest-poll-uid'; + await testDb!.seedUser(uid); + await testDb!.tradingConfigDb.upsertUserConfig( + firebaseUid: uid, + templateName: 'default_paper_watchlist', + config: { + 'data_inputs': >[ + { + 'id': 'primary_watchlist', + 'source': 'alpaca', + 'asset_class': 'us_equity', + 'symbols': ['SPY'], + 'feed': 'iex', + 'poll_interval_seconds': 60, + 'metrics': ['last_trade'], + }, + ], + }, + enabled: true, + ); + + final EffectiveTradingConfig? config = + await testDb!.tradingConfigDb.resolveEffectiveConfig(uid); + expect(config, isNotNull); + + final FixtureLoader fixtures = FixtureLoader(); + final MockHttpClient mock = MockHttpClient() + ..whenGetJson( + '/trades/latest', + await fixtures.loadJson('alpaca_latest_trade.json'), + ); + + final AlpacaEnv env = AlpacaEnv.fromMap({ + 'ALPACA_API_KEY_ID': 'test-key', + 'ALPACA_API_SECRET_KEY': 'test-secret', + }); + final MarketDataIngest ingest = MarketDataIngest( + marketDataDb: testDb!.marketDataDb, + tradingStateDb: testDb!.userTradingStateDb, + alpacaClient: AlpacaMarketDataClient(env: env, httpClient: mock), + ); + + final DateTime tick = DateTime.utc(2026, 5, 23, 12); + await ingest.runIfDue( + firebaseUid: uid, + config: config!, + now: tick, + ); + final int afterFirst = mock.requests.length; + + await ingest.runIfDue( + firebaseUid: uid, + config: config, + now: tick.add(const Duration(seconds: 30)), + ); + + expect(afterFirst, 1); + expect(mock.requests.length, 1); + }); + }); +} diff --git a/server/test/integration/migration_test.dart b/server/test/integration/migration_test.dart new file mode 100644 index 0000000..8a79550 --- /dev/null +++ b/server/test/integration/migration_test.dart @@ -0,0 +1,54 @@ +@Tags(['integration', 'postgres']) +library; + +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_db.dart'; + +void main() { + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + test('migrations 001–004 apply on cyberhybridhub_test', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final Connection connection = testDb!.connection; + final Result tables = await connection.execute( + ''' + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ( + 'users', + 'questions', + 'market_data_snapshots', + 'trading_config_templates', + 'user_trading_config', + 'user_trading_state', + 'trade_orders' + ) + ORDER BY table_name + ''', + ); + expect(tables.map((ResultRow r) => r[0]), hasLength(7)); + + final Result template = await connection.execute( + Sql.named( + 'SELECT name FROM trading_config_templates WHERE name = @name', + ), + parameters: {'name': 'default_paper_watchlist'}, + ); + expect(template, isNotEmpty); + }); +} diff --git a/server/test/integration/trade_actuator_test.dart b/server/test/integration/trade_actuator_test.dart new file mode 100644 index 0000000..00ac99a --- /dev/null +++ b/server/test/integration/trade_actuator_test.dart @@ -0,0 +1,323 @@ +@Tags(['integration', 'postgres']) +library; + +import 'dart:convert'; + +import 'package:cyberhybridhub_server/alpaca/alpaca_env.dart'; +import 'package:cyberhybridhub_server/alpaca/alpaca_trading_client.dart'; +import 'package:cyberhybridhub_server/trading/guardrails.dart'; +import 'package:cyberhybridhub_server/trading/trade_actuator.dart'; +import 'package:cyberhybridhub_server/trading/trade_orders_db.dart'; +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; + +import '../helpers/fixture_loader.dart'; +import '../helpers/mock_http_client.dart'; +import '../helpers/test_db.dart'; + +void main() { + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + Future _seedConfig(String uid) async { + await testDb!.seedUser(uid); + await testDb!.tradingConfigDb.upsertUserConfig( + firebaseUid: uid, + templateName: 'default_paper_watchlist', + enabled: true, + ); + } + + Future _stagePending( + String uid, { + required String clientOrderId, + required String questionId, + required String ruleId, + String symbol = 'SPY', + String side = 'buy', + num notional = 10, + }) async { + await testDb!.userTradingStateDb.addPendingOrder( + firebaseUid: uid, + order: { + 'rule_id': ruleId, + 'question_id': questionId, + 'symbol': symbol, + 'side': side, + 'order_type': 'market', + 'notional_usd': notional, + 'client_order_id': clientOrderId, + 'staged_at': DateTime.utc(2026, 5, 25).toIso8601String(), + }, + ); + } + + TradeActuator _testModeActuator({Guardrails? guardrails}) { + return TradeActuator( + tradingConfigDb: testDb!.tradingConfigDb, + tradingStateDb: testDb!.userTradingStateDb, + tradeOrdersDb: testDb!.tradeOrdersDb, + questionsDb: testDb!.questionsDb, + guardrails: guardrails ?? Guardrails(), + ); + } + + group('TradeActuator (test mode)', () { + test('drains pending order → inserts trade_orders + removes from pending', + () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + const String uid = 'actuator-test-mode-uid'; + await _seedConfig(uid); + + // Real questions row (FK target for trade_orders.question_id). + final Map question = await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: 'Buy SPY?', + correctAnswer: 10, + sourceTag: 'trading:rule:dip_confirm', + pipelineKey: 'trading', + pipelineStep: 'dip_confirm:await_confirm', + ); + final String questionId = question['id']! as String; + final String clientOrderId = '$uid-dip_confirm-$questionId'; + + await _stagePending( + uid, + clientOrderId: clientOrderId, + questionId: questionId, + ruleId: 'dip_confirm', + ); + + final TradeActuator actuator = _testModeActuator(); + final TradeActuatorResult result = + await actuator.processPendingOrders(uid); + + expect(result.submitted, [clientOrderId]); + expect(result.rejected, isEmpty); + expect(result.errors, isEmpty); + + final TradeOrder? saved = + await testDb!.tradeOrdersDb.findByClientOrderId(clientOrderId); + expect(saved, isNotNull); + expect(saved!.alpacaOrderId, 'test-$clientOrderId'); + expect(saved.status, 'test_accepted'); + expect(saved.symbol, 'SPY'); + expect(saved.side, 'buy'); + expect(saved.notionalUsd, 10); + expect(saved.questionId, questionId); + expect(saved.ruleId, 'dip_confirm'); + + final List> remaining = + await testDb!.userTradingStateDb.listPendingOrders(uid); + expect(remaining, isEmpty); + }); + + test('idempotent: existing trade_orders row → no second insert', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + const String uid = 'actuator-idempotent-uid'; + await _seedConfig(uid); + + final Map question = await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: 'Buy SPY?', + correctAnswer: 10, + sourceTag: 'trading:rule:dip_confirm', + pipelineKey: 'trading', + pipelineStep: 'dip_confirm:await_confirm', + ); + final String questionId = question['id']! as String; + final String clientOrderId = '$uid-dip_confirm-$questionId'; + + // Pre-insert a trade_orders row to simulate a prior crashed actuator run. + await testDb!.tradeOrdersDb.insertOrder( + firebaseUid: uid, + clientOrderId: clientOrderId, + symbol: 'SPY', + side: 'buy', + orderType: 'market', + status: 'test_accepted', + alpacaOrderId: 'test-$clientOrderId', + notionalUsd: 10, + questionId: questionId, + ruleId: 'dip_confirm', + ); + + await _stagePending( + uid, + clientOrderId: clientOrderId, + questionId: questionId, + ruleId: 'dip_confirm', + ); + + final TradeActuator actuator = _testModeActuator(); + final TradeActuatorResult result = + await actuator.processPendingOrders(uid); + + expect(result.submitted, [clientOrderId]); + expect(result.rejected, isEmpty); + + // Still exactly one trade_orders row. + expect( + await testDb!.tradeOrdersDb.findByClientOrderId(clientOrderId), + isNotNull, + ); + final List> pending = + await testDb!.userTradingStateDb.listPendingOrders(uid); + expect(pending, isEmpty); + }); + + test('guardrail rejects when notional exceeds server ceiling', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + const String uid = 'actuator-guardrail-uid'; + await _seedConfig(uid); + + final Map question = await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: 'Buy SPY?', + correctAnswer: 10, + sourceTag: 'trading:rule:dip_confirm', + pipelineKey: 'trading', + pipelineStep: 'dip_confirm:await_confirm', + ); + final String questionId = question['id']! as String; + final String clientOrderId = '$uid-dip_confirm-$questionId'; + + await _stagePending( + uid, + clientOrderId: clientOrderId, + questionId: questionId, + ruleId: 'dip_confirm', + notional: 999, + ); + + final TradeActuator actuator = _testModeActuator( + guardrails: Guardrails(serverMaxNotionalUsd: 50), + ); + final TradeActuatorResult result = + await actuator.processPendingOrders(uid); + + expect(result.submitted, isEmpty); + expect(result.rejected, hasLength(1)); + expect( + result.rejected.single.reason, + GuardrailRejectionReason.serverMaxNotionalUsdExceeded, + ); + + expect( + await testDb!.tradeOrdersDb.findByClientOrderId(clientOrderId), + isNull, + ); + final List> pending = + await testDb!.userTradingStateDb.listPendingOrders(uid); + expect(pending, isEmpty); + }); + }); + + group('TradeActuator with mocked Alpaca client', () { + test('POSTs once and persists Alpaca order id + status', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + const String uid = 'actuator-alpaca-mock-uid'; + await _seedConfig(uid); + + final Map question = await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: 'Buy SPY?', + correctAnswer: 10, + sourceTag: 'trading:rule:dip_confirm', + pipelineKey: 'trading', + pipelineStep: 'dip_confirm:await_confirm', + ); + final String questionId = question['id']! as String; + final String clientOrderId = '$uid-dip_confirm-$questionId'; + + await _stagePending( + uid, + clientOrderId: clientOrderId, + questionId: questionId, + ruleId: 'dip_confirm', + ); + + final FixtureLoader fixtures = FixtureLoader(); + final Map orderJson = + await fixtures.loadJson('alpaca_order_accepted.json'); + orderJson['client_order_id'] = clientOrderId; + + final MockHttpClient mock = MockHttpClient(); + mock.whenPost( + '/v2/orders', + http.Response( + jsonEncode(orderJson), + 201, + headers: {'content-type': 'application/json'}, + ), + ); + + final AlpacaEnv env = AlpacaEnv.fromMap({ + 'ALPACA_API_KEY_ID': 'k', + 'ALPACA_API_SECRET_KEY': 's', + 'ALPACA_TRADING_BASE_URL': AlpacaEnv.defaultPaperTradingUrl, + }); + final AlpacaTradingClient client = + AlpacaTradingClient(env: env, httpClient: mock); + + final TradeActuator actuator = TradeActuator( + tradingConfigDb: testDb!.tradingConfigDb, + tradingStateDb: testDb!.userTradingStateDb, + tradeOrdersDb: testDb!.tradeOrdersDb, + questionsDb: testDb!.questionsDb, + guardrails: Guardrails(), + alpacaClient: client, + ); + + final TradeActuatorResult result = + await actuator.processPendingOrders(uid); + expect(result.submitted, [clientOrderId]); + expect(mock.requests, hasLength(1)); + expect(mock.requests.single.method, 'POST'); + + final TradeOrder? saved = + await testDb!.tradeOrdersDb.findByClientOrderId(clientOrderId); + expect(saved, isNotNull); + expect(saved!.alpacaOrderId, '904837e3-3b76-47ec-b432-046db621571b'); + expect(saved.status, 'accepted'); + expect(saved.symbol, 'SPY'); + + // Re-running should NOT POST again (idempotency via findByClientOrderId). + await _stagePending( + uid, + clientOrderId: clientOrderId, + questionId: questionId, + ruleId: 'dip_confirm', + ); + final TradeActuatorResult repeat = + await actuator.processPendingOrders(uid); + expect(repeat.submitted, [clientOrderId]); + expect(mock.requests, hasLength(1)); + }); + }); +} diff --git a/server/test/integration/trade_orders_db_test.dart b/server/test/integration/trade_orders_db_test.dart new file mode 100644 index 0000000..fe86118 --- /dev/null +++ b/server/test/integration/trade_orders_db_test.dart @@ -0,0 +1,122 @@ +@Tags(['integration', 'postgres']) +library; + +import 'package:cyberhybridhub_server/trading/trade_orders_db.dart'; +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; +import 'package:uuid/uuid.dart'; + +import '../helpers/test_db.dart'; + +void main() { + const Uuid uuid = Uuid(); + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + test('duplicate client_order_id returns existing row without second insert', + () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'trade-orders-idempotency-uid'; + await testDb!.seedUser(uid); + final String clientOrderId = 'test-${uuid.v4()}'; + + final TradeOrder first = await testDb!.tradeOrdersDb.insertOrder( + firebaseUid: uid, + clientOrderId: clientOrderId, + symbol: 'SPY', + side: 'buy', + orderType: 'market', + status: 'pending', + notionalUsd: 10, + ruleId: 'dip_confirm', + ); + + final TradeOrder second = await testDb!.tradeOrdersDb.insertOrder( + firebaseUid: uid, + clientOrderId: clientOrderId, + symbol: 'SPY', + side: 'buy', + orderType: 'market', + status: 'pending', + notionalUsd: 99, + ); + + expect(second.id, first.id); + expect(second.notionalUsd, 10); + + final Result count = await testDb!.connection.execute( + Sql.named( + 'SELECT COUNT(*)::int FROM trade_orders WHERE client_order_id = @id', + ), + parameters: {'id': clientOrderId}, + ); + expect((count.first[0]! as num).toInt(), 1); + }); + + test('raw insert with duplicate client_order_id raises unique violation', + () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'trade-orders-unique-uid'; + await testDb!.seedUser(uid); + final String clientOrderId = 'dup-${uuid.v4()}'; + final String orderId = uuid.v4(); + + await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO trade_orders ( + id, firebase_uid, client_order_id, symbol, side, order_type, status + ) VALUES ( + @id::uuid, @uid, @client_order_id, 'SPY', 'buy', 'market', 'pending' + ) + ''', + ), + parameters: { + 'id': orderId, + 'uid': uid, + 'client_order_id': clientOrderId, + }, + ); + + await expectLater( + testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO trade_orders ( + id, firebase_uid, client_order_id, symbol, side, order_type, status + ) VALUES ( + @id::uuid, @uid, @client_order_id, 'SPY', 'buy', 'market', 'pending' + ) + ''', + ), + parameters: { + 'id': uuid.v4(), + 'uid': uid, + 'client_order_id': clientOrderId, + }, + ), + throwsA(isA()), + ); + }); +} diff --git a/server/test/integration/trading_config_db_test.dart b/server/test/integration/trading_config_db_test.dart new file mode 100644 index 0000000..a8dff07 --- /dev/null +++ b/server/test/integration/trading_config_db_test.dart @@ -0,0 +1,79 @@ +@Tags(['integration', 'postgres']) +library; + +import 'package:cyberhybridhub_server/trading/trading_config.dart'; +import 'package:test/test.dart'; + +import '../helpers/fixture_loader.dart'; +import '../helpers/test_db.dart'; + +void main() { + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + test('resolveEffectiveConfig merges template and user override', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'trading-config-merge-uid'; + await testDb!.seedUser(uid); + + final FixtureLoader fixtures = FixtureLoader(); + final Map partialOverride = { + 'rules': >[ + { + 'id': 'dip_confirm', + 'threshold_pct': -2.5, + }, + ], + 'data_inputs': >[ + { + 'id': 'primary_watchlist', + 'symbols': ['SPY'], + }, + ], + }; + // Ensure fixture file is present (smoke for layout). + await fixtures.loadJson('trading_config_default.json'); + + await testDb!.tradingConfigDb.upsertUserConfig( + firebaseUid: uid, + templateName: 'default_paper_watchlist', + config: partialOverride, + enabled: true, + ); + + final EffectiveTradingConfig? effective = + await testDb!.tradingConfigDb.resolveEffectiveConfig(uid); + + expect(effective, isNotNull); + expect(effective!.enabled, isTrue); + expect(effective.dataInputs.single.symbols, ['SPY']); + expect(effective.dataInputs.single.metrics, + containsAll(['last_trade', 'daily_bar', 'prev_close'])); + + final TradingRuleConfig rule = + effective.rules.singleWhere((TradingRuleConfig r) => r.id == 'dip_confirm'); + expect(rule.thresholdPct, -2.5); + expect( + rule.questionTemplate, + contains('Swipe +10'), + ); + expect(effective.guardrails.maxOrdersPerDay, 3); + }); +} diff --git a/server/test/integration/trading_dev_actions_test.dart b/server/test/integration/trading_dev_actions_test.dart new file mode 100644 index 0000000..28409f9 --- /dev/null +++ b/server/test/integration/trading_dev_actions_test.dart @@ -0,0 +1,189 @@ +@Tags(['integration', 'postgres']) +library; + +import 'package:cyberhybridhub_server/trading/trading_dev_actions.dart'; +import 'package:cyberhybridhub_server/trading/trading_pipeline.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_db.dart'; + +void main() { + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + TradingDevActions _actions({DateTime Function()? clock}) { + final TradingPipeline pipeline = TradingPipeline( + questionsDb: testDb!.questionsDb, + questionService: testDb!.questionService(), + marketDataDb: testDb!.marketDataDb, + tradingConfigDb: testDb!.tradingConfigDb, + tradingStateDb: testDb!.userTradingStateDb, + clock: clock, + ); + return TradingDevActions( + questionsDb: testDb!.questionsDb, + marketDataDb: testDb!.marketDataDb, + tradingConfigDb: testDb!.tradingConfigDb, + tradingPipeline: pipeline, + clock: clock, + ); + } + + test('seeds dipped snapshots and forces a dip_confirm question', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + const String uid = 'force-fire-uid'; + await testDb!.seedUser(uid); + await testDb!.tradingConfigDb.upsertUserConfig( + firebaseUid: uid, + templateName: 'default_paper_watchlist', + enabled: true, + ); + + final ForceFireResult result = await _actions().forceFireDip(uid); + + expect(result.skipReason, isNull); + expect(result.evaluation, isNotNull); + expect(result.evaluation!.rulesFired, ['dip_confirm']); + expect(result.evaluation!.questionsCreated, 1); + expect(result.snapshots, hasLength(2)); + expect( + result.snapshots.map((SeededSnapshot s) => s.metric).toSet(), + {'prev_close', 'last_trade'}, + ); + + // The synthetic last_trade should be at least the rule's threshold below + // the reference price (default_paper_watchlist uses threshold_pct=-1.5). + final SeededSnapshot trade = + result.snapshots.firstWhere((SeededSnapshot s) => s.metric == 'last_trade'); + final SeededSnapshot ref = + result.snapshots.firstWhere((SeededSnapshot s) => s.metric == 'prev_close'); + final num pct = ((trade.price - ref.price) / ref.price) * 100; + expect(pct, lessThan(-1.5), + reason: 'forced last_trade should be more than 1.5% below ref'); + + final List> open = + await testDb!.questionsDb.listUnansweredQuestions(uid); + expect(open, hasLength(1)); + expect(open.single['pipelineKey'], 'trading'); + expect(open.single['pipelineStep'], 'dip_confirm:await_confirm'); + }); + + test('reuses existing fresh ref snapshot and only inserts last_trade', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + const String uid = 'force-fire-reuse-uid'; + await testDb!.seedUser(uid); + await testDb!.tradingConfigDb.upsertUserConfig( + firebaseUid: uid, + templateName: 'default_paper_watchlist', + enabled: true, + ); + await testDb!.marketDataDb.insertSnapshot( + symbol: 'SPY', + metric: 'prev_close', + price: 600, + asOf: DateTime.utc(2026, 5, 25, 20), + ); + + final ForceFireResult result = await _actions().forceFireDip(uid); + + final SeededSnapshot ref = + result.snapshots.firstWhere((SeededSnapshot s) => s.metric == 'prev_close'); + expect(ref.created, isFalse, + reason: 'existing prev_close should be reused, not overwritten'); + expect(ref.price, 600); + + final SeededSnapshot trade = + result.snapshots.firstWhere((SeededSnapshot s) => s.metric == 'last_trade'); + expect(trade.created, isTrue); + // 2.0% below 600 = 588 (overshoot 0.5% beyond threshold of -1.5%). + expect(trade.price, closeTo(588, 0.01)); + }); + + test('short-circuits when user has no config', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + const String uid = 'force-fire-noconfig-uid'; + await testDb!.seedUser(uid); + + final ForceFireResult result = await _actions().forceFireDip(uid); + expect(result.skipReason, 'no_config'); + expect(result.snapshots, isEmpty); + expect(result.evaluation, isNull); + }); + + test('short-circuits when user config is disabled', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + const String uid = 'force-fire-disabled-uid'; + await testDb!.seedUser(uid); + await testDb!.tradingConfigDb.upsertUserConfig( + firebaseUid: uid, + templateName: 'default_paper_watchlist', + enabled: false, + ); + + final ForceFireResult result = await _actions().forceFireDip(uid); + expect(result.skipReason, 'disabled'); + }); + + test('clears prior unanswered trading question so a re-fire produces a fresh one', + () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + const String uid = 'force-fire-reuse-question-uid'; + await testDb!.seedUser(uid); + await testDb!.tradingConfigDb.upsertUserConfig( + firebaseUid: uid, + templateName: 'default_paper_watchlist', + enabled: true, + ); + + final TradingDevActions actions = _actions(); + final ForceFireResult first = await actions.forceFireDip(uid); + expect(first.evaluation!.questionsCreated, 1); + + final List> beforeSecond = + await testDb!.questionsDb.listUnansweredQuestions(uid); + expect(beforeSecond, hasLength(1)); + + final ForceFireResult second = await actions.forceFireDip(uid); + // The first question was auto-answered (skipped) by forceFireDip before + // evaluating; the cooldown guard then prevents a second fire on the same + // call. We expect either a fresh question or a clear cooldown skip — the + // important invariant is that the queue never grows beyond one open + // trading question. + final List> afterSecond = + await testDb!.questionsDb.listUnansweredQuestions(uid); + expect(afterSecond.length, lessThanOrEqualTo(1)); + expect( + second.evaluation!.questionsCreated + + second.evaluation!.rulesSkipped.length, + greaterThan(0), + ); + }); +} diff --git a/server/test/integration/trading_orchestrator_gate_a_test.dart b/server/test/integration/trading_orchestrator_gate_a_test.dart new file mode 100644 index 0000000..891f583 --- /dev/null +++ b/server/test/integration/trading_orchestrator_gate_a_test.dart @@ -0,0 +1,182 @@ +@Tags(['integration', 'postgres']) +library; + +import 'package:cyberhybridhub_server/trading/guardrails.dart'; +import 'package:cyberhybridhub_server/trading/trade_actuator.dart'; +import 'package:cyberhybridhub_server/trading/trade_orders_db.dart'; +import 'package:cyberhybridhub_server/trading/trading_orchestrator.dart'; +import 'package:cyberhybridhub_server/trading/trading_pipeline.dart'; +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_db.dart'; + +/// Gate A — Local loop (test mode), per TRADING_TDD_PLAN.md. +/// +/// Seeds a user with the default `default_paper_watchlist` template and a +/// dipped SPY snapshot, then runs the orchestrator three times and asserts: +/// +/// 1. Tick #1 → `pipeline_key=trading` question created. +/// 2. User answers +10 → tick #2 drains pending order → `trade_orders` row. +/// 3. Tick #3 → rule does not re-fire (cooldown holds). +/// +/// Runs entirely in test mode (no Alpaca client). The actuator stamps each +/// `trade_orders` row with `alpaca_order_id='test-'` and +/// `status='test_accepted'`. +void main() { + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + test( + 'Gate A: seed → tick → question → answer → tick → order → tick → cooldown', + () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'gate-a-uid'; + final DateTime tickClock = DateTime.utc(2026, 5, 26, 14, 30); + + await testDb!.seedUser(uid); + await testDb!.tradingConfigDb.upsertUserConfig( + firebaseUid: uid, + templateName: 'default_paper_watchlist', + enabled: true, + ); + + // Seed a SPY dip (-1.6%) within the rule's max_staleness window. + await testDb!.marketDataDb.insertSnapshot( + symbol: 'SPY', + metric: 'prev_close', + price: 500, + asOf: DateTime.utc(2026, 5, 25, 20), + ); + await testDb!.marketDataDb.insertSnapshot( + symbol: 'SPY', + metric: 'last_trade', + price: 492, + asOf: tickClock.subtract(const Duration(minutes: 1)), + ); + + final TradingPipeline pipeline = TradingPipeline( + questionsDb: testDb!.questionsDb, + questionService: testDb!.questionService(), + marketDataDb: testDb!.marketDataDb, + tradingConfigDb: testDb!.tradingConfigDb, + tradingStateDb: testDb!.userTradingStateDb, + clock: () => tickClock, + ); + final TradeActuator actuator = TradeActuator( + tradingConfigDb: testDb!.tradingConfigDb, + tradingStateDb: testDb!.userTradingStateDb, + tradeOrdersDb: testDb!.tradeOrdersDb, + questionsDb: testDb!.questionsDb, + guardrails: Guardrails(), + clock: () => tickClock, + // alpacaClient: null → test mode (test_accepted rows, no HTTP). + ); + final TradingOrchestrator orchestrator = TradingOrchestrator( + questionsDb: testDb!.questionsDb, + tradingConfigDb: testDb!.tradingConfigDb, + pipeline: pipeline, + actuator: actuator, + // ingest: null → fixture snapshots above stand in for live ingest. + ingestEnabled: false, + clock: () => tickClock, + ); + + // === Tick #1 — rule fires, question created ============================ + final TradingTickResult t1 = await orchestrator.tickUser(uid); + expect(t1.skipped, isFalse); + expect(t1.evaluation, isNotNull); + expect(t1.evaluation!.rulesFired, ['dip_confirm']); + expect(t1.actuator!.submitted, isEmpty); + + final Result questionRows = await testDb!.connection.execute( + Sql.named( + ''' + SELECT id, pipeline_key, pipeline_step + FROM questions + WHERE assigned_user_id = @uid AND user_response IS NULL + ''', + ), + parameters: {'uid': uid}, + ); + expect(questionRows, hasLength(1)); + final String questionId = questionRows.first[0]!.toString(); + expect(questionRows.first[1], 'trading'); + expect(questionRows.first[2], 'dip_confirm:await_confirm'); + + // === User answers +10 =================================================== + final Map? answered = + await testDb!.questionsDb.submitAnswer( + questionId: questionId, + assignedUserId: uid, + userResponse: 10, + ); + expect(answered, isNotNull); + await pipeline.handleAnswer( + firebaseUid: uid, + answeredQuestion: answered!, + userResponse: 10, + ); + + final List> staged = + await testDb!.userTradingStateDb.listPendingOrders(uid); + expect(staged, hasLength(1)); + expect(staged.single['client_order_id'], '$uid-dip_confirm-$questionId'); + + // === Tick #2 — actuator drains pending → trade_orders row ============== + final TradingTickResult t2 = await orchestrator.tickUser(uid); + expect(t2.actuator!.submitted, hasLength(1)); + expect(t2.actuator!.rejected, isEmpty); + + final TradeOrder? saved = await testDb!.tradeOrdersDb + .findByClientOrderId('$uid-dip_confirm-$questionId'); + expect(saved, isNotNull, reason: 'trade_orders row should be inserted'); + expect(saved!.alpacaOrderId, startsWith('test-')); + expect(saved.status, 'test_accepted'); + expect(saved.symbol, 'SPY'); + expect(saved.notionalUsd, 10); + expect(saved.questionId, questionId); + expect(saved.ruleId, 'dip_confirm'); + + expect( + await testDb!.userTradingStateDb.listPendingOrders(uid), + isEmpty, + reason: 'pending_orders should be drained after submission', + ); + + // === Tick #3 — cooldown holds, no new question ========================= + final TradingTickResult t3 = await orchestrator.tickUser(uid); + expect(t3.evaluation!.rulesFired, isEmpty); + expect(t3.evaluation!.questionsCreated, 0); + expect(t3.evaluation!.rulesSkipped, hasLength(1)); + expect(t3.evaluation!.rulesSkipped.single, contains('cooldown')); + + final Result remaining = await testDb!.connection.execute( + Sql.named( + ''' + SELECT COUNT(*) FROM questions + WHERE assigned_user_id = @uid AND user_response IS NULL + ''', + ), + parameters: {'uid': uid}, + ); + expect(num.parse(remaining.first[0]!.toString()).toInt(), 0); + }); +} diff --git a/server/test/integration/trading_pipeline_test.dart b/server/test/integration/trading_pipeline_test.dart new file mode 100644 index 0000000..99faeac --- /dev/null +++ b/server/test/integration/trading_pipeline_test.dart @@ -0,0 +1,306 @@ +@Tags(['integration', 'postgres']) +library; + +import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart'; +import 'package:cyberhybridhub_server/trading/trading_pipeline.dart'; +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_db.dart'; + +void main() { + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + final DateTime _testNow = DateTime.utc(2026, 5, 23, 14, 30); + + Future _seedSpyDipSnapshots({DateTime? tradeAsOf}) async { + final DateTime asOfTrade = + tradeAsOf ?? _testNow.subtract(const Duration(minutes: 1)); + final DateTime asOfPrev = DateTime.utc(2026, 5, 22, 20); + await testDb!.marketDataDb.insertSnapshot( + symbol: 'SPY', + metric: 'prev_close', + price: 500, + asOf: asOfPrev, + ); + await testDb!.marketDataDb.insertSnapshot( + symbol: 'SPY', + metric: 'last_trade', + price: 492, + asOf: asOfTrade, + ); + } + + Future _pipeline({DateTime? clock}) async { + final DateTime fixed = clock ?? _testNow; + return TradingPipeline( + questionsDb: testDb!.questionsDb, + questionService: testDb!.questionService(), + marketDataDb: testDb!.marketDataDb, + tradingConfigDb: testDb!.tradingConfigDb, + tradingStateDb: testDb!.userTradingStateDb, + clock: () => fixed, + ); + } + + group('TradingPipeline.evaluate', () { + test('creates pipeline_key=trading question when SPY dip rule fires', + () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + const String uid = 'trading-evaluate-uid'; + await testDb!.seedUser(uid); + await testDb!.tradingConfigDb.upsertUserConfig( + firebaseUid: uid, + templateName: 'default_paper_watchlist', + enabled: true, + ); + await _seedSpyDipSnapshots(); + + final TradingPipeline pipeline = await _pipeline(); + final TradingEvaluationResult result = await pipeline.evaluate(uid); + + expect(result.questionsCreated, 1); + expect(result.rulesFired, ['dip_confirm']); + + final Result rows = await testDb!.connection.execute( + Sql.named( + ''' + SELECT pipeline_key, pipeline_step, source_tag, correct_answer, + question_text + FROM questions + WHERE assigned_user_id = @uid + ''', + ), + parameters: {'uid': uid}, + ); + expect(rows, hasLength(1)); + final ResultRow row = rows.first; + expect(row[0], 'trading'); + expect(row[1], 'dip_confirm:await_confirm'); + expect(row[2], 'trading:rule:dip_confirm'); + expect(num.parse(row[3]!.toString()).toInt(), 10); + expect(row[4] as String, contains('SPY')); + + final Map? ruleState = + await testDb!.userTradingStateDb.getRuleState(uid, 'dip_confirm'); + expect(ruleState, isNotNull); + expect(ruleState!['phase'], 'await_confirm'); + expect(ruleState['question_id'], isA()); + }); + + test('does not double-fire when an open await_confirm question exists', + () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + const String uid = 'trading-evaluate-once-uid'; + await testDb!.seedUser(uid); + await testDb!.tradingConfigDb.upsertUserConfig( + firebaseUid: uid, + templateName: 'default_paper_watchlist', + enabled: true, + ); + await _seedSpyDipSnapshots(); + + // Same clock + same fresh snapshots: the open-question guard must catch + // the second call before the rule engine's cooldown does. + final TradingPipeline pipeline = await _pipeline(); + await pipeline.evaluate(uid); + final TradingEvaluationResult result = await pipeline.evaluate(uid); + + expect(result.questionsCreated, 0); + expect(result.rulesSkipped.first, contains('open_question')); + }); + + test('skips rule via cooldown when same-day last_fired_at exists', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + const String uid = 'trading-evaluate-cooldown-uid'; + await testDb!.seedUser(uid); + await testDb!.tradingConfigDb.upsertUserConfig( + firebaseUid: uid, + templateName: 'default_paper_watchlist', + enabled: true, + ); + await _seedSpyDipSnapshots(); + + final TradingPipeline pipeline = await _pipeline(); + await pipeline.evaluate(uid); + + // Answer (and so close) the await_confirm question so the "open + // question" guard doesn't dominate; we want to see the cooldown path. + final List> open = + await testDb!.questionsDb.listUnansweredQuestions(uid); + await testDb!.questionsDb.submitAnswer( + questionId: open.single['id'] as String, + assignedUserId: uid, + userResponse: -10, + ); + + final TradingEvaluationResult again = await pipeline.evaluate(uid); + expect(again.questionsCreated, 0); + expect( + again.rulesSkipped.single, + contains('cooldown'), + ); + }); + }); + + group('TradingPipeline.handleAnswer', () { + test('+10 stages a pending order in user_trading_state.context', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + const String uid = 'trading-answer-yes-uid'; + await testDb!.seedUser(uid); + await testDb!.tradingConfigDb.upsertUserConfig( + firebaseUid: uid, + templateName: 'default_paper_watchlist', + enabled: true, + ); + await _seedSpyDipSnapshots(); + + final TradingPipeline pipeline = await _pipeline(); + await pipeline.evaluate(uid); + + final List> open = + await testDb!.questionsDb.listUnansweredQuestions(uid); + final Map? updated = + await testDb!.questionsDb.submitAnswer( + questionId: open.single['id'] as String, + assignedUserId: uid, + userResponse: 10, + ); + + await pipeline.handleAnswer( + firebaseUid: uid, + answeredQuestion: updated!, + userResponse: 10, + ); + + final List> pending = + await testDb!.userTradingStateDb.listPendingOrders(uid); + expect(pending, hasLength(1)); + expect(pending.single['symbol'], 'SPY'); + expect(pending.single['side'], 'buy'); + expect(pending.single['notional_usd'], 10); + expect( + pending.single['client_order_id'], + '$uid-dip_confirm-${open.single['id']}', + ); + + final Map? ruleState = + await testDb!.userTradingStateDb.getRuleState(uid, 'dip_confirm'); + expect(ruleState!['phase'], 'submit_order'); + expect(ruleState['answer'], 'yes'); + }); + + test('-10 records skip and does not stage an order', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + const String uid = 'trading-answer-no-uid'; + await testDb!.seedUser(uid); + await testDb!.tradingConfigDb.upsertUserConfig( + firebaseUid: uid, + templateName: 'default_paper_watchlist', + enabled: true, + ); + await _seedSpyDipSnapshots(); + + final TradingPipeline pipeline = await _pipeline(); + await pipeline.evaluate(uid); + + final List> open = + await testDb!.questionsDb.listUnansweredQuestions(uid); + final Map? updated = + await testDb!.questionsDb.submitAnswer( + questionId: open.single['id'] as String, + assignedUserId: uid, + userResponse: -10, + ); + + await pipeline.handleAnswer( + firebaseUid: uid, + answeredQuestion: updated!, + userResponse: -10, + ); + + final List> pending = + await testDb!.userTradingStateDb.listPendingOrders(uid); + expect(pending, isEmpty); + + final Map? ruleState = + await testDb!.userTradingStateDb.getRuleState(uid, 'dip_confirm'); + expect(ruleState!['phase'], 'done'); + expect(ruleState['answer'], 'no'); + }); + }); + + group('QuestionPipeline.onAnswerSubmitted delegation', () { + test('routes pipeline_key=trading answer to TradingPipeline.handleAnswer', + () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + const String uid = 'trading-delegation-uid'; + await testDb!.seedUser(uid); + await testDb!.tradingConfigDb.upsertUserConfig( + firebaseUid: uid, + templateName: 'default_paper_watchlist', + enabled: true, + ); + await _seedSpyDipSnapshots(); + + final TradingPipeline tradingPipeline = await _pipeline(); + final QuestionPipeline questionPipeline = QuestionPipeline( + questionsDb: testDb!.questionsDb, + questionService: testDb!.questionService(), + tradingPipeline: tradingPipeline, + ); + + await tradingPipeline.evaluate(uid); + final List> open = + await testDb!.questionsDb.listUnansweredQuestions(uid); + final Map? updated = + await testDb!.questionsDb.submitAnswer( + questionId: open.single['id'] as String, + assignedUserId: uid, + userResponse: 10, + ); + + await questionPipeline.onAnswerSubmitted( + firebaseUid: uid, + answeredQuestion: updated!, + userResponse: 10, + ); + + final List> pending = + await testDb!.userTradingStateDb.listPendingOrders(uid); + expect(pending, hasLength(1)); + }); + }); +} diff --git a/server/test/integration/trading_schema_test.dart b/server/test/integration/trading_schema_test.dart new file mode 100644 index 0000000..e523ded --- /dev/null +++ b/server/test/integration/trading_schema_test.dart @@ -0,0 +1,90 @@ +@Tags(['integration', 'postgres']) +library; + +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; +import 'package:uuid/uuid.dart'; + +import '../helpers/test_db.dart'; + +void main() { + const Uuid uuid = Uuid(); + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + test('trading tables enforce FK to users', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'trading-schema-test-uid'; + await testDb!.seedUser(uid); + final Connection connection = testDb!.connection; + final DateTime asOf = DateTime.utc(2026, 5, 23, 12); + + await connection.execute( + Sql.named( + ''' + INSERT INTO market_data_snapshots (symbol, metric, price, as_of) + VALUES ('SPY', 'last_trade', 492, @as_of) + ''', + ), + parameters: {'as_of': asOf}, + ); + + await connection.execute( + Sql.named( + ''' + INSERT INTO user_trading_config (firebase_uid, enabled, config) + VALUES (@uid, true, '{}'::jsonb) + ''', + ), + parameters: {'uid': uid}, + ); + + final String orderId = uuid.v4(); + await connection.execute( + Sql.named( + ''' + INSERT INTO trade_orders ( + id, firebase_uid, client_order_id, symbol, side, order_type, status + ) VALUES ( + @id::uuid, @uid, @client_order_id, 'SPY', 'buy', 'market', 'pending' + ) + ''', + ), + parameters: { + 'id': orderId, + 'uid': uid, + 'client_order_id': 'test-$orderId', + }, + ); + + await expectLater( + connection.execute( + Sql.named( + ''' + INSERT INTO user_trading_config (firebase_uid, enabled, config) + VALUES (@uid, true, '{}'::jsonb) + ''', + ), + parameters: {'uid': 'missing-user-uid'}, + ), + throwsA(isA()), + ); + }); +} diff --git a/server/test/smoke_test.dart b/server/test/smoke_test.dart new file mode 100644 index 0000000..1c721ef --- /dev/null +++ b/server/test/smoke_test.dart @@ -0,0 +1,23 @@ +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; + +import 'helpers/fixture_loader.dart'; +import 'helpers/mock_http_client.dart'; + +void main() { + test('test harness layout loads fixtures and the mock http client', () async { + final FixtureLoader fixtures = FixtureLoader(); + final Map trade = + await fixtures.loadJson('alpaca_latest_trade.json'); + expect(trade['symbol'], 'SPY'); + expect(trade['trade'], isA>()); + + final MockHttpClient client = MockHttpClient() + ..whenGetJson('/trades/latest', trade); + final http.Response response = await client.get( + Uri.parse('https://data.alpaca.markets/v2/stocks/SPY/trades/latest'), + ); + expect(response.statusCode, 200); + expect(client.requests, hasLength(1)); + }); +} diff --git a/server/test/trading/guardrails_test.dart b/server/test/trading/guardrails_test.dart new file mode 100644 index 0000000..0f1ee73 --- /dev/null +++ b/server/test/trading/guardrails_test.dart @@ -0,0 +1,176 @@ +import 'package:cyberhybridhub_server/trading/guardrails.dart'; +import 'package:cyberhybridhub_server/trading/trading_config.dart'; +import 'package:test/test.dart'; + +EffectiveTradingConfig _config({ + bool enabled = true, + String mode = 'paper', + List symbols = const ['SPY'], + int maxOrdersPerDay = 3, + num maxNotionalUsdPer4h = 100, + bool requireQuestion = true, + List blocklist = const [], +}) { + return EffectiveTradingConfig.fromJson({ + 'version': 1, + 'enabled': enabled, + 'mode': mode, + 'data_inputs': >[ + { + 'id': 'primary_watchlist', + 'symbols': symbols, + 'metrics': ['last_trade'], + }, + ], + 'rules': >[], + 'guardrails': { + 'max_orders_per_day': maxOrdersPerDay, + 'max_notional_usd_per_4h': maxNotionalUsdPer4h, + 'require_question_before_order': requireQuestion, + 'symbols_blocklist': blocklist, + }, + }); +} + +GuardrailDecision _check( + Guardrails g, { + required EffectiveTradingConfig config, + String symbol = 'SPY', + num notionalUsd = 10, + int dailyOrderCount = 0, + num notionalUsdInWindow = 0, + bool hasUnansweredQuestion = false, + bool questionAnswered = true, +}) { + return g.check( + config: config, + symbol: symbol, + notionalUsd: notionalUsd, + dailyOrderCount: dailyOrderCount, + notionalUsdInWindow: notionalUsdInWindow, + hasUnansweredQuestion: hasUnansweredQuestion, + questionAnswered: questionAnswered, + ); +} + +void main() { + group('Guardrails.check', () { + test('allows a paper order within all limits', () { + final GuardrailDecision decision = + _check(Guardrails(), config: _config()); + expect(decision.allowed, isTrue); + }); + + test('rejects when trading is disabled', () { + final GuardrailDecision decision = _check( + Guardrails(), + config: _config(enabled: false), + ); + expect(decision.reason, GuardrailRejectionReason.tradingDisabled); + }); + + test('rejects blocklisted symbol', () { + final GuardrailDecision decision = _check( + Guardrails(), + config: _config(blocklist: ['SPY']), + ); + expect(decision.reason, GuardrailRejectionReason.blocklistedSymbol); + }); + + test('rejects symbol not in watchlist', () { + final GuardrailDecision decision = _check( + Guardrails(), + config: _config(symbols: ['AAPL']), + ); + expect(decision.reason, GuardrailRejectionReason.symbolNotInWatchlist); + }); + + test('rejects when daily order count reaches config max', () { + final GuardrailDecision decision = _check( + Guardrails(), + config: _config(maxOrdersPerDay: 3), + dailyOrderCount: 3, + ); + expect( + decision.reason, + GuardrailRejectionReason.maxOrdersPerDayExceeded, + ); + }); + + test('rejects when 4h-window notional plus new order would exceed config', + () { + final GuardrailDecision decision = _check( + Guardrails(), + config: _config(maxNotionalUsdPer4h: 100), + notionalUsd: 50, + notionalUsdInWindow: 60, + ); + expect( + decision.reason, + GuardrailRejectionReason.maxNotionalUsdPer4hExceeded, + ); + }); + + test('allows a later order when prior 4h window has rolled off', () { + // Caller queries trade_orders with submitted_at >= now() - 4h, so older + // orders aren't counted here. + final GuardrailDecision decision = _check( + Guardrails(), + config: _config(maxNotionalUsdPer4h: 100), + notionalUsd: 40, + notionalUsdInWindow: 0, + ); + expect(decision.allowed, isTrue); + }); + + test('Guardrails.windowDuration is 4 hours by default', () { + expect(Guardrails().windowDuration, const Duration(hours: 4)); + }); + + test('rejects when require_question_before_order and no answer on file', + () { + final GuardrailDecision decision = _check( + Guardrails(), + config: _config(), + questionAnswered: false, + ); + expect(decision.reason, GuardrailRejectionReason.questionRequired); + }); + + test('rejects when an unanswered question is still open', () { + final GuardrailDecision decision = _check( + Guardrails(), + config: _config(), + hasUnansweredQuestion: true, + ); + expect(decision.reason, GuardrailRejectionReason.unansweredQuestion); + }); + + test('server ceiling overrides high config max_notional_usd_per_4h', () { + final Guardrails g = Guardrails(serverMaxNotionalUsd: 25); + final GuardrailDecision decision = _check( + g, + config: _config(maxNotionalUsdPer4h: 10000), + notionalUsd: 100, + ); + expect( + decision.reason, + GuardrailRejectionReason.serverMaxNotionalUsdExceeded, + ); + }); + + test('refuses live mode unless allowLive=true', () { + final GuardrailDecision blocked = _check( + Guardrails(), + config: _config(mode: 'live'), + ); + expect(blocked.reason, GuardrailRejectionReason.livePaperMismatch); + + final GuardrailDecision allowed = _check( + Guardrails(allowLive: true), + config: _config(mode: 'live'), + ); + expect(allowed.allowed, isTrue); + }); + }); +} diff --git a/server/test/trading/rule_engine_test.dart b/server/test/trading/rule_engine_test.dart new file mode 100644 index 0000000..c8d8b1c --- /dev/null +++ b/server/test/trading/rule_engine_test.dart @@ -0,0 +1,176 @@ +import 'package:cyberhybridhub_server/trading/market_data_db.dart'; +import 'package:cyberhybridhub_server/trading/rule_engine.dart'; +import 'package:cyberhybridhub_server/trading/trading_config.dart'; +import 'package:test/test.dart'; + +void main() { + late TradingRuleConfig dipRule; + late DateTime now; + + setUp(() { + dipRule = TradingRuleConfig.fromJson({ + 'id': 'dip_confirm', + 'type': 'price_below_pct_of_ref', + 'symbol': 'SPY', + 'ref_metric': 'prev_close', + 'threshold_pct': -1.5, + 'question_template': + '{{symbol}} is down {{pct}}% at \${{price}}. Buy?', + 'max_staleness_seconds': 600, + }); + now = DateTime.utc(2026, 5, 25, 14, 30); + }); + + MarketDataSnapshot snapshot( + String metric, + num price, { + DateTime? asOf, + }) { + return MarketDataSnapshot( + symbol: 'SPY', + metric: metric, + asOf: (asOf ?? now).toUtc(), + price: price, + ); + } + + group('RuleEngine.evaluate price_below_pct_of_ref', () { + test('fires when last_trade is 1.6% below prev_close', () { + final RuleEngine engine = RuleEngine(); + final RuleEvaluation result = engine.evaluate( + rule: dipRule, + snapshots: { + 'last_trade': snapshot('last_trade', 492), + 'prev_close': snapshot('prev_close', 500), + }, + now: now, + ); + + expect(result.fired, isTrue); + expect(result.pricePct, closeTo(-1.6, 0.01)); + expect(result.refPrice, 500); + expect(result.observedPrice, 492); + expect( + result.questionText, + 'SPY is down 1.60% at \$492.00. Buy?', + ); + }); + + test('does not fire above threshold (-0.5%)', () { + final RuleEngine engine = RuleEngine(); + final RuleEvaluation result = engine.evaluate( + rule: dipRule, + snapshots: { + 'last_trade': snapshot('last_trade', 497.5), + 'prev_close': snapshot('prev_close', 500), + }, + now: now, + ); + + expect(result.fired, isFalse); + expect(result.skipReason, RuleSkipReason.aboveThreshold); + expect(result.pricePct, closeTo(-0.5, 0.01)); + }); + + test('does not fire when last_trade snapshot missing', () { + final RuleEngine engine = RuleEngine(); + final RuleEvaluation result = engine.evaluate( + rule: dipRule, + snapshots: { + 'prev_close': snapshot('prev_close', 500), + }, + now: now, + ); + + expect(result.fired, isFalse); + expect(result.skipReason, RuleSkipReason.missingMetric); + }); + + test('does not fire when last_trade is older than max_staleness_seconds', + () { + final RuleEngine engine = RuleEngine(); + final RuleEvaluation result = engine.evaluate( + rule: dipRule, + snapshots: { + 'last_trade': snapshot( + 'last_trade', + 492, + asOf: now.subtract(const Duration(minutes: 30)), + ), + 'prev_close': snapshot('prev_close', 500), + }, + now: now, + ); + + expect(result.fired, isFalse); + expect(result.skipReason, RuleSkipReason.staleData); + }); + + test('does not fire when cooldown matches same UTC day', () { + final RuleEngine engine = RuleEngine(); + final RuleEvaluation result = engine.evaluate( + rule: dipRule, + snapshots: { + 'last_trade': snapshot('last_trade', 492), + 'prev_close': snapshot('prev_close', 500), + }, + lastFiredAt: now.subtract(const Duration(hours: 2)), + now: now, + ); + + expect(result.fired, isFalse); + expect(result.skipReason, RuleSkipReason.cooldown); + }); + + test('fires when cooldown was on a previous UTC day', () { + final RuleEngine engine = RuleEngine(); + final RuleEvaluation result = engine.evaluate( + rule: dipRule, + snapshots: { + 'last_trade': snapshot('last_trade', 492), + 'prev_close': snapshot('prev_close', 500), + }, + lastFiredAt: now.subtract(const Duration(days: 1, hours: 2)), + now: now, + ); + + expect(result.fired, isTrue); + }); + + test('refuses zero reference price (would divide by zero)', () { + final RuleEngine engine = RuleEngine(); + final RuleEvaluation result = engine.evaluate( + rule: dipRule, + snapshots: { + 'last_trade': snapshot('last_trade', 492), + 'prev_close': snapshot('prev_close', 0), + }, + now: now, + ); + + expect(result.fired, isFalse); + expect(result.skipReason, RuleSkipReason.zeroReferencePrice); + }); + + test('unknown rule type is skipped', () { + final TradingRuleConfig unknown = TradingRuleConfig.fromJson( + { + 'id': 'foo', + 'type': 'momentum_breakout', + 'symbol': 'SPY', + 'threshold_pct': 1, + 'question_template': 'n/a', + }, + ); + final RuleEngine engine = RuleEngine(); + final RuleEvaluation result = engine.evaluate( + rule: unknown, + snapshots: {}, + now: now, + ); + + expect(result.fired, isFalse); + expect(result.skipReason, RuleSkipReason.unknownType); + }); + }); +} diff --git a/server/test/trading/trading_config_test.dart b/server/test/trading/trading_config_test.dart new file mode 100644 index 0000000..b58d652 --- /dev/null +++ b/server/test/trading/trading_config_test.dart @@ -0,0 +1,68 @@ +import 'package:cyberhybridhub_server/trading/trading_config.dart'; +import 'package:test/test.dart'; + +void main() { + group('EffectiveTradingConfig.mergeJson', () { + test('user rule patch merges into template by id', () { + final Map base = { + 'version': 1, + 'enabled': true, + 'rules': >[ + { + 'id': 'dip_confirm', + 'type': 'price_below_pct_of_ref', + 'symbol': 'SPY', + 'threshold_pct': -1.5, + 'question_template': 'template text', + }, + ], + }; + final Map override = { + 'rules': >[ + { + 'id': 'dip_confirm', + 'threshold_pct': -2.0, + }, + ], + }; + + final Map merged = + EffectiveTradingConfig.mergeJson(base, override); + final List rules = merged['rules'] as List; + final Map rule = + Map.from(rules.single as Map); + + expect(rule['threshold_pct'], -2.0); + expect(rule['question_template'], 'template text'); + }); + + test('user data_inputs patch replaces symbols for same id', () { + final Map base = { + 'data_inputs': >[ + { + 'id': 'primary_watchlist', + 'symbols': ['AAPL', 'MSFT', 'SPY'], + 'metrics': ['last_trade'], + }, + ], + }; + final Map override = { + 'data_inputs': >[ + { + 'id': 'primary_watchlist', + 'symbols': ['SPY'], + }, + ], + }; + + final Map merged = + EffectiveTradingConfig.mergeJson(base, override); + final List inputs = merged['data_inputs'] as List; + final Map input = + Map.from(inputs.single as Map); + + expect(input['symbols'], ['SPY']); + expect(input['metrics'], ['last_trade']); + }); + }); +} diff --git a/startup.sh b/startup.sh index fc6f16c..10b257c 100755 --- a/startup.sh +++ b/startup.sh @@ -78,11 +78,28 @@ log "Starting API on http://localhost:${API_PORT} ..." ) & API_PID=$! +# Build a static debug web bundle and serve it via a plain HTTP server. +# This avoids `flutter run -d web-server`'s DWDS session pinning, which causes +# blank pages when the browser is closed and reopened. +# +# Set SKIP_WEB_BUILD=1 to skip the build (useful when iterating on server-only +# changes and the existing build/web is fine to keep serving). +if [ "${SKIP_WEB_BUILD:-0}" != "1" ]; then + log "Building Flutter web (debug)..." + ( + cd "$ROOT" + flutter build web --debug --source-maps \ + --dart-define=API_BASE_URL="http://localhost:${API_PORT}" \ + 2>&1 | prefix_lines web-build + ) +else + log "SKIP_WEB_BUILD=1 — reusing existing build/web bundle." +fi + log "Starting web app on http://localhost:${WEB_PORT} ..." ( cd "$ROOT" - flutter run -d web-server \ - --dart-define=API_BASE_URL="http://localhost:${API_PORT}" \ + dart run scripts/web_static_server.dart build/web "${WEB_PORT}" \ 2>&1 | prefix_lines web ) & WEB_PID=$!