finsight-api / tools.py
ManasDubey's picture
Finsight Copilot Initial commit
46d4cf5
Raw
History Blame Contribute Delete
13.5 kB
"""Agent tools. Each returns JSON-serializable data; every number carries its
source accession so the verifier and the UI can prove it.
"""
import logging
from functools import lru_cache
from db import query
from market import get_quote as market_get_quote
from retrieval import search_passages
logger = logging.getLogger(__name__)
# Popular abbreviations that differ from the exchange symbol we store. Indian
# companies are the main source of this: the agent tends to reach for the
# colloquial name (RIL, SBI, Airtel) rather than the NSE symbol.
TICKER_ALIASES = {
"RIL": "RELIANCE", "INFOSYS": "INFY", "HDFC": "HDFCBANK", "ICICI": "ICICIBANK",
"SBI": "SBIN", "STATEBANK": "SBIN", "LNT": "LT", "L&T": "LT", "LARSEN": "LT",
"MAHINDRA": "M&M", "BHARTI": "BHARTIARTL", "AIRTEL": "BHARTIARTL",
"KOTAK": "KOTAKBANK", "AXIS": "AXISBANK", "TATAMOTORS": "TMPV",
"MARUTISUZUKI": "MARUTI", "ASIANPAINTS": "ASIANPAINT", "BAJAJFINANCE": "BAJFINANCE",
"SUNPHARMACEUTICAL": "SUNPHARMA", "ULTRATECH": "ULTRACEMCO", "ZOMATO": "ETERNAL",
}
@lru_cache(maxsize=1)
def _directory() -> tuple[frozenset[str], tuple[tuple[str, str], ...]]:
rows = query("select ticker, name from companies")
tickers = frozenset(t for t, _ in rows)
names = tuple((t, n.lower()) for t, n in rows)
return tickers, names
def resolve_ticker(raw: str) -> str:
"""Map a user/agent-supplied name or alias to a symbol we actually hold.
Tries: exact symbol, curated alias, then a company-name match (so 'Reliance'
or 'Tata Consultancy' resolve even without the exact ticker). Falls back to
the input unchanged so unknown tickers still surface a clean 'no data'."""
token = raw.upper().strip()
tickers, names = _directory()
if token in tickers:
return token
stripped = token.replace(" ", "").replace(".", "")
if stripped in TICKER_ALIASES:
return TICKER_ALIASES[stripped]
needle = raw.lower().strip()
if len(needle) >= 3:
for ticker, name in names:
if needle in name or name.startswith(needle):
return ticker
return token
# Canonical metric -> ordered fallback chain of concepts. First concept with
# data wins (companies tag revenue differently). Chains mix us-gaap (SEC) and
# Indian results-taxonomy concepts (ind-as / banking / insurance); a ticker
# only ever matches its own region's concepts, so the union is safe.
METRICS: dict[str, list[str]] = {
"revenue": [
"RevenueFromContractWithCustomerExcludingAssessedTax",
"Revenues",
"SalesRevenueNet",
"RevenueFromOperations", # ind-as
"InterestEarned", # banks: interest income is the top line
"GrossPremiumIncome", # life insurers
],
"cost_of_revenue": ["CostOfRevenue", "CostOfGoodsAndServicesSold"],
"gross_profit": ["GrossProfit"],
"operating_income": ["OperatingIncomeLoss", "OperatingProfitBeforeProvisionAndContingencies"],
"net_income": [
"NetIncomeLoss",
"ProfitLossForPeriod", # ind-as
"ProfitLossForThePeriod", # banking
"ProfitLossAfterTaxAndExtraordinaryItems", # insurance
],
"profit_before_tax": [
"ProfitBeforeTax", # ind-as
"ProfitLossFromOrdinaryActivitiesBeforeTax", # banking
"ProfitLossBeforeTax", # insurance
],
"rnd_expense": ["ResearchAndDevelopmentExpense"],
"sga_expense": ["SellingGeneralAndAdministrativeExpense"],
"eps_basic": [
"EarningsPerShareBasic",
"BasicEarningsLossPerShareFromContinuingAndDiscontinuedOperations",
"BasicEarningsPerShareAfterExtraordinaryItems",
"BasicAndDilutedEPSAfterExtraordinaryItemsNetOfTaxExpenseForThePeriodNotToBeAnnualized",
],
"eps_diluted": [
"EarningsPerShareDiluted",
"DilutedEarningsLossPerShareFromContinuingAndDiscontinuedOperations",
"DilutedEarningsPerShareAfterExtraordinaryItems",
"BasicAndDilutedEPSAfterExtraordinaryItemsNetOfTaxExpenseForThePeriodNotToBeAnnualized",
],
"shares_diluted": ["WeightedAverageNumberOfDilutedSharesOutstanding"],
"total_assets": ["Assets"], # same concept name in us-gaap and ind-as
"total_liabilities": ["Liabilities"],
"stockholders_equity": ["StockholdersEquity"],
"cash": ["CashAndCashEquivalentsAtCarryingValue", "CashAndCashEquivalentsCashFlowStatement"],
"long_term_debt": ["LongTermDebtNoncurrent", "LongTermDebt", "BorrowingsNoncurrent"],
"inventory": ["InventoryNet"],
"operating_cash_flow": [
"NetCashProvidedByUsedInOperatingActivities",
"CashFlowsFromUsedInOperatingActivities", # ind-as (half-yearly)
],
"capex": ["PaymentsToAcquirePropertyPlantAndEquipment"],
"buybacks": ["PaymentsForRepurchaseOfCommonStock"],
"dividends_paid": ["PaymentsOfDividendsCommonStock"],
# India-specific: banks
"interest_expended": ["InterestExpended"],
"provisions": ["ProvisionsOtherThanTaxAndContingencies"],
"gross_npa_pct": ["PercentageOfGrossNpa"],
"net_npa_pct": ["PercentageOfNpa"],
"cet1_ratio": ["CET1Ratio"],
"return_on_assets": ["ReturnOnAssets"],
# India-specific: insurers
"net_premium_income": ["NetPremiumIncome"],
}
# Some filers zero-fill ratio fields in the consolidated XBRL and disclose the
# real numbers only in the standalone filing (HDFC Bank, SBI). A 0.00% GNPA or
# CET1 is regulatorily impossible, so for these metrics zero means "blank".
ZERO_MEANS_UNREPORTED = {"gross_npa_pct", "net_npa_pct", "cet1_ratio", "return_on_assets"}
def query_facts(ticker: str, metrics: list[str], annual: bool = True, years: int = 5) -> dict:
"""Reported fundamentals from XBRL (SEC 10-K/10-Q or NSE results filings)."""
ticker = resolve_ticker(ticker)
years = max(1, min(int(years), 15))
unknown = [m for m in metrics if m not in METRICS]
if unknown:
return {"error": f"Unknown metrics {unknown}. Valid: {sorted(METRICS)}"}
results: dict[str, list[dict]] = {}
for metric in metrics:
# Query the whole fallback chain at once: companies switch concepts
# between years (e.g. Revenues vs RevenueFromContract...), so picking
# "first concept with data" can silently return only old years.
chain = METRICS[metric]
zero_filter = "and f.value <> 0" if metric in ZERO_MEANS_UNREPORTED else ""
if annual:
# Full-year duration facts from 10-Ks (or instant facts for balance sheet)
rows = query(
f"""
select distinct on (f.fiscal_year)
f.fiscal_year, f.period_end::text, f.value, f.unit, f.accession, f.form
from xbrl_facts f join companies c on c.cik = f.cik
where c.ticker = %s and f.concept = any(%s)
and f.form in ('10-K', 'RESULTS')
and (f.period_start is null or f.period_end - f.period_start > 300)
and f.fiscal_period = 'FY' {zero_filter}
order by f.fiscal_year desc, f.period_end desc,
array_position(%s, f.concept)
limit %s
""",
(ticker, chain, chain, years),
)
else:
rows = query(
f"""
select distinct on (f.period_end)
f.fiscal_year, f.period_end::text, f.value, f.unit, f.accession, f.form
from xbrl_facts f join companies c on c.cik = f.cik
where c.ticker = %s and f.concept = any(%s)
and (f.period_start is null or f.period_end - f.period_start between 60 and 120)
{zero_filter}
order by f.period_end desc, array_position(%s, f.concept)
limit %s
""",
(ticker, chain, chain, years * 4),
)
results[metric] = [
{
"fiscal_year": fy,
"period_end": period_end,
"value": float(value),
"unit": unit,
"source_accession": accession,
"source_form": form,
}
for fy, period_end, value, unit, accession, form in rows
]
return {
"ticker": ticker,
"annual": annual,
"metrics": results,
"note": (
"Show these values explicitly in the answer, labeled by fiscal year, in a "
"table — never just a qualitative summary. Compute the ratios the question "
"needs (margins, growth %, CAGR) from them and show the working. Values are "
"absolute in the stated unit: INR figures should be presented in ₹ crore "
"(divide by 10,000,000), USD figures in $B/$M as appropriate."
),
}
def retrieve_passages(query_text: str, ticker: str = "", form: str = "", k: int = 6) -> dict:
"""Semantic search over indexed 10-K/10-Q / annual report text."""
resolved = resolve_ticker(ticker) if ticker else None
hits = search_passages(query_text, ticker=resolved, form=form or None, k=k)
return {"passages": hits}
def get_quote(ticker: str) -> dict:
"""Live market quote."""
try:
return market_get_quote(ticker)
except Exception as exc:
return {"error": f"No market data for '{ticker}': {exc}"}
def list_companies() -> dict:
"""Companies available in the local dataset."""
rows = query("select ticker, name from companies order by ticker")
return {"companies": [{"ticker": t, "name": n} for t, n in rows]}
# ---- Gemini function declarations ----
TOOL_DECLARATIONS = [
{
"name": "query_facts",
"description": (
"Audited fundamentals (XBRL) for one company — US SEC filings ($) or "
"Indian NSE results (₹). The source for every quantitative claim: revenue, "
"income, EPS, balance sheet, cash flow, buybacks, and the inputs for margins "
"and growth; for Indian banks also NPA ratios, provisions and capital, for "
"insurers premium income. Batch EVERY metric the question needs into ONE "
"call — it is far cheaper than several calls. Each value carries its source "
"filing accession for citation."
),
"parameters": {
"type": "object",
"properties": {
"ticker": {
"type": "string",
"description": "Company symbol or name — NSE symbol for Indian names "
"(RELIANCE, INFY, HDFCBANK) or SEC ticker for US (AAPL). "
"Common names/aliases are resolved automatically.",
},
"metrics": {
"type": "array",
"items": {"type": "string", "enum": sorted(METRICS)},
"description": "All metrics needed to answer, in one call. For a margin "
"question include revenue plus the profit lines; for growth "
"include the metric across enough periods.",
},
"annual": {
"type": "boolean",
"description": "true = full fiscal years (default, for yearly trends); "
"false = individual quarters.",
},
"years": {
"type": "integer",
"description": "Periods of history to return. Default 5; use 4-5+ for a "
"trend so the answer isn't thin.",
},
},
"required": ["ticker", "metrics"],
},
},
{
"name": "retrieve_passages",
"description": (
"Semantic search over indexed filing text — US 10-K/10-Q filings and "
"Indian annual reports. Use for qualitative questions: risks, strategy, "
"management discussion, segments, guidance language. Returns passages "
"with source filing and location (SEC Item section, or annual-report page)."
),
"parameters": {
"type": "object",
"properties": {
"query_text": {"type": "string"},
"ticker": {"type": "string", "description": "Optional ticker filter"},
"form": {
"type": "string",
"enum": ["10-K", "10-Q", "ANNUAL"],
"description": "Optional filter; ANNUAL = Indian annual report. Omit to search all forms",
},
"k": {"type": "integer", "description": "Number of passages (default 6)"},
},
"required": ["query_text"],
},
},
{
"name": "get_quote",
"description": "Live market quote: price, day change, market cap, 52-week range.",
"parameters": {
"type": "object",
"properties": {"ticker": {"type": "string"}},
"required": ["ticker"],
},
},
{
"name": "list_companies",
"description": "List the companies available in the local SEC dataset.",
"parameters": {"type": "object", "properties": {}},
},
]
TOOL_FUNCTIONS = {
"query_facts": query_facts,
"retrieve_passages": retrieve_passages,
"get_quote": get_quote,
"list_companies": list_companies,
}