Skip to content

AI Advisor Tool Catalogue

taxAnalyst defines twenty-five read-only tools (ADVISOR_TOOL_NAMES); which of them a run sees depends on its execution profile (see Tiers per execution profile). Inputs are strict Zod objects: unknown fields are rejected. Symbols are upper-cased and must then match ^[A-Z0-9.]{1,20}$. No tool accepts an account id; every tool reads all accounts together, except account_holdings, which takes an account name (trimmed, 1 to 64 characters) and resolves it server-side. A model that sends accountId still fails as INVALID_TOOL_INPUT. No tool accepts a quantity or a currency. Tax figures are per asset across accounts regardless, consistent with Global FIFO vs Custody.

All tools return a discriminated union: { kind: 'ok', payload } or { kind: 'truncated', maxChars, actualChars } (see tool-result budgets).


Each tool belongs to one tier in ADVISOR_TOOL_TIERS (shared-types), checked with satisfies so a tool cannot be added without one. The selection is static code, not a setting: selectExposedTools in ai/tools/toolExposure.ts applies it when the taxAnalyst tool map is built.

Profile Exposed tools
local the 14 core tools
metered, mixed all 25 tools
  • core (14): portfolio_summary, holding_detail, account_holdings, kpis, asset_allocation, spanish_tax_report, tax_year_comparison, fiscal_integrity, data_gaps, token_history, live_prices, scenario_position_value, breakeven_price, explain_metric.
  • extended (11): fiscal_integrity_rows, token_lots, risk_metrics, drawdown_curve, performance_history, volatility_heatmap, derivatives_pnl, custody_locations, scenario_portfolio_shock, concentration_risk, tx_search.

Small local models pick measurably worse from a long tool list, which is why a local run sees only the core tier. A mixed chain resolves to the metered profile and therefore exposes every tool. The agent instructions list exactly the tools the run exposes. See execution profiles.


