Skip to content
Open
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
18 changes: 18 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "rick-tools",
"owner": {
"name": "rickhill65",
"email": "rick@eastmidlandsconsulting.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": "rickhill65"
}
}
]
}
6 changes: 6 additions & 0 deletions .claude/agents/reviewer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
name: reviewer
description: carry out a comprehensive review when requested
---

You review the file planning/PLAN.md and write your feedback to planning/REVIEWER.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
3 changes: 1 addition & 2 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true,
"context7@claude-plugins-official": true,
"independent-reviewer@rick-tools": true,
"playwright@claude-plugins-official": true
}
}
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ celerybeat.pid
*.sage.py

# Environments
.env
.env*
!.env.example
.envrc
.venv
env/
Expand Down
50 changes: 24 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@

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.

Built entirely by coding agents as a capstone project for an agentic AI coding course.
Built entirely by coding agents as a capstone project for an agentic AI coding course. See [`planning/PLAN.md`](planning/PLAN.md) for the full 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 (portfolio, LLM chat, frontend, Docker packaging) is still to be built.

## Architecture
- ✅ Market data backend — GBM simulator + Massive (Polygon.io) client behind a shared interface, SSE price streaming, 73 tests passing. See [`planning/MARKET_DATA_SUMMARY.md`](planning/MARKET_DATA_SUMMARY.md).
- ⬜ Portfolio & trading (positions, trades, P&L)
- ⬜ AI chat assistant (LiteLLM → OpenRouter, Cerebras inference)
- ⬜ Frontend (Next.js trading terminal UI)
- ⬜ Docker packaging & start/stop scripts
- ⬜ E2E tests

## Architecture (target)

Single Docker container serving everything on port 8000:

Expand All @@ -23,40 +25,36 @@ Single Docker container serving everything on port 8000:
- **AI**: LiteLLM → OpenRouter (Cerebras inference) with structured outputs
- **Market data**: Built-in GBM simulator (default) or Massive API (optional)

## Quick Start
## Backend (available now)

```bash
# Clone and configure
cp .env.example .env
# Add your OPENROUTER_API_KEY to .env

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

# Open http://localhost:8000
cd backend
uv sync --extra dev
uv run --extra dev pytest -v # run tests
uv run market_data_demo.py # live terminal dashboard of simulated prices
```

See [`backend/README.md`](backend/README.md) and [`backend/CLAUDE.md`](backend/CLAUDE.md) for details on the market data API.

## 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 |
| `OPENROUTER_API_KEY` | For AI chat | OpenRouter API key |
| `MASSIVE_API_KEY` | No | Massive (Polygon.io) key for real market data; omit to use the simulator |
| `LLM_MOCK` | No | Set `true` for deterministic mock LLM responses (testing) |

## Project Structure

```
finally/
├── frontend/ # Next.js static export
├── backend/ # FastAPI uv project
├── backend/ # FastAPI uv project (market data complete; portfolio/AI/API pending)
├── planning/ # Project documentation and agent contracts
├── test/ # Playwright E2E tests
├── db/ # SQLite volume mount (runtime)
└── scripts/ # Start/stop helpers
└── db/ # SQLite volume mount (runtime)
```

`frontend/`, `scripts/`, and `test/` are not yet created — see `planning/PLAN.md` for their intended layout.

## License

