latest step after first alpaca

This commit is contained in:
Nathan Anderson 2026-05-26 19:18:59 -05:00
parent 33bde0dc82
commit 8278f93a34
66 changed files with 7060 additions and 255 deletions

1
.gitignore vendored
View File

@ -58,7 +58,6 @@ unlinked_spec.ds
**/ios/Runner/GeneratedPluginRegistrant.* **/ios/Runner/GeneratedPluginRegistrant.*
# Web # Web
lib/generated_plugin_registrant.dart
# Coverage # Coverage
coverage/ coverage/

238
TODO.md
View File

@ -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)

View File

@ -143,7 +143,7 @@ Store in Postgres as `JSONB`; validate on write via API or seed migrations.
], ],
"guardrails": { "guardrails": {
"max_orders_per_day": 3, "max_orders_per_day": 3,
"max_notional_usd_per_day": 100, "max_notional_usd_per_4h": 100,
"require_question_before_order": true, "require_question_before_order": true,
"symbols_blocklist": [] "symbols_blocklist": []
} }
@ -342,7 +342,7 @@ On each `QuestionBackgroundWorker._tick()` when `TRADING_ENABLED=true`:
### 8.3 Guardrails (always before Alpaca POST) ### 8.3 Guardrails (always before Alpaca POST)
- `require_question_before_order` and unanswered question must be resolved. - `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`. - `mode == paper` unless `ALPACA_ALLOW_LIVE=true`.
- Symbol in user watchlist and not in `symbols_blocklist`. - Symbol in user watchlist and not in `symbols_blocklist`.
- Idempotent `client_order_id` = `uuid` or `{uid}-{rule_id}-{question_id}`. - Idempotent `client_order_id` = `uuid` or `{uid}-{rule_id}-{question_id}`.

453
TRADING_TDD_PLAN.md Normal file
View File

@ -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 111 — Phase 1 MVP | 🔄 In progress | Day 7: Steps 910 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 1215 — Phase 2 | ⬜ Not started | |
| Steps 1619 — Phase 3 | ⬜ Deferred | |
Legend: ⬜ Not started · 🔄 In progress · ✅ Done · ⏸ Blocked
---
## Progress log
<!-- Agents: append newest entries at the top -->
| 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 | 78 | `dart test` green (47 tests); TradingPipeline evaluate/handleAnswer + QuestionPipeline branch |
| 2026-05-25 | 56 | `dart test` green (39 tests); rule engine + guardrails (pure logic) |
| 2026-05-23 | 34 | `dart test` green (21 tests); Alpaca MD client, ingest, poll interval; live Alpaca OK |
| 2026-05-23 | 1.31.4, 2 | `dart test` green (17 tests); config merge, trade orders, Alpaca env/models |
| 2026-05-23 | 0, 1.11.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 001004 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 010 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.11.2 | Test harness + migration + snapshot DB |
| 2 | 1.31.4, 2 | Config merge + env |
| 3 | 34 | Alpaca MD client + ingest |
| 4 | 56 | Rule engine + guardrails |
| 5 | 78 | 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.*

View File

