diff --git a/pyproject.toml b/pyproject.toml index b3aeacec..f9a25695 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,7 +106,7 @@ dependencies = [ [project.optional-dependencies] dev = [ # RoboSystems client for demo and testing - "robosystems-client==0.3.16", + "robosystems-client==0.3.17", # Testing framework "pytest>=8.4.0,<9.0", diff --git a/robosystems/graphql/resolvers/investor.py b/robosystems/graphql/resolvers/investor.py index 88ad3e80..ae2cfac1 100644 --- a/robosystems/graphql/resolvers/investor.py +++ b/robosystems/graphql/resolvers/investor.py @@ -22,7 +22,7 @@ ) from robosystems.graphql.types.investor import ( HoldingsList, - Portfolio, + PortfolioBlock, PortfolioList, Position, PositionList, @@ -32,6 +32,9 @@ from robosystems.operations.roboinvestor.reads import ( holdings as reads_holdings, ) +from robosystems.operations.roboinvestor.reads import ( + portfolio_block as reads_portfolio_block, +) from robosystems.operations.roboinvestor.reads import ( portfolios as reads_portfolios, ) @@ -88,22 +91,6 @@ def portfolios( _raise_investor_not_initialized() return PortfolioList.from_pydantic(response) - @strawberry.field - def portfolio( - self, - info: Info[GraphQLContext, None], - portfolio_id: str, - ) -> Portfolio | None: - """Single portfolio by id.""" - try: - with _open_session(info, "roboinvestor") as session: - response = reads_portfolios.get_portfolio(session, portfolio_id) - except (ValueError, ProgrammingError): - _raise_investor_not_initialized() - if response is None: - return None - return Portfolio.from_pydantic(response) - # ── Securities ────────────────────────────────────────────────────────── @strawberry.field @@ -209,3 +196,21 @@ def holdings( except (ValueError, ProgrammingError): _raise_investor_not_initialized() return HoldingsList.from_pydantic(response) + + # ── Portfolio Block (molecule envelope) ───────────────────────────────── + + @strawberry.field + def portfolio_block( + self, + info: Info[GraphQLContext, None], + portfolio_id: str, + ) -> PortfolioBlock | None: + """Portfolio-centric molecule: portfolio + active positions + securities + entities.""" + try: + with _open_session(info, "roboinvestor") as session: + response = reads_portfolio_block.get_portfolio_block(session, portfolio_id) + except reads_holdings.PortfolioNotFoundError: + return None + except (ValueError, ProgrammingError): + _raise_investor_not_initialized() + return PortfolioBlock.from_pydantic(response) diff --git a/robosystems/graphql/types/investor.py b/robosystems/graphql/types/investor.py index 5a09d323..ae35e7f1 100644 --- a/robosystems/graphql/types/investor.py +++ b/robosystems/graphql/types/investor.py @@ -15,6 +15,9 @@ from strawberry.scalars import JSON from robosystems.graphql.types.common import PaginationInfo +from robosystems.models.api.extensions.investor import ( + EntityLite as PydanticEntityLite, +) from robosystems.models.api.extensions.investor import ( HoldingResponse as PydanticHoldingResponse, ) @@ -24,12 +27,18 @@ from robosystems.models.api.extensions.investor import ( HoldingsListResponse as PydanticHoldingsListResponse, ) +from robosystems.models.api.extensions.investor import ( + PortfolioBlockEnvelope as PydanticPortfolioBlockEnvelope, +) from robosystems.models.api.extensions.investor import ( PortfolioListResponse as PydanticPortfolioListResponse, ) from robosystems.models.api.extensions.investor import ( PortfolioResponse as PydanticPortfolioResponse, ) +from robosystems.models.api.extensions.investor import ( + PositionBlock as PydanticPositionBlock, +) from robosystems.models.api.extensions.investor import ( PositionListResponse as PydanticPositionListResponse, ) @@ -39,6 +48,9 @@ from robosystems.models.api.extensions.investor import ( SecurityListResponse as PydanticSecurityListResponse, ) +from robosystems.models.api.extensions.investor import ( + SecurityLite as PydanticSecurityLite, +) from robosystems.models.api.extensions.investor import ( SecurityResponse as PydanticSecurityResponse, ) @@ -152,3 +164,36 @@ class Holding: ) class HoldingsList: """Full holdings view for a portfolio.""" + + +# ── Portfolio Block (molecule envelope) ─────────────────────────────────── + + +@strawberry.experimental.pydantic.type(model=PydanticEntityLite, all_fields=True) +class EntityLite: + """Lightweight entity reference.""" + + id: strawberry.ID + + +@strawberry.experimental.pydantic.type(model=PydanticSecurityLite, all_fields=True) +class SecurityLite: + """Lightweight security with issuer and cross-graph reference.""" + + id: strawberry.ID + + +@strawberry.experimental.pydantic.type(model=PydanticPositionBlock, all_fields=True) +class PositionBlock: + """A position with its embedded security.""" + + id: strawberry.ID + + +@strawberry.experimental.pydantic.type( + model=PydanticPortfolioBlockEnvelope, all_fields=True +) +class PortfolioBlock: + """Portfolio-centric molecule envelope — portfolio + positions + securities + entities.""" + + id: strawberry.ID diff --git a/robosystems/models/api/extensions/__init__.py b/robosystems/models/api/extensions/__init__.py index 062f7db7..1b8e48b0 100644 --- a/robosystems/models/api/extensions/__init__.py +++ b/robosystems/models/api/extensions/__init__.py @@ -17,20 +17,30 @@ ClosingBookStructuresResponse, ) from .investor import ( - CreatePortfolioRequest, - CreatePositionRequest, + CreatePortfolioBlockRequest, CreateSecurityRequest, + DeletePortfolioBlockOperation, + DeletePortfolioBlockResponse, + EntityLite, HoldingResponse, HoldingSecuritySummary, HoldingsListResponse, + PortfolioBlockEnvelope, + PortfolioBlockPortfolioFields, + PortfolioBlockPortfolioPatch, + PortfolioBlockPositionAdd, + PortfolioBlockPositionDispose, + PortfolioBlockPositions, + PortfolioBlockPositionUpdate, PortfolioListResponse, PortfolioResponse, + PositionBlock, PositionListResponse, PositionResponse, SecurityListResponse, + SecurityLite, SecurityResponse, - UpdatePortfolioRequest, - UpdatePositionRequest, + UpdatePortfolioBlockOperation, UpdateSecurityRequest, ) from .reports import ( @@ -75,10 +85,12 @@ "ClosingBookCategory", "ClosingBookItem", "ClosingBookStructuresResponse", - "CreatePortfolioRequest", - "CreatePositionRequest", + "CreatePortfolioBlockRequest", "CreateReportRequest", "CreateSecurityRequest", + "DeletePortfolioBlockOperation", + "DeletePortfolioBlockResponse", + "EntityLite", "FactRowResponse", "FinancialStatementAnalysisRequest", "FinancialStatementAnalysisResponse", @@ -94,8 +106,16 @@ "LiveFinancialStatementRequest", "LiveFinancialStatementResponse", "LiveStatementFactRow", + "PortfolioBlockEnvelope", + "PortfolioBlockPortfolioFields", + "PortfolioBlockPortfolioPatch", + "PortfolioBlockPositionAdd", + "PortfolioBlockPositionDispose", + "PortfolioBlockPositionUpdate", + "PortfolioBlockPositions", "PortfolioListResponse", "PortfolioResponse", + "PositionBlock", "PositionListResponse", "PositionResponse", "RegenerateReportRequest", @@ -103,6 +123,7 @@ "ReportResponse", "ResolvedReportInfo", "SecurityListResponse", + "SecurityLite", "SecurityResponse", "ShareReportRequest", "ShareReportResponse", @@ -111,8 +132,7 @@ "StructureSummary", "TrialBalanceResponse", "TrialBalanceRow", - "UpdatePortfolioRequest", - "UpdatePositionRequest", + "UpdatePortfolioBlockOperation", "UpdateSecurityRequest", "ValidationCheckResponse", "cents_to_dollars", diff --git a/robosystems/models/api/extensions/investor.py b/robosystems/models/api/extensions/investor.py index f86e2243..3560d052 100644 --- a/robosystems/models/api/extensions/investor.py +++ b/robosystems/models/api/extensions/investor.py @@ -11,38 +11,6 @@ # ── Portfolio ────────────────────────────────────────────────────────────── -class CreatePortfolioRequest(BaseModel): - name: str = Field(..., min_length=1, max_length=200) - description: str | None = None - strategy: str | None = None - inception_date: date | None = None - base_currency: str = "USD" - - -class UpdatePortfolioRequest(BaseModel): - name: str | None = None - description: str | None = None - strategy: str | None = None - inception_date: date | None = None - base_currency: str | None = None - - -class UpdatePortfolioOperation(UpdatePortfolioRequest): - """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). - """ - - portfolio_id: str = Field(..., description="Target portfolio ID.") - - -class DeletePortfolioOperation(BaseModel): - """CQRS body for `POST /operations/delete-portfolio`.""" - - portfolio_id: str = Field(..., description="Target portfolio ID.") - - class PortfolioResponse(BaseModel): id: str name: str @@ -121,49 +89,10 @@ class SecurityListResponse(BaseModel): # ── Position ─────────────────────────────────────────────────────────────── -class CreatePositionRequest(BaseModel): - portfolio_id: str - security_id: str - quantity: float - quantity_type: str = "shares" - cost_basis: int = 0 # cents - currency: str = "USD" - current_value: int | None = None # cents - valuation_date: date | None = None - valuation_source: str | None = None - acquisition_date: date | None = None - notes: str | None = None - - -class UpdatePositionRequest(BaseModel): - quantity: float | None = None - quantity_type: str | None = None - cost_basis: int | None = None - current_value: int | None = None - valuation_date: date | None = None - valuation_source: str | None = None - acquisition_date: date | None = None - disposition_date: date | None = None - status: str | None = None - notes: str | None = None - - -class UpdatePositionOperation(UpdatePositionRequest): - """CQRS body for `POST /operations/update-position`.""" - - position_id: str = Field(..., description="Target position ID.") - - -class DeletePositionOperation(BaseModel): - """CQRS body for `POST /operations/delete-position` (soft delete).""" - - position_id: str = Field(..., description="Target position ID.") - - class DeleteResult(BaseModel): - """Shared response shape for soft-delete operations (`delete-portfolio`, - `delete-security`, `delete-position`). `deleted=true` means a row was - flipped; 404 is raised by the handler when no row existed.""" + """Shared response shape for soft-delete operations (e.g., + `delete-security`). `deleted=true` means a row was flipped; 404 is + raised by the handler when no row existed.""" deleted: bool @@ -223,3 +152,182 @@ class HoldingsListResponse(BaseModel): holdings: list[HoldingResponse] total_entities: int total_positions: int + + +# ── Portfolio Block (molecule envelope) ──────────────────────────────────── + + +class EntityLite(BaseModel): + id: str + name: str + source_graph_id: str | None = None + + +class SecurityLite(BaseModel): + id: str + name: str + security_type: str + security_subtype: str | None = None + is_active: bool + issuer: EntityLite | None = None + source_graph_id: str | None = None + + +class PositionBlock(BaseModel): + id: str + quantity: float + quantity_type: str + cost_basis_dollars: float + current_value_dollars: float | None = None + valuation_date: date | None = None + valuation_source: str | None = None + acquisition_date: date | None = None + status: str + notes: str | None = None + security: SecurityLite + + +class PortfolioBlockEnvelope(BaseModel): + id: str + name: str + description: str | None = None + strategy: str | None = None + inception_date: date | None = None + base_currency: str + owner: EntityLite | None = None + positions: list[PositionBlock] + total_cost_basis_dollars: float + total_current_value_dollars: float | None = None + active_position_count: int + created_at: datetime + updated_at: datetime + + +# ── Portfolio Block writes (molecule write surface) ─────────────────────── + + +class PortfolioBlockPositionAdd(BaseModel): + """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). + """ + + security_id: str + quantity: float + quantity_type: str = "shares" + cost_basis: int = 0 # cents + currency: str = "USD" + current_value: int | None = None # cents + valuation_date: date | None = None + valuation_source: str | None = None + acquisition_date: date | None = None + notes: str | None = None + + +class PortfolioBlockPositionUpdate(BaseModel): + """Patch-by-id for an existing position in `update-portfolio-block`. + + Unset fields are ignored; `id` is the only required field. + """ + + id: str + quantity: float | None = None + quantity_type: str | None = None + cost_basis: int | None = None + current_value: int | None = None + valuation_date: date | None = None + valuation_source: str | None = None + acquisition_date: date | None = None + notes: str | None = None + + +class PortfolioBlockPositionDispose(BaseModel): + """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`. + """ + + id: str + disposition_reason: str | None = None + + +class PortfolioBlockPositions(BaseModel): + """Position deltas applied atomically inside `update-portfolio-block`.""" + + add: list[PortfolioBlockPositionAdd] = Field(default_factory=list) + update: list[PortfolioBlockPositionUpdate] = Field(default_factory=list) + dispose: list[PortfolioBlockPositionDispose] = Field(default_factory=list) + + +class PortfolioBlockPortfolioFields(BaseModel): + """Fields settable on the portfolio core when creating a block.""" + + name: str = Field(..., min_length=1, max_length=200) + description: str | None = None + strategy: str | None = None + inception_date: date | None = None + base_currency: str = "USD" + entity_id: str | None = None + + +class PortfolioBlockPortfolioPatch(BaseModel): + """Patchable portfolio fields on `update-portfolio-block`. Unset fields ignored.""" + + name: str | None = None + description: str | None = None + strategy: str | None = None + inception_date: date | None = None + base_currency: str | None = None + entity_id: str | None = None + + +class CreatePortfolioBlockRequest(BaseModel): + """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. + """ + + portfolio: PortfolioBlockPortfolioFields + positions: list[PortfolioBlockPositionAdd] = Field(default_factory=list) + + +class UpdatePortfolioBlockOperation(BaseModel): + """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. + """ + + portfolio_id: str = Field(..., description="Target portfolio ID.") + portfolio: PortfolioBlockPortfolioPatch = Field( + default_factory=PortfolioBlockPortfolioPatch + ) + positions: PortfolioBlockPositions = Field(default_factory=PortfolioBlockPositions) + + +class DeletePortfolioBlockOperation(BaseModel): + """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. + """ + + portfolio_id: str = Field(..., description="Target portfolio ID.") + confirm_active_positions: bool = False + + +class DeletePortfolioBlockResponse(BaseModel): + """Result envelope for `delete-portfolio-block`. `positions_deleted` + carries the count of position rows removed alongside the portfolio.""" + + deleted: bool + portfolio_id: str + positions_deleted: int diff --git a/robosystems/operations/roboinvestor/commands/portfolio_block.py b/robosystems/operations/roboinvestor/commands/portfolio_block.py new file mode 100644 index 00000000..1e6b26c3 --- /dev/null +++ b/robosystems/operations/roboinvestor/commands/portfolio_block.py @@ -0,0 +1,270 @@ +"""Portfolio Block write operations — molecule-level command surface. + +Three commands replace the atom-level portfolio + position CRUD: +:func:`create_portfolio_block`, :func:`update_portfolio_block`, +:func:`delete_portfolio_block`. Each validates the entire envelope +before any DB write and applies all changes atomically — partial +failures roll back via the caller's session. + +`PortfolioNotFoundError` is reused from +``robosystems.operations.roboinvestor.reads.holdings`` so the read + +write surface share the same 404 sentinel. `SecurityNotFoundError` +and `PositionNotFoundError` are defined here for the FK-miss cases +hit by the position deltas. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from robosystems.models.api.extensions.investor import ( + CreatePortfolioBlockRequest, + DeletePortfolioBlockOperation, + DeletePortfolioBlockResponse, + PortfolioBlockEnvelope, + PortfolioBlockPositionAdd, + UpdatePortfolioBlockOperation, +) +from robosystems.models.extensions.roboinvestor import Portfolio, Position, Security +from robosystems.operations.roboinvestor.reads.holdings import PortfolioNotFoundError +from robosystems.operations.roboinvestor.reads.portfolio_block import ( + get_portfolio_block, +) + + +class SecurityNotFoundError(LookupError): + """Raised when an `add` delta references an unknown security_id.""" + + +class PositionNotFoundError(LookupError): + """Raised when an `update` or `dispose` delta references an unknown position id.""" + + +class DuplicateActivePositionError(Exception): + """Raised when an `add` would violate the unique active (portfolio,security) index.""" + + +class PositionPortfolioMismatchError(Exception): + """Raised when an `update`/`dispose` targets a position outside this portfolio.""" + + +class ActivePositionsRequireConfirmationError(Exception): + """Raised when delete-portfolio-block has active positions but no confirmation flag.""" + + def __init__(self, active_count: int) -> None: + super().__init__( + f"Portfolio has {active_count} active position(s); set " + f"confirm_active_positions=true to cascade-delete." + ) + self.active_count = active_count + + +def _validate_securities_exist(session: Session, security_ids: list[str]) -> None: + if not security_ids: + return + unique_ids = set(security_ids) + found = ( + session.execute(select(Security.id).where(Security.id.in_(unique_ids))) + .scalars() + .all() + ) + missing = unique_ids - set(found) + if missing: + raise SecurityNotFoundError(sorted(missing)) + + +def _add_position( + session: Session, + portfolio_id: str, + spec: PortfolioBlockPositionAdd, + created_by: str, +) -> Position: + position = Position( + portfolio_id=portfolio_id, + security_id=spec.security_id, + quantity=spec.quantity, + quantity_type=spec.quantity_type, + cost_basis=spec.cost_basis, + currency=spec.currency, + current_value=spec.current_value, + valuation_date=spec.valuation_date, + valuation_source=spec.valuation_source, + acquisition_date=spec.acquisition_date, + notes=spec.notes, + created_by=created_by, + ) + session.add(position) + try: + session.flush() + except IntegrityError as exc: + if getattr(getattr(exc, "orig", None), "pgcode", None) == "23505": + raise DuplicateActivePositionError( + f"An active position already exists for security {spec.security_id} " + f"in portfolio {portfolio_id}." + ) from exc + raise + return position + + +def create_portfolio_block( + session: Session, + body: CreatePortfolioBlockRequest, + created_by: str, +) -> PortfolioBlockEnvelope: + """Create a portfolio with optional initial positions, return its envelope. + + Validates every referenced security up front so we never partially + write a portfolio with bad position data; on validation failure no + rows are added. + """ + _validate_securities_exist(session, [p.security_id for p in body.positions]) + + portfolio = Portfolio( + name=body.portfolio.name, + description=body.portfolio.description, + strategy=body.portfolio.strategy, + inception_date=body.portfolio.inception_date, + base_currency=body.portfolio.base_currency, + entity_id=body.portfolio.entity_id, + created_by=created_by, + ) + session.add(portfolio) + session.flush() + + for spec in body.positions: + _add_position(session, portfolio.id, spec, created_by) + + return get_portfolio_block(session, portfolio.id) + + +def update_portfolio_block( + session: Session, + body: UpdatePortfolioBlockOperation, + created_by: str, +) -> PortfolioBlockEnvelope: + """Patch portfolio fields and apply position deltas atomically. + + `add` items mint new positions; `update` patches by position id; + `dispose` flips status to `disposed`. Partial failures abort the + whole envelope — the caller's session boundary owns the rollback. + """ + portfolio = session.execute( + select(Portfolio).where(Portfolio.id == body.portfolio_id) + ).scalar_one_or_none() + if portfolio is None: + raise PortfolioNotFoundError(body.portfolio_id) + + _validate_securities_exist(session, [a.security_id for a in body.positions.add]) + + update_ids = [u.id for u in body.positions.update] + dispose_ids = [d.id for d in body.positions.dispose] + position_ids_to_load = list({*update_ids, *dispose_ids}) + + position_map: dict[str, Position] = {} + if position_ids_to_load: + rows = ( + session.execute(select(Position).where(Position.id.in_(position_ids_to_load))) + .scalars() + .all() + ) + position_map = {str(p.id): p for p in rows} + + for pid in position_ids_to_load: + row = position_map.get(pid) + if row is None: + raise PositionNotFoundError(pid) + if row.portfolio_id != body.portfolio_id: + raise PositionPortfolioMismatchError( + f"Position {pid} does not belong to portfolio {body.portfolio_id}." + ) + + portfolio_patch = body.portfolio.model_dump(exclude_unset=True) + for field, value in portfolio_patch.items(): + setattr(portfolio, field, value) + + for spec in body.positions.add: + _add_position(session, body.portfolio_id, spec, created_by) + + for patch in body.positions.update: + row = position_map[patch.id] + for field, value in patch.model_dump(exclude_unset=True, exclude={"id"}).items(): + setattr(row, field, value) + + for spec in body.positions.dispose: + row = position_map[spec.id] + row.status = "disposed" + row.disposition_date = datetime.now(UTC).date() + if spec.disposition_reason is not None: + meta = dict(row.metadata_) if isinstance(row.metadata_, dict) else {} + meta["disposition_reason"] = spec.disposition_reason + row.metadata_ = meta + + # Bump updated_at on the portfolio so the envelope reflects this write + # even when only positions changed. + portfolio.updated_at = datetime.now(UTC) + + session.flush() + return get_portfolio_block(session, body.portfolio_id) + + +def delete_portfolio_block( + session: Session, + body: DeletePortfolioBlockOperation, +) -> DeletePortfolioBlockResponse: + """Cascade-delete a portfolio plus all of its positions. + + Active positions trigger `ActivePositionsRequireConfirmationError` + unless `confirm_active_positions=true`; disposed-only portfolios + delete without the flag. + """ + portfolio = session.execute( + select(Portfolio).where(Portfolio.id == body.portfolio_id) + ).scalar_one_or_none() + if portfolio is None: + raise PortfolioNotFoundError(body.portfolio_id) + + active_count = int( + session.execute( + select(func.count()) + .select_from(Position) + .where(Position.portfolio_id == body.portfolio_id) + .where(Position.status == "active") + ).scalar() + or 0 + ) + if active_count and not body.confirm_active_positions: + raise ActivePositionsRequireConfirmationError(active_count) + + positions_to_delete = ( + session.execute(select(Position).where(Position.portfolio_id == body.portfolio_id)) + .scalars() + .all() + ) + for row in positions_to_delete: + session.delete(row) + + session.delete(portfolio) + session.flush() + + return DeletePortfolioBlockResponse( + deleted=True, + portfolio_id=body.portfolio_id, + positions_deleted=len(positions_to_delete), + ) + + +__all__ = [ + "ActivePositionsRequireConfirmationError", + "DuplicateActivePositionError", + "PortfolioNotFoundError", + "PositionNotFoundError", + "PositionPortfolioMismatchError", + "SecurityNotFoundError", + "create_portfolio_block", + "delete_portfolio_block", + "update_portfolio_block", +] diff --git a/robosystems/operations/roboinvestor/commands/portfolios.py b/robosystems/operations/roboinvestor/commands/portfolios.py deleted file mode 100644 index a0200d35..00000000 --- a/robosystems/operations/roboinvestor/commands/portfolios.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Portfolio write operations. - -All four commands share the registrar-compatible signature -`(session, body, created_by=...)` (update/delete drop `created_by` and -are declared with `requires_created_by=False` in the router). Missing -rows raise `PortfolioNotFoundError` so the registrar error map can -surface a clean 404. -""" - -from __future__ import annotations - -from sqlalchemy import func, select -from sqlalchemy.orm import Session - -from robosystems.models.api.extensions.investor import ( - CreatePortfolioRequest, - DeletePortfolioOperation, - DeleteResult, - PortfolioResponse, - UpdatePortfolioOperation, -) -from robosystems.models.extensions.roboinvestor import Portfolio, Position -from robosystems.operations.roboinvestor.reads.portfolios import portfolio_to_response - - -class PortfolioNotFoundError(LookupError): - """Raised when a referenced portfolio does not exist.""" - - -class PortfolioHasActivePositionsError(Exception): - """Raised when trying to delete a portfolio with active positions.""" - - def __init__(self, active_count: int) -> None: - super().__init__(f"Portfolio has {active_count} active position(s).") - self.active_count = active_count - - -def create_portfolio( - session: Session, body: CreatePortfolioRequest, created_by: str -) -> PortfolioResponse: - """Create a portfolio row and return its response representation. - - The caller is expected to `session.commit()` after — this function - only `flush()`es so the generated id is available. - """ - portfolio = Portfolio( - name=body.name, - description=body.description, - strategy=body.strategy, - inception_date=body.inception_date, - base_currency=body.base_currency, - created_by=created_by, - ) - session.add(portfolio) - session.flush() - return portfolio_to_response(portfolio) - - -def update_portfolio( - session: Session, body: UpdatePortfolioOperation -) -> PortfolioResponse: - """Apply updates to a portfolio. Raises `PortfolioNotFoundError` if missing.""" - row = session.execute( - select(Portfolio).where(Portfolio.id == body.portfolio_id) - ).scalar_one_or_none() - if row is None: - raise PortfolioNotFoundError(body.portfolio_id) - - for field, value in body.model_dump( - exclude_unset=True, exclude={"portfolio_id"} - ).items(): - setattr(row, field, value) - - session.flush() - return portfolio_to_response(row) - - -def delete_portfolio(session: Session, body: DeletePortfolioOperation) -> DeleteResult: - """Delete a portfolio. - - Raises `PortfolioNotFoundError` on missing rows (registrar → 404) and - `PortfolioHasActivePositionsError` when active positions remain - (registrar → 409). Returns `DeleteResult(deleted=True)` on success. - """ - row = session.execute( - select(Portfolio).where(Portfolio.id == body.portfolio_id) - ).scalar_one_or_none() - if row is None: - raise PortfolioNotFoundError(body.portfolio_id) - - active_count = session.execute( - select(func.count()) - .select_from(Position) - .where(Position.portfolio_id == body.portfolio_id) - .where(Position.status == "active") - ).scalar() - if active_count: - raise PortfolioHasActivePositionsError(int(active_count)) - - session.delete(row) - return DeleteResult(deleted=True) diff --git a/robosystems/operations/roboinvestor/commands/positions.py b/robosystems/operations/roboinvestor/commands/positions.py deleted file mode 100644 index 2fad8171..00000000 --- a/robosystems/operations/roboinvestor/commands/positions.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Position write operations. - -Registrar-compatible signatures `(session, body, created_by=...)`; -update/delete declare `requires_created_by=False`. Missing rows raise -`PositionNotFoundError` so the registrar error map can surface 404. -`PortfolioNotFoundError` / `SecurityNotFoundError` are re-used from -their owning command modules to keep FK-miss translations unified. -""" - -from __future__ import annotations - -from sqlalchemy import select -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session - -from robosystems.models.api.extensions.investor import ( - CreatePositionRequest, - DeletePositionOperation, - DeleteResult, - PositionResponse, - UpdatePositionOperation, -) -from robosystems.models.extensions import Entity -from robosystems.models.extensions.roboinvestor import Portfolio, Position, Security -from robosystems.operations.roboinvestor.commands.portfolios import ( - PortfolioNotFoundError, -) -from robosystems.operations.roboinvestor.commands.securities import ( - SecurityNotFoundError, -) -from robosystems.operations.roboinvestor.reads.positions import ( - enrich_positions, - position_to_response, -) - -# Re-export FK-miss errors so callers (router, tests) can import them -# from a single location even though the types live on the owning module. -__all__ = [ - "DuplicateActivePositionError", - "PortfolioNotFoundError", - "PositionNotFoundError", - "SecurityNotFoundError", - "create_position", - "soft_delete_position", - "update_position", -] - - -class PositionNotFoundError(LookupError): - """Raised when a referenced position does not exist.""" - - -class DuplicateActivePositionError(Exception): - """Raised when an active position for this (portfolio, security) exists.""" - - -def create_position( - session: Session, body: CreatePositionRequest, created_by: str -) -> PositionResponse: - """Create a position, validating portfolio and security existence. - - Raises `PortfolioNotFoundError`, `SecurityNotFoundError`, or - `DuplicateActivePositionError` — caller maps to 404 / 409. - """ - portfolio = session.execute( - select(Portfolio).where(Portfolio.id == body.portfolio_id) - ).scalar_one_or_none() - if portfolio is None: - raise PortfolioNotFoundError(body.portfolio_id) - - security = session.execute( - select(Security).where(Security.id == body.security_id) - ).scalar_one_or_none() - if security is None: - raise SecurityNotFoundError(body.security_id) - - entity = session.execute( - select(Entity).where(Entity.id == security.entity_id) - ).scalar_one_or_none() - - position = Position( - portfolio_id=body.portfolio_id, - security_id=body.security_id, - quantity=body.quantity, - quantity_type=body.quantity_type, - cost_basis=body.cost_basis, - currency=body.currency, - current_value=body.current_value, - valuation_date=body.valuation_date, - valuation_source=body.valuation_source, - acquisition_date=body.acquisition_date, - notes=body.notes, - created_by=created_by, - ) - session.add(position) - try: - session.flush() - except IntegrityError as exc: - if getattr(getattr(exc, "orig", None), "pgcode", None) == "23505": - raise DuplicateActivePositionError( - "An active position already exists for this security in this portfolio." - ) from exc - raise - - return position_to_response( - position, - security_name=security.name, - entity_name=entity.name if entity else None, - ) - - -def update_position( - session: Session, body: UpdatePositionOperation -) -> PositionResponse: - """Apply updates to a position. Raises `PositionNotFoundError` if missing.""" - row = session.execute( - select(Position).where(Position.id == body.position_id) - ).scalar_one_or_none() - if row is None: - raise PositionNotFoundError(body.position_id) - - for field, value in body.model_dump( - exclude_unset=True, exclude={"position_id"} - ).items(): - setattr(row, field, value) - - session.flush() - enriched = enrich_positions(session, [row]) - return enriched[0] - - -def soft_delete_position( - session: Session, body: DeletePositionOperation -) -> DeleteResult: - """Soft-delete a position (`status='disposed'`). - - Raises `PositionNotFoundError` if the row doesn't exist (registrar → 404). - Historical holding records referencing it remain valid. - """ - row = session.execute( - select(Position).where(Position.id == body.position_id) - ).scalar_one_or_none() - if row is None: - raise PositionNotFoundError(body.position_id) - row.status = "disposed" - session.flush() - return DeleteResult(deleted=True) diff --git a/robosystems/operations/roboinvestor/reads/portfolio_block.py b/robosystems/operations/roboinvestor/reads/portfolio_block.py new file mode 100644 index 00000000..bffeeb33 --- /dev/null +++ b/robosystems/operations/roboinvestor/reads/portfolio_block.py @@ -0,0 +1,135 @@ +"""Portfolio Block read operation — portfolio-centric molecule envelope.""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from robosystems.models.api.extensions.investor import ( + EntityLite, + PortfolioBlockEnvelope, + PositionBlock, + SecurityLite, +) +from robosystems.models.extensions import Entity +from robosystems.models.extensions.roboinvestor import Portfolio, Position, Security +from robosystems.operations.roboinvestor.reads.holdings import PortfolioNotFoundError + + +def _entity_to_lite(entity: Entity | None) -> EntityLite | None: + if entity is None: + return None + meta = entity.metadata_ if isinstance(entity.metadata_, dict) else {} + return EntityLite( + id=entity.id, + name=entity.name, + source_graph_id=meta.get("source_graph_id"), + ) + + +def get_portfolio_block(session: Session, portfolio_id: str) -> PortfolioBlockEnvelope: + """Assemble a Portfolio Block envelope. + + Raises `PortfolioNotFoundError` when the portfolio id is unknown so the + caller can return a 404. Active positions only. + """ + portfolio = session.execute( + select(Portfolio).where(Portfolio.id == portfolio_id) + ).scalar_one_or_none() + if portfolio is None: + raise PortfolioNotFoundError(portfolio_id) + + owner: Entity | None = None + if portfolio.entity_id: + owner = session.execute( + select(Entity).where(Entity.id == portfolio.entity_id) + ).scalar_one_or_none() + + positions = ( + session.execute( + select(Position) + .where(Position.portfolio_id == portfolio_id) + .where(Position.status == "active") + ) + .scalars() + .all() + ) + + sec_map: dict[str, Security] = {} + entity_map: dict[str, Entity] = {} + if positions: + security_ids = {p.security_id for p in positions} + securities = ( + session.execute(select(Security).where(Security.id.in_(security_ids))) + .scalars() + .all() + ) + sec_map = {str(s.id): s for s in securities} + + issuer_ids = {s.entity_id for s in securities if s.entity_id} + if issuer_ids: + issuers = ( + session.execute(select(Entity).where(Entity.id.in_(issuer_ids))).scalars().all() + ) + entity_map = {str(e.id): e for e in issuers} + + position_blocks: list[PositionBlock] = [] + total_cost = 0.0 + total_value = 0.0 + has_value = False + + for p in positions: + sec = sec_map.get(p.security_id) + issuer = entity_map.get(sec.entity_id) if sec and sec.entity_id else None + + if sec is None: + continue + + cost_dollars = float(p.cost_basis) / 100.0 + value_dollars = ( + float(p.current_value) / 100.0 if p.current_value is not None else None + ) + total_cost += cost_dollars + if value_dollars is not None: + total_value += value_dollars + has_value = True + + position_blocks.append( + PositionBlock( + id=p.id, + quantity=p.quantity, + quantity_type=p.quantity_type, + cost_basis_dollars=cost_dollars, + current_value_dollars=value_dollars, + valuation_date=p.valuation_date, + valuation_source=p.valuation_source, + acquisition_date=p.acquisition_date, + status=p.status, + notes=p.notes, + security=SecurityLite( + id=sec.id, + name=sec.name, + security_type=sec.security_type, + security_subtype=sec.security_subtype, + is_active=sec.is_active, + issuer=_entity_to_lite(issuer), + source_graph_id=sec.source_graph_id, + ), + ) + ) + + return PortfolioBlockEnvelope( + id=portfolio.id, + name=portfolio.name, + description=portfolio.description, + strategy=portfolio.strategy, + inception_date=portfolio.inception_date, + base_currency=portfolio.base_currency, + owner=_entity_to_lite(owner), + positions=position_blocks, + total_cost_basis_dollars=total_cost, + total_current_value_dollars=total_value if has_value else None, + active_position_count=len(position_blocks), + created_at=portfolio.created_at, + updated_at=portfolio.updated_at, + ) diff --git a/robosystems/routers/extensions/roboinvestor/operations.py b/robosystems/routers/extensions/roboinvestor/operations.py index ee260fdc..71502a7d 100644 --- a/robosystems/routers/extensions/roboinvestor/operations.py +++ b/robosystems/routers/extensions/roboinvestor/operations.py @@ -1,6 +1,6 @@ """RoboInvestor operation routes. -All 9 operations are declared on a single `OperationRegistrar`. The +All operations are declared on a single `OperationRegistrar`. The registrar mounts each spec as a `POST /extensions/roboinvestor/{graph_id} /operations/{op_name}` route, wires in the auth dependency + feature gate, translates domain exceptions via each spec's `error_map`, and @@ -8,13 +8,16 @@ are auto-generated via `MCPRegistrar` — no MCP-specific code lives here. -To add a new RoboInvestor operation: write the ops command, add a -Pydantic request model to `models/api/extensions/investor.py`, and -declare an `OperationSpec` here. +Portfolio + position writes flow through the **Portfolio Block** +envelope ops (`create-portfolio-block`, `update-portfolio-block`, +`delete-portfolio-block`); atom-level CRUD on portfolios/positions +has been retired. Securities remain Master Data CRUD. """ from __future__ import annotations +from typing import cast + from fastapi import APIRouter, Depends, HTTPException from robosystems.db.extensions import extensions_session @@ -34,45 +37,35 @@ ) from robosystems.middleware.rate_limits import subscription_aware_rate_limit_dependency from robosystems.models.api.extensions.investor import ( - CreatePortfolioRequest, - CreatePositionRequest, + CreatePortfolioBlockRequest, CreateSecurityRequest, - DeletePortfolioOperation, - DeletePositionOperation, + DeletePortfolioBlockOperation, DeleteSecurityOperation, - UpdatePortfolioOperation, - UpdatePositionOperation, + UpdatePortfolioBlockOperation, UpdateSecurityOperation, ) -from robosystems.operations.roboinvestor.commands.portfolios import ( - PortfolioHasActivePositionsError, - PortfolioNotFoundError, -) -from robosystems.operations.roboinvestor.commands.portfolios import ( - create_portfolio as cmd_create_portfolio, -) -from robosystems.operations.roboinvestor.commands.portfolios import ( - delete_portfolio as cmd_delete_portfolio, -) -from robosystems.operations.roboinvestor.commands.portfolios import ( - update_portfolio as cmd_update_portfolio, -) -from robosystems.operations.roboinvestor.commands.positions import ( +from robosystems.operations.roboinvestor.commands.portfolio_block import ( + ActivePositionsRequireConfirmationError, DuplicateActivePositionError, + PortfolioNotFoundError, PositionNotFoundError, + PositionPortfolioMismatchError, + SecurityNotFoundError, ) -from robosystems.operations.roboinvestor.commands.positions import ( - create_position as cmd_create_position, +from robosystems.operations.roboinvestor.commands.portfolio_block import ( + create_portfolio_block as cmd_create_portfolio_block, ) -from robosystems.operations.roboinvestor.commands.positions import ( - soft_delete_position as cmd_soft_delete_position, +from robosystems.operations.roboinvestor.commands.portfolio_block import ( + delete_portfolio_block as cmd_delete_portfolio_block, ) -from robosystems.operations.roboinvestor.commands.positions import ( - update_position as cmd_update_position, +from robosystems.operations.roboinvestor.commands.portfolio_block import ( + update_portfolio_block as cmd_update_portfolio_block, ) from robosystems.operations.roboinvestor.commands.securities import ( EntityNotFoundError, - SecurityNotFoundError, +) +from robosystems.operations.roboinvestor.commands.securities import ( + SecurityNotFoundError as SecurityMasterNotFoundError, ) from robosystems.operations.roboinvestor.commands.securities import ( create_security as cmd_create_security, @@ -143,54 +136,68 @@ async def _dispatch( ) -# ── Portfolio ───────────────────────────────────────────────────────────── +# ── Portfolio Block ─────────────────────────────────────────────────────── -create_portfolio_op = _registrar.register( +create_portfolio_block_op = _registrar.register( OperationSpec( - name="create-portfolio", - summary="Create Portfolio", + name="create-portfolio-block", + summary="Create Portfolio Block", description=( - "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." ), - command=cmd_create_portfolio, - request_model=CreatePortfolioRequest, + command=cmd_create_portfolio_block, + request_model=CreatePortfolioBlockRequest, + error_map={ + SecurityNotFoundError: (404, lambda e: f"Security not found: {e}"), + DuplicateActivePositionError: (409, str), + }, ) ) -update_portfolio_op = _registrar.register( +update_portfolio_block_op = _registrar.register( OperationSpec( - name="update-portfolio", - summary="Update Portfolio", + name="update-portfolio-block", + summary="Update Portfolio Block", description=( - "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." ), - command=cmd_update_portfolio, - request_model=UpdatePortfolioOperation, - error_map={PortfolioNotFoundError: (404, lambda _e: "Portfolio not found.")}, - requires_created_by=False, + command=cmd_update_portfolio_block, + request_model=UpdatePortfolioBlockOperation, + error_map={ + PortfolioNotFoundError: (404, lambda _e: "Portfolio not found."), + PositionNotFoundError: (404, lambda e: f"Position not found: {e}"), + PositionPortfolioMismatchError: (409, str), + SecurityNotFoundError: (404, lambda e: f"Security not found: {e}"), + DuplicateActivePositionError: (409, str), + }, ) ) -delete_portfolio_op = _registrar.register( +delete_portfolio_block_op = _registrar.register( OperationSpec( - name="delete-portfolio", - summary="Delete Portfolio", + name="delete-portfolio-block", + summary="Delete Portfolio Block", description=( - "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." ), - command=cmd_delete_portfolio, - request_model=DeletePortfolioOperation, + command=cmd_delete_portfolio_block, + request_model=DeletePortfolioBlockOperation, error_map={ PortfolioNotFoundError: (404, lambda _e: "Portfolio not found."), - PortfolioHasActivePositionsError: ( + ActivePositionsRequireConfirmationError: ( 409, - lambda e: ( # type: ignore[attr-defined] - f"Portfolio has {e.active_count} active position(s). Dispose them first." + lambda e: ( + f"Portfolio has " + f"{cast(ActivePositionsRequireConfirmationError, e).active_count} " + "active position(s); set confirm_active_positions=true to cascade-delete." ), ), }, @@ -198,7 +205,8 @@ async def _dispatch( ) ) -# ── Security ────────────────────────────────────────────────────────────── + +# ── Security (Master Data CRUD) ─────────────────────────────────────────── create_security_op = _registrar.register( OperationSpec( @@ -226,7 +234,7 @@ async def _dispatch( ), command=cmd_update_security, request_model=UpdateSecurityOperation, - error_map={SecurityNotFoundError: (404, lambda _e: "Security not found.")}, + error_map={SecurityMasterNotFoundError: (404, lambda _e: "Security not found.")}, requires_created_by=False, ) ) @@ -241,60 +249,7 @@ async def _dispatch( ), command=cmd_soft_delete_security, request_model=DeleteSecurityOperation, - error_map={SecurityNotFoundError: (404, lambda _e: "Security not found.")}, - requires_created_by=False, - ) -) - -# ── Position ────────────────────────────────────────────────────────────── - -create_position_op = _registrar.register( - OperationSpec( - name="create-position", - summary="Create Position", - description=( - "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." - ), - command=cmd_create_position, - request_model=CreatePositionRequest, - error_map={ - PortfolioNotFoundError: (404, lambda _e: "Portfolio not found."), - SecurityNotFoundError: (404, lambda _e: "Security not found."), - DuplicateActivePositionError: (409, str), - }, - ) -) - -update_position_op = _registrar.register( - OperationSpec( - name="update-position", - summary="Update Position", - description=( - "Update mutable fields on a position (quantity, cost basis, " - "valuation, notes, status). Use `delete-position` for soft disposal " - "instead of setting `status` manually." - ), - command=cmd_update_position, - request_model=UpdatePositionOperation, - error_map={PositionNotFoundError: (404, lambda _e: "Position not found.")}, - requires_created_by=False, - ) -) - -delete_position_op = _registrar.register( - OperationSpec( - name="delete-position", - summary="Delete Position", - description=( - "Soft-delete the position (`status='disposed'`). Historical holding " - "records referencing it remain valid." - ), - command=cmd_soft_delete_position, - request_model=DeletePositionOperation, - error_map={PositionNotFoundError: (404, lambda _e: "Position not found.")}, + error_map={SecurityMasterNotFoundError: (404, lambda _e: "Security not found.")}, requires_created_by=False, ) ) diff --git a/tests/graphql/extensions/test_investor.py b/tests/graphql/extensions/test_investor.py index e86322a0..74a1a68d 100644 --- a/tests/graphql/extensions/test_investor.py +++ b/tests/graphql/extensions/test_investor.py @@ -162,48 +162,19 @@ def test_raises_typed_error_on_schema_error(self) -> None: assert err.extensions == {"code": "INVESTOR_NOT_INITIALIZED"} -class TestPortfolioResolver: - def test_returns_single_portfolio(self) -> None: - pf = PortfolioResponse( - id="pf_01", - name="Main Fund", - strategy="long_only", - base_currency="USD", - created_at=datetime(2024, 1, 1, tzinfo=UTC), - updated_at=datetime(2024, 1, 1, tzinfo=UTC), - ) +class TestPortfolioResolverDeprecated: + """Singular `portfolio(id)` was removed in Phase a-prime; `portfolioBlock` + is strictly stronger. Confirms the field is no longer in the schema.""" - with ( - _patch_session(), - patch( - "robosystems.operations.roboinvestor.reads.portfolios.get_portfolio", - return_value=pf, - ), - ): - result = schema.execute_sync( - 'query { portfolio(portfolioId: "pf_01") { id name strategy } }', - context_value=_ctx(), - ) - - assert result.errors is None - assert result.data["portfolio"]["id"] == "pf_01" - assert result.data["portfolio"]["strategy"] == "long_only" - - def test_returns_null_when_missing(self) -> None: - with ( - _patch_session(), - patch( - "robosystems.operations.roboinvestor.reads.portfolios.get_portfolio", - return_value=None, - ), - ): - result = schema.execute_sync( - 'query { portfolio(portfolioId: "pf_x") { id } }', - context_value=_ctx(), - ) - - assert result.errors is None - assert result.data == {"portfolio": None} + def test_singular_portfolio_query_rejected(self) -> None: + result = schema.execute_sync( + 'query { portfolio(portfolioId: "pf_01") { id } }', + context_value=_ctx(), + ) + assert result.errors is not None + assert any( + "Cannot query field 'portfolio'" in str(e.message) for e in result.errors + ) class TestSecurityResolver: diff --git a/tests/graphql/extensions/test_schema.py b/tests/graphql/extensions/test_schema.py index 71f85e97..8c89eac5 100644 --- a/tests/graphql/extensions/test_schema.py +++ b/tests/graphql/extensions/test_schema.py @@ -72,7 +72,7 @@ def test_all_investor_queries_are_exposed(self) -> None: query_block = sdl[start:end] for field_name in [ "portfolios", - "portfolio", + "portfolioBlock", "securities", "security", "positions", diff --git a/tests/operations/roboinvestor/test_portfolio_block.py b/tests/operations/roboinvestor/test_portfolio_block.py new file mode 100644 index 00000000..cb9fa03d --- /dev/null +++ b/tests/operations/roboinvestor/test_portfolio_block.py @@ -0,0 +1,242 @@ +"""Tests for operations/roboinvestor/reads/portfolio_block.py.""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +from unittest.mock import MagicMock + +import pytest + +from robosystems.operations.roboinvestor.reads.holdings import PortfolioNotFoundError +from robosystems.operations.roboinvestor.reads.portfolio_block import ( + get_portfolio_block, +) + + +def _make_portfolio(**overrides) -> MagicMock: + defaults = { + "id": "port_01", + "name": "Main Fund", + "description": "Primary portfolio", + "strategy": "venture", + "inception_date": date(2024, 1, 1), + "base_currency": "USD", + "entity_id": "ent_owner", + "created_at": datetime(2024, 1, 1, tzinfo=UTC), + "updated_at": datetime(2024, 6, 1, tzinfo=UTC), + } + defaults.update(overrides) + p = MagicMock() + for k, v in defaults.items(): + setattr(p, k, v) + return p + + +def _make_position(**overrides) -> MagicMock: + defaults = { + "id": "pos_01", + "portfolio_id": "port_01", + "security_id": "sec_01", + "quantity": 100.0, + "quantity_type": "shares", + "cost_basis": 50000, + "current_value": 55000, + "valuation_date": date(2024, 6, 1), + "valuation_source": "manual", + "acquisition_date": date(2024, 6, 1), + "status": "active", + "notes": None, + } + defaults.update(overrides) + row = MagicMock() + for k, v in defaults.items(): + setattr(row, k, v) + return row + + +def _make_security(**overrides) -> MagicMock: + defaults = { + "id": "sec_01", + "entity_id": "ent_issuer_01", + "source_graph_id": "kg_acme", + "name": "Acme Class A", + "security_type": "common_stock", + "security_subtype": "class_a", + "is_active": True, + } + defaults.update(overrides) + s = MagicMock() + for k, v in defaults.items(): + setattr(s, k, v) + return s + + +def _make_entity(**overrides) -> MagicMock: + defaults = { + "id": "ent_issuer_01", + "name": "Acme Corp", + "metadata_": {"source_graph_id": "kg_acme"}, + } + defaults.update(overrides) + e = MagicMock() + for k, v in defaults.items(): + setattr(e, k, v) + return e + + +def _build_session( + portfolio: MagicMock | None, + owner: MagicMock | None = None, + positions: list[MagicMock] | None = None, + securities: list[MagicMock] | None = None, + issuers: list[MagicMock] | None = None, +) -> MagicMock: + """Wire a session mock through the sequence of execute() calls: + portfolio → owner (if entity_id) → positions → securities → issuers.""" + session = MagicMock() + results: list[MagicMock] = [] + + pf_result = MagicMock() + pf_result.scalar_one_or_none.return_value = portfolio + results.append(pf_result) + + if portfolio is None: + session.execute.side_effect = results + return session + + if portfolio.entity_id: + owner_result = MagicMock() + owner_result.scalar_one_or_none.return_value = owner + results.append(owner_result) + + pos_result = MagicMock() + pos_result.scalars.return_value.all.return_value = positions or [] + results.append(pos_result) + + if positions: + sec_result = MagicMock() + sec_result.scalars.return_value.all.return_value = securities or [] + results.append(sec_result) + + if securities and any(s.entity_id for s in securities): + ent_result = MagicMock() + ent_result.scalars.return_value.all.return_value = issuers or [] + results.append(ent_result) + + session.execute.side_effect = results + return session + + +class TestGetPortfolioBlock: + def test_raises_when_portfolio_missing(self) -> None: + session = _build_session(portfolio=None) + with pytest.raises(PortfolioNotFoundError): + get_portfolio_block(session, "port_missing") + + def test_envelope_shape_with_positions(self) -> None: + portfolio = _make_portfolio() + owner = _make_entity( + id="ent_owner", + name="Family Office LLC", + metadata_={"source_graph_id": "kg_owner"}, + ) + positions = [ + _make_position(id="pos_1", security_id="sec_1", cost_basis=10000), + _make_position( + id="pos_2", security_id="sec_2", cost_basis=20000, current_value=25000 + ), + ] + securities = [ + _make_security(id="sec_1", entity_id="ent_a", source_graph_id="kg_a"), + _make_security(id="sec_2", entity_id="ent_b", source_graph_id="kg_b"), + ] + issuers = [ + _make_entity(id="ent_a", name="Alpha Co", metadata_={"source_graph_id": "kg_a"}), + _make_entity(id="ent_b", name="Beta Co", metadata_={"source_graph_id": "kg_b"}), + ] + session = _build_session(portfolio, owner, positions, securities, issuers) + + block = get_portfolio_block(session, "port_01") + + assert block.id == "port_01" + assert block.name == "Main Fund" + assert block.strategy == "venture" + assert block.base_currency == "USD" + assert block.owner is not None + assert block.owner.id == "ent_owner" + assert block.owner.name == "Family Office LLC" + assert block.owner.source_graph_id == "kg_owner" + assert block.active_position_count == 2 + assert block.total_cost_basis_dollars == 300.0 # 100 + 200 + assert block.total_current_value_dollars == 800.0 # 550 + 250 + + pos1 = block.positions[0] + assert pos1.id == "pos_1" + assert pos1.security.id == "sec_1" + assert pos1.security.source_graph_id == "kg_a" + assert pos1.security.issuer is not None + assert pos1.security.issuer.name == "Alpha Co" + assert pos1.security.issuer.source_graph_id == "kg_a" + + def test_empty_positions(self) -> None: + portfolio = _make_portfolio() + owner = _make_entity(id="ent_owner", name="Owner", metadata_={}) + session = _build_session(portfolio, owner, positions=[]) + + block = get_portfolio_block(session, "port_01") + + assert block.active_position_count == 0 + assert block.positions == [] + assert block.total_cost_basis_dollars == 0.0 + assert block.total_current_value_dollars is None + + def test_owner_null_when_portfolio_has_no_entity(self) -> None: + portfolio = _make_portfolio(entity_id=None) + session = _build_session(portfolio, owner=None, positions=[]) + + block = get_portfolio_block(session, "port_01") + + assert block.owner is None + + def test_security_without_issuer(self) -> None: + portfolio = _make_portfolio(entity_id=None) + positions = [_make_position()] + securities = [_make_security(entity_id=None, source_graph_id=None)] + session = _build_session(portfolio, None, positions, securities) + + block = get_portfolio_block(session, "port_01") + + assert len(block.positions) == 1 + assert block.positions[0].security.issuer is None + assert block.positions[0].security.source_graph_id is None + + def test_only_active_positions_loaded(self) -> None: + """The read filters status='active' in the SQL — verify the where + clause is applied. The mock-based test asserts get_portfolio_block + only consumes the rows the session returned (which simulate the + DB-side filter).""" + portfolio = _make_portfolio(entity_id=None) + # Session returns only active rows (simulating the WHERE filter) + positions = [_make_position(id="pos_active", status="active")] + securities = [_make_security()] + issuers = [_make_entity()] + session = _build_session(portfolio, None, positions, securities, issuers) + + block = get_portfolio_block(session, "port_01") + + assert block.active_position_count == 1 + assert all(p.status == "active" for p in block.positions) + + def test_handles_missing_current_value(self) -> None: + portfolio = _make_portfolio(entity_id=None) + positions = [ + _make_position(id="pos_1", current_value=None, cost_basis=10000), + _make_position(id="pos_2", current_value=None, cost_basis=20000), + ] + securities = [_make_security(entity_id=None)] + session = _build_session(portfolio, None, positions, securities) + + block = get_portfolio_block(session, "port_01") + + assert block.total_current_value_dollars is None + assert block.total_cost_basis_dollars == 300.0 diff --git a/tests/operations/roboinvestor/test_portfolio_block_commands.py b/tests/operations/roboinvestor/test_portfolio_block_commands.py new file mode 100644 index 00000000..68387428 --- /dev/null +++ b/tests/operations/roboinvestor/test_portfolio_block_commands.py @@ -0,0 +1,436 @@ +"""Tests for operations/roboinvestor/commands/portfolio_block.py.""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +from unittest.mock import MagicMock, patch + +import pytest +from sqlalchemy.exc import IntegrityError + +from robosystems.models.api.extensions.investor import ( + CreatePortfolioBlockRequest, + DeletePortfolioBlockOperation, + PortfolioBlockEnvelope, + PortfolioBlockPortfolioFields, + PortfolioBlockPortfolioPatch, + PortfolioBlockPositionAdd, + PortfolioBlockPositionDispose, + PortfolioBlockPositions, + PortfolioBlockPositionUpdate, + UpdatePortfolioBlockOperation, +) +from robosystems.operations.roboinvestor.commands.portfolio_block import ( + ActivePositionsRequireConfirmationError, + DuplicateActivePositionError, + PortfolioNotFoundError, + PositionNotFoundError, + PositionPortfolioMismatchError, + SecurityNotFoundError, + create_portfolio_block, + delete_portfolio_block, + update_portfolio_block, +) + +CMD_MODULE = "robosystems.operations.roboinvestor.commands.portfolio_block" + + +def _envelope(portfolio_id: str = "pf_01") -> PortfolioBlockEnvelope: + return PortfolioBlockEnvelope( + id=portfolio_id, + name="Main Fund", + base_currency="USD", + positions=[], + total_cost_basis_dollars=0.0, + total_current_value_dollars=None, + active_position_count=0, + created_at=datetime(2024, 1, 1, tzinfo=UTC), + updated_at=datetime(2024, 1, 1, tzinfo=UTC), + ) + + +def _make_portfolio(**overrides) -> MagicMock: + defaults = { + "id": "pf_01", + "name": "Main Fund", + "description": None, + "strategy": None, + "inception_date": None, + "base_currency": "USD", + "entity_id": None, + "metadata_": {}, + "updated_at": datetime(2024, 1, 1, tzinfo=UTC), + } + defaults.update(overrides) + row = MagicMock() + for k, v in defaults.items(): + setattr(row, k, v) + return row + + +def _make_position(**overrides) -> MagicMock: + defaults = { + "id": "pos_01", + "portfolio_id": "pf_01", + "security_id": "sec_01", + "quantity": 10.0, + "quantity_type": "shares", + "cost_basis": 1000, + "current_value": None, + "valuation_date": None, + "valuation_source": None, + "acquisition_date": None, + "disposition_date": None, + "status": "active", + "metadata_": {}, + "notes": None, + } + defaults.update(overrides) + row = MagicMock() + for k, v in defaults.items(): + setattr(row, k, v) + return row + + +def _security_validation_result(present_ids: list[str]) -> MagicMock: + result = MagicMock() + result.scalars.return_value.all.return_value = present_ids + return result + + +def _scalar_one(row) -> MagicMock: + result = MagicMock() + result.scalar_one_or_none.return_value = row + return result + + +def _scalar(value) -> MagicMock: + result = MagicMock() + result.scalar.return_value = value + return result + + +def _scalars_all(rows) -> MagicMock: + result = MagicMock() + result.scalars.return_value.all.return_value = rows + return result + + +# ──────────────────────────────────────────────────────────────────── +# create_portfolio_block +# ──────────────────────────────────────────────────────────────────── + + +class TestCreatePortfolioBlock: + def test_zero_positions_creates_portfolio(self) -> None: + session = MagicMock() + fake_portfolio = _make_portfolio(id="pf_new") + body = CreatePortfolioBlockRequest( + portfolio=PortfolioBlockPortfolioFields(name="New Fund") + ) + + with ( + patch( + f"{CMD_MODULE}.Portfolio", + return_value=fake_portfolio, + ), + patch( + f"{CMD_MODULE}.get_portfolio_block", + return_value=_envelope("pf_new"), + ) as m_get, + ): + result = create_portfolio_block(session, body, created_by="usr_1") + + session.add.assert_called_once_with(fake_portfolio) + session.flush.assert_called() + assert result.id == "pf_new" + m_get.assert_called_once_with(session, "pf_new") + + def test_validates_securities_before_writing(self) -> None: + session = MagicMock() + # Securities check returns no rows for the requested id + session.execute.side_effect = [_security_validation_result([])] + + body = CreatePortfolioBlockRequest( + portfolio=PortfolioBlockPortfolioFields(name="Fund"), + positions=[ + PortfolioBlockPositionAdd(security_id="sec_missing", quantity=1.0, cost_basis=0) + ], + ) + + with pytest.raises(SecurityNotFoundError): + create_portfolio_block(session, body, created_by="usr_1") + + session.add.assert_not_called() + + def test_creates_with_positions(self) -> None: + session = MagicMock() + # First execute: validate securities. Position add() then triggers flush + # via the position-mock side effects below. + session.execute.side_effect = [_security_validation_result(["sec_01", "sec_02"])] + fake_portfolio = _make_portfolio(id="pf_new") + + body = CreatePortfolioBlockRequest( + portfolio=PortfolioBlockPortfolioFields(name="Fund"), + positions=[ + PortfolioBlockPositionAdd(security_id="sec_01", quantity=1.0, cost_basis=100), + PortfolioBlockPositionAdd(security_id="sec_02", quantity=2.0, cost_basis=200), + ], + ) + + with ( + patch(f"{CMD_MODULE}.Portfolio", return_value=fake_portfolio), + patch( + f"{CMD_MODULE}.get_portfolio_block", + return_value=_envelope("pf_new"), + ), + ): + result = create_portfolio_block(session, body, created_by="usr_1") + + # 1 portfolio + 2 positions = 3 add calls + assert session.add.call_count == 3 + assert result.id == "pf_new" + + def test_duplicate_active_position_propagates(self) -> None: + session = MagicMock() + session.execute.side_effect = [_security_validation_result(["sec_01"])] + fake_portfolio = _make_portfolio(id="pf_new") + + integrity_err = IntegrityError("stmt", {}, MagicMock(pgcode="23505")) + + body = CreatePortfolioBlockRequest( + portfolio=PortfolioBlockPortfolioFields(name="Fund"), + positions=[ + PortfolioBlockPositionAdd(security_id="sec_01", quantity=1.0, cost_basis=0) + ], + ) + + flush_calls = {"n": 0} + + def _flush(): + flush_calls["n"] += 1 + if flush_calls["n"] == 2: + # Second flush is the position add + raise integrity_err + + session.flush.side_effect = _flush + + with patch(f"{CMD_MODULE}.Portfolio", return_value=fake_portfolio): + with pytest.raises(DuplicateActivePositionError): + create_portfolio_block(session, body, created_by="usr_1") + + +# ──────────────────────────────────────────────────────────────────── +# update_portfolio_block +# ──────────────────────────────────────────────────────────────────── + + +class TestUpdatePortfolioBlock: + def test_raises_when_portfolio_missing(self) -> None: + session = MagicMock() + session.execute.side_effect = [_scalar_one(None)] + + body = UpdatePortfolioBlockOperation( + portfolio_id="pf_missing", + portfolio=PortfolioBlockPortfolioPatch(name="X"), + ) + + with pytest.raises(PortfolioNotFoundError): + update_portfolio_block(session, body, created_by="usr_1") + + def test_patches_portfolio_fields(self) -> None: + session = MagicMock() + portfolio = _make_portfolio() + # No add deltas + no update/dispose ids → only the portfolio fetch + # hits session.execute. + session.execute.side_effect = [_scalar_one(portfolio)] + body = UpdatePortfolioBlockOperation( + portfolio_id="pf_01", + portfolio=PortfolioBlockPortfolioPatch(name="Renamed", strategy="balanced"), + ) + + with patch( + f"{CMD_MODULE}.get_portfolio_block", + return_value=_envelope("pf_01"), + ): + result = update_portfolio_block(session, body, created_by="usr_1") + + assert portfolio.name == "Renamed" + assert portfolio.strategy == "balanced" + assert result.id == "pf_01" + + def test_applies_add_update_dispose_in_one_call(self) -> None: + session = MagicMock() + portfolio = _make_portfolio() + pos_to_update = _make_position(id="pos_upd", portfolio_id="pf_01", notes="old") + pos_to_dispose = _make_position(id="pos_dis", portfolio_id="pf_01") + + # Sequence: + # 1) portfolio fetch + # 2) validate add securities + # 3) load positions (update + dispose ids) + session.execute.side_effect = [ + _scalar_one(portfolio), + _security_validation_result(["sec_new"]), + _scalars_all([pos_to_update, pos_to_dispose]), + ] + + body = UpdatePortfolioBlockOperation( + portfolio_id="pf_01", + positions=PortfolioBlockPositions( + add=[ + PortfolioBlockPositionAdd(security_id="sec_new", quantity=5.0, cost_basis=100) + ], + update=[PortfolioBlockPositionUpdate(id="pos_upd", notes="new note")], + dispose=[ + PortfolioBlockPositionDispose(id="pos_dis", disposition_reason="sold") + ], + ), + ) + + with patch( + f"{CMD_MODULE}.get_portfolio_block", + return_value=_envelope("pf_01"), + ): + update_portfolio_block(session, body, created_by="usr_1") + + assert pos_to_update.notes == "new note" + assert pos_to_dispose.status == "disposed" + assert isinstance(pos_to_dispose.disposition_date, date) + assert pos_to_dispose.metadata_ == {"disposition_reason": "sold"} + + def test_unknown_position_raises(self) -> None: + session = MagicMock() + portfolio = _make_portfolio() + # No add deltas → validate_securities is a no-op (no execute); only + # the portfolio fetch and the position load hit session.execute. + session.execute.side_effect = [ + _scalar_one(portfolio), + # Only one of the two requested ids is returned + _scalars_all([_make_position(id="pos_a", portfolio_id="pf_01")]), + ] + + body = UpdatePortfolioBlockOperation( + portfolio_id="pf_01", + positions=PortfolioBlockPositions( + update=[ + PortfolioBlockPositionUpdate(id="pos_a"), + PortfolioBlockPositionUpdate(id="pos_missing"), + ] + ), + ) + + with pytest.raises(PositionNotFoundError): + update_portfolio_block(session, body, created_by="usr_1") + + def test_position_belonging_to_other_portfolio_raises(self) -> None: + session = MagicMock() + portfolio = _make_portfolio() + foreign = _make_position(id="pos_x", portfolio_id="pf_other") + # No add deltas → validate_securities is a no-op (no execute call); the + # side_effect sequence only includes portfolio fetch + position load. + session.execute.side_effect = [ + _scalar_one(portfolio), + _scalars_all([foreign]), + ] + + body = UpdatePortfolioBlockOperation( + portfolio_id="pf_01", + positions=PortfolioBlockPositions( + update=[PortfolioBlockPositionUpdate(id="pos_x")] + ), + ) + + with pytest.raises(PositionPortfolioMismatchError): + update_portfolio_block(session, body, created_by="usr_1") + + def test_partial_failure_does_not_call_get_envelope(self) -> None: + """If validation fails mid-flow, get_portfolio_block is never called — + the caller's transaction will roll back any partial state.""" + session = MagicMock() + portfolio = _make_portfolio() + session.execute.side_effect = [ + _scalar_one(portfolio), + _scalars_all([]), # Position lookup returns nothing + ] + + body = UpdatePortfolioBlockOperation( + portfolio_id="pf_01", + positions=PortfolioBlockPositions( + update=[PortfolioBlockPositionUpdate(id="pos_missing")] + ), + ) + + with patch(f"{CMD_MODULE}.get_portfolio_block") as m_get: + with pytest.raises(PositionNotFoundError): + update_portfolio_block(session, body, created_by="usr_1") + m_get.assert_not_called() + + +# ──────────────────────────────────────────────────────────────────── +# delete_portfolio_block +# ──────────────────────────────────────────────────────────────────── + + +class TestDeletePortfolioBlock: + def test_raises_when_portfolio_missing(self) -> None: + session = MagicMock() + session.execute.side_effect = [_scalar_one(None)] + + with pytest.raises(PortfolioNotFoundError): + delete_portfolio_block( + session, DeletePortfolioBlockOperation(portfolio_id="pf_missing") + ) + + def test_blocks_active_positions_without_confirm(self) -> None: + session = MagicMock() + portfolio = _make_portfolio() + session.execute.side_effect = [ + _scalar_one(portfolio), + _scalar(2), # active count + ] + + with pytest.raises(ActivePositionsRequireConfirmationError) as exc: + delete_portfolio_block( + session, + DeletePortfolioBlockOperation(portfolio_id="pf_01"), + ) + assert exc.value.active_count == 2 + session.delete.assert_not_called() + + def test_succeeds_when_no_active_positions_and_no_flag(self) -> None: + session = MagicMock() + portfolio = _make_portfolio() + disposed = _make_position(id="pos_d", status="disposed") + session.execute.side_effect = [ + _scalar_one(portfolio), + _scalar(0), # active count + _scalars_all([disposed]), # the deletion scan + ] + + body = DeletePortfolioBlockOperation(portfolio_id="pf_01") + result = delete_portfolio_block(session, body) + + assert result.deleted is True + assert result.portfolio_id == "pf_01" + assert result.positions_deleted == 1 + # 1 position + 1 portfolio + assert session.delete.call_count == 2 + + def test_cascade_deletes_with_confirmation(self) -> None: + session = MagicMock() + portfolio = _make_portfolio() + active = _make_position(id="pos_a", status="active") + session.execute.side_effect = [ + _scalar_one(portfolio), + _scalar(1), # active count + _scalars_all([active]), + ] + + body = DeletePortfolioBlockOperation( + portfolio_id="pf_01", confirm_active_positions=True + ) + result = delete_portfolio_block(session, body) + + assert result.deleted is True + assert result.positions_deleted == 1 diff --git a/tests/operations/roboinvestor/test_portfolios.py b/tests/operations/roboinvestor/test_portfolios.py deleted file mode 100644 index 1b2b3719..00000000 --- a/tests/operations/roboinvestor/test_portfolios.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Tests for operations/roboinvestor/{reads,commands}/portfolios.py.""" - -from __future__ import annotations - -from datetime import UTC, date, datetime -from unittest.mock import MagicMock, patch - -import pytest - -from robosystems.models.api.extensions.investor import CreatePortfolioRequest -from robosystems.operations.roboinvestor.commands.portfolios import ( - PortfolioHasActivePositionsError, - create_portfolio, - delete_portfolio, - update_portfolio, -) -from robosystems.operations.roboinvestor.reads.portfolios import ( - get_portfolio, - list_portfolios, - portfolio_to_response, -) - - -def _make_portfolio(**overrides) -> MagicMock: - """Build a MagicMock matching the Portfolio row attributes.""" - defaults = { - "id": "pf_01", - "name": "Main Fund", - "description": "Test fund", - "strategy": "long_only", - "inception_date": date(2024, 1, 1), - "base_currency": "USD", - "created_at": datetime(2024, 1, 1, tzinfo=UTC), - "updated_at": datetime(2024, 1, 1, tzinfo=UTC), - } - defaults.update(overrides) - row = MagicMock() - for k, v in defaults.items(): - setattr(row, k, v) - return row - - -class TestPortfolioToResponse: - def test_maps_all_fields(self) -> None: - row = _make_portfolio(name="Growth Portfolio") - resp = portfolio_to_response(row) - assert resp.id == "pf_01" - assert resp.name == "Growth Portfolio" - assert resp.strategy == "long_only" - assert resp.base_currency == "USD" - - -class TestListPortfolios: - def test_returns_pagination_and_rows(self) -> None: - rows = [_make_portfolio(id="pf_1"), _make_portfolio(id="pf_2")] - session = MagicMock() - # Two execute() calls: count, then list - count_result = MagicMock() - count_result.scalar.return_value = 2 - list_result = MagicMock() - list_result.scalars.return_value.all.return_value = rows - session.execute.side_effect = [count_result, list_result] - - result = list_portfolios(session, limit=50, offset=0) - - assert result.pagination.total == 2 - assert result.pagination.limit == 50 - assert len(result.portfolios) == 2 - assert result.portfolios[0].id == "pf_1" - assert result.portfolios[1].id == "pf_2" - - def test_zero_results(self) -> None: - session = MagicMock() - count_result = MagicMock() - count_result.scalar.return_value = 0 - list_result = MagicMock() - list_result.scalars.return_value.all.return_value = [] - session.execute.side_effect = [count_result, list_result] - - result = list_portfolios(session) - - assert result.pagination.total == 0 - assert result.portfolios == [] - - -class TestGetPortfolio: - def test_returns_response_when_found(self) -> None: - row = _make_portfolio() - session = MagicMock() - session.execute.return_value.scalar_one_or_none.return_value = row - - result = get_portfolio(session, "pf_01") - - assert result is not None - assert result.id == "pf_01" - - def test_returns_none_when_missing(self) -> None: - session = MagicMock() - session.execute.return_value.scalar_one_or_none.return_value = None - assert get_portfolio(session, "pf_missing") is None - - -class TestCreatePortfolio: - def test_adds_flushes_and_returns_response(self) -> None: - """Patch Portfolio so the added instance has DB-default fields populated. - - In production the id / created_at / updated_at columns get their - values during `session.flush()`; here we stand in a fully-formed - mock so `portfolio_to_response` can serialize it. - """ - session = MagicMock() - body = CreatePortfolioRequest( - name="New Fund", - description="Desc", - strategy="balanced", - inception_date=date(2025, 1, 1), - base_currency="USD", - ) - - fake_row = _make_portfolio(id="pf_new", name="New Fund", strategy="balanced") - fake_row.created_by = "usr_1" - - with patch( - "robosystems.operations.roboinvestor.commands.portfolios.Portfolio", - return_value=fake_row, - ): - result = create_portfolio(session, body, created_by="usr_1") - - session.add.assert_called_once_with(fake_row) - session.flush.assert_called_once() - assert result.id == "pf_new" - assert result.name == "New Fund" - assert result.strategy == "balanced" - - -class TestUpdatePortfolio: - def test_applies_updates_and_flushes(self) -> None: - from robosystems.models.api.extensions.investor import UpdatePortfolioOperation - - row = _make_portfolio(name="Original") - session = MagicMock() - session.execute.return_value.scalar_one_or_none.return_value = row - - result = update_portfolio( - session, UpdatePortfolioOperation(portfolio_id="pf_01", name="Renamed") - ) - - assert row.name == "Renamed" - session.flush.assert_called_once() - assert result.name == "Renamed" - - def test_raises_when_missing(self) -> None: - from robosystems.models.api.extensions.investor import UpdatePortfolioOperation - from robosystems.operations.roboinvestor.commands.portfolios import ( - PortfolioNotFoundError, - ) - - session = MagicMock() - session.execute.return_value.scalar_one_or_none.return_value = None - - with pytest.raises(PortfolioNotFoundError): - update_portfolio( - session, UpdatePortfolioOperation(portfolio_id="pf_missing", name="X") - ) - session.flush.assert_not_called() - - -class TestDeletePortfolio: - def test_deletes_when_no_active_positions(self) -> None: - from robosystems.models.api.extensions.investor import DeletePortfolioOperation - - row = _make_portfolio() - session = MagicMock() - # First execute: portfolio lookup. Second execute: active count. - pf_result = MagicMock() - pf_result.scalar_one_or_none.return_value = row - count_result = MagicMock() - count_result.scalar.return_value = 0 - session.execute.side_effect = [pf_result, count_result] - - result = delete_portfolio(session, DeletePortfolioOperation(portfolio_id="pf_01")) - assert result.deleted is True - session.delete.assert_called_once_with(row) - - def test_raises_when_missing(self) -> None: - from robosystems.models.api.extensions.investor import DeletePortfolioOperation - from robosystems.operations.roboinvestor.commands.portfolios import ( - PortfolioNotFoundError, - ) - - session = MagicMock() - pf_result = MagicMock() - pf_result.scalar_one_or_none.return_value = None - session.execute.side_effect = [pf_result] - - with pytest.raises(PortfolioNotFoundError): - delete_portfolio(session, DeletePortfolioOperation(portfolio_id="pf_missing")) - session.delete.assert_not_called() - - def test_raises_when_has_active_positions(self) -> None: - from robosystems.models.api.extensions.investor import DeletePortfolioOperation - - row = _make_portfolio() - session = MagicMock() - pf_result = MagicMock() - pf_result.scalar_one_or_none.return_value = row - count_result = MagicMock() - count_result.scalar.return_value = 3 - session.execute.side_effect = [pf_result, count_result] - - with pytest.raises(PortfolioHasActivePositionsError) as exc_info: - delete_portfolio(session, DeletePortfolioOperation(portfolio_id="pf_01")) - - assert exc_info.value.active_count == 3 - session.delete.assert_not_called() diff --git a/tests/operations/roboinvestor/test_positions.py b/tests/operations/roboinvestor/test_positions.py deleted file mode 100644 index 94a00e45..00000000 --- a/tests/operations/roboinvestor/test_positions.py +++ /dev/null @@ -1,337 +0,0 @@ -"""Tests for operations/roboinvestor/{reads,commands}/positions.py.""" - -from __future__ import annotations - -from datetime import UTC, date, datetime -from unittest.mock import MagicMock, patch - -import pytest -from sqlalchemy.exc import IntegrityError - -from robosystems.models.api.extensions.investor import CreatePositionRequest -from robosystems.operations.roboinvestor.commands.positions import ( - DuplicateActivePositionError, - PortfolioNotFoundError, - SecurityNotFoundError, - create_position, - soft_delete_position, - update_position, -) -from robosystems.operations.roboinvestor.reads.positions import ( - get_position, - list_positions, - position_to_response, -) - - -def _make_position(**overrides) -> MagicMock: - defaults = { - "id": "pos_01", - "portfolio_id": "pf_01", - "security_id": "sec_01", - "quantity": 100.0, - "quantity_type": "shares", - "cost_basis": 50000, # cents - "currency": "USD", - "current_value": 55000, - "valuation_date": date(2024, 12, 31), - "valuation_source": "manual", - "acquisition_date": date(2024, 6, 1), - "disposition_date": None, - "status": "active", - "notes": None, - "created_at": datetime(2024, 6, 1, tzinfo=UTC), - "updated_at": datetime(2024, 6, 1, tzinfo=UTC), - } - defaults.update(overrides) - row = MagicMock() - for k, v in defaults.items(): - setattr(row, k, v) - return row - - -def _make_security(**overrides) -> MagicMock: - defaults = { - "id": "sec_01", - "entity_id": "ent_01", - "name": "Acme Class A", - "security_type": "equity", - } - defaults.update(overrides) - s = MagicMock() - for k, v in defaults.items(): - setattr(s, k, v) - return s - - -def _make_entity(**overrides) -> MagicMock: - defaults = {"id": "ent_01", "name": "Acme Corp"} - defaults.update(overrides) - e = MagicMock() - for k, v in defaults.items(): - setattr(e, k, v) - return e - - -def _make_portfolio() -> MagicMock: - p = MagicMock() - p.id = "pf_01" - return p - - -class TestPositionToResponse: - def test_converts_cents_to_dollars(self) -> None: - row = _make_position(cost_basis=12345, current_value=54321) - resp = position_to_response(row, security_name="Acme", entity_name="Acme Corp") - assert resp.cost_basis == 12345 - assert resp.cost_basis_dollars == 123.45 - assert resp.current_value_dollars == 543.21 - assert resp.security_name == "Acme" - assert resp.entity_name == "Acme Corp" - - def test_handles_none_current_value(self) -> None: - row = _make_position(current_value=None) - resp = position_to_response(row) - assert resp.current_value_dollars is None - - -class TestListPositions: - def test_enriches_with_security_and_entity_names(self) -> None: - positions = [_make_position(id="pos_1"), _make_position(id="pos_2")] - securities = [_make_security()] - entities = [_make_entity()] - - session = MagicMock() - count_result = MagicMock() - count_result.scalar.return_value = 2 - list_result = MagicMock() - list_result.scalars.return_value.all.return_value = positions - sec_result = MagicMock() - sec_result.scalars.return_value.all.return_value = securities - ent_result = MagicMock() - ent_result.scalars.return_value.all.return_value = entities - session.execute.side_effect = [count_result, list_result, sec_result, ent_result] - - result = list_positions(session, portfolio_id="pf_01") - - assert result.pagination.total == 2 - assert len(result.positions) == 2 - assert result.positions[0].security_name == "Acme Class A" - assert result.positions[0].entity_name == "Acme Corp" - - def test_zero_results(self) -> None: - session = MagicMock() - count_result = MagicMock() - count_result.scalar.return_value = 0 - list_result = MagicMock() - list_result.scalars.return_value.all.return_value = [] - session.execute.side_effect = [count_result, list_result] - - result = list_positions(session) - - assert result.positions == [] - - -class TestGetPosition: - def test_returns_enriched_position(self) -> None: - pos = _make_position() - sec = _make_security() - ent = _make_entity() - - session = MagicMock() - pos_result = MagicMock() - pos_result.scalar_one_or_none.return_value = pos - sec_result = MagicMock() - sec_result.scalars.return_value.all.return_value = [sec] - ent_result = MagicMock() - ent_result.scalars.return_value.all.return_value = [ent] - session.execute.side_effect = [pos_result, sec_result, ent_result] - - result = get_position(session, "pos_01") - - assert result is not None - assert result.security_name == "Acme Class A" - assert result.entity_name == "Acme Corp" - - def test_returns_none_when_missing(self) -> None: - session = MagicMock() - miss = MagicMock() - miss.scalar_one_or_none.return_value = None - session.execute.side_effect = [miss] - - assert get_position(session, "pos_missing") is None - - -class TestCreatePosition: - def _base_body(self, **overrides) -> CreatePositionRequest: - defaults = { - "portfolio_id": "pf_01", - "security_id": "sec_01", - "quantity": 100.0, - "quantity_type": "shares", - "cost_basis": 50000, - "currency": "USD", - "acquisition_date": date(2024, 6, 1), - } - defaults.update(overrides) - return CreatePositionRequest(**defaults) - - def test_creates_successfully(self) -> None: - portfolio = _make_portfolio() - security = _make_security() - entity = _make_entity() - - session = MagicMock() - pf_result = MagicMock() - pf_result.scalar_one_or_none.return_value = portfolio - sec_result = MagicMock() - sec_result.scalar_one_or_none.return_value = security - ent_result = MagicMock() - ent_result.scalar_one_or_none.return_value = entity - session.execute.side_effect = [pf_result, sec_result, ent_result] - - fake_pos = _make_position() - with patch( - "robosystems.operations.roboinvestor.commands.positions.Position", - return_value=fake_pos, - ): - result = create_position(session, self._base_body(), created_by="usr_1") - - assert result.security_name == "Acme Class A" - assert result.entity_name == "Acme Corp" - session.add.assert_called_once_with(fake_pos) - session.flush.assert_called_once() - - def test_raises_when_portfolio_missing(self) -> None: - session = MagicMock() - miss = MagicMock() - miss.scalar_one_or_none.return_value = None - session.execute.side_effect = [miss] - - with pytest.raises(PortfolioNotFoundError): - create_position(session, self._base_body(), created_by="usr_1") - session.add.assert_not_called() - - def test_raises_when_security_missing(self) -> None: - session = MagicMock() - pf_result = MagicMock() - pf_result.scalar_one_or_none.return_value = _make_portfolio() - sec_result = MagicMock() - sec_result.scalar_one_or_none.return_value = None - session.execute.side_effect = [pf_result, sec_result] - - with pytest.raises(SecurityNotFoundError): - create_position(session, self._base_body(), created_by="usr_1") - session.add.assert_not_called() - - def test_translates_integrity_error_to_duplicate(self) -> None: - session = MagicMock() - pf_result = MagicMock() - pf_result.scalar_one_or_none.return_value = _make_portfolio() - sec_result = MagicMock() - sec_result.scalar_one_or_none.return_value = _make_security() - ent_result = MagicMock() - ent_result.scalar_one_or_none.return_value = _make_entity() - session.execute.side_effect = [pf_result, sec_result, ent_result] - orig = Exception("unique violation") - orig.pgcode = "23505" # type: ignore[attr-defined] - session.flush.side_effect = IntegrityError("stmt", {}, orig) - - with ( - patch( - "robosystems.operations.roboinvestor.commands.positions.Position", - return_value=_make_position(), - ), - pytest.raises(DuplicateActivePositionError), - ): - create_position(session, self._base_body(), created_by="usr_1") - - def test_reraises_non_unique_integrity_error(self) -> None: - session = MagicMock() - pf_result = MagicMock() - pf_result.scalar_one_or_none.return_value = _make_portfolio() - sec_result = MagicMock() - sec_result.scalar_one_or_none.return_value = _make_security() - ent_result = MagicMock() - ent_result.scalar_one_or_none.return_value = _make_entity() - session.execute.side_effect = [pf_result, sec_result, ent_result] - orig = Exception("fk violation") - orig.pgcode = "23503" # type: ignore[attr-defined] # foreign_key_violation - session.flush.side_effect = IntegrityError("stmt", {}, orig) - - with ( - patch( - "robosystems.operations.roboinvestor.commands.positions.Position", - return_value=_make_position(), - ), - pytest.raises(IntegrityError), - ): - create_position(session, self._base_body(), created_by="usr_1") - - -class TestUpdatePosition: - def test_applies_updates_and_enriches(self) -> None: - from robosystems.models.api.extensions.investor import UpdatePositionOperation - - row = _make_position(notes="old") - sec = _make_security() - ent = _make_entity() - - session = MagicMock() - pos_result = MagicMock() - pos_result.scalar_one_or_none.return_value = row - sec_result = MagicMock() - sec_result.scalars.return_value.all.return_value = [sec] - ent_result = MagicMock() - ent_result.scalars.return_value.all.return_value = [ent] - session.execute.side_effect = [pos_result, sec_result, ent_result] - - result = update_position( - session, UpdatePositionOperation(position_id="pos_01", notes="new") - ) - - assert row.notes == "new" - assert result.notes == "new" - - def test_raises_when_missing(self) -> None: - from robosystems.models.api.extensions.investor import UpdatePositionOperation - from robosystems.operations.roboinvestor.commands.positions import ( - PositionNotFoundError, - ) - - session = MagicMock() - miss = MagicMock() - miss.scalar_one_or_none.return_value = None - session.execute.side_effect = [miss] - - with pytest.raises(PositionNotFoundError): - update_position( - session, UpdatePositionOperation(position_id="pos_missing", notes="x") - ) - - -class TestSoftDeletePosition: - def test_sets_status_to_disposed(self) -> None: - from robosystems.models.api.extensions.investor import DeletePositionOperation - - row = _make_position(status="active") - session = MagicMock() - session.execute.return_value.scalar_one_or_none.return_value = row - - result = soft_delete_position( - session, DeletePositionOperation(position_id="pos_01") - ) - assert result.deleted is True - assert row.status == "disposed" - - def test_raises_when_missing(self) -> None: - from robosystems.models.api.extensions.investor import DeletePositionOperation - from robosystems.operations.roboinvestor.commands.positions import ( - PositionNotFoundError, - ) - - session = MagicMock() - session.execute.return_value.scalar_one_or_none.return_value = None - - with pytest.raises(PositionNotFoundError): - soft_delete_position(session, DeletePositionOperation(position_id="pos_missing")) diff --git a/tests/routers/extensions/roboinvestor/test_operations.py b/tests/routers/extensions/roboinvestor/test_operations.py index 791d16fa..e1fee1c6 100644 --- a/tests/routers/extensions/roboinvestor/test_operations.py +++ b/tests/routers/extensions/roboinvestor/test_operations.py @@ -1,14 +1,17 @@ """Tests for the roboinvestor operation routes. -All 9 RoboInvestor ops are registrar-generated from `OperationSpec` +All RoboInvestor ops are registrar-generated from `OperationSpec` declarations. The registrar late-binds commands through `sys.modules`, so tests patch at the command's origin module path — not the router module. That mirrors the roboledger registrar tests. + +Phase a-prime replaced atom-level portfolio + position CRUD with the +Portfolio Block envelope ops; security ops stay as Master Data CRUD. """ from __future__ import annotations -from datetime import UTC, date, datetime +from datetime import UTC, datetime from unittest.mock import MagicMock, patch import pytest @@ -16,50 +19,49 @@ from robosystems.middleware.operations import OperationEnvelope from robosystems.models.api.extensions.investor import ( - CreatePortfolioRequest, - CreatePositionRequest, + CreatePortfolioBlockRequest, CreateSecurityRequest, - DeletePortfolioOperation, - DeletePositionOperation, + DeletePortfolioBlockOperation, + DeletePortfolioBlockResponse, DeleteResult, DeleteSecurityOperation, - PortfolioResponse, - PositionResponse, + PortfolioBlockEnvelope, + PortfolioBlockPortfolioFields, + PortfolioBlockPortfolioPatch, + PortfolioBlockPositionAdd, + PortfolioBlockPositionDispose, + PortfolioBlockPositions, + PortfolioBlockPositionUpdate, SecurityResponse, - UpdatePortfolioOperation, - UpdatePositionOperation, + UpdatePortfolioBlockOperation, UpdateSecurityOperation, ) -from robosystems.operations.roboinvestor.commands.portfolios import ( - PortfolioHasActivePositionsError, +from robosystems.operations.roboinvestor.commands.portfolio_block import ( + ActivePositionsRequireConfirmationError, PortfolioNotFoundError, -) -from robosystems.operations.roboinvestor.commands.positions import ( - DuplicateActivePositionError, PositionNotFoundError, + SecurityNotFoundError, ) from robosystems.operations.roboinvestor.commands.securities import ( EntityNotFoundError, - SecurityNotFoundError, +) +from robosystems.operations.roboinvestor.commands.securities import ( + SecurityNotFoundError as SecurityMasterNotFoundError, ) from robosystems.routers.extensions.roboinvestor.operations import ( - create_portfolio_op, - create_position_op, + create_portfolio_block_op, create_security_op, - delete_portfolio_op, - delete_position_op, + delete_portfolio_block_op, delete_security_op, - update_portfolio_op, - update_position_op, + update_portfolio_block_op, update_security_op, ) GRAPH_ID = "kg01234567890abcdef" # Command-origin patch paths (registrar resolves commands via sys.modules). -PORTFOLIOS = "robosystems.operations.roboinvestor.commands.portfolios" +PORTFOLIO_BLOCK = "robosystems.operations.roboinvestor.commands.portfolio_block" SECURITIES = "robosystems.operations.roboinvestor.commands.securities" -POSITIONS = "robosystems.operations.roboinvestor.commands.positions" # Session factory patch path — the registrar resolves the session factory # through `sys.modules` too, so the patch must target the origin module. @@ -67,8 +69,6 @@ def _entity_meta(schema_extensions=("roboinvestor",), graph_type="entity"): - """Stub `GraphExtensionContext` the extension gate returns for a normal - entity graph provisioned for roboinvestor.""" from robosystems.middleware.extensions import GraphExtensionContext return GraphExtensionContext( @@ -85,8 +85,6 @@ def _make_user() -> MagicMock: class _FakeCache: - """In-memory idempotency cache matching the real signature.""" - def __init__(self) -> None: self.store: dict = {} @@ -127,11 +125,19 @@ def _mock_session_ctx(): return mock_ctx, mock_session -def _make_portfolio() -> PortfolioResponse: - return PortfolioResponse( - id="pf_01", +def _make_envelope( + *, + portfolio_id: str = "pf_01", + position_count: int = 0, +) -> PortfolioBlockEnvelope: + return PortfolioBlockEnvelope( + id=portfolio_id, name="Main Fund", base_currency="USD", + positions=[], + total_cost_basis_dollars=0.0, + total_current_value_dollars=None, + active_position_count=position_count, created_at=datetime(2024, 1, 1, tzinfo=UTC), updated_at=datetime(2024, 1, 1, tzinfo=UTC), ) @@ -150,39 +156,27 @@ def _make_security() -> SecurityResponse: ) -def _make_position() -> PositionResponse: - return PositionResponse( - id="pos_01", - portfolio_id="pf_01", - security_id="sec_01", - quantity=100.0, - quantity_type="shares", - cost_basis=50000, - cost_basis_dollars=500.0, - currency="USD", - current_value=55000, - current_value_dollars=550.0, - status="active", - created_at=datetime(2024, 6, 1, tzinfo=UTC), - updated_at=datetime(2024, 6, 1, tzinfo=UTC), - ) - - # ──────────────────────────────────────────────────────────────────── -# Portfolio +# Portfolio Block — create # ──────────────────────────────────────────────────────────────────── -class TestCreatePortfolioOp: +class TestCreatePortfolioBlockOp: @pytest.mark.asyncio async def test_happy_path(self) -> None: - body = CreatePortfolioRequest(name="New Fund", base_currency="USD") + body = CreatePortfolioBlockRequest( + portfolio=PortfolioBlockPortfolioFields(name="New Fund"), + positions=[], + ) with ( - patch(f"{PORTFOLIOS}.create_portfolio", return_value=_make_portfolio()), + patch( + f"{PORTFOLIO_BLOCK}.create_portfolio_block", + return_value=_make_envelope(), + ), patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), ): - env = await create_portfolio_op( + env = await create_portfolio_block_op( body=body, graph_id=GRAPH_ID, user=_make_user(), @@ -192,21 +186,53 @@ async def test_happy_path(self) -> None: ) assert isinstance(env, OperationEnvelope) - assert env.operation == "create-portfolio" + assert env.operation == "create-portfolio-block" assert env.status == "completed" assert env.result["id"] == "pf_01" + @pytest.mark.asyncio + async def test_unknown_security_returns_404(self) -> None: + body = CreatePortfolioBlockRequest( + portfolio=PortfolioBlockPortfolioFields(name="Fund"), + positions=[ + PortfolioBlockPositionAdd(security_id="sec_missing", quantity=1.0, cost_basis=0) + ], + ) + + with ( + patch( + f"{PORTFOLIO_BLOCK}.create_portfolio_block", + side_effect=SecurityNotFoundError("sec_missing"), + ), + patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), + ): + with pytest.raises(HTTPException) as exc: + await create_portfolio_block_op( + body=body, + graph_id=GRAPH_ID, + user=_make_user(), + _ext=_entity_meta(), + idempotency_key=None, + cache=_FakeCache(), + ) + assert exc.value.status_code == 404 + assert "Security not found" in exc.value.detail + @pytest.mark.asyncio async def test_schema_missing_returns_404(self) -> None: from sqlalchemy.exc import ProgrammingError + body = CreatePortfolioBlockRequest( + portfolio=PortfolioBlockPortfolioFields(name="X"), + ) + with patch( SESSION_FACTORY, side_effect=ProgrammingError("stmt", {}, Exception("schema missing")), ): with pytest.raises(HTTPException) as exc: - await create_portfolio_op( - body=CreatePortfolioRequest(name="X"), + await create_portfolio_block_op( + body=body, graph_id=GRAPH_ID, user=_make_user(), _ext=_entity_meta(), @@ -217,16 +243,38 @@ async def test_schema_missing_returns_404(self) -> None: assert "not initialized" in exc.value.detail -class TestUpdatePortfolioOp: +# ──────────────────────────────────────────────────────────────────── +# Portfolio Block — update +# ──────────────────────────────────────────────────────────────────── + + +class TestUpdatePortfolioBlockOp: @pytest.mark.asyncio async def test_happy_path(self) -> None: - body = UpdatePortfolioOperation(portfolio_id="pf_01", name="Renamed") + body = UpdatePortfolioBlockOperation( + portfolio_id="pf_01", + portfolio=PortfolioBlockPortfolioPatch(name="Renamed"), + positions=PortfolioBlockPositions( + add=[ + PortfolioBlockPositionAdd( + security_id="sec_01", quantity=10.0, cost_basis=5000 + ) + ], + update=[PortfolioBlockPositionUpdate(id="pos_01", notes="upd")], + dispose=[ + PortfolioBlockPositionDispose(id="pos_old", disposition_reason="sold") + ], + ), + ) with ( - patch(f"{PORTFOLIOS}.update_portfolio", return_value=_make_portfolio()) as m, + patch( + f"{PORTFOLIO_BLOCK}.update_portfolio_block", + return_value=_make_envelope(), + ) as m, patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), ): - env = await update_portfolio_op( + env = await update_portfolio_block_op( body=body, graph_id=GRAPH_ID, user=_make_user(), @@ -235,26 +283,27 @@ async def test_happy_path(self) -> None: cache=_FakeCache(), ) - # Registrar calls `update_portfolio(session, body)` — `body` carries - # portfolio_id, the command pulls it out. _session, passed_body = m.call_args[0] assert passed_body.portfolio_id == "pf_01" - assert passed_body.name == "Renamed" + assert passed_body.portfolio.name == "Renamed" + assert len(passed_body.positions.add) == 1 + assert len(passed_body.positions.update) == 1 + assert len(passed_body.positions.dispose) == 1 assert env.status == "completed" @pytest.mark.asyncio - async def test_not_found_returns_404(self) -> None: - body = UpdatePortfolioOperation(portfolio_id="pf_missing", name="X") + async def test_portfolio_not_found_returns_404(self) -> None: + body = UpdatePortfolioBlockOperation(portfolio_id="pf_missing") with ( patch( - f"{PORTFOLIOS}.update_portfolio", + f"{PORTFOLIO_BLOCK}.update_portfolio_block", side_effect=PortfolioNotFoundError("pf_missing"), ), patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), ): with pytest.raises(HTTPException) as exc: - await update_portfolio_op( + await update_portfolio_block_op( body=body, graph_id=GRAPH_ID, user=_make_user(), @@ -264,17 +313,56 @@ async def test_not_found_returns_404(self) -> None: ) assert exc.value.status_code == 404 + @pytest.mark.asyncio + async def test_position_not_found_returns_404(self) -> None: + body = UpdatePortfolioBlockOperation( + portfolio_id="pf_01", + positions=PortfolioBlockPositions( + update=[PortfolioBlockPositionUpdate(id="pos_missing")] + ), + ) -class TestDeletePortfolioOp: + with ( + patch( + f"{PORTFOLIO_BLOCK}.update_portfolio_block", + side_effect=PositionNotFoundError("pos_missing"), + ), + patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), + ): + with pytest.raises(HTTPException) as exc: + await update_portfolio_block_op( + body=body, + graph_id=GRAPH_ID, + user=_make_user(), + _ext=_entity_meta(), + idempotency_key=None, + cache=_FakeCache(), + ) + assert exc.value.status_code == 404 + + +# ──────────────────────────────────────────────────────────────────── +# Portfolio Block — delete +# ──────────────────────────────────────────────────────────────────── + + +class TestDeletePortfolioBlockOp: @pytest.mark.asyncio async def test_happy_path(self) -> None: - body = DeletePortfolioOperation(portfolio_id="pf_01") + body = DeletePortfolioBlockOperation( + portfolio_id="pf_01", confirm_active_positions=True + ) with ( - patch(f"{PORTFOLIOS}.delete_portfolio", return_value=DeleteResult(deleted=True)), + patch( + f"{PORTFOLIO_BLOCK}.delete_portfolio_block", + return_value=DeletePortfolioBlockResponse( + deleted=True, portfolio_id="pf_01", positions_deleted=2 + ), + ), patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), ): - env = await delete_portfolio_op( + env = await delete_portfolio_block_op( body=body, graph_id=GRAPH_ID, user=_make_user(), @@ -283,21 +371,22 @@ async def test_happy_path(self) -> None: cache=_FakeCache(), ) - assert env.result == {"deleted": True} + assert env.result["deleted"] is True + assert env.result["positions_deleted"] == 2 @pytest.mark.asyncio - async def test_has_active_positions_returns_409(self) -> None: - body = DeletePortfolioOperation(portfolio_id="pf_01") + async def test_active_positions_without_confirm_returns_409(self) -> None: + body = DeletePortfolioBlockOperation(portfolio_id="pf_01") with ( patch( - f"{PORTFOLIOS}.delete_portfolio", - side_effect=PortfolioHasActivePositionsError(3), + f"{PORTFOLIO_BLOCK}.delete_portfolio_block", + side_effect=ActivePositionsRequireConfirmationError(3), ), patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), ): with pytest.raises(HTTPException) as exc: - await delete_portfolio_op( + await delete_portfolio_block_op( body=body, graph_id=GRAPH_ID, user=_make_user(), @@ -310,17 +399,17 @@ async def test_has_active_positions_returns_409(self) -> None: @pytest.mark.asyncio async def test_not_found_returns_404(self) -> None: - body = DeletePortfolioOperation(portfolio_id="pf_x") + body = DeletePortfolioBlockOperation(portfolio_id="pf_x") with ( patch( - f"{PORTFOLIOS}.delete_portfolio", + f"{PORTFOLIO_BLOCK}.delete_portfolio_block", side_effect=PortfolioNotFoundError("pf_x"), ), patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), ): with pytest.raises(HTTPException) as exc: - await delete_portfolio_op( + await delete_portfolio_block_op( body=body, graph_id=GRAPH_ID, user=_make_user(), @@ -332,7 +421,7 @@ async def test_not_found_returns_404(self) -> None: # ──────────────────────────────────────────────────────────────────── -# Security +# Security (Master Data CRUD — unchanged) # ──────────────────────────────────────────────────────────────────── @@ -419,7 +508,7 @@ async def test_not_found_returns_404(self) -> None: with ( patch( f"{SECURITIES}.update_security", - side_effect=SecurityNotFoundError("sec_missing"), + side_effect=SecurityMasterNotFoundError("sec_missing"), ), patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), ): @@ -456,193 +545,3 @@ async def test_happy_path(self) -> None: ) assert env.result == {"deleted": True} - - -# ──────────────────────────────────────────────────────────────────── -# Position -# ──────────────────────────────────────────────────────────────────── - - -class TestCreatePositionOp: - @pytest.mark.asyncio - async def test_happy_path(self) -> None: - body = CreatePositionRequest( - portfolio_id="pf_01", - security_id="sec_01", - quantity=100.0, - cost_basis=50000, - currency="USD", - acquisition_date=date(2024, 6, 1), - ) - - with ( - patch(f"{POSITIONS}.create_position", return_value=_make_position()), - patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), - ): - env = await create_position_op( - body=body, - graph_id=GRAPH_ID, - user=_make_user(), - _ext=_entity_meta(), - idempotency_key=None, - cache=_FakeCache(), - ) - - assert env.operation == "create-position" - # Envelope result uses snake_case (Pydantic model_dump default) — GraphQL - # is the only surface that camelCases via Strawberry's auto_camel_case. - assert env.result["cost_basis_dollars"] == 500.0 - - @pytest.mark.asyncio - async def test_portfolio_not_found_returns_404(self) -> None: - body = CreatePositionRequest( - portfolio_id="pf_missing", - security_id="sec_01", - quantity=1.0, - cost_basis=0, - currency="USD", - ) - - with ( - patch( - f"{POSITIONS}.create_position", - side_effect=PortfolioNotFoundError("pf_missing"), - ), - patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), - ): - with pytest.raises(HTTPException) as exc: - await create_position_op( - body=body, - graph_id=GRAPH_ID, - user=_make_user(), - _ext=_entity_meta(), - idempotency_key=None, - cache=_FakeCache(), - ) - assert exc.value.status_code == 404 - assert "Portfolio not found" in exc.value.detail - - @pytest.mark.asyncio - async def test_security_not_found_returns_404(self) -> None: - body = CreatePositionRequest( - portfolio_id="pf_01", - security_id="sec_missing", - quantity=1.0, - cost_basis=0, - currency="USD", - ) - - with ( - patch( - f"{POSITIONS}.create_position", - side_effect=SecurityNotFoundError("sec_missing"), - ), - patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), - ): - with pytest.raises(HTTPException) as exc: - await create_position_op( - body=body, - graph_id=GRAPH_ID, - user=_make_user(), - _ext=_entity_meta(), - idempotency_key=None, - cache=_FakeCache(), - ) - assert exc.value.status_code == 404 - assert "Security not found" in exc.value.detail - - @pytest.mark.asyncio - async def test_duplicate_active_position_returns_409(self) -> None: - body = CreatePositionRequest( - portfolio_id="pf_01", - security_id="sec_01", - quantity=1.0, - cost_basis=0, - currency="USD", - ) - - with ( - patch( - f"{POSITIONS}.create_position", - side_effect=DuplicateActivePositionError("already exists"), - ), - patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), - ): - with pytest.raises(HTTPException) as exc: - await create_position_op( - body=body, - graph_id=GRAPH_ID, - user=_make_user(), - _ext=_entity_meta(), - idempotency_key=None, - cache=_FakeCache(), - ) - assert exc.value.status_code == 409 - - -class TestUpdatePositionOp: - @pytest.mark.asyncio - async def test_happy_path(self) -> None: - body = UpdatePositionOperation(position_id="pos_01", notes="new note") - - with ( - patch(f"{POSITIONS}.update_position", return_value=_make_position()) as m, - patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), - ): - await update_position_op( - body=body, - graph_id=GRAPH_ID, - user=_make_user(), - _ext=_entity_meta(), - idempotency_key=None, - cache=_FakeCache(), - ) - - _session, passed_body = m.call_args[0] - assert passed_body.position_id == "pos_01" - assert passed_body.notes == "new note" - - -class TestDeletePositionOp: - @pytest.mark.asyncio - async def test_happy_path(self) -> None: - body = DeletePositionOperation(position_id="pos_01") - - with ( - patch( - f"{POSITIONS}.soft_delete_position", return_value=DeleteResult(deleted=True) - ), - patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), - ): - env = await delete_position_op( - body=body, - graph_id=GRAPH_ID, - user=_make_user(), - _ext=_entity_meta(), - idempotency_key=None, - cache=_FakeCache(), - ) - - assert env.result == {"deleted": True} - - @pytest.mark.asyncio - async def test_not_found_returns_404(self) -> None: - body = DeletePositionOperation(position_id="pos_missing") - - with ( - patch( - f"{POSITIONS}.soft_delete_position", - side_effect=PositionNotFoundError("pos_missing"), - ), - patch(SESSION_FACTORY, return_value=_mock_session_ctx()[0]), - ): - with pytest.raises(HTTPException) as exc: - await delete_position_op( - body=body, - graph_id=GRAPH_ID, - user=_make_user(), - _ext=_entity_meta(), - idempotency_key=None, - cache=_FakeCache(), - ) - assert exc.value.status_code == 404 diff --git a/uv.lock b/uv.lock index acf17df5..8dc6f08f 100644 --- a/uv.lock +++ b/uv.lock @@ -3464,7 +3464,7 @@ requires-dist = [ { name = "retrying", specifier = ">=1.4.0,<2.0" }, { name = "rich", marker = "extra == 'dev'", specifier = ">=14.0.0,<15.0" }, { name = "robosystems-client", specifier = ">=0.3.14" }, - { name = "robosystems-client", marker = "extra == 'dev'", specifier = "==0.3.16" }, + { name = "robosystems-client", marker = "extra == 'dev'", specifier = "==0.3.17" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.12.0,<1.0" }, { name = "sqlalchemy", specifier = ">=2.0.0,<3.0" }, { name = "sse-starlette", specifier = ">=3.3.0,<4.0" }, @@ -3487,7 +3487,7 @@ lambda = [ [[package]] name = "robosystems-client" -version = "0.3.16" +version = "0.3.17" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -3496,9 +3496,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/12/e1295852dfe565ee7953d63a1119377340cc58f62697b107ffa11b6d465a/robosystems_client-0.3.16.tar.gz", hash = "sha256:5b477dd1bdc6bd37e31bbca6d4c302da23f5a04a0a88e574c267a2c5fa8d35f6", size = 295865, upload-time = "2026-04-26T04:50:17.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/e7/b1eb354e492698b836c0156927aa268e239432f9b6ed7c5dfea290d10a37/robosystems_client-0.3.17.tar.gz", hash = "sha256:a43f87da3744efd52e561b6f5a89ee1ebf9ffd4c0c219fc0b7f9b492513f1e08", size = 297065, upload-time = "2026-04-26T06:39:19.876Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/76/0ff7ee4fed99c83e15756b36f56dc3fa08fd391191807e9549798e1dad5d/robosystems_client-0.3.16-py3-none-any.whl", hash = "sha256:a03cee03ea20f140c91d9c6f5d5a0e6af13a24a0185a742c833ff17140399c21", size = 684171, upload-time = "2026-04-26T04:50:15.937Z" }, + { url = "https://files.pythonhosted.org/packages/d1/44/be94dd734829cef7432df999e8c7a0914a0cc358a640f97dd392f3c1d31e/robosystems_client-0.3.17-py3-none-any.whl", hash = "sha256:90afe8f315ddfd45dd4a3aede4dde59ae30a3a94c97c0d07cd0e7da7314e6cbb", size = 684189, upload-time = "2026-04-26T06:39:17.851Z" }, ] [[package]]