Tool Wraps Input Payload (summary)
portfolio_summary GetPortfolioSummaryUseCase none metrics (totals, ratesIncomplete, pricesIncomplete, currency), ranked holdings (top topNHoldings by value), omittedCount, unvalued, unvaluedCount. Base currency.
fiscal_integrity GetFiscalIntegrityUseCase none At most 10 groups (qualityFlag, severity, count, pendingReview) ranked by count, plus totalDefects, pendingReview, needsRecalculation. Never rows.
fiscal_integrity_rows GetFiscalIntegrityUseCase qualityFlag, page (zero-based, default 0) One page of transaction-level rows (rowsPageSize) with paging metadata.
token_history GetTokenHistoryUseCase symbol Up to lotsPageSize lots (id, date, exchange, quantities, costs, status, historyCount, relocationCount) and omittedCount.
token_lots GetTokenHistoryUseCase symbol, page (default 0) One page of the full lot list (lotsPageSize) with paging metadata.
asset_allocation GetAssetAllocationUseCase none ranked valued items (amount, valueFiat, allocationPct, currency), omittedCount, unvalued items (amount only). Base currency.
risk_metrics GetRiskMetricsUseCase none maxDrawdownPct, annualizedVolatility, sharpeRatio, alpha, beta. Ratios, no currency field.
kpis GetKpisUseCase none Equity, cost basis, PnL, all-time high, drawdown, volatility, Sharpe, currency, incompleteness flags, and optional trade statistics and best/worst asset. Base currency.
drawdown_curve GetDrawdownCurveUseCase days? (integer 1 to 3650) points of date and drawdownPct, downsampled to at most 120, plus omittedCount.
performance_history GetPerformanceHistoryUseCase days? (1 to 3650) currency (the base currency), points (portfolioValue, nullable; optional btcValue; drawdownPct) downsampled to at most 120, omittedCount.
volatility_heatmap GetVolatilityHeatmapUseCase year? (integer) cells of date and volatility, downsampled to at most 120, omittedCount.
spanish_tax_report GetSpanishTaxReportUseCase year (integer, at least 2009), method? IRPF summary, unconvertible and excluded counters, auditTrail capped at 20 rows, omittedCount. Always EUR.
live_prices IPriceHistoryPort.getLatest symbols (1 to 25) prices (price, currency, timestamp, provider, stalenessSeconds) and notTracked.
holding_detail GetPortfolioSummaryUseCase symbol One of valued (holding with value), unvalued (quantity and cost basis only) or not_held. Finds a holding outside the portfolio_summary top-N. Base currency.
account_holdings ListAccountsUseCase, GetPortfolioSummaryUseCase accountName (trimmed, 1 to 64) resolved with one section per account in the resolved account’s tree (own metrics, ranked, omittedCount, unvalued) plus emptyAccountCount; or ambiguous (candidate names) or not_found (top-level names). Exact case-insensitive match first, a prefix only if none matches exactly. Never sums across accounts.
tax_year_comparison GetSpanishTaxReportUseCase (twice) yearA, yearB (distinct, at least 2009), method? Per year summary, unconvertibleCount, excludedFlaggedEvents, excludedUnresolvedIncomeCount, completeness; deltas (second year minus first) computed by compareTaxSummaries, each a delta or delta_incomparable when either year is incomplete. EUR, no audit trail.
data_gaps GetPortfolioSummaryUseCase, GetFiscalIntegrityUseCase none unvalued split into no_price and no_rate (symbol and quantity only), integrity (at most 10 groups, totalDefects, needsRecalculation) and a fixed nextTools list.
explain_metric static table metric (closed MetricId enum) The definition in the request locale and producedBy, the tool that reports the metric. Text states formulas in words with no example numbers.
scenario_position_value GetPortfolioScenarioUseCase symbol, hypotheticalPrice computed (positionValue, deltaVsCurrent, impliedAllocationPct), not_held, unvalued (position value only) or empty_portfolio; each with currency. Quantity always comes from the ledger.
breakeven_price GetPortfolioScenarioUseCase symbol computed (avgUnitCost), not_held, unconvertible_cost_basis or zero_quantity. Uses the holdings cost basis, which can differ from kpis.
derivatives_pnl GetDerivativesPnlUseCase none Contracts ranked by absolute realized PnL and capped at topNHoldings (realizedPnl, funding, fees, netPnl, currency), omittedCount.
custody_locations GetLotCustodyLocationsUseCase symbol? Quantity per non-synthetic account and asset (lotCount), capped at topNHoldings, omittedCount, and syntheticRowCount for the ownwallet-* rows it does not list. Reads the custody ledger only; location has no effect on taxation.
scenario_portfolio_shock GetPortfolioScenarioUseCase shock: { kind: 'uniform', pct } or { kind: 'per_asset', shocks } (1 to 25 entries); pct from -100 to 1000 computed (per-asset before and after capped at topNHoldings, omittedCount, totalBefore, totalAfter, delta, unvaluedSymbols, notHeld) or empty_portfolio. Unlisted assets stay put in a per-asset shock.
concentration_risk GetPortfolioScenarioUseCase none all and excludingStablecoins blocks (computed with top1Weight, top3Weight, hhi on a zero to one scale, effectiveHoldings, or empty), stablecoinWeight (null when nothing is valued) and unvaluedCount.
tx_search SearchSpotTransactionsUseCase symbol?, from?, to? (inclusive days), types? (non-empty), page (default 1) One page (rowsPageSize) of rows (date, type, assets and amounts, priceFiat, totalFiat, fiatCurrency, fee as NONE or CHARGED, exchange, edited), newest first, with page, pageSize, totalPages, totalCount.

Shared wrappers: fiscal_integrity, fiscal_integrity_rows and (for its integrity half) data_gaps use GetFiscalIntegrityUseCase; token_history and token_lots use GetTokenHistoryUseCase; portfolio_summary, holding_detail, account_holdings and data_gaps read GetPortfolioSummaryUseCase; the four scenario tools (scenario_position_value, breakeven_price, scenario_portfolio_shock, concentration_risk) share GetPortfolioScenarioUseCase; spanish_tax_report and tax_year_comparison share GetSpanishTaxReportUseCase.

Example portfolio_summary result as the model sees it (shape only; figures are illustrative):

{
"kind": "ok",
"payload": {
"metrics": {
"ratesIncomplete": false,
"pricesIncomplete": true,
"totalEquityFiat": "18250.40",
"totalCostBasisFiat": "12000.00",
"totalRealizedPnlFiat": "930.10",
"totalUnrealizedPnlFiat": "6250.40",
"totalPnlFiat": "7180.50",
"currency": "USD"
},
"ranked": [
{
"id": "btc",
"symbol": "BTC",
"amount": "0.25",
"avgPriceFiat": "40000",
"costBasisFiat": "10000",
"currentValueFiat": "15000",
"unrealizedPnlFiat": "5000",
"currency": "USD"
}
],
"omittedCount": 0,
"unvalued": [],
"unvaluedCount": 0
}
}