@ -138,9 +138,10 @@ class HomeScreen extends StatelessWidget {
), ),
if (!showQuestionPanel) if (!showQuestionPanel)
Expanded( Expanded(
child: Padding( child: SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[ children: <Widget>[
Container( Container(

View File

@ -22,10 +22,11 @@ class AuthServiceFirebase {
Future<void> initialize() async { Future<void> initialize() async {
if (kIsWeb) { if (kIsWeb) {
if (googleWebOAuthClientId != null) { // Skip eager google_sign_in init on web. Firebase Auth's signInWithPopup
await _googleSignIn.initialize(clientId: googleWebOAuthClientId); // already loads Google Identity Services, and double-initializing logs
_googleSignInReady = true; // 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; return;
} }
@ -69,7 +70,7 @@ class AuthServiceFirebase {
} }
} }
if (!_googleSignInReady) { if (googleWebOAuthClientId == null) {
throw StateError( throw StateError(
'Google sign-in failed in this browser (often Firefox with strict ' 'Google sign-in failed in this browser (often Firefox with strict '
'privacy). Set googleWebOAuthClientId in lib/config/auth_config.dart ' '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 GoogleSignInAccount account = await _googleSignIn.authenticate();
final String? idToken = account.authentication.idToken; final String? idToken = account.authentication.idToken;
if (idToken == null) { if (idToken == null) {

16
scripts/test-server-alpaca.sh Executable file
View File

@ -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 "$@"

17
scripts/test-server.sh Executable file
View File

@ -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 "$@"

View File

@ -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 <root-dir> <port>
import 'dart:async';
import 'dart:io';
Future<void> main(List<String> 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<void> _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';
}
}

View File

@ -25,6 +25,27 @@ Postgres-backed profile API for the Flutter app.
The API listens on `http://localhost:3000` by default (`PORT` in `.env`). 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 ## Endpoints
| Method | Path | Auth | | Method | Path | Auth |

View File

@ -3,6 +3,8 @@ import 'dart:io';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io; 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/db.dart';
import '../lib/env.dart'; import '../lib/env.dart';
import '../lib/firebase_auth.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/profile_handler.dart';
import '../lib/handlers/questions_handler.dart'; import '../lib/handlers/questions_handler.dart';
import '../lib/handlers/questions_hub_handler.dart'; import '../lib/handlers/questions_hub_handler.dart';
import '../lib/handlers/trading_dev_handler.dart';
import '../lib/pipeline/question_pipeline.dart'; import '../lib/pipeline/question_pipeline.dart';
import '../lib/question_service.dart'; import '../lib/question_service.dart';
import '../lib/questions_db.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'; import '../lib/workers/question_background_worker.dart';
Future<void> main() async { Future<void> main() async {
@ -36,9 +49,81 @@ Future<void> main() async {
questionsDb: questionsDb, questionsDb: questionsDb,
hubConnections: questionsHubConnections, 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( final QuestionPipeline questionPipeline = QuestionPipeline(
questionsDb: questionsDb, questionsDb: questionsDb,
questionService: questionService, questionService: questionService,
tradingPipeline: tradingPipeline,
testMode: env.questionPipelineTestMode, testMode: env.questionPipelineTestMode,
); );
QuestionBackgroundWorker? backgroundWorker; QuestionBackgroundWorker? backgroundWorker;
@ -46,6 +131,7 @@ Future<void> main() async {
backgroundWorker = QuestionBackgroundWorker( backgroundWorker = QuestionBackgroundWorker(
pipeline: questionPipeline, pipeline: questionPipeline,
interval: Duration(seconds: env.questionWorkerIntervalSeconds), interval: Duration(seconds: env.questionWorkerIntervalSeconds),
tradingOrchestrator: tradingOrchestrator,
); );
backgroundWorker.start(); backgroundWorker.start();
} }
@ -64,6 +150,9 @@ Future<void> main() async {
questionService: questionService, questionService: questionService,
questionPipeline: questionPipeline, questionPipeline: questionPipeline,
); );
final Handler? tradingDev = tradingDevActions == null
? null
: tradingDevHandler(auth: auth, devActions: tradingDevActions);
final Handler handler = Pipeline() final Handler handler = Pipeline()
.addMiddleware(logRequests()) .addMiddleware(logRequests())
@ -75,6 +164,9 @@ Future<void> main() async {
if (path == '/v1/me/incoming-question') { if (path == '/v1/me/incoming-question') {
return incomingQuestion(request); return incomingQuestion(request);
} }
if (tradingDev != null && path.startsWith(tradingDevBasePath)) {
return tradingDev(request);
}
if (path.startsWith(questionsBasePath)) { if (path.startsWith(questionsBasePath)) {
return questions(request); return questions(request);
} }

7
server/dart_test.yaml Normal file
View File

@ -0,0 +1,7 @@
# Integration suites share cyberhybridhub_test; run serially.
concurrency: 1
tags:
integration:
postgres:
alpaca:

View File

@ -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<String, String> 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',
);
}
}
}

View File

@ -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<String, String> get _headers => <String, String>{
'APCA-API-KEY-ID': _env.apiKeyId,
'APCA-API-SECRET-KEY': _env.apiSecretKey,
};
/// `GET /v2/stocks/{symbol}/trades/latest`
Future<AlpacaLatestTradeResponse> getLatestTrade(String symbol) async {
_env.requireCredentials();
final Uri uri = Uri.parse(
'${_env.dataBaseUrl}/v2/stocks/${Uri.encodeComponent(symbol)}/trades/latest',
).replace(queryParameters: <String, String>{'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<String, dynamic>,
);
}
/// `GET /v2/stocks/bars` batched symbols, daily bars newest last.
Future<AlpacaBarsResponse> getDailyBars(
List<String> symbols, {
int limit = 2,
}) async {
_env.requireCredentials();
if (symbols.isEmpty) {
return AlpacaBarsResponse(barsBySymbol: <String, List<AlpacaBar>>{});
}
final Uri uri = Uri.parse('${_env.dataBaseUrl}/v2/stocks/bars').replace(
queryParameters: <String, String>{
'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<String, dynamic>,
);
}
void close() => _client.close();
}
class AlpacaMarketDataException implements Exception {
AlpacaMarketDataException(this.message);
final String message;
@override
String toString() => message;
}

View File

@ -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<String, dynamic> json) {
return AlpacaLatestTradeResponse(
symbol: json['symbol']! as String,
trade: AlpacaTrade.fromJson(
Map<String, dynamic>.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<String, dynamic> 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<String, dynamic> 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<String, List<AlpacaBar>> barsBySymbol;
factory AlpacaBarsResponse.fromJson(Map<String, dynamic> json) {
final Map<String, dynamic> rawBars =
Map<String, dynamic>.from(json['bars'] as Map? ?? <String, dynamic>{});
final Map<String, List<AlpacaBar>> parsed = <String, List<AlpacaBar>>{};
for (final MapEntry<String, dynamic> entry in rawBars.entries) {
final List<dynamic> list = entry.value as List<dynamic>? ?? <dynamic>[];
parsed[entry.key] = list
.whereType<Map>()
.map((Map<dynamic, dynamic> m) =>
AlpacaBar.fromJson(Map<String, dynamic>.from(m)))
.toList();
}
return AlpacaBarsResponse(barsBySymbol: parsed);
}
AlpacaBar? latestBar(String symbol) {
final List<AlpacaBar>? 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<AlpacaBar>? 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<String, dynamic>? raw;
factory AlpacaOrderResponse.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() {
return <String, dynamic>{
'symbol': symbol,
'side': side,
'type': type,
'time_in_force': timeInForce,
'client_order_id': clientOrderId,
if (notional != null) 'notional': notional,
if (qty != null) 'qty': qty,
};
}
}

View File

@ -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<String, String> get _headers => <String, String>{
'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<AlpacaOrderResponse> 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<String, dynamic>,
);
}
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<AlpacaOrderResponse?> getOrderByClientOrderId(
String clientOrderId,
) async {
_env.requireCredentials();
final Uri uri =
Uri.parse('${_env.tradingBaseUrl}/v2/orders:by_client_order_id').replace(
queryParameters: <String, String>{'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<String, dynamic>,
);
}
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;
}

View File

@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'alpaca/alpaca_env.dart';
import 'package:dotenv/dotenv.dart'; import 'package:dotenv/dotenv.dart';
class ServerEnv { class ServerEnv {
@ -10,6 +11,11 @@ class ServerEnv {
required this.questionWorkerEnabled, required this.questionWorkerEnabled,
required this.questionWorkerIntervalSeconds, required this.questionWorkerIntervalSeconds,
required this.questionPipelineTestMode, required this.questionPipelineTestMode,
required this.tradingEnabled,
required this.tradingWorkerIngestEnabled,
required this.tradingWorkerEvalEnabled,
required this.tradingDevEndpointsEnabled,
required this.alpaca,
}); });
final String databaseUrl; final String databaseUrl;
@ -18,11 +24,33 @@ class ServerEnv {
final bool questionWorkerEnabled; final bool questionWorkerEnabled;
final int questionWorkerIntervalSeconds; final int questionWorkerIntervalSeconds;
final bool questionPipelineTestMode; 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() { static ServerEnv load() {
final DotEnv env = DotEnv(includePlatformEnvironment: true) final DotEnv env = DotEnv(includePlatformEnvironment: true)
..load(['.env']); ..load(['.env']);
// Build a sanitized snapshot of the Alpaca-relevant keys for AlpacaEnv.
const List<String> alpacaKeys = <String>[
'ALPACA_API_KEY_ID',
'ALPACA_API_SECRET_KEY',
'ALPACA_TRADING_BASE_URL',
'ALPACA_DATA_BASE_URL',
'ALPACA_DATA_FEED',
'ALPACA_ALLOW_LIVE',
];
final Map<String, String> envMap = <String, String>{
for (final String key in alpacaKeys)
if (env[key] != null && env[key]!.isNotEmpty) key: env[key]!,
};
final String? databaseUrl = env['DATABASE_URL']; final String? databaseUrl = env['DATABASE_URL'];
if (databaseUrl == null || databaseUrl.isEmpty) { if (databaseUrl == null || databaseUrl.isEmpty) {
stderr.writeln('DATABASE_URL is required in server/.env'); stderr.writeln('DATABASE_URL is required in server/.env');
@ -42,6 +70,18 @@ class ServerEnv {
int.tryParse(env['QUESTION_WORKER_INTERVAL_SECONDS'] ?? '60') ?? 60; int.tryParse(env['QUESTION_WORKER_INTERVAL_SECONDS'] ?? '60') ?? 60;
final bool pipelineTestMode = final bool pipelineTestMode =
(env['QUESTION_PIPELINE_TEST_MODE'] ?? 'false').toLowerCase() == 'true'; (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._( return ServerEnv._(
databaseUrl: databaseUrl, databaseUrl: databaseUrl,
@ -50,6 +90,11 @@ class ServerEnv {
questionWorkerEnabled: workerEnabled, questionWorkerEnabled: workerEnabled,
questionWorkerIntervalSeconds: workerIntervalSeconds, questionWorkerIntervalSeconds: workerIntervalSeconds,
questionPipelineTestMode: pipelineTestMode, questionPipelineTestMode: pipelineTestMode,
tradingEnabled: tradingEnabled,
tradingWorkerIngestEnabled: tradingWorkerIngestEnabled,
tradingWorkerEvalEnabled: tradingWorkerEvalEnabled,
tradingDevEndpointsEnabled: tradingDevEndpointsEnabled,
alpaca: alpaca,
); );
} }
} }

View File

@ -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, <String, dynamic>{'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, <String, dynamic>{'error': 'Internal error'});
}
});
return (Request request) async {
if (request.method == 'OPTIONS') {
return Response.ok('', headers: apiCorsHeaders());
}
return router.call(request);
};
}
Future<String?> _verify(FirebaseAuthVerifier auth, Request request) {
return auth.verifyBearerToken(
request.headers['Authorization'] ?? request.headers['authorization'],
);
}
Response _jsonResponse(int status, Map<String, dynamic> body) {
return Response(
status,
body: jsonEncode(body),
headers: <String, String>{
...apiCorsHeaders(),
'Content-Type': 'application/json',
},
);
}

View File

@ -4,6 +4,7 @@ import 'dart:math';
import '../question_service.dart'; import '../question_service.dart';
import '../questions_db.dart'; import '../questions_db.dart';
import '../trading/trading_pipeline.dart';
import 'branch_decision.dart'; import 'branch_decision.dart';
import 'external_data_fetcher.dart'; import 'external_data_fetcher.dart';
@ -20,6 +21,7 @@ abstract final class PipelineKeys {
static const String root = 'root'; static const String root = 'root';
static const String geography = 'geography'; static const String geography = 'geography';
static const String weather = 'weather'; static const String weather = 'weather';
static const String trading = 'trading';
} }
/// Steps within each pipeline branch. /// Steps within each pipeline branch.
@ -33,21 +35,39 @@ abstract final class PipelineSteps {
static const String idle = 'idle'; 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. /// Orchestrates API-driven question creation and branches on user answers.
class QuestionPipeline { class QuestionPipeline {
QuestionPipeline({ QuestionPipeline({
required QuestionsDb questionsDb, required QuestionsDb questionsDb,
required QuestionService questionService, required QuestionService questionService,
ExternalDataFetcher? fetcher, ExternalDataFetcher? fetcher,
TradingPipeline? tradingPipeline,
this.maxQueuedQuestions = 3, this.maxQueuedQuestions = 3,
this.testMode = false, this.testMode = false,
}) : _questionsDb = questionsDb, }) : _questionsDb = questionsDb,
_questionService = questionService, _questionService = questionService,
_fetcher = fetcher ?? ExternalDataFetcher(); _fetcher = fetcher ?? ExternalDataFetcher(),
_tradingPipeline = tradingPipeline;
final QuestionsDb _questionsDb; final QuestionsDb _questionsDb;
final QuestionService _questionService; final QuestionService _questionService;
final ExternalDataFetcher _fetcher; final ExternalDataFetcher _fetcher;
final TradingPipeline? _tradingPipeline;
final int maxQueuedQuestions; final int maxQueuedQuestions;
final bool testMode; final bool testMode;
@ -164,6 +184,14 @@ class QuestionPipeline {
correctAnswer: correctAnswer, correctAnswer: correctAnswer,
context: context, context: context,
); );
case PipelineKeys.trading:
if (_tradingPipeline != null) {
await _tradingPipeline.handleAnswer(
firebaseUid: firebaseUid,
answeredQuestion: answeredQuestion,
userResponse: userResponse,
);
}
} }
} }

View File

@ -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<String> watchlist = <String>{
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();
}
}

View File

@ -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<String, dynamic>? raw;
final DateTime? createdAt;
}
/// Postgres access for [market_data_snapshots].
class MarketDataDb {
MarketDataDb(this._connection);
final Connection _connection;
Future<MarketDataSnapshot> insertSnapshot({
required String symbol,
required String metric,
required DateTime asOf,
String assetClass = 'us_equity',
String feed = 'iex',
num? price,
num? volume,
Map<String, dynamic>? 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: <String, dynamic>{
'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<MarketDataSnapshot?> 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: <String, dynamic>{
'symbol': symbol,
'metric': metric,
},
);
if (result.isEmpty) {
return null;
}
return _rowToSnapshot(result.first);
}
MarketDataSnapshot _rowToSnapshot(ResultRow row) {
final Object? rawValue = row[8];
Map<String, dynamic>? raw;
if (rawValue is Map<String, dynamic>) {
raw = rawValue;
} else if (rawValue != null) {
raw = jsonDecode(rawValue.toString()) as Map<String, dynamic>;
}
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());
}
}

View File

@ -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<MarketDataIngestResult> 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<int> _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: <String, dynamic>{
'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: <String, dynamic>{
'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: <String, dynamic>{
'c': prev.close,
'v': prev.volume,
't': prev.timestamp.toIso8601String(),
},
);
written++;
}
}
}
}
return written;
}
}

