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.
Delta reads (FR-38). The two recurring-sync reads — GET
/api/v1/transactions and GET /api/v1/securities — accept
?since=<ISO8601> (a datetime with offset, a naive UTC datetime, or a plain
date meaning start of that day, UTC) and then return only the rows created or
updated strictly after that instant, judged by updated_at. The response
echoes since, carries as_of (the read instant — use it as the next
since) and a delta_note stating the semantics. Deletions are not
represented in a delta read; a caller that must detect deletions performs a
full read. An invalid since is a 422. Delta reads are pull-only: push
delivery (webhooks to a user-configured endpoint) is a separate, still-gated
decision (B3.7) and deliberately not part of this surface.
The human view of the same cut (issue #731) lives on
/transactions?since= and /securities?since= as the Changed since chips:
same parameter name, same accepted forms, same strictly-after-updated_at
cut, so a link an agent hands over opens exactly the slice it read. The one
divergence is deliberate: where the API rejects an invalid since with a
422, the pages degrade to the unfiltered list — a stale bookmark must never
silently narrow what the operator sees.
Securities
The data_quality predicates are the same rule the dashboard counts and
the securities page links to (issue #705): they are defined once in
Portfolixir.Catalog.DataQuality, so a count of N always addresses a list of
N, and an agent asking “which securities have a stale quote” gets exactly the
set a person sees. Combine them with query, holding_status and the other
narrowings; an unknown value is a 422 naming data_quality, never a silent
full list.
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). An optionalfields=(issue #732, extending FR-37, comma-separated) selects a sparse fieldset resolved against the full projection’s field list; a presentfields=supersedesprojection=, because a sparse fieldset is itself a projection, chosen field by field. An unknown name is a422, never a silent fallback. 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),data_quality(stale_quote— no quote newer than 7 days, including securities never priced at all;missing_quote— no quote at all, the narrower set inside it;missing_logo;missing_fx— issue #717: priced, but with no stored rate from its currency to the EUR hub, so storing the rate empties the set),projection(slim/full), andlimit/offsetfor pagination (both non-negative integers). Use these to page large catalogs instead of fetching the whole table at once. The human view of these narrowings is the one-tap filter-chip row on the securities page (issue #717): its chips ride the same URL state (holding=,dq=,filter[]=asset_class:is_nil, pluscur[]=andclass[]=for the currency and effective-class families), so a pre-filtered link and an API read describe the same set.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) — and itsthesis_state, the current thesis derived from the research log (see below). Listings carrythesis_stateasnull; only the detail read computes it.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"
}
}
Research log (ADR-0044)
What the operator or the agent knows about a security is recorded as
append-only dated entries — the security research log — and the current
thesis state is derived from them, never maintained beside them. Entries
are never updated and never deleted: a refuted finding is withdrawn by
appending a retraction that supersedes it, and both stay readable, so the
next run sees the retraction first instead of re-investigating a settled
premise. There is deliberately no PATCH and no DELETE for an entry.
Each entry carries kind (thesis, evidence, invalidation_check,
event_result, risk, retraction, decision), body, source_url (an
http(s) URL; other schemes are a 422),
source_quality (primary, secondary_multi, awareness, unverified —
set, not guessed), as_of (the statement’s cut-off date, distinct from
inserted_at: an entry written today about last quarter carries last
quarter’s date), author (operator, agent, local_model; the API
defaults to agent), machine_generated (an extracted entry is a proposal
until confirmed and must carry its source_url), supersedes_id (the earlier
entry of the same security this one replaces; required for a retraction),
valid_until (a dated block such as a lockup or a self-imposed buying block),
and — on thesis entries only — conviction (low, medium, high),
invalidation_condition and time_stop. Every entry in a response also
carries superseded_by_ids and a superseded flag, so a superseded entry is
shown as superseded rather than hidden. All closed sets are validated; an
unknown value is a 422 naming the field, and no atom is ever created from
input.
GET /api/v1/securities/:security_id/notes— the log, newest first (byas_of, then write time), with the derivedthesis_stateand alog_notestating the append-only contract.POST /api/v1/securities/:security_id/notes— appends one entry from anoteobject (201); journaled under the API-token actor.GET /api/v1/notes/unreviewed?days=N— held securities (net quantity non-zero across all depots) whose newest entry is older thanNdays (default 90) or that have none; rows carrylast_entry_as_ofanddays_since_last_entry(nullwhen never reviewed).GET /api/v1/notes/uncorroborated— entries whosesource_qualityis notprimary, newest first; superseded entries are skipped unlessinclude_superseded=true; optionalsecurity_id.GET /api/v1/notes/expiring?days=N— entries whosevalid_untilfalls within the nextNdays (default 30), soonest first, withdays_until_expiry; lifted (superseded) blocks are skipped; optionalsecurity_id.
The thesis state (thesis_state on the security detail and on the log
read) is the B4.1 projection: status (none, intact, retracted), the
current thesis text, conviction, invalidation_condition, time_stop,
as_of, last_reviewed_at and last_reviewed_by (the newest thesis or
invalidation_check entry), derived_from_entry_id (the thesis entry it
reads from) and retracted_by_entry_id (the retraction whose body carries
the reason), plus a basis sentence stating the derivation. The newest
thesis no other thesis supersedes is the current one; a retraction
superseding it sets retracted.
Example append payload:
{
"note": {
"kind": "retraction",
"body": "Checked the 10-Q on 2026-08-02: no supplier dispute disclosed. Withdrawn.",
"source_url": "https://example.invalid/sec/10-q",
"source_quality": "primary",
"as_of": "2026-08-02",
"supersedes_id": 41
}
}
The human view is the Research tab of the security detail pane on
/securities/:id: the thesis state on top, the entries newest first with
kind and source quality visible, superseded entries marked as such,
retractions legible, and a form that appends an entry as the operator.
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 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. An optionalfields=(FR-37, comma-separated) selects a sparse fieldset: each row then carries exactly the requested fields. The names are validated against the serializer’s own field list — an unknown name is a422, never a silent fallback. The human view offields=(issue #732) is the column picker on the transaction history and on the holdings panel: the same projections, chosen column by column on the page — and the securities list, whose picker predates FR-37, gained the reverse half as its ownfields=(see Securities). An optionalrunning_balance_for=<cash_account_id>adds arunning_balanceto each row — the balance of that cash account after the booking, as a Decimal string in the account’s own currency — plus a top-levelrunning_balance_basisnaming the account, its currency and the fold. Two properties the number depends on: the fold always covers that account’s whole history, so a narrowed read (afrom, a filter) still reports true balances rather than a partial sum; and a row that does not move the account carriesnullrather than repeating the previous figure, which would read as “nothing happened here”. An unknown or non-numeric account id is a422namingrunning_balance_for. This is the API and MCP counterpart of the balance column on the Transactions page.POST /api/v1/transactionscreates a transaction of any bookable kind with atransactionobject (per-kind required fields are validated server-side). The bookabletypevalues arebuy,sell,dividend,interest,deposit,removal,fee,tax,tax_refund,cash_transfer,inbound_delivery,outbound_deliveryandsecurity_transfer(balance_adjustmentis written through the dedicated balance-snapshot endpoint,splitthrough the split routes below). 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 tax refund — for example the tax credited back when a sale realises a loss — is therefore never a negativetaxesvalue on the sell: book the sell with the taxes actually withheld (or0), plus a separatetax_refundtransaction whose positivegross_amountis the cash credited to the account (cash_account_idandgross_amountare its required fields; the changeset rejects a negativetaxeswith exactly this guidance). 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. Those figures are in the security’s own currency — enforced by the cost pair the ledger fold carries (ADR-0033), no longer an assumption; a holding whose security has no quote returnsnullprice, market value and P&L. Each row additionally carries the ADR-0033 base-currency P&L decomposition:base_cost(the settlement-leg amount actually paid, with itsbase_currency),price_return_abs/price_return_pct(the security’s own price move, converted at today’s rate),currency_return_abs/currency_return_pct(the FX effect on the amount originally invested) andtotal_return_base_abs/total_return_base_pct— withtotal = price + currencyholding Decimal-exactly. A row whose decomposition is not derivable reportsdecomposed: falsewith anundecomposed_reason("missing_native_cost"— no security-currency leg in the recorded booking, in which casecost_basis/avg_cost/P&L arenulltoo;"missing_base_cost"— the settlement leg is not in the base currency;"missing_fx"— no stored current rate;"no_price"), never a guessed number. The response is self-describing (FR-13): it carriescurrency_basis: "security_currency"plus acurrency_basis_notenaming which field is in which currency, 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. An optionalfields=(FR-37, comma-separated) selects a sparse fieldset per row, validated against the serializer’s own field list; an unknown name is a422.GET /api/v1/realized_gains(issue #724) returns the Cash-flow Realized-gains roll-up: FIFO-matched realized P&L across all securities and portfolios, grouped by each sale’s close date into an annualyear → monthmatrix in the base currency. The FX basis is D-1 (signed 2026-08-20): each sale converts through the EUR hub at the rate stored on its own close date; a sale with no stored rate for that day is excluded from every converted total and named inexcluded(count+securities) — never converted at a neighbouring date’s rate, never silently dropped. The payload carriescomputation_basis(series, window, reference, gaps) and aconversion_note; the human view is/cashflow?tab=realized.GET /api/v1/external_flows(issue #725) returns the Deposits & withdrawals roll-up: the booked external cash flows (depositandremoval) across all portfolios, per year and month with deposits, withdrawals and a net. Deliberately narrower than the performance walk’sinvested_capital, which additionally counts securities delivered in/out at market value and balance-snapshot residuals — the difference is stated incomputation_basis.excludes. FX basis as in the sibling facet: EUR hub at the rate stored on each flow’s own booking date, unconvertible flows excluded and named by their cash account. The human view is/cashflow?tab=flows.GET /api/v1/costs(issue #726) returns the Costs roll-up: fees and taxes across all portfolios at overview level only, per year and month with yearly fee, tax and combined totals. The series sums the fee and tax legs riding any transaction plus the standalonefee/taxbookings, withtax_refundnetted against taxes; gross amounts are never summed — a buy’s gross includes its legs while a sell’s is net of them, so a gross sum would describe something else. The legs-not-gross rule is stated incomputation_basis.series. FX basis as in the sibling facets: EUR hub at the rate stored on each booking’s own date, unconvertible costs excluded and named by their currency. The human view is/cashflow?tab=costs.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. Each row also carries the resolved nativelatest_pricewithprice_currencyandprice_source, and anunvalued_reasonthat says why a row is unvalued:"no_price"(nothing resolves at all) or"missing_fx"(the price is known but no stored rate path reaches EUR);nullwhen valued. 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.GET /api/v1/holdings/negativereturns the negative-holdings data-quality report: every (depot, security) position whose derived quantity is below zero — import debris from unmodeled corporate actions — asrows(withdepot_name,security_name,isin,portfolio_idand the negativequantityas a Decimal string) plustotalswith each listed security’s total quantity across all depots, so transfer debris (negative in one depot, positive in another) is distinguishable from a truly negative total. Self-describing with anas_ofread date and anote. Nothing is repaired automatically; fix the security’s transaction history.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), ordered by the lowest input row index each match aggregates so a finding maps back to the submitted line, each embedding the security’s identity only (id,name,ticker_symbol,isin,wkn,currency_code), 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. An optionalinclude_positions=false(FR-37) returns the roll-up only — totals, cash balances and cash quote without the per-position rows; the response states which shape was returned viapositions_included.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),year=YYYYfor one calendar year,from=/to=(ISO dates, both required,from <= to) for a custom range — both clamped honestly to the available history, with a backwards or malformed range returning422— 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. Since the daily walk may be served from a durable derived value (ADR-0039), the response is never silent about freshness:as_of(ISO-8601 datetime) is the instant the walk was computed — possibly older than the request when the underlying data has not changed since — andstale(boolean) marks a superseded value served while a fresh one computes; a stored value is invalidated by every write that can affect it, sostale: falsemeans current against the ledger. The response also states the metric’s computation basis (computation_basis): the input series, the effective window, the reference series (null— TTWROR/IRR have none) and the treatment of gaps.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 is the remedy) and each roll-uphas_stale. Weights are Decimal strings. Optionalclassification_id/viewscope as above. Read ergonomics (FR-37, issue #740):min_drift=— the allocation read’s threshold, same spelling (a non-negative Decimal string on|drift_weight|) — returns only the position rows whose drift meets it, wheredrift_weightis the security’s actual weight in the steering basis minus its position target exactly as the allocation computes it (one predicate, two surfaces). Kept rows carrydrift_weight; rows without a drift are filtered out; the response states the appliedmin_drift,position_targets_total(the pre-filter count) and adrift_basissentence. Withoutmin_driftthe rows carry nodrift_weightand the shape is unchanged. An invalid threshold is a422.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.
Recorded tax statements (ADR-0031)
These pots are recorded, not derived — and the reason is not a missing
FIFO. Portfolixir matches lots FIFO already (GET
/api/v1/securities/:id/trades), and that gives a gross gain. A gross gain
is not a tax pot: Teilfreistellung, Vorabpauschale, chronological allowance
consumption and certified prior-year carry-forward are not in the transaction
data at all, and the pots are kept per tax-reporting institution, which
Portfolixir does not model. A derived pot would be wrong, and invisibly so — so
these endpoints transcribe a broker statement, they never compute one from
holdings. Every money value is a
positive magnitude Decimal string: a loss pot is the volume of loss
available for offsetting, not the negative number the statement prints, and a
negative input is rejected rather than silently flipped. Nothing here is tax
advice, and the recorded statement remains the authority. (A tax actually
credited back to a cash account — for example after a loss sale — is a ledger
event, not a pot: book it as a separate tax_refund transaction as described
under “Transactions and Holdings” above.)
GET /api/v1/tax/parameterslists the year-scoped statutory parameters (optionaljurisdiction, currently onlyDE):capital_gains_tax_rate,solidarity_surcharge_rateand thesaver_allowance_single/saver_allowance_jointceilings. Rates are Decimal string fractions ("0.25", never"25"). Seeded 2009–2026; a year with no row is simply absent — the API never substitutes a neighbouring year’s ceiling.PUT /api/v1/tax/parametersinserts or replaces one year’s parameters ({"parameters": {"tax_year": 2027, ...}}).GET /api/v1/tax/profileslists a holder’s effective-dated tax profiles (requiredholder; a missing one returns422 Unprocessable Entity), newestvalid_fromfirst. The profile in force for a date is the row with the greatestvalid_fromat or before it — never an exact match. Church-tax liability defaults to not liable with rate0.POST /api/v1/tax/profilesrecords a profile from a date ({"profile": {"holder": "...", "valid_from": "2024-01-01"}}). A non-zerochurch_tax_rateon a not-liable profile is rejected.PATCH /api/v1/tax/profiles/:idcorrects one profile row. To record a change in the situation, create a new row with a latervalid_from— editing rewrites history, adding does not.DELETE /api/v1/tax/profiles/:iddeletes one profile row. Snapshots already recorded keep their frozen church-tax rate and are unaffected.GET /api/v1/tax/allowance_orderslists the Freistellungsaufträge the taxpayer instructed (optionalholder,institution,tax_year; free-text filters fold case, socomdirectandComdirectare one institution).PUT /api/v1/tax/allowance_ordersrecords or replaces the instructed amount for one(holder, institution, tax_year)({"allowance_order": {...}}).DELETE /api/v1/tax/allowance_orders/:iddeletes one configured order.GET /api/v1/tax/statement_snapshotslists recorded statements (optionalholder,institution,tax_year), newestas_offirst. Each row carries the eleven recorded figures plusallowance_remaining,tax_free_trim_budget,expected_capital_gains_tax, theas_ofthey rest on, astaleflag, and the advisoryfindings. Each row also carries the activity-awarestalenessassessment (#667): staleness as a function of activity, not only of the calendar. It states its own computation basis —age_daysagainstage_threshold_days(90),activity_since_count(the ledger transactions ofactivity_kinds—sell,dividend,interest,tax,tax_refund— dated strictly afteras_of), the two component warnings and the combinedwarning, plus abasisnote recording that bookings are not attributed to an institution or tax year.warningis the signal to act on; the barestaleflag flips the day after anyas_ofand is kept for compatibility.POST /api/v1/tax/statement_snapshotsrecords a statement ({"statement_snapshot": {"institution": "...", "holder": "...", "tax_year": 2025, "as_of": "2025-12-31", ...}}).as_ofmust not lie in the future. Omitchurch_tax_rateto take the holder’s profile in force atas_of; the resolved rate is then frozen on the row, so a later profile edit never rewrites a recorded transcription. Re-recording the same(institution, holder, tax_year, as_of)is a422, not a silent duplicate.GET /api/v1/tax/trim_budgetrolls the latest statement per institution up to one holder and year (requiredholderandtax_year). It reports whichinstitutionsit covers, theas_ofof its oldest component, andcomplete: falsewithmissing_institutionswhen a configured allowance order has no recorded statement for the year — the total is then a partial picture and says so. The response carries the samestalenessassessment, computed against the roll-up’s (oldest)as_of.GET /api/v1/tax/statement_snapshots/:idreads one recorded statement.PATCH /api/v1/tax/statement_snapshots/:idcorrects one recorded statement in place — the re-issued-statement case.DELETE /api/v1/tax/statement_snapshots/:iddeletes one recorded statement.
Consistency findings are advisory and computed at read time. A finding names
the recorded and the expected number and the gap; it never proposes a
corrected value and never blocks a write. Two rules are hard instead, and come
back as 422 changeset errors: allowance used above allowance granted, and
church tax withheld at a zero church-tax rate.
-
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; it carries thewindow, thebase_currency, andcosts_removed/costs_keptnaming which cost kinds left the return). All financial values are Decimal strings.The two sides are not on one basis, and the payload says so (#708, ADR-0027 amendment): the frozen side pays nothing ever, while the real TTWROR is net of the fees and taxes actually booked. The response therefore also carries
real_ttwror_before_costs(the same walk with the window’s trade fees and taxes reclassified as external flows — standalone fee/tax bookings and dividend withholding deliberately stay inside the return, because the frozen holder would have paid those too),transaction_costs(their total in the base currency) andcost_recoverywithstateone ofrecovered,partly_recovered(ahead before costs, not yet after;outstandingstates the gap),not_recovered(behind even before costs, so the costs are not the reason) ornot_comparable(the frozen side has no value at the as-of date — an all-cash snapshot, for example — so there is nothing to be ahead of; the costs still report). Read the three together:real_ttwror_before_costsalone flatters. -
GET /api/v1/portfolios/:portfolio_id/category-results?classification_id=<id>— per-category result (ADR-0041 slice one, #712):invested(the sum of the members’ base-currency cost),current_value,result_absandresult_pctper category, plus the memberpositionsthat produced it and theexcludedrows it could not cover.Read the basis before the numbers. The payload states it in one line (
basis: "current_composition", with abasis_notespelling it out): this is a figure about the positions filed under each category today, so there is no period, no membership variant and no as-of — and it is not a time-weighted return series.result_pctisΣ result ÷ Σ invested, never a mean of the members’ percentages: averaging lets a tiny position at +300 % dominate a category that is flat in money. A member whose result is not derivable — no usable price, or no base-currency decomposition (ADR-0033) — is excluded from both sides of the sum and listed underexcludedwith itsreason; it is never counted as zero, which would quietly understate the category.covered_countandmember_countstate how much of the category the figure covers. A category with nothing invested reportsresult_pct: nullrather than"0", because having nothing to measure is a different claim from being flat. MCP:portfolixir.portfolios.category_results. 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. A plan may deliberately allocate less than 100%:unallocated_remainderstates that gap (1 − top_level_target_sum,"0"when the plan is full) anddrift_basisnames what the drift figures were measured against —"allocated_portion"when each target was renormalised to the allocated share first, so the unsteered remainder is not reported as phantom overweight, and"full_plan"when the plan sums to 100% and drift is the plainactual − target. The remainder is computed on read, never stored (ADR-0040). 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 theviewthe allocation is read 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. Read ergonomics (FR-37):include_positions=falseomits the per-category (andunassigned) position rows for a roll-up-only read, andmin_drift=<decimal>(an absolute drift-weight threshold, e.g.0.02) returns only the category rows whose|drift_weight|meets it — targetless categories carry no drift and are filtered out with it, and kept rows come back flat (an ancestor under the threshold is absent). The response states its own basis:positions_included, the appliedmin_driftandcategories_total(the pre-filter category count). Invalid values are a422. The allocation page carries the same filter as its drift-threshold chips (one shared predicate, so the two surfaces cannot select different categories); the chips speak percentage points, so≥ 5 ppon screen ismin_drift=0.05here.tax_context=true(#667) additionally attaches the current-year tax-free trim budgets — one entry per holder with recorded statements, each with its activity-awarestaleness— so the tax headroom is readable where the trim decision is made; the block states that it rolls up per(holder, tax_year)across institutions and is never scoped to the portfolio or view.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. Each open lot carriesbuy_price(as recorded, transaction currency) plusbuy_price_native— the security-currency basis itsunrealized_pnl_*is computed against (ADR-0033) — and the same base-currency decomposition fields as the holdings rows (base_cost,price_return_*,currency_return_*,total_return_base_*,decomposed/undecomposed_reason, against the EUR hub, since FIFO lots are matched per security across portfolios). A lot with no derivable native leg reportsnullP&L instead of a blind cross-currency figure. 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 rates from the configured provider (ECB by default) and returns{provider, status, upserted, scope}.scope=latest(the default) fetches the daily feed — today’s rates, nothing in the past.scope=history(issue #737, Sprint 9 D-1) runs the one-shot backfill of the historical ECB series (eurofxref-hist.xml) through the same upsert path: every published day at once, so a dated conversion (a realized gain, a cost or a flow the Cash-flow facets excluded and named for a missing booking-date rate) can find its rate. The rate-availability rule is unchanged — a day the ECB did not publish (a weekend, a currency it does not list) stays excluded and named; the backfill fills dates, it does not relax the “exact booking-date rate” basis. An unknownscopeis a422, a provider without a history answers422namingscope, and a provider failure returns502 Bad Gateway. The human view is the Backfill historical rates control inside the exclusion notes on/cashflow.
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 a security is dragged 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.include_positions=false(FR-37, issue #740 — the same parameter the portfolio valuation has) returns the roll-up only: totals, cash balances and cash quote without the per-position rows, and the response statespositions_included. An invalid value is a422.GET /api/v1/views/:view_id/performancereturns the view’s TTWROR and money-weighted IRR across all portfolios: exactly the deduplicated account scope the view valuation covers, so the total and the return always speak about the same accounts. Money crossing the view boundary counts as an external flow (ADR-0019); money moving between two in-scope accounts nets out.?period=(ytd|1y|3y|5y|max, defaultmax),?year=YYYY,?from=/?to=(custom range) and?series=truebehave like the portfolio performance endpoint; the shape mirrors it withview_idin place ofportfolio_id, and all financial values are Decimal strings. Unknown and malformed view ids return404; a bad period422.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.
Imports
Portfolio Performance imports (CSV/JSON v1) run through the Imports view
only: there is no import endpoint under /api/v1 and no MCP tool for it, by
design — the preview-then-apply step with its mapping decisions is an
operator action (ADR-0029). What the API and MCP consumer needs to know is
the re-import preservation guarantee, because everything an agent writes
through this API lives next to the imported history:
- Re-applying the same export is a content-hash no-op. Every transaction row that already exists is skipped as a duplicate; no security is created twice; the response of the apply reports the skipped count.
- What survives a re-import, unchanged, same ids, exact
Decimalvalues: classification assignments; every target plan version with its category and position targets and the cash target; each security’snoteandattributesincluding custom keys; the security research log (/api/v1/securities/:id/notes— the append-only entries and thethesis_statederived from them, ADR-0044); security ids andupdated_at. Pinned bytest/portfolixir/imports/reimport_preservation_test.exssince issue #664 (research log added by #748). - A mutated re-import (a rename, a recorded ISIN change resolved through an alias or an explicit mapping) keeps the same guarantee for the matched securities; only the genuinely new bookings land.
- Not covered: a booking that changed in the source. An edited
transaction hashes differently and is imported as a new row beside the old
one; the old booking is removed or corrected through
PATCH/DELETE /api/v1/transactions/:id. The guarantee is about what Portfolixir maintains around the history, not about reconciling two versions of the history itself.
A research log, a plan or an assignment therefore never “disappears at the next import”; an agent that observes otherwise has found a defect, not a documented limitation.
Contract Version
The surface says what it is, so a consumer can notice it changed (ADR-0044 §8). A tool description is read once at connect time; this read is how an agent learns that the list or the descriptions moved.
GET /api/v1/contractreturns the contract manifest:version(an integer, the newest entry’s),last_changed_at(ISO date),endpoints_total,tools_total, andentriesnewest first — each withversion,date,summary, theendpoints("VERB /api/v1/path") andtoolsthe change added,parameters(one sentence per parameter added to an existing read) and anyremoved_endpoints/removed_tools. Optionalsince=YYYY-MM-DDnarrows the entries to those dated strictly after that day and answerschanged— the?since=idea applied to the contract instead of the rows: storelast_changed_at, poll with it, and re-read the tool list and descriptions whenchangedistrue. An invalidsinceis a422.- The manifest is code-maintained (
PortfolixirWeb.Api.V1.Contract) and a meta-test ties the router’s/api/v1inventory and the MCP companion’s tool inventory to it in both directions, so a route or tool added, renamed or removed without a manifest entry fails the build. Its first entry records the Sprint 9 additions — the research log, the thesis state, theinclude_positions/min_driftparameters that reached the view scope and the position level (#740), the historical backfill scope (#737), and this read.
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.contract.get— the contract-version read (ADR-0044 §8): what the surface offers and when it last changed, pollable withsince=.portfolixir.securities.listportfolixir.securities.get— one security’s full record including itsidentifier_aliases(recorded former ISINs) and its derivedthesis_state(ADR-0044).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.notes.list— a security’s research log newest first with the derived thesis state; the description states that entries never vanish (ADR-0044).portfolixir.notes.append— the only write the log admits; a retraction withsupersedes_idis how a finding is withdrawn.portfolixir.notes.unreviewed— held positions with no entry for N days.portfolixir.notes.uncorroborated— entries whose source quality is not primary.portfolixir.notes.expiring— dated blocks expiring within N days.portfolixir.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.cashflow.realized_gains— the #724 roll-up with its stated FX basis and the excluded-and-named gap treatmentportfolixir.cashflow.external_flows— the #725 roll-up with the stated invested-capital differenceportfolixir.cashflow.costs— the #726 roll-up with the stated legs-not-gross series ruleportfolixir.holdings.by_securityportfolixir.holdings.negativeportfolixir.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.sync—scope=latest(daily feed) orscope=history(the one-shot historical backfill, issue #737).portfolixir.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.views.performanceportfolixir.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.comparisonportfolixir.tax_parameters.listportfolixir.tax_parameters.upsertportfolixir.tax_profiles.listportfolixir.tax_profiles.createportfolixir.tax_profiles.updateportfolixir.tax_profiles.deleteportfolixir.allowance_orders.listportfolixir.allowance_orders.putportfolixir.allowance_orders.deleteportfolixir.tax_snapshots.listportfolixir.tax_snapshots.getportfolixir.tax_snapshots.createportfolixir.tax_snapshots.updateportfolixir.tax_snapshots.deleteportfolixir.tax_snapshots.trim_budget
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.views.performance computes the matching cross-portfolio
TTWROR/IRR for the same account scope, with boundary-crossing money treated
as an external flow (ADR-0019).
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).