From 63085113ed94777dc493926716c25197b7a03141 Mon Sep 17 00:00:00 2001 From: Aryan Singh Date: Wed, 1 Jul 2026 16:29:29 -0500 Subject: [PATCH] feat(tools): run_python, web search/fetch, market data, options, portfolio Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016DvdRZkyCy9NJpHYDdPqmS --- tools/__init__.py | 25 +++++ tools/market_data.py | 213 +++++++++++++++++++++++++++++++++++++++++++ tools/options.py | 104 +++++++++++++++++++++ tools/portfolio.py | 56 ++++++++++++ tools/python_exec.py | 40 ++++++++ tools/web.py | 114 +++++++++++++++++++++++ 6 files changed, 552 insertions(+) create mode 100644 tools/__init__.py create mode 100644 tools/market_data.py create mode 100644 tools/options.py create mode 100644 tools/portfolio.py create mode 100644 tools/python_exec.py create mode 100644 tools/web.py diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..f6b5e2f --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1,25 @@ +"""Tool registry. All personas get the full core toolset; a persona's `tool_emphasis` +is a prompt hint (which tools to lean on), not a hard restriction. get_portfolio is bound +per-persona so each agent sees only its own account. +""" + +from __future__ import annotations + +from config.personas import Persona +from tools.market_data import get_bars, get_fundamentals, get_news, get_quote +from tools.options import get_option_chain, get_option_quote +from tools.portfolio import make_get_portfolio +from tools.python_exec import run_python +from tools.web import web_fetch, web_search + +# Tools that are the same for every persona (portfolio is added per-persona below). +BASE_TOOLS = [ + run_python, web_search, web_fetch, get_quote, get_bars, get_news, get_fundamentals, + get_option_chain, get_option_quote, +] + + +def build_toolset(persona: Persona | None = None) -> list: + """Return the tool list for an agent, including a portfolio tool bound to its account.""" + account = persona.id if persona is not None else "" + return [*BASE_TOOLS, make_get_portfolio(account)] diff --git a/tools/market_data.py b/tools/market_data.py new file mode 100644 index 0000000..3132f68 --- /dev/null +++ b/tools/market_data.py @@ -0,0 +1,213 @@ +"""Market-data tools. Alpaca for prices/bars, Finnhub for fundamentals/news, yfinance as +a keyless fallback. Every tool is defensive: network/API failures return a short error +string, never raise into the agent loop. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from functools import lru_cache + +from langchain_core.tools import tool + +from config.settings import get_settings + + +# --- lazy clients (so a missing key never breaks import) --- +@lru_cache +def _alpaca_data(): + from alpaca.data.historical import StockHistoricalDataClient + + key, secret = get_settings().alpaca_data_creds() + return StockHistoricalDataClient(key, secret) + + +@lru_cache +def _finnhub(): + import finnhub + + return finnhub.Client(api_key=get_settings().finnhub_api_key) + + +_TIMEFRAMES = { + "1min": (1, "Minute"), + "5min": (5, "Minute"), + "15min": (15, "Minute"), + "1hour": (1, "Hour"), + "1day": (1, "Day"), + "1week": (1, "Week"), +} + + +def _timeframe(name: str): + from alpaca.data.timeframe import TimeFrame, TimeFrameUnit + + amount, unit = _TIMEFRAMES.get(name.lower().replace(" ", ""), (1, "Day")) + return TimeFrame(amount, getattr(TimeFrameUnit, unit)) + + +def latest_price(ticker: str) -> float | None: + """Plain latest price (Alpaca trade -> yfinance fallback). None if unavailable. + Shared by the get_quote tool and the morning executor.""" + ticker = ticker.strip().upper() + try: + from alpaca.data.requests import StockLatestTradeRequest + + resp = _alpaca_data().get_stock_latest_trade( + StockLatestTradeRequest(symbol_or_symbols=ticker) + ) + return float(resp[ticker].price) + except Exception: + pass + try: + import yfinance as yf + + return float(yf.Ticker(ticker).fast_info["last_price"]) + except Exception: + return None + + +@tool +def get_quote(ticker: str) -> str: + """Get the latest price for a stock ticker (e.g. 'NVDA'). Returns last trade price.""" + ticker = ticker.strip().upper() + px = latest_price(ticker) + return f"{ticker} last price: ${px:.2f}" if px else f"ERROR: could not get quote for {ticker}" + + +_YF_INTERVAL = { + "1min": "1m", + "5min": "5m", + "15min": "15m", + "1hour": "1h", + "1day": "1d", + "1week": "1wk", +} + + +def _summarize_bars(ticker, timeframe, closes, highs, lows, vols, source="") -> str: + first, last = closes[0], closes[-1] + change = (last - first) / first * 100 if first else 0.0 + hi, lo = max(highs), min(lows) + avg_vol = (sum(vols) / len(vols)) if vols else 0 + recent = ", ".join(f"{c:.2f}" for c in closes[-5:]) + tag = f", {source}" if source else "" + return ( + f"{ticker} {timeframe} ({len(closes)} bars{tag}): last={last:.2f}, " + f"change={change:+.2f}%, high={hi:.2f}, low={lo:.2f}, avg_vol={avg_vol:,.0f}\n" + f"recent closes: [{recent}]" + ) + + +@tool +def get_bars(ticker: str, timeframe: str = "1day", lookback: int = 30) -> str: + """Get recent OHLCV price bars and a compact summary for a ticker. + + timeframe: one of 1min, 5min, 15min, 1hour, 1day, 1week. lookback: number of bars. + Returns period change %, high/low, avg volume, and the most recent closes. + """ + ticker = ticker.strip().upper() + # Alpaca (primary — needs API keys) + try: + from alpaca.data.requests import StockBarsRequest + + start = datetime.now(timezone.utc) - timedelta(days=max(lookback * 2, 5)) + req = StockBarsRequest( + symbol_or_symbols=ticker, timeframe=_timeframe(timeframe), start=start + ) + bars = _alpaca_data().get_stock_bars(req).data.get(ticker, []) + if bars: + bars = bars[-lookback:] + return _summarize_bars( + ticker, timeframe, + [b.close for b in bars], [b.high for b in bars], + [b.low for b in bars], [b.volume for b in bars], + ) + except Exception: + pass + # yfinance fallback (keyless) + try: + import yfinance as yf + + interval = _YF_INTERVAL.get(timeframe.lower().replace(" ", ""), "1d") + if interval == "1d": + period = f"{lookback + 10}d" + elif interval == "1wk": + period = f"{lookback * 7 + 30}d" + else: + period = "5d" # yfinance caps intraday history + hist = yf.Ticker(ticker).history(period=period, interval=interval) + if hist is None or hist.empty: + return f"No bars returned for {ticker}." + hist = hist.tail(lookback) + return _summarize_bars( + ticker, timeframe, + hist["Close"].tolist(), hist["High"].tolist(), + hist["Low"].tolist(), hist["Volume"].tolist(), source="yfinance", + ) + except Exception as e: + return f"ERROR: could not get bars for {ticker}: {e}" + + +@tool +def get_news(ticker: str, limit: int = 5) -> str: + """Get recent news headlines + summaries for a ticker (last ~7 days).""" + ticker = ticker.strip().upper() + try: + today = datetime.now(timezone.utc).date() + frm = (today - timedelta(days=7)).isoformat() + items = _finnhub().company_news(ticker, _from=frm, to=today.isoformat()) + if not items: + return f"No recent news for {ticker}." + out = [] + for it in items[:limit]: + head = it.get("headline", "").strip() + summ = (it.get("summary", "") or "").strip()[:240] + src = it.get("source", "") + out.append(f"- {head} ({src})\n {summ}") + return f"{ticker} recent news:\n" + "\n".join(out) + except Exception as e: + return f"ERROR: could not get news for {ticker}: {e}" + + +@tool +def get_fundamentals(ticker: str) -> str: + """Get key fundamentals for a ticker: profile, market cap, valuation and quality metrics.""" + ticker = ticker.strip().upper() + try: + fh = _finnhub() + prof = fh.company_profile2(symbol=ticker) or {} + fin = fh.company_basic_financials(ticker, "all") or {} + m = fin.get("metric", {}) or {} + + def g(key: str) -> str: + v = m.get(key) + return f"{v:.2f}" if isinstance(v, (int, float)) else "n/a" + + name = prof.get("name", ticker) + industry = prof.get("finnhubIndustry", "n/a") + mcap = prof.get("marketCapitalization") + mcap_s = f"${mcap:,.0f}M" if isinstance(mcap, (int, float)) else "n/a" + return ( + f"{ticker} — {name} ({industry})\n" + f"market cap: {mcap_s}\n" + f"P/E (TTM): {g('peTTM')}, P/B: {g('pbAnnual')}, P/S (TTM): {g('psTTM')}\n" + f"ROE (TTM): {g('roeTTM')}%, gross margin: {g('grossMarginTTM')}%\n" + f"rev growth YoY (TTM): {g('revenueGrowthTTMYoy')}%\n" + f"52wk high/low: {g('52WeekHigh')} / {g('52WeekLow')}" + ) + except Exception as e: + # yfinance fallback for the basics + try: + import yfinance as yf + + info = yf.Ticker(ticker).info + return ( + f"{ticker} — {info.get('shortName', ticker)} ({info.get('industry', 'n/a')})\n" + f"market cap: {info.get('marketCap', 'n/a')}\n" + f"trailing P/E: {info.get('trailingPE', 'n/a')}, " + f"forward P/E: {info.get('forwardPE', 'n/a')}\n" + f"profit margin: {info.get('profitMargins', 'n/a')}" + ) + except Exception as e2: + return f"ERROR: could not get fundamentals for {ticker}: {e} / {e2}" diff --git a/tools/options.py b/tools/options.py new file mode 100644 index 0000000..7a6b3df --- /dev/null +++ b/tools/options.py @@ -0,0 +1,104 @@ +"""Options data tools (Phase 6a — single-leg long calls/puts). + +get_option_chain lists near-the-money contracts near a target DTE with live premiums; the +agent copies a contract_symbol into its pick. get_option_quote prices one contract. +""" + +from __future__ import annotations + +from datetime import date, timedelta +from functools import lru_cache + +from langchain_core.tools import tool + +from config.settings import get_settings +from tools.market_data import latest_price + + +@lru_cache +def _opt_data(): + from alpaca.data.historical.option import OptionHistoricalDataClient + + key, secret = get_settings().alpaca_data_creds() + return OptionHistoricalDataClient(key, secret) + + +@lru_cache +def _opt_trading(): + from alpaca.trading.client import TradingClient + + s = get_settings() + key, secret = s.alpaca_data_creds() + return TradingClient(key, secret, paper=s.alpaca_paper) + + +def option_premium(contract_symbol: str) -> float | None: + """Mid premium for an option contract (per share; ×100 = contract cost). None if unavailable.""" + from alpaca.data.requests import OptionLatestQuoteRequest + + try: + q = _opt_data().get_option_latest_quote( + OptionLatestQuoteRequest(symbol_or_symbols=contract_symbol) + ) + c = q[contract_symbol] + bid, ask = float(c.bid_price or 0), float(c.ask_price or 0) + if bid and ask: + return round((bid + ask) / 2, 2) + return round(ask or bid, 2) or None + except Exception: + return None + + +@tool +def get_option_chain(underlying: str, right: str = "call", target_dte: int = 35) -> str: + """List near-the-money option contracts for an underlying stock. + + right = 'call' (bullish) or 'put' (bearish). target_dte = desired days to expiration + (~30-45 is typical). Returns contract symbols + strike + expiry + mid premium. Copy a + contract_symbol into your pick to trade it. + """ + from alpaca.trading.enums import AssetStatus, ContractType + from alpaca.trading.requests import GetOptionContractsRequest + + underlying = underlying.strip().upper() + px = latest_price(underlying) + if not px: + return f"ERROR: no price for {underlying}" + ct = ContractType.CALL if right.lower().startswith("c") else ContractType.PUT + today = date.today() + try: + req = GetOptionContractsRequest( + underlying_symbols=[underlying], + type=ct, + status=AssetStatus.ACTIVE, + expiration_date_gte=today + timedelta(days=max(target_dte - 15, 1)), + expiration_date_lte=today + timedelta(days=target_dte + 20), + strike_price_gte=str(round(px * 0.85, 2)), + strike_price_lte=str(round(px * 1.15, 2)), + limit=60, + ) + contracts = _opt_trading().get_option_contracts(req).option_contracts + except Exception as e: + return f"ERROR: chain fetch failed: {e}" + if not contracts: + return f"No {right} contracts for {underlying} near {target_dte} DTE." + + # nearest expiry first, then nearest strike to spot + contracts.sort(key=lambda c: (c.expiration_date, abs(float(c.strike_price) - px))) + out = [f"{underlying} ~${px:.2f}: {right}s ~{target_dte}DTE (copy a contract_symbol):"] + for c in contracts[:10]: + prem = option_premium(c.symbol) + prem_s = f"${prem:.2f}" if prem else "n/a" + out.append( + f" {c.symbol} strike ${float(c.strike_price):.1f} exp {c.expiration_date} mid {prem_s}" + ) + return "\n".join(out) + + +@tool +def get_option_quote(contract_symbol: str) -> str: + """Latest mid premium for an option contract symbol (OCC format, from get_option_chain).""" + prem = option_premium(contract_symbol.strip().upper()) + return f"{contract_symbol}: mid ${prem:.2f} (x100 = ${prem * 100:.0f}/contract)" if prem else ( + f"ERROR: no quote for {contract_symbol}" + ) diff --git a/tools/portfolio.py b/tools/portfolio.py new file mode 100644 index 0000000..19dcbfb --- /dev/null +++ b/tools/portfolio.py @@ -0,0 +1,56 @@ +"""get_portfolio — persona-scoped. Each agent sees ONLY its own paper account's holdings +(written by execution/reconcile.py, keyed by persona id), so picks build on that book. +Built per-persona via make_get_portfolio() in tools/build_toolset. +""" + +from __future__ import annotations + +import json + +from langchain_core.tools import tool + +from config.settings import get_settings +from state import db as statedb + + +def make_get_portfolio(account: str): + """Return a get_portfolio tool bound to one account (persona id).""" + + @tool + def get_portfolio() -> str: + """Return YOUR current paper-account holdings, unrealized PnL, and cash. + + Use this BEFORE proposing trades so you account for what you already own. + """ + path = get_settings().db_path + if not path.exists(): + return "No portfolio data yet (fresh account, no trades recorded)." + try: + con = statedb.connect(path) + snap = statedb.latest_snapshot(con, account or None) + con.close() + except Exception as e: + return f"No portfolio data yet ({e})." + if snap is None: + return "No holdings yet (fresh account, all cash)." + + lines = [ + f"As of {snap['ts']}: equity=${snap['equity']:,.2f}, " + f"cash=${snap['cash']:,.2f}, buying_power=${snap['buying_power']:,.2f}" + ] + try: + positions = json.loads(snap["positions_json"] or "[]") + except Exception: + positions = [] + if not positions: + lines.append("Holdings: none (all cash).") + else: + lines.append("Holdings:") + for p in positions: + lines.append( + f" {p.get('symbol')}: {p.get('qty')} @ {p.get('avg_entry')} " + f"-> mv={p.get('market_value')}, unrealized_pl={p.get('unrealized_pl')}" + ) + return "\n".join(lines) + + return get_portfolio diff --git a/tools/python_exec.py b/tools/python_exec.py new file mode 100644 index 0000000..98805cb --- /dev/null +++ b/tools/python_exec.py @@ -0,0 +1,40 @@ +"""run_python — the calculator. Agents call this for ALL arithmetic instead of doing +mental math (LLMs are unreliable at it). Executes in an isolated subprocess with a hard +timeout. Kept math-only by convention; runs model-generated code on this machine, so +scope is deliberately narrow. +""" + +from __future__ import annotations + +import subprocess +import sys + +from langchain_core.tools import tool + +_TIMEOUT_S = 5 + + +@tool +def run_python(code: str) -> str: + """Execute a short Python snippet for EXACT math and return its stdout. + + Use for ANY arithmetic: position sizing, percentages, P/E, returns, averages, ratios. + You MUST print() the result. `math` and `statistics` are importable. + Example: `print(round(1000 / 23.55, 4))`. No network or file access; 5s limit. + """ + try: + proc = subprocess.run( + [sys.executable, "-I", "-c", code], + capture_output=True, + text=True, + timeout=_TIMEOUT_S, + ) + except subprocess.TimeoutExpired: + return f"ERROR: execution timed out ({_TIMEOUT_S}s limit)." + except Exception as e: # pragma: no cover - defensive + return f"ERROR: {type(e).__name__}: {e}" + + if proc.returncode != 0: + return f"ERROR: {proc.stderr.strip() or 'non-zero exit'}" + out = proc.stdout.strip() + return out or "(no output — did you forget to print()?)" diff --git a/tools/web.py b/tools/web.py new file mode 100644 index 0000000..b5c715d --- /dev/null +++ b/tools/web.py @@ -0,0 +1,114 @@ +"""Our own web tools — no search API key. + +web_search hits DuckDuckGo's keyless HTML endpoint and parses results ourselves (lxml, +which ships with trafilatura). Falls back to the `ddgs` library if the HTML layout shifts +or we get rate-limited. web_fetch pulls a page and extracts readable text with trafilatura. +""" + +from __future__ import annotations + +from urllib.parse import parse_qs, unquote, urlparse + +import httpx +import trafilatura +from langchain_core.tools import tool +from lxml import html as lxml_html +from tenacity import retry, stop_after_attempt, wait_exponential + +_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/125.0 Safari/537.36" +) +_MAX_RESULTS = 6 +_FETCH_CHAR_LIMIT = 12000 # ~3k tokens + + +def _clean_url(href: str) -> str: + """DDG HTML wraps results in a /l/?uddg= redirect — unwrap to the real URL.""" + if href.startswith("//"): + href = "https:" + href + if "duckduckgo.com/l/" in href or href.startswith("/l/"): + qs = parse_qs(urlparse(href).query) + if "uddg" in qs: + return unquote(qs["uddg"][0]) + return href + + +@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, max=8), reraise=True) +def _ddg_html_search(query: str, max_results: int = _MAX_RESULTS) -> list[dict]: + resp = httpx.post( + "https://html.duckduckgo.com/html/", + data={"q": query}, + headers={"User-Agent": _UA}, + timeout=15, + follow_redirects=True, + ) + resp.raise_for_status() + doc = lxml_html.fromstring(resp.text) + anchors = doc.xpath('//a[contains(@class, "result__a")]') + snippets = doc.xpath('//a[contains(@class, "result__snippet")]') + results = [] + for i, a in enumerate(anchors[:max_results]): + title = a.text_content().strip() + url = _clean_url(a.get("href", "")) + snippet = snippets[i].text_content().strip() if i < len(snippets) else "" + if title and url: + results.append({"title": title, "url": url, "snippet": snippet}) + return results + + +def _ddgs_search(query: str, max_results: int = _MAX_RESULTS) -> list[dict]: + from ddgs import DDGS + + with DDGS() as d: + return [ + {"title": r.get("title", ""), "url": r.get("href", ""), "snippet": r.get("body", "")} + for r in d.text(query, max_results=max_results) + ] + + +@tool +def web_search(query: str) -> str: + """Search the web; returns the top results as title + URL + snippet. + + Use for news, analyst opinions, macro context, catalysts — anything the structured + market-data tools don't cover. Follow up with web_fetch to read a promising result. + """ + results: list[dict] = [] + try: + results = _ddg_html_search(query) + except Exception: + pass + if not results: + try: + results = _ddgs_search(query) + except Exception as e: + return f"ERROR: web_search failed (both backends): {e}" + if not results: + return f"No results for {query!r}." + return "\n".join( + f"{i}. {r['title']}\n {r['url']}\n {r['snippet']}" for i, r in enumerate(results, 1) + ) + + +@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, max=6), reraise=True) +def _http_get(url: str) -> str: + resp = httpx.get(url, headers={"User-Agent": _UA}, timeout=20, follow_redirects=True) + resp.raise_for_status() + return resp.text + + +@tool +def web_fetch(url: str) -> str: + """Fetch a URL and return its main readable text (truncated). + + Use after web_search to read an article or page in depth. Strips nav/ads/boilerplate. + """ + try: + html_text = _http_get(url) + except Exception as e: + return f"ERROR: fetch failed: {e}" + text = trafilatura.extract(html_text, include_comments=False, include_tables=False) + if not text: + return "ERROR: no readable content extracted from that URL." + return text[:_FETCH_CHAR_LIMIT]