View File

@ -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<String, MarketDataSnapshot> 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);
}
}

View File

@ -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<String> submitted;
/// Orders blocked by guardrails. Same `client_order_id` removed from pending.
final List<TradeActuatorRejection> rejected;
/// Free-form error notes (Alpaca 5xx, DB issues, ). Pending row left in place
/// so the next worker tick can retry.
final List<String> 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-<client_order_id>'` 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<TradeActuatorResult> processPendingOrders(String firebaseUid) async {
final List<String> submitted = <String>[];
final List<TradeActuatorRejection> rejected = <TradeActuatorRejection>[];
final List<String> errors = <String>[];
final EffectiveTradingConfig? config =
await _tradingConfigDb.resolveEffectiveConfig(firebaseUid);
if (config == null) {
return TradeActuatorResult(
submitted: submitted,
rejected: rejected,
errors: errors,
);
}
final List<Map<String, dynamic>> pending =
await _tradingStateDb.listPendingOrders(firebaseUid);
for (final Map<String, dynamic> 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<String, dynamic> 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: <String, dynamic>{
'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<String, dynamic> 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: <String, dynamic>{
'mode': config.mode,
'alpaca': response.raw,
'submitted_at': now.toIso8601String(),
},
);
return _OrderProcessing.success();
}
Future<bool> _hasOtherUnansweredTradingQuestion(
String firebaseUid,
String currentQuestionId,
) async {
final List<Map<String, dynamic>> open =
await _questionsDb.listUnansweredQuestions(firebaseUid);
for (final Map<String, dynamic> 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_;
}

View File

@ -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<String, dynamic>? 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<int> 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: <String, dynamic>{
'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<num> 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: <String, dynamic>{
'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<TradeOrder?> 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: <String, dynamic>{'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<TradeOrder> 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<String, dynamic>? 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: <String, dynamic>{
'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<String, dynamic>? raw;
if (rawValue is Map<String, dynamic>) {
raw = rawValue;
} else if (rawValue != null) {
raw = jsonDecode(rawValue.toString()) as Map<String, dynamic>;
}
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());
}
}