See [LICENSE](LICENSE).
4 changes: 2 additions & 2 deletions backend/app/market/massive_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ async def _poll_once(self) -> None:
for snap in snapshots:
try:
price = snap.last_trade.price
# Massive timestamps are Unix milliseconds → convert to seconds
timestamp = snap.last_trade.timestamp / 1000.0
# Massive trade timestamps (sip_timestamp) are Unix nanoseconds → convert to seconds
timestamp = snap.last_trade.sip_timestamp / 1_000_000_000
self._cache.update(
ticker=snap.ticker,
price=price,
Expand Down
27 changes: 17 additions & 10 deletions backend/tests/market/test_massive.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,25 @@
from unittest.mock import MagicMock, patch

import pytest
from massive.rest.models.trades import LastTrade

from app.market.cache import PriceCache
from app.market.massive_client import MassiveDataSource


def _make_snapshot(ticker: str, price: float, timestamp_ms: int) -> MagicMock:
"""Create a mock Massive snapshot object."""
def _make_snapshot(ticker: str, price: float, sip_timestamp_ns: int) -> MagicMock:
"""Create a mock Massive snapshot object.

last_trade is spec'd against the real LastTrade model so that setting or
reading an attribute that doesn't actually exist on the API response
(e.g. `.timestamp`, which was mistakenly assumed in earlier code) raises
AttributeError here too, instead of silently mocking a fake field.
"""
snap = MagicMock()
snap.ticker = ticker
snap.last_trade = MagicMock()
snap.last_trade = MagicMock(spec_set=LastTrade)
snap.last_trade.price = price
snap.last_trade.timestamp = timestamp_ms
snap.last_trade.sip_timestamp = sip_timestamp_ns
return snap


Expand All @@ -34,8 +41,8 @@ async def test_poll_updates_cache(self):
source._client = MagicMock() # Satisfy the _poll_once guard

mock_snapshots = [
_make_snapshot("AAPL", 190.50, 1707580800000),
_make_snapshot("GOOGL", 175.25, 1707580800000),
_make_snapshot("AAPL", 190.50, 1707580800000000000),
_make_snapshot("GOOGL", 175.25, 1707580800000000000),
]

with patch.object(source, "_fetch_snapshots", return_value=mock_snapshots):
Expand All @@ -55,7 +62,7 @@ async def test_malformed_snapshot_skipped(self):
source._tickers = ["AAPL", "BAD"]
source._client = MagicMock() # Satisfy the _poll_once guard

good_snap = _make_snapshot("AAPL", 190.50, 1707580800000)
good_snap = _make_snapshot("AAPL", 190.50, 1707580800000000000)
bad_snap = MagicMock()
bad_snap.ticker = "BAD"
bad_snap.last_trade = None # Will cause AttributeError
Expand Down Expand Up @@ -84,7 +91,7 @@ async def test_api_error_does_not_crash(self):
assert cache.get_price("AAPL") is None # No update happened

async def test_timestamp_conversion(self):
"""Test that timestamps are converted from milliseconds to seconds."""
"""Test that sip_timestamp is converted from nanoseconds to seconds."""
cache = PriceCache()
source = MassiveDataSource(
api_key="test-key",
Expand All @@ -94,7 +101,7 @@ async def test_timestamp_conversion(self):
source._tickers = ["AAPL"]
source._client = MagicMock() # Satisfy the _poll_once guard

mock_snapshots = [_make_snapshot("AAPL", 190.50, 1707580800000)]
mock_snapshots = [_make_snapshot("AAPL", 190.50, 1707580800000000000)]

with patch.object(source, "_fetch_snapshots", return_value=mock_snapshots):
await source._poll_once()
Expand Down Expand Up @@ -189,7 +196,7 @@ async def test_start_immediate_poll(self):
cache = PriceCache()
source = MassiveDataSource(api_key="test-key", price_cache=cache, poll_interval=60.0)

mock_snapshots = [_make_snapshot("AAPL", 190.50, 1707580800000)]
mock_snapshots = [_make_snapshot("AAPL", 190.50, 1707580800000000000)]

with patch("app.market.massive_client.RESTClient"):
with patch.object(source, "_fetch_snapshots", return_value=mock_snapshots):
Expand Down
21 changes: 21 additions & 0 deletions independent-reviewer/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true,
"context7@claude-plugins-official": true,
"playwright@claude-plugins-official": true
},
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "echo \"$(date '+%Y-%m-%d %H:%M:%S') hook fired\" >> /tmp/claude-stop-hook-debug.log; set -a; source /Users/rickhill65/Cursor_Projects/finally/.env 2>/dev/null; set +a; /Users/rickhill65/.hermes/node/bin/codex exec \"Review changes since last commit and write results to a file named planning/REVIEWHOOK.md\" < /dev/null 2>/dev/null || true",
"async": true,
"timeout": 300
}
]
}
]
}
}
6 changes: 6 additions & 0 deletions independent-reviewer/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "independent-reviewer",
"description": "carry out an independent review of all changes since last commit",
"version": "1.0.0"

}
Loading