latest step after first alpaca
This commit is contained in:
parent
33bde0dc82
commit
8278f93a34
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
238
TODO.md
@ -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)
|
|
||||||
@ -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
453
TRADING_TDD_PLAN.md
Normal 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 1–11 — Phase 1 MVP | 🔄 In progress | Day 7: Steps 9–10 done; Gate A scripted test green |
|
||||||
|
| Gate A — Local loop (test mode) | ✅ Done | 2026-05-26 — `trading_orchestrator_gate_a_test.dart` |
|
||||||
|
| Gate B — Paper Alpaca E2E | ⬜ Not started | |
|
||||||
|
| Steps 12–15 — Phase 2 | ⬜ Not started | |
|
||||||
|
| Steps 16–19 — Phase 3 | ⬜ Deferred | |
|
||||||
|
|
||||||
|
Legend: ⬜ Not started · 🔄 In progress · ✅ Done · ⏸ Blocked
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress log
|
||||||
|
|
||||||
|
<!-- 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 | 7–8 | `dart test` green (47 tests); TradingPipeline evaluate/handleAnswer + QuestionPipeline branch |
|
||||||
|
| 2026-05-25 | 5–6 | `dart test` green (39 tests); rule engine + guardrails (pure logic) |
|
||||||
|
| 2026-05-23 | 3–4 | `dart test` green (21 tests); Alpaca MD client, ingest, poll interval; live Alpaca OK |
|
||||||
|
| 2026-05-23 | 1.3–1.4, 2 | `dart test` green (17 tests); config merge, trade orders, Alpaca env/models |
|
||||||
|
| 2026-05-23 | 0, 1.1–1.2 | `dart test` green (4 tests); migration 004 + MarketDataDb |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
| Principle | How it applies |
|
||||||
|
|-----------|----------------|
|
||||||
|
| **One behavior per cycle** | One failing test → minimal code → gate check |
|
||||||
|
| **No Alpaca in unit tests** | Mock HTTP clients; inject fixture JSON |
|
||||||
|
| **DB tests are integration** | Real Postgres test DB (`cyberhybridhub_test`) |
|
||||||
|
| **E2E gates are manual/scripted** | Flutter swipe + Alpaca paper dashboard at defined checkpoints |
|
||||||
|
| **Feature flags off until wired** | `TRADING_ENABLED=false` until Step 10 complete |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 0 — Test harness
|
||||||
|
|
||||||
|
### 0.1 Server test setup
|
||||||
|
|
||||||
|
- [x] Add `dev_dependencies: test: ^1.25.0` to `server/pubspec.yaml`
|
||||||
|
- [x] Create `server/test/` smoke test
|
||||||
|
- [x] Create `server/test/helpers/fixture_loader.dart`
|
||||||
|
- [x] Create `server/test/helpers/mock_http_client.dart`
|
||||||
|
- [x] Create `server/test/helpers/test_env.dart`
|
||||||
|
- [x] Create fixture files:
|
||||||
|
- [x] `server/test/fixtures/alpaca_latest_trade.json`
|
||||||
|
- [x] `server/test/fixtures/alpaca_daily_bars.json`
|
||||||
|
- [x] `server/test/fixtures/trading_config_default.json`
|
||||||
|
- [x] `server/test/fixtures/market_snapshots_spy_dip.json`
|
||||||
|
|
||||||
|
**Red:** Smoke test fails until layout exists.
|
||||||
|
|
||||||
|
**Green:** Minimal test directory and helpers.
|
||||||
|
|
||||||
|
**Confirm:** `cd server && dart test` passes with 1 smoke test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 0.2 Integration test DB
|
||||||
|
|
||||||
|
- [x] Test helper applies migrations 001–004 on `cyberhybridhub_test`
|
||||||
|
- [x] `server/test/integration/migration_test.dart` — migration applies cleanly
|
||||||
|
|
||||||
|
**Red:** Migration test fails (no `004_trading.sql` yet).
|
||||||
|
|
||||||
|
**Green:** Migration runner + empty-schema apply.
|
||||||
|
|
||||||
|
**Confirm:** `dart test test/integration/migration_test.dart` passes locally and in CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 0.3 Flutter (minimal)
|
||||||
|
|
||||||
|
- [ ] No new Flutter tests until Step 9 — existing `SwipeQuestionTile` + SignalR contract unchanged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Foundation (MVP)
|
||||||
|
|
||||||
|
Maps to TRADING_DEVELOPMENT_PLAN §5, §6, §7, §8, §9, §10, §14 Phase 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 1 — Schema & DB accessors
|
||||||
|
|
||||||
|
**Refs:** §5 Postgres schema · `market_data_db.dart` · `trading_config_db.dart` · `trade_orders_db.dart`
|
||||||
|
|
||||||
|
#### 1.1 Migration
|
||||||
|
|
||||||
|
- [x] **Red:** Integration test — INSERT into `market_data_snapshots`, `user_trading_config`, `trade_orders`; FK to `users` enforced
|
||||||
|
- [x] **Green:** `server/migrations/004_trading.sql`
|
||||||
|
- [x] **Green:** Seed `trading_config_templates` row `default_paper_watchlist`
|
||||||
|
- [x] **Confirm:** `\d market_data_snapshots` in psql; seed template queryable
|
||||||
|
|
||||||
|
#### 1.2 MarketDataDb
|
||||||
|
|
||||||
|
- [x] **Red:** `insertSnapshot()` then `latestForSymbol(symbol, metric)` returns newest row by `as_of`
|
||||||
|
- [x] **Green:** `server/lib/trading/market_data_db.dart`
|
||||||
|
- [x] **Confirm:** Two SPY/`last_trade` inserts → latest returns newer price
|
||||||
|
|
||||||
|
#### 1.3 TradingConfigDb
|
||||||
|
|
||||||
|
- [x] **Red:** `resolveEffectiveConfig(uid)` merges template + user JSONB; user override wins
|
||||||
|
- [x] **Green:** `server/lib/trading/trading_config_db.dart`
|
||||||
|
- [x] **Green:** `server/lib/trading/trading_config.dart` parser
|
||||||
|
- [x] **Confirm:** Partial user override yields merged `enabled`, `data_inputs`, `rules`
|
||||||
|
|
||||||
|
#### 1.4 TradeOrdersDb
|
||||||
|
|
||||||
|
- [x] **Red:** Duplicate `client_order_id` raises unique violation; `findByClientOrderId` returns existing row
|
||||||
|
- [x] **Green:** `server/lib/trading/trade_orders_db.dart`
|
||||||
|
- [x] **Confirm:** Idempotency check prevents double-insert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2 — Alpaca env & models
|
||||||
|
|
||||||
|
**Refs:** §3 · §11 · `alpaca_env.dart` · `alpaca_models.dart`
|
||||||
|
|
||||||
|
- [x] **Red:** `AlpacaEnv.fromMap()` reads keys; `assertPaperOnly()` throws when live URL + `ALPACA_ALLOW_LIVE=false`
|
||||||
|
- [x] **Green:** `server/lib/alpaca/alpaca_env.dart`
|
||||||
|
- [x] **Green:** `server/lib/alpaca/alpaca_models.dart` (Trade, Bar, OrderRequest)
|
||||||
|
- [x] **Green:** Extend `server/lib/env.dart` with trading flags (after env tests pass)
|
||||||
|
- [x] **Confirm:** Unit tests — paper default, live blocked, missing keys
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — Alpaca Market Data client (REST)
|
||||||
|
|
||||||
|
**Refs:** §9.1 · `alpaca_market_data_client.dart`
|
||||||
|
|
||||||
|
- [x] **Red:** Mock HTTP — `getLatestTrade('SPY')` parses fixture; sends `APCA-API-KEY-ID` headers
|
||||||
|
- [x] **Green:** `server/lib/alpaca/alpaca_market_data_client.dart` with injectable `http.Client`
|
||||||
|
- [x] **Confirm:** Optional tagged test `@Tags(['alpaca'])` — one real call (skipped in CI)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4 — Snapshot normalization & ingest
|
||||||
|
|
||||||
|
**Refs:** §9.3 · `market_data_ingest.dart`
|
||||||
|
|
||||||
|
- [x] **Red:** Config `data_inputs` with `[last_trade, daily_bar, prev_close]` → 3 snapshot rows with correct `metric`, `price`, `as_of`
|
||||||
|
- [x] **Green:** `server/lib/trading/market_data_ingest.dart` — `runIfDue()` writes via `MarketDataDb`
|
||||||
|
- [x] **Confirm:** Integration — ingest for seeded user; `SELECT * FROM market_data_snapshots WHERE symbol='SPY'`
|
||||||
|
|
||||||
|
#### 4.1 Poll interval
|
||||||
|
|
||||||
|
- [x] **Red:** Second call within `poll_interval_seconds` does not fetch (uses `user_trading_state.context`)
|
||||||
|
- [x] **Green:** Update `user_trading_state` on ingest
|
||||||
|
- [x] **Confirm:** Two rapid `runIfDue` calls → mock HTTP call count = 1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5 — Rule engine (pure logic)
|
||||||
|
|
||||||
|
**Refs:** §4.2 · §8 · `rule_engine.dart`
|
||||||
|
|
||||||
|
- [x] **Red:** `price_below_pct_of_ref` — SPY `last_trade=492`, `prev_close=500`, threshold `-1.5` → fires with `pct ≈ -1.6`
|
||||||
|
- [x] **Green:** `server/lib/trading/rule_engine.dart` — `RuleEngine.evaluate(rule, snapshots) → RuleEvaluation?`
|
||||||
|
- [x] **Confirm:** Table-driven unit tests:
|
||||||
|
|
||||||
|
| Case | Expected |
|
||||||
|
|------|----------|
|
||||||
|
| Above threshold (-0.5%) | No fire |
|
||||||
|
| Missing metric | No fire |
|
||||||
|
| Stale `as_of` (> max_staleness) | No fire |
|
||||||
|
| Cooldown: rule fired today | No fire |
|
||||||
|
| Template `{{pct}}`, `{{symbol}}`, `{{price}}` | Substituted |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6 — Guardrails
|
||||||
|
|
||||||
|
**Refs:** §8.3
|
||||||
|
|
||||||
|
- [x] **Red:** `Guardrails.check()` rejects when `max_orders_per_day` exceeded
|
||||||
|
- [x] **Red:** Rejects blocklisted symbol
|
||||||
|
- [x] **Red:** Rejects when `require_question_before_order` violated
|
||||||
|
- [x] **Green:** `server/lib/trading/guardrails.dart`
|
||||||
|
- [x] **Confirm:** Server-side max notional ceiling enforced even if config allows more
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7 — Trading pipeline — question creation
|
||||||
|
|
||||||
|
**Refs:** §8.1 · `trading_pipeline.dart` · `QuestionService`
|
||||||
|
|
||||||
|
- [x] **Red:** Rule fires + guardrails pass + queue room → `createAndDeliverQuestion` with `pipeline_key=trading`, `pipeline_step=dip_confirm:await_confirm`, `correct_answer=10`, substituted text
|
||||||
|
- [x] **Green:** `server/lib/trading/trading_pipeline.dart` — `evaluate()` with mocked deps
|
||||||
|
- [x] **Confirm:** Integration — seeded snapshots + config → row in `questions` with correct tags
|
||||||
|
- [x] **Green:** `user_trading_state.context` records pending rule id and phase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 8 — Answer handling → order proposal
|
||||||
|
|
||||||
|
**Refs:** §8.2 · extend `QuestionPipeline.onAnswerSubmitted`
|
||||||
|
|
||||||
|
- [x] **Red:** `pipeline_key=trading`, user `+10`, `BranchDecision.yesNo` → match → stages order (`submit_order`), no Alpaca yet
|
||||||
|
- [x] **Red:** User `-10` → skip logged, no order
|
||||||
|
- [x] **Green:** `PipelineKeys.trading` in `question_pipeline.dart`
|
||||||
|
- [x] **Green:** `_handleTradingAnswer` in `question_pipeline.dart` (delegates to `TradingPipeline.handleAnswer`)
|
||||||
|
- [x] **Confirm:** Integration test exercises the `QuestionPipeline.onAnswerSubmitted` switch for `pipeline_key=trading`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 9 — Trade actuator
|
||||||
|
|
||||||
|
**Refs:** §10 · `alpaca_trading_client.dart` · `TradeActuator`
|
||||||
|
|
||||||
|
- [x] **Red:** Mock HTTP — `submitOrder(notional: 10, side: buy)` POSTs paper URL with `client_order_id`; `trade_orders` row `status=accepted`
|
||||||
|
- [x] **Red:** Duplicate `client_order_id` → 422 → resolved via `getOrderByClientOrderId`
|
||||||
|
- [x] **Green:** `server/lib/alpaca/alpaca_trading_client.dart`
|
||||||
|
- [x] **Green:** `server/lib/trading/trade_actuator.dart` (test mode short-circuit + Alpaca path)
|
||||||
|
- [x] **Confirm:** Tagged integration test (`server/test/alpaca/alpaca_trading_live_test.dart`) — paper roundtrip, skipped without credentials
|
||||||
|
- [x] **Confirm:** `client_order_id` format `{firebase_uid}-{rule_id}-{question_id}` unique (set by `TradingPipeline.handleAnswer`, enforced by `trade_orders.client_order_id UNIQUE`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 10 — Worker integration
|
||||||
|
|
||||||
|
**Refs:** §7 · `question_background_worker.dart` · `trading_orchestrator.dart`
|
||||||
|
|
||||||
|
- [x] **Red:** `TRADING_ENABLED=true`, test mode (no Alpaca client) — one orchestrator tick runs evaluate + actuate against fixture snapshots
|
||||||
|
- [x] **Green:** `server/lib/trading/trading_orchestrator.dart` (ingest → evaluate → actuate, per-stage failure isolation)
|
||||||
|
- [x] **Green:** `QuestionBackgroundWorker` accepts optional `TradingOrchestrator`, runs it after `QuestionPipeline.runMaintenanceCycle`
|
||||||
|
- [x] **Green:** `bin/server.dart` builds the trading stack only when `TRADING_ENABLED=true`; uses real Alpaca clients only when `QUESTION_PIPELINE_TEST_MODE=false` and credentials present
|
||||||
|
- [x] **Confirm:** **Gate A** below — `test/integration/trading_orchestrator_gate_a_test.dart` green
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 11 — End-to-end with real Alpaca paper
|
||||||
|
|
||||||
|
**Refs:** §17 · §15 integration
|
||||||
|
|
||||||
|
- [ ] **Red:** Manual/scripted E2E checklist (or tagged automated test)
|
||||||
|
- [ ] **Green:** Real keys in `.env`, `QUESTION_PIPELINE_TEST_MODE=false`
|
||||||
|
- [ ] **Confirm:** **Gate B** (see below)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gate A — Local loop (test mode)
|
||||||
|
|
||||||
|
**Prerequisite:** Steps 0–10 complete.
|
||||||
|
|
||||||
|
Automated as `server/test/integration/trading_orchestrator_gate_a_test.dart`.
|
||||||
|
|
||||||
|
- [x] Seed user with `user_trading_config.enabled=true` and dip rule
|
||||||
|
- [x] Insert fixture snapshots (or test-mode ingest)
|
||||||
|
- [x] Run one worker tick → question queued
|
||||||
|
- [x] Submit answer +10 via API
|
||||||
|
- [x] Run next tick → `trade_orders` row (fake Alpaca id in test mode)
|
||||||
|
- [x] Third tick → rule does not re-fire (cooldown)
|
||||||
|
- [x] Set `TRADING_ENABLED=true` in dev `.env` only after Gate A passes (manual — flip the flag when ready to start using the live stack against paper Alpaca)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gate B — Paper Alpaca E2E
|
||||||
|
|
||||||
|
**Prerequisite:** Gate A complete.
|
||||||
|
|
||||||
|
- [ ] Ingest real SPY prices
|
||||||
|
- [ ] Rule fires (threshold or market conditions)
|
||||||
|
- [ ] Flutter: question via SignalR
|
||||||
|
- [ ] User swipes +10
|
||||||
|
- [ ] Order visible in Alpaca paper UI
|
||||||
|
- [ ] `trade_orders.question_id` and `rule_id` populated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Configuration API & hardening
|
||||||
|
|
||||||
|
**Start only after Gate B passes.** Maps to TRADING_DEVELOPMENT_PLAN §13, §14 Phase 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 12 — Config validation
|
||||||
|
|
||||||
|
- [ ] **Red:** Validator rejects >30 symbols, missing `rules[].id`, invalid `mode`
|
||||||
|
- [ ] **Green:** `TradingConfigValidator`
|
||||||
|
- [ ] **Confirm:** Invalid JSON → 400 on PUT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 13 — HTTP endpoints
|
||||||
|
|
||||||
|
- [ ] **Red:** Handler tests — `GET/PUT /v1/me/trading/config`
|
||||||
|
- [ ] **Red:** `GET /v1/me/trading/orders`
|
||||||
|
- [ ] **Red:** `GET /v1/me/trading/snapshots?symbol=SPY`
|
||||||
|
- [ ] **Green:** `server/lib/handlers/trading_handler.dart`
|
||||||
|
- [ ] **Confirm:** curl with Firebase token; sanitized config (no secrets)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 14 — Daily guardrail counters
|
||||||
|
|
||||||
|
- [ ] **Red:** After order submit, `context.daily_order_count` increments; resets UTC midnight
|
||||||
|
- [ ] **Green:** Counter in `user_trading_state.context`
|
||||||
|
- [ ] **Confirm:** Third order same day blocked when `max_orders_per_day=3`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 15 — Order status polling
|
||||||
|
|
||||||
|
- [ ] **Red:** Mock Alpaca status `filled` → `trade_orders.status` and `filled_at` updated
|
||||||
|
- [ ] **Green:** Poll on worker tick or post-submit
|
||||||
|
- [ ] **Confirm:** Failed order → follow-up question (optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Streaming & observability
|
||||||
|
|
||||||
|
**Deferred until Phase 2 stable.** Maps to TRADING_DEVELOPMENT_PLAN §9.2, §14 Phase 3.
|
||||||
|
|
||||||
|
| Step | Red test | Green impl | Done |
|
||||||
|
|------|----------|------------|------|
|
||||||
|
| 16 WS ingest | Mock WS message → snapshot upsert | `alpaca_ws_ingest.dart` | ⬜ |
|
||||||
|
| 17 Symbol cap | 31st symbol rejected | Config validator + WS manager | ⬜ |
|
||||||
|
| 18 SignalR `ReceiveTradeUpdate` | Hub mock receives on fill | Optional Flutter listener | ⬜ |
|
||||||
|
| 19 Metrics | Log ingest lag, rule fires, order latency | Structured logging | ⬜ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test pyramid (target)
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Gate B (manual) │ 1 scenario / release
|
||||||
|
├─────────────────┤
|
||||||
|
│ Gate A (worker) │ 1 scripted integration
|
||||||
|
├─────────────────┤
|
||||||
|
│ HTTP handlers │ ~8 tests
|
||||||
|
├─────────────────┤
|
||||||
|
│ DB integration │ ~12 tests
|
||||||
|
├─────────────────┤
|
||||||
|
│ Unit (engine, │ ~40 tests
|
||||||
|
│ clients, config)│
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested schedule (first 2 weeks)
|
||||||
|
|
||||||
|
| Day | Steps | Deliverable |
|
||||||
|
|-----|-------|-------------|
|
||||||
|
| 1 | 0, 1.1–1.2 | Test harness + migration + snapshot DB |
|
||||||
|
| 2 | 1.3–1.4, 2 | Config merge + env |
|
||||||
|
| 3 | 3–4 | Alpaca MD client + ingest |
|
||||||
|
| 4 | 5–6 | Rule engine + guardrails |
|
||||||
|
| 5 | 7–8 | Questions from rules + answer branch |
|
||||||
|
| 6 | 9 | Trade actuator |
|
||||||
|
| 7 | 10 | Worker wire-up → Gate A |
|
||||||
|
| 8 | 11 | Gate B with paper account |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Suggested CI jobs
|
||||||
|
- dart test # unit + DB integration (no Alpaca)
|
||||||
|
- dart test --tags=alpaca # optional; secrets required; nightly/manual
|
||||||
|
```
|
||||||
|
|
||||||
|
**CI env:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TRADING_ENABLED=true
|
||||||
|
QUESTION_PIPELINE_TEST_MODE=true
|
||||||
|
DATABASE_URL=postgres://.../cyberhybridhub_test
|
||||||
|
# No ALPACA_* keys in default CI job
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Safety tests (must pass before live)
|
||||||
|
|
||||||
|
These should **fail the build** if removed:
|
||||||
|
|
||||||
|
- [ ] `AlpacaEnv` refuses live URL when `ALPACA_ALLOW_LIVE=false`
|
||||||
|
- [ ] Server-side max notional ceiling enforced regardless of config
|
||||||
|
- [ ] Every order has non-null `question_id` when `require_question_before_order=true`
|
||||||
|
- [ ] `client_order_id` UNIQUE prevents duplicate Alpaca POST
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mapping to existing code
|
||||||
|
|
||||||
|
| New piece | Extends |
|
||||||
|
|-----------|---------|
|
||||||
|
| `TradingPipeline.evaluate` | `QuestionPipeline._canEnqueue` queue limits |
|
||||||
|
| `onAnswerSubmitted` trading branch | geography/weather switch in `question_pipeline.dart` |
|
||||||
|
| `BranchDecision.yesNo` | +10/-10 swipe (already used for weather) |
|
||||||
|
| `ExternalDataFetcher` pattern | Alpaca clients with injectable `http.Client` |
|
||||||
|
| `QUESTION_PIPELINE_TEST_MODE` | Skip Alpaca; fixture snapshots |
|
||||||
|
|
||||||
|
**Existing touchpoints:**
|
||||||
|
|
||||||
|
| Component | Path |
|
||||||
|
|-----------|------|
|
||||||
|
| Interval worker | `server/lib/workers/question_background_worker.dart` |
|
||||||
|
| Question pipeline | `server/lib/pipeline/question_pipeline.dart` |
|
||||||
|
| Branch decisions | `server/lib/pipeline/branch_decision.dart` |
|
||||||
|
| External fetcher | `server/lib/pipeline/external_data_fetcher.dart` |
|
||||||
|
| Env | `server/lib/env.dart` |
|
||||||
|
| Flutter hub | `lib/services/questions_hub_service.dart` |
|
||||||
|
| Flutter swipe UI | `lib/widgets/swipe_question_tile.dart` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MVP definition of done
|
||||||
|
|
||||||
|
- [ ] `dart test` in `server/` green (~40+ tests, no network in default job)
|
||||||
|
- [ ] Gate A passes with test mode
|
||||||
|
- [ ] Gate B passes on Alpaca paper
|
||||||
|
- [ ] TRADING_DEVELOPMENT_PLAN §17 scenario reproducible from seed SQL
|
||||||
|
- [ ] `TRADING_ENABLED=false` default; no secrets in Flutter
|
||||||
|
- [ ] Every order traceable via `trade_orders.question_id` + `rule_id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [TRADING_DEVELOPMENT_PLAN.md](./TRADING_DEVELOPMENT_PLAN.md)
|
||||||
|
- [server/README.md](./server/README.md)
|
||||||
|
- [Alpaca Market Data API](https://docs.alpaca.markets/docs/market-data-api)
|
||||||
|
- [Alpaca Trading API](https://docs.alpaca.markets/docs/trading-api)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document version: 1.0 — TDD progress tracker for Alpaca trading MVP.*
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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
16
scripts/test-server-alpaca.sh
Executable 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
17
scripts/test-server.sh
Executable 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 "$@"
|
||||||
146
scripts/web_static_server.dart
Normal file
146
scripts/web_static_server.dart
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 |
|
||||||
|
|||||||
@ -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
7
server/dart_test.yaml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Integration suites share cyberhybridhub_test; run serially.
|
||||||
|
concurrency: 1
|
||||||
|
|
||||||
|
tags:
|
||||||
|
integration:
|
||||||
|
postgres:
|
||||||
|
alpaca:
|
||||||
78
server/lib/alpaca/alpaca_env.dart
Normal file
78
server/lib/alpaca/alpaca_env.dart
Normal 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
server/lib/alpaca/alpaca_market_data_client.dart
Normal file
82
server/lib/alpaca/alpaca_market_data_client.dart
Normal 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;
|
||||||
|
}
|
||||||
220
server/lib/alpaca/alpaca_models.dart
Normal file
220
server/lib/alpaca/alpaca_models.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
108
server/lib/alpaca/alpaca_trading_client.dart
Normal file
108
server/lib/alpaca/alpaca_trading_client.dart
Normal 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;
|
||||||
|
}
|
||||||
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
server/lib/handlers/trading_dev_handler.dart
Normal file
64
server/lib/handlers/trading_dev_handler.dart
Normal 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',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
149
server/lib/trading/guardrails.dart
Normal file
149
server/lib/trading/guardrails.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
134
server/lib/trading/market_data_db.dart
Normal file
134
server/lib/trading/market_data_db.dart
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
160
server/lib/trading/market_data_ingest.dart
Normal file
160
server/lib/trading/market_data_ingest.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
178
server/lib/trading/rule_engine.dart
Normal file
178
server/lib/trading/rule_engine.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
305
server/lib/trading/trade_actuator.dart
Normal file
305
server/lib/trading/trade_actuator.dart
Normal 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_;
|
||||||
|
}
|
||||||
239
server/lib/trading/trade_orders_db.dart
Normal file
239
server/lib/trading/trade_orders_db.dart
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
221
server/lib/trading/trading_config.dart
Normal file
221
server/lib/trading/trading_config.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
server/lib/trading/trading_config_db.dart
Normal file
101
server/lib/trading/trading_config_db.dart
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
202
server/lib/trading/trading_dev_actions.dart
Normal file
202
server/lib/trading/trading_dev_actions.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
server/lib/trading/trading_orchestrator.dart
Normal file
148
server/lib/trading/trading_orchestrator.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
291
server/lib/trading/trading_pipeline.dart
Normal file
291
server/lib/trading/trading_pipeline.dart
Normal 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;
|
||||||
|
}
|
||||||
239
server/lib/trading/user_trading_state_db.dart
Normal file
239
server/lib/trading/user_trading_state_db.dart
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
97
server/migrations/004_trading.sql
Normal file
97
server/migrations/004_trading.sql
Normal 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();
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
75
server/test/alpaca/alpaca_env_test.dart
Normal file
75
server/test/alpaca/alpaca_env_test.dart
Normal 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}');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
56
server/test/alpaca/alpaca_market_data_client_test.dart
Normal file
56
server/test/alpaca/alpaca_market_data_client_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
48
server/test/alpaca/alpaca_market_data_live_test.dart
Normal file
48
server/test/alpaca/alpaca_market_data_live_test.dart
Normal 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
58
server/test/alpaca/alpaca_models_test.dart
Normal file
58
server/test/alpaca/alpaca_models_test.dart
Normal 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
174
server/test/alpaca/alpaca_trading_client_test.dart
Normal file
174
server/test/alpaca/alpaca_trading_client_test.dart
Normal 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'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
server/test/alpaca/alpaca_trading_live_test.dart
Normal file
77
server/test/alpaca/alpaca_trading_live_test.dart
Normal 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)));
|
||||||
|
}
|
||||||
27
server/test/fixtures/alpaca_daily_bars.json
vendored
Normal file
27
server/test/fixtures/alpaca_daily_bars.json
vendored
Normal 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
|
||||||
|
}
|
||||||
12
server/test/fixtures/alpaca_latest_trade.json
vendored
Normal file
12
server/test/fixtures/alpaca_latest_trade.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
server/test/fixtures/alpaca_order_accepted.json
vendored
Normal file
29
server/test/fixtures/alpaca_order_accepted.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
4
server/test/fixtures/alpaca_order_duplicate_client_id.json
vendored
Normal file
4
server/test/fixtures/alpaca_order_duplicate_client_id.json
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"code": 42210000,
|
||||||
|
"message": "client_order_id must be unique"
|
||||||
|
}
|
||||||
20
server/test/fixtures/market_snapshots_spy_dip.json
vendored
Normal file
20
server/test/fixtures/market_snapshots_spy_dip.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
37
server/test/fixtures/trading_config_default.json
vendored
Normal file
37
server/test/fixtures/trading_config_default.json
vendored
Normal 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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
22
server/test/helpers/fixture_loader.dart
Normal file
22
server/test/helpers/fixture_loader.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
92
server/test/helpers/mock_http_client.dart
Normal file
92
server/test/helpers/mock_http_client.dart
Normal 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;
|
||||||
|
}
|
||||||
155
server/test/helpers/test_db.dart
Normal file
155
server/test/helpers/test_db.dart
Normal 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 001–004.
|
||||||
|
class TestDb {
|
||||||
|
TestDb._(this.db, this._connection, this.databaseUrl);
|
||||||
|
|
||||||
|
final ProfileDb db;
|
||||||
|
final Connection _connection;
|
||||||
|
final String databaseUrl;
|
||||||
|
|
||||||
|
Connection get connection => _connection;
|
||||||
|
|
||||||
|
static const String testDatabaseName = 'cyberhybridhub_test';
|
||||||
|
|
||||||
|
static Future<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();
|
||||||
|
}
|
||||||
26
server/test/helpers/test_env.dart
Normal file
26
server/test/helpers/test_env.dart
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
59
server/test/integration/market_data_db_test.dart
Normal file
59
server/test/integration/market_data_db_test.dart
Normal 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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
180
server/test/integration/market_data_ingest_test.dart
Normal file
180
server/test/integration/market_data_ingest_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
54
server/test/integration/migration_test.dart
Normal file
54
server/test/integration/migration_test.dart
Normal 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 001–004 apply on cyberhybridhub_test', () async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Connection connection = testDb!.connection;
|
||||||
|
final Result tables = await connection.execute(
|
||||||
|
'''
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name IN (
|
||||||
|
'users',
|
||||||
|
'questions',
|
||||||
|
'market_data_snapshots',
|
||||||
|
'trading_config_templates',
|
||||||
|
'user_trading_config',
|
||||||
|
'user_trading_state',
|
||||||
|
'trade_orders'
|
||||||
|
)
|
||||||
|
ORDER BY table_name
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
expect(tables.map((ResultRow r) => r[0]), hasLength(7));
|
||||||
|
|
||||||
|
final Result template = await connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'SELECT name FROM trading_config_templates WHERE name = @name',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'name': 'default_paper_watchlist'},
|
||||||
|
);
|
||||||
|
expect(template, isNotEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
323
server/test/integration/trade_actuator_test.dart
Normal file
323
server/test/integration/trade_actuator_test.dart
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
122
server/test/integration/trade_orders_db_test.dart
Normal file
122
server/test/integration/trade_orders_db_test.dart
Normal 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>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
79
server/test/integration/trading_config_db_test.dart
Normal file
79
server/test/integration/trading_config_db_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
189
server/test/integration/trading_dev_actions_test.dart
Normal file
189
server/test/integration/trading_dev_actions_test.dart
Normal 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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
182
server/test/integration/trading_orchestrator_gate_a_test.dart
Normal file
182
server/test/integration/trading_orchestrator_gate_a_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
306
server/test/integration/trading_pipeline_test.dart
Normal file
306
server/test/integration/trading_pipeline_test.dart
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
90
server/test/integration/trading_schema_test.dart
Normal file
90
server/test/integration/trading_schema_test.dart
Normal 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>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
23
server/test/smoke_test.dart
Normal file
23
server/test/smoke_test.dart
Normal 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
176
server/test/trading/guardrails_test.dart
Normal file
176
server/test/trading/guardrails_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
176
server/test/trading/rule_engine_test.dart
Normal file
176
server/test/trading/rule_engine_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
68
server/test/trading/trading_config_test.dart
Normal file
68
server/test/trading/trading_config_test.dart
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
21
startup.sh
21
startup.sh
@ -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=$!
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user