Skip to content

Portfolio Routes

The apps/v1 portfolio routes expose portfolio identity rows, detail-page composition, latest portfolio weights, and delete operations.

The routes do not create or update portfolios. Portfolio construction remains a library workflow outside this API surface. ADR 0047 adds the multi-portfolio frames, the batch lookup and the dataset catalog routes described below.

Pre-3.0 field names

Through ms-markets 3.x every response that carries a *_table_update_uid portfolio pointer also repeats it under its pre-3.0 *_data_node_uid name, which OpenAPI marks deprecated, and requests accept either name. ms-markets 4.0 removes the pre-3.0 names (ADR 0048).

Runtime Sources

  • Portfolio identity uses msm.api.portfolios.Portfolio.
  • Optional descriptive metadata uses msm_portfolios.api.portfolios.PortfolioMetadata.
  • Latest weights use msm_portfolios.time_index.portfolios.storage.PortfolioWeightsStorage.
  • Weight row asset labels use AssetSnapshotsStorage; OpenFIGI is not used for portfolio weight labels.
  • Signal weights resolve from nullable Portfolio.signal_uid, which points to SignalMetadataTable.signal_uid. The API does not recompute signal identity from the TimeIndexTableUpdater build configuration or by scanning shared signal storage.
  • Values, weights and signal-weight reads resolve each portfolio's table from the Markets catalog (PortfolioDatasetAvailabilityTable) and read it by uid through TimeIndexTableRef; a portfolio without a catalog row reads the core tables (ADR 0047, see the portfolios knowledge page).

Latest weights resolve through:

Portfolio.uid
  -> Portfolio.unique_identifier
  -> PortfolioWeightsStorage.portfolio_identifier

The optional published_index_uid field is not used for latest-weight resolution. It is publication metadata only.

List Portfolios

GET /api/v1/portfolio/?search=&calendar_uid=&limit=50&offset=0

Portfolio rows always include a non-null calendar_uid that references a persisted CalendarTable row.

Returns command-center.resource_collection@v1 and exposes GET /api/v1/portfolio/discovery/:

{
  "items": [
    {
      "uid": "portfolio-uid",
      "unique_identifier": "example-sleeve",
      "calendar_uid": "00000000-0000-0000-0000-000000000001",
      "published_index_uid": "index-uid",
      "portfolio_weights_table_update_uid": null,
      "signal_weights_table_update_uid": null,
      "signal_uid": null,
      "portfolio_table_update_uid": null,
      "backtest_table_price_column_name": "close"
    }
  ],
  "pageInfo": {
    "pageIndex": 0,
    "pageSize": 50,
    "totalItems": 1,
    "hasNextPage": false,
    "hasPreviousPage": false
  }
}

Portfolio Detail

GET /api/v1/portfolio/{uid}/

Returns the core portfolio row, optional metadata, detail-page tabs, and route links:

{
  "portfolio": {
    "uid": "portfolio-uid",
    "unique_identifier": "example-sleeve",
    "calendar_uid": "00000000-0000-0000-0000-000000000001",
    "published_index_uid": "index-uid",
    "portfolio_weights_table_update_uid": null,
    "signal_weights_table_update_uid": null,
    "signal_uid": null,
    "portfolio_table_update_uid": null,
    "backtest_table_price_column_name": "close"
  },
  "metadata": {
    "uid": "metadata-uid",
    "unique_identifier": "example-sleeve",
    "description": "Example sleeve portfolio."
  },
  "tabs": [
    {
      "key": "latest_weights",
      "label": "Latest Weights",
      "url": "/api/v1/portfolio/portfolio-uid/weights/?order=desc&limit=1&include_asset_detail=true"
    }
  ],
  "links": {
    "summary": "/api/v1/portfolio/portfolio-uid/summary/",
    "latest_weights": "/api/v1/portfolio/portfolio-uid/weights/",
    "delete": "/api/v1/portfolio/portfolio-uid/"
  }
}

Missing metadata does not make the detail route return 404. Only a missing portfolio row returns 404.

Portfolio Summary

GET /api/v1/portfolio/{uid}/summary/

Returns the shared FrontEndDetailSummary contract. The summary entity.id is the portfolio uid string.

