API and MCP
Portfolixir exposes the supported local workflow through the JSON API under
/api/v1. The MCP companion in mcp-server/ is intentionally thin: MCP tools
call the JSON API only and do not access the database directly.
Authentication
API requests require a local bearer token:
Authorization: Bearer <PORTFOLIXIR_API_TOKEN>
The MCP companion uses PORTFOLIXIR_API_TOKEN to call Portfolixir.
PORTFOLIXIR_MCP_TOKEN is required for HTTP transport so local HTTP clients can
authenticate to the companion.
Data Rules
All responses use JSON envelopes with either data or errors. Financial
decimals are serialized as strings, including quantities, prices, fees, taxes,
quote closes, and monetary totals. Request payloads for those values should also
send strings.
DELETE /api/v1/securities/:id is the success exception: it returns
204 No Content with an empty body. Clients should not parse a JSON body for
that successful delete response.
Securities
GET /api/v1/securitieslists securities. Rows default to a slim projection — the fixed whitelistid,name,ticker_symbol,isin,wkn,currency_code,asset_class— so routine listings stay small;projection=fullreturns the complete record (notes, feed config, attributes, timestamps). Optional query params:query,sort,direction, holding_status (all,held, ornot_held),logo_status(missingorpresent—missingpowers the “securities without a logo” overview and excludes rows explicitly set to no logo),projection(slim/full), andlimit/offsetfor pagination (both non-negative integers). Use these to page large catalogs instead of fetching the whole table at once.POST /api/v1/securitiescreates a security with asecurityobject.asset_classis a stable string code:equity,etf,fund,government_bond,bond,crypto,commodity,index,other, plus the certificate/leverage codeswarrant,knock_out,factor_certificate,discount_certificate,bonus_certificate,express_certificate,reverse_convertible. Leave it empty to let the class be inferred from the name/ISIN/ticker on read. To keep a position out of the allocation steering basis (the 100%) and the drift table while leaving it in the valuation totals and performance — e.g. a Bitcoin held as a store of value — tag it with a bucket and exclude that bucket from a view, then read allocation under that view.GET /api/v1/securities/:idreturns one security, including itsidentifier_aliases— the former ISINs recorded via the ISIN-change endpoint below (each withid,former_isin,changed_on,note).PATCH /api/v1/securities/:idupdates a security with asecurityobject. The booleantreat_quotes_as_raw(defaultfalse) is the ADR-0028 escape hatch for providers that never back-adjust their history after a stock split: with the flag set, the security’s provider-synced quote rows are treated as raw (as-traded), so the split-adjustment factors apply to them too.DELETE /api/v1/securities/:iddeletes a security when no dependent transactions or quote history reference it; referenced securities return409 Conflict.GET /api/v1/securities/searchsearches configured online security providers. Query params:query; optionaltypewithsecurityorcrypto.
ISIN changes (identifier aliases)
When a corporate action gives an existing security a new ISIN, record the change instead of editing the ISIN in place: the former ISIN becomes a journaled alias, and the import’s ISIN matching consults current ISINs first, then the aliases — so re-imports of old exports (former ISIN) and new exports (new ISIN) both keep matching the same security instead of duplicating it (ADR-0029). A plain rename needs no ISIN change — it is just a name edit.
POST /api/v1/securities/:security_id/isin-changerecords the change with anisin_changeobject: requirednew_isin(normalized to trimmed uppercase), optionalchanged_on(ISO date, defaults to today) andnote. Returns the updated security including itsidentifier_aliases. Guarded with422and a named conflict whennew_isinequals the current ISIN, is live on another security, or is recorded as another security’s former ISIN; recording a change back to one of the same security’s own former ISINs consumes that alias (a revert). Every security-ISIN write path — create, update, and the import’s create path — symmetrically rejects an ISIN that exists as an alias, naming the aliased security.DELETE /api/v1/securities/:security_id/identifier_aliases/:iddeletes one recorded alias (journaled) when an ISIN change was recorded by mistake; returns204 No Content, or404when the alias does not belong to the security.
Example ISIN-change payload:
{
"isin_change": {
"new_isin": "IE000XZSV718",
"changed_on": "2026-07-01",
"note": "merger rename"
}
}
Logos
Each security can carry a logo, resolved automatically (CoinGecko for crypto, Wikipedia for equities/ETFs/funds) or set manually. A manual logo, or an explicit “no logo”, locks the security so background discovery never overwrites the choice.
GET /api/v1/securities/:security_id/logoreturns the logo status:{ "data": { "security_id", "path", "source", "has_logo", "locked" } }.sourceis one ofcoingecko,wikipedia, ormanual.PUT /api/v1/securities/:security_id/logosets a manual logo from an image URL ({ "logo": { "url": "https://…" } }or{ "url": "https://…" }). The image is downloaded once, validated (png/jpg/jpeg/webp, max 256 KiB) and stored locally; the security is locked to the manual choice. A missing URL returns422.DELETE /api/v1/securities/:security_id/logoremoves the logo and records an explicit “no logo” decision (the row falls back to its initials/flag), also locking it against discovery.POST /api/v1/securities/:security_id/logo/discoverre-runs automatic discovery (“search again”). The response includes aresultofupdated,no_source, orfailed. Locked securities are left untouched.
Example create payload:
{
"security": {
"name": "Example ETF",
"ticker_symbol": "EXM",
"currency_code": "EUR"
}
}
Quotes
GET /api/v1/securities/:security_id/quoteslists quote history for one security. Optional query params:fromandto, formatted as ISO dates. Invalid date filters return422 Unprocessable Entitywith field errors. Each row is self-describing about stock splits (ADR-0028):closeis the stored value (never mutated),adjusted_closethe split-adjusted display value,basisthe row’s storage basis (rawfor as-traded manual rows,provider_mirrorfor back-adjusted sync rows) andadjustedwhether a split factor applied. Chart or value withadjusted_close; audit againstclose. A security whose provider never back-adjusts can be flagged withtreat_quotes_as_raw(see Securities), which forces the raw basis for its synced rows.PUT /api/v1/securities/:security_id/quotesupserts manual quote rows.POST /api/v1/securities/:security_id/sync_quotestriggers quote sync for one security. The response includesstatus(ok,skipped, orerror); skipped and error responses may include areasonsuch asmissing_tickerorno_provider_adapter.
Example quote upsert payload:
{
"quotes": [
{
"date": "2026-05-15",
"close": "123.45",
"source": "manual"
}
]
}
Example quote sync response:
{
"data": {
"status": "skipped",
"reason": "missing_ticker"
}
}
Portfolios and Accounts
Portfolio writes are deprecated (ADR-0024) — compatibility only; use buckets/views for grouping. Portfolios were demoted to internal compatibility records: the UI groups exclusively through buckets and views, and depots/cash accounts no longer need a
portfolio_id(a deterministic internal default is bound automatically).POST /api/v1/portfoliosandPATCH /api/v1/portfolios/:portfolio_idkeep working but answer with aDeprecation: trueresponse header. Sunset note: after two releases without external portfolio writes, a follow-up story merges the records into buckets and views (the ADR’s exit criterion) — plan migrations toPOST /api/v1/bucketsandPOST /api/v1/viewsnow. Every record written here stays visible in the UI’s read-only “Portfolio records (compatibility)” admin list, so nothing becomes invisible.
GET /api/v1/portfolioslists portfolios (compatibility records).POST /api/v1/portfolioscreates a portfolio with aportfolioobject. Deprecated — answers withDeprecation: true; prefer buckets/views.GET /api/v1/cash_accountslists cash accounts. Each carries abalance(decimal string, in the account’s own currency) derived on read from the ledger: amounts are stored as positive magnitudes and the transactiontypeimplies the direction (deposits, dividends, interest, tax refunds and sells add cash; removals, fees, taxes and buys remove it; a cash transfer debits its account and credits the counter account). Abalance_adjustmentsnapshot (see below) anchors the balance to a stated absolute amount as of its date, after which only later bookings adjust it.POST /api/v1/cash_accounts/:id/balancerecords an absolute balance snapshot for one account (ADR-0009): the current balance as of a date, instead of mirroring every booking. Body{"date": "2026-06-01", "amount": "4250.00"}(notesoptional);amountis a decimal string and may be negative (an overdraft). It stores abalance_adjustmenttransaction and returns it. The balance then anchors to that amount and only bookings dated strictly after the snapshot change it, so moving money between your own accounts needs no transfer entry. Unknown accounts return404 Not Found.POST /api/v1/cash_accountscreates a cash account with acash_accountobject.portfolio_idis optional (ADR-0024): when omitted, the account is bound to the deterministic internal default portfolio; an explicit id keeps winning for compatibility clients. The optionalliquidity_role(defaultfree_cash) classifies the account:free_cashis genuine deployable cash;credit_lineis an overdraft/Lombard facility whose negative balance is a liability and whose unused headroom is never liquidity (it never enters deployable cash, even with a positive balance — type beats sign);reserveis a visible-but- excluded bucket (e.g. a business account). Onlyfree_cashaccounts with a non-negative balance contribute to the valuation’s deployable cash and itscash_quote. An unknown value is rejected with422 Unprocessable Entity.GET /api/v1/cash_accounts/:idreturns one cash account.PATCH /api/v1/cash_accounts/:idupdates a cash account (name,currency_code,notes,liquidity_role);portfolio_idcannot be changed.DELETE /api/v1/cash_accounts/:iddeletes a cash account, or returns409 Conflictwhen a transaction or securities account still references it.GET /api/v1/securities_accountslists depots/securities accounts.POST /api/v1/securities_accountscreates a depot/securities account with asecurities_accountobject.portfolio_idis optional (ADR-0024): when omitted, the depot is bound to the deterministic internal default portfolio.GET /api/v1/securities_accounts/:idreturns one securities account.PATCH /api/v1/securities_accounts/:idupdates a securities account (name,notes,cash_account_id);portfolio_idcannot be changed.DELETE /api/v1/securities_accounts/:iddeletes a securities account, or returns409 Conflictwhen a transaction still references it.
Example account payloads:
{
"portfolio": {
"name": "Household Portfolio",
"base_currency_code": "EUR"
}
}
{
"cash_account": {
"portfolio_id": 1,
"name": "Settlement EUR",
"currency_code": "EUR"
}
}
{
"securities_account": {
"portfolio_id": 1,
"cash_account_id": 1,
"name": "Main Depot"
}
}
Transactions and Holdings
GET /api/v1/transactionslists transactions. Optional filters:from/to(ISO dates, inclusive),portfolio_id,security_id,securities_account_id. Invalid filters return422 Unprocessable Entitywith the offending field.POST /api/v1/transactionscreates a transaction of any bookable kind with atransactionobject (per-kind required fields are validated server-side). Booking semantics worth knowing before the first write: a dividend’sgross_amountis the NET cash credited to the account — withheld taxes ride intaxes, and the income report reconstructs gross as net plus withheld tax. An inbound delivery recorded without apriceenters the cost basis at zero — supply the acquisition price when it is known; an outbound delivery removes cost at the position’s running average, so its price is informational only. When reconciling a difference, prefer booking the missing transaction of the correct kind — balance snapshots and unpriced inbound deliveries are last resorts that make numbers look right while distorting cost basis. Amounts are positive magnitudes — the kind implies the direction; onlybalance_adjustmentmay carry a negative (absolute) amount. A security settled through a different-currency cash account (for example a USD security bought through a EUR account) is booked in the security’s own currency and carries the cross-currency settlement fieldssecurity_amount(trade amount in the security currency),settlement_amount(cash amount debited or credited in the account currency) andsettlement_fx_rate(account-currency units per one unit of the security currency). When the rate is omitted but both amounts are supplied it is derived assettlement_amount / security_amount(the broker’s actual rate); a currency mismatch with no rate and no amounts to derive one is rejected. Cost basis stays in the security currency so per-position P&L is FX-honest. All three are Decimal strings andnullfor same-currency bookings.GET /api/v1/transactions/:idreturns one transaction.PATCH /api/v1/transactions/:idupdates a transaction (e.g. to fix a mis-imported booking); the per-kind validation still applies.DELETE /api/v1/transactions/:iddeletes a transaction. Because trades and holdings are derived, correcting or removing the transaction fixes them too.POST /api/v1/splits/previewpreviews a stock split booking (ADR-0028) without writing anything. The request carriessecurity_id, the effectivedate(ISO, not in the future) and the ratio as a pair of positive integersratio_numerator/ratio_denominator(10:1forward,1:10reverse; normalized to lowest terms, so10:5previews and books as2:1). The response shows, per portfolio holding the security, the quantity immediately before and after the effective date and the resulting current position (all Decimal strings; the ratio parts stay integers), pluswarnings:effective_date_before_historymeans the effective date predates the security’s earliest recorded transaction — the stored quantities may already be post-split (Portfolio Performance’s split wizard rewrites history destructively), so booking would double-adjust. Check the preview before booking. The preview also renders the stored closes around the effective date (quotes_around) and aquote_basis_check(misclassification guard, ADR-0028 §2): a visible jump indicates a raw series, continuity an already-adjusted one; when that contradicts the per-rowsourceclassification the preview warns withquote_basis_contradiction(for synced series that never back-adjust, set the security’streat_quotes_as_rawflag instead of booking blindly), and with too few closes on either side it reportsinsufficient_quotes_to_verify_basisinstead of implying a clean check.POST /api/v1/splitsbooks the split: one call fans the event out across all portfolios holding a position in the security at the effective date — one journaledsplitrow per portfolio, inserted atomically — and returns the created transactions (201, regular transaction shape). A portfolio with zero position at the effective date gets no row. A second same-day split for the same security is rejected with422naming the existing event (a retried timeout cannot compound the multiplicative event); a future-dated effective date and a security nobody held at the effective date are rejected with422too. The genericPOST /api/v1/transactionsendpoint rejects thesplitkind — these two routes are the only split write path.GET /api/v1/portfolios/:portfolio_id/holdingslists derived holdings for a portfolio, one row per (depot, security). Each row carriesquantity, a moving-averageavg_costandcost_basis(price-based, so fees and taxes are not folded into the unit cost), thelatest_price,market_value, andunrealized_pnl_abs/unrealized_pnl_pctagainst that price, plussecurity_nameandcurrency_code. All monetary figures are in the security’s own currency (no FX conversion — see the valuation for base-currency totals); a holding whose security has no quote returnsnullprice, market value and P&L. The response is self-describing (FR-13): it carriescurrency_basis: "security_currency"(so a client never has to assume whether FX was applied) and anas_ofdate. Holdings are derived on read with no stored snapshot, soas_ofis the read date. Unknown portfolios return404 Not Found. Optional filters:security_id,securities_account_id.GET /api/v1/holdings/by_securityreturns the global per-security valuation across all portfolios: oneholdingsrow per currently held security with itssecurity_id(an integer), totalquantity, and currentmarket_valueconverted to the EUR hub, plus avaluedflag.valuedisfalse(andmarket_valueisnull) when the security has neither a quote nor a trade price, or no exchange-rate path to EUR, so a missing quote or rate never silently distorts a value. Rows are sorted bysecurity_id. The response is self-describing: a top-levelcurrencyof"EUR", anas_ofread date (the report is derived on read, soas_ofis today’s date, not a stored snapshot), and anotedescribing the hub conversion. This is the cross-portfolio, base-currency counterpart to the per-portfolio holdings list (which stays in each security’s own currency with no FX); for one portfolio’s totals and weights use the valuation endpoint instead.POST /api/v1/holdings/reconcilecompares a user-supplied external position list (a broker statement or depot overview, parsed client-side into rows) against the ledger-derived holdings — strictly read-only: the list arrives only in the request body, is never persisted or logged, and no data is fetched from anywhere (ADR-0029 §6, FR-35). Each row is{identifier, quantity}with optionalcurrencyand an optional pinningsecurity_id;quantitymust be a canonical dot-decimal string (anything else — comma decimals, thousands separators, exponents — is a422naming the row; locale parsing is the client’s job), and an emptyrowslist is a422. Identifiers match through the same stable-identity ladder the import uses: an ISIN-shaped string (format and check digit) matches via the ISIN tier only (current ISINs first, then recorded former ISINs —matched_via: "former_isin"); any other string is tried against WKN, ticker+currency and name+currency with the exactly-one rule applied across the union of those tiers — a string matching one security’s WKN and another’s ticker lands underambiguouswith the candidate securities, never a silent pick, and a currency-less row cannot match by ticker or name (unmatchedwith reasoncurrency_required). The response is self-describing (basiswithas_of,scope, and a delta note) and reports:matchedrows (one per security — rows resolving to the same security are aggregated with their external quantities summed and the contributing rows listed) with thematched_viatier (isin,former_isin,wkn,ticker,name, orpinned),ledger_quantity,external_quantityanddelta(external - ledger) as exact Decimal strings — ticker/name matches carryweak_match: trueand the caveat “confirm the security before booking” —,ambiguousandunmatchedrows, andmissing_from_list(held ledger positions the external list does not cover). The embeddedguidanceis part of the contract: resolve a difference by booking the missing transaction of the correct kind; balance snapshots and unpriced deliveries are last resorts that distort cost basis. Optional scope:portfolio_idorview(a view id; mutually exclusive — both at once is a422), default the whole instance; an unknown portfolio or view is a404.GET /api/v1/portfolios/:portfolio_id/valuationreturns a live valuation of a portfolio: each held position priced from its latest quote close, atotal_value, and each valued position’sweight(its share of the total). Each position’s market value is converted into the portfoliobase_currency(top-level field) from stored exchange rates; per-positionsecurity_currencyshows the native currency. A security without any quote is priced at the latest own trade price (price_source: "trade", counted in the top-leveltrade_priced_count); a quoted position carriesprice_source: "quote". A position with neither price or no exchange-rate path to the base currency is returned withvalued: false,price_source: nullandnullmarket value and weight, so a missing price or rate never distorts the total. Unknown portfolios return404 Not Found. Weights are raw shares (market_value / total_value) emitted at full Decimal precision; because they are normalized ratios they need not sum to exactly1(round for display). Market values andtotal_valueare exact. The valuation also carries cash:cash_balanceslists each cash account (balancein its own currency, plusbase_value/valuedafter converting to the base currency, itsliquidity_role, and adeployableflag),total_cashis the base-currency sum of the valued cash accounts (so a drawn credit line’s negative balance still reduces it), andtotal_with_cashistotal_value + total_cash.cash_quoteis the deployable-cash share of the portfolio: deployable cash is the sum offree_cashaccounts with a non-negative balance (deployable: true), and the quote is computed as if the other accounts did not exist (counting_cash / (total_value + counting_cash),0when there is nothing to value yet) — so a reserve account or a credit line stays listed and insidetotal_cashwithout ever reporting fake liquidity. The response also emitscounting_cash(Decimal string) — the deployable cash that enters the quote — so a consumer can reconstructcash_quoteitself. An account whose currency has no rate path to the base is reportedvalued: falseand excluded fromtotal_cash, mirroring how unpriceable positions are handled. The response is self-describing (FR-13): it carries anas_ofdate (the read date — the valuation is computed live with no stored snapshot) and avaluation_notestating that totals are inbase_currencyvia the EUR hub and that the per-positionprice_sourceandvaluedfields indicate price staleness.GET /api/v1/portfolios/:portfolio_id/performancereturns the portfolio’s true time-weighted rate of return (TTWROR), computed the Portfolio Performance way: the portfolio is valued daily (quotes on or before each day, converted at that day’s rates, plus cash), external flows — deposits, removals, deliveries, and balance-snapshot jumps — are neutralised, and daily returns chain geometrically (see ADR-0010). Optional query params:period(ytd,1y,3y,5y,max— defaultmax; an unknown period returns422 Unprocessable Entity) andseries=trueto include the daily points (date,value,flow,cumulative_ttwror). The response carriesttwror,start_date/end_date,start_value/end_value,net_external_flowsas Decimal strings, andsuspect_dates— dates of bookings older than 1970 (import typos) whose effects were applied on the first plausible day. Alongsidettwrorthe response also carries the money-weighted returnirr— the single annualised rate that discounts the period’s dated external flows and terminal value back to zero (NPV(r) = Σ cf/(1+r)^(days/365) = 0), the figure Portfolio Performance shows next to TTWROR. It is a Decimal string, ornullwhen no rate exists (fewer than two flows, all flows the same sign, or the solver does not converge). Securities without quotes are priced at the latest own trade price (see the valuation endpoint). Unknown portfolios return404 Not Found.GET /api/v1/portfolios/:portfolio_id/incomereturns the retrospective income report: the dividends and interest already booked in the ledger, aggregated three ways (no forecast — the dividend calendar is a separate feature).annualis a list of years (newest first), each withmonths(a map keyed by month number"1"–"12", each carryingdividendsandinterest), and per-yeardividends_total,interest_totalandtotal.positionsis the per-position table:security_id,security_name,security_currency(the original booking currency),gross,tax(the withheld tax, from the dividend’s TAX units stored on the transaction),net(gross - tax),payment_countandlast_payment.transactionsis the per-transaction detail for a year drilldown (kind,date,year,security_id/security_name,currency, the nativenative_gross/native_tax/native_net, the base-currencygross/tax/net, andconverted). A dividend’s gross is its net cash (gross_amount) plus the withheld tax; interest carries no withholding. All amounts are Decimal strings in the portfolio’sbase_currency, converted via the EUR hub at each booking date’s stored rate (the same mechanics as the valuation endpoint), with the original currency retained;unconverted_countcounts bookings with no rate path (converted at parity), andconversion_notestates the basis. Unknown portfolios return404 Not Found. Since ADR-0020 a target plan belongs to a view: the target read/write endpoints accept an optionalview(a view id). Omitting it (or sendingnull) addresses the portfolio-wide Gesamt plan — the behaviour before views existed. A view carries its own plan, so the same classification can hold a different plan per view without the plans summing across each other. A malformedviewreturns422 Unprocessable Entity({"view": ["is invalid"]}) and an unknown view id returns404 Not Found, the same structured contract the analytics endpoints use.GET /api/v1/portfolios/:portfolio_id/targetslists a portfolio’s stored target weights (the target side of the allocation). Optionalclassification_idscopes the list to one tree; optionalviewselects the plan (omitted = Gesamt). Unknown portfolios return404 Not Found.PUT /api/v1/portfolios/:portfolio_id/targetsupserts target weights for one classification. The body is{"classification_id": id, "targets": [{"category_id": id, "target_weight": "0.25"}]}and may carry an optional"view": idto write that view’s plan (omitted = Gesamt). Eachtarget_weightis a string fraction in[0, 1]; targets need not sum to1. Only the supplied categories are changed. A category from another tree returns422 Unprocessable Entity, and an unknown classification returns404 Not Found. Position-level SOLL (ADR-0030): a target entry that also carries a"security_id"sets a weight on that individual position under the category (the security must sit under it, else422); a category entry (nosecurity_id) and its position entries are stored side by side. A presentsecurity_idmust be a positive integer ornull(null= category row) — anything else (a non-numeric string, a float) returns422instead of being coerced into a category write. A plan carries at most one position row per security: filing a security under a second category, or naming the same(category, security)twice in one batch, returns422. Each serialized target carriessecurity_id(nullfor a category row).DELETE /api/v1/portfolios/:portfolio_id/targets/:category_idremoves a portfolio’s category target for one category and returns{deleted}(the number of rows removed). Position rows for the category are left in place. Optionalviewselects the plan (omitted = Gesamt).GET /api/v1/portfolios/:portfolio_id/position_targetslists a portfolio’s position-level SOLL targets (ADR-0030):{"position_targets": [...], "effective_targets": [...]}. Eachposition_targetsrow is a target on a security under a category (withsecurity_idandsecurity_name); eacheffective_targetsentry is a category’s roll-up —explicit(the category-row weight ornull),position_sum(the sum of the position rows filed directly under it — descendants’ rows roll up to their own category),effective(the resolved steering weight — the position sum wins) andconflict(truewhen explicit and position sum disagree, surfacing the mismatch). Each position row also carriesstale(truewhen its security no longer sits under the stored category — reclassified or unassigned; the row still counts where it was filed, re-filing it is your move) and each roll-uphas_stale. Weights are Decimal strings. Optionalclassification_id/viewscope as above.DELETE /api/v1/portfolios/:portfolio_id/position_targets/:category_id/:security_idremoves one position target and returns{deleted}. The category row and the category’s other positions are untouched. Optionalviewselects the plan.GET /api/v1/portfolios/:portfolio_id/planslists a portfolio’s SOLL plan versions (ADR-0027): active first, then drafts and archived plans, each withname,status(active/draft/archived), its scope (view_id,classification_id) andcash_target_weightas a Decimal string. Optionalclassification_idscopes to one tree. Only the active plan of a scope steers the allocation.POST /api/v1/plans/:id/duplicatecopies a plan version (category targets and cash target) into a new draft of the same scope and returns it with201 Created. Optional body{"name": "Plan 2027"}names the copy (default:"<source name> (copy)").POST /api/v1/plans/:id/activatemakes a draft or archived version the active plan of its scope, archiving the previously active plan in the same transaction. Activating the already-active plan is a no-op.PATCH /api/v1/plans/:idrenames a plan version ({"name": "..."}).DELETE /api/v1/plans/:iddeletes one plan version (any status) including its category targets. Deleting the active plan leaves the scope without a plan (the allocation falls back to actual-only).GET /api/v1/snapshotslists depot snapshot markers (ADR-0027): each is aname, a scope (view_id,null= everything) and anas_ofdate. A snapshot copies no financial data — the holdings it represents derive from the transaction ledger on demand.POST /api/v1/snapshotscreates a marker ({"name": "...", "as_of": "2026-02-15", "view_id": 3};view_idoptional). A futureas_ofor a duplicate name within the scope returns422 Unprocessable Entity.DELETE /api/v1/snapshots/:iddeletes one marker; no transactions or holdings are affected.GET /api/v1/portfolios/:portfolio_id/snapshots/:id/comparisonanswers “would I have done better keeping what I had?”: the snapshot’s frozen holdings valued buy-and-hold over the stored quote history (daily, EUR-hub FX) against the scope’s real TTWROR since the as-of date. The response carriesas_of_value,current_value,snapshot_return,real_ttwror, a dailyseries(snapshot_value,snapshot_indexed,real_indexed), agapslist of securities excluded for missing quotes or FX at the as-of date, and a self-describingbasis(gross, price-return only in v1). All financial values are Decimal strings.GET /api/v1/portfolios/:portfolio_id/allocationreturns the target/actual breakdown for one classification (requiredclassification_idquery param; a missing one returns422 Unprocessable Entity). For each category it reportsparent_idanddepth(the categories form a tree),color,own_market_value(positions assigned directly to it),market_value(its whole subtree rolled up),actual_weight(the rolled-up share oftotal_value),target_weight,drift_weight(actual_weight - target_weight: positive = overweight, negative = underweight; ADR-0023), anddrift_value(the drift restated in the base currency — how much to sell (positive) or buy (negative) to reach the target). Each row also carrieschild_target_sum(Decimal string): the advisory sum of its direct children’s targets, ornullwhen no direct child carries a target — a target-consistency hint the UI can flag against the row’s owntarget_weight. A position assigned to a child counts toward that child and every ancestor, so a parent category with a target is compared against its subtree rather than showing 0%; the rows come back in tree order (parent before its children). Because parents aggregate their children, the per-categoryactual_weightvalues intentionally do not sum to 1 across levels — only the leaves plusunassigneddo. Each category (andunassigned) also carriespositions: the per-security breakdown of its own (directly assigned) value —security_id,security_name,quantity,market_value,weight, plus the display-only rebalancing hints (ADR-0023):drift_value(the position’s proportional share of the category drift) andrebalance_quantity(indicative units to sell (positive) or buy (negative) at the valuation’s implied base-currency unit price; no fee/tax modelling, never an order). Both hints arenullwithout a plan and forunassignedpositions without their own position SOLL. Entries come largest first, securities merged across depots; this is what the sunburst’s outermost ring renders. Position-level SOLL (ADR-0030 slice 2a): a category’spositionsare the union of its held positions and the active plan’s position-target rows, matched by security. Each entry additionally carriestarget_weight(its position SOLL,nullwhen none),drift_weight(actual weight − target weight, ADR-0023 sign) andheld. An entry with its own SOLL derivesdrift_valueandrebalance_quantityfrom that own drift instead of the category share. A position with SOLL > 0 that is not yet held appears with IST 0 (held: false, quantity/value/weight"0") and full underweight drift — “this needs buying” — with its indicative quantity priced at the latest stored quote (nullwhen no price exists; none is invented);quote_datenames that quote’s date (nullwhen the hint is not quote-based).heldmeans holdings presence: a held security whose price cannot be determined is never reported as unheld (it stays on the unvalued surfaces instead). Each entry also carriesstale(truewhen its attached position-target row no longer matches the security’s current category). A position is hidden only when its SOLL is 0/absent and its holdings are zero. Each category row also carriesconflict(its explicit weight and position sum disagree — the sum steers) andhas_stale(a position row filed under it is stale), and the breakdown carriesdeep_target_sum— the effective targets’ sum at the topmost targeted level per subtree, which explains a0top_level_target_sumover a plan steered deeper in the tree. Securities held but not assigned in the tree are summed intounassigned; unassigned entries attach their position SOLL too. Weights are shares of the steering basis: the valued positions’ total (scoped by the activeviewwhen one is passed), plus the deployable cash (free_cashaccounts with a non-negative balance).total_valuehere is that steering basis (not the full valuation). The response carries acashobject —market_value(the counting cash),actual_weight(its share oftotal_value),target_weight(the active view’s plan cash target, or0when unset; see the cash-target endpoints below),drift_weight(actual_weight - target_weight, ADR-0023),drift_value(restated in the base currency), anddistributed(boolean) — so cash is steered in the same drift logic as the categories. When the active classification is the built-in currency tree, each cash account’s deployable balance is attributed to its own currency-code category instead of appearing as a separate cash row: EUR cash flows into the EUR category, USD cash into USD, and so on. In that casecash.distributedistrueand consumers should omit the separate cash row; for all other classificationsdistributedisfalseand the cash row behaves as before. Because cash is part of the 100% basis, the category percentages shrink accordingly once cash is present. Thetop_level_target_sumis the sum of the root categories’ targets plus the cash target (except for the currency classification where cash is distributed into categories), compared against1. To keep a holding out of the steering basis while it still counts toward total wealth, tag it with a bucket and exclude that bucket from theviewyou read allocation under — it then falls outside the scoped positions. Since ADR-0020 the target side reflects the active view’s plan: passingview=<id>reports that view’s target weights, cash target andtop_level_target_sum(omitting it uses the Gesamt plan), so the drift table steers against one coherent 100% plan per view. Categorytarget_weightvalues are the effective targets (ADR-0030): the sum of a category’s position rows when any exist (positions are the source of truth), else its explicit category weight — the Σ figures consume the same effective values. For the raw position-target rows and per-category roll-up (the maintenance view) use theposition_targetsendpoint above. Unknown portfolios or classifications return404 Not Found.GET /api/v1/portfolios/:portfolio_id/riskreturns a risk/concentration lens for one portfolio over the steerable basis (the valued positions, scoped by the activeview, the same basis the allocation drift uses). A security held across several depots is merged into one single-name exposure. Weights, caps and the HHI are all on a 0-100 percentage scale (Decimal strings, full precision, no rounding):steerable_basisis the basis the weights are a share of, andbase_currencythe portfolio’s base currency.top_holdingsis the largest single-name exposures, largest first, default N = 10 (override with thetop_nquery param). Each entry carriessecurity_id,security_name,asset_class,market_value,weightand aseverity(ok/warn/hard). The severity is instrument-type aware: a single stock warns above7and goes hard above10; an ETF (theetfasset class) warns above25and never goes hard. Override the defaults with thestock_thresholds[warn]/stock_thresholds[hard]andetf_thresholds[warn]query params.hhicarries the Herfindahl-Hirschman Index of the single-name weights (value= the sum of the squared percentage weights, on the0-10000scale) plus aband:low(< 1500),moderate([1500, 2500]) orconcentrated(> 2500). Override the cutoffs with thehhi_bands[low]andhhi_bands[high]query params.asset_class_violationsare opt-in asset-class cap violations: there are no shipped defaults, so caps are configured per call with theasset_class_caps[<asset_class>]query param (a percentage, e.g.asset_class_caps[equity]=50). Only classes whose current percentage weight exceeds the cap come back, each withasset_class,current_weight,capandoverage(current − cap, in percentage points).
The lens is a pure read-time derivation of the live valuation and the securities’ asset classification — nothing is stored, so it is deterministic on read. A malformed override (e.g. a non-positive
top_n) returns422 Unprocessable Entity; unknown portfolios return404 Not Found.GET /api/v1/portfolios/:portfolio_id/cash_targetreads a plan’s cash target, the target cash share of the allocation’s 100% basis (securities + counting cash). The response is{"cash_target_weight": "0.05"}(a string fraction in[0, 1], ornullwhen none is steered). Optionalviewselects the plan (omitted = the Gesamt plan). Unknown portfolios return404 Not Found, a malformedviewreturns422, and an unknown view id returns404.PUT /api/v1/portfolios/:portfolio_id/cash_targetsets (or clears withnull) a plan’s cash target. The body is{"cash_target_weight": "0.05"}and may carry an optional"view": id(omitted = Gesamt). It echoes the stored value back. Out-of-range weights return422 Unprocessable Entity. The cash target feeds the allocation’scashrow and thetop_level_target_sumfor the addressed view.PATCH /api/v1/portfolios/:portfolio_idpatches a portfolio’s master data. Deprecated (ADR-0024) — answers withDeprecation: true; compatibility only, use buckets/views for grouping. The body is{"portfolio": {...}}. Cash target move (ADR-0020): the cash target moved off the portfolio object onto the per-view target plan, served by the twocash_targetendpoints above. For back-compatibility the portfolio object still exposescash_target_weight— a string fraction in[0, 1](e.g."0.05"for 5%), ornullto stop steering a cash quote — and patching it reads/writes the Gesamt plan’s cash target (viewomitted). So a client that only knows the old field keeps working unchanged; usePUT /cash_target?view=<id>to steer a per-view cash target. Out-of-range weights return422 Unprocessable Entity; unknown portfolios return404 Not Found. Thecash_target_weightis also included in the portfolio objects returned byGET/POST /api/v1/portfolios(the Gesamt cash target).GET /api/v1/securities/:security_id/tradesreturns FIFO-matched trades for one security: open lots, closed round-trips (with realised P&L and holding period in days) and any orphan sells. The response is self-describing (FR-13): it carriesmethod: "fifo", so a client never has to assume how lots were paired against sells. Optionalfrom/to(ISO dates) filter each leg by its own date: open lots by open date, closed round-trips by close date, orphan sells by sell date.
Exchange Rates
GET /api/v1/exchange_rateslists stored exchange rates. Rates are kept against the EUR hub (1 base_currency = rate quote_currency); other pairs are derived by triangulation, andGBX(pence) is handled asGBP × 100.POST /api/v1/exchange_rates/syncfetches the latest rates from the configured provider (ECB daily reference rates by default) and returns{provider, status, upserted}. A provider failure returns502 Bad Gateway.
Classifications
Classification trees organise securities like folders. Built-in trees
(asset_class, currency) are derived automatically and their structure is
locked; editing the structure of a built-in tree returns 422 Unprocessable
Entity. The asset-class tree’s membership, however, is just a view of each
security’s asset_class field: in the UI you can drag a security between its
categories (which sets that field), and the same effect is achieved over the API
with PATCH /api/v1/securities/:id ({"security": {"asset_class": "etf"}}) or
the securities.update MCP tool. Set it to empty/null for “automatic”, which
re-infers the class from the security’s name/ISIN/ticker on read. The currency
tree stays intrinsic and cannot be reassigned.
GET /api/v1/classificationslists every classification as a tree with itscategoriesandassignments({security_id, category_id}). Built-in trees carrybuilt_in: trueand akey.POST /api/v1/classificationscreates a custom classification from aclassificationobject (name, optionalposition,description).PATCH /api/v1/classifications/:idupdates a custom classification’sclassificationobject (name,position,description— all optional).DELETE /api/v1/classifications/:iddeletes a custom classification and cascades its categories and assignments.POST /api/v1/classifications/:classification_id/categoriesadds acategory(name, optionalcolor,description,parent_id,position) to a custom classification.PATCH /api/v1/classifications/:classification_id/categories/:idpatches acategory(name,color,description,parent_id,position— all optional). The category’sclassification_idcannot be changed this way.DELETE /api/v1/classifications/:classification_id/categories/:iddeletes a category and cascades its child categories and assignments.PUT /api/v1/classifications/:classification_id/assignmentsassigns a security to a category (security_id,category_id), replacing any existing assignment for that security in the classification. The response carries astatusofcreated,moved, orunchangedplusprevious_category_id.PUT /api/v1/classifications/:classification_id/assignments/bulkassigns many securities to one category in a single call (category_id,security_ids), returning{assigned, category_id, security_ids}.DELETE /api/v1/classifications/:classification_id/assignments/:security_idremoves a security’s assignment from the classification.
Example transaction payload:
{
"transaction": {
"portfolio_id": 1,
"securities_account_id": 1,
"security_id": 1,
"type": "buy",
"date": "2026-05-15",
"quantity": "10.00000000",
"price": "123.45",
"fees": "1.50",
"taxes": "0",
"currency_code": "EUR"
}
}
Buckets and Views
Buckets are overlapping tags applied to holdings (depots, cash accounts and
individual security positions) for tag-based wealth scoping. Views are named,
global filters over those buckets: a holding matches when it is included
(always under include_all, otherwise when it carries one of the view’s include
buckets) and carries none of the view’s exclude buckets — exclude always wins.
Bucket-definition and assignment writes are journaled (ADR-0017); view-definition
writes are deliberately not journaled (ADR-0018 §5).
GET /api/v1/bucketslists buckets (id,name,color,dimension).dimensionis"tag"(a free overlapping tag) or"scope"— the exclusive dimension: a depot or cash account carries at most one scope bucket, so scope-scoped totals always add up (ADR-0024).POST /api/v1/bucketscreates a bucket from abucketobject (namerequired, optionalcolor, optionaldimensiondefaulting to"tag"). A blank or duplicate name, or an unknown dimension, returns422.GET /api/v1/buckets/:idreturns one bucket; unknown ids return404.PATCH /api/v1/buckets/:idpatches a bucket’sname/color. Thedimensionis fixed at creation; attempts to change it return422.DELETE /api/v1/buckets/:iddeletes a bucket and cascades it out of every assignment and view set, returning204 No Content.GET /api/v1/viewslists views. Each view carriesinclude_all, the resolvedincludeset (the literal"all"underinclude_all, otherwise a list of bucket ids) and theexcludelist of bucket ids.POST /api/v1/viewscreates a view from aviewobject (namerequired, optionalinclude_alldefaulting totrue).GET /api/v1/views/:idreturns one view with its resolved filter.PATCH /api/v1/views/:idpatches a view’sname/include_all.DELETE /api/v1/views/:iddeletes a view and its bucket sets (204).PUT /api/v1/views/:id/bucketsreplaces a view’s include/exclude bucket sets. Body:{"include": [..], "exclude": [..]}(both optional, default[], arrays of bucket ids). A malformed id list returns422.GET /api/v1/views/:view_id/valuationreturns the live valuation of a view across all portfolios (ADR-0024): the deduplicated union of every depot, position and cash account matching the view — an account tagged into several included buckets counts exactly once. The shape mirrors the portfolio valuation (totals, positions with weights andprice_source/valuedflags,cash_balances,cash_quote,as_of, avaluation_note) withview_idin place ofportfolio_id; totals are in EUR, converted via the EUR hub, and all financial values are Decimal strings. Anoverlapobject reports the account-level bucket overlap for UI badges (overlapping, plus thesecurities_account_ids/cash_account_idscarrying more than one included bucket — the totals are already deduplicated). The active view is echoed asview: {id, name}. Unknown and malformed view ids return404.PUT /api/v1/securities_accounts/:id/bucketsreplaces a depot’s default bucket set (the buckets each position inherits unless overridden). Body:{"bucket_ids": [..]}. At most one of the ids may be a scope-dimension bucket; a violating set returns422without writing anything.PUT /api/v1/cash_accounts/:id/bucketsreplaces a cash account’s bucket set. Body:{"bucket_ids": [..]}. The same at-most-one-scope-bucket rule applies.PUT /api/v1/securities_accounts/:id/positions/:security_id/bucketssets the per-position override for one security in one depot. An emptybucket_idsrecords the explicit-empty state (deliberately no buckets), distinct from inheriting the depot default; the override always wins over the depot default. The response reports the resolvedoverride(inherit,explicit_emptyorexplicit) and theeffective_bucket_ids.DELETE /api/v1/securities_accounts/:id/positions/:security_id/bucketsclears the override, returning the position to inherit the depot default.
The analytics endpoints accept an optional view query param (a view id) to
scope the result to the holdings matching that view:
GET /api/v1/portfolios/:portfolio_id/valuation?view=<id>GET /api/v1/portfolios/:portfolio_id/allocation?classification_id=<id>&view=<id>GET /api/v1/portfolios/:portfolio_id/performance?view=<id>GET /api/v1/portfolios/:portfolio_id/risk?view=<id>
When a view is supplied, the response echoes the active view as
view: {id, name} (FR-13); the unscoped/default call is unchanged and carries
no view field. A malformed view id returns 422; an unknown view id returns
404. The same view scope (and the same 422/404 contract) applies to the
target endpoints — GET/PUT
/api/v1/portfolios/:portfolio_id/targets, DELETE
/api/v1/portfolios/:portfolio_id/targets/:category_id and the cash-target
endpoints GET/PUT /api/v1/portfolios/:portfolio_id/cash_target — where a
view selects the target plan (omitted = the Gesamt plan). The holdings endpoint
(GET /api/v1/portfolios/:portfolio_id/holdings) is not view-scoped: it
returns the raw per-(depot, security) rows in each security’s own currency, so a
client can apply the buckets/views model itself using each row’s
securities_account_id and security_id.
Settings
A minimal keyed preference store backs the user-facing defaults (ADR-0024). Today it carries one preference: the default view the Wealth page and dashboard open on when no explicit view was chosen in the UI. No financial decimals are involved.
GET /api/v1/settings/default_viewreturns the current default:{"data": {"view_id": null, "view": null}}when unset (the built-in Everything scope), otherwise the id plus aview: {id, name}echo.PUT /api/v1/settings/default_viewsets it. Body:{"view_id": <id>}with a live view id, or{"view_id": null}to clear back to Everything. An unknown view id returns404(nothing is written); a malformedview_idreturns422. The response mirrors theGETshape.
Audit Journal
Every financial write (create, update, delete) is recorded in an append-only audit journal in the same database transaction as the write itself, so any change — including deletions — stays attributable and reversible by inspection. Market-data ingestion (quote and exchange-rate sync) is operational data and is deliberately not journaled.
GET /api/v1/journallists journal entries, newest first. Each entry carriesactor_type(owner_ui,api_token_rw,api_token_ro,import_session,system_job) and an optionalactor_label, theoperation(create,update,delete,upsert), theresource_type/resource_idit touched, and thebefore/aftersnapshots (Decimal values are strings). Optional filters:resource_type,resource_id,actor_type,operation,limit(default 100, max 1000) andinclude_scenarios(trueto include persisted what-if writes; real writes only by default). The response is self-describing: ametaobject echoes theas_ofinstant, theorder(inserted_at:desc,id:desc), thecountand thefiltersapplied. SOLL target writes (category and position rows alike) are journaled underresource_type=target; plan-version writes underresource_type=target_plan.
The journal currently covers the Catalog/Fx contexts (security master-data writes); the remaining write contexts are armed in sequence.
MCP Tools
The MCP companion exposes the same local contract as tool calls. Decimal inputs in MCP schemas are strings.
portfolixir.securities.listportfolixir.securities.get— one security’s full record including itsidentifier_aliases(recorded former ISINs).portfolixir.securities.createportfolixir.securities.updateportfolixir.securities.deleteportfolixir.securities.isin_change— records a corporate-action ISIN change so imports keep matching via the former ISIN (ADR-0029).portfolixir.securities.delete_isin_alias— journaled delete of one recorded former-ISIN alias.portfolixir.securities.search_onlineportfolixir.quotes.syncportfolixir.quotes.listportfolixir.quotes.upsertportfolixir.portfolios.list— deprecated (ADR-0024): steers to buckets/views in its description.portfolixir.portfolios.create— deprecated (ADR-0024): compatibility only; preferportfolixir.buckets.create/portfolixir.views.create.portfolixir.cash_accounts.listportfolixir.cash_accounts.createportfolixir.cash_accounts.updateportfolixir.cash_accounts.deleteportfolixir.cash_accounts.set_balanceportfolixir.securities_accounts.listportfolixir.securities_accounts.createportfolixir.securities_accounts.updateportfolixir.securities_accounts.deleteportfolixir.transactions.listportfolixir.transactions.createportfolixir.transactions.updateportfolixir.transactions.deleteportfolixir.splits.previewportfolixir.splits.createportfolixir.holdings.listportfolixir.holdings.by_securityportfolixir.holdings.reconcile— read-only compare of a pasted external position list against the ledger; its description steers the agent toward booking the missing transaction of the correct kind instead of balance snapshots or unpriced deliveries.portfolixir.portfolios.valuationportfolixir.exchange_rates.listportfolixir.exchange_rates.syncportfolixir.classifications.listportfolixir.classifications.createportfolixir.classifications.categories.createportfolixir.classifications.updateportfolixir.classifications.deleteportfolixir.classifications.categories.updateportfolixir.classifications.categories.deleteportfolixir.classifications.assignportfolixir.classifications.assign_bulkportfolixir.classifications.unassignportfolixir.trades.listportfolixir.targets.listportfolixir.targets.setportfolixir.targets.deleteportfolixir.targets.list_positionsportfolixir.targets.delete_positionportfolixir.portfolios.allocationportfolixir.portfolios.riskportfolixir.portfolios.cash_targetportfolixir.portfolios.set_cash_targetportfolixir.portfolios.incomeportfolixir.portfolios.performanceportfolixir.journal.listportfolixir.buckets.listportfolixir.buckets.getportfolixir.buckets.createportfolixir.buckets.updateportfolixir.buckets.deleteportfolixir.views.listportfolixir.views.getportfolixir.views.createportfolixir.views.updateportfolixir.views.deleteportfolixir.views.set_bucketsportfolixir.views.valuationportfolixir.securities_accounts.set_bucketsportfolixir.cash_accounts.set_bucketsportfolixir.securities_accounts.set_position_bucketsportfolixir.securities_accounts.clear_position_bucketsportfolixir.settings.get_default_viewportfolixir.settings.set_default_viewportfolixir.plans.listportfolixir.plans.duplicateportfolixir.plans.activateportfolixir.plans.renameportfolixir.plans.deleteportfolixir.snapshots.listportfolixir.snapshots.createportfolixir.snapshots.deleteportfolixir.snapshots.comparison
The portfolixir.portfolios.valuation, portfolixir.portfolios.allocation,
portfolixir.portfolios.performance and portfolixir.portfolios.risk tools
accept an optional view (a view id) that scopes the result to the holdings
matching that bucket view; the response then echoes the active view.
portfolixir.views.valuation values a view across all portfolios in one
call (each matching account counted once, EUR totals, overlap badge data) —
use it instead of summing per-portfolio valuations client-side.
portfolixir.settings.get_default_view / portfolixir.settings.set_default_view
read and set the default-view preference (ADR-0024): pass a view_id to pin a
view, or null/omit it to clear back to the built-in Everything scope.
Since ADR-0020 the target tools (portfolixir.targets.list,
portfolixir.targets.set, portfolixir.targets.delete) and the cash-target
tools (portfolixir.portfolios.cash_target to read,
portfolixir.portfolios.set_cash_target to set or clear) also accept an optional
view (a view id) that selects the target plan; omitting it addresses the
portfolio-wide Gesamt plan. The cash target moved off the portfolio object onto
the plan, but portfolixir.portfolios.set_cash_target without a view still
steers the Gesamt cash target, so it keeps the same effect as the legacy
portfolio cash_target_weight field. All cash targets and target weights are
exposed and accepted as Decimal strings.
Since ADR-0030 (#481) the same tools carry position-level SOLL: a
portfolixir.targets.set entry that adds a security_id sets a weight on that
individual position under its category, portfolixir.targets.list_positions
reads the position rows plus each category’s effective roll-up (explicit,
position sum, effective steering weight, and a conflict flag surfacing an
explicit/position mismatch — plus per-row stale and per-category has_stale
flags marking rows whose security no longer sits under the stored category),
and portfolixir.targets.delete_position removes one position target.
Category-only calls are unchanged.
Since ADR-0027 the plan tools (portfolixir.plans.list,
portfolixir.plans.duplicate, portfolixir.plans.activate,
portfolixir.plans.rename, portfolixir.plans.delete) manage named plan
versions: duplicate the active plan into a draft, edit the draft through the
target tools (the drafts are addressed by the plan endpoints; view-addressed
target writes keep editing the active plan), then activate it. The snapshot
tools (portfolixir.snapshots.list, portfolixir.snapshots.create,
portfolixir.snapshots.delete, portfolixir.snapshots.comparison) freeze a
depot state as a marker and read the counterfactual comparison; every financial
value in the comparison is a Decimal string and the response labels its basis
(gross, price-return only).