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. Optional query params:query,sort,direction, holding_status (all,held, ornot_held), 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.GET /api/v1/securities/:idreturns one security.PATCH /api/v1/securities/:idupdates a security with asecurityobject.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.
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.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
GET /api/v1/portfolioslists portfolios.POST /api/v1/portfolioscreates a portfolio with aportfolioobject.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.GET /api/v1/cash_accounts/:idreturns one cash account.PATCH /api/v1/cash_accounts/:idupdates a cash account (name,currency_code,notes);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.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 manual buy or sell transaction with atransactionobject.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.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. Unknown portfolios return404 Not Found. Optional filters:security_id,securities_account_id.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 position with no quote or no exchange-rate path to the base currency is returned withvalued: falseandnullmarket 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),total_cashis the base-currency sum of the valued cash accounts, andtotal_with_cashistotal_value + total_cash.cash_quoteis the cash share of the whole portfolio (total_cash / total_with_cash,0when there is nothing to value yet). An account whose currency has no rate path to the base is reportedvalued: falseand excluded fromtotal_cash, mirroring how unpriceable positions are handled.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_valueandnet_external_flowsas Decimal strings. Unknown portfolios return404 Not Found.GET /api/v1/portfolios/:portfolio_id/targetslists a portfolio’s stored target weights (the SOLL side of the allocation). Optionalclassification_idscopes the list to one tree. 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"}]}. 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.DELETE /api/v1/portfolios/:portfolio_id/targets/:category_idremoves a portfolio’s target weight for one category and returns{deleted}(the number of rows removed).GET /api/v1/portfolios/:portfolio_id/allocationreturns the SOLL/IST breakdown for one classification (requiredclassification_idquery param; a missing one returns422 Unprocessable Entity). For each category it reportsmarket_value,actual_weight(its share oftotal_value),target_weight,drift_weight(target_weight - actual_weight), anddrift_value(the drift restated in the base currency). Securities held but not assigned in the tree are summed intounassigned. Weights mirror the valuation: shares of the valued positions’ total, cash excluded. Unknown portfolios or classifications return404 Not Found.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. 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"
}
}
MCP Tools
The MCP companion exposes the same local contract as tool calls. Decimal inputs in MCP schemas are strings.
portfolixir.securities.listportfolixir.securities.createportfolixir.securities.updateportfolixir.securities.deleteportfolixir.securities.search_onlineportfolixir.quotes.syncportfolixir.quotes.listportfolixir.quotes.upsertportfolixir.portfolios.listportfolixir.portfolios.createportfolixir.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.holdings.listportfolixir.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.portfolios.allocationportfolixir.portfolios.performance