Spaces:
Running
Running
| """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", | |
| } | |
| 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, | |
| } | |