The summary includes PortfolioTable pointer fields as inline code fields. Summary badges, inline fields, highlight fields, and stats can carry a nullable link_url field. The response does not use a separate summary links array; frontend navigation belongs on the actual row being rendered.

{
  "key": "calendar_uid",
  "label": "Calendar UID",
  "value": "calendar-uid",
  "kind": "code",
  "icon": null,
  "link_url": "/api/v1/calendar/calendar-uid/"
}

Portfolio summaries attach link_url to the calendar field, published-index badge/field, portfolio-weights node field, signal-weights node field, and portfolio-values node field when the backing UID exists. extensions keeps page-specific resolved metadata for consumers that need more than a field URL.

{
  "badges": [
    {
      "key": "published_index",
      "label": "Published Index",
      "tone": "info",
      "link_url": "/api/v1/index/index-uid/"
    }
  ],
  "inline_fields": [
    {
      "key": "calendar_uid",
      "label": "Calendar UID",
      "value": "calendar-uid",
      "kind": "code",
      "icon": null,
      "link_url": "/api/v1/calendar/calendar-uid/"
    },
    {
      "key": "portfolio_weights_table_update_uid",
      "label": "Portfolio Weights Node",
      "value": "weights-node-uid",
      "kind": "code",
      "icon": null,
      "link_url": "/api/v1/portfolio/portfolio-uid/weights/?order=desc&limit=1&include_asset_detail=true"
    },
    {
      "key": "signal_weights_table_update_uid",
      "label": "Signal Weights Node",
      "value": "signal-node-uid",
      "kind": "code",
      "icon": null,
      "link_url": "/api/v1/portfolio/portfolio-uid/signals_weights/?order=desc&limit=100"
    },
    {
      "key": "portfolio_table_update_uid",
      "label": "Portfolio Values Node",
      "value": "values-node-uid",
      "kind": "code",
      "icon": null,
      "link_url": "/api/v1/portfolio/portfolio-uid/portfolio_values/?order=desc&limit=100"
    }
  ],
  "highlight_fields": [
    {
      "key": "calendar",
      "label": "Calendar",
      "value": "Example Calendar",
      "kind": "text",
      "icon": "calendar",
      "link_url": "/api/v1/calendar/calendar-uid/"
    }
  ],
  "extensions": {
    "calendar": {
      "uid": "calendar-uid",
      "label": "Example Calendar",
      "display_name": "Example Calendar",
      "unique_identifier": "EXAMPLE_CALENDAR",
      "detail_url": "/api/v1/calendar/calendar-uid/",
      "dates_url": "/api/v1/calendar/calendar-uid/dates/",
      "sessions_url": "/api/v1/calendar/calendar-uid/sessions/",
      "events_url": "/api/v1/calendar/calendar-uid/events/"
    },
    "nodes": {
      "portfolio_weights": {
        "uid": "weights-node-uid",
        "label": "Portfolio weights",
        "url": "/api/v1/portfolio/portfolio-uid/weights/?order=desc&limit=1&include_asset_detail=true"
      },
      "signal_weights": {
        "uid": "signal-node-uid",
        "signal_uid": "canonical-signal-uid",
        "label": "Signal weights",
        "url": "/api/v1/portfolio/portfolio-uid/signals_weights/?order=desc&limit=100"
      },
      "portfolio_values": {
        "uid": "values-node-uid",
        "label": "Portfolio values",
        "url": "/api/v1/portfolio/portfolio-uid/portfolio_values/?order=desc&limit=100"
      }
    },
    "pointers": {
      "portfolio_weights_table_update_uid": "weights-node-uid",
      "signal_weights_table_update_uid": "signal-node-uid",
      "signal_uid": "canonical-signal-uid",
      "portfolio_table_update_uid": "values-node-uid"
    }
  }
}

signal_uid is nullable for portfolios created before a signal workflow has run. When present, it is the canonical SignalMetadataTable.signal_uid used by GET /api/v1/portfolio/{uid}/signals_weights/. The signal-weights endpoint does not infer the signal by scanning shared signal-weight storage.

Batch Lookup By Uid

GET /api/v1/portfolio/by-uids/?uid__in=<uid>&uid__in=<uid>

Returns the matching Portfolio rows in request order inside a ResourceCollection. uid__in is repeated or comma-separated, at most 500; unknown uids are omitted. A bounded lookup, not a browsable collection: no limit, offset, search or discovery route. include=<sidecar MetaTable uid> attaches provider sidecar rows under includes[<uid>], as described for accounts.

