Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ jobs:
run: pip install -r api/requirements-dev.txt

- name: Lint
run: ruff check api/src/
run: ruff check api/src/ bot/src/ sync/src/

- name: Format check
run: ruff format --check api/src/
run: ruff format --check api/src/ bot/src/ sync/src/

- name: Test
run: cd api && pytest tests/ -v
Expand Down
2 changes: 1 addition & 1 deletion api/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

_REQUIRED_ENV = ["API_KEY", "MONGODB_URI"]

app = FastAPI(title="trade-compass API")
app = FastAPI(title="trade-compass API", redirect_slashes=False)


@app.on_event("startup")
Expand Down
5 changes: 5 additions & 0 deletions api/src/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class Preferences(BaseModel):
"risk_tolerance": "medium",
"sectors": ["tech", "energy"],
"max_position_size": 0.1,
"llm_model": "meta-llama/llama-3.3-70b-instruct:free",
}
}
)
Expand All @@ -78,3 +79,7 @@ class Preferences(BaseModel):
max_position_size: float = Field(
default=0.1, description="Max single position as fraction of portfolio (0–1)"
)
llm_model: str = Field(
default="",
description="LLM model for analysis. Configured in bot/config.json.",
)
Comment thread
PCBZ marked this conversation as resolved.
12 changes: 11 additions & 1 deletion api/src/routers/decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@
}


@router.get(
"",
responses={200: {"content": {"application/json": {"example": [_example]}}}},
)
async def list_decisions():
"""Return all decisions, newest first."""
db = get_db()
return await db.decisions.find({}, {"_id": 0}).sort("created_at", -1).to_list(None)

Comment on lines +16 to +24

