AI Advisor Tool Catalogue
🧰 AI Advisor Tool Catalogue
Section titled “🧰 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).
🪜 Tiers per Execution Profile
Section titled “🪜 Tiers per Execution Profile”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.
📚 Catalogue
Section titled “📚 Catalogue”| 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).
💱 Currency Rules
Section titled “💱 Currency Rules”portfolio_summary,kpis,asset_allocation, andlive_priceshonour the user’s base currency, read server-side from settings and injected through the request context. The model never supplies a currency.asset_allocationconverts the canonical-EUR daily valuation at the rate of the valuation date (VALUATION_CONVERTED, likekpis). A holding with no price series, or no exchange rate, isunvalued: it reports its quantity and is excluded from every percentage, never passed through at a factor of one.risk_metrics,drawdown_curve, andvolatility_heatmapreturn ratios, percentages, or statistics with no currency.spanish_tax_reportandtax_year_comparisonare always EUR, as IRPF requires.holding_detail,account_holdings,data_gaps, the four scenario tools, andderivatives_pnlread the base currency from the request context. Scenario results carry acurrencyfield.custody_locationsreturns quantities, and eachtx_searchrow carries its ownfiatCurrency.performance_historyhonours the base currency: each point is converted at the latest storedEUR/<base>rate on or before that point’s own date, likekpis, and the result’scurrencyfield states it. A point no rate covers hasportfolioValue: null, never an EUR value or0.drawdownPctis 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_integritynever returns rows. Transaction-level rows are reachable only throughfiscal_integrity_rows.token_historyreturns one page of lots, not a fixed top 20; the full list is reachable throughtoken_lots. Both are subject to the same character budgets, and a page that still exceeds its budget is reported astruncated.tx_searchpages start at 1 (default) and are ordered newest first. A page past the end is an emptyokresult with the true totals, and the date and type filters run on the edited values: the pre-edit row is never returned.live_pricesreads 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 innotTracked.
🧮 Arithmetic Lives in core-domain
Section titled “🧮 Arithmetic Lives in core-domain”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.
🔢 Display-Only Ordering
Section titled “🔢 Display-Only Ordering”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.