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
44 changes: 44 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Keep the build context small and keep secrets and local state out of the image.

# Version control and editor/agent config
.git
.gitignore
.claude
.vscode
.idea

# Secrets: pass these at runtime (--env-file / compose env_file), never bake them in
.env
.env.*
**/.env
**/.env.*

# Local runtime database (the volume provides this in the container)
db/

# Node
**/node_modules
frontend/.next
frontend/out
**/*.tsbuildinfo

# Python
**/.venv
**/__pycache__
**/*.pyc
**/.pytest_cache
**/.ruff_cache
**/.coverage

# Not needed at runtime
planning
backend/tests
backend/market_data_demo.py
test
scripts
*.md
!backend/README.md
Dockerfile
docker-compose*.yml
railway.toml
deploy
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Shell scripts must keep LF endings: a CRLF "#!/usr/bin/env bash\r" fails on macOS/Linux,
# even when the file was committed from Windows with core.autocrlf enabled.
*.sh text eol=lf
Dockerfile text eol=lf
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,7 @@ cython_debug/
marimo/_static/
marimo/_lsp/
__marimo__/

# FinAlly runtime database (volume-mounted; only the directory is tracked)
db/*
!db/.gitkeep
77 changes: 77 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# FinAlly: single container, single port (PLAN.md section 11).
# Stage 1 builds the Next.js frontend into static files.
# Stage 2 runs FastAPI, which serves both /api/* and those static files on :8000.
#
# Build: docker build -t finally .
# Run: docker run -v finally-data:/app/db -p 8000:8000 --env-file .env finally
# Railway: see deploy/RAILWAY.md (the app listens on Railway's $PORT automatically).

# ---------------------------------------------------------------------------
# Stage 1: build the frontend (static export)
# ---------------------------------------------------------------------------
FROM node:20-slim AS frontend-build
WORKDIR /frontend

# Install dependencies first so this layer is cached until package files change.
# `npm ci` installs exactly what package-lock.json pins (reproducible; fails on drift).
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --no-audit --no-fund

# `next build` runs with NODE_ENV=production, which enables `output: "export"` in
# next.config.mjs. The result is a plain static site in /frontend/out (HTML, JS, CSS and the
# contents of public/), not .next/.
COPY frontend/ ./
RUN npm run build

# ---------------------------------------------------------------------------
# Stage 2: backend + static frontend
# ---------------------------------------------------------------------------
FROM python:3.12-slim AS runtime

# The image mirrors the repo layout under /app because the backend derives its paths from
# where app/ lives (PROJECT_ROOT = /app):
# /app/backend/app application code
# /app/backend/db/schema.sql schema, applied on startup (kept OUT of the volume mount)
# /app/static the built frontend, served at /
# /app/db SQLite database (finally.db), the volume mount point
WORKDIR /app/backend

ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
PATH="/app/backend/.venv/bin:$PATH"

RUN pip install --no-cache-dir uv

# Dependencies first (cached until pyproject.toml / uv.lock change). --no-install-project skips
# building the app itself, which would need the source tree; the code is run from /app/backend.
COPY backend/pyproject.toml backend/uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

# Application code and schema.
COPY backend/app ./app
COPY backend/db/schema.sql ./db/schema.sql

# The built frontend (Stage 1 output).
COPY --from=frontend-build /frontend/out /app/static

# Run as a non-root user. /app/db is created and owned by that user so that a fresh named
# volume mounted there (Docker copies the ownership from the image) is writable.
RUN useradd --create-home --uid 10001 finally \
&& mkdir -p /app/db \
&& chown -R finally:finally /app/db
USER finally

# No VOLUME instruction: Railway rejects Dockerfiles that contain one. It is not needed here,
# because docker run / compose mount a named volume at /app/db explicitly, and Docker copies this
# directory's ownership into a fresh named volume. On Railway, attach a Railway volume at /app/db.
EXPOSE 8000

# Listen on $PORT when the platform provides it (Railway injects one at runtime and ignores
# EXPOSE); default to 8000 for docker run / compose. The health check follows the same port.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import os, urllib.request; urllib.request.urlopen('http://localhost:%s/api/health' % os.environ.get('PORT', '8000'), timeout=3)"

# Shell form (via sh -c) so ${PORT:-8000} is expanded; exec makes uvicorn PID 1 for clean shutdown.
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
15 changes: 15 additions & 0 deletions backend/app/agents/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Multi-agent trading system. See planning/AGENT_ARCHITECTURE.md."""

from .analyzer import AnalyzerAgent
from .orchestrator import OrchestratorAgent
from .portfolio import PortfolioAgent
from .risk import RiskAgent
from .watchlist import WatchlistAgent

__all__ = [
"AnalyzerAgent",
"OrchestratorAgent",
"PortfolioAgent",
"RiskAgent",
"WatchlistAgent",
]
90 changes: 90 additions & 0 deletions backend/app/agents/analyzer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Analyzer Agent: deterministic, read-only portfolio analysis used as LLM context."""

from __future__ import annotations

from ..db import Database
from ..market import PriceCache
from .base import BaseAgent
from .metrics import cash_pct, concentration_flags, herfindahl, position_metrics
from .models import AnalysisReport, Mover, PortfolioSummary

# Flag the portfolio as mostly idle above this cash share.
HIGH_CASH_PCT = 80.0
# Ignore tiny ticks when picking watchlist movers.
MOVER_MIN_PCT = 0.05


class AnalyzerAgent(BaseAgent):
agent_id = "analyzer"

def __init__(self, db: Database, cache: PriceCache, concentration_flag_pct: float) -> None:
super().__init__(db)
self.cache = cache
# Same threshold the Risk agent warns at, so the two never disagree about "concentrated".
self.concentration_flag_pct = concentration_flag_pct

def analyze(self, summary: PortfolioSummary, watchlist: list[str]) -> AnalysisReport:
with self.timed() as elapsed:
report = self._analyze(summary, watchlist)
self.log("analyze", "success", output=report, duration_ms=elapsed())
return report

def _analyze(self, summary: PortfolioSummary, watchlist: list[str]) -> AnalysisReport:
metrics = position_metrics(summary)
flags = concentration_flags(metrics, self.concentration_flag_pct)
for m in metrics:
upd = self.cache.get(m.ticker)
if upd is None:
flags.append(f"no live price for {m.ticker}; using last known price")
else:
# Day change here is the last-tick change from the price cache (no daily open).
m.day_change_pct = upd.change_percent

cash = cash_pct(summary)
if cash > HIGH_CASH_PCT:
flags.append(f"{cash:.0f}% of the portfolio is idle cash")

# max/min with default=None so an empty portfolio (all cash) is handled gracefully.
return AnalysisReport(
total_value=summary.total_value,
cash_pct=cash,
positions=sorted(metrics, key=lambda m: -m.weight_pct), # largest first
top_concentration=max(metrics, key=lambda m: m.weight_pct, default=None),
herfindahl_index=herfindahl(metrics),
best_performer=max(metrics, key=lambda m: m.unrealized_pnl, default=None),
worst_performer=min(metrics, key=lambda m: m.unrealized_pnl, default=None),
watchlist_movers=self.movers(watchlist, {p.ticker for p in summary.positions}),
flags=flags,
)

def movers(self, watchlist: list[str], held: set[str], limit: int = 3) -> list[Mover]:
"""Largest absolute % moves among watched tickers that are not held."""
out = []
for t in watchlist:
if t in held: # held tickers are covered by the positions section
continue
upd = self.cache.get(t)
if upd and abs(upd.change_percent) >= MOVER_MIN_PCT:
out.append(Mover(ticker=t, price=upd.price, change_percent=upd.change_percent))
return sorted(out, key=lambda m: -abs(m.change_percent))[:limit]

@staticmethod
def to_prompt_context(report: AnalysisReport) -> str:
lines = [
f"Cash: {report.cash_pct:.1f}% of portfolio; concentration index (HHI): "
f"{report.herfindahl_index:.2f}"
]
if report.best_performer:
b = report.best_performer
lines.append(f"Best: {b.ticker} ({b.pnl_pct:+.1f}%, ${b.unrealized_pnl:+,.2f})")
if report.worst_performer and report.worst_performer is not report.best_performer:
w = report.worst_performer
lines.append(f"Worst: {w.ticker} ({w.pnl_pct:+.1f}%, ${w.unrealized_pnl:+,.2f})")
if report.watchlist_movers:
lines.append(
"Unheld movers: "
+ ", ".join(f"{m.ticker} {m.change_percent:+.2f}%" for m in report.watchlist_movers)
)
if report.flags:
lines.append("Flags: " + "; ".join(report.flags))
return "\n".join(lines)
95 changes: 95 additions & 0 deletions backend/app/agents/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""BaseAgent: identity, DB handle and audit logging shared by all agents."""

from __future__ import annotations

import json
import logging
import time
import uuid
from contextlib import contextmanager
from contextvars import ContextVar
from datetime import UTC, datetime
from typing import Any

from pydantic import BaseModel

from ..db import Database

logger = logging.getLogger(__name__)

# One correlation id per API request; every agent call in it is logged under the same id.
# ContextVar (not a global) so concurrent requests each keep their own id.
correlation_id: ContextVar[str] = ContextVar("correlation_id", default="")

# Tolerance for float comparisons on share quantities and cash (avoids 0.30000000000000004 issues).
# Shared by Risk (approve) and Portfolio (re-check) so the two can never disagree.
EPS = 1e-9

# Single-user app: every row uses this id. Kept in one place so multi-user is a small change later.
USER_ID = "default"


def new_id() -> str:
return str(uuid.uuid4())


# ISO-8601 UTC with millisecond precision; matches the format used by schema.sql defaults.
def now_iso() -> str:
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"


# Convert pydantic models (possibly nested in lists/dicts) into plain JSON-serializable data.
def _jsonable(value: Any) -> Any:
if isinstance(value, BaseModel):
return value.model_dump()
if isinstance(value, list):
return [_jsonable(v) for v in value]
if isinstance(value, dict):
return {k: _jsonable(v) for k, v in value.items()}
return value


class BaseAgent:
# Stable identifier written to agent_logs.agent_id (and trades.agent_id for the Portfolio agent).
agent_id: str = "base"

def __init__(self, db: Database) -> None:
self.db = db

def log(
self,
action: str,
status: str,
input: Any = None,
output: Any = None,
error: str | None = None,
duration_ms: int | None = None,
) -> None:
"""Write one agent_logs row. Auditing must never break a request, so failures are swallowed."""
try:
self.db.execute(
"INSERT INTO agent_logs (id, user_id, correlation_id, agent_id, action, status,"
" input, output, error, duration_ms, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(
new_id(),
USER_ID,
correlation_id.get() or new_id(),
self.agent_id,
action,
status,
json.dumps(_jsonable(input), default=str) if input is not None else None,
json.dumps(_jsonable(output), default=str) if output is not None else None,
error,
duration_ms,
now_iso(),
),
)
except Exception: # noqa: BLE001
# A logging failure must never fail the trade or chat request, but it must not be silent.
logger.warning("agent_logs write failed for %s.%s", self.agent_id, action, exc_info=True)

@contextmanager
def timed(self):
"""Yields a callable returning elapsed milliseconds."""
start = time.perf_counter()
yield lambda: int((time.perf_counter() - start) * 1000)
37 changes: 37 additions & 0 deletions backend/app/agents/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Concentration maths shared by the Analyzer and Risk agents (one definition, one threshold)."""

from __future__ import annotations

from .models import PortfolioSummary, PositionMetric


def position_metrics(summary: PortfolioSummary) -> list[PositionMetric]:
"""One metric per position, weights as % of total portfolio value."""
total = summary.total_value
return [
PositionMetric(
ticker=p.ticker,
weight_pct=(p.market_value / total * 100) if total else 0.0,
unrealized_pnl=p.unrealized_pnl,
pnl_pct=p.pnl_percent,
)
for p in summary.positions
]


def herfindahl(metrics: list[PositionMetric]) -> float:
"""Sum of squared weights: 1.0 = a single position, ~0 = well diversified."""
return sum((m.weight_pct / 100) ** 2 for m in metrics)


def cash_pct(summary: PortfolioSummary) -> float:
"""Cash as % of total value; an empty (zero-value) portfolio counts as all cash."""
return summary.cash_balance / summary.total_value * 100 if summary.total_value else 100.0


def concentration_flags(metrics: list[PositionMetric], threshold_pct: float) -> list[str]:
return [
f"{m.ticker} is {m.weight_pct:.0f}% of the portfolio"
for m in metrics
if m.weight_pct > threshold_pct
]
Loading