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}"