feat(tools): run_python, web search/fetch, market data, options, portfolio
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016DvdRZkyCy9NJpHYDdPqmS
This commit is contained in:
1 parent
f41cf75d8a
commit
63085113ed
6 files changed
+552
No files matched your search
@@ -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)]
|
||||||
@@ -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}"
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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()?)"
|
||||||
+114
@@ -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=<encoded> 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]
|
||||||
Reference in new issue
Block a user