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
6 changes: 6 additions & 0 deletions .claude/agents/review.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. Provide feedback on the code, documentation, and overall quality of the project. Suggest improvements and highlight any potential issues.
---

you review the file planning/PLAN.md and write your feedback to planning/REVIEW.md.
8 changes: 6 additions & 2 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true,
"frontend-design@claude-plugins-official": false,
"context7@claude-plugins-official": true,
"playwright@claude-plugins-official": true
}
},
"env":{
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
},
"teammateMode": "in-process"
}
52 changes: 52 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# FinAlly .dockerignore — keep the build context small and reproducible.

# Version control / CI
.git
.github
.gitignore

# Docs & planning (not needed in the image)
planning
README.md
LICENSE
*.md

# Environment / secrets (never bake into the image)
.env
.envrc
.env.*
!.env.example

# Python
**/__pycache__
**/*.py[codz]
**/.venv
**/.pytest_cache
**/.ruff_cache
**/.mypy_cache
**/htmlcov
**/.coverage
**/*.egg-info

# Node / Next.js
**/node_modules
**/.next
**/out
**/npm-debug.log*

# Runtime database / volume data
db/*.db
db/*.db-*
**/db/*.db

# Editor / OS cruft
.vscode
.idea
.DS_Store
Thumbs.db

# Tests & docker meta (not part of the app image)
test
Dockerfile
.dockerignore
docker-compose.yml
25 changes: 25 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# FinAlly environment variables.
# Copy this file to `.env` (at the repo root) and fill in values as needed:
# cp .env.example .env

# Required: OpenRouter API key for LLM chat functionality.
# Get one at https://openrouter.ai/. Leave blank only if running with LLM_MOCK=true.
OPENROUTER_API_KEY=

# Optional: Massive (Polygon.io) API key for real market data.
# If set and non-empty -> backend uses the Massive REST API for live prices.
# If absent or empty -> backend uses the built-in market simulator (recommended).
MASSIVE_API_KEY=

# Optional: set to "true" for deterministic mock LLM responses (no network calls).
# Used by E2E tests and for development without an API key.
LLM_MOCK=false

# Optional: SQLite database path.
# Leave this UNSET for both Docker and local dev:
# - In the container the image already sets FINALLY_DB_PATH=/app/db/finally.db
# (an absolute path on the mounted volume). Defining it here would override
# that via --env-file and can misplace the database — so keep it commented.
# - For local `uv run`, the backend defaults to <repo-root>/db/finally.db.
# Only set an absolute path here if you intentionally want a custom location.
# FINALLY_DB_PATH=/app/db/finally.db
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,9 @@ cython_debug/
marimo/_static/
marimo/_lsp/
__marimo__/

# Session scratch / test artifacts
.playwright-mcp/
backend/test-results/
/finally-terminal.png
/finally-after-trade.png
69 changes: 69 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# syntax=docker/dockerfile:1

# ==============================================================================
# FinAlly — multi-stage build
# Stage 1 (node): build the Next.js static export -> frontend/out
# Stage 2 (python): install backend via uv, copy the export in as static/
# Final image serves the FastAPI app (app.main:app) on port 8000 and the
# static frontend from /app/static. SQLite lives at /app/db (named volume).
# ==============================================================================

# ------------------------------------------------------------------------------
# Stage 1: Frontend build (Next.js static export -> /app/frontend/out)
# ------------------------------------------------------------------------------
FROM node:20-slim AS frontend-builder

WORKDIR /app/frontend

# Install dependencies first for better layer caching.
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci

# Build the static export. next.config with `output: 'export'` emits to ./out
COPY frontend/ ./
RUN npm run build

# ------------------------------------------------------------------------------
# Stage 2: Backend runtime (FastAPI + uv), serving the static export
# ------------------------------------------------------------------------------
FROM python:3.12-slim AS runtime

# uv: fast, reproducible Python dependency management (copied from official image)
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# uv settings: use a project-local .venv, compile bytecode, copy (not link) so
# the venv is self-contained and the build works across filesystems.
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/app/.venv \
PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
FINALLY_DB_PATH=/app/db/finally.db

WORKDIR /app

# 1) Install ONLY dependencies first (no project source) for layer caching.
# --no-dev skips the `dev` optional group; --frozen requires uv.lock to match.
COPY backend/pyproject.toml backend/uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev

# 2) Copy backend source and install the project itself into the venv.
COPY backend/ ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev

# 3) Copy the built frontend export to the location FastAPI serves as static.
COPY --from=frontend-builder /app/frontend/out ./static

# Runtime volume mount point for the SQLite database (db/finally.db).
RUN mkdir -p /app/db

EXPOSE 8000

# Basic container healthcheck against the API health endpoint.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0) if urllib.request.urlopen('http://localhost:8000/api/health').status==200 else sys.exit(1)"

# Serve FastAPI (which also mounts the static export) on all interfaces.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
1 change: 1 addition & 0 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""REST API routers for FinAlly."""
101 changes: 101 additions & 0 deletions backend/app/api/chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Chat endpoint: LLM conversation with auto-executed trades & watchlist changes."""

from __future__ import annotations

import logging

from fastapi import APIRouter
from pydantic import BaseModel, Field

from app.db import repo
from app.llm import complete_chat
from app.runtime import get_market_source, get_price_cache
from app.services.portfolio import TradeError, build_portfolio, execute_trade

from .watchlist import build_watchlist

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api", tags=["chat"])


class ChatBody(BaseModel):
message: str = Field(..., min_length=1)


async def _apply_trades(trades) -> list[dict]:
"""Execute each requested trade, returning per-action status records."""
cache = get_price_cache()
results: list[dict] = []
for t in trades:
ticker = t.ticker.upper()
record = {
"ticker": ticker,
"side": t.side,
"quantity": t.quantity,
"status": "executed",
"error": None,
}
price = cache.get_price(ticker)
try:
if price is None:
raise TradeError(f"No price available for {ticker}.")
execute_trade(ticker, t.side, t.quantity, price)
except TradeError as exc:
record["status"] = "failed"
record["error"] = str(exc)
results.append(record)
return results


async def _apply_watchlist(changes) -> list[dict]:
"""Apply each watchlist change, returning per-action status records."""
source = get_market_source()
results: list[dict] = []
for c in changes:
ticker = c.ticker.upper()
record = {"ticker": ticker, "action": c.action, "status": "applied", "error": None}
try:
if c.action == "add":
if repo.add_watchlist(ticker):
await source.add_ticker(ticker)
else:
record["status"] = "failed"
record["error"] = f"{ticker} already on watchlist."
elif c.action == "remove":
if repo.remove_watchlist(ticker):
await source.remove_ticker(ticker)
else:
record["status"] = "failed"
record["error"] = f"{ticker} not on watchlist."
except Exception as exc: # pragma: no cover - source failures
record["status"] = "failed"
record["error"] = str(exc)
results.append(record)
return results


@router.post("/chat")
async def post_chat(body: ChatBody) -> dict:
user_message = body.message.strip()

portfolio = build_portfolio()
watchlist = build_watchlist()["watchlist"]
history = repo.get_chat_messages(limit=20)

repo.insert_chat_message("user", user_message, None)

response = complete_chat(user_message, portfolio, watchlist, history)

trade_results = await _apply_trades(response.trades)
watchlist_results = await _apply_watchlist(response.watchlist_changes)

actions = {"trades": trade_results, "watchlist_changes": watchlist_results}
repo.insert_chat_message("assistant", response.message, actions if (trade_results or watchlist_results) else None)

return {
"message": response.message,
"trades": trade_results,
"watchlist_changes": watchlist_results,
"portfolio": build_portfolio(),
}
12 changes: 12 additions & 0 deletions backend/app/api/health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Health check endpoint (for Docker / deployment)."""

from __future__ import annotations

from fastapi import APIRouter

router = APIRouter(prefix="/api", tags=["system"])


@router.get("/health")
async def health() -> dict:
return {"status": "ok"}
41 changes: 41 additions & 0 deletions backend/app/api/portfolio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Portfolio endpoints: current holdings, trade execution, value history."""

from __future__ import annotations

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field

from app.db import repo
from app.runtime import get_price_cache
from app.services.portfolio import TradeError, build_portfolio, execute_trade

router = APIRouter(prefix="/api/portfolio", tags=["portfolio"])


class TradeBody(BaseModel):
ticker: str = Field(..., min_length=1)
quantity: float = Field(..., gt=0)
side: str


@router.get("")
async def get_portfolio() -> dict:
return build_portfolio()


@router.get("/history")
async def get_history() -> dict:
return {"snapshots": repo.get_snapshots()}


@router.post("/trade")
async def post_trade(body: TradeBody) -> dict:
ticker = body.ticker.strip().upper()
price = get_price_cache().get_price(ticker)
if price is None:
raise HTTPException(status_code=400, detail=f"No price available for {ticker}.")
try:
trade = execute_trade(ticker, body.side, body.quantity, price)
except TradeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"success": True, "trade": trade, "portfolio": build_portfolio()}
Loading