Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/agents/reviewer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
name: change-reviewer
description: carry out comprehensive review of all changes since last commit
tools: Read, Grep, Glob, Bash, Write, Edit
model: inherit
---

You are the reviewer agent for the FinAlly project. Your sole job is to review changes since last commit. You only ever write your output to `planning/review.md`.
1 change: 1 addition & 0 deletions .claude/commands/doc-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Review the documentation file in the planning folder called $ARGUMENTS and add questions, clarifications or feedback to a new section at the end, along with any opportunities to simplify
2 changes: 2 additions & 0 deletions .claude/skills/cerebras/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ description: Use this to write code to call an LLM using LiteLLM and OpenRouter

# Calling an LLM via Cerebras

> **Not for FinAlly's chat feature.** `planning/PLAN.md` §9 specifies `openrouter/nvidia/nemotron-3-ultra-550b-a55b:free` with no Cerebras provider routing for FinAlly's AI chat assistant. Do not use this skill (or the `gpt-oss-120b`/Cerebras example below) to implement that feature — follow PLAN.md §9 instead. This skill remains available for other, unrelated Cerebras-routed LLM calls.

These instructions allow you write code to call an LLM with Cerebras specified as the inference provider.
This method uses LiteLLM and OpenRouter.

Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

2 changes: 1 addition & 1 deletion .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 *)'

5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ local_settings.py
db.sqlite3
db.sqlite3-journal