Values Frame For Several Portfolios

GET /api/v1/portfolio/values/?portfolio_uid__in=<uid>&portfolio_uid__in=<uid>&start_date=&end_date=&order=desc&limit=500&offset=0

Returns one core.tabular_frame@v1 with columns time_index, portfolio_identifier, portfolio_uid, close, return, calculated_close and close_time for every requested portfolio. The portfolios are grouped by the PortfoliosTS instance the catalog records for them; each instance is read once, so comparing portfolios computed by two different providers is one call whose two sources live in two namespaces. order sorts by time_index across all portfolios and limit (1 to 5000) bounds the merged result; offset skips that many rows of the merged order, so a longer window is read in pages (MarketsClient.portfolio_values pages for a larger limit). Every uid must be a portfolio (404 otherwise); source.context names the requested uids and the window.

Weights Frame For Several Portfolios

GET /api/v1/portfolio/weights/?portfolio_uid__in=<uid>&weights_date=&as_of=true

Returns one core.tabular_frame@v1 with one weight snapshot per portfolio: the latest snapshot, the latest at or before weights_date with as_of=true, or the exact weights_date snapshot with as_of=false. Columns are time_index, portfolio_identifier, portfolio_uid, asset_identifier, weight, weight_before, price_current, price_before, volume_current and volume_before. Portfolios are grouped by their PortfolioWeightsTS instance as for values.

Dataset Catalog

POST /api/v1/portfolio/{uid}/datasets/
GET  /api/v1/portfolio/{uid}/datasets/
POST /api/v1/portfolio/datasets/reconcile/

A provider that computes a portfolio in its own namespace registers, per portfolio, the PortfoliosTS, PortfolioWeightsTS and SignalWeightsTS instances that hold its rows, and reconciles after each run (ADR 0047).

  • POST /api/v1/portfolio/{uid}/datasets/ takes values_meta_table_uid (required), weights_meta_table_uid, signal_meta_table_uid and reconcile (default true). Each table must be a TimeIndexMetaTable visible to the Markets workload that exposes the contract columns and index names; extra provider columns are allowed. The row is upserted, the signal's SignalMetadata.meta_table_uid is bound when the portfolio has a signal_uid, and the values instance is reconciled unless reconcile is false, in which case the row is unavailable with error code not_reconciled. Returns the PortfolioDatasetState with 201; 404 for an unknown portfolio or a table that is not visible, 400 for a table that does not satisfy its contract.
  • GET /api/v1/portfolio/{uid}/datasets/ returns the catalog row, or null when the portfolio exists and reads the core tables; 404 for an unknown portfolio.
  • POST /api/v1/portfolio/datasets/reconcile/ takes portfolio_uids and portfolio_identifiers (at least one selector, at most 500 in all) and returns a PortfolioDatasetReconciliationResult: the reconciled states, one aggregate per instance however many portfolios it holds, plus skipped_portfolio_uids for portfolios without a catalog row. 404 names selectors that are not portfolios.

PortfolioDatasetState carries portfolio_uid, portfolio_identifier, the three table uids, population_state (populated, compatible_empty or unavailable), row_count, earliest_time_index, latest_time_index, reconciled_at and error. Registration and reconciliation record the acting identity.

Latest Portfolio Weights

GET /api/v1/portfolio/{uid}/weights/?order=desc&limit=1&include_asset_detail=true

Returns one PortfolioWeightsSnapshotResponse:

{
  "portfolio_uid": "portfolio-uid",
  "portfolio_unique_identifier": "example-sleeve",
  "published_index_uid": "index-uid",
  "portfolio_identifier": "example-sleeve",
  "weights_date": "2026-06-07T10:30:00Z",
  "resolution_warning": null,
  "weights": [
    {
      "time_index": "2026-06-07T10:30:00Z",
      "portfolio_identifier": "example-sleeve",
      "asset_identifier": "example-asset-btc",
      "weight": "0.600000000000000000",
      "weight_before": "0.550000000000000000",
      "price_current": "100.0",
      "price_before": "95.0",
      "volume_current": null,
      "volume_before": null,
      "asset": {
        "uid": "asset-uid",
        "unique_identifier": "example-asset-btc",
        "current_snapshot": {
          "name": "Bitcoin",
          "ticker": "BTC"
        }
      }
    }
  ]
}

