Skip to main content
Glama
PORTFOLIO_PERSONALIZATION_PLAN.md25.8 kB
# PORTFOLIO PERSONALIZATION - EXECUTION PLAN ## 1. Big Picture / Goal **Objective:** Transform MaverickMCP's portfolio analysis tools from stateless, repetitive-input operations into an intelligent, personalized AI financial assistant through persistent portfolio storage and context-aware tool integration. **Architectural Goal:** Implement a two-phase system that (1) adds persistent portfolio storage with cost basis tracking using established DDD patterns, and (2) intelligently enhances existing tools to auto-detect user holdings and provide personalized analysis without breaking the stateless MCP tool contract. **Success Criteria (Mandatory):** - **Phase 1 Complete:** 4 new MCP tools (`add_portfolio_position`, `get_my_portfolio`, `remove_portfolio_position`, `clear_my_portfolio`) and 1 MCP resource (`portfolio://my-holdings`) fully functional - **Database Integration:** SQLAlchemy models with proper cost basis averaging, Alembic migration creating tables without conflicts - **Phase 2 Integration:** 3 existing tools enhanced (`risk_adjusted_analysis`, `portfolio_correlation_analysis`, `compare_tickers`) with automatic portfolio detection - **AI Context Injection:** Portfolio resource provides live P&L, diversification metrics, and position details to AI agents automatically - **Test Coverage:** 85%+ test coverage with unit, integration, and domain tests passing - **Code Quality:** Zero linting errors (ruff), full type annotations (ty), all hooks passing - **Documentation:** PORTFOLIO.md guide, updated tool docstrings, usage examples in Claude Desktop **Financial Disclaimer:** All portfolio features include educational disclaimers. No investment recommendations. Local-first storage only. No tax advice provided. ## 2. To-Do List (High Level) ### Phase 1: Persistent Portfolio Storage Foundation (4-5 days) - [ ] **Spike 1:** Research cost basis averaging algorithms and edge cases (FIFO, average cost) - [ ] **Domain Entities:** Create `Portfolio` and `Position` domain entities with business logic - [ ] **Database Models:** Implement `UserPortfolio` and `PortfolioPosition` SQLAlchemy models - [ ] **Migration:** Create Alembic migration with proper indexes and constraints - [ ] **MCP Tools:** Implement 4 portfolio management tools with validation - [ ] **MCP Resource:** Implement `portfolio://my-holdings` with live P&L calculations - [ ] **Unit Tests:** Comprehensive domain entity and cost basis tests - [ ] **Integration Tests:** Database operation and transaction tests ### Phase 2: Intelligent Tool Integration (2-3 days) - [ ] **Risk Analysis Enhancement:** Add position awareness to `risk_adjusted_analysis` - [ ] **Correlation Enhancement:** Enable `portfolio_correlation_analysis` with no arguments - [ ] **Comparison Enhancement:** Enable `compare_tickers` with optional portfolio auto-fill - [ ] **Resource Enhancement:** Add live market data to portfolio resource - [ ] **Integration Tests:** Cross-tool functionality validation - [ ] **Documentation:** Update existing tool docstrings with new capabilities ### Phase 3: Polish & Documentation (1-2 days) - [ ] **Manual Testing:** Claude Desktop end-to-end workflow validation - [ ] **Error Handling:** Edge case coverage (partial sells, zero shares, invalid tickers) - [ ] **Performance:** Query optimization, batch operations, caching strategy - [ ] **Documentation:** Complete PORTFOLIO.md with examples and screenshots - [ ] **Migration Testing:** Test upgrade/downgrade paths ## 3. Plan Details (Spikes & Features) ### Spike 1: Cost Basis Averaging Research **Action:** Investigate cost basis calculation methods (FIFO, LIFO, average cost) and determine optimal approach for educational portfolio tracking. **Steps:** 1. Research IRS cost basis methods and educational best practices 2. Analyze existing `PortfolioManager` tool (JSON-based, average cost) for patterns 3. Design algorithm for averaging purchases and handling partial sells 4. Create specification document for edge cases: - Multiple purchases at different prices - Partial position sales - Zero/negative share handling - Rounding and precision (financial data uses Numeric(12,4)) 5. Benchmark performance for 100+ positions with 1000+ transactions **Expected Outcome:** Clear specification for cost basis implementation using **average cost method** (simplest for educational use, matches existing PortfolioManager), with edge case handling documented. **Decision Rationale:** Average cost is simpler than FIFO/LIFO, appropriate for educational context, and avoids tax accounting complexity. --- ### Feature A: Domain Entities (DDD Pattern) **Goal:** Create pure business logic entities following MaverickMCP's established DDD patterns (similar to backtesting domain entities). **Files to Create:** - `maverick_mcp/domain/portfolio.py` - Core domain entities - `maverick_mcp/domain/position.py` - Position value objects **Domain Entity Design:** ```python # maverick_mcp/domain/portfolio.py from dataclasses import dataclass from datetime import datetime from decimal import Decimal from typing import List, Optional @dataclass class Position: """Value object representing a single portfolio position.""" ticker: str shares: Decimal # Use Decimal for precision average_cost_basis: Decimal total_cost: Decimal purchase_date: datetime # Earliest purchase notes: Optional[str] = None def add_shares(self, shares: Decimal, price: Decimal, date: datetime) -> "Position": """Add shares with automatic cost basis averaging.""" new_total_shares = self.shares + shares new_total_cost = self.total_cost + (shares * price) new_avg_cost = new_total_cost / new_total_shares return Position( ticker=self.ticker, shares=new_total_shares, average_cost_basis=new_avg_cost, total_cost=new_total_cost, purchase_date=min(self.purchase_date, date), notes=self.notes ) def remove_shares(self, shares: Decimal) -> Optional["Position"]: """Remove shares, return None if position fully closed.""" if shares >= self.shares: return None # Full position close new_shares = self.shares - shares new_total_cost = new_shares * self.average_cost_basis return Position( ticker=self.ticker, shares=new_shares, average_cost_basis=self.average_cost_basis, total_cost=new_total_cost, purchase_date=self.purchase_date, notes=self.notes ) def calculate_current_value(self, current_price: Decimal) -> dict: """Calculate live P&L metrics.""" current_value = self.shares * current_price unrealized_pnl = current_value - self.total_cost pnl_percentage = (unrealized_pnl / self.total_cost * 100) if self.total_cost else Decimal(0) return { "current_value": current_value, "unrealized_pnl": unrealized_pnl, "pnl_percentage": pnl_percentage } @dataclass class Portfolio: """Aggregate root for user portfolio.""" portfolio_id: str # UUID user_id: str # "default" for single-user name: str positions: List[Position] created_at: datetime updated_at: datetime def add_position(self, ticker: str, shares: Decimal, price: Decimal, date: datetime, notes: Optional[str] = None) -> None: """Add or update position with automatic averaging.""" # Find existing position for i, pos in enumerate(self.positions): if pos.ticker == ticker: self.positions[i] = pos.add_shares(shares, price, date) self.updated_at = datetime.now(UTC) return # Create new position new_position = Position( ticker=ticker, shares=shares, average_cost_basis=price, total_cost=shares * price, purchase_date=date, notes=notes ) self.positions.append(new_position) self.updated_at = datetime.now(UTC) def remove_position(self, ticker: str, shares: Optional[Decimal] = None) -> bool: """Remove position or partial shares.""" for i, pos in enumerate(self.positions): if pos.ticker == ticker: if shares is None or shares >= pos.shares: # Full position removal self.positions.pop(i) else: # Partial removal updated_pos = pos.remove_shares(shares) if updated_pos: self.positions[i] = updated_pos else: self.positions.pop(i) self.updated_at = datetime.now(UTC) return True return False def get_position(self, ticker: str) -> Optional[Position]: """Get position by ticker.""" return next((pos for pos in self.positions if pos.ticker == ticker), None) def get_total_invested(self) -> Decimal: """Calculate total capital invested.""" return sum(pos.total_cost for pos in self.positions) def calculate_portfolio_metrics(self, current_prices: dict[str, Decimal]) -> dict: """Calculate comprehensive portfolio metrics.""" total_value = Decimal(0) total_cost = Decimal(0) position_details = [] for pos in self.positions: current_price = current_prices.get(pos.ticker, pos.average_cost_basis) metrics = pos.calculate_current_value(current_price) total_value += metrics["current_value"] total_cost += pos.total_cost position_details.append({ "ticker": pos.ticker, "shares": float(pos.shares), "cost_basis": float(pos.average_cost_basis), "current_price": float(current_price), **{k: float(v) for k, v in metrics.items()} }) total_pnl = total_value - total_cost total_pnl_pct = (total_pnl / total_cost * 100) if total_cost else Decimal(0) return { "total_value": float(total_value), "total_invested": float(total_cost), "total_pnl": float(total_pnl), "total_pnl_percentage": float(total_pnl_pct), "position_count": len(self.positions), "positions": position_details } ``` **Testing Strategy:** - Unit tests for cost basis averaging edge cases - Property-based tests for arithmetic precision - Edge case tests: zero shares, negative P&L, division by zero --- ### Feature B: Database Models (SQLAlchemy ORM) **Goal:** Create persistent storage models following established patterns in `maverick_mcp/data/models.py`. **Files to Modify:** - `maverick_mcp/data/models.py` - Add new models (lines ~1700+) **Model Design:** ```python # Add to maverick_mcp/data/models.py class UserPortfolio(TimestampMixin, Base): """ User portfolio for tracking investment holdings. Follows personal-use design: single user_id="default" """ __tablename__ = "mcp_portfolios" id = Column(Uuid, primary_key=True, default=uuid.uuid4) user_id = Column(String(50), nullable=False, default="default", index=True) name = Column(String(200), nullable=False, default="My Portfolio") # Relationships positions = relationship( "PortfolioPosition", back_populates="portfolio", cascade="all, delete-orphan", lazy="selectin" # Efficient loading ) # Indexes for queries __table_args__ = ( Index("idx_portfolio_user", "user_id"), UniqueConstraint("user_id", "name", name="uq_user_portfolio_name"), ) def __repr__(self): return f"<UserPortfolio(id={self.id}, name='{self.name}', positions={len(self.positions)})>" class PortfolioPosition(TimestampMixin, Base): """ Individual position within a portfolio with cost basis tracking. """ __tablename__ = "mcp_portfolio_positions" id = Column(Uuid, primary_key=True, default=uuid.uuid4) portfolio_id = Column(Uuid, ForeignKey("mcp_portfolios.id", ondelete="CASCADE"), nullable=False) # Position details ticker = Column(String(20), nullable=False, index=True) shares = Column(Numeric(20, 8), nullable=False) # High precision for fractional shares average_cost_basis = Column(Numeric(12, 4), nullable=False) # Financial precision total_cost = Column(Numeric(20, 4), nullable=False) # Total capital invested purchase_date = Column(DateTime(timezone=True), nullable=False) # Earliest purchase notes = Column(Text, nullable=True) # Optional user notes # Relationships portfolio = relationship("UserPortfolio", back_populates="positions") # Indexes for efficient queries __table_args__ = ( Index("idx_position_portfolio", "portfolio_id"), Index("idx_position_ticker", "ticker"), Index("idx_position_portfolio_ticker", "portfolio_id", "ticker"), UniqueConstraint("portfolio_id", "ticker", name="uq_portfolio_position_ticker"), ) def __repr__(self): return f"<PortfolioPosition(ticker='{self.ticker}', shares={self.shares}, cost_basis={self.average_cost_basis})>" ``` **Key Design Decisions:** 1. **Table Names:** `mcp_portfolios` and `mcp_portfolio_positions` (consistent with `mcp_*` pattern) 2. **user_id:** Default "default" for single-user personal use 3. **Numeric Precision:** Matches existing financial data patterns (12,4 for prices, 20,8 for shares) 4. **Cascade Delete:** Portfolio deletion removes all positions automatically 5. **Unique Constraint:** One position per ticker per portfolio 6. **Indexes:** Optimized for common queries (user lookup, ticker filtering) --- ### Feature C: Alembic Migration **Goal:** Create database migration following established patterns without conflicts. **File to Create:** - `alembic/versions/014_add_portfolio_models.py` **Migration Pattern:** ```python """Add portfolio and position models Revision ID: 014_add_portfolio_models Revises: 013_add_backtest_persistence_models Create Date: 2025-11-01 10:00:00.000000 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql # revision identifiers revision = '014_add_portfolio_models' down_revision = '013_add_backtest_persistence_models' branch_labels = None depends_on = None def upgrade(): """Create portfolio management tables.""" # Create portfolios table op.create_table( 'mcp_portfolios', sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True), sa.Column('user_id', sa.String(50), nullable=False, server_default='default'), sa.Column('name', sa.String(200), nullable=False, server_default='My Portfolio'), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), ) # Create indexes on portfolios op.create_index('idx_portfolio_user', 'mcp_portfolios', ['user_id']) op.create_unique_constraint('uq_user_portfolio_name', 'mcp_portfolios', ['user_id', 'name']) # Create positions table op.create_table( 'mcp_portfolio_positions', sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True), sa.Column('portfolio_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('ticker', sa.String(20), nullable=False), sa.Column('shares', sa.Numeric(20, 8), nullable=False), sa.Column('average_cost_basis', sa.Numeric(12, 4), nullable=False), sa.Column('total_cost', sa.Numeric(20, 4), nullable=False), sa.Column('purchase_date', sa.DateTime(timezone=True), nullable=False), sa.Column('notes', sa.Text, nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), sa.ForeignKeyConstraint(['portfolio_id'], ['mcp_portfolios.id'], ondelete='CASCADE'), ) # Create indexes on positions op.create_index('idx_position_portfolio', 'mcp_portfolio_positions', ['portfolio_id']) op.create_index('idx_position_ticker', 'mcp_portfolio_positions', ['ticker']) op.create_index('idx_position_portfolio_ticker', 'mcp_portfolio_positions', ['portfolio_id', 'ticker']) op.create_unique_constraint('uq_portfolio_position_ticker', 'mcp_portfolio_positions', ['portfolio_id', 'ticker']) def downgrade(): """Drop portfolio management tables.""" op.drop_table('mcp_portfolio_positions') op.drop_table('mcp_portfolios') ``` **Testing:** - Test upgrade: `alembic upgrade head` - Test downgrade: `alembic downgrade -1` - Verify indexes created: SQL query inspection - Test with SQLite and PostgreSQL --- ### Feature D: MCP Tools Implementation **Goal:** Implement 4 portfolio management tools following tool_registry.py pattern. **Files to Create:** - `maverick_mcp/api/routers/portfolio_management.py` - New tool implementations - `maverick_mcp/api/services/portfolio_persistence_service.py` - Service layer - `maverick_mcp/validation/portfolio_management.py` - Pydantic validation **Service Layer Pattern:** ```python # maverick_mcp/api/services/portfolio_persistence_service.py class PortfolioPersistenceService(BaseService): """Service for portfolio CRUD operations.""" async def get_or_create_default_portfolio(self) -> UserPortfolio: """Get the default portfolio, create if doesn't exist.""" pass async def add_position(self, ticker: str, shares: Decimal, price: Decimal, date: datetime, notes: Optional[str]) -> PortfolioPosition: """Add or update position with cost averaging.""" pass async def get_portfolio_with_live_data(self) -> dict: """Fetch portfolio with current market prices.""" pass async def remove_position(self, ticker: str, shares: Optional[Decimal]) -> bool: """Remove position or partial shares.""" pass async def clear_portfolio(self) -> bool: """Delete all positions.""" pass ``` **Tool Registration:** ```python # Add to maverick_mcp/api/routers/tool_registry.py def register_portfolio_management_tools(mcp: FastMCP) -> None: """Register portfolio management tools.""" from maverick_mcp.api.routers.portfolio_management import ( add_portfolio_position, get_my_portfolio, remove_portfolio_position, clear_my_portfolio ) mcp.tool(name="portfolio_add_position")(add_portfolio_position) mcp.tool(name="portfolio_get_my_portfolio")(get_my_portfolio) mcp.tool(name="portfolio_remove_position")(remove_portfolio_position) mcp.tool(name="portfolio_clear")(clear_my_portfolio) ``` --- ### Feature E: MCP Resource Implementation **Goal:** Create `portfolio://my-holdings` resource for automatic AI context injection. **File to Modify:** - `maverick_mcp/api/server.py` - Add resource alongside existing health:// and dashboard:// resources **Resource Implementation:** ```python # Add to maverick_mcp/api/server.py (around line 823, near other resources) @mcp.resource("portfolio://my-holdings") def portfolio_holdings_resource() -> dict[str, Any]: """ Portfolio holdings resource for AI context injection. Provides comprehensive portfolio context to AI agents including: - Current positions with live P&L - Portfolio metrics and diversification - Sector exposure analysis - Top/bottom performers This resource is automatically available to AI agents during conversations, enabling personalized analysis without requiring manual ticker input. """ # Implementation using service layer with async handling pass ``` --- ### Feature F: Phase 2 Tool Enhancements **Goal:** Enhance existing tools to auto-detect portfolio holdings. **Files to Modify:** 1. `maverick_mcp/api/routers/portfolio.py` - Enhance 3 existing tools 2. `maverick_mcp/validation/portfolio.py` - Update validation to allow optional parameters **Enhancement Pattern:** - Add optional parameters (tickers can be None) - Check portfolio for holdings if no tickers provided - Add position awareness to analysis results - Maintain backward compatibility --- ## 4. Progress (Living Document Section) | Date | Time | Item Completed / Status Update | Resulting Changes (LOC/Files) | |:-----|:-----|:------------------------------|:------------------------------| | 2025-11-01 | Start | Plan approved and documented | PORTFOLIO_PERSONALIZATION_PLAN.md created | | TBD | TBD | Implementation begins | - | _(This section will be updated during implementation)_ --- ## 5. Surprises and Discoveries _(Technical issues discovered during implementation will be documented here)_ **Anticipated Challenges:** 1. **MCP Resource Async Context:** Resources are sync functions but need async database calls - solved with event loop management (see existing health_resource pattern) 2. **Cost Basis Precision:** Financial calculations require Decimal precision, not floats - use Numeric(12,4) for prices, Numeric(20,8) for shares 3. **Portfolio Resource Performance:** Live price fetching could be slow - implement caching strategy, consider async batching 4. **Single User Assumption:** No user authentication means all operations use user_id="default" - acceptable for personal use --- ## 6. Decision Log | Date | Decision | Rationale | |:-----|:---------|:----------| | 2025-11-01 | **Cost Basis Method: Average Cost** | Simplest for educational use, matches existing PortfolioManager, avoids tax accounting complexity | | 2025-11-01 | **Table Names: mcp_portfolios, mcp_portfolio_positions** | Consistent with existing mcp_* naming convention for MCP-specific tables | | 2025-11-01 | **User ID: "default" for all users** | Single-user personal-use design, consistent with auth disabled architecture | | 2025-11-01 | **Numeric Precision: Numeric(12,4) for prices, Numeric(20,8) for shares** | Matches existing financial data patterns, supports fractional shares | | 2025-11-01 | **Optional tickers parameter for Phase 2** | Enables "just works" UX while maintaining backward compatibility | | 2025-11-01 | **MCP Resource for AI context** | Most elegant solution for automatic context injection without breaking tool contracts | | 2025-11-01 | **Domain-Driven Design pattern** | Follows established MaverickMCP architecture, clean separation of concerns | --- ## 7. Implementation Phases ### Phase 1: Foundation (4-5 days) **Files Created:** 8 new files **Files Modified:** 3 existing files **Estimated LOC:** ~2,500 lines **Tests:** ~1,200 lines ### Phase 2: Integration (2-3 days) **Files Modified:** 4 existing files **Estimated LOC:** ~800 lines additional **Tests:** ~600 lines additional ### Phase 3: Polish (1-2 days) **Documentation:** PORTFOLIO.md (~300 lines) **Performance:** Query optimization **Testing:** Manual Claude Desktop validation **Total Effort:** 7-10 days **Total New Code:** ~3,500 lines (including tests) **Total Tests:** ~1,800 lines --- ## 8. Risk Assessment **Low Risk:** - ✅ Follows established patterns - ✅ No breaking changes to existing tools - ✅ Optional Phase 2 enhancements - ✅ Well-scoped feature **Medium Risk:** - ⚠️ MCP resource performance with live prices - ⚠️ Migration compatibility (SQLite vs PostgreSQL) - ⚠️ Edge cases in cost basis averaging **Mitigation Strategies:** 1. **Performance:** Implement caching, batch price fetches, add timeout protection 2. **Migration:** Test with both SQLite and PostgreSQL, provide rollback path 3. **Edge Cases:** Comprehensive unit tests, property-based testing for arithmetic --- ## 9. Testing Strategy **Unit Tests (~60% of test code):** - Domain entity logic (Position, Portfolio) - Cost basis averaging edge cases - Numeric precision validation - Business logic validation **Integration Tests (~30% of test code):** - Database CRUD operations - Migration upgrade/downgrade - Service layer with real database - Cross-tool functionality **Manual Tests (~10% of effort):** - Claude Desktop end-to-end workflows - Natural language interactions - MCP resource visibility - Tool integration scenarios **Test Coverage Target:** 85%+ --- ## 10. Success Metrics **Functional Success:** - [ ] All 4 new tools work in Claude Desktop - [ ] Portfolio resource visible to AI agents - [ ] Cost basis averaging accurate to 4 decimal places - [ ] Migration works on SQLite and PostgreSQL - [ ] 3 enhanced tools auto-detect portfolio **Quality Success:** - [ ] 85%+ test coverage - [ ] Zero linting errors (ruff) - [ ] Full type annotations (ty check passes) - [ ] All pre-commit hooks pass **UX Success:** - [ ] "Analyze my portfolio" works without ticker input - [ ] AI agents reference actual holdings in responses - [ ] Natural language interactions feel seamless - [ ] Error messages are clear and actionable --- ## 11. Related Documentation - **Original Issue:** [#40 - Portfolio Personalization](https://github.com/wshobson/maverick-mcp/issues/40) - **User Documentation:** `docs/PORTFOLIO.md` (to be created) - **API Documentation:** Tool docstrings and MCP introspection - **Testing Guide:** `tests/README.md` (to be updated) --- This execution plan provides a comprehensive roadmap following the PLANS.md rubric structure. The implementation is well-scoped, follows established patterns, and delivers significant UX improvement while maintaining code quality and architectural integrity.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/wshobson/maverick-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server