From cc045d54052066c05dc6a0bb41c45c934fda81a6 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Wed, 20 May 2026 22:16:16 -0700 Subject: [PATCH 01/29] step1. create a langgraph workflow --- bot/Dockerfile | 12 ++++++ bot/agents/__init__.py | 0 bot/agents/data.py | 21 ++++++++++ bot/agents/decision.py | 22 +++++++++++ bot/agents/fundamental.py | 21 ++++++++++ bot/agents/portfolio.py | 22 +++++++++++ bot/agents/sentiment.py | 21 ++++++++++ bot/graph/__init__.py | 0 bot/graph/workflow.py | 80 ++++++++++++++++++++++++++++++++++++++ bot/main.py | 29 ++++++++++++++ bot/requirements.txt | 8 ++++ bot/state.py | 72 ++++++++++++++++++++++++++++++++++ bot/telegram/__init__.py | 0 bot/tools/__init__.py | 0 bot/tools/market_data.py | 31 +++++++++++++++ bot/tools/portfolio_api.py | 37 ++++++++++++++++++ 16 files changed, 376 insertions(+) create mode 100644 bot/Dockerfile create mode 100644 bot/agents/__init__.py create mode 100644 bot/agents/data.py create mode 100644 bot/agents/decision.py create mode 100644 bot/agents/fundamental.py create mode 100644 bot/agents/portfolio.py create mode 100644 bot/agents/sentiment.py create mode 100644 bot/graph/__init__.py create mode 100644 bot/graph/workflow.py create mode 100644 bot/main.py create mode 100644 bot/requirements.txt create mode 100644 bot/state.py create mode 100644 bot/telegram/__init__.py create mode 100644 bot/tools/__init__.py create mode 100644 bot/tools/market_data.py create mode 100644 bot/tools/portfolio_api.py diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..ae7d0d6 --- /dev/null +++ b/bot/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV PYTHONUNBUFFERED=1 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/bot/agents/__init__.py b/bot/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/agents/data.py b/bot/agents/data.py new file mode 100644 index 0000000..d1dede7 --- /dev/null +++ b/bot/agents/data.py @@ -0,0 +1,21 @@ +"""Data Agent — fetches market data and holdings, populates raw_data.""" + +from __future__ import annotations + +from bot.state import AnalysisState + + +async def data_agent(state: AnalysisState) -> dict: + """ + Responsibilities: + - Fetch Yahoo Finance quote, financials, and recent news for state["ticker"] + - Fetch current holdings from trade-compass REST API + - Populate: raw_data, holdings + + Implemented in Step 2 (issue #19). + """ + # TODO: implement in Step 2 + return { + "raw_data": {}, + "holdings": [], + } diff --git a/bot/agents/decision.py b/bot/agents/decision.py new file mode 100644 index 0000000..eb9044b --- /dev/null +++ b/bot/agents/decision.py @@ -0,0 +1,22 @@ +"""Decision Agent — synthesises scores, outputs BUY/HOLD/SELL verdict.""" + +from __future__ import annotations + +from bot.state import AnalysisState + + +async def decision_agent(state: AnalysisState) -> dict: + """ + Responsibilities: + - Combine fundamental_analysis + sentiment_analysis into a ScoreCard + - Weight dimensions by user preferences (style, horizon, risk) + - Call LLM (via OpenRouter) to produce verdict + thesis + assumptions + - Save decision to trade-compass REST API (POST /decisions) + - Write to decision + + Implemented in Step 4 (issue #22). + """ + # TODO: implement in Step 4 + return { + "decision": None, + } diff --git a/bot/agents/fundamental.py b/bot/agents/fundamental.py new file mode 100644 index 0000000..f118e43 --- /dev/null +++ b/bot/agents/fundamental.py @@ -0,0 +1,21 @@ +"""Fundamental Agent — valuation, growth, quality analysis.""" + +from __future__ import annotations + +from bot.state import AnalysisState + + +async def fundamental_agent(state: AnalysisState) -> dict: + """ + Responsibilities: + - Analyse valuation: P/E vs sector peers, EV/EBITDA + - Analyse growth: revenue trajectory, EPS growth + - Analyse quality: FCF yield, operating margins + - Write scores (0-10) to fundamental_analysis + + Implemented in Step 3 (issue #20). + """ + # TODO: implement in Step 3 + return { + "fundamental_analysis": {}, + } diff --git a/bot/agents/portfolio.py b/bot/agents/portfolio.py new file mode 100644 index 0000000..aec5950 --- /dev/null +++ b/bot/agents/portfolio.py @@ -0,0 +1,22 @@ +"""Portfolio Agent — runs decision_agent across all holdings.""" + +from __future__ import annotations + +from bot.state import AnalysisState + + +async def portfolio_agent(state: AnalysisState) -> dict: + """ + Responsibilities: + - Read all current holdings from REST API + - Run the single-stock subgraph (data → fundamental ║ sentiment → decision) + for each position + - Aggregate results: per-holding verdicts, concentration risk flags + - Write to portfolio_summary + + Implemented in Step 5 (issue #23). + """ + # TODO: implement in Step 5 + return { + "portfolio_summary": {}, + } diff --git a/bot/agents/sentiment.py b/bot/agents/sentiment.py new file mode 100644 index 0000000..052fe39 --- /dev/null +++ b/bot/agents/sentiment.py @@ -0,0 +1,21 @@ +"""Sentiment Agent — news, analyst ratings, price targets, timing.""" + +from __future__ import annotations + +from bot.state import AnalysisState + + +async def sentiment_agent(state: AnalysisState) -> dict: + """ + Responsibilities: + - Search recent news via Brave Search API + - Fetch analyst ratings and price targets from Yahoo Finance + - Score sentiment (0-10) and timing (0-10) dimensions + - Write to sentiment_analysis + + Implemented in Step 3 (issue #21). + """ + # TODO: implement in Step 3 + return { + "sentiment_analysis": {}, + } diff --git a/bot/graph/__init__.py b/bot/graph/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/graph/workflow.py b/bot/graph/workflow.py new file mode 100644 index 0000000..877645f --- /dev/null +++ b/bot/graph/workflow.py @@ -0,0 +1,80 @@ +"""LangGraph workflow topology. + +Graph structure: + START + └─► route_intent + ├─► (single) data_agent + │ ├─► fundamental_agent ─┐ + │ └─► sentiment_agent ─┴─► decision_agent ─► END + └─► (portfolio) portfolio_agent ──────────────────────► END +""" + +from __future__ import annotations + +from langgraph.graph import END, START, StateGraph + +from bot.agents.data import data_agent +from bot.agents.decision import decision_agent +from bot.agents.fundamental import fundamental_agent +from bot.agents.portfolio import portfolio_agent +from bot.agents.sentiment import sentiment_agent +from bot.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"] + + +# ── Graph assembly ──────────────────────────────────────────────────────────── + +def build_graph() -> StateGraph: + builder = StateGraph(AnalysisState) + + # Nodes + 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) + + # Edges + builder.add_conditional_edges(START, route_intent, { + "data_agent": "data_agent", + "portfolio_agent": "portfolio_agent", + END: END, + }) + + # Fan-out after data fetch + builder.add_conditional_edges("data_agent", after_data, { + "fundamental_agent": "fundamental_agent", + "sentiment_agent": "sentiment_agent", + END: END, + }) + + # Fan-in: both analysis agents converge on decision_agent + builder.add_edge("fundamental_agent", "decision_agent") + builder.add_edge("sentiment_agent", "decision_agent") + + # Terminal edges + builder.add_edge("decision_agent", END) + builder.add_edge("portfolio_agent", END) + + return builder.compile() + + +# Compiled graph (import this in bot handlers) +graph = build_graph() diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..bb4ab30 --- /dev/null +++ b/bot/main.py @@ -0,0 +1,29 @@ +"""trade-compass-bot entry point. + +Starts a FastAPI server that: +- Receives Telegram webhook POSTs at /webhook +- Exposes /health for Cloud Run health checks +- Exposes /push for Cloud Scheduler-triggered active push notifications + +Telegram bot handler and push scheduler implemented in Step 6 (issue #24). +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv +from fastapi import FastAPI + +load_dotenv() + +app = FastAPI(title="trade-compass-bot") + + +@app.get("/health") +async def health() -> dict: + return {"status": "ok"} + + +# TODO (Step 6): mount Telegram webhook router +# TODO (Step 6): mount push notification router diff --git a/bot/requirements.txt b/bot/requirements.txt new file mode 100644 index 0000000..d6b169e --- /dev/null +++ b/bot/requirements.txt @@ -0,0 +1,8 @@ +langgraph==0.2.55 +langchain-openai==0.2.14 +httpx==0.27.0 +python-telegram-bot==21.6 +yfinance==0.2.50 +python-dotenv==1.0.1 +fastapi==0.115.0 +uvicorn[standard]==0.32.0 diff --git a/bot/state.py b/bot/state.py new file mode 100644 index 0000000..12b4884 --- /dev/null +++ b/bot/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/telegram/__init__.py b/bot/telegram/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/tools/__init__.py b/bot/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/tools/market_data.py b/bot/tools/market_data.py new file mode 100644 index 0000000..723abe5 --- /dev/null +++ b/bot/tools/market_data.py @@ -0,0 +1,31 @@ +"""Yahoo Finance market data tools. + +Used by data_agent and fundamental_agent. +Implemented in Step 2 (issue #19). +""" + +from __future__ import annotations + + +async def fetch_quote(ticker: str) -> dict: + """Fetch current price, P/E, market cap, 52-week range.""" + # TODO: implement in Step 2 using yfinance + return {} + + +async def fetch_financials(ticker: str) -> dict: + """Fetch revenue, EPS, FCF, margins from Yahoo Finance.""" + # TODO: implement in Step 2 using yfinance + return {} + + +async def fetch_news(ticker: str, limit: int = 10) -> list[dict]: + """Fetch recent news headlines + snippets.""" + # TODO: implement in Step 2 using yfinance or Brave API + return [] + + +async def fetch_analyst_ratings(ticker: str) -> dict: + """Fetch analyst consensus rating and price targets.""" + # TODO: implement in Step 3 using yfinance + return {} diff --git a/bot/tools/portfolio_api.py b/bot/tools/portfolio_api.py new file mode 100644 index 0000000..5724c3d --- /dev/null +++ b/bot/tools/portfolio_api.py @@ -0,0 +1,37 @@ +"""REST API client for trade-compass-api (Cloud Run). + +Used by data_agent and decision_agent to read/write holdings, decisions, +and preferences. +Implemented in Step 2 (issue #19). +""" + +from __future__ import annotations + +import os + +import httpx + +_API_URL = os.environ.get("API_URL", "") +_API_KEY = os.environ.get("API_KEY", "") + + +def _headers() -> dict[str, str]: + return {"X-API-Key": _API_KEY} + + +async def get_holdings() -> list[dict]: + """GET /holdings — returns current positions list.""" + # TODO: implement in Step 2 + return [] + + +async def get_preferences() -> dict: + """GET /preferences — returns user style/horizon/risk settings.""" + # TODO: implement in Step 2 + return {} + + +async def post_decision(ticker: str, decision: dict) -> None: + """POST /decisions — persists a verdict to MongoDB.""" + # TODO: implement in Step 4 + pass From 677a0a6025b7be99a6d6b444a762991a0e6b4db0 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Wed, 20 May 2026 22:23:11 -0700 Subject: [PATCH 02/29] read API_URL and API_KEY from previous deloyment --- bot/.env.example | 8 ++++++++ terraform/deploy.sh | 11 +++++++++++ 2 files changed, 19 insertions(+) create mode 100644 bot/.env.example diff --git a/bot/.env.example b/bot/.env.example new file mode 100644 index 0000000..0da33b5 --- /dev/null +++ b/bot/.env.example @@ -0,0 +1,8 @@ +# 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 +OPENROUTER_API_KEY= +TELEGRAM_BOT_TOKEN= +BRAVE_API_KEY= diff --git a/terraform/deploy.sh b/terraform/deploy.sh index b995c27..58ed9ce 100755 --- a/terraform/deploy.sh +++ b/terraform/deploy.sh @@ -43,4 +43,15 @@ 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: Generate bot/.env ===" +API_KEY=$(cd cloud_run && terraform output -raw api_key) +cat > "${ROOT_DIR}/bot/.env" < Date: Thu, 21 May 2026 12:26:37 -0700 Subject: [PATCH 03/29] implement data agent --- bot/.env.example | 2 +- bot/agents/data.py | 69 +++++++++++--- bot/requirements.txt | 1 - bot/tools/market_data.py | 189 +++++++++++++++++++++++++++++++++---- bot/tools/portfolio_api.py | 39 ++++---- terraform/deploy.sh | 4 +- 6 files changed, 254 insertions(+), 50 deletions(-) diff --git a/bot/.env.example b/bot/.env.example index 0da33b5..c1a67a2 100644 --- a/bot/.env.example +++ b/bot/.env.example @@ -3,6 +3,6 @@ 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= -BRAVE_API_KEY= diff --git a/bot/agents/data.py b/bot/agents/data.py index d1dede7..31a5589 100644 --- a/bot/agents/data.py +++ b/bot/agents/data.py @@ -1,21 +1,68 @@ -"""Data Agent — fetches market data and holdings, populates raw_data.""" +"""Data Agent — fetches market data and holdings, populates shared state.""" from __future__ import annotations +import asyncio + +import httpx + from bot.state import AnalysisState +from bot.tools.market_data import ( + fetch_analyst_ratings, + fetch_financials, + fetch_key_metrics, + fetch_news, + fetch_profile, + fetch_quote, +) +from bot.tools.portfolio_api import get_holdings, get_preferences async def data_agent(state: AnalysisState) -> dict: """ - Responsibilities: - - Fetch Yahoo Finance quote, financials, and recent news for state["ticker"] - - Fetch current holdings from trade-compass REST API - - Populate: raw_data, holdings + 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 - Implemented in Step 2 (issue #19). + Writes: raw_data, holdings, preferences """ - # TODO: implement in Step 2 - return { - "raw_data": {}, - "holdings": [], - } + 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, + 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_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, + "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/requirements.txt b/bot/requirements.txt index d6b169e..2ece064 100644 --- a/bot/requirements.txt +++ b/bot/requirements.txt @@ -2,7 +2,6 @@ langgraph==0.2.55 langchain-openai==0.2.14 httpx==0.27.0 python-telegram-bot==21.6 -yfinance==0.2.50 python-dotenv==1.0.1 fastapi==0.115.0 uvicorn[standard]==0.32.0 diff --git a/bot/tools/market_data.py b/bot/tools/market_data.py index 723abe5..9ddd721 100644 --- a/bot/tools/market_data.py +++ b/bot/tools/market_data.py @@ -1,31 +1,182 @@ -"""Yahoo Finance market data tools. +"""Financial Modeling Prep (FMP) market data tools. -Used by data_agent and fundamental_agent. -Implemented in Step 2 (issue #19). +All calls are async via httpx. Requires FMP_API_KEY in environment. +Free tier: 250 requests/day. + +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" +_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: + resp = await client.get( + f"{_BASE}{path}", params={**_key(), **params}, timeout=10 + ) + resp.raise_for_status() + return resp.json() + + +# ── One function per FMP endpoint ──────────────────────────────────────────── + +async def fetch_quote(client: httpx.AsyncClient, ticker: str) -> dict[str, Any]: + """Real-time price, PE, market cap, 52-week range. + + Source: GET /api/v3/quote/{symbol} + """ + data = await _get(client, f"/api/v3/quote/{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"), + "trailing_pe": q.get("pe"), + "volume": q.get("volume"), + "change_pct": q.get("changesPercentage"), + } + + +async def fetch_profile(client: httpx.AsyncClient, ticker: str) -> dict[str, Any]: + """Company profile: sector, industry, beta, description. + + Source: GET /api/v3/profile/{symbol} + """ + data = await _get(client, f"/api/v3/profile/{ticker}") + if not data: + return {} + p = data[0] + return { + "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]: + """TTM valuation and quality metrics: ROE, FCF, EV/EBITDA, D/E. + + Source: GET /api/v3/key-metrics-ttm/{symbol} + """ + data = await _get(client, f"/api/v3/key-metrics-ttm/{ticker}") + if not data: + return {} + m = data[0] + return { + "forward_pe": m.get("peRatioTTM"), + "price_to_book": m.get("priceToBookRatioTTM"), + "ev_to_ebitda": m.get("enterpriseValueOverEBITDATTM"), + "return_on_equity": m.get("roeTTM"), + "free_cashflow_per_share": m.get("freeCashFlowPerShareTTM"), + "debt_to_equity": m.get("debtToEquityTTM"), + } + + +async def fetch_financials(client: httpx.AsyncClient, ticker: str, limit: int = 4) -> dict[str, Any]: + """Annual income statement — last 4 years. + + Source: GET /api/v3/income-statement/{symbol} + """ + data = await _get( + client, f"/api/v3/income-statement/{ticker}", period="annual", 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("eps")) + 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_news(client: httpx.AsyncClient, ticker: str, limit: int = 8) -> list[dict[str, Any]]: + """Recent news headlines and summaries. -async def fetch_quote(ticker: str) -> dict: - """Fetch current price, P/E, market cap, 52-week range.""" - # TODO: implement in Step 2 using yfinance - return {} + Source: GET /api/v3/stock_news + """ + data = await _get(client, "/api/v3/stock_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 []) + ] -async def fetch_financials(ticker: str) -> dict: - """Fetch revenue, EPS, FCF, margins from Yahoo Finance.""" - # TODO: implement in Step 2 using yfinance - return {} +async def fetch_analyst_ratings(client: httpx.AsyncClient, ticker: str) -> dict[str, Any]: + """Analyst price targets and recommendation breakdown. + Sources: + GET /api/v4/price-target-consensus + GET /api/v3/analyst-stock-recommendations/{symbol} + """ + import asyncio -async def fetch_news(ticker: str, limit: int = 10) -> list[dict]: - """Fetch recent news headlines + snippets.""" - # TODO: implement in Step 2 using yfinance or Brave API - return [] + targets_data, recs_data = await asyncio.gather( + _get(client, "/api/v4/price-target-consensus", symbol=ticker), + _get(client, f"/api/v3/analyst-stock-recommendations/{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 []) + ] -async def fetch_analyst_ratings(ticker: str) -> dict: - """Fetch analyst consensus rating and price targets.""" - # TODO: implement in Step 3 using yfinance - return {} + 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/tools/portfolio_api.py b/bot/tools/portfolio_api.py index 5724c3d..f6b6c8f 100644 --- a/bot/tools/portfolio_api.py +++ b/bot/tools/portfolio_api.py @@ -1,17 +1,16 @@ """REST API client for trade-compass-api (Cloud Run). -Used by data_agent and decision_agent to read/write holdings, decisions, -and preferences. -Implemented in Step 2 (issue #19). +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", "") +_API_URL = os.environ.get("API_URL", "").rstrip("/") _API_KEY = os.environ.get("API_KEY", "") @@ -19,19 +18,27 @@ def _headers() -> dict[str, str]: return {"X-API-Key": _API_KEY} -async def get_holdings() -> list[dict]: - """GET /holdings — returns current positions list.""" - # TODO: implement in Step 2 - return [] +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: - """GET /preferences — returns user style/horizon/risk settings.""" - # TODO: implement in Step 2 - return {} +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(ticker: str, decision: dict) -> None: - """POST /decisions — persists a verdict to MongoDB.""" - # TODO: implement in Step 4 - pass +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/terraform/deploy.sh b/terraform/deploy.sh index 58ed9ce..b383d6b 100755 --- a/terraform/deploy.sh +++ b/terraform/deploy.sh @@ -48,10 +48,10 @@ API_KEY=$(cd cloud_run && terraform output -raw api_key) cat > "${ROOT_DIR}/bot/.env" < Date: Thu, 21 May 2026 14:25:00 -0700 Subject: [PATCH 04/29] implement fundamental agent and sentiment agent. --- bot/agents/data.py | 4 +++ bot/agents/fundamental.py | 68 +++++++++++++++++++++++++++++++++------ bot/agents/sentiment.py | 64 ++++++++++++++++++++++++++++++------ bot/tools/market_data.py | 15 +++++++++ 4 files changed, 131 insertions(+), 20 deletions(-) diff --git a/bot/agents/data.py b/bot/agents/data.py index 31a5589..f483ce8 100644 --- a/bot/agents/data.py +++ b/bot/agents/data.py @@ -14,6 +14,7 @@ fetch_news, fetch_profile, fetch_quote, + fetch_scores, ) from bot.tools.portfolio_api import get_holdings, get_preferences @@ -36,6 +37,7 @@ async def data_agent(state: AnalysisState) -> dict: profile, key_metrics, financials, + scores, news, analyst, holdings, @@ -45,6 +47,7 @@ async def data_agent(state: AnalysisState) -> dict: 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(), @@ -57,6 +60,7 @@ async def data_agent(state: AnalysisState) -> dict: "profile": profile, "key_metrics": key_metrics, "financials": financials, + "scores": scores, "news": news, "analyst": analyst, }, diff --git a/bot/agents/fundamental.py b/bot/agents/fundamental.py index f118e43..23999b1 100644 --- a/bot/agents/fundamental.py +++ b/bot/agents/fundamental.py @@ -1,4 +1,10 @@ -"""Fundamental Agent — valuation, growth, quality analysis.""" +"""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 @@ -7,15 +13,57 @@ async def fundamental_agent(state: AnalysisState) -> dict: """ - Responsibilities: - - Analyse valuation: P/E vs sector peers, EV/EBITDA - - Analyse growth: revenue trajectory, EPS growth - - Analyse quality: FCF yield, operating margins - - Write scores (0-10) to fundamental_analysis - - Implemented in Step 3 (issue #20). + Organises fundamental context from raw_data. + Writes: fundamental_analysis """ - # TODO: implement in Step 3 + 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": {}, + "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": { + "trailing_pe": quote.get("trailing_pe"), + "forward_pe": key_metrics.get("forward_pe"), + "ev_to_ebitda": key_metrics.get("ev_to_ebitda"), + "price_to_book": key_metrics.get("price_to_book"), + "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"), + "free_cashflow_per_share": key_metrics.get("free_cashflow_per_share"), + "debt_to_equity": key_metrics.get("debt_to_equity"), + }, + } } diff --git a/bot/agents/sentiment.py b/bot/agents/sentiment.py index 052fe39..635430f 100644 --- a/bot/agents/sentiment.py +++ b/bot/agents/sentiment.py @@ -1,4 +1,8 @@ -"""Sentiment Agent — news, analyst ratings, price targets, timing.""" +"""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 @@ -7,15 +11,55 @@ async def sentiment_agent(state: AnalysisState) -> dict: """ - Responsibilities: - - Search recent news via Brave Search API - - Fetch analyst ratings and price targets from Yahoo Finance - - Score sentiment (0-10) and timing (0-10) dimensions - - Write to sentiment_analysis - - Implemented in Step 3 (issue #21). + Organises sentiment and timing context from raw_data. + Writes: sentiment_analysis """ - # TODO: implement in Step 3 + 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": {}, + "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/tools/market_data.py b/bot/tools/market_data.py index 9ddd721..02238e4 100644 --- a/bot/tools/market_data.py +++ b/bot/tools/market_data.py @@ -126,6 +126,21 @@ async def fetch_financials(client: httpx.AsyncClient, ticker: str, limit: int = } +async def fetch_scores(client: httpx.AsyncClient, ticker: str) -> dict[str, Any]: + """Piotroski F-Score and Altman Z-Score from FMP's pre-computed scores. + + Source: GET /stable/scores + """ + data = await _get(client, "/stable/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 and summaries. From 1c75dbf27429062373ff810b29417f332b97bfc9 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Thu, 21 May 2026 16:44:21 -0700 Subject: [PATCH 05/29] set llm --- api/src/models.py | 5 ++ bot/agents/decision.py | 101 +++++++++++++++++++++++++++++++++++------ bot/config.json | 34 ++++++++++++++ bot/config.py | 46 +++++++++++++++++++ bot/tools/llm.py | 51 +++++++++++++++++++++ bot/tools/prompt.py | 88 +++++++++++++++++++++++++++++++++++ 6 files changed, 312 insertions(+), 13 deletions(-) create mode 100644 bot/config.json create mode 100644 bot/config.py create mode 100644 bot/tools/llm.py create mode 100644 bot/tools/prompt.py diff --git a/api/src/models.py b/api/src/models.py index 54c3c92..bf895bc 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="meta-llama/llama-3.3-70b-instruct:free", + description="LLM model for analysis. Configured in bot/config.json.", + ) diff --git a/bot/agents/decision.py b/bot/agents/decision.py index eb9044b..3c6e02c 100644 --- a/bot/agents/decision.py +++ b/bot/agents/decision.py @@ -1,22 +1,97 @@ -"""Decision Agent — synthesises scores, outputs BUY/HOLD/SELL verdict.""" +"""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 bot.state import AnalysisState +from bot.tools.llm import get_llm +from bot.tools.portfolio_api import post_decision +from bot.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: """ - Responsibilities: - - Combine fundamental_analysis + sentiment_analysis into a ScoreCard - - Weight dimensions by user preferences (style, horizon, risk) - - Call LLM (via OpenRouter) to produce verdict + thesis + assumptions - - Save decision to trade-compass REST API (POST /decisions) - - Write to decision - - Implemented in Step 4 (issue #22). + Builds a structured prompt from fundamental + sentiment analysis, + calls OpenRouter LLM with structured output, persists result to REST API. + Writes: decision """ - # TODO: implement in Step 4 - return { - "decision": None, - } + 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 (non-blocking — don't fail analysis if this errors) + 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/config.json b/bot/config.json new file mode 100644 index 0000000..0cc2e4c --- /dev/null +++ b/bot/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.", + "default": true + }, + { + "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": false + }, + { + "id": "qwen/qwen3-next-80b-a3b-instruct:free", + "name": "Qwen3 80B", + "description": "Strong analytical tasks.", + "default": false + } + ] +} diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..660fa6e --- /dev/null +++ b/bot/config.py @@ -0,0 +1,46 @@ +"""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/tools/llm.py b/bot/tools/llm.py new file mode 100644 index 0000000..5c9a258 --- /dev/null +++ b/bot/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 bot.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/tools/prompt.py b/bot/tools/prompt.py new file mode 100644 index 0000000..67f4e0c --- /dev/null +++ b/bot/tools/prompt.py @@ -0,0 +1,88 @@ +"""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", [{}])[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 +Trailing PE: {valuation.get('trailing_pe', 'N/A')} +Forward PE: {valuation.get('forward_pe', 'N/A')} +EV/EBITDA: {valuation.get('ev_to_ebitda', 'N/A')} +Price/Book: {valuation.get('price_to_book', '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')} +FCF/share: {quality.get('free_cashflow_per_share', 'N/A')} +Debt/Equity: {quality.get('debt_to_equity', '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. +""" From 8cb9763f90307f220bc1ac1bf9d053b769bbad74 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Thu, 21 May 2026 17:12:05 -0700 Subject: [PATCH 06/29] portfolio agent --- bot/agents/portfolio.py | 137 ++++++++++++++++++++++++++++++++++++---- bot/graph/workflow.py | 52 ++++++++++----- 2 files changed, 161 insertions(+), 28 deletions(-) diff --git a/bot/agents/portfolio.py b/bot/agents/portfolio.py index aec5950..3ee5de1 100644 --- a/bot/agents/portfolio.py +++ b/bot/agents/portfolio.py @@ -1,22 +1,137 @@ -"""Portfolio Agent — runs decision_agent across all holdings.""" +"""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 bot.state import AnalysisState +from bot.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: """ - Responsibilities: - - Read all current holdings from REST API - - Run the single-stock subgraph (data → fundamental ║ sentiment → decision) - for each position - - Aggregate results: per-holding verdicts, concentration risk flags - - Write to portfolio_summary - - Implemented in Step 5 (issue #23). + Runs single-stock analysis for each STOCK holding. + Writes: portfolio_summary """ - # TODO: implement in Step 5 + # Import here to avoid circular import at module load time + from bot.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": {}, + "portfolio_summary": { + "holdings_count": len(all_holdings), + "analyzed_count": len(stock_holdings), + "verdicts": verdicts, + "concentration_risk": concentration_risk, + } } diff --git a/bot/graph/workflow.py b/bot/graph/workflow.py index 877645f..f7e6abd 100644 --- a/bot/graph/workflow.py +++ b/bot/graph/workflow.py @@ -1,12 +1,14 @@ """LangGraph workflow topology. -Graph structure: - START - └─► route_intent - ├─► (single) data_agent - │ ├─► fundamental_agent ─┐ - │ └─► sentiment_agent ─┴─► decision_agent ─► END - └─► (portfolio) portfolio_agent ──────────────────────► END +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 @@ -39,42 +41,58 @@ def after_data(state: AnalysisState) -> list[str]: return ["fundamental_agent", "sentiment_agent"] -# ── Graph assembly ──────────────────────────────────────────────────────────── +# ── 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) - # Nodes 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) - # Edges builder.add_conditional_edges(START, route_intent, { "data_agent": "data_agent", "portfolio_agent": "portfolio_agent", END: END, }) - - # Fan-out after data fetch builder.add_conditional_edges("data_agent", after_data, { "fundamental_agent": "fundamental_agent", "sentiment_agent": "sentiment_agent", END: END, }) - - # Fan-in: both analysis agents converge on decision_agent builder.add_edge("fundamental_agent", "decision_agent") builder.add_edge("sentiment_agent", "decision_agent") - - # Terminal edges builder.add_edge("decision_agent", END) builder.add_edge("portfolio_agent", END) return builder.compile() -# Compiled graph (import this in bot handlers) +# Exported compiled graphs +single_stock_graph = build_single_stock_graph() graph = build_graph() From 1f9a6cdcfe2a7cc84fdb470a4bf1f8966b51434e Mon Sep 17 00:00:00 2001 From: PCBZ Date: Thu, 21 May 2026 18:30:30 -0700 Subject: [PATCH 07/29] add bot agent --- bot/.env.example | 1 + bot/main.py | 24 ++-- bot/{telegram => tg}/__init__.py | 0 bot/tg/bot.py | 59 +++++++++ bot/tg/handlers.py | 210 +++++++++++++++++++++++++++++++ bot/tg/push.py | 67 ++++++++++ 6 files changed, 349 insertions(+), 12 deletions(-) rename bot/{telegram => tg}/__init__.py (100%) create mode 100644 bot/tg/bot.py create mode 100644 bot/tg/handlers.py create mode 100644 bot/tg/push.py diff --git a/bot/.env.example b/bot/.env.example index c1a67a2..bb4641e 100644 --- a/bot/.env.example +++ b/bot/.env.example @@ -6,3 +6,4 @@ API_KEY=your-api-key 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/main.py b/bot/main.py index bb4ab30..314af89 100644 --- a/bot/main.py +++ b/bot/main.py @@ -1,29 +1,29 @@ """trade-compass-bot entry point. -Starts a FastAPI server that: -- Receives Telegram webhook POSTs at /webhook -- Exposes /health for Cloud Run health checks -- Exposes /push for Cloud Scheduler-triggered active push notifications - -Telegram bot handler and push scheduler implemented in Step 6 (issue #24). +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 -import os +from pathlib import Path from dotenv import load_dotenv from fastapi import FastAPI -load_dotenv() +load_dotenv(dotenv_path=Path(__file__).parent / ".env") + +from bot.tg.bot import webhook_router # noqa: E402 +from bot.tg.push import push_router # noqa: E402 app = FastAPI(title="trade-compass-bot") +app.include_router(webhook_router) +app.include_router(push_router) + @app.get("/health") async def health() -> dict: return {"status": "ok"} - - -# TODO (Step 6): mount Telegram webhook router -# TODO (Step 6): mount push notification router diff --git a/bot/telegram/__init__.py b/bot/tg/__init__.py similarity index 100% rename from bot/telegram/__init__.py rename to bot/tg/__init__.py diff --git a/bot/tg/bot.py b/bot/tg/bot.py new file mode 100644 index 0000000..fa5a455 --- /dev/null +++ b/bot/tg/bot.py @@ -0,0 +1,59 @@ +"""Telegram bot initialisation and webhook router. + +Registers the Application instance (shared across all handlers) +and exposes a FastAPI router that receives Telegram webhook updates. +""" + +from __future__ import annotations + +import os + +from fastapi import APIRouter, Request, Response +from telegram import Update +from telegram.ext import ( + Application, + CallbackQueryHandler, + CommandHandler, +) + +from bot.tg.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() + +# ── 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.initialize() + await application.process_update(update) + return Response(status_code=200) diff --git a/bot/tg/handlers.py b/bot/tg/handlers.py new file mode 100644 index 0000000..c2170a8 --- /dev/null +++ b/bot/tg/handlers.py @@ -0,0 +1,210 @@ +"""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 bot.config import get_llm_models, is_valid_model +from bot.graph.workflow import graph, single_stock_graph +from bot.tools.portfolio_api import get_preferences, get_holdings +import httpx +import os + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +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} *{ticker}* — {verdict} ({confidence})", + f"_{thesis}_", + ] + if assumptions: + lines.append("\n*Key assumptions:*") + for a in assumptions: + lines.append(f" • {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} *{v['ticker']}* — {v.get('verdict')} ({v.get('confidence', '')})" + ) + if v.get("thesis"): + lines.append(f" _{v['thesis'][:120]}..._") + + if risks: + lines.append("\n⚠️ *Concentration Risk*") + for r in risks: + lines.append(f" • {r['ticker']} {r['weight_pct']}% — {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="Markdown", + ) + return + + ticker = args[0].upper() + await update.message.reply_text(f"🔍 Analysing *{ticker}*...", parse_mode="Markdown") + + 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="Markdown") + + 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="Markdown") + + 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="Markdown") + + 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="Markdown", + ) + + +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 *{name}*", parse_mode="Markdown") + + +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="Markdown") diff --git a/bot/tg/push.py b/bot/tg/push.py new file mode 100644 index 0000000..ecfca33 --- /dev/null +++ b/bot/tg/push.py @@ -0,0 +1,67 @@ +"""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 bot.graph.workflow import graph +from bot.tg.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 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="Markdown") + + return {"sent": True, "tickers": [v["ticker"] for v in summary["verdicts"]]} From 44baea762d4f5791b75511033bd7a2600c043bb8 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Thu, 21 May 2026 22:36:36 -0700 Subject: [PATCH 08/29] fix connection failure --- api/src/main.py | 2 +- api/src/routers/decisions.py | 12 ++++- api/src/routers/holdings.py | 4 +- api/src/routers/preferences.py | 4 +- bot/pytest.ini | 2 + bot/requirements-dev.txt | 2 + bot/tests/__init__.py | 0 bot/tests/test_api.py | 87 +++++++++++++++++++++++++++++++ bot/tests/test_fmp.py | 93 ++++++++++++++++++++++++++++++++++ bot/tests/test_openrouter.py | 74 +++++++++++++++++++++++++++ terraform/atlas/main.tf | 7 +++ terraform/bootstrap/main.tf | 1 + terraform/cloud_run/main.tf | 57 ++++++++++++++++++--- terraform/deploy.sh | 33 ++++++++---- 14 files changed, 355 insertions(+), 23 deletions(-) create mode 100644 bot/pytest.ini create mode 100644 bot/requirements-dev.txt create mode 100644 bot/tests/__init__.py create mode 100644 bot/tests/test_api.py create mode 100644 bot/tests/test_fmp.py create mode 100644 bot/tests/test_openrouter.py 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/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/bot/pytest.ini b/bot/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/bot/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto 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/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..0c1b34e --- /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 ( + 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..a7a9574 --- /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 +from bot.tools.llm import get_llm +from bot.agents.decision import DecisionOutput + + +@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/terraform/atlas/main.tf b/terraform/atlas/main.tf index 450e24a..c278014 100644 --- a/terraform/atlas/main.tf +++ b/terraform/atlas/main.tf @@ -57,3 +57,10 @@ resource "mongodbatlas_project_ip_access_list" "compute_engine" { ip_address = data.terraform_remote_state.compute_engine.outputs.external_ip comment = "Compute Engine static IP" } + +# Cloud Run uses dynamic egress IPs — allow all so the API can reach Atlas +resource "mongodbatlas_project_ip_access_list" "allow_all" { + project_id = mongodbatlas_project.trade_compass.id + cidr_block = "0.0.0.0/0" + comment = "Cloud Run dynamic egress IPs" +} diff --git a/terraform/bootstrap/main.tf b/terraform/bootstrap/main.tf index e08c501..55a9a17 100644 --- a/terraform/bootstrap/main.tf +++ b/terraform/bootstrap/main.tf @@ -20,6 +20,7 @@ resource "google_project_service" "apis" { "artifactregistry.googleapis.com", "compute.googleapis.com", "storage.googleapis.com", + "cloudbuild.googleapis.com", ]) service = each.value disable_on_destroy = false diff --git a/terraform/cloud_run/main.tf b/terraform/cloud_run/main.tf index daa64f7..bb471c1 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.admin" + 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 b383d6b..e96b60c 100755 --- a/terraform/deploy.sh +++ b/terraform/deploy.sh @@ -43,15 +43,30 @@ 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: Generate bot/.env ===" +echo "=== Step 6: Update bot/.env with terraform outputs ===" API_KEY=$(cd cloud_run && terraform output -raw api_key) -cat > "${ROOT_DIR}/bot/.env" </dev/null; then + sed -i '' "s|^${key}=.*|${key}=${val}|" "${ENV_FILE}" + else + echo "${key}=${val}" >> "${ENV_FILE}" + fi +} + +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 "=== Done ===" From c6efe0978763f7a8903301af32c4b0ea8d4d7401 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Fri, 22 May 2026 12:32:24 -0700 Subject: [PATCH 09/29] fix openrouter connection --- bot/config.json | 8 ++-- bot/tools/market_data.py | 84 +++++++++++++++++++++++----------------- 2 files changed, 52 insertions(+), 40 deletions(-) diff --git a/bot/config.json b/bot/config.json index 0cc2e4c..68f2aa0 100644 --- a/bot/config.json +++ b/bot/config.json @@ -3,8 +3,8 @@ { "id": "meta-llama/llama-3.3-70b-instruct:free", "name": "Llama 3.3 70B", - "description": "Solid general reasoning. Default.", - "default": true + "description": "Solid general reasoning.", + "default": false }, { "id": "deepseek/deepseek-v4-flash:free", @@ -21,8 +21,8 @@ { "id": "nvidia/nemotron-3-super-120b-a12b:free", "name": "Nemotron 120B", - "description": "Large model. Best reasoning quality.", - "default": false + "description": "Large model. Best reasoning quality. Default.", + "default": true }, { "id": "qwen/qwen3-next-80b-a3b-instruct:free", diff --git a/bot/tools/market_data.py b/bot/tools/market_data.py index 02238e4..cf23eea 100644 --- a/bot/tools/market_data.py +++ b/bot/tools/market_data.py @@ -1,7 +1,7 @@ """Financial Modeling Prep (FMP) market data tools. All calls are async via httpx. Requires FMP_API_KEY in environment. -Free tier: 250 requests/day. +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. @@ -14,7 +14,7 @@ import httpx -_BASE = "https://financialmodelingprep.com" +_BASE = "https://financialmodelingprep.com/stable" _API_KEY = os.environ.get("FMP_API_KEY", "") @@ -23,21 +23,28 @@ def _key() -> dict[str, str]: 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() - return resp.json() + 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, PE, market cap, 52-week range. + """Real-time price, market cap, 52-week range. - Source: GET /api/v3/quote/{symbol} + Source: GET /stable/quote?symbol= """ - data = await _get(client, f"/api/v3/quote/{ticker}") + data = await _get(client, "/quote", symbol=ticker) if not data: return {} q = data[0] @@ -48,18 +55,17 @@ async def fetch_quote(client: httpx.AsyncClient, ticker: str) -> dict[str, Any]: "fifty_two_week_high": q.get("yearHigh"), "fifty_two_week_low": q.get("yearLow"), "market_cap": q.get("marketCap"), - "trailing_pe": q.get("pe"), "volume": q.get("volume"), - "change_pct": q.get("changesPercentage"), + "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 /api/v3/profile/{symbol} + Source: GET /stable/profile?symbol= """ - data = await _get(client, f"/api/v3/profile/{ticker}") + data = await _get(client, "/profile", symbol=ticker) if not data: return {} p = data[0] @@ -74,32 +80,36 @@ async def fetch_profile(client: httpx.AsyncClient, ticker: str) -> dict[str, Any async def fetch_key_metrics(client: httpx.AsyncClient, ticker: str) -> dict[str, Any]: - """TTM valuation and quality metrics: ROE, FCF, EV/EBITDA, D/E. + """Annual valuation and quality metrics: ROE, EV/EBITDA, FCF yield. - Source: GET /api/v3/key-metrics-ttm/{symbol} + Source: GET /stable/key-metrics?symbol=&limit=1 """ - data = await _get(client, f"/api/v3/key-metrics-ttm/{ticker}") + 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 { - "forward_pe": m.get("peRatioTTM"), - "price_to_book": m.get("priceToBookRatioTTM"), - "ev_to_ebitda": m.get("enterpriseValueOverEBITDATTM"), - "return_on_equity": m.get("roeTTM"), - "free_cashflow_per_share": m.get("freeCashFlowPerShareTTM"), - "debt_to_equity": m.get("debtToEquityTTM"), + "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 4 years. + """Annual income statement — last N years. - Source: GET /api/v3/income-statement/{symbol} + Source: GET /stable/income-statement?symbol=&limit= """ - data = await _get( - client, f"/api/v3/income-statement/{ticker}", period="annual", limit=limit - ) + data = await _get(client, "/income-statement", symbol=ticker, limit=limit) if not data: return {} @@ -112,7 +122,7 @@ async def fetch_financials(client: httpx.AsyncClient, ticker: str, limit: int = gross_profit.append(row.get("grossProfit")) operating_income.append(row.get("operatingIncome")) net_income.append(row.get("netIncome")) - eps.append(row.get("eps")) + eps.append(row.get("epsDiluted")) ebitda.append(row.get("ebitda")) return { @@ -127,11 +137,11 @@ async def fetch_financials(client: httpx.AsyncClient, ticker: str, limit: int = async def fetch_scores(client: httpx.AsyncClient, ticker: str) -> dict[str, Any]: - """Piotroski F-Score and Altman Z-Score from FMP's pre-computed scores. + """Piotroski F-Score and Altman Z-Score (may be empty on free tier). - Source: GET /stable/scores + Source: GET /stable/scores?symbol= """ - data = await _get(client, "/stable/scores", symbol=ticker) + data = await _get(client, "/financial-scores", symbol=ticker) if not data: return {} s = data[0] @@ -142,11 +152,11 @@ async def fetch_scores(client: httpx.AsyncClient, ticker: str) -> dict[str, Any] async def fetch_news(client: httpx.AsyncClient, ticker: str, limit: int = 8) -> list[dict[str, Any]]: - """Recent news headlines and summaries. + """Recent news headlines. Returns [] if restricted on free tier. - Source: GET /api/v3/stock_news + Source: GET /stable/stock-news?tickers=&limit= """ - data = await _get(client, "/api/v3/stock_news", tickers=ticker, limit=limit) + data = await _get(client, "/news", tickers=ticker, limit=limit) return [ { "title": item.get("title", ""), @@ -156,6 +166,7 @@ async def fetch_news(client: httpx.AsyncClient, ticker: str, limit: int = 8) -> "summary": item.get("text", ""), } for item in (data or []) + if isinstance(item, dict) ] @@ -163,14 +174,14 @@ async def fetch_analyst_ratings(client: httpx.AsyncClient, ticker: str) -> dict[ """Analyst price targets and recommendation breakdown. Sources: - GET /api/v4/price-target-consensus - GET /api/v3/analyst-stock-recommendations/{symbol} + GET /stable/price-target-consensus?symbol= + GET /stable/analyst-stock-recommendations?symbol= """ import asyncio targets_data, recs_data = await asyncio.gather( - _get(client, "/api/v4/price-target-consensus", symbol=ticker), - _get(client, f"/api/v3/analyst-stock-recommendations/{ticker}", limit=2), + _get(client, "/price-target-consensus", symbol=ticker), + _get(client, "/analyst-recommendation", symbol=ticker, limit=2), ) targets = targets_data[0] if targets_data else {} @@ -178,12 +189,13 @@ async def fetch_analyst_ratings(client: httpx.AsyncClient, ticker: str) -> dict[ { "period": row.get("date", ""), "strong_buy": row.get("analystRatingsStrongBuy", 0), - "buy": row.get("analystRatingsbuy", 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 { From f31377ac0d3de285b0a8c5035579cb3271bf1db2 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Fri, 22 May 2026 15:59:51 -0700 Subject: [PATCH 10/29] set bot cloud run % cloud schedule --- bot/.dockerignore | 9 ++ bot/Dockerfile | 9 +- bot/__init__.py | 0 terraform/bootstrap/main.tf | 1 + terraform/bot/backend.tf | 3 + terraform/bot/main.tf | 267 ++++++++++++++++++++++++++++++++++++ terraform/bot/outputs.tf | 4 + terraform/bot/variables.tf | 40 ++++++ terraform/deploy.sh | 19 +++ 9 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 bot/.dockerignore create mode 100644 bot/__init__.py create mode 100644 terraform/bot/backend.tf create mode 100644 terraform/bot/main.tf create mode 100644 terraform/bot/outputs.tf create mode 100644 terraform/bot/variables.tf 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/Dockerfile b/bot/Dockerfile index ae7d0d6..eff555e 100644 --- a/bot/Dockerfile +++ b/bot/Dockerfile @@ -1,12 +1,15 @@ -FROM python:3.12-slim +FROM python:3.14-slim WORKDIR /app +# Install dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY . . +# Copy bot source as a package: /app/bot/ +COPY . ./bot/ ENV PYTHONUNBUFFERED=1 -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] +# 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/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/terraform/bootstrap/main.tf b/terraform/bootstrap/main.tf index 55a9a17..f55c8d9 100644 --- a/terraform/bootstrap/main.tf +++ b/terraform/bootstrap/main.tf @@ -21,6 +21,7 @@ resource "google_project_service" "apis" { "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..cde62eb --- /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", "**")) : filesha1("${path.root}/../../bot/${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 = "America/New_York" + 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/deploy.sh b/terraform/deploy.sh index e96b60c..3e3a995 100755 --- a/terraform/deploy.sh +++ b/terraform/deploy.sh @@ -69,4 +69,23 @@ grep -q "^TELEGRAM_CHAT_ID=" "${ENV_FILE}" || echo "TELEGRAM_CHAT_ID=" >> 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 "=== Done ===" +echo "" +echo " API URL : ${API_URL}" +echo " Bot URL : ${BOT_URL}" +echo "" +echo "Next: register Telegram webhook:" +echo " curl -s 'https://api.telegram.org/bot/setWebhook?url=${BOT_URL}/webhook'" From 6d9ed76306adeabbdab6131e932cad317295cb99 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Fri, 22 May 2026 23:34:24 -0700 Subject: [PATCH 11/29] set telegram webhook --- terraform/deploy.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/terraform/deploy.sh b/terraform/deploy.sh index 3e3a995..d6077c1 100755 --- a/terraform/deploy.sh +++ b/terraform/deploy.sh @@ -82,10 +82,17 @@ terraform apply -auto-approve \ 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-) +WEBHOOK_RESP=$(curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/setWebhook?url=${BOT_URL}/webhook") +echo " Response: ${WEBHOOK_RESP}" +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 — check response above" +fi + echo "=== Done ===" echo "" echo " API URL : ${API_URL}" echo " Bot URL : ${BOT_URL}" -echo "" -echo "Next: register Telegram webhook:" -echo " curl -s 'https://api.telegram.org/bot/setWebhook?url=${BOT_URL}/webhook'" From a23d590745b776881f8001564ff35dfc17ab0cf7 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Sun, 24 May 2026 20:02:43 -0700 Subject: [PATCH 12/29] fix format --- bot/tg/handlers.py | 47 +++++++++++++++++++++++++-------------------- bot/tg/push.py | 12 ++++++------ bot/tools/prompt.py | 2 +- terraform/deploy.sh | 13 +++++++++++++ 4 files changed, 46 insertions(+), 28 deletions(-) diff --git a/bot/tg/handlers.py b/bot/tg/handlers.py index c2170a8..c491784 100644 --- a/bot/tg/handlers.py +++ b/bot/tg/handlers.py @@ -20,6 +20,11 @@ # ── 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", "") @@ -31,13 +36,13 @@ def _format_decision(ticker: str, decision: dict) -> str: emoji = {"BUY": "🟢", "HOLD": "🟡", "SELL": "🔴"}.get(verdict, "⚪") lines = [ - f"{emoji} *{ticker}* — {verdict} ({confidence})", - f"_{thesis}_", + f"{emoji} {_esc(ticker)} — {_esc(verdict)} ({_esc(confidence)})", + f"{_esc(thesis)}", ] if assumptions: - lines.append("\n*Key assumptions:*") + lines.append("\nKey assumptions:") for a in assumptions: - lines.append(f" • {a}") + lines.append(f" • {_esc(str(a))}") if stop_loss: lines.append(f"\n🛑 Stop-loss: ${stop_loss:.2f}") if target: @@ -52,20 +57,20 @@ def _format_portfolio_summary(summary: dict) -> str: analyzed = summary.get("analyzed_count", 0) total = summary.get("holdings_count", 0) - lines = [f"📁 *Portfolio Analysis* ({analyzed}/{total} stocks)\n"] + lines = [f"📁 Portfolio Analysis ({analyzed}/{total} stocks)\n"] for v in verdicts: emoji = {"BUY": "🟢", "HOLD": "🟡", "SELL": "🔴"}.get(v.get("verdict"), "⚪") lines.append( - f"{emoji} *{v['ticker']}* — {v.get('verdict')} ({v.get('confidence', '')})" + f"{emoji} {_esc(v['ticker'])} — {_esc(v.get('verdict', ''))} ({_esc(v.get('confidence', ''))})" ) if v.get("thesis"): - lines.append(f" _{v['thesis'][:120]}..._") + lines.append(f" {_esc(v['thesis'][:120])}...") if risks: - lines.append("\n⚠️ *Concentration Risk*") + lines.append("\n⚠️ Concentration Risk") for r in risks: - lines.append(f" • {r['ticker']} {r['weight_pct']}% — {r['flag']}") + lines.append(f" • {_esc(r['ticker'])} {r['weight_pct']}% — {_esc(r['flag'])}") return "\n".join(lines) @@ -94,13 +99,13 @@ async def handle_decide(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N args = context.args if not args: await update.message.reply_text( - "Usage: `/decide TICKER` e.g. `/decide NVDA`", - parse_mode="Markdown", + "Usage: /decide TICKER e.g. /decide NVDA", + parse_mode="HTML", ) return ticker = args[0].upper() - await update.message.reply_text(f"🔍 Analysing *{ticker}*...", parse_mode="Markdown") + await update.message.reply_text(f"🔍 Analysing {_esc(ticker)}...", parse_mode="HTML") try: state = await _get_initial_state(ticker, "single") @@ -111,7 +116,7 @@ async def handle_decide(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N return text = _format_decision(ticker, result.get("decision") or {}) - await update.message.reply_text(text, parse_mode="Markdown") + await update.message.reply_text(text, parse_mode="HTML") except Exception as exc: # noqa: BLE001 await update.message.reply_text(f"❌ Analysis failed: {exc}") @@ -119,7 +124,7 @@ async def handle_decide(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N 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="Markdown") + await update.message.reply_text("📁 Analysing your portfolio...", parse_mode="HTML") try: state = await _get_initial_state(None, "portfolio") @@ -135,7 +140,7 @@ async def handle_portfolio(update: Update, context: ContextTypes.DEFAULT_TYPE) - return text = _format_portfolio_summary(summary) - await update.message.reply_text(text, parse_mode="Markdown") + 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}") @@ -154,9 +159,9 @@ async def handle_model(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No reply_markup = InlineKeyboardMarkup(keyboard) await update.message.reply_text( - "🤖 *Select LLM model for analysis:*", + "🤖 Select LLM model for analysis:", reply_markup=reply_markup, - parse_mode="Markdown", + parse_mode="HTML", ) @@ -195,16 +200,16 @@ async def handle_model_selection(update: Update, context: ContextTypes.DEFAULT_T 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 *{name}*", parse_mode="Markdown") + 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" + "📊 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="Markdown") + await update.message.reply_text(text, parse_mode="HTML") diff --git a/bot/tg/push.py b/bot/tg/push.py index ecfca33..2d4e594 100644 --- a/bot/tg/push.py +++ b/bot/tg/push.py @@ -25,11 +25,11 @@ 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)", + "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)", } @@ -62,6 +62,6 @@ async def push(request: Request, body: PushRequest) -> dict: 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="Markdown") + 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/tools/prompt.py b/bot/tools/prompt.py index 67f4e0c..c6493d2 100644 --- a/bot/tools/prompt.py +++ b/bot/tools/prompt.py @@ -28,7 +28,7 @@ def build_decision_prompt( timing = s.get("timing", {}) news = s.get("news", []) targets = analyst.get("price_targets", {}) - recs = analyst.get("recommendations", [{}])[0] + recs = (analyst.get("recommendations") or [{}])[0] headlines = "\n".join( f" - [{n.get('publisher', '')}] {n.get('title', '')}" diff --git a/terraform/deploy.sh b/terraform/deploy.sh index d6077c1..aaa1cf5 100755 --- a/terraform/deploy.sh +++ b/terraform/deploy.sh @@ -84,6 +84,19 @@ 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..." +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})" + break + fi + echo " Attempt ${i}/12: status=${STATUS}, retrying in 10s..." + sleep 10 +done + WEBHOOK_RESP=$(curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/setWebhook?url=${BOT_URL}/webhook") echo " Response: ${WEBHOOK_RESP}" if echo "${WEBHOOK_RESP}" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('ok') else 1)"; then From 0df096221605ce2c18f7bd27d70e966671c66401 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Sun, 24 May 2026 20:06:40 -0700 Subject: [PATCH 13/29] add push test --- bot/tests/test_push.py | 80 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 bot/tests/test_push.py 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 From 7f9f5a636a3a19b5d53217e5df04a4f1ce40674d Mon Sep 17 00:00:00 2001 From: PCBZ Date: Sun, 24 May 2026 20:42:24 -0700 Subject: [PATCH 14/29] fix lint error --- bot/tests/test_fmp.py | 2 +- bot/tests/test_openrouter.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/tests/test_fmp.py b/bot/tests/test_fmp.py index 0c1b34e..35e6049 100644 --- a/bot/tests/test_fmp.py +++ b/bot/tests/test_fmp.py @@ -17,7 +17,7 @@ load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env") -from bot.tools.market_data import ( +from bot.tools.market_data import ( # noqa: E402 fetch_quote, fetch_profile, fetch_key_metrics, diff --git a/bot/tests/test_openrouter.py b/bot/tests/test_openrouter.py index a7a9574..d279be4 100644 --- a/bot/tests/test_openrouter.py +++ b/bot/tests/test_openrouter.py @@ -16,9 +16,9 @@ load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env") -from bot.config import get_llm_models, get_default_model_id -from bot.tools.llm import get_llm -from bot.agents.decision import DecisionOutput +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 From 96307cc4ae970ba180d5a61f177a9d93604f1d90 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Sun, 24 May 2026 21:32:52 -0700 Subject: [PATCH 15/29] reset structure. --- .github/workflows/lint.yml | 4 +- bot/Dockerfile | 2 +- bot/pytest.ini | 2 + bot/{ => src}/__init__.py | 0 bot/{ => src}/agents/__init__.py | 0 bot/{ => src}/agents/data.py | 6 +- bot/{ => src}/agents/decision.py | 26 ++++---- bot/{ => src}/agents/fundamental.py | 40 ++++++------ bot/{ => src}/agents/portfolio.py | 97 ++++++++++++++++------------ bot/{ => src}/agents/sentiment.py | 14 ++-- bot/{ => src}/config.json | 0 bot/{ => src}/config.py | 1 + bot/{ => src}/graph/__init__.py | 0 bot/{ => src}/graph/workflow.py | 58 +++++++++++------ bot/{ => src}/main.py | 4 +- bot/{ => src}/state.py | 4 +- bot/{ => src}/tg/__init__.py | 0 bot/{ => src}/tg/bot.py | 3 +- bot/{ => src}/tg/handlers.py | 40 +++++++----- bot/{ => src}/tg/push.py | 18 +++--- bot/{ => src}/tools/__init__.py | 0 bot/{ => src}/tools/llm.py | 2 +- bot/{ => src}/tools/market_data.py | 17 +++-- bot/{ => src}/tools/portfolio_api.py | 4 +- bot/{ => src}/tools/prompt.py | 32 ++++----- sync/{ => src}/main.py | 22 ++++--- terraform/bot/main.tf | 2 +- 27 files changed, 224 insertions(+), 174 deletions(-) rename bot/{ => src}/__init__.py (100%) rename bot/{ => src}/agents/__init__.py (100%) rename bot/{ => src}/agents/data.py (93%) rename bot/{ => src}/agents/decision.py (82%) rename bot/{ => src}/agents/fundamental.py (54%) rename bot/{ => src}/agents/portfolio.py (58%) rename bot/{ => src}/agents/sentiment.py (86%) rename bot/{ => src}/config.json (100%) rename bot/{ => src}/config.py (99%) rename bot/{ => src}/graph/__init__.py (100%) rename bot/{ => src}/graph/workflow.py (75%) rename bot/{ => src}/main.py (85%) rename bot/{ => src}/state.py (94%) rename bot/{ => src}/tg/__init__.py (100%) rename bot/{ => src}/tg/bot.py (98%) rename bot/{ => src}/tg/handlers.py (87%) rename bot/{ => src}/tg/push.py (76%) rename bot/{ => src}/tools/__init__.py (100%) rename bot/{ => src}/tools/llm.py (97%) rename bot/{ => src}/tools/market_data.py (94%) rename bot/{ => src}/tools/portfolio_api.py (92%) rename bot/{ => src}/tools/prompt.py (83%) rename sync/{ => src}/main.py (77%) 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/bot/Dockerfile b/bot/Dockerfile index eff555e..2c7041f 100644 --- a/bot/Dockerfile +++ b/bot/Dockerfile @@ -7,7 +7,7 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy bot source as a package: /app/bot/ -COPY . ./bot/ +COPY src/ ./bot/ ENV PYTHONUNBUFFERED=1 diff --git a/bot/pytest.ini b/bot/pytest.ini index 2f4c80e..0cd96f9 100644 --- a/bot/pytest.ini +++ b/bot/pytest.ini @@ -1,2 +1,4 @@ [pytest] asyncio_mode = auto +pythonpath = src +testpaths = tests diff --git a/bot/__init__.py b/bot/src/__init__.py similarity index 100% rename from bot/__init__.py rename to bot/src/__init__.py diff --git a/bot/agents/__init__.py b/bot/src/agents/__init__.py similarity index 100% rename from bot/agents/__init__.py rename to bot/src/agents/__init__.py diff --git a/bot/agents/data.py b/bot/src/agents/data.py similarity index 93% rename from bot/agents/data.py rename to bot/src/agents/data.py index f483ce8..74c9fff 100644 --- a/bot/agents/data.py +++ b/bot/src/agents/data.py @@ -6,8 +6,8 @@ import httpx -from bot.state import AnalysisState -from bot.tools.market_data import ( +from ..state import AnalysisState +from ..tools.market_data import ( fetch_analyst_ratings, fetch_financials, fetch_key_metrics, @@ -16,7 +16,7 @@ fetch_quote, fetch_scores, ) -from bot.tools.portfolio_api import get_holdings, get_preferences +from ..tools.portfolio_api import get_holdings, get_preferences async def data_agent(state: AnalysisState) -> dict: diff --git a/bot/agents/decision.py b/bot/src/agents/decision.py similarity index 82% rename from bot/agents/decision.py rename to bot/src/agents/decision.py index 3c6e02c..d6c85cd 100644 --- a/bot/agents/decision.py +++ b/bot/src/agents/decision.py @@ -11,14 +11,15 @@ from pydantic import BaseModel, Field -from bot.state import AnalysisState -from bot.tools.llm import get_llm -from bot.tools.portfolio_api import post_decision -from bot.tools.prompt import build_decision_prompt +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" @@ -33,28 +34,27 @@ class DecisionOutput(BaseModel): 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." + 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." + 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", {}) + 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( diff --git a/bot/agents/fundamental.py b/bot/src/agents/fundamental.py similarity index 54% rename from bot/agents/fundamental.py rename to bot/src/agents/fundamental.py index 23999b1..c01b268 100644 --- a/bot/agents/fundamental.py +++ b/bot/src/agents/fundamental.py @@ -8,7 +8,7 @@ from __future__ import annotations -from bot.state import AnalysisState +from ..state import AnalysisState async def fundamental_agent(state: AnalysisState) -> dict: @@ -16,54 +16,50 @@ 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", {}) + raw = state.get("raw_data", {}) + quote = raw.get("quote", {}) key_metrics = raw.get("key_metrics", {}) - financials = raw.get("financials", {}) - scores = raw.get("scores", {}) + 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 - ) + 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 - ) + 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 + "piotroski": scores.get("piotroski_score"), # 0–9 + "altman_z": scores.get("altman_z_score"), # >2.99 = safe }, # Valuation "valuation": { - "trailing_pe": quote.get("trailing_pe"), - "forward_pe": key_metrics.get("forward_pe"), - "ev_to_ebitda": key_metrics.get("ev_to_ebitda"), + "trailing_pe": quote.get("trailing_pe"), + "forward_pe": key_metrics.get("forward_pe"), + "ev_to_ebitda": key_metrics.get("ev_to_ebitda"), "price_to_book": key_metrics.get("price_to_book"), - "market_cap": quote.get("market_cap"), + "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, + "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_equity": key_metrics.get("return_on_equity"), "free_cashflow_per_share": key_metrics.get("free_cashflow_per_share"), - "debt_to_equity": key_metrics.get("debt_to_equity"), + "debt_to_equity": key_metrics.get("debt_to_equity"), }, } } diff --git a/bot/agents/portfolio.py b/bot/src/agents/portfolio.py similarity index 58% rename from bot/agents/portfolio.py rename to bot/src/agents/portfolio.py index 3ee5de1..8524989 100644 --- a/bot/agents/portfolio.py +++ b/bot/src/agents/portfolio.py @@ -8,8 +8,8 @@ from __future__ import annotations -from bot.state import AnalysisState -from bot.tools.portfolio_api import get_preferences +from ..state import AnalysisState +from ..tools.portfolio_api import get_preferences _STOCK_TYPE = "STOCK" @@ -17,6 +17,7 @@ # ── Concentration risk ──────────────────────────────────────────────────────── + def _detect_concentration_risk( holdings: list[dict], verdicts: list[dict], @@ -36,42 +37,46 @@ def _detect_concentration_risk( 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}%)", - }) + 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", - }) + 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 bot.graph.workflow import single_stock_graph + 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 + h for h in all_holdings if h.get("security_type", "").upper() == _STOCK_TYPE ] if not stock_holdings: @@ -91,36 +96,42 @@ async def portfolio_agent(state: AnalysisState) -> dict: 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, - }) + 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"), - }) + 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}", - }) + 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( diff --git a/bot/agents/sentiment.py b/bot/src/agents/sentiment.py similarity index 86% rename from bot/agents/sentiment.py rename to bot/src/agents/sentiment.py index 635430f..e4ee403 100644 --- a/bot/agents/sentiment.py +++ b/bot/src/agents/sentiment.py @@ -6,7 +6,7 @@ from __future__ import annotations -from bot.state import AnalysisState +from ..state import AnalysisState async def sentiment_agent(state: AnalysisState) -> dict: @@ -15,14 +15,14 @@ async def sentiment_agent(state: AnalysisState) -> dict: Writes: sentiment_analysis """ raw = state.get("raw_data", {}) - quote = raw.get("quote", {}) + quote = raw.get("quote", {}) analyst = raw.get("analyst", {}) - news = raw.get("news", []) + 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", {}) + 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) diff --git a/bot/config.json b/bot/src/config.json similarity index 100% rename from bot/config.json rename to bot/src/config.json diff --git a/bot/config.py b/bot/src/config.py similarity index 99% rename from bot/config.py rename to bot/src/config.py index 660fa6e..9297951 100644 --- a/bot/config.py +++ b/bot/src/config.py @@ -23,6 +23,7 @@ def _load() -> dict[str, Any]: # ── LLM models ──────────────────────────────────────────────────────────────── + def get_llm_models() -> list[dict[str, Any]]: """Return all configured LLM models.""" return _config["llm_models"] diff --git a/bot/graph/__init__.py b/bot/src/graph/__init__.py similarity index 100% rename from bot/graph/__init__.py rename to bot/src/graph/__init__.py diff --git a/bot/graph/workflow.py b/bot/src/graph/workflow.py similarity index 75% rename from bot/graph/workflow.py rename to bot/src/graph/workflow.py index f7e6abd..1d0147f 100644 --- a/bot/graph/workflow.py +++ b/bot/src/graph/workflow.py @@ -15,16 +15,17 @@ from langgraph.graph import END, START, StateGraph -from bot.agents.data import data_agent -from bot.agents.decision import decision_agent -from bot.agents.fundamental import fundamental_agent -from bot.agents.portfolio import portfolio_agent -from bot.agents.sentiment import sentiment_agent -from bot.state import AnalysisState +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"): @@ -34,6 +35,7 @@ def route_intent(state: AnalysisState) -> str: # ── 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"): @@ -43,6 +45,7 @@ def after_data(state: AnalysisState) -> list[str]: # ── Single-stock subgraph (reused by portfolio_agent) ──────────────────────── + def build_single_stock_graph() -> StateGraph: builder = StateGraph(AnalysisState) @@ -52,11 +55,15 @@ def build_single_stock_graph() -> StateGraph: 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_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) @@ -66,6 +73,7 @@ def build_single_stock_graph() -> StateGraph: # ── Full graph (main entry point) ───────────────────────────────────────────── + def build_graph() -> StateGraph: builder = StateGraph(AnalysisState) @@ -75,16 +83,24 @@ def build_graph() -> StateGraph: 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_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) diff --git a/bot/main.py b/bot/src/main.py similarity index 85% rename from bot/main.py rename to bot/src/main.py index 314af89..207d310 100644 --- a/bot/main.py +++ b/bot/src/main.py @@ -15,8 +15,8 @@ load_dotenv(dotenv_path=Path(__file__).parent / ".env") -from bot.tg.bot import webhook_router # noqa: E402 -from bot.tg.push import push_router # noqa: E402 +from .tg.bot import webhook_router # noqa: E402 +from .tg.push import push_router # noqa: E402 app = FastAPI(title="trade-compass-bot") diff --git a/bot/state.py b/bot/src/state.py similarity index 94% rename from bot/state.py rename to bot/src/state.py index 12b4884..144203f 100644 --- a/bot/state.py +++ b/bot/src/state.py @@ -57,8 +57,8 @@ class AnalysisState(TypedDict): 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 + 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] diff --git a/bot/tg/__init__.py b/bot/src/tg/__init__.py similarity index 100% rename from bot/tg/__init__.py rename to bot/src/tg/__init__.py diff --git a/bot/tg/bot.py b/bot/src/tg/bot.py similarity index 98% rename from bot/tg/bot.py rename to bot/src/tg/bot.py index fa5a455..4824229 100644 --- a/bot/tg/bot.py +++ b/bot/src/tg/bot.py @@ -16,7 +16,7 @@ CommandHandler, ) -from bot.tg.handlers import ( +from .handlers import ( handle_decide, handle_help, handle_model, @@ -26,6 +26,7 @@ # ── Telegram Application (singleton) ───────────────────────────────────────── + def build_application() -> Application: token = os.environ.get("TELEGRAM_BOT_TOKEN", "") app = Application.builder().token(token).build() diff --git a/bot/tg/handlers.py b/bot/src/tg/handlers.py similarity index 87% rename from bot/tg/handlers.py rename to bot/src/tg/handlers.py index c491784..972cfe4 100644 --- a/bot/tg/handlers.py +++ b/bot/src/tg/handlers.py @@ -11,27 +11,28 @@ from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ContextTypes -from bot.config import get_llm_models, is_valid_model -from bot.graph.workflow import graph, single_stock_graph -from bot.tools.portfolio_api import get_preferences, get_holdings +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", "") + 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") + stop_loss = decision.get("stop_loss") + target = decision.get("target_price") emoji = {"BUY": "🟢", "HOLD": "🟡", "SELL": "🔴"}.get(verdict, "⚪") @@ -53,9 +54,9 @@ def _format_decision(ticker: str, decision: dict) -> str: def _format_portfolio_summary(summary: dict) -> str: verdicts = summary.get("verdicts", []) - risks = summary.get("concentration_risk", []) + risks = summary.get("concentration_risk", []) analyzed = summary.get("analyzed_count", 0) - total = summary.get("holdings_count", 0) + total = summary.get("holdings_count", 0) lines = [f"📁 Portfolio Analysis ({analyzed}/{total} stocks)\n"] @@ -70,14 +71,16 @@ def _format_portfolio_summary(summary: dict) -> str: if risks: lines.append("\n⚠️ Concentration Risk") for r in risks: - lines.append(f" • {_esc(r['ticker'])} {r['weight_pct']}% — {_esc(r['flag'])}") + 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() + holdings = await get_holdings() return { "ticker": ticker, "mode": mode, @@ -94,6 +97,7 @@ async def _get_initial_state(ticker: str | None, mode: str) -> dict: # ── Command handlers ────────────────────────────────────────────────────────── + async def handle_decide(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/decide NVDA — single-stock analysis.""" args = context.args @@ -105,7 +109,9 @@ async def handle_decide(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N return ticker = args[0].upper() - await update.message.reply_text(f"🔍 Analysing {_esc(ticker)}...", parse_mode="HTML") + await update.message.reply_text( + f"🔍 Analysing {_esc(ticker)}...", parse_mode="HTML" + ) try: state = await _get_initial_state(ticker, "single") @@ -165,7 +171,9 @@ async def handle_model(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No ) -async def handle_model_selection(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: +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() @@ -200,7 +208,9 @@ async def handle_model_selection(update: Update, context: ContextTypes.DEFAULT_T 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") + 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: diff --git a/bot/tg/push.py b/bot/src/tg/push.py similarity index 76% rename from bot/tg/push.py rename to bot/src/tg/push.py index 2d4e594..a9c1705 100644 --- a/bot/tg/push.py +++ b/bot/src/tg/push.py @@ -19,16 +19,16 @@ from pydantic import BaseModel from telegram import Bot -from bot.graph.workflow import graph -from bot.tg.handlers import _format_portfolio_summary, _get_initial_state +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)", + "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)", } @@ -47,11 +47,11 @@ async def push(request: Request, body: PushRequest) -> dict: ) chat_id = os.environ.get("TELEGRAM_CHAT_ID") - token = os.environ.get("TELEGRAM_BOT_TOKEN") + token = os.environ.get("TELEGRAM_BOT_TOKEN") if not chat_id or not token: raise HTTPException(status_code=500, detail="TELEGRAM_CHAT_ID or TOKEN not set") - state = await _get_initial_state(None, "portfolio") + state = await _get_initial_state(None, "portfolio") result = await graph.ainvoke(state) summary = result.get("portfolio_summary") or {} @@ -59,7 +59,7 @@ async def push(request: Request, body: PushRequest) -> dict: return {"sent": False, "reason": "no stock positions"} header = _PUSH_TYPES[body.type] - text = f"{header}\n\n{_format_portfolio_summary(summary)}" + 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") diff --git a/bot/tools/__init__.py b/bot/src/tools/__init__.py similarity index 100% rename from bot/tools/__init__.py rename to bot/src/tools/__init__.py diff --git a/bot/tools/llm.py b/bot/src/tools/llm.py similarity index 97% rename from bot/tools/llm.py rename to bot/src/tools/llm.py index 5c9a258..f1e4d4c 100644 --- a/bot/tools/llm.py +++ b/bot/src/tools/llm.py @@ -13,7 +13,7 @@ from langchain_openai import ChatOpenAI from pydantic import BaseModel -from bot.config import get_default_model_id +from ..config import get_default_model_id _OPENROUTER_BASE = "https://openrouter.ai/api/v1" diff --git a/bot/tools/market_data.py b/bot/src/tools/market_data.py similarity index 94% rename from bot/tools/market_data.py rename to bot/src/tools/market_data.py index cf23eea..041c4ff 100644 --- a/bot/tools/market_data.py +++ b/bot/src/tools/market_data.py @@ -24,9 +24,7 @@ def _key() -> dict[str, str]: 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 - ) + 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() @@ -39,6 +37,7 @@ async def _get(client: httpx.AsyncClient, path: str, **params: Any) -> Any: # ── 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. @@ -104,7 +103,9 @@ async def fetch_key_metrics(client: httpx.AsyncClient, ticker: str) -> dict[str, } -async def fetch_financials(client: httpx.AsyncClient, ticker: str, limit: int = 4) -> dict[str, Any]: +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= @@ -151,7 +152,9 @@ async def fetch_scores(client: httpx.AsyncClient, ticker: str) -> dict[str, Any] } -async def fetch_news(client: httpx.AsyncClient, ticker: str, limit: int = 8) -> list[dict[str, Any]]: +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/stock-news?tickers=&limit= @@ -170,7 +173,9 @@ async def fetch_news(client: httpx.AsyncClient, ticker: str, limit: int = 8) -> ] -async def fetch_analyst_ratings(client: httpx.AsyncClient, ticker: str) -> dict[str, Any]: +async def fetch_analyst_ratings( + client: httpx.AsyncClient, ticker: str +) -> dict[str, Any]: """Analyst price targets and recommendation breakdown. Sources: diff --git a/bot/tools/portfolio_api.py b/bot/src/tools/portfolio_api.py similarity index 92% rename from bot/tools/portfolio_api.py rename to bot/src/tools/portfolio_api.py index f6b6c8f..76cd13e 100644 --- a/bot/tools/portfolio_api.py +++ b/bot/src/tools/portfolio_api.py @@ -29,7 +29,9 @@ async def get_holdings() -> list[dict[str, Any]]: 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 = await client.get( + f"{_API_URL}/preferences", headers=_headers(), timeout=10 + ) resp.raise_for_status() return resp.json() diff --git a/bot/tools/prompt.py b/bot/src/tools/prompt.py similarity index 83% rename from bot/tools/prompt.py rename to bot/src/tools/prompt.py index c6493d2..471b906 100644 --- a/bot/tools/prompt.py +++ b/bot/src/tools/prompt.py @@ -19,24 +19,26 @@ def build_decision_prompt( f = fundamental s = sentiment - scores = f.get("scores", {}) + 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." + 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") + altman_z = scores.get("altman_z") return f"""You are a senior equity analyst. Analyse the following data and provide a structured investment verdict. diff --git a/sync/main.py b/sync/src/main.py similarity index 77% rename from sync/main.py rename to sync/src/main.py index a0d3c11..af1db64 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() diff --git a/terraform/bot/main.tf b/terraform/bot/main.tf index cde62eb..9a0f7e5 100644 --- a/terraform/bot/main.tf +++ b/terraform/bot/main.tf @@ -35,7 +35,7 @@ data "terraform_remote_state" "cloud_run" { # ── Image: reuse existing Artifact Registry repo ────────────────────────────── locals { - src_hash = sha1(join("", [for f in sort(fileset("${path.root}/../../bot", "**")) : filesha1("${path.root}/../../bot/${f}")])) + 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" } From 8f70ae4823e54e78bf8068343fc655680796a38e Mon Sep 17 00:00:00 2001 From: PCBZ Date: Sun, 24 May 2026 22:01:52 -0700 Subject: [PATCH 16/29] fix API error --- api/tests/test_auth.py | 6 +++--- api/tests/test_decisions.py | 8 ++++---- api/tests/test_holdings.py | 14 +++++++------- api/tests/test_preferences.py | 8 ++++---- 4 files changed, 18 insertions(+), 18 deletions(-) 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..6798d7e 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,7 @@ 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." 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"] From c2d214773660b402cb74f4d0492ca8faf9919b9b Mon Sep 17 00:00:00 2001 From: PCBZ Date: Sun, 24 May 2026 22:18:48 -0700 Subject: [PATCH 17/29] fix code review for deplot bash --- terraform/deploy.sh | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/terraform/deploy.sh b/terraform/deploy.sh index aaa1cf5..895da56 100755 --- a/terraform/deploy.sh +++ b/terraform/deploy.sh @@ -47,14 +47,22 @@ 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 +# 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)" if grep -q "^${key}=" "${ENV_FILE}" 2>/dev/null; then - sed -i '' "s|^${key}=.*|${key}=${val}|" "${ENV_FILE}" + # 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 - echo "${key}=${val}" >> "${ENV_FILE}" + cp "${ENV_FILE}" "${tmp}" + printf '%s=%s\n' "${key}" "${val}" >> "${tmp}" fi + mv "${tmp}" "${ENV_FILE}" } touch "${ENV_FILE}" @@ -97,12 +105,16 @@ for i in $(seq 1 12); do sleep 10 done -WEBHOOK_RESP=$(curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/setWebhook?url=${BOT_URL}/webhook") -echo " Response: ${WEBHOOK_RESP}" +# 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 — check response above" + echo " WARNING: webhook registration may have failed" + echo " Response: ${WEBHOOK_RESP}" fi echo "=== Done ===" From b431de39e064c5ee40249e232c3b7f1fcee2f1e9 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Sun, 24 May 2026 22:26:32 -0700 Subject: [PATCH 18/29] set public ip --- terraform/atlas/main.tf | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/terraform/atlas/main.tf b/terraform/atlas/main.tf index c278014..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 @@ -58,9 +59,10 @@ resource "mongodbatlas_project_ip_access_list" "compute_engine" { comment = "Compute Engine static IP" } -# Cloud Run uses dynamic egress IPs — allow all so the API can reach Atlas -resource "mongodbatlas_project_ip_access_list" "allow_all" { +# 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 dynamic egress IPs" + comment = "Cloud Run egress — M0 does not support VPC peering; auth via Secret Manager" } From 9befc9c8ca9af9a9c7fcc319599f09e233627fe9 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Sun, 24 May 2026 22:41:20 -0700 Subject: [PATCH 19/29] use lifespan in bot API --- bot/src/main.py | 4 ++-- bot/src/tg/bot.py | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/bot/src/main.py b/bot/src/main.py index 207d310..fb7377a 100644 --- a/bot/src/main.py +++ b/bot/src/main.py @@ -15,10 +15,10 @@ load_dotenv(dotenv_path=Path(__file__).parent / ".env") -from .tg.bot import webhook_router # noqa: E402 +from .tg.bot import lifespan, webhook_router # noqa: E402 from .tg.push import push_router # noqa: E402 -app = FastAPI(title="trade-compass-bot") +app = FastAPI(title="trade-compass-bot", lifespan=lifespan) app.include_router(webhook_router) app.include_router(push_router) diff --git a/bot/src/tg/bot.py b/bot/src/tg/bot.py index 4824229..8912c14 100644 --- a/bot/src/tg/bot.py +++ b/bot/src/tg/bot.py @@ -2,11 +2,16 @@ 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 @@ -45,6 +50,18 @@ def build_application() -> Application: 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.""" + await application.initialize() + yield + await application.shutdown() + + # ── Webhook FastAPI router ──────────────────────────────────────────────────── webhook_router = APIRouter() @@ -55,6 +72,5 @@ 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.initialize() await application.process_update(update) return Response(status_code=200) From 5baffc6e57fd7aaf630a64bfc94abc5e1cd8698a Mon Sep 17 00:00:00 2001 From: PCBZ Date: Mon, 25 May 2026 13:38:14 -0700 Subject: [PATCH 20/29] fix code review --- api/src/models.py | 2 +- bot/src/agents/fundamental.py | 11 ++++++----- bot/src/tg/push.py | 2 +- bot/src/tools/market_data.py | 7 ++++--- terraform/bot/main.tf | 2 +- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/api/src/models.py b/api/src/models.py index bf895bc..29cbcb6 100644 --- a/api/src/models.py +++ b/api/src/models.py @@ -80,6 +80,6 @@ class Preferences(BaseModel): default=0.1, description="Max single position as fraction of portfolio (0–1)" ) llm_model: str = Field( - default="meta-llama/llama-3.3-70b-instruct:free", + default="", description="LLM model for analysis. Configured in bot/config.json.", ) diff --git a/bot/src/agents/fundamental.py b/bot/src/agents/fundamental.py index c01b268..b686f6b 100644 --- a/bot/src/agents/fundamental.py +++ b/bot/src/agents/fundamental.py @@ -42,10 +42,9 @@ async def fundamental_agent(state: AnalysisState) -> dict: }, # Valuation "valuation": { - "trailing_pe": quote.get("trailing_pe"), - "forward_pe": key_metrics.get("forward_pe"), + "pe_ratio": key_metrics.get("pe_ratio"), "ev_to_ebitda": key_metrics.get("ev_to_ebitda"), - "price_to_book": key_metrics.get("price_to_book"), + "ev_to_sales": key_metrics.get("ev_to_sales"), "market_cap": quote.get("market_cap"), }, # Growth @@ -58,8 +57,10 @@ async def fundamental_agent(state: AnalysisState) -> dict: # Quality "quality": { "return_on_equity": key_metrics.get("return_on_equity"), - "free_cashflow_per_share": key_metrics.get("free_cashflow_per_share"), - "debt_to_equity": key_metrics.get("debt_to_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/tg/push.py b/bot/src/tg/push.py index a9c1705..088005e 100644 --- a/bot/src/tg/push.py +++ b/bot/src/tg/push.py @@ -49,7 +49,7 @@ async def push(request: Request, body: PushRequest) -> dict: 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 TOKEN not set") + 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) diff --git a/bot/src/tools/market_data.py b/bot/src/tools/market_data.py index 041c4ff..8f7e2ed 100644 --- a/bot/src/tools/market_data.py +++ b/bot/src/tools/market_data.py @@ -69,6 +69,7 @@ async def fetch_profile(client: httpx.AsyncClient, ticker: str) -> dict[str, Any return {} p = data[0] return { + "name": p.get("companyName", ""), "sector": p.get("sector", ""), "industry": p.get("industry", ""), "beta": p.get("beta"), @@ -140,7 +141,7 @@ async def fetch_financials( 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/scores?symbol= + Source: GET /stable/financial-scores?symbol= """ data = await _get(client, "/financial-scores", symbol=ticker) if not data: @@ -157,7 +158,7 @@ async def fetch_news( ) -> list[dict[str, Any]]: """Recent news headlines. Returns [] if restricted on free tier. - Source: GET /stable/stock-news?tickers=&limit= + Source: GET /stable/news?tickers=&limit= """ data = await _get(client, "/news", tickers=ticker, limit=limit) return [ @@ -180,7 +181,7 @@ async def fetch_analyst_ratings( Sources: GET /stable/price-target-consensus?symbol= - GET /stable/analyst-stock-recommendations?symbol= + GET /stable/analyst-recommendation?symbol= """ import asyncio diff --git a/terraform/bot/main.tf b/terraform/bot/main.tf index 9a0f7e5..524bab6 100644 --- a/terraform/bot/main.tf +++ b/terraform/bot/main.tf @@ -251,7 +251,7 @@ resource "google_cloud_scheduler_job" "push" { name = "trade-compass-push-${each.key}" description = "trade-compass bot ${each.key} push" schedule = each.value - time_zone = "America/New_York" + time_zone = "UTC" attempt_deadline = "180s" http_target { From 37d02814a8956feb850f017a360bdab321c3e176 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Mon, 25 May 2026 13:51:45 -0700 Subject: [PATCH 21/29] fix format --- bot/src/agents/fundamental.py | 4 +++- bot/src/tg/push.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/src/agents/fundamental.py b/bot/src/agents/fundamental.py index b686f6b..b0e9c3a 100644 --- a/bot/src/agents/fundamental.py +++ b/bot/src/agents/fundamental.py @@ -57,7 +57,9 @@ async def fundamental_agent(state: AnalysisState) -> dict: # Quality "quality": { "return_on_equity": key_metrics.get("return_on_equity"), - "return_on_invested_capital": key_metrics.get("return_on_invested_capital"), + "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/tg/push.py b/bot/src/tg/push.py index 088005e..b5716ff 100644 --- a/bot/src/tg/push.py +++ b/bot/src/tg/push.py @@ -49,7 +49,9 @@ async def push(request: Request, body: PushRequest) -> dict: 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") + 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) From 565ff725d130f853a01f4b1c1bbd1daece19d80d Mon Sep 17 00:00:00 2001 From: PCBZ Date: Mon, 25 May 2026 14:09:16 -0700 Subject: [PATCH 22/29] fit for linux --- terraform/deploy.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terraform/deploy.sh b/terraform/deploy.sh index 895da56..43bc1ab 100755 --- a/terraform/deploy.sh +++ b/terraform/deploy.sh @@ -53,7 +53,7 @@ ENV_FILE="${ROOT_DIR}/bot/.env" set_env() { local key="$1" val="$2" local tmp - tmp="$(mktemp)" + 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}" From 4a4bb0c98ed606fc461bcaaddab937f0abda18ba Mon Sep 17 00:00:00 2001 From: PCBZ Date: Mon, 25 May 2026 14:10:44 -0700 Subject: [PATCH 23/29] add healthy check to ensure the health condition --- terraform/deploy.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/terraform/deploy.sh b/terraform/deploy.sh index 43bc1ab..751bd2b 100755 --- a/terraform/deploy.sh +++ b/terraform/deploy.sh @@ -95,16 +95,23 @@ TELEGRAM_BOT_TOKEN=$(grep '^TELEGRAM_BOT_TOKEN=' "${ROOT_DIR}/bot/.env" | cut -d # 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 \ From 6cf82dd6988a4d2c376bbc40d03d0d8a86862d18 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Mon, 25 May 2026 16:46:11 -0700 Subject: [PATCH 24/29] change storage role --- terraform/cloud_run/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terraform/cloud_run/main.tf b/terraform/cloud_run/main.tf index bb471c1..f3db5ad 100644 --- a/terraform/cloud_run/main.tf +++ b/terraform/cloud_run/main.tf @@ -41,7 +41,7 @@ locals { resource "google_project_iam_member" "cloudbuild_storage" { project = var.gcp_project_id - role = "roles/storage.admin" + role = "roles/storage.objectAdmin" member = local.cloudbuild_sa } From 8a98fcf89902d5ffb4898fa496ec1e0c6682da63 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Mon, 25 May 2026 18:46:24 -0700 Subject: [PATCH 25/29] fix slash --- sync/src/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sync/src/main.py b/sync/src/main.py index af1db64..00df3d4 100644 --- a/sync/src/main.py +++ b/sync/src/main.py @@ -69,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}, ) From 9650cbec8f266036cf2ecaddedb16af562f8119f Mon Sep 17 00:00:00 2001 From: PCBZ Date: Mon, 25 May 2026 18:47:24 -0700 Subject: [PATCH 26/29] fix prompt --- bot/src/tools/prompt.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/bot/src/tools/prompt.py b/bot/src/tools/prompt.py index 471b906..6a0465c 100644 --- a/bot/src/tools/prompt.py +++ b/bot/src/tools/prompt.py @@ -52,10 +52,9 @@ def build_decision_prompt( Altman Z-Score: {altman_z} (>2.99 safe, <1.81 distress) ## Valuation -Trailing PE: {valuation.get('trailing_pe', 'N/A')} -Forward PE: {valuation.get('forward_pe', 'N/A')} -EV/EBITDA: {valuation.get('ev_to_ebitda', 'N/A')} -Price/Book: {valuation.get('price_to_book', 'N/A')} +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')}% @@ -63,9 +62,11 @@ def build_decision_prompt( Latest EPS: {growth.get('latest_eps', 'N/A')} ## Quality -ROE: {quality.get('return_on_equity', 'N/A')} -FCF/share: {quality.get('free_cashflow_per_share', 'N/A')} -Debt/Equity: {quality.get('debt_to_equity', 'N/A')} +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')} From d4514957986068e1f18ee91d924caccaa334fe14 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Mon, 25 May 2026 18:48:49 -0700 Subject: [PATCH 27/29] fix decision --- bot/src/agents/decision.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/bot/src/agents/decision.py b/bot/src/agents/decision.py index d6c85cd..748a714 100644 --- a/bot/src/agents/decision.py +++ b/bot/src/agents/decision.py @@ -72,15 +72,17 @@ async def decision_agent(state: AnalysisState) -> dict: ) result: DecisionOutput = await llm.ainvoke(prompt) - # Persist to REST API (non-blocking — don't fail analysis if this errors) - try: - await post_decision( - symbol=ticker, - verdict=result.verdict, - reasoning=result.thesis, - ) - except Exception: # noqa: BLE001 - pass + # 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()} From 85c5f60569c37156fac336f12f681994d228106c Mon Sep 17 00:00:00 2001 From: PCBZ Date: Mon, 25 May 2026 21:28:21 -0700 Subject: [PATCH 28/29] fix bot cr --- bot/src/tg/bot.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/src/tg/bot.py b/bot/src/tg/bot.py index 8912c14..b5d6483 100644 --- a/bot/src/tg/bot.py +++ b/bot/src/tg/bot.py @@ -57,6 +57,8 @@ def build_application() -> Application: @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() From 975e64933a87423b6d43432ebfac8911bd761b64 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Mon, 25 May 2026 22:15:37 -0700 Subject: [PATCH 29/29] add tests for decisions --- api/tests/test_decisions.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/api/tests/test_decisions.py b/api/tests/test_decisions.py index 6798d7e..ad5f485 100644 --- a/api/tests/test_decisions.py +++ b/api/tests/test_decisions.py @@ -37,3 +37,35 @@ async def test_get_returns_latest(client): 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