From 7bb71ae4801cd35bcfaac7af04b3d70fdfa06907 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Sun, 26 Apr 2026 01:35:48 -0500 Subject: [PATCH] feat: Implement portfolio block management models and operations - Added models for portfolio block operations including: - PortfolioBlockPortfolioPatch - PortfolioBlockPositionAdd - PortfolioBlockPositionDispose - PortfolioBlockPositionUpdate - PortfolioBlockPositions - UpdatePortfolioBlockOperation - Updated tests to cover new portfolio block functionalities: - Test for creating, updating, and deleting portfolio blocks. - Enhanced test coverage for portfolio block retrieval and position management. --- ...tfolio.py => op_create_portfolio_block.py} | 70 +++-- .../op_create_position.py | 257 ------------------ ...tfolio.py => op_delete_portfolio_block.py} | 74 +++-- .../op_delete_position.py | 257 ------------------ ...tfolio.py => op_update_portfolio_block.py} | 70 ++--- .../op_update_position.py | 253 ----------------- robosystems_client/clients/investor_client.py | 121 +++------ .../graphql/queries/investor/__init__.py | 35 ++- robosystems_client/models/__init__.py | 30 +- .../models/create_portfolio_block_request.py | 97 +++++++ ...py => delete_portfolio_block_operation.py} | 32 ++- .../models/delete_position_operation.py | 62 ----- ...py => portfolio_block_portfolio_fields.py} | 33 ++- ....py => portfolio_block_portfolio_patch.py} | 61 +++-- ...est.py => portfolio_block_position_add.py} | 46 ++-- .../portfolio_block_position_dispose.py | 88 ++++++ ....py => portfolio_block_position_update.py} | 96 ++----- .../models/portfolio_block_positions.py | 126 +++++++++ .../update_portfolio_block_operation.py | 109 ++++++++ tests/test_investor_client.py | 175 +++++++----- 20 files changed, 891 insertions(+), 1201 deletions(-) rename robosystems_client/api/extensions_robo_investor/{op_create_portfolio.py => op_create_portfolio_block.py} (70%) delete mode 100644 robosystems_client/api/extensions_robo_investor/op_create_position.py rename robosystems_client/api/extensions_robo_investor/{op_delete_portfolio.py => op_delete_portfolio_block.py} (68%) delete mode 100644 robosystems_client/api/extensions_robo_investor/op_delete_position.py rename robosystems_client/api/extensions_robo_investor/{op_update_portfolio.py => op_update_portfolio_block.py} (74%) delete mode 100644 robosystems_client/api/extensions_robo_investor/op_update_position.py create mode 100644 robosystems_client/models/create_portfolio_block_request.py rename robosystems_client/models/{delete_portfolio_operation.py => delete_portfolio_block_operation.py} (53%) delete mode 100644 robosystems_client/models/delete_position_operation.py rename robosystems_client/models/{create_portfolio_request.py => portfolio_block_portfolio_fields.py} (81%) rename robosystems_client/models/{update_portfolio_operation.py => portfolio_block_portfolio_patch.py} (78%) rename robosystems_client/models/{create_position_request.py => portfolio_block_position_add.py} (86%) create mode 100644 robosystems_client/models/portfolio_block_position_dispose.py rename robosystems_client/models/{update_position_operation.py => portfolio_block_position_update.py} (72%) create mode 100644 robosystems_client/models/portfolio_block_positions.py create mode 100644 robosystems_client/models/update_portfolio_block_operation.py diff --git a/robosystems_client/api/extensions_robo_investor/op_create_portfolio.py b/robosystems_client/api/extensions_robo_investor/op_create_portfolio_block.py similarity index 70% rename from robosystems_client/api/extensions_robo_investor/op_create_portfolio.py rename to robosystems_client/api/extensions_robo_investor/op_create_portfolio_block.py index ed2b6a8..9657833 100644 --- a/robosystems_client/api/extensions_robo_investor/op_create_portfolio.py +++ b/robosystems_client/api/extensions_robo_investor/op_create_portfolio_block.py @@ -6,7 +6,7 @@ from ... import errors from ...client import AuthenticatedClient, Client -from ...models.create_portfolio_request import CreatePortfolioRequest +from ...models.create_portfolio_block_request import CreatePortfolioBlockRequest from ...models.http_validation_error import HTTPValidationError from ...models.operation_envelope import OperationEnvelope from ...models.operation_error import OperationError @@ -16,7 +16,7 @@ def _get_kwargs( graph_id: str, *, - body: CreatePortfolioRequest, + body: CreatePortfolioBlockRequest, idempotency_key: None | str | Unset = UNSET, ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -25,7 +25,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "post", - "url": "/extensions/roboinvestor/{graph_id}/operations/create-portfolio".format( + "url": "/extensions/roboinvestor/{graph_id}/operations/create-portfolio-block".format( graph_id=quote(str(graph_id), safe=""), ), } @@ -103,13 +103,14 @@ def sync_detailed( graph_id: str, *, client: AuthenticatedClient, - body: CreatePortfolioRequest, + body: CreatePortfolioBlockRequest, idempotency_key: None | str | Unset = UNSET, ) -> Response[Any | HTTPValidationError | OperationEnvelope | OperationError]: - """Create Portfolio + """Create Portfolio Block - Create an investment portfolio within this graph. Portfolios are logical groupings of securities - (stocks, notes, warrants) owned by the entity; subsequent positions attach to the portfolio. + Create a portfolio with optional initial positions in a single atomic envelope. Each position + references an existing security; this operation never mints securities (use `create-security`). + Whole envelope validates before any write. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -117,7 +118,12 @@ def sync_detailed( Args: graph_id (str): idempotency_key (None | str | Unset): - body (CreatePortfolioRequest): + body (CreatePortfolioBlockRequest): CQRS body for `POST /operations/create-portfolio- + block`. + + Whole envelope validated before any DB write; the portfolio + initial + positions land in one transaction. Each `position` references an + existing `security_id` — the operation does not mint securities. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -144,13 +150,14 @@ def sync( graph_id: str, *, client: AuthenticatedClient, - body: CreatePortfolioRequest, + body: CreatePortfolioBlockRequest, idempotency_key: None | str | Unset = UNSET, ) -> Any | HTTPValidationError | OperationEnvelope | OperationError | None: - """Create Portfolio + """Create Portfolio Block - Create an investment portfolio within this graph. Portfolios are logical groupings of securities - (stocks, notes, warrants) owned by the entity; subsequent positions attach to the portfolio. + Create a portfolio with optional initial positions in a single atomic envelope. Each position + references an existing security; this operation never mints securities (use `create-security`). + Whole envelope validates before any write. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -158,7 +165,12 @@ def sync( Args: graph_id (str): idempotency_key (None | str | Unset): - body (CreatePortfolioRequest): + body (CreatePortfolioBlockRequest): CQRS body for `POST /operations/create-portfolio- + block`. + + Whole envelope validated before any DB write; the portfolio + initial + positions land in one transaction. Each `position` references an + existing `security_id` — the operation does not mint securities. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -180,13 +192,14 @@ async def asyncio_detailed( graph_id: str, *, client: AuthenticatedClient, - body: CreatePortfolioRequest, + body: CreatePortfolioBlockRequest, idempotency_key: None | str | Unset = UNSET, ) -> Response[Any | HTTPValidationError | OperationEnvelope | OperationError]: - """Create Portfolio + """Create Portfolio Block - Create an investment portfolio within this graph. Portfolios are logical groupings of securities - (stocks, notes, warrants) owned by the entity; subsequent positions attach to the portfolio. + Create a portfolio with optional initial positions in a single atomic envelope. Each position + references an existing security; this operation never mints securities (use `create-security`). + Whole envelope validates before any write. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -194,7 +207,12 @@ async def asyncio_detailed( Args: graph_id (str): idempotency_key (None | str | Unset): - body (CreatePortfolioRequest): + body (CreatePortfolioBlockRequest): CQRS body for `POST /operations/create-portfolio- + block`. + + Whole envelope validated before any DB write; the portfolio + initial + positions land in one transaction. Each `position` references an + existing `security_id` — the operation does not mint securities. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -219,13 +237,14 @@ async def asyncio( graph_id: str, *, client: AuthenticatedClient, - body: CreatePortfolioRequest, + body: CreatePortfolioBlockRequest, idempotency_key: None | str | Unset = UNSET, ) -> Any | HTTPValidationError | OperationEnvelope | OperationError | None: - """Create Portfolio + """Create Portfolio Block - Create an investment portfolio within this graph. Portfolios are logical groupings of securities - (stocks, notes, warrants) owned by the entity; subsequent positions attach to the portfolio. + Create a portfolio with optional initial positions in a single atomic envelope. Each position + references an existing security; this operation never mints securities (use `create-security`). + Whole envelope validates before any write. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -233,7 +252,12 @@ async def asyncio( Args: graph_id (str): idempotency_key (None | str | Unset): - body (CreatePortfolioRequest): + body (CreatePortfolioBlockRequest): CQRS body for `POST /operations/create-portfolio- + block`. + + Whole envelope validated before any DB write; the portfolio + initial + positions land in one transaction. Each `position` references an + existing `security_id` — the operation does not mint securities. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/robosystems_client/api/extensions_robo_investor/op_create_position.py b/robosystems_client/api/extensions_robo_investor/op_create_position.py deleted file mode 100644 index 81a8033..0000000 --- a/robosystems_client/api/extensions_robo_investor/op_create_position.py +++ /dev/null @@ -1,257 +0,0 @@ -from http import HTTPStatus -from typing import Any, cast -from urllib.parse import quote - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.create_position_request import CreatePositionRequest -from ...models.http_validation_error import HTTPValidationError -from ...models.operation_envelope import OperationEnvelope -from ...models.operation_error import OperationError -from ...types import UNSET, Response, Unset - - -def _get_kwargs( - graph_id: str, - *, - body: CreatePositionRequest, - idempotency_key: None | str | Unset = UNSET, -) -> dict[str, Any]: - headers: dict[str, Any] = {} - if not isinstance(idempotency_key, Unset): - headers["Idempotency-Key"] = idempotency_key - - _kwargs: dict[str, Any] = { - "method": "post", - "url": "/extensions/roboinvestor/{graph_id}/operations/create-position".format( - graph_id=quote(str(graph_id), safe=""), - ), - } - - _kwargs["json"] = body.to_dict() - - headers["Content-Type"] = "application/json" - - _kwargs["headers"] = headers - return _kwargs - - -def _parse_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Any | HTTPValidationError | OperationEnvelope | OperationError | None: - if response.status_code == 200: - response_200 = OperationEnvelope.from_dict(response.json()) - - return response_200 - - if response.status_code == 400: - response_400 = OperationError.from_dict(response.json()) - - return response_400 - - if response.status_code == 401: - response_401 = cast(Any, None) - return response_401 - - if response.status_code == 403: - response_403 = cast(Any, None) - return response_403 - - if response.status_code == 404: - response_404 = OperationError.from_dict(response.json()) - - return response_404 - - if response.status_code == 409: - response_409 = OperationError.from_dict(response.json()) - - return response_409 - - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - - if response.status_code == 429: - response_429 = cast(Any, None) - return response_429 - - if response.status_code == 500: - response_500 = cast(Any, None) - return response_500 - - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Any | HTTPValidationError | OperationEnvelope | OperationError]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - graph_id: str, - *, - client: AuthenticatedClient, - body: CreatePositionRequest, - idempotency_key: None | str | Unset = UNSET, -) -> Response[Any | HTTPValidationError | OperationEnvelope | OperationError]: - """Create Position - - Open a position in a security within a portfolio. One active position per (portfolio, security) pair - — creating a second active position returns 409. Dispose an existing position via `delete-position` - before opening a new one. - - **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours - return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. - - Args: - graph_id (str): - idempotency_key (None | str | Unset): - body (CreatePositionRequest): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Any | HTTPValidationError | OperationEnvelope | OperationError] - """ - - kwargs = _get_kwargs( - graph_id=graph_id, - body=body, - idempotency_key=idempotency_key, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - graph_id: str, - *, - client: AuthenticatedClient, - body: CreatePositionRequest, - idempotency_key: None | str | Unset = UNSET, -) -> Any | HTTPValidationError | OperationEnvelope | OperationError | None: - """Create Position - - Open a position in a security within a portfolio. One active position per (portfolio, security) pair - — creating a second active position returns 409. Dispose an existing position via `delete-position` - before opening a new one. - - **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours - return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. - - Args: - graph_id (str): - idempotency_key (None | str | Unset): - body (CreatePositionRequest): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Any | HTTPValidationError | OperationEnvelope | OperationError - """ - - return sync_detailed( - graph_id=graph_id, - client=client, - body=body, - idempotency_key=idempotency_key, - ).parsed - - -async def asyncio_detailed( - graph_id: str, - *, - client: AuthenticatedClient, - body: CreatePositionRequest, - idempotency_key: None | str | Unset = UNSET, -) -> Response[Any | HTTPValidationError | OperationEnvelope | OperationError]: - """Create Position - - Open a position in a security within a portfolio. One active position per (portfolio, security) pair - — creating a second active position returns 409. Dispose an existing position via `delete-position` - before opening a new one. - - **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours - return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. - - Args: - graph_id (str): - idempotency_key (None | str | Unset): - body (CreatePositionRequest): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Any | HTTPValidationError | OperationEnvelope | OperationError] - """ - - kwargs = _get_kwargs( - graph_id=graph_id, - body=body, - idempotency_key=idempotency_key, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - graph_id: str, - *, - client: AuthenticatedClient, - body: CreatePositionRequest, - idempotency_key: None | str | Unset = UNSET, -) -> Any | HTTPValidationError | OperationEnvelope | OperationError | None: - """Create Position - - Open a position in a security within a portfolio. One active position per (portfolio, security) pair - — creating a second active position returns 409. Dispose an existing position via `delete-position` - before opening a new one. - - **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours - return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. - - Args: - graph_id (str): - idempotency_key (None | str | Unset): - body (CreatePositionRequest): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Any | HTTPValidationError | OperationEnvelope | OperationError - """ - - return ( - await asyncio_detailed( - graph_id=graph_id, - client=client, - body=body, - idempotency_key=idempotency_key, - ) - ).parsed diff --git a/robosystems_client/api/extensions_robo_investor/op_delete_portfolio.py b/robosystems_client/api/extensions_robo_investor/op_delete_portfolio_block.py similarity index 68% rename from robosystems_client/api/extensions_robo_investor/op_delete_portfolio.py rename to robosystems_client/api/extensions_robo_investor/op_delete_portfolio_block.py index 8741801..8ad39da 100644 --- a/robosystems_client/api/extensions_robo_investor/op_delete_portfolio.py +++ b/robosystems_client/api/extensions_robo_investor/op_delete_portfolio_block.py @@ -6,7 +6,7 @@ from ... import errors from ...client import AuthenticatedClient, Client -from ...models.delete_portfolio_operation import DeletePortfolioOperation +from ...models.delete_portfolio_block_operation import DeletePortfolioBlockOperation from ...models.http_validation_error import HTTPValidationError from ...models.operation_envelope import OperationEnvelope from ...models.operation_error import OperationError @@ -16,7 +16,7 @@ def _get_kwargs( graph_id: str, *, - body: DeletePortfolioOperation, + body: DeletePortfolioBlockOperation, idempotency_key: None | str | Unset = UNSET, ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -25,7 +25,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "post", - "url": "/extensions/roboinvestor/{graph_id}/operations/delete-portfolio".format( + "url": "/extensions/roboinvestor/{graph_id}/operations/delete-portfolio-block".format( graph_id=quote(str(graph_id), safe=""), ), } @@ -103,13 +103,14 @@ def sync_detailed( graph_id: str, *, client: AuthenticatedClient, - body: DeletePortfolioOperation, + body: DeletePortfolioBlockOperation, idempotency_key: None | str | Unset = UNSET, ) -> Response[Any | HTTPValidationError | OperationEnvelope | OperationError]: - """Delete Portfolio + """Delete Portfolio Block - Hard-delete a portfolio. Returns 409 if the portfolio has active positions — dispose (soft-delete) - them first. + Cascade-delete the portfolio plus all of its positions. When active positions exist, the request + must include `confirm_active_positions: true` — safety belt to prevent accidental cascade. Disposed- + only portfolios delete without the flag. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -117,7 +118,13 @@ def sync_detailed( Args: graph_id (str): idempotency_key (None | str | Unset): - body (DeletePortfolioOperation): CQRS body for `POST /operations/delete-portfolio`. + body (DeletePortfolioBlockOperation): CQRS body for `POST /operations/delete-portfolio- + block`. + + Cascade-deletes the portfolio plus all of its positions. When the + portfolio still has active positions, the operation is rejected + unless `confirm_active_positions=true` is set — safety belt to + prevent accidental cascade. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -144,13 +151,14 @@ def sync( graph_id: str, *, client: AuthenticatedClient, - body: DeletePortfolioOperation, + body: DeletePortfolioBlockOperation, idempotency_key: None | str | Unset = UNSET, ) -> Any | HTTPValidationError | OperationEnvelope | OperationError | None: - """Delete Portfolio + """Delete Portfolio Block - Hard-delete a portfolio. Returns 409 if the portfolio has active positions — dispose (soft-delete) - them first. + Cascade-delete the portfolio plus all of its positions. When active positions exist, the request + must include `confirm_active_positions: true` — safety belt to prevent accidental cascade. Disposed- + only portfolios delete without the flag. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -158,7 +166,13 @@ def sync( Args: graph_id (str): idempotency_key (None | str | Unset): - body (DeletePortfolioOperation): CQRS body for `POST /operations/delete-portfolio`. + body (DeletePortfolioBlockOperation): CQRS body for `POST /operations/delete-portfolio- + block`. + + Cascade-deletes the portfolio plus all of its positions. When the + portfolio still has active positions, the operation is rejected + unless `confirm_active_positions=true` is set — safety belt to + prevent accidental cascade. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -180,13 +194,14 @@ async def asyncio_detailed( graph_id: str, *, client: AuthenticatedClient, - body: DeletePortfolioOperation, + body: DeletePortfolioBlockOperation, idempotency_key: None | str | Unset = UNSET, ) -> Response[Any | HTTPValidationError | OperationEnvelope | OperationError]: - """Delete Portfolio + """Delete Portfolio Block - Hard-delete a portfolio. Returns 409 if the portfolio has active positions — dispose (soft-delete) - them first. + Cascade-delete the portfolio plus all of its positions. When active positions exist, the request + must include `confirm_active_positions: true` — safety belt to prevent accidental cascade. Disposed- + only portfolios delete without the flag. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -194,7 +209,13 @@ async def asyncio_detailed( Args: graph_id (str): idempotency_key (None | str | Unset): - body (DeletePortfolioOperation): CQRS body for `POST /operations/delete-portfolio`. + body (DeletePortfolioBlockOperation): CQRS body for `POST /operations/delete-portfolio- + block`. + + Cascade-deletes the portfolio plus all of its positions. When the + portfolio still has active positions, the operation is rejected + unless `confirm_active_positions=true` is set — safety belt to + prevent accidental cascade. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -219,13 +240,14 @@ async def asyncio( graph_id: str, *, client: AuthenticatedClient, - body: DeletePortfolioOperation, + body: DeletePortfolioBlockOperation, idempotency_key: None | str | Unset = UNSET, ) -> Any | HTTPValidationError | OperationEnvelope | OperationError | None: - """Delete Portfolio + """Delete Portfolio Block - Hard-delete a portfolio. Returns 409 if the portfolio has active positions — dispose (soft-delete) - them first. + Cascade-delete the portfolio plus all of its positions. When active positions exist, the request + must include `confirm_active_positions: true` — safety belt to prevent accidental cascade. Disposed- + only portfolios delete without the flag. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -233,7 +255,13 @@ async def asyncio( Args: graph_id (str): idempotency_key (None | str | Unset): - body (DeletePortfolioOperation): CQRS body for `POST /operations/delete-portfolio`. + body (DeletePortfolioBlockOperation): CQRS body for `POST /operations/delete-portfolio- + block`. + + Cascade-deletes the portfolio plus all of its positions. When the + portfolio still has active positions, the operation is rejected + unless `confirm_active_positions=true` is set — safety belt to + prevent accidental cascade. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/robosystems_client/api/extensions_robo_investor/op_delete_position.py b/robosystems_client/api/extensions_robo_investor/op_delete_position.py deleted file mode 100644 index 4b0d284..0000000 --- a/robosystems_client/api/extensions_robo_investor/op_delete_position.py +++ /dev/null @@ -1,257 +0,0 @@ -from http import HTTPStatus -from typing import Any, cast -from urllib.parse import quote - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.delete_position_operation import DeletePositionOperation -from ...models.http_validation_error import HTTPValidationError -from ...models.operation_envelope import OperationEnvelope -from ...models.operation_error import OperationError -from ...types import UNSET, Response, Unset - - -def _get_kwargs( - graph_id: str, - *, - body: DeletePositionOperation, - idempotency_key: None | str | Unset = UNSET, -) -> dict[str, Any]: - headers: dict[str, Any] = {} - if not isinstance(idempotency_key, Unset): - headers["Idempotency-Key"] = idempotency_key - - _kwargs: dict[str, Any] = { - "method": "post", - "url": "/extensions/roboinvestor/{graph_id}/operations/delete-position".format( - graph_id=quote(str(graph_id), safe=""), - ), - } - - _kwargs["json"] = body.to_dict() - - headers["Content-Type"] = "application/json" - - _kwargs["headers"] = headers - return _kwargs - - -def _parse_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Any | HTTPValidationError | OperationEnvelope | OperationError | None: - if response.status_code == 200: - response_200 = OperationEnvelope.from_dict(response.json()) - - return response_200 - - if response.status_code == 400: - response_400 = OperationError.from_dict(response.json()) - - return response_400 - - if response.status_code == 401: - response_401 = cast(Any, None) - return response_401 - - if response.status_code == 403: - response_403 = cast(Any, None) - return response_403 - - if response.status_code == 404: - response_404 = OperationError.from_dict(response.json()) - - return response_404 - - if response.status_code == 409: - response_409 = OperationError.from_dict(response.json()) - - return response_409 - - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - - if response.status_code == 429: - response_429 = cast(Any, None) - return response_429 - - if response.status_code == 500: - response_500 = cast(Any, None) - return response_500 - - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Any | HTTPValidationError | OperationEnvelope | OperationError]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - graph_id: str, - *, - client: AuthenticatedClient, - body: DeletePositionOperation, - idempotency_key: None | str | Unset = UNSET, -) -> Response[Any | HTTPValidationError | OperationEnvelope | OperationError]: - """Delete Position - - Soft-delete the position (`status='disposed'`). Historical holding records referencing it remain - valid. - - **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours - return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. - - Args: - graph_id (str): - idempotency_key (None | str | Unset): - body (DeletePositionOperation): CQRS body for `POST /operations/delete-position` (soft - delete). - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Any | HTTPValidationError | OperationEnvelope | OperationError] - """ - - kwargs = _get_kwargs( - graph_id=graph_id, - body=body, - idempotency_key=idempotency_key, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - graph_id: str, - *, - client: AuthenticatedClient, - body: DeletePositionOperation, - idempotency_key: None | str | Unset = UNSET, -) -> Any | HTTPValidationError | OperationEnvelope | OperationError | None: - """Delete Position - - Soft-delete the position (`status='disposed'`). Historical holding records referencing it remain - valid. - - **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours - return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. - - Args: - graph_id (str): - idempotency_key (None | str | Unset): - body (DeletePositionOperation): CQRS body for `POST /operations/delete-position` (soft - delete). - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Any | HTTPValidationError | OperationEnvelope | OperationError - """ - - return sync_detailed( - graph_id=graph_id, - client=client, - body=body, - idempotency_key=idempotency_key, - ).parsed - - -async def asyncio_detailed( - graph_id: str, - *, - client: AuthenticatedClient, - body: DeletePositionOperation, - idempotency_key: None | str | Unset = UNSET, -) -> Response[Any | HTTPValidationError | OperationEnvelope | OperationError]: - """Delete Position - - Soft-delete the position (`status='disposed'`). Historical holding records referencing it remain - valid. - - **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours - return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. - - Args: - graph_id (str): - idempotency_key (None | str | Unset): - body (DeletePositionOperation): CQRS body for `POST /operations/delete-position` (soft - delete). - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Any | HTTPValidationError | OperationEnvelope | OperationError] - """ - - kwargs = _get_kwargs( - graph_id=graph_id, - body=body, - idempotency_key=idempotency_key, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - graph_id: str, - *, - client: AuthenticatedClient, - body: DeletePositionOperation, - idempotency_key: None | str | Unset = UNSET, -) -> Any | HTTPValidationError | OperationEnvelope | OperationError | None: - """Delete Position - - Soft-delete the position (`status='disposed'`). Historical holding records referencing it remain - valid. - - **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours - return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. - - Args: - graph_id (str): - idempotency_key (None | str | Unset): - body (DeletePositionOperation): CQRS body for `POST /operations/delete-position` (soft - delete). - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Any | HTTPValidationError | OperationEnvelope | OperationError - """ - - return ( - await asyncio_detailed( - graph_id=graph_id, - client=client, - body=body, - idempotency_key=idempotency_key, - ) - ).parsed diff --git a/robosystems_client/api/extensions_robo_investor/op_update_portfolio.py b/robosystems_client/api/extensions_robo_investor/op_update_portfolio_block.py similarity index 74% rename from robosystems_client/api/extensions_robo_investor/op_update_portfolio.py rename to robosystems_client/api/extensions_robo_investor/op_update_portfolio_block.py index b4efa82..83d10d0 100644 --- a/robosystems_client/api/extensions_robo_investor/op_update_portfolio.py +++ b/robosystems_client/api/extensions_robo_investor/op_update_portfolio_block.py @@ -9,14 +9,14 @@ from ...models.http_validation_error import HTTPValidationError from ...models.operation_envelope import OperationEnvelope from ...models.operation_error import OperationError -from ...models.update_portfolio_operation import UpdatePortfolioOperation +from ...models.update_portfolio_block_operation import UpdatePortfolioBlockOperation from ...types import UNSET, Response, Unset def _get_kwargs( graph_id: str, *, - body: UpdatePortfolioOperation, + body: UpdatePortfolioBlockOperation, idempotency_key: None | str | Unset = UNSET, ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -25,7 +25,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "post", - "url": "/extensions/roboinvestor/{graph_id}/operations/update-portfolio".format( + "url": "/extensions/roboinvestor/{graph_id}/operations/update-portfolio-block".format( graph_id=quote(str(graph_id), safe=""), ), } @@ -103,13 +103,13 @@ def sync_detailed( graph_id: str, *, client: AuthenticatedClient, - body: UpdatePortfolioOperation, + body: UpdatePortfolioBlockOperation, idempotency_key: None | str | Unset = UNSET, ) -> Response[Any | HTTPValidationError | OperationEnvelope | OperationError]: - """Update Portfolio + """Update Portfolio Block - Update mutable fields on a portfolio (name, description, strategy, inception_date, base_currency). - Unset fields are ignored — partial updates are explicit. + Patch portfolio fields and apply position deltas (`add` / `update` / `dispose`) atomically. Partial + failures roll back. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -117,10 +117,12 @@ def sync_detailed( Args: graph_id (str): idempotency_key (None | str | Unset): - body (UpdatePortfolioOperation): CQRS body for `POST /operations/update-portfolio`. + body (UpdatePortfolioBlockOperation): CQRS body for `POST /operations/update-portfolio- + block`. - Folds `portfolio_id` into the payload so REST + MCP share one body - type via the registrar. Unset fields are ignored (partial update). + Carries an optional patch to portfolio fields and three position + delta lists (`add` / `update` / `dispose`). All apply atomically + with the portfolio patch — partial failures roll back. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -147,13 +149,13 @@ def sync( graph_id: str, *, client: AuthenticatedClient, - body: UpdatePortfolioOperation, + body: UpdatePortfolioBlockOperation, idempotency_key: None | str | Unset = UNSET, ) -> Any | HTTPValidationError | OperationEnvelope | OperationError | None: - """Update Portfolio + """Update Portfolio Block - Update mutable fields on a portfolio (name, description, strategy, inception_date, base_currency). - Unset fields are ignored — partial updates are explicit. + Patch portfolio fields and apply position deltas (`add` / `update` / `dispose`) atomically. Partial + failures roll back. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -161,10 +163,12 @@ def sync( Args: graph_id (str): idempotency_key (None | str | Unset): - body (UpdatePortfolioOperation): CQRS body for `POST /operations/update-portfolio`. + body (UpdatePortfolioBlockOperation): CQRS body for `POST /operations/update-portfolio- + block`. - Folds `portfolio_id` into the payload so REST + MCP share one body - type via the registrar. Unset fields are ignored (partial update). + Carries an optional patch to portfolio fields and three position + delta lists (`add` / `update` / `dispose`). All apply atomically + with the portfolio patch — partial failures roll back. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -186,13 +190,13 @@ async def asyncio_detailed( graph_id: str, *, client: AuthenticatedClient, - body: UpdatePortfolioOperation, + body: UpdatePortfolioBlockOperation, idempotency_key: None | str | Unset = UNSET, ) -> Response[Any | HTTPValidationError | OperationEnvelope | OperationError]: - """Update Portfolio + """Update Portfolio Block - Update mutable fields on a portfolio (name, description, strategy, inception_date, base_currency). - Unset fields are ignored — partial updates are explicit. + Patch portfolio fields and apply position deltas (`add` / `update` / `dispose`) atomically. Partial + failures roll back. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -200,10 +204,12 @@ async def asyncio_detailed( Args: graph_id (str): idempotency_key (None | str | Unset): - body (UpdatePortfolioOperation): CQRS body for `POST /operations/update-portfolio`. + body (UpdatePortfolioBlockOperation): CQRS body for `POST /operations/update-portfolio- + block`. - Folds `portfolio_id` into the payload so REST + MCP share one body - type via the registrar. Unset fields are ignored (partial update). + Carries an optional patch to portfolio fields and three position + delta lists (`add` / `update` / `dispose`). All apply atomically + with the portfolio patch — partial failures roll back. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -228,13 +234,13 @@ async def asyncio( graph_id: str, *, client: AuthenticatedClient, - body: UpdatePortfolioOperation, + body: UpdatePortfolioBlockOperation, idempotency_key: None | str | Unset = UNSET, ) -> Any | HTTPValidationError | OperationEnvelope | OperationError | None: - """Update Portfolio + """Update Portfolio Block - Update mutable fields on a portfolio (name, description, strategy, inception_date, base_currency). - Unset fields are ignored — partial updates are explicit. + Patch portfolio fields and apply position deltas (`add` / `update` / `dispose`) atomically. Partial + failures roll back. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -242,10 +248,12 @@ async def asyncio( Args: graph_id (str): idempotency_key (None | str | Unset): - body (UpdatePortfolioOperation): CQRS body for `POST /operations/update-portfolio`. + body (UpdatePortfolioBlockOperation): CQRS body for `POST /operations/update-portfolio- + block`. - Folds `portfolio_id` into the payload so REST + MCP share one body - type via the registrar. Unset fields are ignored (partial update). + Carries an optional patch to portfolio fields and three position + delta lists (`add` / `update` / `dispose`). All apply atomically + with the portfolio patch — partial failures roll back. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/robosystems_client/api/extensions_robo_investor/op_update_position.py b/robosystems_client/api/extensions_robo_investor/op_update_position.py deleted file mode 100644 index 506b2ae..0000000 --- a/robosystems_client/api/extensions_robo_investor/op_update_position.py +++ /dev/null @@ -1,253 +0,0 @@ -from http import HTTPStatus -from typing import Any, cast -from urllib.parse import quote - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.http_validation_error import HTTPValidationError -from ...models.operation_envelope import OperationEnvelope -from ...models.operation_error import OperationError -from ...models.update_position_operation import UpdatePositionOperation -from ...types import UNSET, Response, Unset - - -def _get_kwargs( - graph_id: str, - *, - body: UpdatePositionOperation, - idempotency_key: None | str | Unset = UNSET, -) -> dict[str, Any]: - headers: dict[str, Any] = {} - if not isinstance(idempotency_key, Unset): - headers["Idempotency-Key"] = idempotency_key - - _kwargs: dict[str, Any] = { - "method": "post", - "url": "/extensions/roboinvestor/{graph_id}/operations/update-position".format( - graph_id=quote(str(graph_id), safe=""), - ), - } - - _kwargs["json"] = body.to_dict() - - headers["Content-Type"] = "application/json" - - _kwargs["headers"] = headers - return _kwargs - - -def _parse_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Any | HTTPValidationError | OperationEnvelope | OperationError | None: - if response.status_code == 200: - response_200 = OperationEnvelope.from_dict(response.json()) - - return response_200 - - if response.status_code == 400: - response_400 = OperationError.from_dict(response.json()) - - return response_400 - - if response.status_code == 401: - response_401 = cast(Any, None) - return response_401 - - if response.status_code == 403: - response_403 = cast(Any, None) - return response_403 - - if response.status_code == 404: - response_404 = OperationError.from_dict(response.json()) - - return response_404 - - if response.status_code == 409: - response_409 = OperationError.from_dict(response.json()) - - return response_409 - - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - - if response.status_code == 429: - response_429 = cast(Any, None) - return response_429 - - if response.status_code == 500: - response_500 = cast(Any, None) - return response_500 - - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Any | HTTPValidationError | OperationEnvelope | OperationError]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - graph_id: str, - *, - client: AuthenticatedClient, - body: UpdatePositionOperation, - idempotency_key: None | str | Unset = UNSET, -) -> Response[Any | HTTPValidationError | OperationEnvelope | OperationError]: - """Update Position - - Update mutable fields on a position (quantity, cost basis, valuation, notes, status). Use `delete- - position` for soft disposal instead of setting `status` manually. - - **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours - return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. - - Args: - graph_id (str): - idempotency_key (None | str | Unset): - body (UpdatePositionOperation): CQRS body for `POST /operations/update-position`. - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Any | HTTPValidationError | OperationEnvelope | OperationError] - """ - - kwargs = _get_kwargs( - graph_id=graph_id, - body=body, - idempotency_key=idempotency_key, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - graph_id: str, - *, - client: AuthenticatedClient, - body: UpdatePositionOperation, - idempotency_key: None | str | Unset = UNSET, -) -> Any | HTTPValidationError | OperationEnvelope | OperationError | None: - """Update Position - - Update mutable fields on a position (quantity, cost basis, valuation, notes, status). Use `delete- - position` for soft disposal instead of setting `status` manually. - - **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours - return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. - - Args: - graph_id (str): - idempotency_key (None | str | Unset): - body (UpdatePositionOperation): CQRS body for `POST /operations/update-position`. - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Any | HTTPValidationError | OperationEnvelope | OperationError - """ - - return sync_detailed( - graph_id=graph_id, - client=client, - body=body, - idempotency_key=idempotency_key, - ).parsed - - -async def asyncio_detailed( - graph_id: str, - *, - client: AuthenticatedClient, - body: UpdatePositionOperation, - idempotency_key: None | str | Unset = UNSET, -) -> Response[Any | HTTPValidationError | OperationEnvelope | OperationError]: - """Update Position - - Update mutable fields on a position (quantity, cost basis, valuation, notes, status). Use `delete- - position` for soft disposal instead of setting `status` manually. - - **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours - return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. - - Args: - graph_id (str): - idempotency_key (None | str | Unset): - body (UpdatePositionOperation): CQRS body for `POST /operations/update-position`. - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Any | HTTPValidationError | OperationEnvelope | OperationError] - """ - - kwargs = _get_kwargs( - graph_id=graph_id, - body=body, - idempotency_key=idempotency_key, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - graph_id: str, - *, - client: AuthenticatedClient, - body: UpdatePositionOperation, - idempotency_key: None | str | Unset = UNSET, -) -> Any | HTTPValidationError | OperationEnvelope | OperationError | None: - """Update Position - - Update mutable fields on a position (quantity, cost basis, valuation, notes, status). Use `delete- - position` for soft disposal instead of setting `status` manually. - - **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours - return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. - - Args: - graph_id (str): - idempotency_key (None | str | Unset): - body (UpdatePositionOperation): CQRS body for `POST /operations/update-position`. - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Any | HTTPValidationError | OperationEnvelope | OperationError - """ - - return ( - await asyncio_detailed( - graph_id=graph_id, - client=client, - body=body, - idempotency_key=idempotency_key, - ) - ).parsed diff --git a/robosystems_client/clients/investor_client.py b/robosystems_client/clients/investor_client.py index e16e86d..17e7f87 100644 --- a/robosystems_client/clients/investor_client.py +++ b/robosystems_client/clients/investor_client.py @@ -17,29 +17,20 @@ from http import HTTPStatus from typing import Any -from ..api.extensions_robo_investor.op_create_portfolio import ( - sync_detailed as op_create_portfolio, -) -from ..api.extensions_robo_investor.op_create_position import ( - sync_detailed as op_create_position, +from ..api.extensions_robo_investor.op_create_portfolio_block import ( + sync_detailed as op_create_portfolio_block, ) from ..api.extensions_robo_investor.op_create_security import ( sync_detailed as op_create_security, ) -from ..api.extensions_robo_investor.op_delete_portfolio import ( - sync_detailed as op_delete_portfolio, -) -from ..api.extensions_robo_investor.op_delete_position import ( - sync_detailed as op_delete_position, +from ..api.extensions_robo_investor.op_delete_portfolio_block import ( + sync_detailed as op_delete_portfolio_block, ) from ..api.extensions_robo_investor.op_delete_security import ( sync_detailed as op_delete_security, ) -from ..api.extensions_robo_investor.op_update_portfolio import ( - sync_detailed as op_update_portfolio, -) -from ..api.extensions_robo_investor.op_update_position import ( - sync_detailed as op_update_position, +from ..api.extensions_robo_investor.op_update_portfolio_block import ( + sync_detailed as op_update_portfolio_block, ) from ..api.extensions_robo_investor.op_update_security import ( sync_detailed as op_update_security, @@ -48,29 +39,26 @@ from ..graphql.client import GraphQLClient, strip_none_vars from ..graphql.queries.investor import ( GET_HOLDINGS_QUERY, - GET_PORTFOLIO_QUERY, + GET_PORTFOLIO_BLOCK_QUERY, GET_POSITION_QUERY, GET_SECURITY_QUERY, LIST_PORTFOLIOS_QUERY, LIST_POSITIONS_QUERY, LIST_SECURITIES_QUERY, parse_holdings, - parse_portfolio, + parse_portfolio_block, parse_portfolios, parse_position, parse_positions, parse_securities, parse_security, ) -from ..models.create_portfolio_request import CreatePortfolioRequest -from ..models.create_position_request import CreatePositionRequest +from ..models.create_portfolio_block_request import CreatePortfolioBlockRequest from ..models.create_security_request import CreateSecurityRequest -from ..models.delete_portfolio_operation import DeletePortfolioOperation -from ..models.delete_position_operation import DeletePositionOperation +from ..models.delete_portfolio_block_operation import DeletePortfolioBlockOperation from ..models.delete_security_operation import DeleteSecurityOperation from ..models.operation_envelope import OperationEnvelope -from ..models.update_portfolio_operation import UpdatePortfolioOperation -from ..models.update_position_operation import UpdatePositionOperation +from ..models.update_portfolio_block_operation import UpdatePortfolioBlockOperation from ..models.update_security_operation import UpdateSecurityOperation @@ -140,42 +128,56 @@ def list_portfolios( ) return parse_portfolios(data) - def get_portfolio(self, graph_id: str, portfolio_id: str) -> dict[str, Any] | None: - """Get a single portfolio by id. Returns None if it doesn't exist.""" - data = self._query(graph_id, GET_PORTFOLIO_QUERY, {"portfolioId": portfolio_id}) - return parse_portfolio(data) + def get_portfolio_block( + self, graph_id: str, portfolio_id: str + ) -> dict[str, Any] | None: + """Get the full portfolio block (portfolio + positions + securities). Returns None if not found.""" + data = self._query( + graph_id, GET_PORTFOLIO_BLOCK_QUERY, {"portfolioId": portfolio_id} + ) + return parse_portfolio_block(data) - def create_portfolio(self, graph_id: str, body: dict[str, Any]) -> dict[str, Any]: - """Create a new portfolio. Returns the created portfolio.""" - request = CreatePortfolioRequest.from_dict(body) - response = op_create_portfolio( + def create_portfolio_block( + self, graph_id: str, body: dict[str, Any] + ) -> dict[str, Any]: + """Create a portfolio with optional initial positions in one atomic operation.""" + request = CreatePortfolioBlockRequest.from_dict(body) + response = op_create_portfolio_block( graph_id=graph_id, body=request, client=self._get_client() ) - envelope = self._call_op("Create portfolio", response) + envelope = self._call_op("Create portfolio block", response) return envelope.result or {} - def update_portfolio( + def update_portfolio_block( self, graph_id: str, portfolio_id: str, updates: dict[str, Any], ) -> dict[str, Any]: - """Update a portfolio's metadata. Only provided fields are applied.""" + """Update portfolio metadata and/or apply position deltas (add/update/dispose).""" body_dict = {**updates, "portfolio_id": portfolio_id} - body = UpdatePortfolioOperation.from_dict(body_dict) - response = op_update_portfolio( + body = UpdatePortfolioBlockOperation.from_dict(body_dict) + response = op_update_portfolio_block( graph_id=graph_id, body=body, client=self._get_client() ) - envelope = self._call_op("Update portfolio", response) + envelope = self._call_op("Update portfolio block", response) return envelope.result or {} - def delete_portfolio(self, graph_id: str, portfolio_id: str) -> dict[str, Any]: - """Delete a portfolio. Fails with 409 if it still has active positions.""" - body = DeletePortfolioOperation(portfolio_id=portfolio_id) - response = op_delete_portfolio( + def delete_portfolio_block( + self, + graph_id: str, + portfolio_id: str, + confirm_active_positions: bool = False, + ) -> dict[str, Any]: + """Delete a portfolio and all its positions. Requires `confirm_active_positions=True` when active positions exist.""" + body = DeletePortfolioBlockOperation( + portfolio_id=portfolio_id, + confirm_active_positions=confirm_active_positions, + ) + response = op_delete_portfolio_block( graph_id=graph_id, body=body, client=self._get_client() ) - envelope = self._call_op("Delete portfolio", response) + envelope = self._call_op("Delete portfolio block", response) return envelope.result if envelope.result is not None else {"deleted": True} # ── Securities ────────────────────────────────────────────────────── @@ -241,7 +243,7 @@ def delete_security(self, graph_id: str, security_id: str) -> dict[str, Any]: envelope = self._call_op("Delete security", response) return envelope.result if envelope.result is not None else {"deleted": True} - # ── Positions ─────────────────────────────────────────────────────── + # ── Positions (read-only — writes go through portfolio block) ──────── def list_positions( self, @@ -271,39 +273,6 @@ def get_position(self, graph_id: str, position_id: str) -> dict[str, Any] | None data = self._query(graph_id, GET_POSITION_QUERY, {"positionId": position_id}) return parse_position(data) - def create_position(self, graph_id: str, body: dict[str, Any]) -> dict[str, Any]: - """Create a new position.""" - request = CreatePositionRequest.from_dict(body) - response = op_create_position( - graph_id=graph_id, body=request, client=self._get_client() - ) - envelope = self._call_op("Create position", response) - return envelope.result or {} - - def update_position( - self, - graph_id: str, - position_id: str, - updates: dict[str, Any], - ) -> dict[str, Any]: - """Update a position. Only provided fields are applied.""" - body_dict = {**updates, "position_id": position_id} - body = UpdatePositionOperation.from_dict(body_dict) - response = op_update_position( - graph_id=graph_id, body=body, client=self._get_client() - ) - envelope = self._call_op("Update position", response) - return envelope.result or {} - - def delete_position(self, graph_id: str, position_id: str) -> dict[str, Any]: - """Delete (dispose) a position.""" - body = DeletePositionOperation(position_id=position_id) - response = op_delete_position( - graph_id=graph_id, body=body, client=self._get_client() - ) - envelope = self._call_op("Delete position", response) - return envelope.result if envelope.result is not None else {"deleted": True} - # ── Holdings (aggregation) ───────────────────────────────────────── def get_holdings(self, graph_id: str, portfolio_id: str) -> dict[str, Any] | None: diff --git a/robosystems_client/graphql/queries/investor/__init__.py b/robosystems_client/graphql/queries/investor/__init__.py index 907e3ff..bca2ad0 100644 --- a/robosystems_client/graphql/queries/investor/__init__.py +++ b/robosystems_client/graphql/queries/investor/__init__.py @@ -1,8 +1,12 @@ """Investor-domain GraphQL queries and parsers. -All 7 investor reads from the `/extensions/{graph_id}/graphql` endpoint +All investor reads from the `/extensions/{graph_id}/graphql` endpoint live here. Same pattern as the ledger queries: one constant + one `parse_*` helper per query, returning camelCase → snake_case dicts. + +Portfolio reads go through the `portfolioBlock` molecule envelope — +the singular `portfolio(id)` field has been retired in favor of the +block. List portfolios remains for collection scans. """ from __future__ import annotations @@ -33,20 +37,37 @@ def parse_portfolios(data: dict[str, Any]) -> dict[str, Any] | None: return keys_to_snake(p) if p is not None else None -GET_PORTFOLIO_QUERY = """ -query GetInvestorPortfolio($portfolioId: String!) { - portfolio(portfolioId: $portfolioId) { +# ── Portfolio Block (molecule envelope) ──────────────────────────────── + +GET_PORTFOLIO_BLOCK_QUERY = """ +query GetInvestorPortfolioBlock($portfolioId: String!) { + portfolioBlock(portfolioId: $portfolioId) { id name description strategy inceptionDate baseCurrency + owner { id name sourceGraphId } + positions { + id quantity quantityType + costBasisDollars currentValueDollars + valuationDate valuationSource + acquisitionDate + status notes + security { + id name securityType securitySubtype + isActive sourceGraphId + issuer { id name sourceGraphId } + } + } + totalCostBasisDollars totalCurrentValueDollars + activePositionCount createdAt updatedAt } } """.strip() -def parse_portfolio(data: dict[str, Any]) -> dict[str, Any] | None: - p = data.get("portfolio") - return keys_to_snake(p) if p is not None else None +def parse_portfolio_block(data: dict[str, Any]) -> dict[str, Any] | None: + block = data.get("portfolioBlock") + return keys_to_snake(block) if block is not None else None # ── Securities ───────────────────────────────────────────────────────── diff --git a/robosystems_client/models/__init__.py b/robosystems_client/models/__init__.py index f47f2bb..adc1672 100644 --- a/robosystems_client/models/__init__.py +++ b/robosystems_client/models/__init__.py @@ -90,8 +90,7 @@ from .create_mapping_association_operation_association_type import ( CreateMappingAssociationOperationAssociationType, ) -from .create_portfolio_request import CreatePortfolioRequest -from .create_position_request import CreatePositionRequest +from .create_portfolio_block_request import CreatePortfolioBlockRequest from .create_publish_list_request import CreatePublishListRequest from .create_report_request import CreateReportRequest from .create_repository_subscription_request import CreateRepositorySubscriptionRequest @@ -127,8 +126,7 @@ ) from .delete_journal_entry_request import DeleteJournalEntryRequest from .delete_mapping_association_operation import DeleteMappingAssociationOperation -from .delete_portfolio_operation import DeletePortfolioOperation -from .delete_position_operation import DeletePositionOperation +from .delete_portfolio_block_operation import DeletePortfolioBlockOperation from .delete_publish_list_operation import DeletePublishListOperation from .delete_report_operation import DeleteReportOperation from .delete_security_operation import DeleteSecurityOperation @@ -266,6 +264,12 @@ from .performance_insights_slow_queries_item import PerformanceInsightsSlowQueriesItem from .period_spec import PeriodSpec from .portal_session_response import PortalSessionResponse +from .portfolio_block_portfolio_fields import PortfolioBlockPortfolioFields +from .portfolio_block_portfolio_patch import PortfolioBlockPortfolioPatch +from .portfolio_block_position_add import PortfolioBlockPositionAdd +from .portfolio_block_position_dispose import PortfolioBlockPositionDispose +from .portfolio_block_position_update import PortfolioBlockPositionUpdate +from .portfolio_block_positions import PortfolioBlockPositions from .query_limits import QueryLimits from .quick_books_connection_config import QuickBooksConnectionConfig from .rate_limits import RateLimits @@ -396,8 +400,7 @@ from .update_member_role_request import UpdateMemberRoleRequest from .update_org_request import UpdateOrgRequest from .update_password_request import UpdatePasswordRequest -from .update_portfolio_operation import UpdatePortfolioOperation -from .update_position_operation import UpdatePositionOperation +from .update_portfolio_block_operation import UpdatePortfolioBlockOperation from .update_publish_list_operation import UpdatePublishListOperation from .update_security_operation import UpdateSecurityOperation from .update_security_operation_terms_type_0 import UpdateSecurityOperationTermsType0 @@ -488,8 +491,7 @@ "CreateInformationBlockRequestPayload", "CreateMappingAssociationOperation", "CreateMappingAssociationOperationAssociationType", - "CreatePortfolioRequest", - "CreatePositionRequest", + "CreatePortfolioBlockRequest", "CreatePublishListRequest", "CreateReportRequest", "CreateRepositorySubscriptionRequest", @@ -519,8 +521,7 @@ "DeleteInformationBlockRequestPayload", "DeleteJournalEntryRequest", "DeleteMappingAssociationOperation", - "DeletePortfolioOperation", - "DeletePositionOperation", + "DeletePortfolioBlockOperation", "DeletePublishListOperation", "DeleteReportOperation", "DeleteSecurityOperation", @@ -642,6 +643,12 @@ "PerformanceInsightsSlowQueriesItem", "PeriodSpec", "PortalSessionResponse", + "PortfolioBlockPortfolioFields", + "PortfolioBlockPortfolioPatch", + "PortfolioBlockPositionAdd", + "PortfolioBlockPositionDispose", + "PortfolioBlockPositions", + "PortfolioBlockPositionUpdate", "QueryLimits", "QuickBooksConnectionConfig", "RateLimits", @@ -738,8 +745,7 @@ "UpdateMemberRoleRequest", "UpdateOrgRequest", "UpdatePasswordRequest", - "UpdatePortfolioOperation", - "UpdatePositionOperation", + "UpdatePortfolioBlockOperation", "UpdatePublishListOperation", "UpdateSecurityOperation", "UpdateSecurityOperationTermsType0", diff --git a/robosystems_client/models/create_portfolio_block_request.py b/robosystems_client/models/create_portfolio_block_request.py new file mode 100644 index 0000000..dd5eca0 --- /dev/null +++ b/robosystems_client/models/create_portfolio_block_request.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.portfolio_block_portfolio_fields import PortfolioBlockPortfolioFields + from ..models.portfolio_block_position_add import PortfolioBlockPositionAdd + + +T = TypeVar("T", bound="CreatePortfolioBlockRequest") + + +@_attrs_define +class CreatePortfolioBlockRequest: + """CQRS body for `POST /operations/create-portfolio-block`. + + Whole envelope validated before any DB write; the portfolio + initial + positions land in one transaction. Each `position` references an + existing `security_id` — the operation does not mint securities. + + Attributes: + portfolio (PortfolioBlockPortfolioFields): Fields settable on the portfolio core when creating a block. + positions (list[PortfolioBlockPositionAdd] | Unset): + """ + + portfolio: PortfolioBlockPortfolioFields + positions: list[PortfolioBlockPositionAdd] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + portfolio = self.portfolio.to_dict() + + positions: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.positions, Unset): + positions = [] + for positions_item_data in self.positions: + positions_item = positions_item_data.to_dict() + positions.append(positions_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "portfolio": portfolio, + } + ) + if positions is not UNSET: + field_dict["positions"] = positions + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.portfolio_block_portfolio_fields import PortfolioBlockPortfolioFields + from ..models.portfolio_block_position_add import PortfolioBlockPositionAdd + + d = dict(src_dict) + portfolio = PortfolioBlockPortfolioFields.from_dict(d.pop("portfolio")) + + _positions = d.pop("positions", UNSET) + positions: list[PortfolioBlockPositionAdd] | Unset = UNSET + if _positions is not UNSET: + positions = [] + for positions_item_data in _positions: + positions_item = PortfolioBlockPositionAdd.from_dict(positions_item_data) + + positions.append(positions_item) + + create_portfolio_block_request = cls( + portfolio=portfolio, + positions=positions, + ) + + create_portfolio_block_request.additional_properties = d + return create_portfolio_block_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/delete_portfolio_operation.py b/robosystems_client/models/delete_portfolio_block_operation.py similarity index 53% rename from robosystems_client/models/delete_portfolio_operation.py rename to robosystems_client/models/delete_portfolio_block_operation.py index a37cd0d..d53abe6 100644 --- a/robosystems_client/models/delete_portfolio_operation.py +++ b/robosystems_client/models/delete_portfolio_block_operation.py @@ -6,23 +6,34 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field -T = TypeVar("T", bound="DeletePortfolioOperation") +from ..types import UNSET, Unset + +T = TypeVar("T", bound="DeletePortfolioBlockOperation") @_attrs_define -class DeletePortfolioOperation: - """CQRS body for `POST /operations/delete-portfolio`. +class DeletePortfolioBlockOperation: + """CQRS body for `POST /operations/delete-portfolio-block`. + + Cascade-deletes the portfolio plus all of its positions. When the + portfolio still has active positions, the operation is rejected + unless `confirm_active_positions=true` is set — safety belt to + prevent accidental cascade. - Attributes: - portfolio_id (str): Target portfolio ID. + Attributes: + portfolio_id (str): Target portfolio ID. + confirm_active_positions (bool | Unset): Default: False. """ portfolio_id: str + confirm_active_positions: bool | Unset = False additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: portfolio_id = self.portfolio_id + confirm_active_positions = self.confirm_active_positions + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -30,6 +41,8 @@ def to_dict(self) -> dict[str, Any]: "portfolio_id": portfolio_id, } ) + if confirm_active_positions is not UNSET: + field_dict["confirm_active_positions"] = confirm_active_positions return field_dict @@ -38,12 +51,15 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: d = dict(src_dict) portfolio_id = d.pop("portfolio_id") - delete_portfolio_operation = cls( + confirm_active_positions = d.pop("confirm_active_positions", UNSET) + + delete_portfolio_block_operation = cls( portfolio_id=portfolio_id, + confirm_active_positions=confirm_active_positions, ) - delete_portfolio_operation.additional_properties = d - return delete_portfolio_operation + delete_portfolio_block_operation.additional_properties = d + return delete_portfolio_block_operation @property def additional_keys(self) -> list[str]: diff --git a/robosystems_client/models/delete_position_operation.py b/robosystems_client/models/delete_position_operation.py deleted file mode 100644 index 3e57137..0000000 --- a/robosystems_client/models/delete_position_operation.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define -from attrs import field as _attrs_field - -T = TypeVar("T", bound="DeletePositionOperation") - - -@_attrs_define -class DeletePositionOperation: - """CQRS body for `POST /operations/delete-position` (soft delete). - - Attributes: - position_id (str): Target position ID. - """ - - position_id: str - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - position_id = self.position_id - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "position_id": position_id, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - position_id = d.pop("position_id") - - delete_position_operation = cls( - position_id=position_id, - ) - - delete_position_operation.additional_properties = d - return delete_position_operation - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/robosystems_client/models/create_portfolio_request.py b/robosystems_client/models/portfolio_block_portfolio_fields.py similarity index 81% rename from robosystems_client/models/create_portfolio_request.py rename to robosystems_client/models/portfolio_block_portfolio_fields.py index d0f94ac..79b0593 100644 --- a/robosystems_client/models/create_portfolio_request.py +++ b/robosystems_client/models/portfolio_block_portfolio_fields.py @@ -10,18 +10,20 @@ from ..types import UNSET, Unset -T = TypeVar("T", bound="CreatePortfolioRequest") +T = TypeVar("T", bound="PortfolioBlockPortfolioFields") @_attrs_define -class CreatePortfolioRequest: - """ +class PortfolioBlockPortfolioFields: + """Fields settable on the portfolio core when creating a block. + Attributes: name (str): description (None | str | Unset): strategy (None | str | Unset): inception_date (datetime.date | None | Unset): base_currency (str | Unset): Default: 'USD'. + entity_id (None | str | Unset): """ name: str @@ -29,6 +31,7 @@ class CreatePortfolioRequest: strategy: None | str | Unset = UNSET inception_date: datetime.date | None | Unset = UNSET base_currency: str | Unset = "USD" + entity_id: None | str | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -56,6 +59,12 @@ def to_dict(self) -> dict[str, Any]: base_currency = self.base_currency + entity_id: None | str | Unset + if isinstance(self.entity_id, Unset): + entity_id = UNSET + else: + entity_id = self.entity_id + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -71,6 +80,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["inception_date"] = inception_date if base_currency is not UNSET: field_dict["base_currency"] = base_currency + if entity_id is not UNSET: + field_dict["entity_id"] = entity_id return field_dict @@ -116,16 +127,26 @@ def _parse_inception_date(data: object) -> datetime.date | None | Unset: base_currency = d.pop("base_currency", UNSET) - create_portfolio_request = cls( + def _parse_entity_id(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + entity_id = _parse_entity_id(d.pop("entity_id", UNSET)) + + portfolio_block_portfolio_fields = cls( name=name, description=description, strategy=strategy, inception_date=inception_date, base_currency=base_currency, + entity_id=entity_id, ) - create_portfolio_request.additional_properties = d - return create_portfolio_request + portfolio_block_portfolio_fields.additional_properties = d + return portfolio_block_portfolio_fields @property def additional_keys(self) -> list[str]: diff --git a/robosystems_client/models/update_portfolio_operation.py b/robosystems_client/models/portfolio_block_portfolio_patch.py similarity index 78% rename from robosystems_client/models/update_portfolio_operation.py rename to robosystems_client/models/portfolio_block_portfolio_patch.py index fada6a4..8b419d7 100644 --- a/robosystems_client/models/update_portfolio_operation.py +++ b/robosystems_client/models/portfolio_block_portfolio_patch.py @@ -10,36 +10,31 @@ from ..types import UNSET, Unset -T = TypeVar("T", bound="UpdatePortfolioOperation") +T = TypeVar("T", bound="PortfolioBlockPortfolioPatch") @_attrs_define -class UpdatePortfolioOperation: - """CQRS body for `POST /operations/update-portfolio`. - - Folds `portfolio_id` into the payload so REST + MCP share one body - type via the registrar. Unset fields are ignored (partial update). - - Attributes: - portfolio_id (str): Target portfolio ID. - name (None | str | Unset): - description (None | str | Unset): - strategy (None | str | Unset): - inception_date (datetime.date | None | Unset): - base_currency (None | str | Unset): +class PortfolioBlockPortfolioPatch: + """Patchable portfolio fields on `update-portfolio-block`. Unset fields ignored. + + Attributes: + name (None | str | Unset): + description (None | str | Unset): + strategy (None | str | Unset): + inception_date (datetime.date | None | Unset): + base_currency (None | str | Unset): + entity_id (None | str | Unset): """ - portfolio_id: str name: None | str | Unset = UNSET description: None | str | Unset = UNSET strategy: None | str | Unset = UNSET inception_date: datetime.date | None | Unset = UNSET base_currency: None | str | Unset = UNSET + entity_id: None | str | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: - portfolio_id = self.portfolio_id - name: None | str | Unset if isinstance(self.name, Unset): name = UNSET @@ -72,13 +67,15 @@ def to_dict(self) -> dict[str, Any]: else: base_currency = self.base_currency + entity_id: None | str | Unset + if isinstance(self.entity_id, Unset): + entity_id = UNSET + else: + entity_id = self.entity_id + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) - field_dict.update( - { - "portfolio_id": portfolio_id, - } - ) + field_dict.update({}) if name is not UNSET: field_dict["name"] = name if description is not UNSET: @@ -89,13 +86,14 @@ def to_dict(self) -> dict[str, Any]: field_dict["inception_date"] = inception_date if base_currency is not UNSET: field_dict["base_currency"] = base_currency + if entity_id is not UNSET: + field_dict["entity_id"] = entity_id return field_dict @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: d = dict(src_dict) - portfolio_id = d.pop("portfolio_id") def _parse_name(data: object) -> None | str | Unset: if data is None: @@ -150,17 +148,26 @@ def _parse_base_currency(data: object) -> None | str | Unset: base_currency = _parse_base_currency(d.pop("base_currency", UNSET)) - update_portfolio_operation = cls( - portfolio_id=portfolio_id, + def _parse_entity_id(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + entity_id = _parse_entity_id(d.pop("entity_id", UNSET)) + + portfolio_block_portfolio_patch = cls( name=name, description=description, strategy=strategy, inception_date=inception_date, base_currency=base_currency, + entity_id=entity_id, ) - update_portfolio_operation.additional_properties = d - return update_portfolio_operation + portfolio_block_portfolio_patch.additional_properties = d + return portfolio_block_portfolio_patch @property def additional_keys(self) -> list[str]: diff --git a/robosystems_client/models/create_position_request.py b/robosystems_client/models/portfolio_block_position_add.py similarity index 86% rename from robosystems_client/models/create_position_request.py rename to robosystems_client/models/portfolio_block_position_add.py index ffb4791..d3a432f 100644 --- a/robosystems_client/models/create_position_request.py +++ b/robosystems_client/models/portfolio_block_position_add.py @@ -10,27 +10,29 @@ from ..types import UNSET, Unset -T = TypeVar("T", bound="CreatePositionRequest") +T = TypeVar("T", bound="PortfolioBlockPositionAdd") @_attrs_define -class CreatePositionRequest: - """ - Attributes: - portfolio_id (str): - security_id (str): - quantity (float): - quantity_type (str | Unset): Default: 'shares'. - cost_basis (int | Unset): Default: 0. - currency (str | Unset): Default: 'USD'. - current_value (int | None | Unset): - valuation_date (datetime.date | None | Unset): - valuation_source (None | str | Unset): - acquisition_date (datetime.date | None | Unset): - notes (None | str | Unset): +class PortfolioBlockPositionAdd: + """A single new position to mint inside a portfolio-block create/update. + + References an existing security; this surface never creates securities + (Master Data CRUD owns that lifecycle). + + Attributes: + security_id (str): + quantity (float): + quantity_type (str | Unset): Default: 'shares'. + cost_basis (int | Unset): Default: 0. + currency (str | Unset): Default: 'USD'. + current_value (int | None | Unset): + valuation_date (datetime.date | None | Unset): + valuation_source (None | str | Unset): + acquisition_date (datetime.date | None | Unset): + notes (None | str | Unset): """ - portfolio_id: str security_id: str quantity: float quantity_type: str | Unset = "shares" @@ -44,8 +46,6 @@ class CreatePositionRequest: additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: - portfolio_id = self.portfolio_id - security_id = self.security_id quantity = self.quantity @@ -94,7 +94,6 @@ def to_dict(self) -> dict[str, Any]: field_dict.update(self.additional_properties) field_dict.update( { - "portfolio_id": portfolio_id, "security_id": security_id, "quantity": quantity, } @@ -121,8 +120,6 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: d = dict(src_dict) - portfolio_id = d.pop("portfolio_id") - security_id = d.pop("security_id") quantity = d.pop("quantity") @@ -194,8 +191,7 @@ def _parse_notes(data: object) -> None | str | Unset: notes = _parse_notes(d.pop("notes", UNSET)) - create_position_request = cls( - portfolio_id=portfolio_id, + portfolio_block_position_add = cls( security_id=security_id, quantity=quantity, quantity_type=quantity_type, @@ -208,8 +204,8 @@ def _parse_notes(data: object) -> None | str | Unset: notes=notes, ) - create_position_request.additional_properties = d - return create_position_request + portfolio_block_position_add.additional_properties = d + return portfolio_block_position_add @property def additional_keys(self) -> list[str]: diff --git a/robosystems_client/models/portfolio_block_position_dispose.py b/robosystems_client/models/portfolio_block_position_dispose.py new file mode 100644 index 0000000..b07c63a --- /dev/null +++ b/robosystems_client/models/portfolio_block_position_dispose.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="PortfolioBlockPositionDispose") + + +@_attrs_define +class PortfolioBlockPositionDispose: + """Dispose-by-id for an existing position in `update-portfolio-block`. + + Soft-delete: status flips to `disposed` and `disposition_date` is + stamped. `disposition_reason`, when supplied, is recorded under + `metadata.disposition_reason`. + + Attributes: + id (str): + disposition_reason (None | str | Unset): + """ + + id: str + disposition_reason: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + id = self.id + + disposition_reason: None | str | Unset + if isinstance(self.disposition_reason, Unset): + disposition_reason = UNSET + else: + disposition_reason = self.disposition_reason + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + } + ) + if disposition_reason is not UNSET: + field_dict["disposition_reason"] = disposition_reason + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = d.pop("id") + + def _parse_disposition_reason(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + disposition_reason = _parse_disposition_reason(d.pop("disposition_reason", UNSET)) + + portfolio_block_position_dispose = cls( + id=id, + disposition_reason=disposition_reason, + ) + + portfolio_block_position_dispose.additional_properties = d + return portfolio_block_position_dispose + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/update_position_operation.py b/robosystems_client/models/portfolio_block_position_update.py similarity index 72% rename from robosystems_client/models/update_position_operation.py rename to robosystems_client/models/portfolio_block_position_update.py index ca5824c..1e5a096 100644 --- a/robosystems_client/models/update_position_operation.py +++ b/robosystems_client/models/portfolio_block_position_update.py @@ -10,28 +10,28 @@ from ..types import UNSET, Unset -T = TypeVar("T", bound="UpdatePositionOperation") +T = TypeVar("T", bound="PortfolioBlockPositionUpdate") @_attrs_define -class UpdatePositionOperation: - """CQRS body for `POST /operations/update-position`. - - Attributes: - position_id (str): Target position ID. - quantity (float | None | Unset): - quantity_type (None | str | Unset): - cost_basis (int | None | Unset): - current_value (int | None | Unset): - valuation_date (datetime.date | None | Unset): - valuation_source (None | str | Unset): - acquisition_date (datetime.date | None | Unset): - disposition_date (datetime.date | None | Unset): - status (None | str | Unset): - notes (None | str | Unset): +class PortfolioBlockPositionUpdate: + """Patch-by-id for an existing position in `update-portfolio-block`. + + Unset fields are ignored; `id` is the only required field. + + Attributes: + id (str): + quantity (float | None | Unset): + quantity_type (None | str | Unset): + cost_basis (int | None | Unset): + current_value (int | None | Unset): + valuation_date (datetime.date | None | Unset): + valuation_source (None | str | Unset): + acquisition_date (datetime.date | None | Unset): + notes (None | str | Unset): """ - position_id: str + id: str quantity: float | None | Unset = UNSET quantity_type: None | str | Unset = UNSET cost_basis: int | None | Unset = UNSET @@ -39,13 +39,11 @@ class UpdatePositionOperation: valuation_date: datetime.date | None | Unset = UNSET valuation_source: None | str | Unset = UNSET acquisition_date: datetime.date | None | Unset = UNSET - disposition_date: datetime.date | None | Unset = UNSET - status: None | str | Unset = UNSET notes: None | str | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: - position_id = self.position_id + id = self.id quantity: float | None | Unset if isinstance(self.quantity, Unset): @@ -93,20 +91,6 @@ def to_dict(self) -> dict[str, Any]: else: acquisition_date = self.acquisition_date - disposition_date: None | str | Unset - if isinstance(self.disposition_date, Unset): - disposition_date = UNSET - elif isinstance(self.disposition_date, datetime.date): - disposition_date = self.disposition_date.isoformat() - else: - disposition_date = self.disposition_date - - status: None | str | Unset - if isinstance(self.status, Unset): - status = UNSET - else: - status = self.status - notes: None | str | Unset if isinstance(self.notes, Unset): notes = UNSET @@ -117,7 +101,7 @@ def to_dict(self) -> dict[str, Any]: field_dict.update(self.additional_properties) field_dict.update( { - "position_id": position_id, + "id": id, } ) if quantity is not UNSET: @@ -134,10 +118,6 @@ def to_dict(self) -> dict[str, Any]: field_dict["valuation_source"] = valuation_source if acquisition_date is not UNSET: field_dict["acquisition_date"] = acquisition_date - if disposition_date is not UNSET: - field_dict["disposition_date"] = disposition_date - if status is not UNSET: - field_dict["status"] = status if notes is not UNSET: field_dict["notes"] = notes @@ -146,7 +126,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: d = dict(src_dict) - position_id = d.pop("position_id") + id = d.pop("id") def _parse_quantity(data: object) -> float | None | Unset: if data is None: @@ -227,32 +207,6 @@ def _parse_acquisition_date(data: object) -> datetime.date | None | Unset: acquisition_date = _parse_acquisition_date(d.pop("acquisition_date", UNSET)) - def _parse_disposition_date(data: object) -> datetime.date | None | Unset: - if data is None: - return data - if isinstance(data, Unset): - return data - try: - if not isinstance(data, str): - raise TypeError() - disposition_date_type_0 = isoparse(data).date() - - return disposition_date_type_0 - except (TypeError, ValueError, AttributeError, KeyError): - pass - return cast(datetime.date | None | Unset, data) - - disposition_date = _parse_disposition_date(d.pop("disposition_date", UNSET)) - - def _parse_status(data: object) -> None | str | Unset: - if data is None: - return data - if isinstance(data, Unset): - return data - return cast(None | str | Unset, data) - - status = _parse_status(d.pop("status", UNSET)) - def _parse_notes(data: object) -> None | str | Unset: if data is None: return data @@ -262,8 +216,8 @@ def _parse_notes(data: object) -> None | str | Unset: notes = _parse_notes(d.pop("notes", UNSET)) - update_position_operation = cls( - position_id=position_id, + portfolio_block_position_update = cls( + id=id, quantity=quantity, quantity_type=quantity_type, cost_basis=cost_basis, @@ -271,13 +225,11 @@ def _parse_notes(data: object) -> None | str | Unset: valuation_date=valuation_date, valuation_source=valuation_source, acquisition_date=acquisition_date, - disposition_date=disposition_date, - status=status, notes=notes, ) - update_position_operation.additional_properties = d - return update_position_operation + portfolio_block_position_update.additional_properties = d + return portfolio_block_position_update @property def additional_keys(self) -> list[str]: diff --git a/robosystems_client/models/portfolio_block_positions.py b/robosystems_client/models/portfolio_block_positions.py new file mode 100644 index 0000000..889eff1 --- /dev/null +++ b/robosystems_client/models/portfolio_block_positions.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.portfolio_block_position_add import PortfolioBlockPositionAdd + from ..models.portfolio_block_position_dispose import PortfolioBlockPositionDispose + from ..models.portfolio_block_position_update import PortfolioBlockPositionUpdate + + +T = TypeVar("T", bound="PortfolioBlockPositions") + + +@_attrs_define +class PortfolioBlockPositions: + """Position deltas applied atomically inside `update-portfolio-block`. + + Attributes: + add (list[PortfolioBlockPositionAdd] | Unset): + update (list[PortfolioBlockPositionUpdate] | Unset): + dispose (list[PortfolioBlockPositionDispose] | Unset): + """ + + add: list[PortfolioBlockPositionAdd] | Unset = UNSET + update: list[PortfolioBlockPositionUpdate] | Unset = UNSET + dispose: list[PortfolioBlockPositionDispose] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + add: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.add, Unset): + add = [] + for add_item_data in self.add: + add_item = add_item_data.to_dict() + add.append(add_item) + + update: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.update, Unset): + update = [] + for update_item_data in self.update: + update_item = update_item_data.to_dict() + update.append(update_item) + + dispose: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.dispose, Unset): + dispose = [] + for dispose_item_data in self.dispose: + dispose_item = dispose_item_data.to_dict() + dispose.append(dispose_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if add is not UNSET: + field_dict["add"] = add + if update is not UNSET: + field_dict["update"] = update + if dispose is not UNSET: + field_dict["dispose"] = dispose + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.portfolio_block_position_add import PortfolioBlockPositionAdd + from ..models.portfolio_block_position_dispose import PortfolioBlockPositionDispose + from ..models.portfolio_block_position_update import PortfolioBlockPositionUpdate + + d = dict(src_dict) + _add = d.pop("add", UNSET) + add: list[PortfolioBlockPositionAdd] | Unset = UNSET + if _add is not UNSET: + add = [] + for add_item_data in _add: + add_item = PortfolioBlockPositionAdd.from_dict(add_item_data) + + add.append(add_item) + + _update = d.pop("update", UNSET) + update: list[PortfolioBlockPositionUpdate] | Unset = UNSET + if _update is not UNSET: + update = [] + for update_item_data in _update: + update_item = PortfolioBlockPositionUpdate.from_dict(update_item_data) + + update.append(update_item) + + _dispose = d.pop("dispose", UNSET) + dispose: list[PortfolioBlockPositionDispose] | Unset = UNSET + if _dispose is not UNSET: + dispose = [] + for dispose_item_data in _dispose: + dispose_item = PortfolioBlockPositionDispose.from_dict(dispose_item_data) + + dispose.append(dispose_item) + + portfolio_block_positions = cls( + add=add, + update=update, + dispose=dispose, + ) + + portfolio_block_positions.additional_properties = d + return portfolio_block_positions + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/update_portfolio_block_operation.py b/robosystems_client/models/update_portfolio_block_operation.py new file mode 100644 index 0000000..e32498e --- /dev/null +++ b/robosystems_client/models/update_portfolio_block_operation.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.portfolio_block_portfolio_patch import PortfolioBlockPortfolioPatch + from ..models.portfolio_block_positions import PortfolioBlockPositions + + +T = TypeVar("T", bound="UpdatePortfolioBlockOperation") + + +@_attrs_define +class UpdatePortfolioBlockOperation: + """CQRS body for `POST /operations/update-portfolio-block`. + + Carries an optional patch to portfolio fields and three position + delta lists (`add` / `update` / `dispose`). All apply atomically + with the portfolio patch — partial failures roll back. + + Attributes: + portfolio_id (str): Target portfolio ID. + portfolio (PortfolioBlockPortfolioPatch | Unset): Patchable portfolio fields on `update-portfolio-block`. Unset + fields ignored. + positions (PortfolioBlockPositions | Unset): Position deltas applied atomically inside `update-portfolio-block`. + """ + + portfolio_id: str + portfolio: PortfolioBlockPortfolioPatch | Unset = UNSET + positions: PortfolioBlockPositions | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + portfolio_id = self.portfolio_id + + portfolio: dict[str, Any] | Unset = UNSET + if not isinstance(self.portfolio, Unset): + portfolio = self.portfolio.to_dict() + + positions: dict[str, Any] | Unset = UNSET + if not isinstance(self.positions, Unset): + positions = self.positions.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "portfolio_id": portfolio_id, + } + ) + if portfolio is not UNSET: + field_dict["portfolio"] = portfolio + if positions is not UNSET: + field_dict["positions"] = positions + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.portfolio_block_portfolio_patch import PortfolioBlockPortfolioPatch + from ..models.portfolio_block_positions import PortfolioBlockPositions + + d = dict(src_dict) + portfolio_id = d.pop("portfolio_id") + + _portfolio = d.pop("portfolio", UNSET) + portfolio: PortfolioBlockPortfolioPatch | Unset + if isinstance(_portfolio, Unset): + portfolio = UNSET + else: + portfolio = PortfolioBlockPortfolioPatch.from_dict(_portfolio) + + _positions = d.pop("positions", UNSET) + positions: PortfolioBlockPositions | Unset + if isinstance(_positions, Unset): + positions = UNSET + else: + positions = PortfolioBlockPositions.from_dict(_positions) + + update_portfolio_block_operation = cls( + portfolio_id=portfolio_id, + portfolio=portfolio, + positions=positions, + ) + + update_portfolio_block_operation.additional_properties = d + return update_portfolio_block_operation + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/tests/test_investor_client.py b/tests/test_investor_client.py index f2e0221..6370c89 100644 --- a/tests/test_investor_client.py +++ b/tests/test_investor_client.py @@ -41,6 +41,45 @@ def _mock_response( return resp +_SAMPLE_BLOCK_RESULT = { + "id": "port_new", + "name": "New Fund", + "description": None, + "strategy": "growth", + "inception_date": "2026-01-01", + "base_currency": "USD", + "owner": {"id": "ent_owner", "name": "Family Office", "source_graph_id": None}, + "positions": [ + { + "id": "pos_new", + "quantity": 100, + "quantity_type": "shares", + "cost_basis_dollars": 1000, + "current_value_dollars": None, + "valuation_date": None, + "valuation_source": None, + "acquisition_date": "2026-01-01", + "status": "active", + "notes": None, + "security": { + "id": "sec_1", + "name": "Series A Preferred", + "security_type": "common_stock", + "security_subtype": None, + "is_active": True, + "issuer": {"id": "ent_issuer", "name": "ACME", "source_graph_id": "kg_acme"}, + "source_graph_id": "kg_acme", + }, + } + ], + "total_cost_basis_dollars": 1000, + "total_current_value_dollars": None, + "active_position_count": 1, + "created_at": "2026-04-14T00:00:00Z", + "updated_at": "2026-04-14T00:00:00Z", +} + + @pytest.mark.unit class TestInvestorClientInit: def test_initialization(self, mock_config): @@ -87,81 +126,107 @@ def test_list_portfolios(self, mock_execute, mock_config, graph_id): assert result["portfolios"][0]["base_currency"] == "USD" @patch("robosystems_client.graphql.client.GraphQLClient.execute") - def test_get_portfolio_missing(self, mock_execute, mock_config, graph_id): - mock_execute.return_value = {"portfolio": None} + def test_get_portfolio_block_missing(self, mock_execute, mock_config, graph_id): + mock_execute.return_value = {"portfolioBlock": None} client = InvestorClient(mock_config) - assert client.get_portfolio(graph_id, "port_x") is None + assert client.get_portfolio_block(graph_id, "port_x") is None + + @patch("robosystems_client.graphql.client.GraphQLClient.execute") + def test_get_portfolio_block_found(self, mock_execute, mock_config, graph_id): + mock_execute.return_value = { + "portfolioBlock": { + "id": "port_1", + "name": "Seed Fund I", + "description": None, + "strategy": "early-stage", + "inceptionDate": "2024-01-01", + "baseCurrency": "USD", + "owner": {"id": "ent_owner", "name": "FO", "sourceGraphId": None}, + "positions": [], + "totalCostBasisDollars": 0, + "totalCurrentValueDollars": None, + "activePositionCount": 0, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + } + } + client = InvestorClient(mock_config) + result = client.get_portfolio_block(graph_id, "port_1") + assert result is not None + assert result["id"] == "port_1" + assert result["owner"]["name"] == "FO" + assert result["positions"] == [] -# ── Portfolio writes ─────────────────────────────────────────────────── +# ── Portfolio block writes ───────────────────────────────────────────── @pytest.mark.unit -class TestPortfolioWrites: - @patch("robosystems_client.clients.investor_client.op_create_portfolio") - def test_create_portfolio(self, mock_op, mock_config, graph_id): - envelope = _envelope( - "create-portfolio", - {"id": "port_new", "name": "New Fund", "base_currency": "USD"}, - ) +class TestPortfolioBlockWrites: + @patch("robosystems_client.clients.investor_client.op_create_portfolio_block") + def test_create_portfolio_block(self, mock_op, mock_config, graph_id): + envelope = _envelope("create-portfolio-block", _SAMPLE_BLOCK_RESULT) mock_op.return_value = _mock_response(envelope) client = InvestorClient(mock_config) - result = client.create_portfolio(graph_id, {"name": "New Fund"}) + result = client.create_portfolio_block( + graph_id, + { + "portfolio": {"name": "New Fund", "strategy": "growth"}, + "positions": [{"security_id": "sec_1", "quantity": 100, "cost_basis": 100000}], + }, + ) assert result["id"] == "port_new" + assert result["owner"]["name"] == "Family Office" + assert len(result["positions"]) == 1 - @patch("robosystems_client.clients.investor_client.op_update_portfolio") - def test_update_portfolio_merges_id_into_body(self, mock_op, mock_config, graph_id): + @patch("robosystems_client.clients.investor_client.op_update_portfolio_block") + def test_update_portfolio_block_merges_id(self, mock_op, mock_config, graph_id): envelope = _envelope( - "update-portfolio", - {"id": "port_1", "name": "Renamed"}, + "update-portfolio-block", {**_SAMPLE_BLOCK_RESULT, "name": "Renamed Fund"} ) mock_op.return_value = _mock_response(envelope) client = InvestorClient(mock_config) - result = client.update_portfolio(graph_id, "port_1", {"name": "Renamed"}) - assert result["name"] == "Renamed" + result = client.update_portfolio_block( + graph_id, + "port_1", + { + "portfolio": {"name": "Renamed Fund"}, + "positions": {"dispose": [{"id": "pos_old"}]}, + }, + ) + assert result["name"] == "Renamed Fund" body = mock_op.call_args.kwargs["body"] assert body.portfolio_id == "port_1" - assert body.name == "Renamed" - @patch("robosystems_client.clients.investor_client.op_delete_portfolio") - def test_delete_portfolio(self, mock_op, mock_config, graph_id): - envelope = _envelope("delete-portfolio", {"deleted": True}) + @patch("robosystems_client.clients.investor_client.op_delete_portfolio_block") + def test_delete_portfolio_block_default_no_confirm( + self, mock_op, mock_config, graph_id + ): + envelope = _envelope("delete-portfolio-block", {"deleted": True}) mock_op.return_value = _mock_response(envelope) client = InvestorClient(mock_config) - result = client.delete_portfolio(graph_id, "port_1") + result = client.delete_portfolio_block(graph_id, "port_1") assert result["deleted"] is True + body = mock_op.call_args.kwargs["body"] + assert body.confirm_active_positions is False - @patch("robosystems_client.clients.investor_client.op_delete_portfolio") - def test_delete_portfolio_returns_server_response_when_present( - self, mock_op, mock_config, graph_id - ): - """The facade must not overwrite a non-empty server response with the stub. - - Before the ``is not None`` fix, an empty ``{}`` from the server would - trip the ``or {...}`` fallback — but so would any falsy value. This - exercises the happy path: a real response with fields carries through. - """ - envelope = _envelope( - "delete-portfolio", - {"id": "port_1", "deleted_at": "2026-04-14T12:00:00Z"}, - ) + @patch("robosystems_client.clients.investor_client.op_delete_portfolio_block") + def test_delete_portfolio_block_confirm_flag(self, mock_op, mock_config, graph_id): + envelope = _envelope("delete-portfolio-block", {"deleted": True}) mock_op.return_value = _mock_response(envelope) client = InvestorClient(mock_config) - result = client.delete_portfolio(graph_id, "port_1") - assert result["id"] == "port_1" - assert result["deleted_at"] == "2026-04-14T12:00:00Z" - assert "deleted" not in result # stub didn't fire + client.delete_portfolio_block(graph_id, "port_1", confirm_active_positions=True) + body = mock_op.call_args.kwargs["body"] + assert body.confirm_active_positions is True - @patch("robosystems_client.clients.investor_client.op_delete_portfolio") - def test_delete_portfolio_stubs_when_result_is_none( + @patch("robosystems_client.clients.investor_client.op_delete_portfolio_block") + def test_delete_portfolio_block_stubs_when_result_is_none( self, mock_op, mock_config, graph_id ): - """When the server returns an envelope with ``result: null`` the facade - substitutes a ``{"deleted": True}`` stub so callers get a dict.""" - envelope = _envelope("delete-portfolio", None) + envelope = _envelope("delete-portfolio-block", None) mock_op.return_value = _mock_response(envelope) client = InvestorClient(mock_config) - result = client.delete_portfolio(graph_id, "port_1") + result = client.delete_portfolio_block(graph_id, "port_1") assert result == {"deleted": True} @@ -228,7 +293,7 @@ def test_create_security(self, mock_op, mock_config, graph_id): assert result["id"] == "sec_new" -# ── Positions ────────────────────────────────────────────────────────── +# ── Positions (read-only) ────────────────────────────────────────────── @pytest.mark.unit @@ -270,20 +335,6 @@ def test_list_positions(self, mock_execute, mock_config, graph_id): assert len(result["positions"]) == 1 assert result["positions"][0]["cost_basis_dollars"] == 1000 - @patch("robosystems_client.clients.investor_client.op_create_position") - def test_create_position(self, mock_op, mock_config, graph_id): - envelope = _envelope( - "create-position", - {"id": "pos_new", "portfolio_id": "port_1", "security_id": "sec_1"}, - ) - mock_op.return_value = _mock_response(envelope) - client = InvestorClient(mock_config) - result = client.create_position( - graph_id, - {"portfolio_id": "port_1", "security_id": "sec_1", "quantity": 100}, - ) - assert result["id"] == "pos_new" - # ── Holdings ───────────────────────────────────────────────────────────