View File

@ -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<DataInputConfig> dataInputs;
final List<TradingRuleConfig> rules;
final GuardrailsConfig guardrails;
final String? templateName;
factory EffectiveTradingConfig.fromJson(
Map<String, dynamic> 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<String, dynamic>? ?? <String, dynamic>{},
),
templateName: templateName,
);
}
static List<DataInputConfig> _parseDataInputs(Object? raw) {
if (raw is! List) {
return <DataInputConfig>[];
}
return raw
.whereType<Map>()
.map((Map<dynamic, dynamic> m) =>
DataInputConfig.fromJson(Map<String, dynamic>.from(m)))
.toList();
}
static List<TradingRuleConfig> _parseRules(Object? raw) {
if (raw is! List) {
return <TradingRuleConfig>[];
}
return raw
.whereType<Map>()
.map((Map<dynamic, dynamic> m) =>
TradingRuleConfig.fromJson(Map<String, dynamic>.from(m)))
.toList();
}
/// Deep-merge [override] onto [base]. Lists with `id` fields merge by id.
static Map<String, dynamic> mergeJson(
Map<String, dynamic> base,
Map<String, dynamic> override,
) {
final Map<String, dynamic> result = Map<String, dynamic>.from(base);
for (final MapEntry<String, dynamic> 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 : <Object?>[],
overrideValue is List ? overrideValue : <Object?>[],
);
} else if (baseValue is Map<String, dynamic> &&
overrideValue is Map<String, dynamic>) {
result[entry.key] = mergeJson(baseValue, overrideValue);
} else {
result[entry.key] = overrideValue;
}
}
return result;
}
static List<Object?> _mergeListById(List<Object?> base, List<Object?> override) {
final Map<String, Map<String, dynamic>> byId = <String, Map<String, dynamic>>{};
for (final Object? item in base) {
if (item is Map) {
final Map<String, dynamic> map = Map<String, dynamic>.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<String, dynamic> patch = Map<String, dynamic>.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<String> symbols;
final String feed;
final int pollIntervalSeconds;
final List<String> metrics;
factory DataInputConfig.fromJson(Map<String, dynamic> 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<dynamic>? ?? <dynamic>[])
.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<dynamic>? ?? <dynamic>[])
.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<String, dynamic>? onAnswerMatch;
factory TradingRuleConfig.fromJson(Map<String, dynamic> 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<String, dynamic>.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<String> symbolsBlocklist;
factory GuardrailsConfig.fromJson(Map<String, dynamic> 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<dynamic>? ??
<dynamic>[])
.map((dynamic s) => s as String)
.toList(),
);
}
}

View File

@ -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<EffectiveTradingConfig?> 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: <String, dynamic>{'uid': firebaseUid},
);
if (result.isEmpty) {
return null;
}
final ResultRow row = result.first;
final String? templateName = row[0] as String?;
final Map<String, dynamic> userConfig = _readJsonMap(row[1]);
final bool userEnabled = row[2]! as bool;
Map<String, dynamic> merged = userConfig;
if (templateName != null && templateName.isNotEmpty) {
final Map<String, dynamic>? templateConfig =
await _loadTemplateConfig(templateName);
if (templateConfig != null) {
merged = EffectiveTradingConfig.mergeJson(templateConfig, userConfig);
}
}
return EffectiveTradingConfig.fromJson(
merged,
templateName: templateName,
userEnabled: userEnabled,
);
}
Future<void> upsertUserConfig({
required String firebaseUid,
String? templateName,
Map<String, dynamic> config = const <String, dynamic>{},
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: <String, dynamic>{
'uid': firebaseUid,
'template_name': templateName,
'config': jsonEncode(config),
'enabled': enabled,
},
);
}
Future<Map<String, dynamic>?> _loadTemplateConfig(String name) async {
final Result result = await _connection.execute(
Sql.named(
'SELECT config FROM trading_config_templates WHERE name = @name',
),
parameters: <String, dynamic>{'name': name},
);
if (result.isEmpty) {
return null;
}
return _readJsonMap(result.first[0]);
}
Map<String, dynamic> _readJsonMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return Map<String, dynamic>.from(value);
}
if (value == null) {
return <String, dynamic>{};
}
return jsonDecode(value.toString()) as Map<String, dynamic>;
}
}

View File