Portfolio Weights By Date

GET /api/v1/portfolio/{uid}/weights/?weights_date=2026-06-07T10:30:00Z&include_asset_detail=true

weights_date selects the exact PortfolioWeightsStorage.time_index snapshot and takes precedence over order.

If the portfolio exists but no rows exist for the requested timestamp, the response is 200 with an empty weights list:

{
  "portfolio_uid": "portfolio-uid",
  "portfolio_unique_identifier": "example-sleeve",
  "published_index_uid": "index-uid",
  "portfolio_identifier": "example-sleeve",
  "weights_date": null,
  "resolution_warning": null,
  "weights": []
}

Delete Portfolio

DELETE /api/v1/portfolio/{uid}/

Deletes the portfolio identity row plus historical PortfolioWeightsStorage and PortfoliosStorage rows resolved through:

Portfolio.uid
  -> Portfolio.unique_identifier
  -> PortfolioWeightsStorage.portfolio_identifier
  -> PortfoliosStorage.portfolio_identifier

Returns:

{
  "detail": "Portfolio deleted.",
  "deleted_count": 1,
  "deleted_weights_count": 4,
  "deleted_values_count": 4,
  "retained_datasets": []
}

If protected rows, such as virtual funds or account target-position history, reference the portfolio, the route returns 409 and does not delete the portfolio.

When the Markets catalog (PortfolioDatasetAvailabilityTable, ADR 0047) places the portfolio's values or weights in a contract instance another application owns, Markets does not delete that table's rows: only the owning application writes or deletes them. The route still deletes the portfolio row, its catalog row and any rows left in the core tables, and lists each table it left in place:

{
  "detail": "Portfolio deleted. Rows in 1 table another application owns were left in place; see retained_datasets.",
  "deleted_count": 1,
  "deleted_weights_count": 0,
  "deleted_values_count": 0,
  "retained_datasets": [
    {
      "portfolio_uid": "portfolio-uid",
      "portfolio_identifier": "example-sleeve",
      "dataset": "values",
      "meta_table_uid": "provider-values-table-uid"
    }
  ]
}

The deleted counts cover the core tables only. A catalog row that names the core table itself counts as core. POST /api/v1/portfolio/bulk-delete/preflight/ reports each retained table as a warning before the delete, and the bulk and cascade deletes return the same retained_datasets list.

Storage cleanup uses each storage table's TimeIndexMetaTable.delete_after_date(None, dimension_filters=...) API, scoped by Portfolio.unique_identifier. The delete route does not delete PortfolioMetadataTable rows.

Delete Portfolio Weights

DELETE /api/v1/portfolio/{uid}/weights/?weights_date=2026-06-07T10:30:00Z

Deletes PortfolioWeightsStorage rows for the portfolio identifier through the storage table's TimeIndexMetaTable.delete_after_date(...) API. If weights_date is omitted, all weight rows for that portfolio identifier are deleted by calling delete_after_date(None, dimension_filters=...) with the portfolio scope. If weights_date is provided, rows at or after that timestamp are deleted.

Returns:

{
  "detail": "Portfolio weights deleted.",
  "portfolio_uid": "portfolio-uid",
  "portfolio_identifier": "example-sleeve",
  "weights_date": "2026-06-07T10:30:00Z",
  "deleted_count": 4
}

Weight deletion itself is scoped by Portfolio.unique_identifier; the API never calls delete_after_date(None) without that dimension scope. Protected portfolio references only matter for deleting the portfolio identity row.

When the catalog places the portfolio's weights in a PortfolioWeightsTS instance another application owns, the route returns 409, deletes nothing and names the table. Delete those rows through the application that owns the table.

Bulk Delete Portfolios

POST /api/v1/portfolio/bulk-delete/

Request:

{
  "uids": ["portfolio-uid-1", "portfolio-uid-2"]
}

Response:

{
  "detail": "Deleted 1 portfolio; 1 portfolio could not be deleted.",
  "deleted_count": 1,
  "deleted_weights_count": 4,
  "failed": [
    {
      "uid": "protected-portfolio-uid",
      "reason": "Portfolio is referenced by target positions."
    }
  ]
}