From 6495f2ef590e8d5e9907cd31d1cfeb8ab04d4669 Mon Sep 17 00:00:00 2001 From: Aidin Teymouri Date: Fri, 9 Oct 2026 13:41:00 +1000 Subject: [PATCH 1/4] Resolve PLAN.md review items and fold decisions into spec Define API response shapes, trading rules, chat/mock LLM contract, SSE payload format, DB concurrency, and simplified Docker/test setup. Co-Authored-By: Claude Opus 5.5 --- planning/PLAN.md | 423 +++++++++++++++++++++++++++++++---------------- 1 file changed, 281 insertions(+), 142 deletions(-) diff --git a/planning/PLAN.md b/planning/PLAN.md index bc1811b33..c6cc69ee0 100644 --- a/planning/PLAN.md +++ b/planning/PLAN.md @@ -12,7 +12,7 @@ This is the capstone project for an agentic AI coding course. It is built entire ### First Launch -The user runs a single Docker command (or a provided start script). A browser opens to `http://localhost:8000`. No login, no signup. They immediately see: +The user runs a single command (`docker compose up -d`, or the provided start script). A browser opens to `http://localhost:8000`. No login, no signup. They immediately see: - A watchlist of 10 default tickers with live-updating prices in a grid - $10,000 in virtual cash @@ -39,6 +39,7 @@ The user runs a single Docker command (or a provided start script). A browser op - **Responsive but desktop-first**: optimized for wide screens, functional on tablet ### Color Scheme + - Accent Yellow: `#ecad0a` - Blue Primary: `#209dd7` - Purple Secondary: `#753991` (submit buttons) @@ -54,31 +55,35 @@ The user runs a single Docker command (or a provided start script). A browser op │ FastAPI (Python/uv) │ │ ├── /api/* REST endpoints │ │ ├── /api/stream/* SSE streaming │ -│ └── /* Static file serving │ -│ (Next.js export) │ +│ └── /* Static file serving │ +│ (Next.js export) │ │ │ -│ SQLite database (volume-mounted) │ -│ Background task: market data polling/sim │ +│ SQLite database (Docker volume) │ +│ Background tasks: │ +│ ├── market data simulator / Massive poller │ +│ └── portfolio snapshot recorder (every 60s) │ └─────────────────────────────────────────────────┘ ``` - **Frontend**: Next.js with TypeScript, built as a static export (`output: 'export'`), served by FastAPI as static files - **Backend**: FastAPI (Python), managed as a `uv` project -- **Database**: SQLite, single file at `db/finally.db`, volume-mounted for persistence +- **Database**: SQLite, single file `finally.db`, persisted in a Docker volume - **Real-time data**: Server-Sent Events (SSE) — simpler than WebSockets, one-way server→client push, works everywhere - **AI integration**: LiteLLM → OpenRouter (Cerebras for fast inference), with structured outputs for trade execution - **Market data**: Environment-variable driven — simulator by default, real data via Massive API if key provided +**Static file routing:** FastAPI includes all API routers first, then mounts the Next.js export as the last route: `app.mount("/", StaticFiles(directory="static", html=True))`. This guarantees `/api/*` is never shadowed by static files and `/` serves `index.html`. + ### Why These Choices -| Decision | Rationale | -|---|---| -| SSE over WebSockets | One-way push is all we need; simpler, no bidirectional complexity, universal browser support | -| Static Next.js export | Single origin, no CORS issues, one port, one container, simple deployment | -| SQLite over Postgres | No auth = no multi-user = no need for a database server; self-contained, zero config | -| Single Docker container | Students run one command; no docker-compose for production, no service orchestration | -| uv for Python | Fast, modern Python project management; reproducible lockfile; what students should learn | -| Market orders only | Eliminates order book, limit order logic, partial fills — dramatically simpler portfolio math | +| Decision | Rationale | +| ----------------------- | --------------------------------------------------------------------------------------------- | +| SSE over WebSockets | One-way push is all we need; simpler, no bidirectional complexity, universal browser support | +| Static Next.js export | Single origin, no CORS issues, one port, one container, simple deployment | +| SQLite over Postgres | No auth = no multi-user = no need for a database server; self-contained, zero config | +| Single Docker container | Students run one command; no service orchestration | +| uv for Python | Fast, modern Python project management; reproducible lockfile; what students should learn | +| Market orders only | Eliminates order book, limit order logic, partial fills — dramatically simpler portfolio math | --- @@ -88,20 +93,20 @@ The user runs a single Docker command (or a provided start script). A browser op finally/ ├── frontend/ # Next.js TypeScript project (static export) ├── backend/ # FastAPI uv project (Python) -│ └── db/ # Schema definitions, seed data, migration logic +│ └── app/ # The only installable package (hatch packages = ["app"]) +│ ├── market/ # Market data (complete — see MARKET_DATA_SUMMARY.md) +│ └── db/ # Schema SQL, seed data, connection helpers ├── planning/ # Project-wide documentation for agents │ ├── PLAN.md # This document │ └── ... # Additional agent reference docs ├── scripts/ -│ ├── start_mac.sh # Launch Docker container (macOS/Linux) -│ ├── stop_mac.sh # Stop Docker container (macOS/Linux) -│ ├── start_windows.ps1 # Launch Docker container (Windows PowerShell) -│ └── stop_windows.ps1 # Stop Docker container (Windows PowerShell) -├── test/ # Playwright E2E tests + docker-compose.test.yml -├── db/ # Volume mount target (SQLite file lives here at runtime) -│ └── .gitkeep # Directory exists in repo; finally.db is gitignored +│ ├── start_mac.sh # Thin wrapper: docker compose up (macOS/Linux) +│ └── stop_mac.sh # Thin wrapper: docker compose down (macOS/Linux) +├── test/ # Playwright E2E tests (run from the host) +├── db/ # Local-dev SQLite location (not used by Docker) +│ └── .gitkeep # finally.db is gitignored ├── Dockerfile # Multi-stage build (Node → Python) -├── docker-compose.yml # Optional convenience wrapper +├── docker-compose.yml # Primary way to run the app (all platforms) ├── .env # Environment variables (gitignored, .env.example committed) └── .gitignore ``` @@ -109,105 +114,135 @@ finally/ ### Key Boundaries - **`frontend/`** is a self-contained Next.js project. It knows nothing about Python. It talks to the backend via `/api/*` endpoints and `/api/stream/*` SSE endpoints. Internal structure is up to the Frontend Engineer agent. -- **`backend/`** is a self-contained uv project with its own `pyproject.toml`. It owns all server logic including database initialization, schema, seed data, API routes, SSE streaming, market data, and LLM integration. Internal structure is up to the Backend/Market Data agents. -- **`backend/db/`** contains schema SQL definitions and seed logic. The backend lazily initializes the database on first request — creating tables and seeding default data if the SQLite file doesn't exist or is empty. -- **`db/`** at the top level is the runtime volume mount point. The SQLite file (`db/finally.db`) is created here by the backend and persists across container restarts via Docker volume. +- **`backend/`** is a self-contained uv project with its own `pyproject.toml`. It owns all server logic including database initialization, schema, seed data, API routes, SSE streaming, market data, and LLM integration. All code lives inside the `app` package so it is importable from the installed wheel. +- **`backend/app/db/`** contains schema SQL definitions and seed logic. The backend initializes the database at startup — creating tables and seeding default data if the SQLite file doesn't exist or is empty. +- **`db/`** at the top level is where the SQLite file lives during local development (running `uv run uvicorn …` outside Docker). In Docker, the database lives in the named volume `finally-data` mounted at `/app/db`. - **`planning/`** contains project-wide documentation, including this plan. All agents reference files here as the shared contract. -- **`test/`** contains Playwright E2E tests and supporting infrastructure (e.g., `docker-compose.test.yml`). Unit tests live within `frontend/` and `backend/` respectively, following each framework's conventions. -- **`scripts/`** contains start/stop scripts that wrap Docker commands. +- **`test/`** contains Playwright E2E tests. Unit tests live within `frontend/` and `backend/` respectively, following each framework's conventions. +- **`scripts/`** contains thin start/stop wrappers around `docker compose`. --- ## 5. Environment Variables ```bash -# Required: OpenRouter API key for LLM chat functionality +# OpenRouter API key for LLM chat. The app starts without it; only chat is disabled. OPENROUTER_API_KEY=your-openrouter-api-key-here # Optional: Massive (Polygon.io) API key for real market data # If not set, the built-in market simulator is used (recommended for most users) MASSIVE_API_KEY= -# Optional: Set to "true" for deterministic mock LLM responses (testing) +# Optional: Set to "true" for deterministic mock LLM responses (testing, no API key needed) LLM_MOCK=false + +# Optional: SQLite file path. Defaults to /db/finally.db for local dev; +# the Dockerfile sets DB_PATH=/app/db/finally.db. +DB_PATH= ``` ### Behavior - If `MASSIVE_API_KEY` is set and non-empty → backend uses Massive REST API for market data - If `MASSIVE_API_KEY` is absent or empty → backend uses the built-in market simulator -- If `LLM_MOCK=true` → backend returns deterministic mock LLM responses (for E2E tests) -- The backend reads `.env` from the project root (mounted into the container or read via docker `--env-file`) +- If `LLM_MOCK=true` → backend returns deterministic mock LLM responses (see Section 9) +- If `OPENROUTER_API_KEY` is missing and `LLM_MOCK` is not `true` → the app starts normally; `POST /api/chat` returns `503` with `{"detail": "AI chat is not configured. Set OPENROUTER_API_KEY in .env (or LLM_MOCK=true)."}` and the chat panel shows that message. No silent fallback to mock. +- **The backend reads configuration from environment variables only.** In Docker they are supplied via `env_file: .env` in `docker-compose.yml`. For local development, the backend calls `python-dotenv`'s `load_dotenv()` on the project-root `.env` at startup; this is a no-op in the container where no `.env` file exists. --- ## 6. Market Data -### Two Implementations, One Interface +The market data subsystem is **complete** in `backend/app/market/`. See `planning/MARKET_DATA_SUMMARY.md` for the architecture, the `MarketDataSource` interface, `PriceCache` API, simulator details, and Massive client. This section defines only the contract the rest of the app builds against, plus the changes the rest of the app requires. -Both the simulator and the Massive client implement the same abstract interface. The backend selects which to use based on the environment variable. All downstream code (SSE streaming, price cache, frontend) is agnostic to the source. +### Interface Contract -### Simulator (Default) +- `create_market_data_source(cache)` returns a simulator (default) or Massive poller (`MASSIVE_API_KEY` set). Both write to a shared in-memory `PriceCache`. +- `source.add_ticker(t)` / `source.remove_ticker(t)` change the tracked set. The simulator seeds a price into the cache **immediately** on `add_ticker` (unknown tickers get a random seed price between $50 and $300). Massive only has a price after its next poll (up to 15s on the free tier). +- `cache.get_price(t)` returns `float | None`. `None` means "no price yet" and must be handled by every consumer. -- Generates prices using geometric Brownian motion (GBM) with configurable drift and volatility per ticker -- Updates at ~500ms intervals -- Correlated moves across tickers (e.g., tech stocks move together) -- Occasional random "events" — sudden 2-5% moves on a ticker for drama -- Starts from realistic seed prices (e.g., AAPL ~$190, GOOGL ~$175, etc.) -- Runs as an in-process background task — no external dependencies +### Tracked Tickers = Watchlist ∪ Held Positions -### Massive API (Optional) +The set of tickers the market data source tracks is the union of the watchlist and all tickers with an open position. Removing a ticker from the watchlist hides it from the watchlist panel, but if the user still holds it, it stays tracked so the position can be valued. A ticker is removed from the market data source only when it is in neither set (e.g. after removing it from the watchlist while flat, or after fully selling a position that isn't on the watchlist). On startup, the backend loads both sets from the database and calls `source.start(watchlist ∪ positions)`. -- REST API polling (not WebSocket) — simpler, works on all tiers -- Polls for the union of all watched tickers on a configurable interval -- Free tier (5 calls/min): poll every 15 seconds -- Paid tiers: poll every 2-15 seconds depending on tier -- Parses REST response into the same format as the simulator +### Ticker Validation -### Shared Price Cache +Tickers are normalized to uppercase and trimmed, then must match `^[A-Z]{1,5}$`. Anything else is rejected with `400`. There is no whitelist: in simulator mode any valid symbol gets a price; in Massive mode a symbol the API doesn't recognize simply never receives a price (it shows as `—` in the UI, and trades on it return `409`). -- A single background task (simulator or Massive poller) writes to an in-memory price cache -- The cache holds the latest price, previous price, and timestamp for each ticker -- SSE streams read from this cache and push updates to connected clients -- This architecture supports future multi-user scenarios without changes to the data layer +### Session Change % (required change to the market module) + +The cache's `change_percent` is tick-to-tick and near zero, so it is not useful for display. The `PriceCache` must record an **`open_price`** per ticker — the first price written for that ticker since it started being tracked in this process — and `PriceUpdate.to_dict()` must add `open_price` and `session_change_percent` (`(price - open_price) / open_price * 100`, rounded to 4 dp). The watchlist column is labelled **"Chg %"** (not "Daily") and shows `session_change_percent`. ### SSE Streaming -- Endpoint: `GET /api/stream/prices` -- Long-lived SSE connection; client uses native `EventSource` API -- Server pushes price updates for all tickers known to the system at a regular cadence (~500ms) — in the single-user model this is equivalent to the user's watchlist -- Each SSE event contains ticker, price, previous price, timestamp, and change direction -- Client handles reconnection automatically (EventSource has built-in retry) +- Endpoint: `GET /api/stream/prices` (`text/event-stream`); client uses native `EventSource` +- The server first sends `retry: 1000` so the browser reconnects 1s after a drop +- Every ~500ms the server checks the cache; **if anything changed** it sends **one event containing all tracked tickers**, keyed by ticker. If nothing changed (e.g. between Massive polls), nothing is sent. +- Event payload (one `data:` line, JSON): + +```json +{ + "AAPL": { + "ticker": "AAPL", + "price": 190.5, + "previous_price": 190.42, + "open_price": 190.0, + "timestamp": 1760000000.123, + "change": 0.08, + "change_percent": 0.042, + "session_change_percent": 0.2632, + "direction": "up" + }, + "GOOGL": { "...": "..." } +} +``` + +- `timestamp` is **Unix seconds (float)**; all REST endpoints use ISO 8601 UTC strings. +- `direction` is `"up"`, `"down"`, or `"flat"` (tick-to-tick) and drives the flash animation. +- Because the payload includes all tracked tickers, it may include held tickers that aren't on the watchlist. The frontend uses those prices for valuation but only renders watchlist tickers in the watchlist panel. + +### Massive Free Tier Note + +With the Massive free tier (5 calls/min) prices update every 15 seconds, so the UI looks fairly static (few flashes, slow sparklines). Correlated moves and random events are simulator-only. The README must say this so users aren't surprised. --- ## 7. Database -### SQLite with Lazy Initialization +### SQLite Initialization & Access -The backend checks for the SQLite database on startup (or first request). If the file doesn't exist or tables are missing, it creates the schema and seeds default data. This means: +At startup, the backend opens the SQLite file at `DB_PATH` (creating parent directories if needed). If the file doesn't exist or tables are missing, it creates the schema and seeds default data. This means: - No separate migration step - No manual database setup - Fresh Docker volumes start with a clean, seeded database automatically +Concurrency rules (the snapshot task, chat handler, and trade endpoint all write): + +- **WAL mode** enabled on initialization (`PRAGMA journal_mode=WAL`) +- **One connection per operation** (open, use, close); no shared long-lived connection across tasks +- **Trade execution runs in a single transaction** started with `BEGIN IMMEDIATE`: read cash and position → validate → upsert/delete position → insert trade → update cash → commit. This prevents races between a manual trade and an LLM trade. + ### Schema All tables include a `user_id` column defaulting to `"default"`. This is hardcoded for now (single-user) but enables future multi-user support without schema migration. **users_profile** — User state (cash balance) + - `id` TEXT PRIMARY KEY (default: `"default"`) - `cash_balance` REAL (default: `10000.0`) - `created_at` TEXT (ISO timestamp) **watchlist** — Tickers the user is watching + - `id` TEXT PRIMARY KEY (UUID) - `user_id` TEXT (default: `"default"`) - `ticker` TEXT - `added_at` TEXT (ISO timestamp) - UNIQUE constraint on `(user_id, ticker)` -**positions** — Current holdings (one row per ticker per user) +**positions** — Current holdings (one row per ticker per user). A position whose quantity falls below `1e-9` after a sell is **deleted**, not stored as zero. + - `id` TEXT PRIMARY KEY (UUID) - `user_id` TEXT (default: `"default"`) - `ticker` TEXT @@ -217,6 +252,7 @@ All tables include a `user_id` column defaulting to `"default"`. This is hardcod - UNIQUE constraint on `(user_id, ticker)` **trades** — Trade history (append-only log) + - `id` TEXT PRIMARY KEY (UUID) - `user_id` TEXT (default: `"default"`) - `ticker` TEXT @@ -225,18 +261,20 @@ All tables include a `user_id` column defaulting to `"default"`. This is hardcod - `price` REAL - `executed_at` TEXT (ISO timestamp) -**portfolio_snapshots** — Portfolio value over time (for P&L chart). Recorded every 30 seconds by a background task, and immediately after each trade execution. +**portfolio_snapshots** — Portfolio value over time (for P&L chart). `total_value` = cash + Σ(quantity × current price), i.e. **includes cash**. Recorded once at startup, every **60 seconds** by a background task, and immediately after each successful trade. + - `id` TEXT PRIMARY KEY (UUID) - `user_id` TEXT (default: `"default"`) - `total_value` REAL - `recorded_at` TEXT (ISO timestamp) **chat_messages** — Conversation history with LLM + - `id` TEXT PRIMARY KEY (UUID) - `user_id` TEXT (default: `"default"`) - `role` TEXT (`"user"` or `"assistant"`) - `content` TEXT -- `actions` TEXT (JSON — trades executed, watchlist changes made; null for user messages) +- `actions` TEXT (JSON `{"trades": [...], "watchlist_changes": [...]}` with per-item results, same shape as the `/api/chat` response; null for user messages) - `created_at` TEXT (ISO timestamp) ### Default Seed Data @@ -246,90 +284,177 @@ All tables include a `user_id` column defaulting to `"default"`. This is hardcod --- -## 8. API Endpoints +## 8. Trading Rules + +These rules apply identically to manual trades (`POST /api/portfolio/trade`) and LLM trades. + +- **Ticker**: normalized and validated per Section 6. Trading a ticker that is not on the watchlist is allowed; it is **automatically added to the watchlist** first (so it starts being tracked and appears in the UI). +- **Quantity**: must be a finite number `> 0`. No minimum lot size. Fractional shares allowed. +- **Price**: the current cache price. If there is no price yet (`None`), the trade is rejected with `409` `{"detail": "No price available for XYZ yet. Try again shortly."}` and nothing is written. +- **Buy**: requires `quantity × price ≤ cash` (with a `1e-9` tolerance), else `400` `"Insufficient cash"`. Updates `avg_cost` as a weighted average: `(old_qty × old_avg + qty × price) / (old_qty + qty)`. +- **Sell**: requires `quantity ≤ held quantity` (with a `1e-9` tolerance), else `400` `"Insufficient shares"`. `avg_cost` is unchanged. If the remaining quantity is `< 1e-9`, the position row is deleted. Selling a ticker you don't hold is `400`. +- **Realized P&L** is not tracked (out of scope). Unrealized P&L = `(current_price − avg_cost) × quantity`. +- **Rounding**: store full-precision floats; round only for display (prices and money to 2 dp, quantities to up to 4 dp). +- A successful trade inserts a `trades` row and records a portfolio snapshot. + +--- + +## 9. API Endpoints + +All REST responses are JSON. Timestamps are ISO 8601 UTC strings. Prices that aren't available yet are `null`. + +**Errors** use FastAPI's default shape `{"detail": "human-readable message"}`: + +- `400` — business validation failure (invalid ticker, insufficient cash/shares, quantity ≤ 0) +- `404` — not found (e.g. deleting a ticker not on the watchlist) +- `409` — price not yet available +- `422` — malformed request body (FastAPI's automatic validation; here `detail` is a list — the frontend should fall back to a generic message) +- `502` — LLM call failed or returned unparseable output +- `503` — chat not configured (no API key) + +### Portfolio Object + +Returned by `GET /api/portfolio` and embedded in trade and chat responses: + +```json +{ + "cash": 8099.0, + "positions_value": 1905.0, + "total_value": 10004.0, + "unrealized_pnl": 4.0, + "positions": [ + { + "ticker": "AAPL", + "quantity": 10, + "avg_cost": 190.1, + "current_price": 190.5, + "market_value": 1905.0, + "unrealized_pnl": 4.0, + "unrealized_pnl_percent": 0.2104, + "weight": 0.1904 + } + ] +} +``` + +`weight` = market value / total value (including cash). If a position's `current_price` is `null` (e.g. Massive hasn't polled yet after a restart), it is valued at `avg_cost` and its P&L fields are `0`. ### Market Data -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/stream/prices` | SSE stream of live price updates | + +| Method | Path | Description | +| ------ | -------------------- | ------------------------------------------- | +| GET | `/api/stream/prices` | SSE stream of live price updates (see §6) | ### Portfolio -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/portfolio` | Current positions, cash balance, total value, unrealized P&L | -| POST | `/api/portfolio/trade` | Execute a trade: `{ticker, quantity, side}` | -| GET | `/api/portfolio/history` | Portfolio value snapshots over time (for P&L chart) | + +| Method | Path | Request | Response | +| ------ | ------------------------ | --------------------------- | ------------------------------------------------------------------------- | +| GET | `/api/portfolio` | — | Portfolio object | +| POST | `/api/portfolio/trade` | `{ticker, quantity, side}` | `{"trade": {id, ticker, side, quantity, price, executed_at}, "portfolio": {...}}` | +| GET | `/api/portfolio/history` | — | `{"snapshots": [{"total_value": 10000.0, "recorded_at": "..."}]}` — most recent 500, oldest first | ### Watchlist -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/watchlist` | Current watchlist tickers with latest prices | -| POST | `/api/watchlist` | Add a ticker: `{ticker}` | -| DELETE | `/api/watchlist/{ticker}` | Remove a ticker | + +| Method | Path | Request | Response | +| ------ | ------------------------- | ---------- | ---------------------------------------------------------------------------------------- | +| GET | `/api/watchlist` | — | `{"tickers": [{ticker, added_at, price, previous_price, open_price, session_change_percent, direction}]}` (in `added_at` order) | +| POST | `/api/watchlist` | `{ticker}` | `201` with the watchlist item. Idempotent: adding an existing ticker returns `200` with the existing item. | +| DELETE | `/api/watchlist/{ticker}` | — | `204`. `404` if not on the watchlist. Does not affect positions (§6). | ### Chat -| Method | Path | Description | -|--------|------|-------------| -| POST | `/api/chat` | Send a message, receive complete JSON response (message + executed actions) | + +| Method | Path | Request | Response | +| ------ | ------------------- | ----------- | ----------------------------------------------------------- | +| POST | `/api/chat` | `{message}` | Chat response (see §10) | +| GET | `/api/chat/history` | — | `{"messages": [{id, role, content, actions, created_at}]}` — most recent 50, oldest first. Used to restore the chat panel on page load. | ### System -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/health` | Health check (for Docker/deployment) | + +| Method | Path | Description | +| ------ | ------------- | ------------------------------------------------ | +| GET | `/api/health` | `{"status": "ok"}` (for Docker/deployment) | --- -## 9. LLM Integration +## 10. LLM Integration When writing code to make calls to LLMs, use cerebras-inference skill to use LiteLLM via OpenRouter to the `openrouter/openai/gpt-oss-120b` model with Cerebras as the inference provider. Structured Outputs should be used to interpret the results. There is an OPENROUTER_API_KEY in the .env file in the project root. +**Provider fallback:** the request sets `provider.order = ["cerebras"]` (per the skill) and leaves OpenRouter's default fallback enabled, so if Cerebras is unavailable OpenRouter routes to another provider. Slower, but chat keeps working. + ### How It Works When the user sends a chat message, the backend: 1. Loads the user's current portfolio context (cash, positions with P&L, watchlist with live prices, total portfolio value) -2. Loads recent conversation history from the `chat_messages` table +2. Loads the **last 20** messages from `chat_messages`. For assistant messages with actions, a compact summary of the action results is appended to the content (e.g. `[Actions: buy 10 AAPL @ 190.10 executed; sell 5 TSLA failed: Insufficient shares]`) so the LLM sees the outcome of its previous actions. 3. Constructs a prompt with a system message, portfolio context, conversation history, and the user's new message 4. Calls the LLM via LiteLLM → OpenRouter, requesting structured output, using the cerebras-inference skill 5. Parses the complete structured JSON response -6. Auto-executes any trades or watchlist changes specified in the response -7. Stores the message and executed actions in `chat_messages` -8. Returns the complete JSON response to the frontend (no token-by-token streaming — Cerebras inference is fast enough that a loading indicator is sufficient) +6. Executes watchlist changes, then trades, **sequentially and independently**: each one is validated and applied on its own (Section 8). A failure doesn't stop the others; each item gets its own `status` and `error`. +7. Stores the user message and the assistant message (with action results in `actions`) in `chat_messages` +8. Returns the chat response to the frontend (no token-by-token streaming — Cerebras inference is fast enough that a loading indicator is sufficient) + +There is **no second LLM call** after execution. If a trade fails, the LLM's message may be optimistic ("Done, bought 10 AAPL"). The frontend renders the per-action result (✓ / ✗ with error) inline under the message, so the user always sees what actually happened. The LLM sees failures on the next turn through the history (step 2). + +If the LLM call fails or its output can't be parsed into the schema, the endpoint returns `502` and stores nothing. -### Structured Output Schema +### Structured Output Schema (LLM → backend) -The LLM is instructed to respond with JSON matching this schema: +The LLM is instructed to respond with JSON matching this schema. **All three fields are required**; `trades` and `watchlist_changes` may be empty arrays. ```json { "message": "Your conversational response to the user", + "trades": [{ "ticker": "AAPL", "side": "buy", "quantity": 10 }], + "watchlist_changes": [{ "ticker": "PYPL", "action": "add" }] +} +``` + +- `message`: The conversational text shown to the user +- `trades`: Trades to auto-execute. `side` is `"buy"` or `"sell"`. +- `watchlist_changes`: Watchlist modifications. `action` is `"add"` or `"remove"`. + +Define these as Pydantic models (with `Literal` types for `side`/`action`) and pass the model as `response_format`. + +### Chat Response (backend → frontend) + +```json +{ + "message": "Bought 10 AAPL and added PYPL to your watchlist.", "trades": [ - {"ticker": "AAPL", "side": "buy", "quantity": 10} + { + "ticker": "AAPL", + "side": "buy", + "quantity": 10, + "price": 190.1, + "status": "executed", + "error": null + } ], "watchlist_changes": [ - {"ticker": "PYPL", "action": "add"} - ] + { "ticker": "PYPL", "action": "add", "status": "executed", "error": null } + ], + "portfolio": { "...": "Portfolio object (§9), reflecting any executed trades" } } ``` -- `message` (required): The conversational text shown to the user -- `trades` (optional): Array of trades to auto-execute. Each trade goes through the same validation as manual trades (sufficient cash for buys, sufficient shares for sells) -- `watchlist_changes` (optional): Array of watchlist modifications +`status` is `"executed"` or `"failed"`; on failure `price` is `null` and `error` holds the same message the REST endpoint would return. The `actions` column in `chat_messages` stores `{"trades": [...], "watchlist_changes": [...]}` in exactly this shape. ### Auto-Execution Trades specified by the LLM execute automatically — no confirmation dialog. This is a deliberate design choice: + - It's a simulated environment with fake money, so the stakes are zero - It creates an impressive, fluid demo experience - It demonstrates agentic AI capabilities — the core theme of the course -If a trade fails validation (e.g., insufficient cash), the error is included in the chat response so the LLM can inform the user. - ### System Prompt Guidance The LLM should be prompted as "FinAlly, an AI trading assistant" with instructions to: + - Analyze portfolio composition, risk concentration, and P&L - Suggest trades with reasoning - Execute trades when the user asks or agrees @@ -339,39 +464,49 @@ The LLM should be prompted as "FinAlly, an AI trading assistant" with instructio ### LLM Mock Mode -When `LLM_MOCK=true`, the backend returns deterministic mock responses instead of calling OpenRouter. This enables: -- Fast, free, reproducible E2E tests -- Development without an API key -- CI/CD pipelines +When `LLM_MOCK=true`, the backend skips OpenRouter and builds the structured response deterministically from the user's message, then runs it through the **same execution path** as real responses. Rules (case-insensitive, all matches applied in order of appearance): + +- `buy ` / `sell ` (N may be decimal) → a trade +- `add ` / `remove ` → a watchlist change +- `message` is `"Mock response: "` if any action matched, otherwise exactly `"Mock response: I am FinAlly, your AI trading assistant."` + +Example: `"please buy 5 AAPL and add PYPL"` → one buy of 5 AAPL, one watchlist add of PYPL. This enables fast, free, reproducible E2E tests, development without an API key, and CI. --- -## 10. Frontend Design +## 11. Frontend Design ### Layout The frontend is a single-page application with a dense, terminal-inspired layout. The specific component architecture and layout system is up to the Frontend Engineer, but the UI should include these elements: -- **Watchlist panel** — grid/table of watched tickers with: ticker symbol, current price (flashing green/red on change), daily change %, and a sparkline mini-chart (accumulated from SSE since page load) +- **Watchlist panel** — grid/table of watched tickers with: ticker symbol, current price (flashing green/red on change), **Chg %** (`session_change_percent`, §6), and a sparkline mini-chart (accumulated from SSE since page load). Tickers without a price yet show `—`. - **Main chart area** — larger chart for the currently selected ticker, with at minimum price over time. Clicking a ticker in the watchlist selects it here. - **Portfolio heatmap** — treemap visualization where each rectangle is a position, sized by portfolio weight, colored by P&L (green = profit, red = loss) -- **P&L chart** — line chart showing total portfolio value over time, using data from `portfolio_snapshots` +- **P&L chart** — line chart showing total portfolio value over time, from `GET /api/portfolio/history`. Lowest-priority UI element: build it last. - **Positions table** — tabular view of all positions: ticker, quantity, avg cost, current price, unrealized P&L, % change -- **Trade bar** — simple input area: ticker field, quantity field, buy button, sell button. Market orders, instant fill. -- **AI chat panel** — docked/collapsible sidebar. Message input, scrolling conversation history, loading indicator while waiting for LLM response. Trade executions and watchlist changes shown inline as confirmations. +- **Trade bar** — simple input area: ticker field, quantity field, buy button, sell button. Market orders, instant fill. Errors (`detail`) shown inline. +- **AI chat panel** — docked/collapsible sidebar. Message input, scrolling conversation history (restored from `GET /api/chat/history` on load), loading indicator while waiting for LLM response. Trade executions and watchlist changes shown inline under each assistant message with ✓/✗ status and error text. - **Header** — portfolio total value (updating live), connection status indicator, cash balance +### Data Flow + +- On load: `GET /api/portfolio`, `GET /api/watchlist`, `GET /api/portfolio/history`, `GET /api/chat/history`, and open the SSE stream. +- After a trade or chat response: replace portfolio state with the `portfolio` object returned in the response (no follow-up GET). After a chat response with watchlist changes, re-fetch `GET /api/watchlist`. +- **Live total value** is computed on the frontend: `cash + Σ(quantity × latest SSE price)` (falling back to the server's `current_price`/`avg_cost` when no SSE price is known). No backend push is needed. +- **Price history buffer**: the frontend keeps up to **500** points per ticker from the SSE stream (ring buffer, dropping the oldest). The main chart uses the whole buffer; sparklines use the last 100 points. + ### Technical Notes -- Use `EventSource` for SSE connection to `/api/stream/prices` -- Canvas-based charting library preferred (Lightweight Charts or Recharts) for performance +- Use `EventSource` for SSE connection to `/api/stream/prices`. Connection status: `open` → green; `error` while `readyState === CONNECTING` → yellow; `readyState === CLOSED` → red. +- **Charts: Recharts** for the main price chart, the P&L chart, and the heatmap (`Treemap`). Sparklines are a tiny hand-rolled SVG `` (no library). - Price flash effect: on receiving a new price, briefly apply a CSS class with background color transition, then remove it - All API calls go to the same origin (`/api/*`) — no CORS configuration needed - Tailwind CSS for styling with a custom dark theme --- -## 11. Docker & Deployment +## 12. Docker & Deployment ### Multi-Stage Dockerfile @@ -385,37 +520,38 @@ Stage 2: Python 3.12 slim - Copy backend/ - uv sync (install Python dependencies from lockfile) - Copy frontend build output into a static/ directory + - ENV DB_PATH=/app/db/finally.db - Expose port 8000 - CMD: uvicorn serving FastAPI app ``` FastAPI serves the static frontend files and all API routes on port 8000. -### Docker Volume - -The SQLite database persists via a named Docker volume: - -```bash -docker run -v finally-data:/app/db -p 8000:8000 --env-file .env finally +### docker-compose.yml (primary run path, all platforms) + +```yaml +services: + app: + build: . + image: finally + container_name: finally + ports: ["8000:8000"] + env_file: .env + volumes: ["finally-data:/app/db"] +volumes: + finally-data: ``` -The `db/` directory in the project root maps to `/app/db` in the container. The backend writes `finally.db` to this path. +- Start: `docker compose up -d` (add `--build` to rebuild). Works on macOS, Linux, and Windows. +- Stop: `docker compose down` — the named volume `finally-data` (and the database) persists. `docker compose down -v` resets all data. ### Start/Stop Scripts -**`scripts/start_mac.sh`** (macOS/Linux): -- Builds the Docker image if not already built (or if `--build` flag passed) -- Runs the container with the volume mount, port mapping, and `.env` file -- Prints the URL to access the app -- Optionally opens the browser +**`scripts/start_mac.sh`** (macOS/Linux): runs `docker compose up -d` (passing `--build` through if given), prints `http://localhost:8000`, and opens the browser if `open`/`xdg-open` is available. -**`scripts/stop_mac.sh`** (macOS/Linux): -- Stops and removes the running container -- Does NOT remove the volume (data persists) +**`scripts/stop_mac.sh`** (macOS/Linux): runs `docker compose down` (never `-v`). -**`scripts/start_windows.ps1`** / **`scripts/stop_windows.ps1`**: PowerShell equivalents for Windows. - -All scripts should be idempotent — safe to run multiple times. +There are no Windows scripts; the README tells Windows users to run the `docker compose` commands directly. Both scripts are idempotent. ### Optional Cloud Deployment @@ -423,34 +559,37 @@ The container is designed to deploy to AWS App Runner, Render, or any container --- -## 12. Testing Strategy +## 13. Testing Strategy ### Unit Tests (within `frontend/` and `backend/`) **Backend (pytest)**: -- Market data: simulator generates valid prices, GBM math is correct, Massive API response parsing works, both implementations conform to the abstract interface -- Portfolio: trade execution logic, P&L calculations, edge cases (selling more than owned, buying with insufficient cash, selling at a loss) -- LLM: structured output parsing handles all valid schemas, graceful handling of malformed responses, trade validation within chat flow + +- Market data: already covered (73 tests). Add tests for `open_price` / `session_change_percent`. +- Portfolio: trade execution logic, weighted avg cost, P&L calculations, edge cases (selling more than owned, buying with insufficient cash, selling at a loss, selling the full position deletes the row, float-tolerance boundaries, no price → 409) +- Tracked tickers: removing a held ticker from the watchlist keeps it tracked; selling it out while off-watchlist untracks it +- LLM: structured output parsing handles all valid schemas, graceful handling of malformed responses (502), per-trade independent execution within chat flow, mock-mode parsing rules - API routes: correct status codes, response shapes, error handling -**Frontend (React Testing Library or similar)**: -- Component rendering with mock data -- Price flash animation triggers correctly on price changes -- Watchlist CRUD operations -- Portfolio display calculations -- Chat message rendering and loading state +**Frontend (Vitest)** — focus on pure logic, not rendering or snapshots: -### E2E Tests (in `test/`) +- P&L and live total-value calculations +- Price history ring buffer (cap at 500, sparkline slice) +- Flash-class trigger (up/down/flat → class) +- Connection-status state transitions from `EventSource` events +- API response parsing (portfolio object, chat action results) -**Infrastructure**: A separate `docker-compose.test.yml` in `test/` that spins up the app container plus a Playwright container. This keeps browser dependencies out of the production image. +### E2E Tests (in `test/`) -**Environment**: Tests run with `LLM_MOCK=true` by default for speed and determinism. +**Infrastructure**: Playwright runs **on the host** (`npx playwright install` locally or in CI) against the app started with `docker compose up -d` and `LLM_MOCK=true`. No separate Playwright container. **Key Scenarios**: + - Fresh start: default watchlist appears, $10k balance shown, prices are streaming - Add and remove a ticker from the watchlist - Buy shares: cash decreases, position appears, portfolio updates - Sell shares: cash increases, position updates or disappears - Portfolio visualization: heatmap renders with correct colors, P&L chart has data points -- AI chat (mocked): send a message, receive a response, trade execution appears inline -- SSE resilience: disconnect and verify reconnection +- AI chat (mocked): send `buy 5 AAPL`, receive a response, trade execution appears inline with ✓ and the position appears + +SSE reconnection is covered by the frontend connection-status unit test rather than E2E. From d741e115ac3ea7ad12c0e11c3f8e52d28bfa2693 Mon Sep 17 00:00:00 2001 From: Aidin Teymouri Date: Fri, 9 Oct 2026 14:14:53 +1000 Subject: [PATCH 2/4] Fix PLAN.md review blockers B1-B6 - Auto-add to watchlist on buys only; sells never re-add (B1) - Define trade execution order; auto-add persists on failure (B2) - Re-fetch watchlist after every trade and chat response (B3) - Pass LLM_MOCK through compose; document E2E run commands (B4) - Require async litellm.acompletion with a timeout, mapped to 502 (B5) - Make .env optional in compose (required: false) (B6) Co-Authored-By: Claude Opus 5.5 --- planning/PLAN.md | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/planning/PLAN.md b/planning/PLAN.md index c6cc69ee0..696c70781 100644 --- a/planning/PLAN.md +++ b/planning/PLAN.md @@ -147,7 +147,7 @@ DB_PATH= - If `MASSIVE_API_KEY` is absent or empty → backend uses the built-in market simulator - If `LLM_MOCK=true` → backend returns deterministic mock LLM responses (see Section 9) - If `OPENROUTER_API_KEY` is missing and `LLM_MOCK` is not `true` → the app starts normally; `POST /api/chat` returns `503` with `{"detail": "AI chat is not configured. Set OPENROUTER_API_KEY in .env (or LLM_MOCK=true)."}` and the chat panel shows that message. No silent fallback to mock. -- **The backend reads configuration from environment variables only.** In Docker they are supplied via `env_file: .env` in `docker-compose.yml`. For local development, the backend calls `python-dotenv`'s `load_dotenv()` on the project-root `.env` at startup; this is a no-op in the container where no `.env` file exists. +- **The backend reads configuration from environment variables only.** In Docker they are supplied via an optional `env_file` (`.env`, `required: false`) plus an explicit `LLM_MOCK` passthrough in `docker-compose.yml` (§12). For local development, the backend calls `python-dotenv`'s `load_dotenv()` on the project-root `.env` at startup; this is a no-op in the container where no `.env` file exists. --- @@ -288,15 +288,28 @@ All tables include a `user_id` column defaulting to `"default"`. This is hardcod These rules apply identically to manual trades (`POST /api/portfolio/trade`) and LLM trades. -- **Ticker**: normalized and validated per Section 6. Trading a ticker that is not on the watchlist is allowed; it is **automatically added to the watchlist** first (so it starts being tracked and appears in the UI). +- **Ticker**: normalized and validated per Section 6. **Buying** a ticker that is not on the watchlist is allowed; it is **automatically added to the watchlist** first (so it starts being tracked and appears in the UI). Sells never touch the watchlist: a sell requires a held position, and held tickers are already tracked (§6), so fully selling an off-watchlist position untracks it rather than re-adding it. - **Quantity**: must be a finite number `> 0`. No minimum lot size. Fractional shares allowed. -- **Price**: the current cache price. If there is no price yet (`None`), the trade is rejected with `409` `{"detail": "No price available for XYZ yet. Try again shortly."}` and nothing is written. +- **Price**: the current cache price, **read once** and used both for validation and as the recorded trade price. If there is no price yet (`None`), the trade is rejected with `409` `{"detail": "No price available for XYZ yet. Try again shortly."}` and no trade, position, cash, or snapshot rows are written. - **Buy**: requires `quantity × price ≤ cash` (with a `1e-9` tolerance), else `400` `"Insufficient cash"`. Updates `avg_cost` as a weighted average: `(old_qty × old_avg + qty × price) / (old_qty + qty)`. - **Sell**: requires `quantity ≤ held quantity` (with a `1e-9` tolerance), else `400` `"Insufficient shares"`. `avg_cost` is unchanged. If the remaining quantity is `< 1e-9`, the position row is deleted. Selling a ticker you don't hold is `400`. - **Realized P&L** is not tracked (out of scope). Unrealized P&L = `(current_price − avg_cost) × quantity`. - **Rounding**: store full-precision floats; round only for display (prices and money to 2 dp, quantities to up to 4 dp). - A successful trade inserts a `trades` row and records a portfolio snapshot. +### Trade Execution Order + +Every trade (manual or LLM) runs these steps in order. The async `source.add_ticker` call can't happen inside the SQLite transaction, so the watchlist auto-add is a separate step that comes before it: + +1. **Validate input**: normalize the ticker, check quantity and side. On failure, return `400` and write nothing. +2. **Auto-add (buys only)**: if the ticker is not on the watchlist, insert the watchlist row and `await source.add_ticker(ticker)`. **This persists even if the trade later fails** (409, insufficient cash, etc.); the user simply ends up watching the ticker. In simulator mode a price is available immediately. In Massive mode the trade typically returns `409` until the next poll. +3. **Read the price** once from the cache. If it is `None`, return `409`. +4. **Transaction**: `BEGIN IMMEDIATE` → read cash and position → validate (insufficient cash or shares → rollback, `400`) → upsert/delete the position → insert the trade → update cash → commit. +5. **Snapshot**: record a portfolio snapshot. +6. **Untrack**: if the position was closed and the ticker is not on the watchlist, `await source.remove_ticker(ticker)`. + +In chat, each action runs this sequence independently, so a failed watchlist change has no effect on a following trade. A buy still auto-adds its own ticker. + --- ## 9. API Endpoints @@ -391,7 +404,7 @@ When the user sends a chat message, the backend: 1. Loads the user's current portfolio context (cash, positions with P&L, watchlist with live prices, total portfolio value) 2. Loads the **last 20** messages from `chat_messages`. For assistant messages with actions, a compact summary of the action results is appended to the content (e.g. `[Actions: buy 10 AAPL @ 190.10 executed; sell 5 TSLA failed: Insufficient shares]`) so the LLM sees the outcome of its previous actions. 3. Constructs a prompt with a system message, portfolio context, conversation history, and the user's new message -4. Calls the LLM via LiteLLM → OpenRouter, requesting structured output, using the cerebras-inference skill +4. Calls the LLM via LiteLLM → OpenRouter, requesting structured output, using the cerebras-inference skill. The call **must be async**: `await litellm.acompletion(..., timeout=30)`. The skill's examples use synchronous `litellm.completion`, which would block the event loop and freeze SSE, the simulator, and all other requests for the whole call. A timeout or any other LLM exception maps to `502`. 5. Parses the complete structured JSON response 6. Executes watchlist changes, then trades, **sequentially and independently**: each one is validated and applied on its own (Section 8). A failure doesn't stop the others; each item gets its own `status` and `error`. 7. Stores the user message and the assistant message (with action results in `actions`) in `chat_messages` @@ -492,7 +505,7 @@ The frontend is a single-page application with a dense, terminal-inspired layout ### Data Flow - On load: `GET /api/portfolio`, `GET /api/watchlist`, `GET /api/portfolio/history`, `GET /api/chat/history`, and open the SSE stream. -- After a trade or chat response: replace portfolio state with the `portfolio` object returned in the response (no follow-up GET). After a chat response with watchlist changes, re-fetch `GET /api/watchlist`. +- After a trade or chat response: replace portfolio state with the `portfolio` object returned in the response (no follow-up GET). After **every successful trade and every chat response**, re-fetch `GET /api/watchlist`, because buys can auto-add tickers (§8) even when the chat response has no `watchlist_changes`. - **Live total value** is computed on the frontend: `cash + Σ(quantity × latest SSE price)` (falling back to the server's `current_price`/`avg_cost` when no SSE price is known). No backend push is needed. - **Price history buffer**: the frontend keeps up to **500** points per ticker from the SSE stream (ring buffer, dropping the oldest). The main chart uses the whole buffer; sparklines use the last 100 points. @@ -536,12 +549,19 @@ services: image: finally container_name: finally ports: ["8000:8000"] - env_file: .env + env_file: + - path: .env + required: false # app must start without .env (§5); needs Compose ≥ 2.24 + environment: + - LLM_MOCK=${LLM_MOCK:-false} # lets `LLM_MOCK=true docker compose up` work for E2E volumes: ["finally-data:/app/db"] volumes: finally-data: ``` +- `.env` is optional. Without it the app starts with the simulator, and chat returns `503` (§5). +- `LLM_MOCK` is passed through explicitly because `env_file` only reads the file and never sees shell variables. Compose interpolation reads both the shell and `.env`, so setting `LLM_MOCK=true` in either place works. `environment` takes precedence over `env_file`. + - Start: `docker compose up -d` (add `--build` to rebuild). Works on macOS, Linux, and Windows. - Stop: `docker compose down` — the named volume `finally-data` (and the database) persists. `docker compose down -v` resets all data. @@ -581,7 +601,15 @@ The container is designed to deploy to AWS App Runner, Render, or any container ### E2E Tests (in `test/`) -**Infrastructure**: Playwright runs **on the host** (`npx playwright install` locally or in CI) against the app started with `docker compose up -d` and `LLM_MOCK=true`. No separate Playwright container. +**Infrastructure**: Playwright runs **on the host** (`npx playwright install` locally or in CI) against the app started fresh in mock mode: + +```bash +docker compose down -v # empty volume → seeded DB, $10k cash +LLM_MOCK=true docker compose up -d --build +npx playwright test +``` + +No separate Playwright container. The `down -v` matters because "Fresh start" scenarios assume a newly seeded database. **Key Scenarios**: From 0b8bcfd10e7ddd549b2dbbb27752d3a9814148fb Mon Sep 17 00:00:00 2001 From: Aidin Teymouri Date: Sat, 10 Oct 2026 12:38:16 +1000 Subject: [PATCH 3/4] Add README and fix independent-reviewer Stop hook Move hooks/ to the plugin root so it loads, and add a 120s timeout. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 85 ++++++++++--------- .../.claude-plugin/plugin.json | 11 +++ independent-reviewer/hooks/hooks.json | 15 ++++ 3 files changed, 70 insertions(+), 41 deletions(-) create mode 100644 independent-reviewer/.claude-plugin/plugin.json create mode 100644 independent-reviewer/hooks/hooks.json diff --git a/README.md b/README.md index 3f2582ae2..c7ba8e79a 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,65 @@ # FinAlly — AI Trading Workstation -A visually stunning AI-powered trading workstation that streams live market data, simulates portfolio trading, and integrates an LLM chat assistant that can analyze positions and execute trades via natural language. +FinAlly (Finance Ally) is a Bloomberg-style trading terminal with an AI copilot. It streams live prices, lets you trade a simulated $10,000 portfolio, and includes an LLM chat assistant that can analyze positions and execute trades or manage your watchlist through natural language. -Built entirely by coding agents as a capstone project for an agentic AI coding course. +Built entirely by coding agents as the capstone of an agentic AI coding course. -## Features +## Status -- **Live price streaming** via SSE with green/red flash animations -- **Simulated portfolio** — $10k virtual cash, market orders, instant fills -- **Portfolio visualizations** — heatmap (treemap), P&L chart, positions table -- **AI chat assistant** — analyzes holdings, suggests and auto-executes trades -- **Watchlist management** — track tickers manually or via AI -- **Dark terminal aesthetic** — Bloomberg-inspired, data-dense layout +| Component | State | +| --- | --- | +| Market data (simulator + Massive client, price cache) | Done — `backend/app/market/` | +| Database, trading logic, REST/SSE API, LLM chat | Planned | +| Next.js frontend | Planned | +| Docker packaging, start scripts, E2E tests | Planned | -## Architecture +Full spec: [`planning/PLAN.md`](planning/PLAN.md). Market data design: [`planning/MARKET_DATA_SUMMARY.md`](planning/MARKET_DATA_SUMMARY.md). -Single Docker container serving everything on port 8000: +## Features (target) -- **Frontend**: Next.js (static export) with TypeScript and Tailwind CSS -- **Backend**: FastAPI (Python/uv) with SSE streaming -- **Database**: SQLite with lazy initialization -- **AI**: LiteLLM → OpenRouter (Cerebras inference) with structured outputs -- **Market data**: Built-in GBM simulator (default) or Massive API (optional) +- Live watchlist with price flashes and sparklines +- Market-order trading, fractional shares, no fees +- Portfolio heatmap, positions table, P&L chart +- AI chat that auto-executes trades and watchlist changes +- Single container, single port, no login -## Quick Start +## Architecture -```bash -# Clone and configure -cp .env.example .env -# Add your OPENROUTER_API_KEY to .env +- **Backend:** FastAPI (Python, `uv`), SQLite, Server-Sent Events +- **Frontend:** Next.js (TypeScript), static export served by FastAPI +- **AI:** LiteLLM → OpenRouter (Cerebras) with structured outputs +- **Market data:** built-in simulator by default; real data via the Massive API if a key is set -# Run with Docker -docker build -t finally . -docker run -v finally-data:/app/db -p 8000:8000 --env-file .env finally +## Quick start (once complete) -# Open http://localhost:8000 +```bash +cp .env.example .env # add OPENROUTER_API_KEY for chat +docker compose up -d # open http://localhost:8000 +docker compose down # stop; data persists (`down -v` resets it) ``` -## Environment Variables +The app starts without a `.env`; only chat is disabled. -| Variable | Required | Description | -|---|---|---| -| `OPENROUTER_API_KEY` | Yes | OpenRouter API key for AI chat | -| `MASSIVE_API_KEY` | No | Massive (Polygon.io) key for real market data; omit to use simulator | -| `LLM_MOCK` | No | Set `true` for deterministic mock LLM responses (testing) | +## Configuration -## Project Structure +| Variable | Purpose | +| --- | --- | +| `OPENROUTER_API_KEY` | Enables AI chat | +| `MASSIVE_API_KEY` | Optional. Real market data instead of the simulator | +| `LLM_MOCK` | `true` for deterministic mock LLM replies (testing) | +| `DB_PATH` | SQLite file location | -``` -finally/ -├── frontend/ # Next.js static export -├── backend/ # FastAPI uv project -├── planning/ # Project documentation and agent contracts -├── test/ # Playwright E2E tests -├── db/ # SQLite volume mount (runtime) -└── scripts/ # Start/stop helpers +On the Massive free tier (5 calls/min) prices update every 15 seconds, so the UI looks fairly static. The simulator is recommended for most users. + +## Development + +```bash +cd backend +uv sync +uv run market_data_demo.py # market data demo +uv run pytest ``` ## License -See [LICENSE](LICENSE). +See [`LICENSE`](LICENSE). diff --git a/independent-reviewer/.claude-plugin/plugin.json b/independent-reviewer/.claude-plugin/plugin.json new file mode 100644 index 000000000..571d1cd02 --- /dev/null +++ b/independent-reviewer/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "independent-reviewer", + "description": "A plugin that reviews code changes and generates a review report.", + "version": "1.0.0", + "author": { + "name": "Aidin Teymouri", + "email": "aidintv@gmail.com" + }, + "license": "MIT", + "keywords": ["code review", "automation", "plugin"] +} diff --git a/independent-reviewer/hooks/hooks.json b/independent-reviewer/hooks/hooks.json new file mode 100644 index 000000000..41fe60d1f --- /dev/null +++ b/independent-reviewer/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "codex exec \"Review changes since last commit and write results to a file named planning/REVIEW.md\"", + "timeout": 120 + } + ] + } + ] + } +} From c7963555251f3d23ba3e5821702c7108f1d41b92 Mon Sep 17 00:00:00 2001 From: Aidin Teymouri Date: Sat, 10 Oct 2026 12:38:34 +1000 Subject: [PATCH 4/4] Add plugin marketplace manifest Co-Authored-By: Claude Sonnet 5.5 --- .claude-plugin/marketplace.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .claude-plugin/marketplace.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..5365f42c8 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,21 @@ +{ + "name": "aidin-tools", + "description": "A plugin that reviews code changes and generates a review report.", + "version": "1.0.0", + "author": "Aidin Teymouri", + "license": "MIT", + "owner": { + "name": "Aidin Teymouri", + "email": "aidintv@gmail.com" + }, + "plugins": [ + { + "name": "independent-reviewer", + "source": "./independent-reviewer", + "author": { + "name": "Aidin Teymouri", + "email": "aidintv@gmail.com" + } + } + ] +}