@ -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<String, dynamic> toJson() => <String, dynamic>{
'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<SeededSnapshot> 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<String, dynamic> toJson() => <String, dynamic>{
'snapshots': snapshots.map((SeededSnapshot s) => s.toJson()).toList(),
'evaluation': evaluation == null
? null
: <String, dynamic>{
'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<ForceFireResult> forceFireDip(String firebaseUid) async {
final EffectiveTradingConfig? config =
await _tradingConfigDb.resolveEffectiveConfig(firebaseUid);
if (config == null) {
return ForceFireResult(
snapshots: <SeededSnapshot>[],
evaluation: null,
skipReason: 'no_config',
);
}
if (!config.enabled) {
return ForceFireResult(
snapshots: <SeededSnapshot>[],
evaluation: null,
skipReason: 'disabled',
);
}
final List<SeededSnapshot> seeded = <SeededSnapshot>[];
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<void> _cancelOpenTradingQuestions(String firebaseUid) async {
final List<Map<String, dynamic>> open =
await _questionsDb.listUnansweredQuestions(firebaseUid);
for (final Map<String, dynamic> q in open) {
if (q['pipelineKey'] != 'trading') continue;
await _questionsDb.submitAnswer(
questionId: q['id']! as String,
assignedUserId: firebaseUid,
userResponse: 0,
);
}
}
}

View File

@ -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<void> runMaintenanceCycle() async {
final List<String> 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<TradingTickResult> 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,
);
}
}

View File

@ -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<String> rulesFired;
final List<String> 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<TradingEvaluationResult> evaluate(String firebaseUid) async {
final List<String> fired = <String>[];
final List<String> skipped = <String>[];
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<String, MarketDataSnapshot> 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<String, dynamic> 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: <String, dynamic>{
'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<void> handleAnswer({
required String firebaseUid,
required Map<String, dynamic> answeredQuestion,
required num userResponse,
}) async {
final String? pipelineStep =
answeredQuestion['pipelineStep'] as String?;
if (pipelineStep == null) {
return;
}
final List<String> 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<String, dynamic>? priorState =
await _tradingStateDb.getRuleState(firebaseUid, ruleId);
final Map<String, dynamic> baseState = <String, dynamic>{
...?priorState,
};
if (outcome == BranchOutcome.match) {
final Map<String, dynamic>? match =
rule.onAnswerMatch is Map<String, dynamic>
? 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: <String, dynamic>{
'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<Map<String, MarketDataSnapshot>> _loadSnapshotsForRule(
TradingRuleConfig rule,
) async {
final List<String> metrics = <String>{'last_trade', rule.refMetric}.toList();
final Map<String, MarketDataSnapshot> result =
<String, MarketDataSnapshot>{};
for (final String metric in metrics) {
final MarketDataSnapshot? snap =
await _marketDataDb.latestForSymbol(rule.symbol, metric);
if (snap != null) {
result[metric] = snap;
}
}
return result;
}
Future<bool> _ruleHasOpenQuestion(
String firebaseUid,
String ruleId,
) async {
final List<Map<String, dynamic>> open =
await _questionsDb.listUnansweredQuestions(firebaseUid);
return open.any((Map<String, dynamic> q) =>
q['pipelineKey'] == PipelineKeys.trading &&
(q['pipelineStep'] as String? ?? '').startsWith('$ruleId:'));
}
/// Exposed for the actuator (Step 9) and tests.
Guardrails get guardrails => _guardrails;
}

View File

@ -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<void> 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: <String, dynamic>{'uid': firebaseUid},
);
}
Future<Map<String, dynamic>> 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: <String, dynamic>{'uid': firebaseUid},
);
if (result.isEmpty) {
return <String, dynamic>{};
}
return _readJsonMap(result.first[0]);
}
/// ISO-8601 timestamp of last successful fetch for [dataInputId], or null.
Future<DateTime?> getInputLastFetch(
String firebaseUid,
String dataInputId,
) async {
final Map<String, dynamic> context = await getContext(firebaseUid);
final Map<String, dynamic> ingest = Map<String, dynamic>.from(
context[ingestContextKey] as Map? ?? <String, dynamic>{},
);
final String? raw = ingest[dataInputId] as String?;
if (raw == null || raw.isEmpty) {
return null;
}
return DateTime.parse(raw).toUtc();
}
Future<void> recordInputFetch(
String firebaseUid,
String dataInputId,
DateTime fetchedAt,
) async {
await ensureExists(firebaseUid);
final Map<String, dynamic> context = await getContext(firebaseUid);
final Map<String, dynamic> ingest = Map<String, dynamic>.from(
context[ingestContextKey] as Map? ?? <String, dynamic>{},
);
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: <String, dynamic>{
'uid': firebaseUid,
'context': jsonEncode(context),
'last_ingest_at': fetchedAt.toUtc(),
},
);
}
Future<Map<String, dynamic>?> getRuleState(
String firebaseUid,
String ruleId,
) async {
final Map<String, dynamic> context = await getContext(firebaseUid);
final Map<String, dynamic> rules = Map<String, dynamic>.from(
context[rulesContextKey] as Map? ?? <String, dynamic>{},
);
final Map? raw = rules[ruleId] as Map?;
if (raw == null) {
return null;
}
return Map<String, dynamic>.from(raw);
}
Future<DateTime?> getRuleLastFiredAt(
String firebaseUid,
String ruleId,
) async {
final Map<String, dynamic>? 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<void> setRuleState({
required String firebaseUid,
required String ruleId,
required Map<String, dynamic> state,
}) async {
await ensureExists(firebaseUid);
final Map<String, dynamic> context = await getContext(firebaseUid);
final Map<String, dynamic> rules = Map<String, dynamic>.from(
context[rulesContextKey] as Map? ?? <String, dynamic>{},
);
rules[ruleId] = state;
context[rulesContextKey] = rules;
await _writeContext(firebaseUid, context, touchEvalAt: true);
}
Future<List<Map<String, dynamic>>> listPendingOrders(
String firebaseUid,
) async {
final Map<String, dynamic> context = await getContext(firebaseUid);
final List<dynamic> raw =
context[pendingOrdersContextKey] as List<dynamic>? ?? <dynamic>[];
return raw
.whereType<Map>()
.map((Map<dynamic, dynamic> m) => Map<String, dynamic>.from(m))
.toList();
}
Future<void> addPendingOrder({
required String firebaseUid,
required Map<String, dynamic> order,
}) async {
await ensureExists(firebaseUid);
final Map<String, dynamic> context = await getContext(firebaseUid);
final List<dynamic> existing =
context[pendingOrdersContextKey] as List<dynamic>? ?? <dynamic>[];
final List<Map<String, dynamic>> orders = <Map<String, dynamic>>[
...existing.whereType<Map>().map(
(Map<dynamic, dynamic> m) => Map<String, dynamic>.from(m),
),
order,
];
context[pendingOrdersContextKey] = orders;
await _writeContext(firebaseUid, context, touchEvalAt: true);
}
Future<void> removePendingOrder({
required String firebaseUid,
required String clientOrderId,
}) async {
await ensureExists(firebaseUid);
final Map<String, dynamic> context = await getContext(firebaseUid);
final List<dynamic> existing =
context[pendingOrdersContextKey] as List<dynamic>? ?? <dynamic>[];
final List<Map<String, dynamic>> kept = existing
.whereType<Map>()
.map((Map<dynamic, dynamic> m) => Map<String, dynamic>.from(m))
.where((Map<String, dynamic> m) =>
m['client_order_id'] != clientOrderId)
.toList();
context[pendingOrdersContextKey] = kept;
await _writeContext(firebaseUid, context, touchEvalAt: false);
}
Future<void> recordSkip({
required String firebaseUid,
required String ruleId,
required String questionId,
required DateTime at,
}) async {
await ensureExists(firebaseUid);
final Map<String, dynamic> context = await getContext(firebaseUid);
final List<dynamic> existing =
context[skippedContextKey] as List<dynamic>? ?? <dynamic>[];
final List<Map<String, dynamic>> skipped = <Map<String, dynamic>>[
...existing.whereType<Map>().map(
(Map<dynamic, dynamic> m) => Map<String, dynamic>.from(m),
),
<String, dynamic>{
'rule_id': ruleId,
'question_id': questionId,
'at': at.toUtc().toIso8601String(),
},
];
context[skippedContextKey] = skipped;
await _writeContext(firebaseUid, context, touchEvalAt: false);
}
Future<void> _writeContext(
String firebaseUid,
Map<String, dynamic> 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: <String, dynamic>{
'uid': firebaseUid,
'context': jsonEncode(context),
},
);
}
Map<String, dynamic> _readJsonMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return Map<String, dynamic>.from(value);
}
if (value == null) {
return <String, dynamic>{};
}
return jsonDecode(value.toString()) as Map<String, dynamic>;
}
}