Tool failures surface on the stream as tool-error with INVALID_TOOL_INPUT (Mastra validation error on the input) or USE_CASE_FAILED (the wrapped use case threw).


  • portfolio_summary, kpis, asset_allocation, and live_prices honour the user’s base currency, read server-side from settings and injected through the request context. The model never supplies a currency.
  • asset_allocation converts the canonical-EUR daily valuation at the rate of the valuation date (VALUATION_CONVERTED, like kpis). A holding with no price series, or no exchange rate, is unvalued: it reports its quantity and is excluded from every percentage, never passed through at a factor of one.
  • risk_metrics, drawdown_curve, and volatility_heatmap return ratios, percentages, or statistics with no currency.
  • spanish_tax_report and tax_year_comparison are always EUR, as IRPF requires.
  • holding_detail, account_holdings, data_gaps, the four scenario tools, and derivatives_pnl read the base currency from the request context. Scenario results carry a currency field. custody_locations returns quantities, and each tx_search row carries its own fiatCurrency.
  • performance_history honours the base currency: each point is converted at the latest stored EUR/<base> rate on or before that point’s own date, like kpis, and the result’s currency field states it. A point no rate covers has portfolioValue: null, never an EUR value or 0. drawdownPct is a ratio and stays derived from the EUR series, so it does not depend on the base currency.

The agent is instructed to state each result’s declared currency beside the figure and never to convert or relabel.


📄 Pagination and the Live-Prices Guarantee

Section titled “📄 Pagination and the Live-Prices Guarantee”
  • fiscal_integrity never returns rows. Transaction-level rows are reachable only through fiscal_integrity_rows.
  • token_history returns one page of lots, not a fixed top 20; the full list is reachable through token_lots. Both are subject to the same character budgets, and a page that still exceeds its budget is reported as truncated.
  • tx_search pages start at 1 (default) and are ordered newest first. A page past the end is an empty ok result with the true totals, and the date and type filters run on the edited values: the pre-edit row is never returned.
  • live_prices reads the latest already-cached price per symbol from the price-history port (the same cache the market stream fills). It opens no provider connection and starts no stream. A symbol with no cached price appears in notTracked.

A scenario figure is produced by a pure function in packages/core-domain: positionValueAt, breakevenPrice, applyShock and concentrationOf in portfolioScenarios.ts, plus compareTaxSummaries and summarizeCustodyLocations. Every one goes through Money, and every variable division is guarded by an isZero check that returns a typed outcome instead of throwing. The AI subtree imports neither Money nor decimal.js, and a source scan enforces it. The agent is told never to compute, convert, sum, subtract, or estimate a figure, and to state a non-computed outcome (not held, ambiguous, unconvertible cost basis) as it is.

Stablecoins in concentration_risk come from the closed STABLECOIN_SYMBOLS list in shared-types (USDT, USDC, DAI, EURC, FDUSD, PYUSD, TUSD, USDE); an unlisted symbol counts as risky. The list is display classification only, and no tax or FIFO code reads it.


🚫 Tools Deliberately Left Out: fees_paid and cash_flow_summary

Section titled “🚫 Tools Deliberately Left Out: fees_paid and cash_flow_summary”

Both were measured against real exchange exports and failed the gate, so they are not in the catalogue. Only Bitvavo’s fees are natively EUR and complete; every other source charges in crypto or USD, which would need a dated price per fee asset (new analytical logic, not a wrapper). A portfolio-wide fiat in/out figure would count Kraken spot’s internal spottofutures transfer as a withdrawal, and three sources export no fiat movements at all. Shipping them per source was rejected because a total that silently covers only some sources breaks the “every figure states its completeness” rule.

They return in a follow-up once Kraken’s spottofutures subtype is declared an internal transfer in profiles.ts, fee-asset EUR valuation exists as a use case, and explicit-zero fees survive normalization in every profile.


The AI layer contains no .sort( of its own. Pure helpers in packages/core-domain supply every ordering: rankHoldingsByValue and rankByAbsoluteValue (monetary, compared through Money), orderByCountDescending, orderByIsoDateDescending and orderByIsoDateDescendingThenKey (integer and ISO-date keys, the last with a key tiebreak used by tx_search), plus downsampleSeries (uniform stride, at most 120 points). None has access to a lot, a queue, or an account, so the separation of tax FIFO and custody is untouched.