From 5b439553cdf5e7db2a650de6b75b4d51659fd146 Mon Sep 17 00:00:00 2001 From: Benjamin Torres Alvarez Date: Wed, 21 Oct 2026 23:28:42 -0500 Subject: [PATCH 1/4] Finally App - Initial version --- .claude-plugin/marketplace.json | 18 ++ .claude/agents/change-reviewer.md | 10 + .claude/agents/codex-reviewer.md | 9 + .claude/commands/doc-review.md | 1 + .claude/settings.json | 4 +- README.md | 85 +++--- .../.claude-plugin/plugin.json | 5 + independent-reviewer/hooks/hooks.json | 14 + planning/PLAN.md | 260 +++++++++++++++--- planning/REVIEW.md | 15 + 10 files changed, 343 insertions(+), 78 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude/agents/change-reviewer.md create mode 100644 .claude/agents/codex-reviewer.md create mode 100644 .claude/commands/doc-review.md create mode 100644 independent-reviewer/.claude-plugin/plugin.json create mode 100644 independent-reviewer/hooks/hooks.json create mode 100644 planning/REVIEW.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..a2273b236 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,18 @@ +{ + "name": "my-custom-plugin", + "owner": { + "name": "Ben", + "email": "btorresal@gmail.com" + }, + "plugins": [ + { + "name": "independent-reviewer", + "source": "./independent-reviewer", + "description": "Carry out an independent review of all changes since last commit", + "version": "1.0.0", + "author": { + "name": "Ben" + } + } + ] +} diff --git a/.claude/agents/change-reviewer.md b/.claude/agents/change-reviewer.md new file mode 100644 index 000000000..6f828aafb --- /dev/null +++ b/.claude/agents/change-reviewer.md @@ -0,0 +1,10 @@ +--- +name: change-reviewer +description: Carry out a comprehensive review of all changes since the last commit +--- +This subagent review all changes since the last commit using shell commands. +IMPORTANT: You should not review the changes yourself, but rather, you should run the following shell command to kick of codex - codex is a separate AI Agent that will carry out independent review. +Run this shell command: +`codex exec "Please review all changes since the last commit and write feedback to planning/REVIEW.md"` +This will run the review process and save the results. +Do not review yourself. \ No newline at end of file diff --git a/.claude/agents/codex-reviewer.md b/.claude/agents/codex-reviewer.md new file mode 100644 index 000000000..9fcaef6e1 --- /dev/null +++ b/.claude/agents/codex-reviewer.md @@ -0,0 +1,9 @@ +--- +name: codex-reviewer +description: Carry out a comprehensive review of PLAN.md when requested using codex +--- +You are using different AI Agent to carry out a review of the document: planning/PLAN.md. +You MUST execute the following shell command to carry out the review - do not review yourself: +`codex exec "Please review the file planning/PLAN.md and write your feedback to planning/REVIEW.md"` +This will run the review process and save the results. +Do not review yourself. \ No newline at end of file diff --git a/.claude/commands/doc-review.md b/.claude/commands/doc-review.md new file mode 100644 index 000000000..bfc241424 --- /dev/null +++ b/.claude/commands/doc-review.md @@ -0,0 +1 @@ +Review the documentation file in the planning folder $ARGUMENTS and add questions, clarifications or feedback tio a new section at the end, along with any opportunities to simplify \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json index aa06f43dc..cb32f5d88 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,5 @@ { "enabledPlugins": { - "frontend-design@claude-plugins-official": true, - "context7@claude-plugins-official": true, - "playwright@claude-plugins-official": true + "playwright@anthropic-plugin-directory": true } } diff --git a/README.md b/README.md index 3f2582ae2..00475c84a 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,77 @@ # 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. +An AI-powered trading workstation that streams live market data, lets you trade a simulated $10,000 portfolio, and includes an LLM chat assistant that can analyze your positions and execute trades for you. Think Bloomberg terminal with an AI copilot. -Built entirely by coding agents as a capstone project for an agentic AI coding course. +Built entirely by coding agents as the capstone project for an agentic AI coding course. Agents coordinate through the docs in [`planning/`](planning/); the full spec is [`planning/PLAN.md`](planning/PLAN.md). ## Features -- **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 +- **Live prices** streamed over SSE, with green/red flash animations and sparklines +- **Simulated trading**: market orders, instant fills, no fees +- **Portfolio views**: positions table, P&L heatmap (treemap), and portfolio value chart +- **AI chat**: ask about your portfolio, and have the assistant place trades and edit your watchlist +- **Watchlist**: add or remove tickers by hand or through chat ## Architecture -Single Docker container serving everything on port 8000: +Everything runs in a single Docker container on port 8000: -- **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) +| Layer | Tech | +|---|---| +| Frontend | Next.js + TypeScript + Tailwind, built as a static export | +| Backend | FastAPI (Python, managed with `uv`), with SSE streaming | +| Database | SQLite, created and seeded automatically on startup | +| AI | LiteLLM → OpenRouter (Cerebras inference) with structured outputs | +| Market data | Built-in GBM simulator (default) or the Massive/Polygon.io API | + +## Status + +The market data subsystem is complete (see [`planning/MARKET_DATA_SUMMARY.md`](planning/MARKET_DATA_SUMMARY.md)). The rest of the platform (portfolio, chat, frontend, Docker) is in progress. ## Quick Start ```bash -# Clone and configure -cp .env.example .env -# Add your OPENROUTER_API_KEY to .env - -# Run with Docker +# optional: create .env in the project root with any keys below docker build -t finally . docker run -v finally-data:/app/db -p 8000:8000 --env-file .env finally - -# Open http://localhost:8000 +# open http://localhost:8000 ``` +The start/stop scripts in `scripts/` (macOS/Linux and Windows) wrap these commands once they are added. + ## Environment Variables -| 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) | +All are optional. Without any keys the app runs on the simulator, and chat replies that the AI is not configured. + +| Variable | Purpose | +|---|---| +| `OPENROUTER_API_KEY` | Enables the AI chat assistant | +| `MASSIVE_API_KEY` | Use real market data instead of the simulator | +| `LLM_MOCK` | `true` for deterministic mock chat responses (testing) | +| `DB_PATH` | SQLite file location (default `db/finally.db`) | + +## Development + +Backend: + +```bash +cd backend +uv sync --dev +uv run pytest +uv run ruff check . +``` + +To watch the market simulator in your terminal: `uv run market_data_demo.py`. -## Project Structure +## Project Layout ``` -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 +backend/ FastAPI app (uv project): market data, API, DB, LLM +frontend/ Next.js app (static export) +planning/ Specs and shared docs for the agents +scripts/ Start/stop scripts for Docker +test/ Playwright end-to-end tests +db/ Runtime volume for the SQLite database ``` ## License diff --git a/independent-reviewer/.claude-plugin/plugin.json b/independent-reviewer/.claude-plugin/plugin.json new file mode 100644 index 000000000..e8f2e4481 --- /dev/null +++ b/independent-reviewer/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "independent-reviewer", + "description": "Carry out an independent review of all changes since last commit", + "version": "1.0.0" +} \ No newline at end of file diff --git a/independent-reviewer/hooks/hooks.json b/independent-reviewer/hooks/hooks.json new file mode 100644 index 000000000..379412120 --- /dev/null +++ b/independent-reviewer/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "codex exec \"Review changes since last commit and write results to a file named planning/REVIEW.md\"" + } + ] + } + ] + } +} diff --git a/planning/PLAN.md b/planning/PLAN.md index bc1811b33..5a1cd0bfb 100644 --- a/planning/PLAN.md +++ b/planning/PLAN.md @@ -23,7 +23,7 @@ The user runs a single Docker command (or a provided start script). A browser op - **Watch prices stream** — prices flash green (uptick) or red (downtick) with subtle CSS animations that fade - **View sparkline mini-charts** — price action beside each ticker in the watchlist, accumulated on the frontend from the SSE stream since page load (sparklines fill in progressively) -- **Click a ticker** to see a larger detailed chart in the main chart area +- **Click a ticker** to see a larger detailed chart in the main chart area (also accumulated from the SSE stream since page load — there is no historical price endpoint) - **Buy and sell shares** — market orders only, instant fill at current price, no fees, no confirmation dialog - **Monitor their portfolio** — a heatmap (treemap) showing positions sized by weight and colored by P&L, plus a P&L chart tracking total portfolio value over time - **View a positions table** — ticker, quantity, average cost, current price, unrealized P&L, % change @@ -64,11 +64,13 @@ The user runs a single Docker command (or a provided start script). A browser op - **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 at `db/finally.db` (path configurable via `DB_PATH`), volume-mounted for persistence - **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 +The only background task is the market data source (simulator loop or Massive poller), started and stopped in the FastAPI lifespan hook. Portfolio snapshots are recorded on demand, not by a separate loop (see Section 7). + ### Why These Choices | Decision | Rationale | @@ -88,7 +90,9 @@ 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/ +│ ├── market/ # Market data subsystem (complete) +│ └── db/ # Schema definitions, seed data, init logic (Python package) ├── planning/ # Project-wide documentation for agents │ ├── PLAN.md # This document │ └── ... # Additional agent reference docs @@ -110,7 +114,7 @@ finally/ - **`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. +- **`backend/app/db/`** contains schema SQL definitions and seed logic, as part of the `app` Python package. It deliberately does **not** live at `backend/db/`: in the container, the backend is copied to `/app` and the data volume is mounted at `/app/db`, so a `backend/db/` folder would be hidden by the volume. - **`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. - **`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. @@ -121,7 +125,8 @@ finally/ ## 5. Environment Variables ```bash -# Required: OpenRouter API key for LLM chat functionality +# Optional: OpenRouter API key for LLM chat functionality. +# Without it the app runs normally; only chat returns a friendly "AI unavailable" message. OPENROUTER_API_KEY=your-openrouter-api-key-here # Optional: Massive (Polygon.io) API key for real market data @@ -130,19 +135,26 @@ MASSIVE_API_KEY= # Optional: Set to "true" for deterministic mock LLM responses (testing) LLM_MOCK=false + +# Optional: SQLite file location. Default: db/finally.db relative to the project root. +# In the container this is set to /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 (for E2E tests); no API key needed +- If `OPENROUTER_API_KEY` is absent and `LLM_MOCK` is not `true` → the app starts normally; `POST /api/chat` returns a normal chat response whose message explains that the AI assistant is not configured +- **Loading**: in Docker, variables come from `--env-file .env` (the container never reads a `.env` file itself). For local development, the backend calls `python-dotenv`'s `load_dotenv()` at startup, which loads the project-root `.env` if present and is a no-op otherwise. --- ## 6. Market Data +> The market data subsystem is built — see `planning/MARKET_DATA_SUMMARY.md`. Items marked **(change required)** below are small additions to that completed code. + ### Two Implementations, One Interface 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. @@ -155,45 +167,88 @@ Both the simulator and the Massive client implement the same abstract interface. - 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 +- **Fixed ticker universe (change required)**: the simulator only supports a fixed set of 50 well-known US tickers, each with a seed price, GBM parameters, and a correlation group in `seed_prices.py`. This replaces today's behavior of giving unknown tickers a random $50–$300 seed price, so typos like `APPL` are rejected rather than becoming tradable stocks. The universe: + - Default watchlist (10): AAPL, GOOGL, MSFT, AMZN, TSLA, NVDA, META, JPM, V, NFLX + - Tech (12): AMD, INTC, ORCL, CRM, ADBE, CSCO, AVGO, QCOM, IBM, UBER, SHOP, PLTR + - Finance (8): BAC, WFC, GS, MS, C, MA, PYPL, AXP + - Consumer (10): WMT, COST, HD, MCD, NKE, SBUX, KO, PEP, DIS, PG + - Healthcare (5): JNJ, UNH, PFE, LLY, MRK + - Energy & industrials (5): XOM, CVX, BA, CAT, GE ### Massive API (Optional) - REST API polling (not WebSocket) — simpler, works on all tiers -- Polls for the union of all watched tickers on a configurable interval +- Polls for the union of all tracked 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 +- Tickers are not validated up front. A ticker for which Massive returns no data simply never gets a price in the cache; the UI shows "—" for it and trades on it are rejected (see Section 8). + +### Ticker Validation + +Every ticker entering the system (watchlist add, manual trade, LLM action) is uppercased and trimmed, then validated by the active market data source: +- **Simulator**: must be in the fixed universe +- **Massive**: must match `^[A-Z][A-Z.]{0,9}$` + +The market data source exposes this as a method (e.g. `is_supported(ticker) -> bool`) **(change required)**, so validation logic lives in one place. + +### Tracked Tickers + +The market data source tracks the **union of watchlist tickers and tickers with an open position**. Removing a ticker from the watchlist only stops tracking it if the user holds no shares of it; likewise, selling a full position only stops tracking the ticker if it is not on the watchlist. This keeps portfolio valuation live for every holding. + +On startup, the backend initializes the database first, then starts the market data source with this union. ### Shared Price Cache - 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 +- The cache holds the latest price, previous price, session open 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 +### Daily Change (change required) + +`previous_price` is the previous tick (~500ms ago), so it cannot drive a "daily change" figure. Each `PriceUpdate` therefore also carries a `session_open` price: +- **Simulator**: the ticker's first price after the backend started tracking it (its seed price) +- **Massive**: the previous day's close from the snapshot response (`prevDay.c`); falls back to the first polled price if absent + +`PriceUpdate.to_dict()` adds `session_open` and `daily_change_percent = (price - session_open) / session_open * 100`. The watchlist's "daily change %" column uses `daily_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 +- The first message is `retry: 1000` (browser reconnects after 1s if the connection drops) +- Roughly every 500ms, if any price changed, the server sends **one event containing all tracked tickers**, keyed by ticker: + +``` +data: {"AAPL": {"ticker": "AAPL", "price": 190.52, "previous_price": 190.48, "session_open": 190.00, + "timestamp": 1760352000.123, "change": 0.04, "change_percent": 0.021, + "daily_change_percent": 0.2737, "direction": "up"}, + "GOOGL": {...}, ...} +``` + +- Field meanings: `change`/`change_percent`/`direction` are versus the previous tick (drive the flash animation); `daily_change_percent` is versus `session_open` (drives the watchlist column); `timestamp` is Unix seconds. +- Events use the default (unnamed) event type, so the frontend uses `EventSource.onmessage`. - Client handles reconnection automatically (EventSource has built-in retry) --- ## 7. Database -### SQLite with Lazy Initialization +### SQLite with Startup Initialization -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: +The backend initializes the database in the FastAPI lifespan (startup) hook, before the market data source starts — because the market data source needs the watchlist tickers at startup. If the SQLite 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 +- No per-request "is the database initialized?" checks - Fresh Docker volumes start with a clean, seeded database automatically +The file location comes from `DB_PATH` (see Section 5). The backend creates the parent directory if it is missing. + ### 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. +All tables except `users_profile` include a `user_id` column defaulting to `"default"`; `users_profile` is keyed by its `id` column, which holds the same user id. 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"`) @@ -211,26 +266,34 @@ All tables include a `user_id` column defaulting to `"default"`. This is hardcod - `id` TEXT PRIMARY KEY (UUID) - `user_id` TEXT (default: `"default"`) - `ticker` TEXT -- `quantity` REAL (fractional shares supported) +- `quantity` INTEGER (whole shares only) - `avg_cost` REAL - `updated_at` TEXT (ISO timestamp) - UNIQUE constraint on `(user_id, ticker)` +- A position sold down to zero is **deleted** — zero-quantity rows never exist, so they never appear in the positions table or heatmap **trades** — Trade history (append-only log) - `id` TEXT PRIMARY KEY (UUID) - `user_id` TEXT (default: `"default"`) - `ticker` TEXT - `side` TEXT (`"buy"` or `"sell"`) -- `quantity` REAL (fractional shares supported) +- `quantity` INTEGER (whole shares only) - `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) - `id` TEXT PRIMARY KEY (UUID) - `user_id` TEXT (default: `"default"`) - `total_value` REAL - `recorded_at` TEXT (ISO timestamp) +Snapshots are recorded without a dedicated background loop: +- once at startup (after the market data source has produced its first prices), so the P&L chart has a starting point on first launch +- immediately after each successful trade +- on `GET /api/portfolio/history`, if the most recent snapshot is more than 30 seconds old + +The frontend polls `GET /api/portfolio/history` every 30 seconds, which produces a ~30s cadence while the app is open. No snapshots are recorded while no browser is open, which is acceptable for this app. + **chat_messages** — Conversation history with LLM - `id` TEXT PRIMARY KEY (UUID) - `user_id` TEXT (default: `"default"`) @@ -251,7 +314,7 @@ All tables include a `user_id` column defaulting to `"default"`. This is hardcod ### Market Data | Method | Path | Description | |--------|------|-------------| -| GET | `/api/stream/prices` | SSE stream of live price updates | +| GET | `/api/stream/prices` | SSE stream of live price updates (format in Section 6) | ### Portfolio | Method | Path | Description | @@ -271,11 +334,100 @@ All tables include a `user_id` column defaulting to `"default"`. This is hardcod | Method | Path | Description | |--------|------|-------------| | POST | `/api/chat` | Send a message, receive complete JSON response (message + executed actions) | +| GET | `/api/chat/history` | Stored conversation, oldest first, so the chat panel survives a page refresh | ### System | Method | Path | Description | |--------|------|-------------| -| GET | `/api/health` | Health check (for Docker/deployment) | +| GET | `/api/health` | Health check (for Docker/deployment): `{"status": "ok"}` | + +### Conventions + +- All request and response bodies are JSON. Timestamps in REST responses are ISO 8601 strings; SSE timestamps are Unix seconds (as already implemented). +- Tickers in requests are case-insensitive; the backend uppercases and trims them, and all responses use uppercase. +- Money values are returned unrounded as floats; the frontend formats them for display. +- **Errors** use FastAPI's default shape: `{"detail": "Human-readable message"}`, with HTTP `400` for business-rule failures (insufficient cash, insufficient shares, no price available, unsupported ticker, invalid quantity), `404` for removing a ticker that is not on the watchlist, and `422` for malformed request bodies (FastAPI validation). + +### Response Shapes + +**`GET /api/portfolio`** +```json +{ + "cash_balance": 8095.0, + "positions_value": 1905.2, + "total_value": 10000.2, + "unrealized_pnl": 0.2, + "positions": [ + { + "ticker": "AAPL", + "quantity": 10, + "avg_cost": 190.5, + "current_price": 190.52, + "market_value": 1905.2, + "unrealized_pnl": 0.2, + "unrealized_pnl_percent": 0.0105 + } + ] +} +``` +If a position's ticker has no cached price yet, `current_price` falls back to `avg_cost`. + +**`POST /api/portfolio/trade`** — request `{"ticker": "AAPL", "quantity": 10, "side": "buy"}`, response: +```json +{ + "trade": {"id": "uuid", "ticker": "AAPL", "side": "buy", "quantity": 10, "price": 190.5, "executed_at": "2026-10-13T14:00:00Z"}, + "cash_balance": 8095.0 +} +``` +The frontend refetches `/api/portfolio` after a trade rather than relying on the trade response for portfolio state. + +**`GET /api/portfolio/history`** — the most recent 500 snapshots, oldest first: +```json +{"snapshots": [{"total_value": 10000.0, "recorded_at": "2026-10-13T14:00:00Z"}]} +``` + +**`GET /api/watchlist`** — includes a price snapshot so the grid is populated before the first SSE event arrives; price fields are `null` if no price is cached yet: +```json +{"tickers": [{"ticker": "AAPL", "price": 190.52, "daily_change_percent": 0.27, "added_at": "2026-10-13T14:00:00Z"}]} +``` + +**`POST /api/watchlist`** — request `{"ticker": "PYPL"}`; returns `201` with the new entry (same shape as an item above). Adding a ticker already on the watchlist returns `200` with the existing entry (idempotent). Unsupported tickers return `400`. + +**`DELETE /api/watchlist/{ticker}`** — returns `204`. Removing a ticker not on the watchlist returns `404`. + +**`POST /api/chat`** — request `{"message": "Buy 10 AAPL"}`, response: +```json +{ + "message": "Done — bought 10 AAPL at $190.50.", + "actions": { + "trades": [ + {"ticker": "AAPL", "side": "buy", "quantity": 10, "status": "executed", "price": 190.5, "error": null} + ], + "watchlist_changes": [ + {"ticker": "PYPL", "action": "add", "status": "executed", "error": null} + ] + } +} +``` +`status` is `"executed"` or `"failed"`; on failure `price` is `null` and `error` holds the same message the REST endpoint would return. `actions` has empty arrays when the assistant took no action. This `actions` object is also what is stored in `chat_messages.actions`. + +**`GET /api/chat/history`** — the most recent 100 messages, oldest first: +```json +{"messages": [{"id": "uuid", "role": "assistant", "content": "...", "actions": {...}, "created_at": "2026-10-13T14:00:00Z"}]} +``` +`actions` is `null` for user messages. + +### Trade Execution Rules + +Manual trades (`POST /api/portfolio/trade`) and LLM trades call **the same trade-execution service function** — validation and portfolio math are never duplicated. That function: + +1. Normalizes the ticker and validates it with the market data source (Section 6) +2. Requires `quantity` to be a **positive whole number of shares** (no fractions, no dollar amounts); anything else is rejected with `400` +3. Reads the fill price from the price cache; if the ticker has **no cached price**, the trade is rejected (`"No price available for XYZ"`). A trade never adds its ticker to the watchlist. +4. Buy: requires `cash_balance >= quantity × price`; updates the weighted-average `avg_cost` +5. Sell: requires held quantity ≥ requested quantity; `avg_cost` is unchanged; the position is deleted if the remainder is zero +6. Applies the cash update, position update, and trade-log insert in **one SQLite transaction** +7. Records a portfolio snapshot, and updates the tracked-ticker set (a new position starts tracking its ticker; a closed position stops tracking it unless watched) --- @@ -290,13 +442,15 @@ There is an OPENROUTER_API_KEY in the .env file in the project root. 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** of conversation history from the `chat_messages` table 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 **once** 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. Auto-executes any watchlist changes, then any trades (see Auto-Execution) +7. Stores the user message and the assistant message (with its `actions`) in `chat_messages` +8. Returns the complete JSON response to the frontend (shape in Section 8; no token-by-token streaming — Cerebras inference is fast enough that a loading indicator is sufficient) + +If the LLM call fails or returns JSON that doesn't match the schema, the endpoint still returns `200` with a message like "Sorry, I couldn't process that — please try again." and empty actions. No actions are executed in that case. ### Structured Output Schema @@ -315,8 +469,8 @@ The LLM is instructed to respond with JSON matching this schema: ``` - `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 +- `trades` (optional): Array of trades to auto-execute. Each trade goes through the same trade-execution function as manual trades (Section 8) +- `watchlist_changes` (optional): Array of watchlist modifications; `action` is `"add"` or `"remove"` ### Auto-Execution @@ -325,7 +479,11 @@ Trades specified by the LLM execute automatically — no confirmation dialog. Th - 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. +Execution rules: +- Watchlist changes run first, then trades, each list in the order the LLM returned it +- Each action executes **independently**: if trade #2 fails, trades #1 and #3 still execute. Each individual trade is atomic (Section 8) +- Every action's result (`executed` or `failed` with an error) is reported in the response's `actions` +- There is no second LLM call. If any action fails, the backend appends a short system-generated note to the LLM's message, one line per failure, e.g. `⚠ Sell 50 TSLA failed: insufficient shares (you hold 10)`. This combined text is what is stored and returned as `message`. ### System Prompt Guidance @@ -344,6 +502,16 @@ When `LLM_MOCK=true`, the backend returns deterministic mock responses instead o - Development without an API key - CI/CD pipelines +The mock parses the user's message (case-insensitive) with these rules, and the resulting actions go through the normal auto-execution path: + +| User message matches | Mock returns | +|---|---| +| `buy ` | `message: "Buying ."`, one buy trade | +| `sell ` | `message: "Selling ."`, one sell trade | +| `watch ` | `message: "Adding to your watchlist."`, one watchlist add | +| `unwatch ` | `message: "Removing from your watchlist."`, one watchlist remove | +| anything else | `message: "This is a mock response from FinAlly."`, no actions | + --- ## 10. Frontend Design @@ -352,22 +520,29 @@ When `LLM_MOCK=true`, the backend returns deterministic mock responses instead o 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) -- **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. +- **Watchlist panel** — grid/table of watched tickers with: ticker symbol, current price (flashing green/red on change), daily change % (`daily_change_percent` from SSE), and a sparkline mini-chart (accumulated from SSE since page load). Tickers with no price yet show "—". +- **Main chart area** — larger chart for the currently selected ticker, with at minimum price over time (accumulated from SSE since page load). 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, using data from `GET /api/portfolio/history` (polled every 30 seconds) - **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 (e.g. insufficient cash) are shown inline next to the trade bar. +- **AI chat panel** — docked sidebar that can be collapsed/expanded (no drag-to-resize, no persisted layout). Loads prior conversation from `GET /api/chat/history` on page load. Message input, scrolling conversation history, loading indicator while waiting for LLM response. Trade executions and watchlist changes (including failures) shown inline as confirmations, from the response's `actions`. - **Header** — portfolio total value (updating live), connection status indicator, cash balance +### Data Flow + +- **Live total value** is computed on the frontend: `cash_balance + Σ(position.quantity × latest SSE price)`, using positions and cash from `GET /api/portfolio`. Positions table P&L and the heatmap use the same live prices. +- The frontend refetches `/api/portfolio` and `/api/watchlist` after every manual trade, watchlist change, and chat response that contains actions. +- On page load: fetch `/api/portfolio`, `/api/watchlist`, `/api/portfolio/history`, `/api/chat/history`, and open the SSE connection. + ### Technical Notes - Use `EventSource` for SSE connection to `/api/stream/prices` -- Canvas-based charting library preferred (Lightweight Charts or Recharts) for performance +- **Charts**: Lightweight Charts (canvas-based) for the main price chart and the P&L chart. Sparklines may use Lightweight Charts or a small inline SVG. The heatmap uses Recharts' `Treemap` (SVG is fine for a handful of positions). No other charting libraries. - 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 +- Static export: `next.config` sets `output: 'export'`, and `images.unoptimized: true` if `next/image` is used --- @@ -382,14 +557,15 @@ Stage 1: Node 20 slim Stage 2: Python 3.12 slim - Install uv - - Copy backend/ + - Copy backend/ to /app - uv sync (install Python dependencies from lockfile) - - Copy frontend build output into a static/ directory + - Copy frontend build output into /app/static + - 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. +FastAPI serves the static frontend files and all API routes on port 8000. The static mount (`StaticFiles(directory="static", html=True)` at `/`) must be registered **after** all `/api` routers, so it doesn't shadow them; `html=True` makes `/` serve `index.html`. ### Docker Volume @@ -399,7 +575,7 @@ The SQLite database persists via a named Docker volume: docker run -v finally-data:/app/db -p 8000:8000 --env-file .env finally ``` -The `db/` directory in the project root maps to `/app/db` in the container. The backend writes `finally.db` to this path. +The `db/` directory in the project root maps to `/app/db` in the container. The backend writes `finally.db` to this path. Nothing else in the image lives under `/app/db`, so the volume never hides application files. ### Start/Stop Scripts @@ -428,10 +604,10 @@ The container is designed to deploy to AWS App Runner, Render, or any container ### 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 -- API routes: correct status codes, response shapes, error handling +- Market data: simulator generates valid prices, GBM math is correct, Massive API response parsing works, both implementations conform to the abstract interface, ticker validation, `session_open`/`daily_change_percent` +- Portfolio: trade execution logic, P&L calculations, edge cases (selling more than owned, buying with insufficient cash, selling at a loss, trading a ticker with no price, selling a full position deletes the row, tracked tickers stay live after removing a held ticker from the watchlist) +- LLM: structured output parsing handles all valid schemas, graceful handling of malformed responses, trade validation within chat flow, partial failure of multiple actions, mock-mode keyword rules +- API routes: correct status codes, response shapes, error handling (Section 8) **Frontend (React Testing Library or similar)**: - Component rendering with mock data @@ -446,6 +622,8 @@ The container is designed to deploy to AWS App Runner, Render, or any container **Environment**: Tests run with `LLM_MOCK=true` by default for speed and determinism. +**Determinism**: Simulator prices are random and tick every ~500ms, so tests must not assert against prices read from the screen. Assert against the fill `price` in the `POST /api/portfolio/trade` (or `/api/chat`) response captured via Playwright's `page.waitForResponse`, or assert relationships (cash decreased, position quantity is 10) rather than exact values. + **Key Scenarios**: - Fresh start: default watchlist appears, $10k balance shown, prices are streaming - Add and remove a ticker from the watchlist diff --git a/planning/REVIEW.md b/planning/REVIEW.md new file mode 100644 index 000000000..efd2cb6b2 --- /dev/null +++ b/planning/REVIEW.md @@ -0,0 +1,15 @@ +# Review: Changes Since Last Commit + +## Findings + +### [P1] The documented Docker quick start cannot build the image + +`README.md:35` tells users to run `docker build -t finally .`, but there is no `Dockerfile` in the repository. A clean checkout therefore cannot build the image, so the documented startup path fails before launch. Add the Docker setup or change the quick start to use an available startup path. + +### [P2] The Docker run command requires a `.env` file described as optional + +`README.md:34-36` describes creating `.env` as optional, then always passes `--env-file .env`. Docker exits if that path does not exist; a clean checkout does not contain a tracked `.env`. Omit `--env-file` when no file is present, or make creating the file an explicit prerequisite with a committed example. + +## Scope and checks + +Reviewed tracked diffs and new files since `HEAD`, including README/planning changes and Claude plugin, hook, and reviewer configuration. Confirmed the repository has no `Dockerfile` or `scripts/` directory and no tracked `.env`. `git diff --check HEAD` reported no whitespace errors. From 0ad534e396376d93b9036af32c9f15e0d7232a8f Mon Sep 17 00:00:00 2001 From: dev-app-source <134657426+dev-app-source@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:32:07 -0500 Subject: [PATCH 2/4] "Update Claude PR Assistant workflow" --- .github/workflows/claude.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267f1..6b15fac7a 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -46,5 +46,5 @@ jobs: # Optional: Add claude_args to customize behavior and configuration # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' + # claude_args: '--allowed-tools Bash(gh pr *)' From 9ca19f39a6c62896a91a6292c6d74dec6cccb926 Mon Sep 17 00:00:00 2001 From: dev-app-source <134657426+dev-app-source@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:32:09 -0500 Subject: [PATCH 3/4] "Update Claude Code Review workflow" --- .github/workflows/claude-code-review.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4d..37e66f3fd 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -38,7 +38,8 @@ jobs: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + prompt: '/code-review:code-review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment"' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options From 55880e94f9940b0fdf25f5455644e4784d9a0eed Mon Sep 17 00:00:00 2001 From: Benjamin Torres Alvarez Date: Mon, 26 Oct 2026 23:03:56 -0500 Subject: [PATCH 4/4] Update review of commit 5b43955 Co-Authored-By: Claude Opus 5.5 --- planning/REVIEW.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/planning/REVIEW.md b/planning/REVIEW.md index efd2cb6b2..96509b000 100644 --- a/planning/REVIEW.md +++ b/planning/REVIEW.md @@ -1,15 +1,17 @@ -# Review: Changes Since Last Commit +# Review: Changes Since Last Commit + +Reviewed commit `5b43955` against its parent (`14550e1`), which is the one commit on `main` not yet in `origin/main`. ## Findings -### [P1] The documented Docker quick start cannot build the image +### [P1] Quick Start cannot build the advertised app -`README.md:35` tells users to run `docker build -t finally .`, but there is no `Dockerfile` in the repository. A clean checkout therefore cannot build the image, so the documented startup path fails before launch. Add the Docker setup or change the quick start to use an available startup path. +`README.md` instructs users to run `docker build -t finally .`, but the commit adds no Dockerfile and the repository has none. A clean checkout cannot build the image, so the documented Quick Start stops at its first command. Add the Docker build setup or point the Quick Start to a runnable path. -### [P2] The Docker run command requires a `.env` file described as optional +### [P2] Quick Start requires a `.env` file it calls optional -`README.md:34-36` describes creating `.env` as optional, then always passes `--env-file .env`. Docker exits if that path does not exist; a clean checkout does not contain a tracked `.env`. Omit `--env-file` when no file is present, or make creating the file an explicit prerequisite with a committed example. +The README says creating `.env` is optional, then always passes `--env-file .env` to `docker run`. Docker fails when the file is absent, as it is in a clean checkout. Make that option conditional or document creation of the file as required. ## Scope and checks -Reviewed tracked diffs and new files since `HEAD`, including README/planning changes and Claude plugin, hook, and reviewer configuration. Confirmed the repository has no `Dockerfile` or `scripts/` directory and no tracked `.env`. `git diff --check HEAD` reported no whitespace errors. +Reviewed the commit's README, planning, and Claude plugin/hook configuration changes. Confirmed there is no tracked Dockerfile, `scripts/` directory, or `.env` file. `git diff --check HEAD^ HEAD` reports trailing whitespace in `.claude/agents/codex-reviewer.md` (line 5). No application tests were run; the commit changes documentation and agent configuration, not application code.