View File

@ -2,16 +2,22 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import '../pipeline/question_pipeline.dart'; 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 { class QuestionBackgroundWorker {
QuestionBackgroundWorker({ QuestionBackgroundWorker({
required QuestionPipeline pipeline, required QuestionPipeline pipeline,
required Duration interval, required Duration interval,
TradingOrchestrator? tradingOrchestrator,
}) : _pipeline = pipeline, }) : _pipeline = pipeline,
_interval = interval; _interval = interval,
_tradingOrchestrator = tradingOrchestrator;
final QuestionPipeline _pipeline; final QuestionPipeline _pipeline;
final TradingOrchestrator? _tradingOrchestrator;
final Duration _interval; final Duration _interval;
Timer? _timer; Timer? _timer;
bool _running = false; bool _running = false;
@ -21,7 +27,8 @@ class QuestionBackgroundWorker {
return; return;
} }
stdout.writeln( 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()); _timer = Timer.periodic(_interval, (_) => _tick());
unawaited(_tick()); unawaited(_tick());
@ -42,8 +49,14 @@ class QuestionBackgroundWorker {
await _pipeline.runMaintenanceCycle(); await _pipeline.runMaintenanceCycle();
} catch (e, st) { } catch (e, st) {
stderr.writeln('Question background worker tick failed: $e\n$st'); stderr.writeln('Question background worker tick failed: $e\n$st');
} finally { }
if (_tradingOrchestrator != null) {
try {
await _tradingOrchestrator.runMaintenanceCycle();
} catch (e, st) {
stderr.writeln('Trading orchestrator tick failed: $e\n$st');
}
}
_running = false; _running = false;
} }
}
} }

View File

@ -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();

View File

@ -1,6 +1,22 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: 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: args:
dependency: transitive dependency: transitive
description: description:
@ -17,6 +33,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" 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: buffer:
dependency: transitive dependency: transitive
description: description:
@ -33,6 +57,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: collection:
dependency: transitive dependency: transitive
description: description:
@ -41,6 +73,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" 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: crypto:
dependency: transitive dependency: transitive
description: description:
@ -57,6 +105,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.0" version: "4.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -65,6 +121,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" 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: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -81,6 +153,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" 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: http_parser:
dependency: transitive dependency: transitive
description: description:
@ -89,6 +169,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" 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: meta:
dependency: transitive dependency: transitive
description: description:
@ -97,6 +201,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.2" 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: path:
dependency: transitive dependency: transitive
description: description:
@ -121,6 +249,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.5.11" 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: shelf:
dependency: "direct main" dependency: "direct main"
description: description:
@ -137,6 +273,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.5" 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: shelf_router:
dependency: "direct main" dependency: "direct main"
description: description:
@ -145,6 +289,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.4" 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: shelf_web_socket:
dependency: "direct main" dependency: "direct main"
description: description:
@ -153,6 +305,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" 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: source_span:
dependency: transitive dependency: transitive
description: description:
@ -193,6 +361,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.2" 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: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -209,6 +401,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.3" 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: web:
dependency: transitive dependency: transitive
description: description:
@ -233,5 +441,21 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" 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: sdks:
dart: ">=3.12.0 <4.0.0" dart: ">=3.12.0 <4.0.0"

View File

@ -15,3 +15,6 @@ dependencies:
http: ^1.6.0 http: ^1.6.0
uuid: ^4.5.3 uuid: ^4.5.3
web_socket_channel: ^3.0.0 web_socket_channel: ^3.0.0
dev_dependencies:
test: ^1.25.0

View File

@ -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(<String, String>{});
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(<String, String>{
'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(<String, String>{
'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(<String, String>{});
expect(env.hasCredentials, isFalse);
expect(env.requireCredentials, throwsStateError);
});
test('requireCredentials passes when keys present', () {
final AlpacaEnv env = AlpacaEnv.fromMap(<String, String>{
'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(<String, String>{
'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}');
}
});
});
}

View File

@ -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(<String, String>{
'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<String, dynamic> 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<String, String> 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<String, dynamic> 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(<String>['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');
});
}

