diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9cfc6ec..9cdfe97 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 diff --git a/api/src/main.py b/api/src/main.py index f62d1d5..2d9b254 100644 --- a/api/src/main.py +++ b/api/src/main.py @@ -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") diff --git a/api/src/models.py b/api/src/models.py index 54c3c92..29cbcb6 100644 --- a/api/src/models.py +++ b/api/src/models.py @@ -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", } } ) @@ -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.", + ) diff --git a/api/src/routers/decisions.py b/api/src/routers/decisions.py index 263c218..20840c3 100644 --- a/api/src/routers/decisions.py +++ b/api/src/routers/decisions.py @@ -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) + + @router.get( "/{symbol}", responses={200: {"content": {"application/json": {"example": _example}}}}, @@ -29,7 +39,7 @@ async def get_decision(symbol: str): @router.post( - "/", + "", status_code=201, responses={201: {"content": {"application/json": {"example": {"saved": "NVDA"}}}}}, ) diff --git a/api/src/routers/holdings.py b/api/src/routers/holdings.py index 281f12b..c5063f4 100644 --- a/api/src/routers/holdings.py +++ b/api/src/routers/holdings.py @@ -19,7 +19,7 @@ @router.get( - "/", + "", responses={200: {"content": {"application/json": {"example": [_example]}}}}, ) async def list_holdings(): @@ -29,7 +29,7 @@ async def list_holdings(): @router.post( - "/", + "", status_code=201, responses={201: {"content": {"application/json": {"example": {"upserted": 2}}}}}, ) diff --git a/api/src/routers/preferences.py b/api/src/routers/preferences.py index 5f1a402..6c2f499 100644 --- a/api/src/routers/preferences.py +++ b/api/src/routers/preferences.py @@ -15,7 +15,7 @@ @router.get( - "/", + "", responses={200: {"content": {"application/json": {"example": _example}}}}, ) async def get_preferences(): @@ -26,7 +26,7 @@ async def get_preferences(): @router.put( - "/", + "", responses={200: {"content": {"application/json": {"example": _example}}}}, ) async def update_preferences(prefs: Preferences): diff --git a/api/tests/test_auth.py b/api/tests/test_auth.py index 3399d09..3c9e68e 100644 --- a/api/tests/test_auth.py +++ b/api/tests/test_auth.py @@ -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 diff --git a/api/tests/test_decisions.py b/api/tests/test_decisions.py index e655cb5..ad5f485 100644 --- a/api/tests/test_decisions.py +++ b/api/tests/test_decisions.py @@ -18,14 +18,14 @@ 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" @@ -33,7 +33,39 @@ async def test_get_case_insensitive(client): @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 diff --git a/api/tests/test_holdings.py b/api/tests/test_holdings.py index ef8f3be..7808443 100644 --- a/api/tests/test_holdings.py +++ b/api/tests/test_holdings.py @@ -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 diff --git a/api/tests/test_preferences.py b/api/tests/test_preferences.py index fdea00e..bd37840 100644 --- a/api/tests/test_preferences.py +++ b/api/tests/test_preferences.py @@ -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 @@ -14,7 +14,7 @@ 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" @@ -22,7 +22,7 @@ async def test_put_preferences(client): @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"] diff --git a/bot/.dockerignore b/bot/.dockerignore new file mode 100644 index 0000000..b730b9f --- /dev/null +++ b/bot/.dockerignore @@ -0,0 +1,9 @@ +.env +.env.* +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +tests/ +requirements-dev.txt +pytest.ini diff --git a/bot/.env.example b/bot/.env.example new file mode 100644 index 0000000..bb4641e --- /dev/null +++ b/bot/.env.example @@ -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) diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..2c7041f --- /dev/null +++ b/bot/Dockerfile @@ -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"] diff --git a/bot/pytest.ini b/bot/pytest.ini new file mode 100644 index 0000000..0cd96f9 --- /dev/null +++ b/bot/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +asyncio_mode = auto +pythonpath = src +testpaths = tests diff --git a/bot/requirements-dev.txt b/bot/requirements-dev.txt new file mode 100644 index 0000000..8d6d9ee --- /dev/null +++ b/bot/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest==8.3.3 +pytest-asyncio==0.24.0 diff --git a/bot/requirements.txt b/bot/requirements.txt new file mode 100644 index 0000000..2ece064 --- /dev/null +++ b/bot/requirements.txt @@ -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 diff --git a/bot/src/__init__.py b/bot/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/src/agents/__init__.py b/bot/src/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/src/agents/data.py b/bot/src/agents/data.py new file mode 100644 index 0000000..74c9fff --- /dev/null +++ b/bot/src/agents/data.py @@ -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}"} diff --git a/bot/src/agents/decision.py b/bot/src/agents/decision.py new file mode 100644 index 0000000..748a714 --- /dev/null +++ b/bot/src/agents/decision.py @@ -0,0 +1,99 @@ +"""Decision Agent — synthesises fundamental + sentiment via LLM. + +Calls OpenRouter LLM with structured output (with_structured_output). +Outputs a DecisionOutput written to state["decision"]. +Persists verdict to trade-compass REST API. +""" + +from __future__ import annotations + +from typing import Literal, Optional + +from pydantic import BaseModel, Field + +from ..state import AnalysisState +from ..tools.llm import get_llm +from ..tools.portfolio_api import post_decision +from ..tools.prompt import build_decision_prompt + + +# ── Structured output schema ────────────────────────────────────────────────── + + +class DecisionOutput(BaseModel): + verdict: Literal["BUY", "HOLD", "SELL", "INSUFFICIENT_DATA"] = Field( + description="Investment verdict" + ) + confidence: Literal["low", "medium", "medium-high", "high"] = Field( + description="Confidence level in the verdict" + ) + thesis: str = Field( + description="2-3 sentence investment thesis explaining the verdict" + ) + key_assumptions: list[str] = Field( + description="2-3 key assumptions this verdict depends on" + ) + stop_loss: Optional[float] = Field( + default=None, description="Suggested stop-loss price. Null if not applicable." + ) + target_price: Optional[float] = Field( + default=None, description="12-month price target. Null if insufficient data." + ) + + +# ── Agent ───────────────────────────────────────────────────────────────────── + + +async def decision_agent(state: AnalysisState) -> dict: + """ + Builds a structured prompt from fundamental + sentiment analysis, + calls OpenRouter LLM with structured output, persists result to REST API. + Writes: decision + """ + ticker = state.get("ticker", "") + raw = state.get("raw_data", {}) + fundamental = state.get("fundamental_analysis", {}) + sentiment = state.get("sentiment_analysis", {}) + preferences = state.get("preferences", {}) + + try: + prompt = build_decision_prompt( + ticker=ticker, + profile=raw.get("profile", {}), + fundamental=fundamental, + sentiment=sentiment, + preferences=preferences, + ) + + # Model selected dynamically from user preferences (set via /model in bot) + llm = get_llm( + model=preferences.get("llm_model"), + output_schema=DecisionOutput, + ) + result: DecisionOutput = await llm.ainvoke(prompt) + + # Persist to REST API — only for actionable verdicts (BUY/HOLD/SELL). + # INSUFFICIENT_DATA is not accepted by the API model and has nothing to store. + if result.verdict != "INSUFFICIENT_DATA": + try: + await post_decision( + symbol=ticker, + verdict=result.verdict, + reasoning=result.thesis, + ) + except Exception: # noqa: BLE001 + pass + + return {"decision": result.model_dump()} + + except Exception as exc: # noqa: BLE001 + return { + "decision": { + "verdict": "INSUFFICIENT_DATA", + "confidence": "low", + "thesis": f"Analysis failed: {exc}", + "key_assumptions": [], + "stop_loss": None, + "target_price": None, + } + } diff --git a/bot/src/agents/fundamental.py b/bot/src/agents/fundamental.py new file mode 100644 index 0000000..b0e9c3a --- /dev/null +++ b/bot/src/agents/fundamental.py @@ -0,0 +1,68 @@ +"""Fundamental Agent — organises valuation, growth, quality, and FMP scores. + +Piotroski F-Score and Altman Z-Score are pre-computed by FMP (/stable/scores). +No manual calculation, no LLM call here. + +All data is passed as structured context to decision_agent's LLM prompt. +""" + +from __future__ import annotations + +from ..state import AnalysisState + + +async def fundamental_agent(state: AnalysisState) -> dict: + """ + Organises fundamental context from raw_data. + Writes: fundamental_analysis + """ + raw = state.get("raw_data", {}) + quote = raw.get("quote", {}) + key_metrics = raw.get("key_metrics", {}) + financials = raw.get("financials", {}) + scores = raw.get("scores", {}) + + revenues = financials.get("total_revenue", []) + eps_list = financials.get("diluted_eps", []) + + rev_growth_pct = None + if len(revenues) >= 2 and revenues[0] and revenues[1]: + rev_growth_pct = round((revenues[0] - revenues[1]) / abs(revenues[1]) * 100, 1) + + eps_growth_pct = None + if len(eps_list) >= 2 and eps_list[0] and eps_list[1] and eps_list[1] > 0: + eps_growth_pct = round((eps_list[0] - eps_list[1]) / abs(eps_list[1]) * 100, 1) + + return { + "fundamental_analysis": { + # Pre-computed scores from FMP (objective anchors for LLM) + "scores": { + "piotroski": scores.get("piotroski_score"), # 0–9 + "altman_z": scores.get("altman_z_score"), # >2.99 = safe + }, + # Valuation + "valuation": { + "pe_ratio": key_metrics.get("pe_ratio"), + "ev_to_ebitda": key_metrics.get("ev_to_ebitda"), + "ev_to_sales": key_metrics.get("ev_to_sales"), + "market_cap": quote.get("market_cap"), + }, + # Growth + "growth": { + "revenue_growth_pct": rev_growth_pct, + "eps_growth_pct": eps_growth_pct, + "latest_revenue": revenues[0] if revenues else None, + "latest_eps": eps_list[0] if eps_list else None, + }, + # Quality + "quality": { + "return_on_equity": key_metrics.get("return_on_equity"), + "return_on_invested_capital": key_metrics.get( + "return_on_invested_capital" + ), + "free_cashflow_yield": key_metrics.get("free_cashflow_yield"), + "net_debt_to_ebitda": key_metrics.get("net_debt_to_ebitda"), + "current_ratio": key_metrics.get("current_ratio"), + }, + } + } diff --git a/bot/src/agents/portfolio.py b/bot/src/agents/portfolio.py new file mode 100644 index 0000000..8524989 --- /dev/null +++ b/bot/src/agents/portfolio.py @@ -0,0 +1,148 @@ +"""Portfolio Agent — runs single-stock analysis across all STOCK holdings. + +Filters out ETF, BOND, FUND, WARRANT, FUTURE positions. +Runs the single-stock subgraph sequentially per holding (avoids overwhelming +the FMP free-tier rate limit with concurrent bursts). +Detects concentration risk based on user preferences. +""" + +from __future__ import annotations + +from ..state import AnalysisState +from ..tools.portfolio_api import get_preferences + + +_STOCK_TYPE = "STOCK" + + +# ── Concentration risk ──────────────────────────────────────────────────────── + + +def _detect_concentration_risk( + holdings: list[dict], + verdicts: list[dict], + max_position_size: float, +) -> list[dict]: + """ + Flag positions that exceed max_position_size or top-3 concentration > 60%. + """ + total_value = sum(h.get("market_value", 0) for h in holdings) + if total_value == 0: + return [] + + flags = [] + weights = [] + + for h in holdings: + weight = h.get("market_value", 0) / total_value + weights.append((h["symbol"], weight)) + if weight > max_position_size: + flags.append( + { + "ticker": h["symbol"], + "weight_pct": round(weight * 100, 1), + "flag": f"exceeds max position size ({max_position_size * 100:.0f}%)", + } + ) + + # Top-3 concentration + weights.sort(key=lambda x: x[1], reverse=True) + top3_weight = sum(w for _, w in weights[:3]) + if top3_weight > 0.60: + flags.append( + { + "ticker": ", ".join(s for s, _ in weights[:3]), + "weight_pct": round(top3_weight * 100, 1), + "flag": "top-3 positions exceed 60% of portfolio", + } + ) + + return flags + + +# ── Agent ───────────────────────────────────────────────────────────────────── + + +async def portfolio_agent(state: AnalysisState) -> dict: + """ + Runs single-stock analysis for each STOCK holding. + Writes: portfolio_summary + """ + # Import here to avoid circular import at module load time + from ..graph.workflow import single_stock_graph + + preferences = state.get("preferences") or await get_preferences() + all_holdings = state.get("holdings") or [] + + # Filter to STOCK only + stock_holdings = [ + h for h in all_holdings if h.get("security_type", "").upper() == _STOCK_TYPE + ] + + if not stock_holdings: + return { + "portfolio_summary": { + "holdings_count": 0, + "analyzed_count": 0, + "verdicts": [], + "concentration_risk": [], + "error": "No STOCK positions found in holdings.", + } + } + + verdicts = [] + + # Run sequentially to respect FMP free-tier rate limit + for holding in stock_holdings: + ticker = holding["symbol"] + try: + result = await single_stock_graph.ainvoke( + { + "ticker": ticker, + "mode": "single", + "preferences": preferences, + "raw_data": {}, + "holdings": [], + "fundamental_analysis": {}, + "sentiment_analysis": {}, + "decision": None, + "portfolio_summary": {}, + "error": None, + } + ) + decision = result.get("decision") or {} + verdicts.append( + { + "ticker": ticker, + "qty": holding.get("qty"), + "market_value": holding.get("market_value"), + "verdict": decision.get("verdict", "INSUFFICIENT_DATA"), + "confidence": decision.get("confidence", "low"), + "thesis": decision.get("thesis", ""), + "stop_loss": decision.get("stop_loss"), + "target_price": decision.get("target_price"), + } + ) + except Exception as exc: # noqa: BLE001 + verdicts.append( + { + "ticker": ticker, + "verdict": "INSUFFICIENT_DATA", + "confidence": "low", + "thesis": f"Analysis failed: {exc}", + } + ) + + max_position_size = preferences.get("max_position_size", 0.1) + concentration_risk = _detect_concentration_risk( + stock_holdings, verdicts, max_position_size + ) + + return { + "portfolio_summary": { + "holdings_count": len(all_holdings), + "analyzed_count": len(stock_holdings), + "verdicts": verdicts, + "concentration_risk": concentration_risk, + } + } diff --git a/bot/src/agents/sentiment.py b/bot/src/agents/sentiment.py new file mode 100644 index 0000000..e4ee403 --- /dev/null +++ b/bot/src/agents/sentiment.py @@ -0,0 +1,65 @@ +"""Sentiment Agent — organises analyst ratings, news, and timing data. + +No scoring here. Raw context is passed to decision_agent's LLM prompt, +which interprets sentiment and timing in light of industry/narrative context. +""" + +from __future__ import annotations + +from ..state import AnalysisState + + +async def sentiment_agent(state: AnalysisState) -> dict: + """ + Organises sentiment and timing context from raw_data. + Writes: sentiment_analysis + """ + raw = state.get("raw_data", {}) + quote = raw.get("quote", {}) + analyst = raw.get("analyst", {}) + news = raw.get("news", []) + + price = quote.get("current_price") + high_52 = quote.get("fifty_two_week_high") + low_52 = quote.get("fifty_two_week_low") + targets = analyst.get("price_targets", {}) + mean_target = targets.get("mean") + + # Price position in 52-week range (0 = at low, 1 = at high) + position_in_range = None + if price and high_52 and low_52 and high_52 > low_52: + position_in_range = round((price - low_52) / (high_52 - low_52), 3) + + # Upside to mean analyst price target + upside_to_target_pct = None + if price and mean_target and price > 0: + upside_to_target_pct = round((mean_target - price) / price * 100, 1) + + return { + "sentiment_analysis": { + # Analyst ratings + "analyst": { + "price_targets": targets, + "upside_to_target_pct": upside_to_target_pct, + "recommendations": analyst.get("recommendations", []), + }, + # Timing / technicals + "timing": { + "current_price": price, + "fifty_two_week_high": high_52, + "fifty_two_week_low": low_52, + "position_in_52w_range": position_in_range, + "change_pct_today": quote.get("change_pct"), + }, + # News headlines for LLM to read + "news": [ + { + "title": n.get("title", ""), + "publisher": n.get("publisher", ""), + "published_at": n.get("published_at", ""), + "summary": n.get("summary", ""), + } + for n in news + ], + } + } diff --git a/bot/src/config.json b/bot/src/config.json new file mode 100644 index 0000000..68f2aa0 --- /dev/null +++ b/bot/src/config.json @@ -0,0 +1,34 @@ +{ + "llm_models": [ + { + "id": "meta-llama/llama-3.3-70b-instruct:free", + "name": "Llama 3.3 70B", + "description": "Solid general reasoning.", + "default": false + }, + { + "id": "deepseek/deepseek-v4-flash:free", + "name": "DeepSeek V4 Flash", + "description": "Strong finance analysis. 1M context.", + "default": false + }, + { + "id": "google/gemma-4-31b-it:free", + "name": "Gemma 4 31B", + "description": "Good multilingual support.", + "default": false + }, + { + "id": "nvidia/nemotron-3-super-120b-a12b:free", + "name": "Nemotron 120B", + "description": "Large model. Best reasoning quality. Default.", + "default": true + }, + { + "id": "qwen/qwen3-next-80b-a3b-instruct:free", + "name": "Qwen3 80B", + "description": "Strong analytical tasks.", + "default": false + } + ] +} diff --git a/bot/src/config.py b/bot/src/config.py new file mode 100644 index 0000000..9297951 --- /dev/null +++ b/bot/src/config.py @@ -0,0 +1,47 @@ +"""Bot configuration loader. + +Reads config.json at startup. Single source of truth for +configurable options (LLM models, etc.). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +_CONFIG_PATH = Path(__file__).parent / "config.json" + + +def _load() -> dict[str, Any]: + with open(_CONFIG_PATH) as f: + return json.load(f) + + +_config = _load() + + +# ── LLM models ──────────────────────────────────────────────────────────────── + + +def get_llm_models() -> list[dict[str, Any]]: + """Return all configured LLM models.""" + return _config["llm_models"] + + +def get_default_model_id() -> str: + """Return the default model ID (marked default=true in config.json).""" + for m in _config["llm_models"]: + if m.get("default"): + return m["id"] + return _config["llm_models"][0]["id"] + + +def get_model_ids() -> list[str]: + """Return all valid model IDs for validation.""" + return [m["id"] for m in _config["llm_models"]] + + +def is_valid_model(model_id: str) -> bool: + """Check if a model ID is in the configured list.""" + return model_id in get_model_ids() diff --git a/bot/src/graph/__init__.py b/bot/src/graph/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/src/graph/workflow.py b/bot/src/graph/workflow.py new file mode 100644 index 0000000..1d0147f --- /dev/null +++ b/bot/src/graph/workflow.py @@ -0,0 +1,114 @@ +"""LangGraph workflow topology. + +Two compiled graphs are exported: + + single_stock_graph — used by bot handlers for /decide, /choose + START → data_agent → (fundamental_agent ║ sentiment_agent) → decision_agent → END + + graph — full graph with intent routing, used as the main entry point + START → route_intent + ├─► (single) single_stock_graph nodes + └─► (portfolio) portfolio_agent → END +""" + +from __future__ import annotations + +from langgraph.graph import END, START, StateGraph + +from ..agents.data import data_agent +from ..agents.decision import decision_agent +from ..agents.fundamental import fundamental_agent +from ..agents.portfolio import portfolio_agent +from ..agents.sentiment import sentiment_agent +from ..state import AnalysisState + + +# ── Routing edge ────────────────────────────────────────────────────────────── + + +def route_intent(state: AnalysisState) -> str: + """Conditional edge: branch on analysis mode.""" + if state.get("error"): + return END + return "data_agent" if state["mode"] == "single" else "portfolio_agent" + + +# ── After data_agent: fan out to both analysis agents in parallel ───────────── + + +def after_data(state: AnalysisState) -> list[str]: + """Fan-out edge: run fundamental and sentiment agents in parallel.""" + if state.get("error"): + return [END] + return ["fundamental_agent", "sentiment_agent"] + + +# ── Single-stock subgraph (reused by portfolio_agent) ──────────────────────── + + +def build_single_stock_graph() -> StateGraph: + builder = StateGraph(AnalysisState) + + builder.add_node("data_agent", data_agent) + builder.add_node("fundamental_agent", fundamental_agent) + builder.add_node("sentiment_agent", sentiment_agent) + builder.add_node("decision_agent", decision_agent) + + builder.add_edge(START, "data_agent") + builder.add_conditional_edges( + "data_agent", + after_data, + { + "fundamental_agent": "fundamental_agent", + "sentiment_agent": "sentiment_agent", + END: END, + }, + ) + builder.add_edge("fundamental_agent", "decision_agent") + builder.add_edge("sentiment_agent", "decision_agent") + builder.add_edge("decision_agent", END) + + return builder.compile() + + +# ── Full graph (main entry point) ───────────────────────────────────────────── + + +def build_graph() -> StateGraph: + builder = StateGraph(AnalysisState) + + builder.add_node("data_agent", data_agent) + builder.add_node("fundamental_agent", fundamental_agent) + builder.add_node("sentiment_agent", sentiment_agent) + builder.add_node("decision_agent", decision_agent) + builder.add_node("portfolio_agent", portfolio_agent) + + builder.add_conditional_edges( + START, + route_intent, + { + "data_agent": "data_agent", + "portfolio_agent": "portfolio_agent", + END: END, + }, + ) + builder.add_conditional_edges( + "data_agent", + after_data, + { + "fundamental_agent": "fundamental_agent", + "sentiment_agent": "sentiment_agent", + END: END, + }, + ) + builder.add_edge("fundamental_agent", "decision_agent") + builder.add_edge("sentiment_agent", "decision_agent") + builder.add_edge("decision_agent", END) + builder.add_edge("portfolio_agent", END) + + return builder.compile() + + +# Exported compiled graphs +single_stock_graph = build_single_stock_graph() +graph = build_graph() diff --git a/bot/src/main.py b/bot/src/main.py new file mode 100644 index 0000000..fb7377a --- /dev/null +++ b/bot/src/main.py @@ -0,0 +1,29 @@ +"""trade-compass-bot entry point. + +FastAPI server with three endpoints: + GET /health — Cloud Run health check + POST /webhook — Telegram webhook (user messages) + POST /push — Cloud Scheduler active push notifications +""" + +from __future__ import annotations + +from pathlib import Path + +from dotenv import load_dotenv +from fastapi import FastAPI + +load_dotenv(dotenv_path=Path(__file__).parent / ".env") + +from .tg.bot import lifespan, webhook_router # noqa: E402 +from .tg.push import push_router # noqa: E402 + +app = FastAPI(title="trade-compass-bot", lifespan=lifespan) + +app.include_router(webhook_router) +app.include_router(push_router) + + +@app.get("/health") +async def health() -> dict: + return {"status": "ok"} diff --git a/bot/src/state.py b/bot/src/state.py new file mode 100644 index 0000000..144203f --- /dev/null +++ b/bot/src/state.py @@ -0,0 +1,72 @@ +"""Shared state TypedDict for the LangGraph analysis workflow.""" + +from __future__ import annotations + +from typing import Any, Literal, Optional +from typing_extensions import TypedDict + + +Verdict = Literal["BUY", "HOLD", "SELL", "INSUFFICIENT_DATA"] + + +class Position(TypedDict): + ticker: str + qty: float + cost_price: float + market_price: float + unrealized_pl: float + unrealized_pl_ratio: float + + +class ScoreCard(TypedDict): + """Five-dimension scorecard (0–10 each).""" + + valuation: float + growth: float + quality: float + sentiment: float + timing: float + + +class DecisionOutput(TypedDict): + verdict: Verdict + confidence: str # "low" | "medium" | "medium-high" | "high" + thesis: str + key_assumptions: list[str] + stop_loss: Optional[float] + target_price: Optional[float] + score_card: ScoreCard + + +class AnalysisState(TypedDict): + """ + Single source of truth passed through every graph node. + + Populated incrementally: + - Orchestrator edge sets: ticker, mode, preferences + - data_agent sets: raw_data, holdings + - fundamental_agent sets: fundamental_analysis + - sentiment_agent sets: sentiment_analysis + - decision_agent sets: decision + - portfolio_agent sets: portfolio_summary + """ + + # ── Input ───────────────────────────────────────────────────── + ticker: Optional[str] # None when mode == "portfolio" + mode: Literal["single", "portfolio"] + preferences: dict[str, Any] # {style, horizon, risk} + + # ── Data layer ──────────────────────────────────────────────── + raw_data: dict[str, Any] # Yahoo Finance quote + financials + news + holdings: list[Position] # current Futu positions from REST API + + # ── Agent outputs ───────────────────────────────────────────── + fundamental_analysis: dict[str, Any] + sentiment_analysis: dict[str, Any] + decision: Optional[DecisionOutput] + + # ── Portfolio mode only ─────────────────────────────────────── + portfolio_summary: dict[str, Any] # per-holding verdicts + concentration + + # ── Error propagation ───────────────────────────────────────── + error: Optional[str] diff --git a/bot/src/tg/__init__.py b/bot/src/tg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/src/tg/bot.py b/bot/src/tg/bot.py new file mode 100644 index 0000000..b5d6483 --- /dev/null +++ b/bot/src/tg/bot.py @@ -0,0 +1,78 @@ +"""Telegram bot initialisation and webhook router. + +Registers the Application instance (shared across all handlers) +and exposes a FastAPI router that receives Telegram webhook updates. + +Application.initialize() / shutdown() are called once via FastAPI lifespan, +not on every request. +""" + +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from fastapi import APIRouter, Request, Response +from telegram import Update +from telegram.ext import ( + Application, + CallbackQueryHandler, + CommandHandler, +) + +from .handlers import ( + handle_decide, + handle_help, + handle_model, + handle_model_selection, + handle_portfolio, +) + +# ── Telegram Application (singleton) ───────────────────────────────────────── + + +def build_application() -> Application: + token = os.environ.get("TELEGRAM_BOT_TOKEN", "") + app = Application.builder().token(token).build() + + app.add_handler(CommandHandler("decide", handle_decide)) + app.add_handler(CommandHandler("portfolio", handle_portfolio)) + app.add_handler(CommandHandler("model", handle_model)) + app.add_handler(CommandHandler("help", handle_help)) + app.add_handler(CommandHandler("start", handle_help)) + + # Inline keyboard callback for /model selection + app.add_handler(CallbackQueryHandler(handle_model_selection, pattern=r"^model:")) + + return app + + +application = build_application() + + +# ── Lifespan: initialize once at startup, shutdown cleanly ─────────────────── + + +@asynccontextmanager +async def lifespan(_: object) -> AsyncGenerator[None, None]: + """Initialize Telegram Application on startup; shut it down on stop.""" + if not os.environ.get("TELEGRAM_BOT_TOKEN"): + raise RuntimeError("TELEGRAM_BOT_TOKEN is not set — cannot start bot") + await application.initialize() + yield + await application.shutdown() + + +# ── Webhook FastAPI router ──────────────────────────────────────────────────── + +webhook_router = APIRouter() + + +@webhook_router.post("/webhook") +async def webhook(request: Request) -> Response: + """Receive Telegram update and dispatch to handlers.""" + data = await request.json() + update = Update.de_json(data, application.bot) + await application.process_update(update) + return Response(status_code=200) diff --git a/bot/src/tg/handlers.py b/bot/src/tg/handlers.py new file mode 100644 index 0000000..972cfe4 --- /dev/null +++ b/bot/src/tg/handlers.py @@ -0,0 +1,225 @@ +"""Telegram command handlers. + +/decide NVDA → single-stock analysis +/portfolio → full portfolio analysis +/model → inline keyboard to select LLM model +/help → list commands +""" + +from __future__ import annotations + +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.ext import ContextTypes + +from ..config import get_llm_models, is_valid_model +from ..graph.workflow import graph, single_stock_graph +from ..tools.portfolio_api import get_preferences, get_holdings +import httpx +import os + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _esc(text: str) -> str: + """Escape HTML special characters for Telegram HTML parse mode.""" + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +def _format_decision(ticker: str, decision: dict) -> str: + verdict = decision.get("verdict", "N/A") + confidence = decision.get("confidence", "") + thesis = decision.get("thesis", "") + assumptions = decision.get("key_assumptions", []) + stop_loss = decision.get("stop_loss") + target = decision.get("target_price") + + emoji = {"BUY": "🟢", "HOLD": "🟡", "SELL": "🔴"}.get(verdict, "⚪") + + lines = [ + f"{emoji} {_esc(ticker)} — {_esc(verdict)} ({_esc(confidence)})", + f"{_esc(thesis)}", + ] + if assumptions: + lines.append("\nKey assumptions:") + for a in assumptions: + lines.append(f" • {_esc(str(a))}") + if stop_loss: + lines.append(f"\n🛑 Stop-loss: ${stop_loss:.2f}") + if target: + lines.append(f"🎯 Target: ${target:.2f}") + + return "\n".join(lines) + + +def _format_portfolio_summary(summary: dict) -> str: + verdicts = summary.get("verdicts", []) + risks = summary.get("concentration_risk", []) + analyzed = summary.get("analyzed_count", 0) + total = summary.get("holdings_count", 0) + + lines = [f"📁 Portfolio Analysis ({analyzed}/{total} stocks)\n"] + + for v in verdicts: + emoji = {"BUY": "🟢", "HOLD": "🟡", "SELL": "🔴"}.get(v.get("verdict"), "⚪") + lines.append( + f"{emoji} {_esc(v['ticker'])} — {_esc(v.get('verdict', ''))} ({_esc(v.get('confidence', ''))})" + ) + if v.get("thesis"): + lines.append(f" {_esc(v['thesis'][:120])}...") + + if risks: + lines.append("\n⚠️ Concentration Risk") + for r in risks: + lines.append( + f" • {_esc(r['ticker'])} {r['weight_pct']}% — {_esc(r['flag'])}" + ) + + return "\n".join(lines) + + +async def _get_initial_state(ticker: str | None, mode: str) -> dict: + preferences = await get_preferences() + holdings = await get_holdings() + return { + "ticker": ticker, + "mode": mode, + "preferences": preferences, + "holdings": holdings, + "raw_data": {}, + "fundamental_analysis": {}, + "sentiment_analysis": {}, + "decision": None, + "portfolio_summary": {}, + "error": None, + } + + +# ── Command handlers ────────────────────────────────────────────────────────── + + +async def handle_decide(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """/decide NVDA — single-stock analysis.""" + args = context.args + if not args: + await update.message.reply_text( + "Usage: /decide TICKER e.g. /decide NVDA", + parse_mode="HTML", + ) + return + + ticker = args[0].upper() + await update.message.reply_text( + f"🔍 Analysing {_esc(ticker)}...", parse_mode="HTML" + ) + + try: + state = await _get_initial_state(ticker, "single") + result = await single_stock_graph.ainvoke(state) + + if result.get("error"): + await update.message.reply_text(f"❌ Error: {result['error']}") + return + + text = _format_decision(ticker, result.get("decision") or {}) + await update.message.reply_text(text, parse_mode="HTML") + + except Exception as exc: # noqa: BLE001 + await update.message.reply_text(f"❌ Analysis failed: {exc}") + + +async def handle_portfolio(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """/portfolio — analyse all STOCK holdings.""" + await update.message.reply_text("📁 Analysing your portfolio...", parse_mode="HTML") + + try: + state = await _get_initial_state(None, "portfolio") + result = await graph.ainvoke(state) + + if result.get("error"): + await update.message.reply_text(f"❌ Error: {result['error']}") + return + + summary = result.get("portfolio_summary") or {} + if not summary.get("verdicts"): + await update.message.reply_text("No STOCK positions found.") + return + + text = _format_portfolio_summary(summary) + await update.message.reply_text(text, parse_mode="HTML") + + except Exception as exc: # noqa: BLE001 + await update.message.reply_text(f"❌ Portfolio analysis failed: {exc}") + + +async def handle_model(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """/model — show inline keyboard with available LLM models.""" + preferences = await get_preferences() + current_model = preferences.get("llm_model", "") + + models = get_llm_models() + keyboard = [] + for m in models: + label = f"{'✅ ' if m['id'] == current_model else ''}{m['name']} — {m['description']}" + keyboard.append([InlineKeyboardButton(label, callback_data=f"model:{m['id']}")]) + + reply_markup = InlineKeyboardMarkup(keyboard) + await update.message.reply_text( + "🤖 Select LLM model for analysis:", + reply_markup=reply_markup, + parse_mode="HTML", + ) + + +async def handle_model_selection( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Inline keyboard callback — saves selected model to preferences.""" + query = update.callback_query + await query.answer() + + model_id = query.data.replace("model:", "") + if not is_valid_model(model_id): + await query.edit_message_text("❌ Invalid model selection.") + return + + # Save to preferences via REST API + api_url = os.environ.get("API_URL", "").rstrip("/") + api_key = os.environ.get("API_KEY", "") + try: + async with httpx.AsyncClient() as client: + prefs = ( + await client.get( + f"{api_url}/preferences", + headers={"X-API-Key": api_key}, + timeout=10, + ) + ).json() + prefs["llm_model"] = model_id + await client.put( + f"{api_url}/preferences", + json=prefs, + headers={"X-API-Key": api_key}, + timeout=10, + ) + except Exception as exc: # noqa: BLE001 + await query.edit_message_text(f"❌ Failed to save model: {exc}") + return + + models = get_llm_models() + name = next((m["name"] for m in models if m["id"] == model_id), model_id) + await query.edit_message_text( + f"✅ Model set to {_esc(name)}", parse_mode="HTML" + ) + + +async def handle_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """/help — list available commands.""" + text = ( + "📊 trade-compass\n\n" + "/decide TICKER — analyse a single stock\n" + "/portfolio — analyse all your holdings\n" + "/model — select LLM model\n" + "/help — show this message" + ) + await update.message.reply_text(text, parse_mode="HTML") diff --git a/bot/src/tg/push.py b/bot/src/tg/push.py new file mode 100644 index 0000000..b5716ff --- /dev/null +++ b/bot/src/tg/push.py @@ -0,0 +1,69 @@ +"""Active push notifications triggered by Cloud Scheduler. + +5 push types (all times ET / UTC): + pre_market 9:25 AM / 13:25 UTC — pre-open brief + morning 11:00 AM / 15:00 UTC — mid-morning check + noon 12:30 PM / 16:30 UTC — lunch update + afternoon 2:30 PM / 18:30 UTC — afternoon check + post_market 4:05 PM / 20:05 UTC — closing summary + +Cloud Scheduler POSTs to /push with body: {"type": "pre_market"} +TELEGRAM_CHAT_ID must be set in environment (your personal chat ID). +""" + +from __future__ import annotations + +import os + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel +from telegram import Bot + +from ..graph.workflow import graph +from .handlers import _format_portfolio_summary, _get_initial_state + +push_router = APIRouter() + +_PUSH_TYPES = { + "pre_market": "🌅 Pre-market Brief (9:25 AM ET)", + "morning": "☀️ Morning Update (11:00 AM ET)", + "noon": "🌤 Midday Check (12:30 PM ET)", + "afternoon": "🌥 Afternoon Update (2:30 PM ET)", + "post_market": "🌆 Closing Summary (4:05 PM ET)", +} + + +class PushRequest(BaseModel): + type: str + + +@push_router.post("/push") +async def push(request: Request, body: PushRequest) -> dict: + """Triggered by Cloud Scheduler. Runs portfolio analysis and sends to Telegram.""" + if body.type not in _PUSH_TYPES: + raise HTTPException( + status_code=400, + detail=f"Unknown push type '{body.type}'. Valid: {list(_PUSH_TYPES)}", + ) + + chat_id = os.environ.get("TELEGRAM_CHAT_ID") + token = os.environ.get("TELEGRAM_BOT_TOKEN") + if not chat_id or not token: + raise HTTPException( + status_code=500, detail="TELEGRAM_CHAT_ID or TELEGRAM_BOT_TOKEN not set" + ) + + state = await _get_initial_state(None, "portfolio") + result = await graph.ainvoke(state) + + summary = result.get("portfolio_summary") or {} + if not summary.get("verdicts"): + return {"sent": False, "reason": "no stock positions"} + + header = _PUSH_TYPES[body.type] + text = f"{header}\n\n{_format_portfolio_summary(summary)}" + + bot = Bot(token=token) + await bot.send_message(chat_id=chat_id, text=text, parse_mode="HTML") + + return {"sent": True, "tickers": [v["ticker"] for v in summary["verdicts"]]} diff --git a/bot/src/tools/__init__.py b/bot/src/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/src/tools/llm.py b/bot/src/tools/llm.py new file mode 100644 index 0000000..f1e4d4c --- /dev/null +++ b/bot/src/tools/llm.py @@ -0,0 +1,51 @@ +"""LLM client via OpenRouter. + +Model is selected dynamically from user preferences (set via /model in Telegram). +Available models are defined in bot/config.json. +Default model is whichever entry has "default": true in config.json. +""" + +from __future__ import annotations + +import os +from typing import Any, Type + +from langchain_openai import ChatOpenAI +from pydantic import BaseModel + +from ..config import get_default_model_id + +_OPENROUTER_BASE = "https://openrouter.ai/api/v1" + + +def get_llm( + model: str | None = None, + output_schema: Type[BaseModel] | None = None, +) -> Any: + """ + Returns a LangChain ChatOpenAI client pointed at OpenRouter. + + Model priority: + 1. model argument (from user preferences, set via /model in Telegram) + 2. default model defined in bot/config.json + + If output_schema is provided, returns llm.with_structured_output(schema) + which guarantees the response is parsed into the given Pydantic model. + + Usage: + llm = get_llm(model="deepseek/deepseek-v4-flash:free", output_schema=DecisionOutput) + result: DecisionOutput = await llm.ainvoke(prompt) + """ + model = model or get_default_model_id() + + llm = ChatOpenAI( + model=model, + openai_api_key=os.environ.get("OPENROUTER_API_KEY", ""), + openai_api_base=_OPENROUTER_BASE, + temperature=0.2, # low temperature for consistent financial analysis + ) + + if output_schema is not None: + return llm.with_structured_output(output_schema) + + return llm diff --git a/bot/src/tools/market_data.py b/bot/src/tools/market_data.py new file mode 100644 index 0000000..8f7e2ed --- /dev/null +++ b/bot/src/tools/market_data.py @@ -0,0 +1,215 @@ +"""Financial Modeling Prep (FMP) market data tools. + +All calls are async via httpx. Requires FMP_API_KEY in environment. +Free tier: 250 requests/day. All endpoints use the /stable/ API (post-Aug 2025). + +Each function maps 1:1 to a single FMP endpoint. +Aggregation happens in data_agent, not here. +""" + +from __future__ import annotations + +import os +from typing import Any + +import httpx + +_BASE = "https://financialmodelingprep.com/stable" +_API_KEY = os.environ.get("FMP_API_KEY", "") + + +def _key() -> dict[str, str]: + return {"apikey": _API_KEY} + + +async def _get(client: httpx.AsyncClient, path: str, **params: Any) -> Any: + """GET a /stable/ endpoint. Returns [] gracefully on 401/403/404 (free tier limits).""" + resp = await client.get(f"{_BASE}{path}", params={**_key(), **params}, timeout=10) + if resp.status_code in (401, 403, 404): + return [] + resp.raise_for_status() + data = resp.json() + # Some restricted endpoints return a plain string error message + if isinstance(data, str): + return [] + return data + + +# ── One function per FMP endpoint ──────────────────────────────────────────── + + +async def fetch_quote(client: httpx.AsyncClient, ticker: str) -> dict[str, Any]: + """Real-time price, market cap, 52-week range. + + Source: GET /stable/quote?symbol= + """ + data = await _get(client, "/quote", symbol=ticker) + if not data: + return {} + q = data[0] + return { + "symbol": q.get("symbol", ticker), + "name": q.get("name", ""), + "current_price": q.get("price"), + "fifty_two_week_high": q.get("yearHigh"), + "fifty_two_week_low": q.get("yearLow"), + "market_cap": q.get("marketCap"), + "volume": q.get("volume"), + "change_pct": q.get("changePercentage"), + } + + +async def fetch_profile(client: httpx.AsyncClient, ticker: str) -> dict[str, Any]: + """Company profile: sector, industry, beta, description. + + Source: GET /stable/profile?symbol= + """ + data = await _get(client, "/profile", symbol=ticker) + if not data: + return {} + p = data[0] + return { + "name": p.get("companyName", ""), + "sector": p.get("sector", ""), + "industry": p.get("industry", ""), + "beta": p.get("beta"), + "description": p.get("description", ""), + "country": p.get("country", ""), + "currency": p.get("currency", "USD"), + } + + +async def fetch_key_metrics(client: httpx.AsyncClient, ticker: str) -> dict[str, Any]: + """Annual valuation and quality metrics: ROE, EV/EBITDA, FCF yield. + + Source: GET /stable/key-metrics?symbol=&limit=1 + """ + data = await _get(client, "/key-metrics", symbol=ticker, limit=1) + if not data: + return {} + m = data[0] + # PE = 1 / earningsYield when earningsYield > 0 + earnings_yield = m.get("earningsYield") + pe = round(1 / earnings_yield, 1) if earnings_yield and earnings_yield > 0 else None + return { + "pe_ratio": pe, + "ev_to_ebitda": m.get("evToEBITDA"), + "ev_to_sales": m.get("evToSales"), + "return_on_equity": m.get("returnOnEquity"), + "return_on_assets": m.get("returnOnAssets"), + "return_on_invested_capital": m.get("returnOnInvestedCapital"), + "free_cashflow_yield": m.get("freeCashFlowYield"), + "current_ratio": m.get("currentRatio"), + "net_debt_to_ebitda": m.get("netDebtToEBITDA"), + } + + +async def fetch_financials( + client: httpx.AsyncClient, ticker: str, limit: int = 4 +) -> dict[str, Any]: + """Annual income statement — last N years. + + Source: GET /stable/income-statement?symbol=&limit= + """ + data = await _get(client, "/income-statement", symbol=ticker, limit=limit) + if not data: + return {} + + periods, revenue, gross_profit = [], [], [] + operating_income, net_income, eps, ebitda = [], [], [], [] + + for row in data: + periods.append(row.get("date", "")) + revenue.append(row.get("revenue")) + gross_profit.append(row.get("grossProfit")) + operating_income.append(row.get("operatingIncome")) + net_income.append(row.get("netIncome")) + eps.append(row.get("epsDiluted")) + ebitda.append(row.get("ebitda")) + + return { + "periods": periods, + "total_revenue": revenue, + "gross_profit": gross_profit, + "operating_income": operating_income, + "net_income": net_income, + "diluted_eps": eps, + "ebitda": ebitda, + } + + +async def fetch_scores(client: httpx.AsyncClient, ticker: str) -> dict[str, Any]: + """Piotroski F-Score and Altman Z-Score (may be empty on free tier). + + Source: GET /stable/financial-scores?symbol= + """ + data = await _get(client, "/financial-scores", symbol=ticker) + if not data: + return {} + s = data[0] + return { + "piotroski_score": s.get("piotroskiScore"), + "altman_z_score": s.get("altmanZScore"), + } + + +async def fetch_news( + client: httpx.AsyncClient, ticker: str, limit: int = 8 +) -> list[dict[str, Any]]: + """Recent news headlines. Returns [] if restricted on free tier. + + Source: GET /stable/news?tickers=&limit= + """ + data = await _get(client, "/news", tickers=ticker, limit=limit) + return [ + { + "title": item.get("title", ""), + "publisher": item.get("site", ""), + "link": item.get("url", ""), + "published_at": item.get("publishedDate", ""), + "summary": item.get("text", ""), + } + for item in (data or []) + if isinstance(item, dict) + ] + + +async def fetch_analyst_ratings( + client: httpx.AsyncClient, ticker: str +) -> dict[str, Any]: + """Analyst price targets and recommendation breakdown. + + Sources: + GET /stable/price-target-consensus?symbol= + GET /stable/analyst-recommendation?symbol= + """ + import asyncio + + targets_data, recs_data = await asyncio.gather( + _get(client, "/price-target-consensus", symbol=ticker), + _get(client, "/analyst-recommendation", symbol=ticker, limit=2), + ) + + targets = targets_data[0] if targets_data else {} + rec_list = [ + { + "period": row.get("date", ""), + "strong_buy": row.get("analystRatingsStrongBuy", 0), + "buy": row.get("analystRatingsBuy", 0), + "hold": row.get("analystRatingsHold", 0), + "sell": row.get("analystRatingsSell", 0), + "strong_sell": row.get("analystRatingsStrongSell", 0), + } + for row in (recs_data or []) + if isinstance(row, dict) + ] + + return { + "price_targets": { + "low": targets.get("targetLow"), + "mean": targets.get("targetConsensus"), + "median": targets.get("targetMedian"), + "high": targets.get("targetHigh"), + }, + "recommendations": rec_list, + } diff --git a/bot/src/tools/portfolio_api.py b/bot/src/tools/portfolio_api.py new file mode 100644 index 0000000..76cd13e --- /dev/null +++ b/bot/src/tools/portfolio_api.py @@ -0,0 +1,46 @@ +"""REST API client for trade-compass-api (Cloud Run). + +Reads API_URL and API_KEY from environment (set via bot/.env). +""" + +from __future__ import annotations + +import os +from typing import Any + +import httpx + +_API_URL = os.environ.get("API_URL", "").rstrip("/") +_API_KEY = os.environ.get("API_KEY", "") + + +def _headers() -> dict[str, str]: + return {"X-API-Key": _API_KEY} + + +async def get_holdings() -> list[dict[str, Any]]: + """GET /holdings — returns current Futu positions.""" + async with httpx.AsyncClient() as client: + resp = await client.get(f"{_API_URL}/holdings", headers=_headers(), timeout=10) + resp.raise_for_status() + return resp.json() + + +async def get_preferences() -> dict[str, Any]: + """GET /preferences — returns user risk/style/sector settings.""" + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{_API_URL}/preferences", headers=_headers(), timeout=10 + ) + resp.raise_for_status() + return resp.json() + + +async def post_decision(symbol: str, verdict: str, reasoning: str) -> None: + """POST /decisions — persists a BUY/HOLD/SELL verdict to MongoDB.""" + payload = {"symbol": symbol.upper(), "verdict": verdict, "reasoning": reasoning} + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{_API_URL}/decisions", json=payload, headers=_headers(), timeout=10 + ) + resp.raise_for_status() diff --git a/bot/src/tools/prompt.py b/bot/src/tools/prompt.py new file mode 100644 index 0000000..6a0465c --- /dev/null +++ b/bot/src/tools/prompt.py @@ -0,0 +1,91 @@ +"""Prompt builder for decision_agent. + +Converts structured fundamental + sentiment analysis into a +concise, information-dense prompt for the LLM. +""" + +from __future__ import annotations + +from typing import Any + + +def build_decision_prompt( + ticker: str, + profile: dict[str, Any], + fundamental: dict[str, Any], + sentiment: dict[str, Any], + preferences: dict[str, Any], +) -> str: + f = fundamental + s = sentiment + + scores = f.get("scores", {}) + valuation = f.get("valuation", {}) + growth = f.get("growth", {}) + quality = f.get("quality", {}) + + analyst = s.get("analyst", {}) + timing = s.get("timing", {}) + news = s.get("news", []) + targets = analyst.get("price_targets", {}) + recs = (analyst.get("recommendations") or [{}])[0] + + headlines = ( + "\n".join( + f" - [{n.get('publisher', '')}] {n.get('title', '')}" for n in news[:5] + ) + or " No recent news." + ) + + piotroski = scores.get("piotroski") + altman_z = scores.get("altman_z") + + return f"""You are a senior equity analyst. Analyse the following data and provide a structured investment verdict. + +## Company +Ticker: {ticker} +Name: {profile.get('name') or ticker} +Sector: {profile.get('sector', 'N/A')} | Industry: {profile.get('industry', 'N/A')} + +## Financial Health (objective scores) +Piotroski F-Score: {piotroski}/9 (≥7 strong, ≤2 weak) +Altman Z-Score: {altman_z} (>2.99 safe, <1.81 distress) + +## Valuation +PE Ratio: {valuation.get('pe_ratio', 'N/A')} +EV/EBITDA: {valuation.get('ev_to_ebitda', 'N/A')} +EV/Sales: {valuation.get('ev_to_sales', 'N/A')} + +## Growth (YoY) +Revenue growth: {growth.get('revenue_growth_pct', 'N/A')}% +EPS growth: {growth.get('eps_growth_pct', 'N/A')}% +Latest EPS: {growth.get('latest_eps', 'N/A')} + +## Quality +ROE: {quality.get('return_on_equity', 'N/A')} +ROIC: {quality.get('return_on_invested_capital', 'N/A')} +FCF Yield: {quality.get('free_cashflow_yield', 'N/A')} +Net Debt/EBITDA: {quality.get('net_debt_to_ebitda', 'N/A')} +Current Ratio: {quality.get('current_ratio', 'N/A')} + +## Market Sentiment +Current price: ${timing.get('current_price', 'N/A')} +52w range: ${timing.get('fifty_two_week_low', 'N/A')} – ${timing.get('fifty_two_week_high', 'N/A')} +Position in range: {timing.get('position_in_52w_range', 'N/A')} (0=low, 1=high) +Analyst targets: low ${targets.get('low', 'N/A')} / mean ${targets.get('mean', 'N/A')} / high ${targets.get('high', 'N/A')} +Upside to mean: {analyst.get('upside_to_target_pct', 'N/A')}% +Analyst ratings: strongBuy={recs.get('strong_buy', 0)} buy={recs.get('buy', 0)} hold={recs.get('hold', 0)} sell={recs.get('sell', 0)} + +## Recent News +{headlines} + +## User Preferences +Risk tolerance: {preferences.get('risk_tolerance', 'medium')} +Sectors of interest: {', '.join(preferences.get('sectors', [])) or 'any'} +Max position size: {preferences.get('max_position_size', 0.1) * 100:.0f}% of portfolio + +## Instructions +Based on all the above, provide your investment verdict. +Consider sector-appropriate valuation benchmarks (e.g. high-growth tech warrants higher multiples). +Be concise but specific. Cite 2–3 key reasons for your verdict. +""" diff --git a/bot/tests/__init__.py b/bot/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/tests/test_api.py b/bot/tests/test_api.py new file mode 100644 index 0000000..087ae3d --- /dev/null +++ b/bot/tests/test_api.py @@ -0,0 +1,87 @@ +"""Integration tests — trade-compass REST API connectivity. + +Verifies we can reach the deployed Cloud Run API and read/write data. +Requires API_URL + API_KEY in bot/.env. + +Run: + cd trade-compass + python -m pytest bot/tests/test_api.py -v +""" + +from __future__ import annotations + +import pytest +import httpx +import os +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env") + +API_URL = os.environ.get("API_URL", "").rstrip("/") +API_KEY = os.environ.get("API_KEY", "") +HEADERS = {"X-API-Key": API_KEY} + + +@pytest.mark.asyncio +async def test_health(): + """/health returns 200 ok.""" + async with httpx.AsyncClient() as client: + resp = await client.get(f"{API_URL}/health", timeout=30) + print(f"\n status: {resp.status_code} body: {resp.json()}") + assert resp.status_code == 200 + assert resp.json().get("status") == "ok" + + +@pytest.mark.asyncio +async def test_auth_missing_key(): + """Request without API key returns 403.""" + async with httpx.AsyncClient() as client: + resp = await client.get(f"{API_URL}/holdings", timeout=30) + print(f"\n status: {resp.status_code}") + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_auth_wrong_key(): + """Request with wrong API key returns 401.""" + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{API_URL}/holdings", + headers={"X-API-Key": "wrong-key"}, + timeout=30, + ) + print(f"\n status: {resp.status_code}") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_holdings(): + """GET /holdings returns a list.""" + async with httpx.AsyncClient() as client: + resp = await client.get(f"{API_URL}/holdings", headers=HEADERS, timeout=30) + print(f"\n status: {resp.status_code} count: {len(resp.json())}") + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + +@pytest.mark.asyncio +async def test_get_preferences(): + """GET /preferences returns expected fields.""" + async with httpx.AsyncClient() as client: + resp = await client.get(f"{API_URL}/preferences", headers=HEADERS, timeout=30) + data = resp.json() + print(f"\n status: {resp.status_code} prefs: {data}") + assert resp.status_code == 200 + assert "risk_tolerance" in data + assert "llm_model" in data + + +@pytest.mark.asyncio +async def test_get_decisions(): + """GET /decisions returns a list.""" + async with httpx.AsyncClient() as client: + resp = await client.get(f"{API_URL}/decisions", headers=HEADERS, timeout=30) + print(f"\n status: {resp.status_code}") + assert resp.status_code == 200 + assert isinstance(resp.json(), list) diff --git a/bot/tests/test_fmp.py b/bot/tests/test_fmp.py new file mode 100644 index 0000000..35e6049 --- /dev/null +++ b/bot/tests/test_fmp.py @@ -0,0 +1,93 @@ +"""Integration tests — FMP API connectivity. + +Verifies we can reach Financial Modeling Prep and get real data. +Requires FMP_API_KEY in bot/.env. + +Run: + cd trade-compass + python -m pytest bot/tests/test_fmp.py -v +""" + +from __future__ import annotations + +import pytest +import httpx +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env") + +from bot.tools.market_data import ( # noqa: E402 + fetch_quote, + fetch_profile, + fetch_key_metrics, + fetch_financials, + fetch_scores, + fetch_news, + fetch_analyst_ratings, +) + +TICKER = "NVDA" + + +@pytest.fixture +async def client(): + async with httpx.AsyncClient() as c: + yield c + + +@pytest.mark.asyncio +async def test_fetch_quote(client): + data = await fetch_quote(client, TICKER) + print(f"\n quote: {data}") + assert data, "quote returned empty" + assert data.get("symbol") == TICKER + assert data.get("current_price") is not None, "no current_price" + + +@pytest.mark.asyncio +async def test_fetch_profile(client): + data = await fetch_profile(client, TICKER) + print(f"\n profile: {data}") + # 403 on free tier returns {} — just verify no exception + assert isinstance(data, dict) + + +@pytest.mark.asyncio +async def test_fetch_key_metrics(client): + data = await fetch_key_metrics(client, TICKER) + print(f"\n key_metrics: {data}") + assert isinstance(data, dict) + + +@pytest.mark.asyncio +async def test_fetch_financials(client): + data = await fetch_financials(client, TICKER) + print(f"\n financials periods: {data.get('periods')}") + assert isinstance(data, dict) + if data: + assert "total_revenue" in data + + +@pytest.mark.asyncio +async def test_fetch_scores(client): + data = await fetch_scores(client, TICKER) + print(f"\n scores: {data}") + assert isinstance(data, dict) + + +@pytest.mark.asyncio +async def test_fetch_news(client): + data = await fetch_news(client, TICKER, limit=3) + print(f"\n news count: {len(data)}") + assert isinstance(data, list) + if data: + assert "title" in data[0] + + +@pytest.mark.asyncio +async def test_fetch_analyst_ratings(client): + data = await fetch_analyst_ratings(client, TICKER) + print(f"\n analyst: {data}") + assert isinstance(data, dict) + assert "price_targets" in data diff --git a/bot/tests/test_openrouter.py b/bot/tests/test_openrouter.py new file mode 100644 index 0000000..d279be4 --- /dev/null +++ b/bot/tests/test_openrouter.py @@ -0,0 +1,74 @@ +"""Integration tests — OpenRouter LLM connectivity. + +Verifies we can reach OpenRouter and get a structured response. +Requires OPENROUTER_API_KEY in bot/.env. + +Run: + cd trade-compass + python -m pytest bot/tests/test_openrouter.py -v +""" + +from __future__ import annotations + +import pytest +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env") + +from bot.config import get_llm_models, get_default_model_id # noqa: E402 +from bot.tools.llm import get_llm # noqa: E402 +from bot.agents.decision import DecisionOutput # noqa: E402 + + +@pytest.mark.asyncio +async def test_default_model_reachable(): + """Default model returns a valid DecisionOutput for a simple prompt.""" + llm = get_llm(output_schema=DecisionOutput) + + prompt = """You are an investment analyst. + +## Company +Ticker: NVDA +Sector: Technology + +## Financial Health +Piotroski F-Score: 8/9 +Altman Z-Score: 9.2 + +## Valuation +Forward PE: 19 +EV/EBITDA: 40 + +## Growth +Revenue growth: 69% +EPS growth: 95% + +## Sentiment +Upside to target: 24% +Analyst ratings: strongBuy=35 buy=15 hold=5 + +## User Preferences +Risk tolerance: medium + +Provide your investment verdict.""" + + result: DecisionOutput = await llm.ainvoke(prompt) + print(f"\n verdict: {result.verdict}") + print(f" confidence: {result.confidence}") + print(f" thesis: {result.thesis[:100]}...") + + assert result.verdict in ("BUY", "HOLD", "SELL", "INSUFFICIENT_DATA") + assert result.confidence in ("low", "medium", "medium-high", "high") + assert len(result.thesis) > 20 + + +@pytest.mark.asyncio +async def test_all_configured_models_listed(): + """All models in config.json are accessible (just checks config, not API).""" + models = get_llm_models() + print(f"\n configured models: {len(models)}") + for m in models: + print(f" {'[default]' if m.get('default') else ' '} {m['name']}") + assert len(models) >= 1 + assert get_default_model_id() in [m["id"] for m in models] diff --git a/bot/tests/test_push.py b/bot/tests/test_push.py new file mode 100644 index 0000000..789189b --- /dev/null +++ b/bot/tests/test_push.py @@ -0,0 +1,80 @@ +"""Integration test — push notification endpoint. + +Tests POST /push with type=post_market end-to-end against the deployed bot. +Seeds test holdings before the test; restores originals after. + +Requires in bot/.env: + BOT_URL — deployed Cloud Run bot URL + API_URL — deployed Cloud Run API URL + API_KEY — API key + +Run: + cd trade-compass + python3 -m pytest bot/tests/test_push.py -v -s +""" + +from __future__ import annotations + +import os +import pytest +import httpx +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env") + +BOT_URL = os.environ.get("BOT_URL", "").rstrip("/") +API_URL = os.environ.get("API_URL", "").rstrip("/") +API_KEY = os.environ.get("API_KEY", "") +API_HEADERS = {"X-API-Key": API_KEY} + +TEST_HOLDINGS = [ + { + "symbol": "NVDA", + "name": "NVIDIA Corporation", + "security_type": "STOCK", + "qty": 10, + "avg_cost": 650.00, + "market_value": 9000.00, + "currency": "USD", + }, + { + "symbol": "AAPL", + "name": "Apple Inc.", + "security_type": "STOCK", + "qty": 5, + "avg_cost": 170.00, + "market_value": 975.00, + "currency": "USD", + }, +] + + +@pytest.fixture(autouse=True) +async def seed_and_cleanup(): + """Seed test holdings before test; restore originals after.""" + async with httpx.AsyncClient() as client: + original = (await client.get(f"{API_URL}/holdings", headers=API_HEADERS, timeout=30)).json() + await client.post(f"{API_URL}/holdings", json=TEST_HOLDINGS, headers=API_HEADERS, timeout=30) + + yield + + async with httpx.AsyncClient() as client: + await client.post(f"{API_URL}/holdings", json=original, headers=API_HEADERS, timeout=30) + + +@pytest.mark.asyncio +async def test_push_post_market(): + """POST /push post_market → sent=True, tickers non-empty, Telegram message delivered.""" + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{BOT_URL}/push", + json={"type": "post_market"}, + timeout=120, + ) + data = resp.json() + print(f"\n status={resp.status_code} body={data}") + assert resp.status_code == 200 + assert data.get("sent") is True + assert isinstance(data.get("tickers"), list) + assert len(data["tickers"]) > 0 diff --git a/sync/main.py b/sync/src/main.py similarity index 75% rename from sync/main.py rename to sync/src/main.py index a0d3c11..00df3d4 100644 --- a/sync/main.py +++ b/sync/src/main.py @@ -43,15 +43,19 @@ def fetch_positions() -> list[dict]: holdings = [] for _, row in data.iterrows(): - holdings.append({ - "symbol": row["code"].split(".")[-1], # US.AAPL → AAPL - "name": row.get("stock_name", ""), - "qty": float(row["qty"]), - "avg_cost": float(row["cost_price"]), - "market_value": float(row["market_val"]), - "security_type": _SECURITY_TYPE_MAP.get(str(row.get("security_type", "")).upper(), "NONE"), - "currency": "USD", - }) + holdings.append( + { + "symbol": row["code"].split(".")[-1], # US.AAPL → AAPL + "name": row.get("stock_name", ""), + "qty": float(row["qty"]), + "avg_cost": float(row["cost_price"]), + "market_value": float(row["market_val"]), + "security_type": _SECURITY_TYPE_MAP.get( + str(row.get("security_type", "")).upper(), "NONE" + ), + "currency": "USD", + } + ) return holdings finally: ctx.close() @@ -65,7 +69,7 @@ def push_holdings(holdings: list[dict]) -> None: with httpx.Client(timeout=30) as client: r = client.post( - f"{API_URL}/holdings/", + f"{API_URL}/holdings", json=holdings, headers={"X-API-Key": API_KEY}, ) diff --git a/terraform/atlas/main.tf b/terraform/atlas/main.tf index 450e24a..ae749e7 100644 --- a/terraform/atlas/main.tf +++ b/terraform/atlas/main.tf @@ -21,6 +21,7 @@ data "terraform_remote_state" "compute_engine" { } } + # ── Project ─────────────────────────────────────────────────── resource "mongodbatlas_project" "trade_compass" { name = var.project_name @@ -57,3 +58,11 @@ resource "mongodbatlas_project_ip_access_list" "compute_engine" { ip_address = data.terraform_remote_state.compute_engine.outputs.external_ip comment = "Compute Engine static IP" } + +# M0 free tier does not support VPC peering or private endpoints. +# Real security is the credentials stored in Secret Manager. +resource "mongodbatlas_project_ip_access_list" "cloud_run" { + project_id = mongodbatlas_project.trade_compass.id + cidr_block = "0.0.0.0/0" + comment = "Cloud Run egress — M0 does not support VPC peering; auth via Secret Manager" +} diff --git a/terraform/bootstrap/main.tf b/terraform/bootstrap/main.tf index e08c501..f55c8d9 100644 --- a/terraform/bootstrap/main.tf +++ b/terraform/bootstrap/main.tf @@ -20,6 +20,8 @@ resource "google_project_service" "apis" { "artifactregistry.googleapis.com", "compute.googleapis.com", "storage.googleapis.com", + "cloudbuild.googleapis.com", + "cloudscheduler.googleapis.com", ]) service = each.value disable_on_destroy = false diff --git a/terraform/bot/backend.tf b/terraform/bot/backend.tf new file mode 100644 index 0000000..2770f71 --- /dev/null +++ b/terraform/bot/backend.tf @@ -0,0 +1,3 @@ +terraform { + backend "gcs" {} +} diff --git a/terraform/bot/main.tf b/terraform/bot/main.tf new file mode 100644 index 0000000..524bab6 --- /dev/null +++ b/terraform/bot/main.tf @@ -0,0 +1,267 @@ +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.0" + } + null = { + source = "hashicorp/null" + version = "~> 3.0" + } + time = { + source = "hashicorp/time" + version = "~> 0.11" + } + } +} + +provider "google" { + project = var.gcp_project_id + region = var.region +} + +data "google_project" "project" { + project_id = var.gcp_project_id +} + +# ── Remote state: read API URL + key from cloud_run ─────────────────────────── +data "terraform_remote_state" "cloud_run" { + backend = "gcs" + config = { + bucket = coalesce(var.tfstate_bucket, "trade-compass-tfstate-${var.gcp_project_id}") + prefix = "cloud_run" + } +} + +# ── Image: reuse existing Artifact Registry repo ────────────────────────────── +locals { + src_hash = sha1(join("", [for f in sort(fileset("${path.root}/../../bot/src", "**")) : filesha1("${path.root}/../../bot/src/${f}")])) + image = "${var.region}-docker.pkg.dev/${var.gcp_project_id}/trade-compass/bot:${local.src_hash}" + cloudbuild_sa = "serviceAccount:${data.google_project.project.number}-compute@developer.gserviceaccount.com" +} + +# ── Build and push bot image via Cloud Build ────────────────────────────────── +resource "null_resource" "build_push" { + triggers = { + src_hash = local.src_hash + } + + provisioner "local-exec" { + command = "gcloud builds submit ${path.root}/../../bot --tag ${local.image} --project ${var.gcp_project_id}" + } +} + +# ── Service account ─────────────────────────────────────────────────────────── +resource "google_service_account" "bot" { + account_id = "trade-compass-bot" + display_name = "trade-compass Bot Cloud Run SA" +} + +resource "google_artifact_registry_repository_iam_member" "bot_image_pull" { + location = var.region + repository = "trade-compass" + role = "roles/artifactregistry.reader" + member = "serviceAccount:${google_service_account.bot.email}" +} + +# ── Secrets ─────────────────────────────────────────────────────────────────── +resource "google_secret_manager_secret" "telegram_bot_token" { + secret_id = "trade-compass-telegram-bot-token" + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "telegram_bot_token" { + secret = google_secret_manager_secret.telegram_bot_token.id + secret_data = var.telegram_bot_token +} + +resource "google_secret_manager_secret" "telegram_chat_id" { + secret_id = "trade-compass-telegram-chat-id" + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "telegram_chat_id" { + secret = google_secret_manager_secret.telegram_chat_id.id + secret_data = var.telegram_chat_id +} + +resource "google_secret_manager_secret" "fmp_api_key" { + secret_id = "trade-compass-fmp-api-key" + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "fmp_api_key" { + secret = google_secret_manager_secret.fmp_api_key.id + secret_data = var.fmp_api_key +} + +resource "google_secret_manager_secret" "openrouter_api_key" { + secret_id = "trade-compass-openrouter-api-key" + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "openrouter_api_key" { + secret = google_secret_manager_secret.openrouter_api_key.id + secret_data = var.openrouter_api_key +} + +# ── Grant bot SA access to all secrets ─────────────────────────────────────── +locals { + bot_secrets = [ + google_secret_manager_secret.telegram_bot_token.id, + google_secret_manager_secret.telegram_chat_id.id, + google_secret_manager_secret.fmp_api_key.id, + google_secret_manager_secret.openrouter_api_key.id, + ] +} + +resource "google_secret_manager_secret_iam_member" "bot_secret_access" { + count = length(local.bot_secrets) + secret_id = local.bot_secrets[count.index] + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.bot.email}" +} + +# Also grant access to API key secret (owned by cloud_run module) +resource "google_secret_manager_secret_iam_member" "bot_api_key_access" { + secret_id = "trade-compass-api-key" + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.bot.email}" +} + +# ── Cloud Run service ───────────────────────────────────────────────────────── +resource "google_cloud_run_v2_service" "bot" { + name = "trade-compass-bot" + location = var.region + + template { + service_account = google_service_account.bot.email + + containers { + image = local.image + + env { + name = "TELEGRAM_BOT_TOKEN" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.telegram_bot_token.secret_id + version = "latest" + } + } + } + + env { + name = "TELEGRAM_CHAT_ID" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.telegram_chat_id.secret_id + version = "latest" + } + } + } + + env { + name = "FMP_API_KEY" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.fmp_api_key.secret_id + version = "latest" + } + } + } + + env { + name = "OPENROUTER_API_KEY" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.openrouter_api_key.secret_id + version = "latest" + } + } + } + + env { + name = "API_URL" + value = data.terraform_remote_state.cloud_run.outputs.service_url + } + + env { + name = "API_KEY" + value_source { + secret_key_ref { + secret = "trade-compass-api-key" + version = "latest" + } + } + } + + resources { + limits = { + memory = "1Gi" + cpu = "1" + } + } + } + } + + depends_on = [ + null_resource.build_push, + google_secret_manager_secret_iam_member.bot_secret_access, + google_secret_manager_secret_iam_member.bot_api_key_access, + ] +} + +# ── Allow unauthenticated (Telegram webhook needs public access) ────────────── +resource "google_cloud_run_v2_service_iam_member" "public" { + name = google_cloud_run_v2_service.bot.name + location = var.region + role = "roles/run.invoker" + member = "allUsers" +} + +# ── Cloud Scheduler: 5 daily push notifications ─────────────────────────────── +# All times in UTC (ET = UTC-4 in summer, UTC-5 in winter; using UTC-4 / EDT) +# pre_market 09:25 ET → 13:25 UTC +# morning 11:00 ET → 15:00 UTC +# noon 12:30 ET → 16:30 UTC +# afternoon 14:30 ET → 18:30 UTC +# post_market 16:05 ET → 20:05 UTC + +locals { + push_schedules = { + pre_market = "25 13 * * 1-5" + morning = "0 15 * * 1-5" + noon = "30 16 * * 1-5" + afternoon = "30 18 * * 1-5" + post_market = "5 20 * * 1-5" + } +} + +resource "google_cloud_scheduler_job" "push" { + for_each = local.push_schedules + + name = "trade-compass-push-${each.key}" + description = "trade-compass bot ${each.key} push" + schedule = each.value + time_zone = "UTC" + attempt_deadline = "180s" + + http_target { + http_method = "POST" + uri = "${google_cloud_run_v2_service.bot.uri}/push" + body = base64encode(jsonencode({ type = each.key })) + headers = { + "Content-Type" = "application/json" + } + } + + depends_on = [google_cloud_run_v2_service.bot] +} diff --git a/terraform/bot/outputs.tf b/terraform/bot/outputs.tf new file mode 100644 index 0000000..c52d50c --- /dev/null +++ b/terraform/bot/outputs.tf @@ -0,0 +1,4 @@ +output "bot_url" { + description = "Cloud Run bot service URL (use as Telegram webhook URL)" + value = google_cloud_run_v2_service.bot.uri +} diff --git a/terraform/bot/variables.tf b/terraform/bot/variables.tf new file mode 100644 index 0000000..9fc04df --- /dev/null +++ b/terraform/bot/variables.tf @@ -0,0 +1,40 @@ +variable "gcp_project_id" { + description = "GCP project ID" + type = string +} + +variable "region" { + description = "GCP region" + type = string + default = "us-west1" +} + +variable "tfstate_bucket" { + description = "GCS bucket name for Terraform remote state" + type = string + default = null +} + +variable "telegram_bot_token" { + description = "Telegram bot token from BotFather" + type = string + sensitive = true +} + +variable "telegram_chat_id" { + description = "Telegram chat ID to send push notifications to" + type = string + sensitive = true +} + +variable "fmp_api_key" { + description = "Financial Modeling Prep API key" + type = string + sensitive = true +} + +variable "openrouter_api_key" { + description = "OpenRouter API key for LLM access" + type = string + sensitive = true +} diff --git a/terraform/cloud_run/main.tf b/terraform/cloud_run/main.tf index daa64f7..f3db5ad 100644 --- a/terraform/cloud_run/main.tf +++ b/terraform/cloud_run/main.tf @@ -12,6 +12,10 @@ terraform { source = "hashicorp/random" version = "~> 3.0" } + time = { + source = "hashicorp/time" + version = "~> 0.11" + } } } @@ -27,24 +31,61 @@ resource "google_artifact_registry_repository" "api" { location = var.region } +# ── Grant Cloud Build SA storage + logging access ───────────────────────────── +# GCP (2024+) uses the Compute Engine default SA for Cloud Build in new projects. locals { - image = "${var.region}-docker.pkg.dev/${var.gcp_project_id}/${google_artifact_registry_repository.api.repository_id}/api:latest" + src_hash = sha1(join("", [for f in sort(fileset("${path.root}/../../api", "**")) : filesha1("${path.root}/../../api/${f}")])) + image = "${var.region}-docker.pkg.dev/${var.gcp_project_id}/${google_artifact_registry_repository.api.repository_id}/api:${local.src_hash}" + cloudbuild_sa = "serviceAccount:${data.google_project.project.number}-compute@developer.gserviceaccount.com" +} + +resource "google_project_iam_member" "cloudbuild_storage" { + project = var.gcp_project_id + role = "roles/storage.objectAdmin" + member = local.cloudbuild_sa +} + +resource "google_project_iam_member" "cloudbuild_logs" { + project = var.gcp_project_id + role = "roles/logging.logWriter" + member = local.cloudbuild_sa +} + +resource "google_project_iam_member" "cloudbuild_ar_writer" { + project = var.gcp_project_id + role = "roles/artifactregistry.writer" + member = local.cloudbuild_sa } -# ── Build and push image ────────────────────────────────────── +data "google_project" "project" { + project_id = var.gcp_project_id +} + +# ── Wait for IAM to propagate before building ──────────────────────────────── +resource "time_sleep" "iam_propagation" { + create_duration = "90s" + + depends_on = [ + google_project_iam_member.cloudbuild_storage, + google_project_iam_member.cloudbuild_logs, + google_project_iam_member.cloudbuild_ar_writer, + ] +} + +# ── Build and push image via Cloud Build (no local Docker needed) ───────────── resource "null_resource" "build_push" { triggers = { - src_hash = sha1(join("", [ - for f in sort(fileset("${path.root}/../../api", "**")) : - filesha1("${path.root}/../../api/${f}") - ])) + src_hash = local.src_hash } provisioner "local-exec" { - command = "docker build --platform linux/amd64 -t ${local.image} ${path.root}/../../api && docker push ${local.image}" + command = "gcloud builds submit ${path.root}/../../api --tag ${local.image} --project ${var.gcp_project_id}" } - depends_on = [google_artifact_registry_repository.api] + depends_on = [ + google_artifact_registry_repository.api, + time_sleep.iam_propagation, + ] } # ── Service account ─────────────────────────────────────────── diff --git a/terraform/deploy.sh b/terraform/deploy.sh index b995c27..751bd2b 100755 --- a/terraform/deploy.sh +++ b/terraform/deploy.sh @@ -43,4 +43,88 @@ cd compute_engine terraform apply -auto-approve -var="gcp_project_id=${PROJECT_ID}" -var="tfstate_bucket=${BUCKET}" -var="api_url=${API_URL}" cd .. +echo "=== Step 6: Update bot/.env with terraform outputs ===" +API_KEY=$(cd cloud_run && terraform output -raw api_key) +ENV_FILE="${ROOT_DIR}/bot/.env" + +# Helper: set or replace a key in bot/.env, preserving all other lines. +# Portable (macOS + Linux): rewrites file via temp file instead of sed -i. +# Values are written as-is via printf to avoid sed special-char issues. +set_env() { + local key="$1" val="$2" + local tmp + tmp="$(mktemp "${TMPDIR:-/tmp}/trade-compass.XXXXXX")" + if grep -q "^${key}=" "${ENV_FILE}" 2>/dev/null; then + # Copy every line except the matching key, then append updated key=val + grep -v "^${key}=" "${ENV_FILE}" > "${tmp}" + printf '%s=%s\n' "${key}" "${val}" >> "${tmp}" + else + cp "${ENV_FILE}" "${tmp}" + printf '%s=%s\n' "${key}" "${val}" >> "${tmp}" + fi + mv "${tmp}" "${ENV_FILE}" +} + +touch "${ENV_FILE}" +set_env "API_URL" "${API_URL}" +set_env "API_KEY" "${API_KEY}" + +# Only add placeholder lines if key is not already present +grep -q "^FMP_API_KEY=" "${ENV_FILE}" || echo "FMP_API_KEY=" >> "${ENV_FILE}" +grep -q "^OPENROUTER_API_KEY=" "${ENV_FILE}" || echo "OPENROUTER_API_KEY=" >> "${ENV_FILE}" +grep -q "^TELEGRAM_BOT_TOKEN=" "${ENV_FILE}" || echo "TELEGRAM_BOT_TOKEN=" >> "${ENV_FILE}" +grep -q "^TELEGRAM_CHAT_ID=" "${ENV_FILE}" || echo "TELEGRAM_CHAT_ID=" >> "${ENV_FILE}" + +echo "bot/.env updated (API_URL + API_KEY refreshed; existing keys preserved)" + +echo "=== Step 7: Bot Cloud Run ===" +cd bot +terraform init -backend-config="bucket=${BUCKET}" -backend-config="prefix=bot" +terraform apply -auto-approve \ + -var="gcp_project_id=${PROJECT_ID}" \ + -var="tfstate_bucket=${BUCKET}" \ + -var="telegram_bot_token=$(grep '^TELEGRAM_BOT_TOKEN=' "${ROOT_DIR}/bot/.env" | cut -d= -f2-)" \ + -var="telegram_chat_id=$(grep '^TELEGRAM_CHAT_ID=' "${ROOT_DIR}/bot/.env" | cut -d= -f2-)" \ + -var="fmp_api_key=$(grep '^FMP_API_KEY=' "${ROOT_DIR}/bot/.env" | cut -d= -f2-)" \ + -var="openrouter_api_key=$(grep '^OPENROUTER_API_KEY=' "${ROOT_DIR}/bot/.env" | cut -d= -f2-)" +BOT_URL=$(terraform output -raw bot_url) +cd .. + +echo "=== Step 8: Register Telegram webhook ===" +TELEGRAM_BOT_TOKEN=$(grep '^TELEGRAM_BOT_TOKEN=' "${ROOT_DIR}/bot/.env" | cut -d= -f2-) + +# Wait for bot Cloud Run to be healthy before registering webhook +echo " Waiting for bot service to be healthy..." +HEALTHY=0 +for i in $(seq 1 12); do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${BOT_URL}/health" 2>/dev/null || echo "000") + if [ "${STATUS}" = "200" ]; then + echo " Bot service is healthy (attempt ${i})" + HEALTHY=1 + break + fi + echo " Attempt ${i}/12: status=${STATUS}, retrying in 10s..." + sleep 10 +done + +if [ "${HEALTHY}" != "1" ]; then + echo "ERROR: Bot service never became healthy after 120s. Aborting webhook registration." + exit 1 +fi + +# Token is passed in the URL path (Telegram API requirement), but we avoid +# echoing the full URL to logs to reduce accidental token exposure. +WEBHOOK_RESP=$(curl -s \ + --url "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/setWebhook" \ + --data-urlencode "url=${BOT_URL}/webhook") +if echo "${WEBHOOK_RESP}" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('ok') else 1)"; then + echo " Webhook registered: ${BOT_URL}/webhook" +else + echo " WARNING: webhook registration may have failed" + echo " Response: ${WEBHOOK_RESP}" +fi + echo "=== Done ===" +echo "" +echo " API URL : ${API_URL}" +echo " Bot URL : ${BOT_URL}"