Read portfolios
Performance returns chain observed portfolio values after taking out external
deposits and withdrawals. Each flow is placed at the preceding reading, an
approximation to valuation at the time of the flow. The first reading starts
at zero. Daily return runs from the last reading at or before midnight UTC;
a baseline older than the preceding UTC day makes it
null (not known).
Unknown returns stay null; readers must not fill them with zero, carry an
older day’s return, or infer a gain from a change in portfolio value. Owner
performance points include daily_return_reason and cumulative_return_reason.
These fields are null for measured returns and explain unknown ones. Status and
detail use dailyReturnReason and cumulativeReturnReason. A metric without
enough measured returns is null, with an insufficient-data message. Public
strategy summaries use totalReturn: null with totalReturnReason; their history
points use a null value with reason.
Cumulative and daily fields use ratios: 0.01 means 1%. Trades and commissions
remain in returns. Paper portfolios do not record dividends.
Holdings and trades carry their own currency; don’t guess it from the ticker or exchange. Many amounts are decimal strings, but some are numbers, and a portfolio’s total_value is in US dollars. Check each field’s schema.
For a live broker account with cash in several currencies, live_broker_state
reports available, reserved, and total cash for the whole account in the account
currency, beside their dollar values. For example, USD 150 available and EUR 200
available at $1.20 per euro give 390 available in a USD account. Older snapshots
get these whole-account native totals on their next sync.
A short holding has a negative quantity and current_value_usd, and total_value is cash plus the signed value of every holding. unrealized_pnl_pct is the holding’s P&L as a share of its cost, so a short whose price falls shows a gain. In a paper portfolio, a holding moves to exited_holdings once the absolute value of its quantity is below 0.0001, so a long or short of 0.00009 shares has exited. In a live portfolio, it moves only when its quantity is zero.
For a portfolio valued from its holdings, an open holding without a usable current price makes the current total and all weights unknown. A live mirror keeps the non-null total from its latest broker snapshot even when a local holding lacks a price. A price placeholder, a holding imported without a price that the starting capital leaves out, is not funded, so it stays out of the total, the weights and the P&L totals. When the current total is unknown, the list returns total_value: null; detail returns metrics.totalValue: null. Both include valuation_unavailable_reason and valuationError with a code and message. Detail does not use older metrics in place of an unknown current total. Paper status and summaries return current: null, empty holdings, and valuationError with code PORTFOLIO_VALUE_UNKNOWN and a reason. Historical rows keep their own dates and values.
Holdings history records the book each time it changes: on import, on each
fill, when cash is reserved or released, on broker sync and when the portfolio
is liquidated. It also records the book every 15 minutes during market hours. A
change and its record are saved together, so a past date reads the book as it
stood then.
Holdings history keeps every open line in an unpriced snapshot. All weights are
null, and valuation_unavailable_reason names the lines that lack a usable price,
currency or exchange rate. Those lines also have null prices and values. A
price placeholder is the exception: it stays in each record with
a null value, weight and price and its own reason, and the other lines keep
their values and weights.
A live mirror’s broker total does not prove that each local holding has a usable
price. Detail and portfolio tools keep that total and name any missing local
valuation in valuation_unavailable_reason. Priced holdings keep their values;
unpriced holdings have current_value_usd: null. Portfolio status includes this
reason even when it does not include holding rows. Public strategy holdings keep
priced weights against the broker total and return a null weight with the price
reason for each unpriced line. Trading status and summaries require all local
prices, so they return the whole-book refusal above even with a known broker total.
If any open holding lacks a current value because its price is missing or not above zero, its currency is missing, or its exchange rate is missing or invalid, its unrealized_pnl and unrealized_pnl_pct are null. Detail also returns null for pnl_metrics.total_unrealized_pnl, total_pnl, and total_pnl_pct, with the same valuation reason. Recorded realized gains and losses stay available. A dollar holding needs an exchange rate of exactly 1; a holding in another currency needs a positive rate.
A known net asset value of zero or less also has no current valuation or weights. The list returns total_value: null; detail returns metrics: null. Both include valuationError with code PORTFOLIO_VALUE_NOT_POSITIVE and a message giving the value, plus the same message in valuation_unavailable_reason. They do not substitute older stored metrics.
Transitions refuse zero net asset value while any non-cash position has a nonzero
quantity, including offsetting longs and shorts. With no open positions, a
zero-value transition queues no trades and retires open intent.
Paper status and summaries apply this check even when the portfolio has no open
holdings. An empty book worth $0 returns current: null, empty holdings, and
PORTFOLIO_VALUE_NOT_POSITIVE, with or without older stored metrics.
Position returns need an entry cost. When that cost is zero, unrealized_pnl_pct (or pnl_pct in paper status and summaries) is null, because the return is not known. The dollar gain or loss still uses the signed quantity.
For a portfolio valued from its holdings, an open holding without a usable current price or exchange rate makes its net asset value and weights unknown. Factor cards use a separate rule: that line counts as zero, gets no weight, and appears in missing_price_symbols:<count>:<SYMBOLS>. The other factor-card weights use the valued lines, including cash. For example, $100 cash, one AAPL share at $100 and an unpriced MSFT short give AAPL and cash weights of 0.5 each on the factor card. A missing exchange rate for a line in another currency has the same effect. Forecast backtests leave all weights unavailable and name the unvalued lines; they never use entry cost as a current price. A price placeholder isn’t funded: factor cards and backtests leave it out of the figures and name it in missing_price_symbols, and a backtest keeps the other weights.
Zero and negative prices are unusable. A dollar cash unit must have a price of exactly one; another currency’s cash unit must match its dollar rate within 0.000001. Cash with an unusable price follows the same missing-price rule as any other holding.
A portfolio created by import includes portfolio_value: the amount, currency, date, basis and source you accepted at import. It is the value you confirmed then, not a live price. Older portfolios may return null.
When a portfolio response includes factorScorecard, its coverage reports
longWeight, shortWeight, grossWeight and netWeight as decimal strings.
They exclude cash: long is the sum of positive weights, short the sum of negative
weights, gross is long minus short, and net is long plus short. A neutral book
with long "1.00000000" and short "-1.00000000" has gross "2.00000000" and
net "0.00000000". Each field is null when the stored scorecard lacks it.
A factor card holds two dates. factorScorecard.asOf is the model date: prices
and the risk model end on it. The worker calculated the card later, from the
holdings, fund books, peers and benchmark constituents on file at that time, so
a book uploaded after the model date can be in it. A request with
factor_as_of returns the newest card whose model date is on or before that
date, and its book can be newer than the date you asked for. The response gives
the calculation time in scorecard_input_dates.holdings_fund_books_peers_and_benchmark_read_by
and says what each date covers in scorecard_input_dates.meaning. That time is
null for the current card, which stores none. A response with no card has no
scorecard_input_dates.
After import or a holdings change, the queued factor refresh adds missing
names to the active model before it builds the scorecard, including names in
held funds, the benchmark and the peer portfolio. It fetches a held or peer
fund’s book when none is stored. Scorable names need no further action or
daily model run. A name with too little new data stays covered while its
older model rows remain fresh, then becomes uncovered. If adding names
fails, the scorecard uses the covered names and carries a warning. An expired
model or stale stored fund book can still delay the scorecard.
Live model inputs expire after 48 weekday hours, counted in UTC. Saturdays and
Sundays add no age; holidays still count. A Friday model stays fresh all Monday
and expires just after Tuesday 00:00 UTC if no newer inputs replace it. Stored
fund books expire 48 calendar hours after fetch; weekend runs renew them too.
Historical model reads keep their 45-calendar-day limit.
A held fund’s derivative counts at its notional, the size of the position it
stands for, and only for risk. A contract for difference on a share the same
fund holds adds its notional to that share. A small future the fund offsets
itself rides on the fund’s own returns: at most half a percent of the fund, on
the same side as its other holdings. The scorecard names every other
derivative with its reason, such as “a derivative’s underlying is not mapped
to a listing”, counts its share as uncovered and prices the rest of the fund.
Where a fund’s rows add to one with the derivatives inside, each line is
restated over the fund’s net assets, its rows other than derivatives. A
fund’s contra rows only offset its derivatives and carry no exposure.
A fund whose weights are shares of a total its rows cannot size is not priced.
The scorecard names the fund with “a derivative’s exposure is not known”,
counts its weight as uncovered and measures the rest of the book. A
comparison treats such a fund in a benchmark or peer book the same way.
A name the model covers but whose prices the day’s read could not get keeps
its modelled risk when nothing else in the book is priced from returns.
Otherwise the scorecard names it with the read’s cause and counts its weight
as uncovered, because its link to the returns-priced holdings is not known.
When a holding priced from its own returns needs another holding’s link to it,
and a series is too short or the two share too few dates, the scorecard names
the holding whose series causes the gap, with that cause, and counts its
weight as uncovered; the rest of the book is measured. A holding priced from
its own returns with no series at all is named the same way.
A modelled name whose link a returns-priced holding needs is set aside the
same way when it has no series or too few shared dates. Held directly, it is
named with that cause and its weight counts as uncovered. Held through a fund,
it is priced with the rest of the fund from the fund’s own returns, and the
scorecard names it; if the fund has no usable series either, the fund is
named instead. The fund’s other modelled holdings keep their risk, and only the
part of the fund they do not cover counts as uncovered.
An unread name stays outside the new additions without a missing-data reason;
other names can still be added, and the scorecard carries a warning.
The model trusts prices as they arrive and leaves no name out for the size of a
move. A bad price that comes back costs that day’s price, not the name’s rows.
An earlier model run recorded the reason prices_not_usable for a name whose
price rose a hundredfold or more in one day: “Its price rose more than a
hundredfold in one day, so its risk is not known.” The model keeps that answer
for the dates it covers, and older model rows for such a name are not carried
forward. When a held fund holds such a name, the scorecard names it under the
fund with its share of the fund, and the fund’s own returns measure that
weight.
Closed residues below 0.0001 shares on either sign do not block performance
refreshes or affect the inferred benchmark. This holds even when the residue
has no current price or currency evidence. An open short still needs usable
valuation evidence and counts when choosing the benchmark.
Stock splits preserve a paper position’s direction. A two-for-one split changes
a short of 100 shares at a $100 entry cost per share to a short of 200 shares
at $50 per share. The same adjustment applies when a missed split is backfilled.
Live holdings follow the broker’s split records.
Create a portfolio
POST /portfolios takes name and allocated_amount, plus optional description and strategy_id. Allocations must be between 1 and 1 billion US dollars, or the equivalent. It returns 202.
To link a strategy later, use PUT /portfolios/{id}/link; DELETE on the same path unlinks it. To turn automatic execution on or off for a paper portfolio, use PATCH /portfolios/{id}/auto-execute. Live portfolios ignore this setting: their trades always wait for your approval.
Linking to a live broker portfolio returns 400 SHORT_TRADE_UNSUPPORTED if the strategy’s latest decision target has any negative non-cash weight or its current book holds an open short. An open short has a negative quantity with an absolute value of at least 0.0001. The response says: “Live short sales are not supported. A strategy whose book or latest target holds a short cannot link to a broker.” The strategy remains unassigned.
Import holdings
Importing turns a list of holdings into a new paper portfolio in two steps: A draft accepts up to 3,000 holding rows. Both steps use this limit, including rows left out of the import.- Validate.
POST /portfolios/import/validatematches each holding to an instrument, prices it and returns a draft with anapproval_token. - Commit. After a person has reviewed the draft,
POST /portfolios/importsaves it. Send thenormalized_draftfrom step 1 withapproved_atset to when it was approved, theapproval_token, and the output ID with:approvedadded to the end.
200 with ok: false and a list of issues. If the token expires, prices move, or you change the draft, validate again.
An invalid row does not stop the review from pricing valid rows. A failed validation can return holding_prices and row_warnings beside row_errors. When valuation_scope is valid_rows, book_summary covers only those valid rows. It is not the full book’s exposure. No approval token is issued while errors remain; fix them and validate again before importing.
In the draft, check valuation_coverage. When it is partial, some holdings had no current USD price; they are listed in unvalued_holding_row_ids. Without a stated portfolio_value they are also left out of allocated_amount_usd; a stated value already counts them.
Check and import responses also return row_warnings for holdings with missing valuation inputs. Each issue has severity: "warning" and the holding’s row_id. Missing-price warnings use field: "price"; a missing exchange rate uses field: "currency". A warning does not block confirmation. The codes mean:
Pricing adds no missing-close warning when a holding already has a validated usable unit value. Uploaded
unit_value fields remain ignored; they do not replace provider prices. These warnings leave the existing unknown book value and weight rules below in place.
When the upload states no portfolio_value, a holding imported without a price becomes a price placeholder: the starting capital leaves it out, so it is not funded, and the price refresh never prices it. A stated portfolio_value already counts every holding, so a US dollar holding imported without a price stays funded, and the next price refresh prices it. A holding in another currency becomes a placeholder either way, because it has no rate to US dollars without a price.
A paper BUY or SELL_SHORT fill replaces a placeholder’s quantity, even when the imported quantity has the opposite sign, and clears the placeholder flag. A SELL or BUY_TO_COVER cannot close that row because it has no funded position.
A short is a holding with a negative quantity. A row can instead keep a positive quantity and set side to short or S; validation makes the quantity negative. Cash can be negative, for borrowed cash. When every holding has a current value, cash plus holdings at current prices must be above zero. book_summary in the validate response gives the long, short, gross and net value in US dollars and as a share of net asset value; cash counts in net asset value but not in exposure.
If any included holding has no current US dollar value, book_summary.nav and all four weights are null. The affected leg’s value is null; gross_value and net_value are also null. A leg with no holdings has value "0". Show valuation_unavailable_reason with these unknown values. Keep valuation_coverage and the unvalued row list; allocated_amount_usd is the stated portfolio_value, or without one the partial allocated amount; it does not establish net asset value. Validation refuses a known net asset value at or below zero. When some holdings have no current value, it refuses a book whose cash plus priced holdings is below zero, because that sum becomes the starting capital.
Both validation and import check the current priced total even when portfolio_value declares a positive amount for an earlier date.
BUY, SELL, SELL_SHORT or BUY_TO_COVER. The planner splits a change across zero: selling 150 shares from a holding of 100 queues SELL 100, then SELL_SHORT 50. Buying 80 against a short of 50 queues BUY_TO_COVER 50, then BUY 30. Once the whole change meets the minimum trade amount, the planner keeps the closing leg even below that minimum. It drops an opening leg below the minimum, so the trade only closes the old position.
A short sale adds its proceeds to cash, less fees. The planner funds covers, including fees, from current cash plus net long-sale proceeds after fees in the same basket. Starting cash may be negative if those sales repay the debt and fund the covers. Short-sale proceeds cannot fund a cover in that basket. For opening buys, current cash less any cover cost not funded by net long sales must exceed buy notional times 1.10. Each fill debits its fees.
Fills run in this order: SELL, BUY_TO_COVER, SELL_SHORT, then BUY. Closing legs keep their share quantity through price changes; opening legs use their target dollar amount at the fill price. A closing fill cannot exceed the held quantity. A cash refusal fails only that leg while the rest continue, so a basket can settle in part. A cover that lacks cash stays queued while a long sale in its basket is still queued, such as one on a closed exchange, and fails once none is. Live broker trades and mirror sync still refuse shorts.
Live rebalance validation and submission return SHORT_TRADE_UNSUPPORTED if any non-cash target weight is negative or the portfolio holds an open short (negative quantity with an absolute value of at least 0.0001). This check runs before the minimum trade amount and no-trade handling, so an unchanged short or a target worth less than $1 still refuses the rebalance. The refusal includes: “Itoflow cannot yet open, add to or cover a short.”
Trade history returns those four sides and SPLIT for a corporate action adjustment. SPLIT is a history record, not an order side.
Compare with a benchmark
PUT /portfolios/{id}/benchmark needs both peer_portfolio_id and benchmark_index_id every time; send null to clear one. A peer must be another of your active portfolios. The comparison updates in the background, so new results appear a little later.
Measure risk through MCP
Through MCP, your assistant calls the public risk run operations to measure a saved portfolio or a what-if book against a benchmark and up to 10 peer funds. The result covers risk, exposures, attribution, factor profit and loss, bets, sizing, scenarios and valuation, the same breakdown chat shows. A fund held beside other holdings is measured through its holdings at the line’s signed weight. Cash the fund holds counts as the book’s cash. Factor risk excludes shared-specific factors. Specific risk includes each name’s own variance and shared-specific factors: residual moves several names share. Those shared moves can link separate books, so their specific variances need not add. Factor and specific variances add to total variance; the squares of their reported volatilities add to the square of total volatility. An unresolved fund holding stays outside the model at its signed weight and does not change a direct holding of the same name. If no usable fund book is available, the run measures the other names and gives the fund line’s reason. A failed request stays distinct from an absent or expired book. A held fund’s book passes the same weight check as a fund measured on its own: when its stated weights differ from what its share counts and prices are worth, the run rebuilds them from those values, or refuses the book and names the refusal as the fund line’s reason. A fund inside a held fund is looked through the same way, at the product of the signed weights, down to three funds. A fund that holds itself, or sits below the third, stays one gap at its weight with the reason codeno_fund_book.
include_forecast_quality defaults to false, as in the example. Set it to
true to request the historical forecast-quality check and its factor snapshots.
Current risk, coverage checks, attribution, risk change and scenarios remain
included, with the history each needs. The trust section says “Forecast quality
not requested.” and appears in missing_by_design, not missing_sections.
This omission alone does not make the run partial. Monitor runs retain the full
report. Use a new request_key when changing this option; omitted and false
mean the same request.
analyses chooses what the run computes: ["risk"], ["performance"] or
both, the default. Risk covers risk, risk change, bets, sizing, scenarios,
drivers, trust, exposures, liquidity and valuation. Performance covers
performance, attribution and factor P&L. A run of one analysis skips the
factor and price reads its sections do not use, so a performance-only run
skips covariance reads. Every run reads the same holdings books, so a section
gives the same figures as in a run of both. Only risk reads the current model,
so without it unmodelled_count is null and coverage is absent. The
other sections appear in missing_by_design with the reason not_requested,
and they alone do not make the run partial. Compliance and changes come only
with both. Omitting analyses and listing both mean the same request; listing
one is a new request and needs a new request_key.
When performance is requested, a saved portfolio reads its stored return series,
unless the run looks through it as one listed fund; that path uses the fund’s
prices. Empty or unknown stored readings put performance in missing_sections,
which makes the run partial. A risk-only request still lists unrequested
performance in missing_by_design. An unlisted what-if has no stored return
series, so its performance remains missing by design.
A what-if holds 1 to 3,000 lines. A saved portfolio may hold up to 3,000 securities. A run refuses more with invalid_risk_engine_request. Weights are fractions of the book’s net assets; a short is below zero. With a CASH line, weights add to 1. Without one, the rest is cash; a book that borrows lists CASH below zero. At most three runs may run at once. Each newly launched background job has a 60-minute budget, including coverage and output. Initial validation, startup and stopping a timed-out job can add time before its terminal status is available.
For figures at past dates, over a range of dates, or for several books side by side, your assistant starts a risk measure instead. One measure covers a single date or a range of up to 60, month ends by default; a year of month ends on a few books takes a minute or two. It reads a saved portfolio at its newest snapshot by the end of each date, unless you name another time, and funds and the benchmark through their stored holdings on or before that date. A measure counts toward the three runs while it runs, and its answer stays for 24 hours after it ends. It does not add names to the model; a risk run on the book does. For a worked example, see Measure a book’s factor risk.
Before measuring, the engine asks for names missing from the selected model publication, including holdings inside fund lines and the benchmark and peers. It checks every five seconds for up to ten minutes. A tool run leaves five minutes of its remaining deadline for measurement, so a retry can wait less. Submission, status reads and polling all share this wait budget. Names already covered need no wait. The run measures available coverage when a request fails or times out; fresh older rows can still cover a name under the model’s freshness rule. A name the model refused to add at that publication stays outside current measurements even when fresh older rows exist. Its signed weight and refusal reason appear in each book that holds it; those older rows still serve history.
A run whose books hold names the model has not scored first waits up to 10 minutes for coverage within the same job budget. While it waits, api.getRiskRun returns running with a progress object: { stage: "waiting_for_coverage", names_asked, waited_seconds, expected_seconds, at_most_seconds }, then { stage: "measuring" }. expected_seconds is about 2 seconds for each name asked, never more than at_most_seconds, the most this run waits before it measures what it has. measuring means the run is computing figures from the coverage it has. A retry shows plain running until it reports fresh progress. A run that asks for no names goes straight to measuring. When a run that asked for names ends, its risk section says so in one sentence, for example “Asked the factor model to score 233 names and waited 7 min 0 s.” The sentence counts requests and wait time across all attempts. A run that asked for none adds no sentence. Monitor runs add the same sentence.
The result’s coverage list holds every gap in the subject, benchmark and each peer, with book, symbol, signed weight, reason_code and reason. unmodelled remains a view of the subject’s 20 largest gaps by absolute weight, and unmodelled_count counts all subject gaps. The receipt groups the full list by book and reason, with each group’s share of the book. Reasons include the model’s refusal to add a name, not_added_in_time, coverage_failed, no_fund_book, no_equity_listing, cash_currency, not_listed and invalid_inputs. A failed provider read is coverage_failed, not a model refusal. A held fund with no usable holdings book keeps one gap at its signed outer weight and gives the fetch’s reason. A row with no confirmed equity listing stays a separate signed gap even when its label matches a covered holding in another book.
A dropped peer’s reason states the cause. For example: “Its holdings book is dated 2026-09-17, after the report date 2026-09-16.” A provider answer with only sector or region totals says so; it does not count as a holdings book.
Current fund holdings use the book’s own date, even when the factor publication
is older. A short fund line therefore measures the same risk as its holdings
written out at that signed weight. A source row with no confirmed listing has the
reason code
no_equity_listing; the run does not ask the worker to score its
bare ticker. Each
reason explains the gap in plain words and names the subject as the portfolio
or fund. The book and reason_code fields retain their machine identifiers.
A peer whose holdings book cannot be read after fetching is dropped from comparisons and named in dropped_peers with the fetch’s reason. The remaining peers still take part. A benchmark with no required book keeps the existing refusal rule. Monitor runs use this same measurement and receipt.
Keep one request_key for each new run. The same key and request join that run for 24 hours after it ends. Another request with that key returns 409. Finished results and full artifacts stay available for 24 hours even after the sandbox sleeps. Successful runs include a top-level analytics reference to the complete schema-version-2 document. Read it with GET /risk/runs/{run_id}/artifact; use gzip ranges and verify both hashes for a large copy. All three groups of exposure sides, sizing modelled-book scope and valuation comparisons remain present. A publication failure fails the run. See the risk-run transport contract. Cancellation, timeout and container loss leave a known terminal outcome.
A failed run returns 200 and says why in failure_code and reason. engine_refused means change the request. rate_limited and provider_unavailable mean the price provider failed: start the run again later, with a new request_key.
Risk runs need to be enabled for your account. When they aren’t, all three operations return 403 with feature_not_enabled. Through api.*, a refused call returns { ok: false, status, body }. If the runtime does not answer, the operation returns 503; retry with the same key.
Approve trades
GET /portfolios/approvals/pending lists everything waiting for your approval. For one portfolio:
GET /portfolios/{id}/pending-tradesreturns the proposed trades.- Show them to the person who will approve them.
POST /portfolios/{id}/pending-trades/approveapproves them.GET /portfolios/{id}/trades/{tradeId}/attemptsshows what happened at the broker.
DELETE /portfolios/{id}/pending-trades.
Live portfolios. Approve the exact proposal you reviewed. Copy metadata.approvalProposal.id and metadata.approvalProposal.hash from the pending-trades response and send { approvalProposal: { id, hash } } in the approve request. If the proposal has changed since you read it, the approval fails. Read it again and get a fresh approval. Paper portfolios accept { decisionId? }.
Live portfolios always need this approval; automatic execution can only be turned on for paper portfolios.
An approval is not a fill. Check the attempts and the portfolio afterward.
Live portfolio operations
These endpoints act on a live portfolio and its broker account:
The liquidation response includes
total_value_usd and
valuation_unavailable_reason. If any open holding lacks a usable price,
currency or exchange rate, the total is null and the reason explains why.
The response never gives a partial sum. A queued task still has to pass trade
sizing; follow its status to check whether liquidation finishes.
liquidate and DELETE are refused while trades are pending or executing (TRADES_IN_PROGRESS). DELETE can’t be undone. Some of these return a task ID; follow it with GET /transition/{taskId} until it finishes.
See the portfolio endpoint reference for every field.