# FinAlly runtime database (see planning/PLAN.md §4)
db/*.db
db/*.db-journal
!db/.gitkeep

# Flask stuff:
instance/
.webassets-cache
Expand Down
153 changes: 115 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,62 +1,139 @@
# FinAlly — AI Trading Workstation
# FinAlly — the Finance Ally

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.
A visually stunning, AI-powered trading workstation: live-streaming market data, a simulated portfolio, and an LLM chat assistant that can analyze your positions and execute trades on your behalf. Think Bloomberg terminal with an AI copilot.

Built entirely by coding agents as a capstone project for an agentic AI coding course.
This is the capstone project for an agentic AI coding course — built entirely by coding agents to demonstrate how orchestrated AI agents can produce a production-quality full-stack application. Agents coordinate through documents in [`planning/`](planning/), most importantly [`planning/PLAN.md`](planning/PLAN.md), the full project specification.

## 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
🚧 **In progress.** The market data subsystem is complete; the rest of the platform (API, database, frontend, LLM chat, Docker packaging) is still being built. See [`planning/MARKET_DATA_SUMMARY.md`](planning/MARKET_DATA_SUMMARY.md) for what's done.

## Architecture
| Component | Status |
|---|---|
| Market data simulator (GBM, SSE-ready) | ✅ Complete |
| Massive (Polygon.io) live data client | ✅ Complete, unused by default |
| FastAPI app, database, portfolio/trade endpoints | ⏳ Not started |
| LLM chat assistant | ⏳ Not started |
| Angular frontend | ⏳ Not started |
| Docker packaging | ⏳ Not started |

Single Docker container serving everything on port 8000:
## Vision

- **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)
When finished, running a single command will open a browser to a live trading terminal: a watchlist of streaming prices, a $10,000 virtual cash balance, portfolio visualizations (heatmap, P&L chart, positions table), and a docked AI chat assistant that can analyze the portfolio and place trades through natural language. Full UX details are in [`planning/PLAN.md`](planning/PLAN.md) §2.

## Quick Start
### Planned features

- **Live price streaming** — 10 default tickers (AAPL, GOOGL, MSFT, AMZN, TSLA, NVDA, META, JPM, V, NFLX) updating over SSE, with green/red flash animations on each tick
- **Sparklines** — mini price-history charts next to each watchlist ticker, accumulated client-side since page load
- **Buy/sell** — market orders only, instant fill at current price, no fees or confirmation dialogs
- **Portfolio heatmap** — treemap sized by position weight, colored by P&L
- **P&L chart** — total portfolio value over time
- **Positions table** — ticker, quantity, avg cost, current price, unrealized P&L, % change
- **AI chat assistant** — "FinAlly", backed by an LLM, that can analyze the portfolio and auto-execute trades or watchlist changes it recommends
- **Dark, data-dense terminal UI** — Bloomberg-inspired, accent yellow `#ecad0a`, blue `#209dd7`, purple `#753991`

### Planned API surface

| Method | Path | Description |
|---|---|---|
| GET | `/api/stream/prices` | SSE stream of live price updates |
| GET | `/api/portfolio` | 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 (for the P&L chart) |
| GET | `/api/watchlist` | Current watchlist with latest prices |
| POST | `/api/watchlist` | Add a ticker: `{ticker}` |
| DELETE | `/api/watchlist/{ticker}` | Remove a ticker |
| POST | `/api/chat` | Send a chat message, get back a response plus any executed actions |
| GET | `/api/health` | Health check |

Full request/response contracts and the SQLite schema (`users_profile`, `watchlist`, `positions`, `trades`, `portfolio_snapshots`, `chat_messages`) are in [`planning/PLAN.md`](planning/PLAN.md) §7–8.

## Architecture (target)

Everything ships in a single Docker container on one port:

```
┌─────────────────────────────────────────────────┐
│ Docker Container (port 8000) │
│ │
│ FastAPI (Python/uv) │
│ ├── /api/* REST endpoints │
│ ├── /api/stream/* SSE streaming │
│ └── /* Static file serving │
│ (Angular build) │
│ │
│ SQLite database (volume-mounted) │
│ Background task: market data polling/sim │
└─────────────────────────────────────────────────┘
```

- **Frontend**: Angular + TypeScript, built to static assets and served by FastAPI
- **Backend**: FastAPI (Python), managed as a `uv` project
- **Database**: SQLite, lazily initialized, volume-mounted at `db/finally.db`
- **Real-time data**: Server-Sent Events (`/api/stream/prices`)
- **AI**: LiteLLM → OpenRouter, structured outputs for chat-driven trades
- **Market data**: simulator by default; real data via Massive API if `MASSIVE_API_KEY` is set

Full rationale for these choices is in [`planning/PLAN.md`](planning/PLAN.md) §3.

## What's built so far: market data

A self-contained market data subsystem lives in `backend/app/market/` — a `PriceCache`, a GBM-based simulator with correlated, per-sector price moves, a Massive/Polygon.io REST client behind the same interface, and an SSE stream factory. It's fully tested (79 tests, 99% coverage overall — every module is 94-100%, `stream.py` included) and has a standalone terminal demo:

```bash
# Clone and configure
cp .env.example .env
# Add your OPENROUTER_API_KEY to .env
cd backend
uv sync --dev
uv run market_data_demo.py
```

This runs a live Rich dashboard of all 10 default tickers with sparklines and an event log — no server, database, or frontend required. See [`backend/README.md`](backend/README.md) and [`planning/MARKET_DATA_SUMMARY.md`](planning/MARKET_DATA_SUMMARY.md) for details.

# Run with Docker
docker build -t finally .
docker run -v finally-data:/app/db -p 8000:8000 --env-file .env finally
Run the backend test suite:

# Open http://localhost:8000
```bash
cd backend
uv run pytest
```

## 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) |
Create a `.env` file at the project root:

## Project Structure
```bash
# Required for AI chat once it's built
OPENROUTER_API_KEY=your-openrouter-api-key-here

# Optional: use real market data instead of the simulator
MASSIVE_API_KEY=

# Optional: deterministic mock LLM responses (for testing)
LLM_MOCK=false
```

## 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 uv project (Python)
│ └── app/market/ # Market data subsystem (complete)
├── frontend/ # Angular project (not yet created)
├── planning/ # Shared spec and docs the agents build from
│ ├── PLAN.md
│ └── MARKET_DATA_SUMMARY.md
├── db/ # SQLite volume mount point (runtime)
└── test/ # Playwright E2E tests (not yet created)
```

See [`planning/PLAN.md`](planning/PLAN.md) §4 for the full target layout and the boundaries between components.

## Testing Strategy (planned)

- **Backend (pytest)** — market data math, trade execution and P&L edge cases, LLM structured-output parsing, API route contracts
- **Frontend (Jasmine/Karma via Angular CLI)** — component rendering, price flash animations, watchlist CRUD, chat rendering
- **E2E (Playwright, in `test/`)** — fresh-start flow, watchlist add/remove, buy/sell, portfolio visualizations, mocked AI chat, SSE reconnection. Runs against a container with `LLM_MOCK=true` for speed and determinism.

Full scenario list is in [`planning/PLAN.md`](planning/PLAN.md) §12.

## License

See [LICENSE](LICENSE).
MIT — see [LICENSE](LICENSE).
2 changes: 1 addition & 1 deletion backend/app/market/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def update(self, ticker: str, price: float, timestamp: float | None = None) -> P
If this is the first update for the ticker, previous_price == price (direction='flat').
"""
with self._lock:
ts = timestamp or time.time()
ts = timestamp if timestamp is not None else time.time()
prev = self._prices.get(ticker)
previous_price = prev.price if prev else price

Expand Down
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dev = [
"pytest-asyncio>=0.24.0",
"pytest-cov>=5.0.0",
"ruff>=0.7.0",
"httpx>=0.27.0",
]

[build-system]
Expand Down
44 changes: 33 additions & 11 deletions backend/tests/market/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,55 +10,75 @@ class TestPriceUpdate:

def test_price_update_creation(self):
"""Test basic PriceUpdate creation."""
update = PriceUpdate(ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0
)
assert update.ticker == "AAPL"
assert update.price == 190.50
assert update.previous_price == 190.00
assert update.timestamp == 1234567890.0

def test_change_calculation(self):
"""Test price change calculation."""
update = PriceUpdate(ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0
)
assert update.change == 0.50

def test_change_negative(self):
"""Test negative price change."""
update = PriceUpdate(ticker="AAPL", price=189.50, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=189.50, previous_price=190.00, timestamp=1234567890.0
)
assert update.change == -0.50

def test_change_percent_up(self):
"""Test percentage change calculation (up)."""
update = PriceUpdate(ticker="AAPL", price=190.00, previous_price=100.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=190.00, previous_price=100.00, timestamp=1234567890.0
)
assert update.change_percent == 90.0

def test_change_percent_down(self):
"""Test percentage change calculation (down)."""
update = PriceUpdate(ticker="AAPL", price=100.00, previous_price=200.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=100.00, previous_price=200.00, timestamp=1234567890.0
)
assert update.change_percent == -50.0

def test_change_percent_zero_previous(self):
"""Test percentage change with zero previous price."""
update = PriceUpdate(ticker="AAPL", price=100.00, previous_price=0.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=100.00, previous_price=0.00, timestamp=1234567890.0
)
assert update.change_percent == 0.0

def test_direction_up(self):
"""Test direction calculation (up)."""
update = PriceUpdate(ticker="AAPL", price=191.00, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=191.00, previous_price=190.00, timestamp=1234567890.0
)
assert update.direction == "up"

def test_direction_down(self):
"""Test direction calculation (down)."""
update = PriceUpdate(ticker="AAPL", price=189.00, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=189.00, previous_price=190.00, timestamp=1234567890.0
)
assert update.direction == "down"

def test_direction_flat(self):
"""Test direction calculation (flat)."""
update = PriceUpdate(ticker="AAPL", price=190.00, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=190.00, previous_price=190.00, timestamp=1234567890.0
)
assert update.direction == "flat"

def test_to_dict(self):
"""Test serialization to dictionary."""
update = PriceUpdate(ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0
)
result = update.to_dict()

assert result["ticker"] == "AAPL"
Expand All @@ -71,7 +91,9 @@ def test_to_dict(self):

def test_immutability(self):
"""Test that PriceUpdate is immutable."""
update = PriceUpdate(ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0
)

with pytest.raises(AttributeError):
update.price = 200.00 # Should raise error
4 changes: 2 additions & 2 deletions backend/tests/market/test_simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,6 @@ def test_prices_rounded_to_two_decimals(self):
result = sim.step()
price_str = str(result["AAPL"])
# Check that we have at most 2 decimal places
if '.' in price_str:
decimal_part = price_str.split('.')[1]
if "." in price_str:
decimal_part = price_str.split(".")[1]
assert len(decimal_part) <= 2
Loading