View File

@ -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<String> alpacaKeys = <String>[
'ALPACA_API_KEY_ID',
'ALPACA_API_SECRET_KEY',
'ALPACA_TRADING_BASE_URL',
'ALPACA_DATA_BASE_URL',
'ALPACA_DATA_FEED',
'ALPACA_ALLOW_LIVE',
];
final Map<String, String> envMap = <String, String>{
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));
});
}

View File

@ -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<String, dynamic> 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<String, dynamic> 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(),
<String, dynamic>{
'symbol': 'SPY',
'side': 'buy',
'type': 'market',
'time_in_force': 'day',
'client_order_id': 'uid-dip_confirm-qid',
'notional': 10,
},
);
});
}

View File

@ -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(<String, String>{
'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<String, dynamic> sent =
jsonDecode(http.capturedBodies.first) as Map<String, dynamic>;
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<AlpacaTradingDuplicateClientOrderIdException>()),
);
});
test('refuses live URL when ALPACA_ALLOW_LIVE=false', () async {
final AlpacaEnv liveEnv = AlpacaEnv.fromMap(<String, String>{
'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(<String, dynamic>{'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<String, dynamic> body, {int statusCode = 200}) {
return http.Response(
jsonEncode(body),
statusCode,
headers: <String, String>{'content-type': 'application/json'},
);
}
}

View File

@ -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<String> alpacaKeys = <String>[
'ALPACA_API_KEY_ID',
'ALPACA_API_SECRET_KEY',
'ALPACA_TRADING_BASE_URL',
'ALPACA_DATA_BASE_URL',
'ALPACA_DATA_FEED',
'ALPACA_ALLOW_LIVE',
];
final Map<String, String> envMap = <String, String>{
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)));
}

View File

@ -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
}

View File

@ -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"
}
}

View File

@ -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"
}

View File

@ -0,0 +1,4 @@
{
"code": 42210000,
"message": "client_order_id must be unique"
}

View File

@ -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"
}
]

View File

@ -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": []
}
}

View File

@ -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<Map<String, dynamic>> loadJson(String name) async {
final String path = '$_basePath${Platform.pathSeparator}$name';
final String contents = await File(path).readAsString();
return jsonDecode(contents) as Map<String, dynamic>;
}
Future<String> loadString(String name) async {
final String path = '$_basePath${Platform.pathSeparator}$name';
return File(path).readAsString();
}
}

View File

@ -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<String, _MatchedResponse>? responses})
: _responses = responses ?? <String, _MatchedResponse>{};
final Map<String, _MatchedResponse> _responses;
final List<http.BaseRequest> requests = <http.BaseRequest>[];
/// Captured request bodies indexed by request order in [requests].
final List<String> capturedBodies = <String>[];
void whenGet(String pathSuffix, http.Response response) {
_responses[pathSuffix] = _MatchedResponse(method: 'GET', response: response);
}
void whenGetJson(String pathSuffix, Map<String, dynamic> body,
{int statusCode = 200}) {
whenGet(
pathSuffix,
http.Response(jsonEncode(body), statusCode, headers: <String, String>{
'content-type': 'application/json',
}),
);
}
void whenPost(String pathSuffix, http.Response response) {
_responses['POST:$pathSuffix'] =
_MatchedResponse(method: 'POST', response: response);
}
void whenPostJson(String pathSuffix, Map<String, dynamic> body,
{int statusCode = 200}) {
whenPost(
pathSuffix,
http.Response(jsonEncode(body), statusCode, headers: <String, String>{
'content-type': 'application/json',
}),
);
}
@override
Future<http.StreamedResponse> 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<String, _MatchedResponse> 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<List<int>>.value(utf8.encode(match.response.body)),
match.response.statusCode,
headers: match.response.headers,
request: request,
);
}
}
return http.StreamedResponse(
Stream<List<int>>.value(utf8.encode('{}')),
404,
request: request,
);
}
Future<String> _readBody(http.BaseRequest request) async {
if (request is http.Request) {
return request.body;
}
final List<int> 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;
}

View File

@ -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 001004.
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<TestDb?> 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<void> _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: <String, dynamic>{'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<void> 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<void> 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: <String, dynamic>{
'uid': firebaseUid,
'email': '$firebaseUid@test.local',
},
);
}
Future<void> close() => db.close();
}

View File

@ -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';
}
}

View File

@ -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),
);
});
}

View File

@ -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: <String, dynamic>{
'data_inputs': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'primary_watchlist',
'symbols': <String>['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(<String, String>{
'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: <String, dynamic>{
'data_inputs': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'primary_watchlist',
'source': 'alpaca',
'asset_class': 'us_equity',
'symbols': <String>['SPY'],
'feed': 'iex',
'poll_interval_seconds': 60,
'metrics': <String>['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(<String, String>{
'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);
});
});
}

View File

@ -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 001004 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: <String, dynamic>{'name': 'default_paper_watchlist'},
);
expect(template, isNotEmpty);
});
}

View File

@ -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<void> _seedConfig(String uid) async {
await testDb!.seedUser(uid);
await testDb!.tradingConfigDb.upsertUserConfig(
firebaseUid: uid,
templateName: 'default_paper_watchlist',
enabled: true,
);
}
Future<void> _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: <String, dynamic>{
'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<String, dynamic> 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, <String>[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<Map<String, dynamic>> 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<String, dynamic> 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, <String>[clientOrderId]);
expect(result.rejected, isEmpty);
// Still exactly one trade_orders row.
expect(
await testDb!.tradeOrdersDb.findByClientOrderId(clientOrderId),
isNotNull,
);
final List<Map<String, dynamic>> 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<String, dynamic> 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<Map<String, dynamic>> 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<String, dynamic> 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<String, dynamic> 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: <String, String>{'content-type': 'application/json'},
),
);
final AlpacaEnv env = AlpacaEnv.fromMap(<String, String>{
'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, <String>[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, <String>[clientOrderId]);
expect(mock.requests, hasLength(1));
});
});
}

View File

@ -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: <String, dynamic>{'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: <String, dynamic>{
'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: <String, dynamic>{
'id': uuid.v4(),
'uid': uid,
'client_order_id': clientOrderId,
},
),
throwsA(isA<ServerException>()),
);
});
}

View File