@router.get(
"/{symbol}",
responses={200: {"content": {"application/json": {"example": _example}}}},
Expand All @@ -29,7 +39,7 @@ async def get_decision(symbol: str):


@router.post(
"/",
"",
status_code=201,
responses={201: {"content": {"application/json": {"example": {"saved": "NVDA"}}}}},
)
Expand Down
4 changes: 2 additions & 2 deletions api/src/routers/holdings.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@


@router.get(
"/",
"",
responses={200: {"content": {"application/json": {"example": [_example]}}}},
)
async def list_holdings():
Expand All @@ -29,7 +29,7 @@ async def list_holdings():


@router.post(
"/",
"",
status_code=201,
responses={201: {"content": {"application/json": {"example": {"upserted": 2}}}}},
)
Expand Down
4 changes: 2 additions & 2 deletions api/src/routers/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@


@router.get(
"/",
"",
responses={200: {"content": {"application/json": {"example": _example}}}},
)
async def get_preferences():
Expand All @@ -26,7 +26,7 @@ async def get_preferences():


@router.put(
"/",
"",
responses={200: {"content": {"application/json": {"example": _example}}}},
)
async def update_preferences(prefs: Preferences):
Expand Down
6 changes: 3 additions & 3 deletions api/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,17 @@ async def test_health_no_key(client):

@pytest.mark.asyncio
async def test_missing_key(client):
r = await client.get("/holdings/")
r = await client.get("/holdings")
assert r.status_code == 403


@pytest.mark.asyncio
async def test_wrong_key(client):
r = await client.get("/holdings/", headers={"X-API-Key": "wrong"})
r = await client.get("/holdings", headers={"X-API-Key": "wrong"})
assert r.status_code == 401


@pytest.mark.asyncio
async def test_valid_key(client):
r = await client.get("/holdings/", headers=HEADERS)
r = await client.get("/holdings", headers=HEADERS)
assert r.status_code == 200
40 changes: 36 additions & 4 deletions api/tests/test_decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,54 @@ async def test_get_missing(client):

@pytest.mark.asyncio
async def test_save_decision(client):
r = await client.post("/decisions/", json=_decision, headers=HEADERS)
r = await client.post("/decisions", json=_decision, headers=HEADERS)
assert r.status_code == 201
assert r.json() == {"saved": "NVDA"}


@pytest.mark.asyncio
async def test_get_case_insensitive(client):
await client.post("/decisions/", json=_decision, headers=HEADERS)
await client.post("/decisions", json=_decision, headers=HEADERS)
r = await client.get("/decisions/nvda", headers=HEADERS)
assert r.status_code == 200
assert r.json()["symbol"] == "NVDA"


@pytest.mark.asyncio
async def test_get_returns_latest(client):
await client.post("/decisions/", json={**_decision, "reasoning": "First.", "created_at": "2026-01-01T00:00:00Z"}, headers=HEADERS)
await client.post("/decisions/", json={**_decision, "reasoning": "Latest.", "created_at": "2026-06-01T00:00:00Z"}, headers=HEADERS)
await client.post("/decisions", json={**_decision, "reasoning": "First.", "created_at": "2026-01-01T00:00:00Z"}, headers=HEADERS)
await client.post("/decisions", json={**_decision, "reasoning": "Latest.", "created_at": "2026-06-01T00:00:00Z"}, headers=HEADERS)
r = await client.get("/decisions/NVDA", headers=HEADERS)
assert r.json()["reasoning"] == "Latest."


@pytest.mark.asyncio
async def test_list_decisions_empty(client):
r = await client.get("/decisions", headers=HEADERS)
assert r.status_code == 200
assert r.json() == []


@pytest.mark.asyncio
async def test_list_decisions_newest_first(client):
await client.post("/decisions", json={**_decision, "symbol": "AAPL", "reasoning": "First.", "created_at": "2026-01-01T00:00:00Z"}, headers=HEADERS)
await client.post("/decisions", json={**_decision, "symbol": "TSLA", "reasoning": "Second.", "created_at": "2026-06-01T00:00:00Z"}, headers=HEADERS)
r = await client.get("/decisions", headers=HEADERS)
assert r.status_code == 200
results = r.json()
assert len(results) == 2
assert results[0]["symbol"] == "TSLA" # newest first
assert results[1]["symbol"] == "AAPL"


@pytest.mark.asyncio
async def test_list_decisions_shape(client):
await client.post("/decisions", json=_decision, headers=HEADERS)
r = await client.get("/decisions", headers=HEADERS)
assert r.status_code == 200
item = r.json()[0]
assert item["symbol"] == "NVDA"
assert item["verdict"] == "BUY"
assert "reasoning" in item
assert "created_at" in item
assert "_id" not in item # MongoDB _id must be stripped
14 changes: 7 additions & 7 deletions api/tests/test_holdings.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,30 @@

@pytest.mark.asyncio
async def test_list_empty(client):
r = await client.get("/holdings/", headers=HEADERS)
r = await client.get("/holdings", headers=HEADERS)
assert r.status_code == 200
assert r.json() == []


@pytest.mark.asyncio
async def test_upsert_returns_count(client):
r = await client.post("/holdings/", json=[_holding, {**_holding, "symbol": "NVDA"}], headers=HEADERS)
r = await client.post("/holdings", json=[_holding, {**_holding, "symbol": "NVDA"}], headers=HEADERS)
assert r.status_code == 201
assert r.json() == {"upserted": 2}


@pytest.mark.asyncio
async def test_list_after_upsert(client):
await client.post("/holdings/", json=[_holding], headers=HEADERS)
r = await client.get("/holdings/", headers=HEADERS)
await client.post("/holdings", json=[_holding], headers=HEADERS)
r = await client.get("/holdings", headers=HEADERS)
assert len(r.json()) == 1
assert r.json()[0]["symbol"] == "AAPL"


@pytest.mark.asyncio
async def test_upsert_deduplicates(client):
await client.post("/holdings/", json=[_holding], headers=HEADERS)
await client.post("/holdings/", json=[{**_holding, "qty": 20.0}], headers=HEADERS)
r = await client.get("/holdings/", headers=HEADERS)
await client.post("/holdings", json=[_holding], headers=HEADERS)
await client.post("/holdings", json=[{**_holding, "qty": 20.0}], headers=HEADERS)
r = await client.get("/holdings", headers=HEADERS)
assert len(r.json()) == 1
assert r.json()[0]["qty"] == 20.0
8 changes: 4 additions & 4 deletions api/tests/test_preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

@pytest.mark.asyncio
async def test_get_defaults(client):
r = await client.get("/preferences/", headers=HEADERS)
r = await client.get("/preferences", headers=HEADERS)
assert r.status_code == 200
assert r.json()["risk_tolerance"] == "medium"
assert r.json()["max_position_size"] == 0.1
Expand All @@ -14,15 +14,15 @@ async def test_get_defaults(client):
@pytest.mark.asyncio
async def test_put_preferences(client):
payload = {"risk_tolerance": "high", "sectors": ["tech"], "max_position_size": 0.2}
r = await client.put("/preferences/", json=payload, headers=HEADERS)
r = await client.put("/preferences", json=payload, headers=HEADERS)
assert r.status_code == 200
assert r.json()["risk_tolerance"] == "high"


@pytest.mark.asyncio
async def test_get_after_put(client):
payload = {"risk_tolerance": "low", "sectors": ["energy"], "max_position_size": 0.05}
await client.put("/preferences/", json=payload, headers=HEADERS)
r = await client.get("/preferences/", headers=HEADERS)
await client.put("/preferences", json=payload, headers=HEADERS)
r = await client.get("/preferences", headers=HEADERS)
assert r.json()["risk_tolerance"] == "low"
assert r.json()["sectors"] == ["energy"]
9 changes: 9 additions & 0 deletions bot/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.env
.env.*
__pycache__/
*.pyc
*.pyo
.pytest_cache/
tests/
requirements-dev.txt
pytest.ini
9 changes: 9 additions & 0 deletions bot/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Auto-generated by terraform/deploy.sh (API_URL + API_KEY)
API_URL=https://your-cloud-run-api-url
API_KEY=your-api-key

# Fill in manually after obtaining keys
FMP_API_KEY=
OPENROUTER_API_KEY=
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID= # your personal Telegram chat ID (send /start to @userinfobot)
15 changes: 15 additions & 0 deletions bot/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM python:3.14-slim

WORKDIR /app

# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy bot source as a package: /app/bot/
COPY src/ ./bot/

ENV PYTHONUNBUFFERED=1

# main.py lives at /app/bot/main.py; import path is bot.main:app
CMD ["uvicorn", "bot.main:app", "--host", "0.0.0.0", "--port", "8080"]
4 changes: 4 additions & 0 deletions bot/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[pytest]
asyncio_mode = auto
pythonpath = src
testpaths = tests
2 changes: 2 additions & 0 deletions bot/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pytest==8.3.3
pytest-asyncio==0.24.0
7 changes: 7 additions & 0 deletions bot/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
langgraph==0.2.55
langchain-openai==0.2.14
httpx==0.27.0
python-telegram-bot==21.6
python-dotenv==1.0.1
fastapi==0.115.0
uvicorn[standard]==0.32.0
Empty file added bot/src/__init__.py
Empty file.
Empty file added bot/src/agents/__init__.py
Empty file.
72 changes: 72 additions & 0 deletions bot/src/agents/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Data Agent — fetches market data and holdings, populates shared state."""

from __future__ import annotations

import asyncio

import httpx

from ..state import AnalysisState
from ..tools.market_data import (
fetch_analyst_ratings,
fetch_financials,
fetch_key_metrics,
fetch_news,
fetch_profile,
fetch_quote,
fetch_scores,
)
from ..tools.portfolio_api import get_holdings, get_preferences


async def data_agent(state: AnalysisState) -> dict:
"""
Fetches all raw data needed by downstream agents in parallel:
- FMP: quote, profile, key_metrics, financials, news, analyst_ratings
- REST API: current holdings, user preferences

Writes: raw_data, holdings, preferences
"""
ticker = state.get("ticker", "")

try:
async with httpx.AsyncClient() as client:
# All 6 FMP endpoints + 2 REST API calls in parallel
(
quote,
profile,
key_metrics,
financials,
scores,
news,
analyst,
holdings,
preferences,
) = await asyncio.gather(
fetch_quote(client, ticker),
fetch_profile(client, ticker),
fetch_key_metrics(client, ticker),
fetch_financials(client, ticker),
fetch_scores(client, ticker),
fetch_news(client, ticker),
fetch_analyst_ratings(client, ticker),
get_holdings(),
get_preferences(),
)

return {
"raw_data": {
"quote": quote,
"profile": profile,
"key_metrics": key_metrics,
"financials": financials,
"scores": scores,
"news": news,
"analyst": analyst,
},
"holdings": holdings,
"preferences": preferences,
}

except Exception as exc: # noqa: BLE001
return {"error": f"data_agent failed: {exc}"}
Loading
Loading