@ -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<String, dynamic> partialOverride = <String, dynamic>{
'rules': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'dip_confirm',
'threshold_pct': -2.5,
},
],
'data_inputs': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'primary_watchlist',
'symbols': <String>['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, <String>['SPY']);
expect(effective.dataInputs.single.metrics,
containsAll(<String>['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);
});
}

View File

@ -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, <String>['dip_confirm']);
expect(result.evaluation!.questionsCreated, 1);
expect(result.snapshots, hasLength(2));
expect(
result.snapshots.map((SeededSnapshot s) => s.metric).toSet(),
<String>{'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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> afterSecond =
await testDb!.questionsDb.listUnansweredQuestions(uid);
expect(afterSecond.length, lessThanOrEqualTo(1));
expect(
second.evaluation!.questionsCreated +
second.evaluation!.rulesSkipped.length,
greaterThan(0),
);
});
}

View File

@ -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-<client_order_id>'` 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, <String>['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: <String, dynamic>{'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<String, dynamic>? 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<Map<String, dynamic>> 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: <String, dynamic>{'uid': uid},
);
expect(num.parse(remaining.first[0]!.toString()).toInt(), 0);
});
}

View File

@ -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<void> _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<TradingPipeline> _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, <String>['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: <String, dynamic>{'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<String, dynamic>? ruleState =
await testDb!.userTradingStateDb.getRuleState(uid, 'dip_confirm');
expect(ruleState, isNotNull);
expect(ruleState!['phase'], 'await_confirm');
expect(ruleState['question_id'], isA<String>());
});
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<Map<String, dynamic>> 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<Map<String, dynamic>> open =
await testDb!.questionsDb.listUnansweredQuestions(uid);
final Map<String, dynamic>? 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<Map<String, dynamic>> 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<String, dynamic>? 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<Map<String, dynamic>> open =
await testDb!.questionsDb.listUnansweredQuestions(uid);
final Map<String, dynamic>? 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<Map<String, dynamic>> pending =
await testDb!.userTradingStateDb.listPendingOrders(uid);
expect(pending, isEmpty);
final Map<String, dynamic>? 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<Map<String, dynamic>> open =
await testDb!.questionsDb.listUnansweredQuestions(uid);
final Map<String, dynamic>? 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<Map<String, dynamic>> pending =
await testDb!.userTradingStateDb.listPendingOrders(uid);
expect(pending, hasLength(1));
});
});
}

View File

@ -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: <String, dynamic>{'as_of': asOf},
);
await connection.execute(
Sql.named(
'''
INSERT INTO user_trading_config (firebase_uid, enabled, config)
VALUES (@uid, true, '{}'::jsonb)
''',
),
parameters: <String, dynamic>{'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: <String, dynamic>{
'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: <String, dynamic>{'uid': 'missing-user-uid'},
),
throwsA(isA<ServerException>()),
);
});
}

View File

@ -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<String, dynamic> trade =
await fixtures.loadJson('alpaca_latest_trade.json');
expect(trade['symbol'], 'SPY');
expect(trade['trade'], isA<Map<String, dynamic>>());
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));
});
}

View File

@ -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<String> symbols = const <String>['SPY'],
int maxOrdersPerDay = 3,
num maxNotionalUsdPer4h = 100,
bool requireQuestion = true,
List<String> blocklist = const <String>[],
}) {
return EffectiveTradingConfig.fromJson(<String, dynamic>{
'version': 1,
'enabled': enabled,
'mode': mode,
'data_inputs': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'primary_watchlist',
'symbols': symbols,
'metrics': <String>['last_trade'],
},
],
'rules': <Map<String, dynamic>>[],
'guardrails': <String, dynamic>{
'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: <String>['SPY']),
);
expect(decision.reason, GuardrailRejectionReason.blocklistedSymbol);
});
test('rejects symbol not in watchlist', () {
final GuardrailDecision decision = _check(
Guardrails(),
config: _config(symbols: <String>['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);
});
});
}

View File

@ -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(<String, dynamic>{
'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: <String, MarketDataSnapshot>{
'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: <String, MarketDataSnapshot>{
'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: <String, MarketDataSnapshot>{
'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: <String, MarketDataSnapshot>{
'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: <String, MarketDataSnapshot>{
'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: <String, MarketDataSnapshot>{
'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: <String, MarketDataSnapshot>{
'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(
<String, dynamic>{
'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: <String, MarketDataSnapshot>{},
now: now,
);
expect(result.fired, isFalse);
expect(result.skipReason, RuleSkipReason.unknownType);
});
});
}

View File

@ -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<String, dynamic> base = <String, dynamic>{
'version': 1,
'enabled': true,
'rules': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'dip_confirm',
'type': 'price_below_pct_of_ref',
'symbol': 'SPY',
'threshold_pct': -1.5,
'question_template': 'template text',
},
],
};
final Map<String, dynamic> override = <String, dynamic>{
'rules': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'dip_confirm',
'threshold_pct': -2.0,
},
],
};
final Map<String, dynamic> merged =
EffectiveTradingConfig.mergeJson(base, override);
final List<dynamic> rules = merged['rules'] as List<dynamic>;
final Map<String, dynamic> rule =
Map<String, dynamic>.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<String, dynamic> base = <String, dynamic>{
'data_inputs': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'primary_watchlist',
'symbols': <String>['AAPL', 'MSFT', 'SPY'],
'metrics': <String>['last_trade'],
},
],
};
final Map<String, dynamic> override = <String, dynamic>{
'data_inputs': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'primary_watchlist',
'symbols': <String>['SPY'],
},
],
};
final Map<String, dynamic> merged =
EffectiveTradingConfig.mergeJson(base, override);
final List<dynamic> inputs = merged['data_inputs'] as List<dynamic>;
final Map<String, dynamic> input =
Map<String, dynamic>.from(inputs.single as Map);
expect(input['symbols'], <String>['SPY']);
expect(input['metrics'], <String>['last_trade']);
});
});
}

View File

@ -78,11 +78,28 @@ log "Starting API on http://localhost:${API_PORT} ..."
) & ) &
API_PID=$! 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} ..." log "Starting web app on http://localhost:${WEB_PORT} ..."
( (
cd "$ROOT" cd "$ROOT"
flutter run -d web-server \ dart run scripts/web_static_server.dart build/web "${WEB_PORT}" \
--dart-define=API_BASE_URL="http://localhost:${API_PORT}" \
2>&1 | prefix_lines web 2>&1 | prefix_lines web
) & ) &
WEB_PID=$! WEB_PID=$!