feat: Implement Folio CLI Phase 1 - Interactive Shell
Browse filesThis commit adds a new interactive command-line interface for Folio portfolio management.
The CLI provides an interactive shell environment for running portfolio simulations,
analyzing positions, and exploring investment scenarios.
Key features:
- Interactive shell with command history and auto-completion
- Portfolio loading and viewing capabilities
- SPY simulation with customizable parameters
- Position-specific analysis with detailed breakdowns
- Comprehensive help system
Technical details:
- Uses prompt_toolkit for the interactive shell
- Leverages existing code from src/folio modules
- Maintains the same output formatting using Rich
- Follows project coding conventions and passes linting
The CLI provides at least the same functionality as scripts/folio-simulator.py
but in an interactive shell environment where users can run multiple commands
without restarting the application.
This is Phase 1 of the planned implementation, focusing on core functionality.
Future phases will add enhanced interactivity, additional commands, and more
sophisticated analysis capabilities.
- docs/ai-rules.md +2 -0
- docs/focli-mvp-v2.md +770 -0
- scripts/focli.py +30 -0
- src/focli/__init__.py +8 -0
- src/focli/commands/__init__.py +95 -0
- src/focli/commands/help.py +117 -0
- src/focli/commands/portfolio.py +137 -0
- src/focli/commands/position.py +68 -0
- src/focli/commands/simulate.py +120 -0
- src/focli/formatters.py +278 -0
- src/focli/shell.py +112 -0
- src/focli/utils.py +185 -0
|
@@ -8,6 +8,8 @@ alwaysApply: true
|
|
| 8 |
- Read [project-design.md](docs/project-design.md) to understand the codebase
|
| 9 |
- Read [project-conventions.md](docs/project-conventions.md) to understand _how_ to write code for the codebase
|
| 10 |
- Run `make lint` and `make test` after every change. `lint` in particular can be run very frequently.
|
|
|
|
|
|
|
| 11 |
|
| 12 |
## Prohibited actions
|
| 13 |
|
|
|
|
| 8 |
- Read [project-design.md](docs/project-design.md) to understand the codebase
|
| 9 |
- Read [project-conventions.md](docs/project-conventions.md) to understand _how_ to write code for the codebase
|
| 10 |
- Run `make lint` and `make test` after every change. `lint` in particular can be run very frequently.
|
| 11 |
+
- When user starts a prompt with `QQ:` or `Question:`, just answer the question or prompt without producing code.
|
| 12 |
+
- Prefer small testable steps, after each step give a summary to the user and summarize the next step
|
| 13 |
|
| 14 |
## Prohibited actions
|
| 15 |
|
|
@@ -0,0 +1,770 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Folio CLI MVP Plan (v2)
|
| 2 |
+
|
| 3 |
+
## Project Checklist
|
| 4 |
+
|
| 5 |
+
### Phase 1: Basic Interactive Shell
|
| 6 |
+
- [x] Set up project structure
|
| 7 |
+
- [x] Install dependencies (prompt_toolkit, rich)
|
| 8 |
+
- [x] Create basic REPL shell with command history
|
| 9 |
+
- [x] Implement portfolio loading functionality
|
| 10 |
+
- [x] Port basic SPY simulation command
|
| 11 |
+
- [x] Add help and exit commands
|
| 12 |
+
|
| 13 |
+
### Phase 2: Enhanced Interactivity
|
| 14 |
+
- [ ] Implement position-specific analysis
|
| 15 |
+
- [ ] Add ticker filtering capability
|
| 16 |
+
- [ ] Create state management between commands
|
| 17 |
+
- [ ] Implement parameter customization
|
| 18 |
+
- [ ] Add detailed position breakdowns
|
| 19 |
+
|
| 20 |
+
### Phase 3: Additional Commands and Polish
|
| 21 |
+
- [ ] Add portfolio summary command
|
| 22 |
+
- [ ] Implement "what-if" scenario analysis
|
| 23 |
+
- [ ] Add comprehensive help text
|
| 24 |
+
- [ ] Improve error handling
|
| 25 |
+
- [ ] Add command auto-completion
|
| 26 |
+
- [ ] Create tests for core functionality
|
| 27 |
+
|
| 28 |
+
## Overview
|
| 29 |
+
|
| 30 |
+
The Folio CLI MVP will create an interactive shell-like environment that leverages the existing portfolio simulation and analysis capabilities in the Folio codebase. This approach will allow users to run simulations, explore different options, and analyze portfolio data through an interactive command-line interface.
|
| 31 |
+
|
| 32 |
+
## Goals
|
| 33 |
+
|
| 34 |
+
1. Create an interactive shell for portfolio simulation and analysis
|
| 35 |
+
2. Reuse existing code from `src/folio` modules
|
| 36 |
+
3. Provide at least the same functionality as `scripts/folio-simulator.py`
|
| 37 |
+
4. Allow users to run multiple commands without restarting the application
|
| 38 |
+
5. Support detailed analysis of specific position groups
|
| 39 |
+
|
| 40 |
+
## Technology Selection
|
| 41 |
+
|
| 42 |
+
After analyzing the codebase and evaluating different options, we recommend:
|
| 43 |
+
|
| 44 |
+
### Primary Framework: Prompt Toolkit + Typer
|
| 45 |
+
|
| 46 |
+
**[Python Prompt Toolkit](https://python-prompt-toolkit.readthedocs.io/)** is ideal for our interactive shell requirements:
|
| 47 |
+
- Designed specifically for building interactive command-line applications
|
| 48 |
+
- Built-in support for REPL (Read-Eval-Print Loop) interfaces
|
| 49 |
+
- Excellent auto-completion capabilities
|
| 50 |
+
- History navigation and search
|
| 51 |
+
- Customizable key bindings
|
| 52 |
+
|
| 53 |
+
**[Typer](https://typer.tiangolo.com/)** will be used for command parsing within the shell:
|
| 54 |
+
- Modern API with type hints
|
| 55 |
+
- Built on Click, inheriting its stability
|
| 56 |
+
- Excellent documentation and growing community
|
| 57 |
+
|
| 58 |
+
**[Rich](https://rich.readthedocs.io/)** will continue to be used for output formatting:
|
| 59 |
+
- Already used in the existing simulator
|
| 60 |
+
- Excellent for tables, charts, and formatted text
|
| 61 |
+
- Good integration with both Prompt Toolkit and Typer
|
| 62 |
+
|
| 63 |
+
## Implementation Plan
|
| 64 |
+
|
| 65 |
+
### Phase 1: Basic Interactive Shell (1 week)
|
| 66 |
+
|
| 67 |
+
#### Tasks
|
| 68 |
+
- [ ] **1.1 Set up project structure**
|
| 69 |
+
- Create directory structure
|
| 70 |
+
- Set up package files
|
| 71 |
+
- Configure dependencies
|
| 72 |
+
|
| 73 |
+
- [ ] **1.2 Create Shell Framework**
|
| 74 |
+
- Set up a basic REPL using Prompt Toolkit
|
| 75 |
+
- Implement command history and navigation
|
| 76 |
+
- Add basic auto-completion for commands
|
| 77 |
+
|
| 78 |
+
- [ ] **1.3 Implement Portfolio Loading**
|
| 79 |
+
- Create portfolio loading function
|
| 80 |
+
- Add error handling for missing files
|
| 81 |
+
- Implement portfolio reloading command
|
| 82 |
+
|
| 83 |
+
- [ ] **1.4 Port Core Simulator Commands**
|
| 84 |
+
- Directly use `simulate_portfolio_with_spy_changes` from `src/folio/simulator.py`
|
| 85 |
+
- Reuse the portfolio loading code from `src/folio/portfolio.py`
|
| 86 |
+
- Maintain the same output formatting using Rich
|
| 87 |
+
|
| 88 |
+
- [ ] **1.5 Add Basic Help System**
|
| 89 |
+
- Implement help command
|
| 90 |
+
- Add command documentation
|
| 91 |
+
- Create exit command with confirmation
|
| 92 |
+
|
| 93 |
+
#### Deliverables
|
| 94 |
+
- Working REPL shell
|
| 95 |
+
- Basic simulation command
|
| 96 |
+
- Portfolio loading functionality
|
| 97 |
+
- Help and exit commands
|
| 98 |
+
|
| 99 |
+
### Phase 2: Enhanced Interactivity (1 week)
|
| 100 |
+
|
| 101 |
+
#### Tasks
|
| 102 |
+
- [ ] **2.1 Add Position-Specific Analysis**
|
| 103 |
+
- Implement commands to analyze specific position groups
|
| 104 |
+
- Create detailed position view
|
| 105 |
+
- Add option chain visualization
|
| 106 |
+
|
| 107 |
+
- [ ] **2.2 Implement Filtering Capabilities**
|
| 108 |
+
- Allow filtering by ticker
|
| 109 |
+
- Add sorting options
|
| 110 |
+
- Implement focus mode for specific tickers
|
| 111 |
+
|
| 112 |
+
- [ ] **2.3 Create State Management**
|
| 113 |
+
- Allow referencing previous simulation results
|
| 114 |
+
- Maintain portfolio state between commands
|
| 115 |
+
- Implement session history
|
| 116 |
+
|
| 117 |
+
- [ ] **2.4 Add Parameter Customization**
|
| 118 |
+
- Add ability to modify simulation parameters incrementally
|
| 119 |
+
- Create parameter presets
|
| 120 |
+
- Implement parameter validation
|
| 121 |
+
|
| 122 |
+
#### Deliverables
|
| 123 |
+
- Position analysis commands
|
| 124 |
+
- Filtering and sorting capabilities
|
| 125 |
+
- State management between commands
|
| 126 |
+
- Parameter customization options
|
| 127 |
+
|
| 128 |
+
### Phase 3: Additional Commands and Polish (1 week)
|
| 129 |
+
|
| 130 |
+
#### Tasks
|
| 131 |
+
- [ ] **3.1 Add Supplementary Commands**
|
| 132 |
+
- Portfolio viewing and basic analysis
|
| 133 |
+
- "What-if" scenario analysis
|
| 134 |
+
- Portfolio comparison tools
|
| 135 |
+
|
| 136 |
+
- [ ] **3.2 Enhance Command Completion**
|
| 137 |
+
- Add context-aware command completion
|
| 138 |
+
- Implement parameter suggestions
|
| 139 |
+
- Create command aliases
|
| 140 |
+
|
| 141 |
+
- [ ] **3.3 Improve Error Handling**
|
| 142 |
+
- Add comprehensive error messages
|
| 143 |
+
- Implement error recovery
|
| 144 |
+
- Create debugging commands
|
| 145 |
+
|
| 146 |
+
- [ ] **3.4 Add Testing and Documentation**
|
| 147 |
+
- Write unit tests for core functionality
|
| 148 |
+
- Create integration tests
|
| 149 |
+
- Add comprehensive help text
|
| 150 |
+
- Create user documentation
|
| 151 |
+
|
| 152 |
+
#### Deliverables
|
| 153 |
+
- Additional analysis commands
|
| 154 |
+
- Enhanced command completion
|
| 155 |
+
- Robust error handling
|
| 156 |
+
- Comprehensive tests and documentation
|
| 157 |
+
|
| 158 |
+
## Implementation Details
|
| 159 |
+
|
| 160 |
+
### Project Structure
|
| 161 |
+
|
| 162 |
+
```
|
| 163 |
+
src/
|
| 164 |
+
└── focli/
|
| 165 |
+
├── __init__.py # Package initialization
|
| 166 |
+
├── shell.py # Interactive shell implementation
|
| 167 |
+
├── commands/ # Command implementations
|
| 168 |
+
│ ├── __init__.py # Command registration
|
| 169 |
+
│ ├── simulate.py # Simulation commands
|
| 170 |
+
│ ├── position.py # Position analysis commands
|
| 171 |
+
│ └── portfolio.py # Portfolio management commands
|
| 172 |
+
├── formatters.py # Output formatting utilities
|
| 173 |
+
└── utils.py # Utility functions
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
#### Key Files and Their Responsibilities
|
| 177 |
+
|
| 178 |
+
- **`shell.py`**: Main entry point, REPL implementation, command routing
|
| 179 |
+
- **`commands/__init__.py`**: Command registration and discovery
|
| 180 |
+
- **`commands/simulate.py`**: Portfolio simulation commands
|
| 181 |
+
- **`commands/position.py`**: Position-specific analysis
|
| 182 |
+
- **`commands/portfolio.py`**: Portfolio management and overview
|
| 183 |
+
- **`formatters.py`**: Rich formatting for tables, charts, and text
|
| 184 |
+
- **`utils.py`**: Helper functions and utilities
|
| 185 |
+
|
| 186 |
+
### Code Reuse Strategy
|
| 187 |
+
|
| 188 |
+
The implementation will directly leverage the following existing modules:
|
| 189 |
+
|
| 190 |
+
1. **`src/folio/simulator.py`**
|
| 191 |
+
- `simulate_portfolio_with_spy_changes`: Core simulation function
|
| 192 |
+
- `calculate_percentage_changes`: Utility for calculating changes
|
| 193 |
+
|
| 194 |
+
2. **`src/folio/portfolio.py`**
|
| 195 |
+
- `process_portfolio_data`: Load and process portfolio data
|
| 196 |
+
- `recalculate_portfolio_with_prices`: Recalculate with price changes
|
| 197 |
+
- `calculate_portfolio_summary`: Generate portfolio summaries
|
| 198 |
+
|
| 199 |
+
3. **`src/folio/data_model.py`**
|
| 200 |
+
- Data classes for portfolio representation
|
| 201 |
+
- Conversion methods between objects and dictionaries
|
| 202 |
+
|
| 203 |
+
4. **`src/folio/formatting.py`**
|
| 204 |
+
- Formatting utilities for currency and percentages
|
| 205 |
+
|
| 206 |
+
### Implementation Approach
|
| 207 |
+
|
| 208 |
+
#### 1. Interactive Shell Implementation
|
| 209 |
+
|
| 210 |
+
The interactive shell will be implemented using Prompt Toolkit's REPL capabilities:
|
| 211 |
+
|
| 212 |
+
```python
|
| 213 |
+
# src/focli/shell.py
|
| 214 |
+
from prompt_toolkit import PromptSession
|
| 215 |
+
from prompt_toolkit.completion import NestedCompleter
|
| 216 |
+
from rich.console import Console
|
| 217 |
+
|
| 218 |
+
from src.folio.portfolio import process_portfolio_data
|
| 219 |
+
from src.focli.commands import get_command_registry, execute_command
|
| 220 |
+
|
| 221 |
+
console = Console()
|
| 222 |
+
|
| 223 |
+
def create_completer():
|
| 224 |
+
"""Create a nested completer for command auto-completion."""
|
| 225 |
+
# Build a nested completer from the command registry
|
| 226 |
+
commands = get_command_registry()
|
| 227 |
+
|
| 228 |
+
# Create completion dictionary with subcommands and parameters
|
| 229 |
+
completion_dict = {}
|
| 230 |
+
for cmd_name, cmd_info in commands.items():
|
| 231 |
+
if cmd_info.get("subcommands"):
|
| 232 |
+
completion_dict[cmd_name] = {
|
| 233 |
+
subcmd: None for subcmd in cmd_info["subcommands"]
|
| 234 |
+
}
|
| 235 |
+
else:
|
| 236 |
+
completion_dict[cmd_name] = None
|
| 237 |
+
|
| 238 |
+
return NestedCompleter.from_nested_dict(completion_dict)
|
| 239 |
+
|
| 240 |
+
def main():
|
| 241 |
+
"""Main entry point for the Folio CLI."""
|
| 242 |
+
console.print("[bold]Folio Interactive Shell[/bold]")
|
| 243 |
+
console.print("Type 'help' for available commands.")
|
| 244 |
+
|
| 245 |
+
# Create session with auto-completion
|
| 246 |
+
session = PromptSession(completer=create_completer())
|
| 247 |
+
|
| 248 |
+
# Initialize application state
|
| 249 |
+
state = {
|
| 250 |
+
"portfolio_groups": None,
|
| 251 |
+
"portfolio_summary": None,
|
| 252 |
+
"last_simulation": None,
|
| 253 |
+
"loaded_portfolio": None,
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
# Try to load default portfolio
|
| 257 |
+
try:
|
| 258 |
+
load_portfolio("private-data/portfolio-private.csv", state)
|
| 259 |
+
except Exception as e:
|
| 260 |
+
console.print(f"[yellow]Could not load default portfolio: {e}[/yellow]")
|
| 261 |
+
console.print("[yellow]Use 'load <path>' to load a portfolio.[/yellow]")
|
| 262 |
+
|
| 263 |
+
# Main REPL loop
|
| 264 |
+
while True:
|
| 265 |
+
try:
|
| 266 |
+
# Get user input
|
| 267 |
+
text = session.prompt("folio> ")
|
| 268 |
+
|
| 269 |
+
if not text.strip():
|
| 270 |
+
continue
|
| 271 |
+
|
| 272 |
+
# Handle exit command directly
|
| 273 |
+
if text.strip().lower() == "exit":
|
| 274 |
+
if confirm_exit():
|
| 275 |
+
break
|
| 276 |
+
continue
|
| 277 |
+
|
| 278 |
+
# Process the command
|
| 279 |
+
execute_command(text, state, console)
|
| 280 |
+
|
| 281 |
+
except KeyboardInterrupt:
|
| 282 |
+
# Handle Ctrl+C
|
| 283 |
+
console.print("\n[yellow]Use 'exit' to exit the application.[/yellow]")
|
| 284 |
+
continue
|
| 285 |
+
except EOFError:
|
| 286 |
+
# Handle Ctrl+D
|
| 287 |
+
console.print("\nGoodbye!")
|
| 288 |
+
break
|
| 289 |
+
except Exception as e:
|
| 290 |
+
# Handle unexpected errors
|
| 291 |
+
console.print(f"[bold red]Error:[/bold red] {str(e)}")
|
| 292 |
+
|
| 293 |
+
console.print("Goodbye!")
|
| 294 |
+
|
| 295 |
+
def load_portfolio(path, state):
|
| 296 |
+
"""Load a portfolio from a CSV file."""
|
| 297 |
+
import pandas as pd
|
| 298 |
+
from src.folio.portfolio import process_portfolio_data
|
| 299 |
+
|
| 300 |
+
df = pd.read_csv(path)
|
| 301 |
+
groups, summary, _ = process_portfolio_data(df, update_prices=True)
|
| 302 |
+
|
| 303 |
+
state["portfolio_groups"] = groups
|
| 304 |
+
state["portfolio_summary"] = summary
|
| 305 |
+
state["loaded_portfolio"] = path
|
| 306 |
+
|
| 307 |
+
return groups, summary
|
| 308 |
+
|
| 309 |
+
def confirm_exit():
|
| 310 |
+
"""Confirm exit with the user."""
|
| 311 |
+
from prompt_toolkit.shortcuts import confirm
|
| 312 |
+
|
| 313 |
+
return confirm("Are you sure you want to exit?")
|
| 314 |
+
|
| 315 |
+
if __name__ == "__main__":
|
| 316 |
+
main()
|
| 317 |
+
```
|
| 318 |
+
|
| 319 |
+
#### 2. Command Registration and Execution
|
| 320 |
+
|
| 321 |
+
Commands will be registered and executed through a central registry:
|
| 322 |
+
|
| 323 |
+
```python
|
| 324 |
+
# src/focli/commands/__init__.py
|
| 325 |
+
from typing import Dict, Any, Callable, List
|
| 326 |
+
|
| 327 |
+
# Command registry
|
| 328 |
+
_COMMANDS = {}
|
| 329 |
+
|
| 330 |
+
def register_command(name: str, handler: Callable, help_text: str, subcommands: List[str] = None):
|
| 331 |
+
"""Register a command with the command registry."""
|
| 332 |
+
_COMMANDS[name] = {
|
| 333 |
+
"handler": handler,
|
| 334 |
+
"help": help_text,
|
| 335 |
+
"subcommands": subcommands,
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
def get_command_registry():
|
| 339 |
+
"""Get the command registry."""
|
| 340 |
+
return _COMMANDS
|
| 341 |
+
|
| 342 |
+
def execute_command(command_line: str, state: Dict[str, Any], console):
|
| 343 |
+
"""Execute a command from the command line."""
|
| 344 |
+
# Parse the command line
|
| 345 |
+
parts = command_line.strip().split()
|
| 346 |
+
if not parts:
|
| 347 |
+
return
|
| 348 |
+
|
| 349 |
+
command = parts[0].lower()
|
| 350 |
+
args = parts[1:]
|
| 351 |
+
|
| 352 |
+
# Check if the command exists
|
| 353 |
+
if command not in _COMMANDS:
|
| 354 |
+
console.print(f"[bold red]Unknown command:[/bold red] {command}")
|
| 355 |
+
console.print("Type 'help' to see available commands.")
|
| 356 |
+
return
|
| 357 |
+
|
| 358 |
+
# Execute the command
|
| 359 |
+
try:
|
| 360 |
+
_COMMANDS[command]["handler"](args, state, console)
|
| 361 |
+
except Exception as e:
|
| 362 |
+
console.print(f"[bold red]Error executing command '{command}':[/bold red] {str(e)}")
|
| 363 |
+
|
| 364 |
+
# Import and register commands
|
| 365 |
+
from .simulate import simulate_command
|
| 366 |
+
from .position import position_command
|
| 367 |
+
from .portfolio import portfolio_command
|
| 368 |
+
from .help import help_command
|
| 369 |
+
|
| 370 |
+
# Register commands
|
| 371 |
+
register_command("simulate", simulate_command, "Simulate portfolio performance with SPY changes",
|
| 372 |
+
["spy", "scenario"])
|
| 373 |
+
register_command("position", position_command, "Analyze a specific position group")
|
| 374 |
+
register_command("portfolio", portfolio_command, "View and analyze portfolio",
|
| 375 |
+
["list", "summary", "load"])
|
| 376 |
+
register_command("help", help_command, "Show help information")
|
| 377 |
+
```
|
| 378 |
+
|
| 379 |
+
#### 3. Simulation Command Implementation
|
| 380 |
+
|
| 381 |
+
The simulation command will directly use the existing simulator functionality:
|
| 382 |
+
|
| 383 |
+
```python
|
| 384 |
+
# src/focli/commands/simulate.py
|
| 385 |
+
from typing import Dict, Any, List
|
| 386 |
+
import numpy as np
|
| 387 |
+
|
| 388 |
+
from src.folio.simulator import simulate_portfolio_with_spy_changes
|
| 389 |
+
from src.focli.formatters import display_simulation_results
|
| 390 |
+
|
| 391 |
+
def simulate_command(args: List[str], state: Dict[str, Any], console):
|
| 392 |
+
"""Simulate portfolio performance with SPY changes."""
|
| 393 |
+
# Check if a portfolio is loaded
|
| 394 |
+
if not state.get("portfolio_groups"):
|
| 395 |
+
console.print("[bold red]Error:[/bold red] No portfolio loaded.")
|
| 396 |
+
console.print("Use 'portfolio load <path>' to load a portfolio.")
|
| 397 |
+
return
|
| 398 |
+
|
| 399 |
+
# Default parameters
|
| 400 |
+
range_pct = 20.0
|
| 401 |
+
steps = 13
|
| 402 |
+
focus_tickers = None
|
| 403 |
+
detailed = False
|
| 404 |
+
|
| 405 |
+
# Parse arguments
|
| 406 |
+
i = 0
|
| 407 |
+
while i < len(args):
|
| 408 |
+
arg = args[i]
|
| 409 |
+
|
| 410 |
+
if arg == "spy":
|
| 411 |
+
# This is the default simulation type
|
| 412 |
+
i += 1
|
| 413 |
+
continue
|
| 414 |
+
|
| 415 |
+
elif arg == "--range" or arg == "-r":
|
| 416 |
+
if i + 1 < len(args):
|
| 417 |
+
try:
|
| 418 |
+
range_pct = float(args[i + 1])
|
| 419 |
+
i += 2
|
| 420 |
+
continue
|
| 421 |
+
except ValueError:
|
| 422 |
+
console.print(f"[bold red]Invalid range value:[/bold red] {args[i + 1]}")
|
| 423 |
+
return
|
| 424 |
+
else:
|
| 425 |
+
console.print("[bold red]Missing value for --range[/bold red]")
|
| 426 |
+
return
|
| 427 |
+
|
| 428 |
+
elif arg == "--steps" or arg == "-s":
|
| 429 |
+
if i + 1 < len(args):
|
| 430 |
+
try:
|
| 431 |
+
steps = int(args[i + 1])
|
| 432 |
+
i += 2
|
| 433 |
+
continue
|
| 434 |
+
except ValueError:
|
| 435 |
+
console.print(f"[bold red]Invalid steps value:[/bold red] {args[i + 1]}")
|
| 436 |
+
return
|
| 437 |
+
else:
|
| 438 |
+
console.print("[bold red]Missing value for --steps[/bold red]")
|
| 439 |
+
return
|
| 440 |
+
|
| 441 |
+
elif arg == "--focus" or arg == "-f":
|
| 442 |
+
if i + 1 < len(args):
|
| 443 |
+
focus_tickers = [t.strip().upper() for t in args[i + 1].split(",")]
|
| 444 |
+
i += 2
|
| 445 |
+
continue
|
| 446 |
+
else:
|
| 447 |
+
console.print("[bold red]Missing value for --focus[/bold red]")
|
| 448 |
+
return
|
| 449 |
+
|
| 450 |
+
elif arg == "--detailed" or arg == "-d":
|
| 451 |
+
detailed = True
|
| 452 |
+
i += 1
|
| 453 |
+
continue
|
| 454 |
+
|
| 455 |
+
else:
|
| 456 |
+
console.print(f"[bold red]Unknown argument:[/bold red] {arg}")
|
| 457 |
+
return
|
| 458 |
+
|
| 459 |
+
# Calculate the step size
|
| 460 |
+
step_size = (2 * range_pct) / (steps - 1) if steps > 1 else 0
|
| 461 |
+
|
| 462 |
+
# Generate the SPY changes
|
| 463 |
+
spy_changes = [-range_pct + i * step_size for i in range(steps)]
|
| 464 |
+
|
| 465 |
+
# Ensure we have a zero point
|
| 466 |
+
if 0.0 not in spy_changes and steps > 2:
|
| 467 |
+
# Find the closest point to zero and replace it with zero
|
| 468 |
+
closest_to_zero = min(spy_changes, key=lambda x: abs(x))
|
| 469 |
+
zero_index = spy_changes.index(closest_to_zero)
|
| 470 |
+
spy_changes[zero_index] = 0.0
|
| 471 |
+
|
| 472 |
+
# Convert to percentages
|
| 473 |
+
spy_changes = [change / 100.0 for change in spy_changes]
|
| 474 |
+
|
| 475 |
+
# Run the simulation
|
| 476 |
+
console.print(f"[bold]Running simulation with range ±{range_pct}% and {steps} steps...[/bold]")
|
| 477 |
+
|
| 478 |
+
results = simulate_portfolio_with_spy_changes(
|
| 479 |
+
portfolio_groups=state["portfolio_groups"],
|
| 480 |
+
spy_changes=spy_changes,
|
| 481 |
+
cash_like_positions=state["portfolio_summary"].cash_like_positions,
|
| 482 |
+
pending_activity_value=state["portfolio_summary"].pending_activity_value,
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
# Store results for future reference
|
| 486 |
+
state["last_simulation"] = results
|
| 487 |
+
|
| 488 |
+
# Display the results
|
| 489 |
+
display_simulation_results(results, detailed, focus_tickers, console)
|
| 490 |
+
```
|
| 491 |
+
|
| 492 |
+
#### 4. Formatters Implementation
|
| 493 |
+
|
| 494 |
+
The display functions will reuse the formatting from the existing simulator script:
|
| 495 |
+
|
| 496 |
+
```python
|
| 497 |
+
# src/focli/formatters.py
|
| 498 |
+
from rich.console import Console
|
| 499 |
+
from rich.table import Table
|
| 500 |
+
from rich.panel import Panel
|
| 501 |
+
from rich.box import ROUNDED
|
| 502 |
+
|
| 503 |
+
from src.folio.formatting import format_currency
|
| 504 |
+
|
| 505 |
+
def display_simulation_results(results, detailed=False, focus_tickers=None, console=None):
|
| 506 |
+
"""Display simulation results using Rich."""
|
| 507 |
+
if console is None:
|
| 508 |
+
console = Console()
|
| 509 |
+
|
| 510 |
+
# Get the current value (at 0% SPY change)
|
| 511 |
+
current_value = results["current_value"]
|
| 512 |
+
|
| 513 |
+
# Get min and max values
|
| 514 |
+
min_value = min(results["portfolio_values"])
|
| 515 |
+
max_value = max(results["portfolio_values"])
|
| 516 |
+
min_index = results["portfolio_values"].index(min_value)
|
| 517 |
+
max_index = results["portfolio_values"].index(max_value)
|
| 518 |
+
min_spy_change = results["spy_changes"][min_index] * 100 # Convert to percentage
|
| 519 |
+
max_spy_change = results["spy_changes"][max_index] * 100 # Convert to percentage
|
| 520 |
+
|
| 521 |
+
# Create a summary table
|
| 522 |
+
console.print("\n[bold cyan]Portfolio Simulation Results[/bold cyan]")
|
| 523 |
+
|
| 524 |
+
summary_table = Table(title="Portfolio Summary", box=ROUNDED)
|
| 525 |
+
summary_table.add_column("Metric", style="cyan")
|
| 526 |
+
summary_table.add_column("Value", style="green")
|
| 527 |
+
summary_table.add_column("SPY Change", style="yellow")
|
| 528 |
+
|
| 529 |
+
summary_table.add_row("Current Value", f"${current_value:,.2f}", "0.0%")
|
| 530 |
+
summary_table.add_row("Minimum Value", f"${min_value:,.2f}", f"{min_spy_change:.1f}%")
|
| 531 |
+
summary_table.add_row("Maximum Value", f"${max_value:,.2f}", f"{max_spy_change:.1f}%")
|
| 532 |
+
|
| 533 |
+
console.print(summary_table)
|
| 534 |
+
|
| 535 |
+
# Create a detailed table with all values
|
| 536 |
+
value_table = Table(title="Portfolio Values at Different SPY Changes", box=ROUNDED)
|
| 537 |
+
value_table.add_column("SPY Change", style="yellow")
|
| 538 |
+
value_table.add_column("Portfolio Value", style="green")
|
| 539 |
+
value_table.add_column("Change", style="cyan")
|
| 540 |
+
value_table.add_column("% Change", style="magenta")
|
| 541 |
+
|
| 542 |
+
for i, spy_change in enumerate(results["spy_changes"]):
|
| 543 |
+
portfolio_value = results["portfolio_values"][i]
|
| 544 |
+
value_change = portfolio_value - current_value
|
| 545 |
+
pct_change = (value_change / current_value) * 100 if current_value != 0 else 0
|
| 546 |
+
|
| 547 |
+
# Format the change with color based on positive/negative
|
| 548 |
+
change_str = f"${value_change:+,.2f}"
|
| 549 |
+
pct_change_str = f"{pct_change:+.2f}%"
|
| 550 |
+
|
| 551 |
+
value_table.add_row(
|
| 552 |
+
f"{spy_change * 100:.1f}%",
|
| 553 |
+
f"${portfolio_value:,.2f}",
|
| 554 |
+
change_str,
|
| 555 |
+
pct_change_str,
|
| 556 |
+
)
|
| 557 |
+
|
| 558 |
+
console.print(value_table)
|
| 559 |
+
|
| 560 |
+
# If detailed is True, show position-level analysis
|
| 561 |
+
if detailed:
|
| 562 |
+
display_position_analysis(results, focus_tickers, console)
|
| 563 |
+
|
| 564 |
+
def display_position_analysis(results, focus_tickers=None, console=None):
|
| 565 |
+
"""Display position-level analysis."""
|
| 566 |
+
if console is None:
|
| 567 |
+
console = Console()
|
| 568 |
+
|
| 569 |
+
# Get position details
|
| 570 |
+
position_details = results.get("position_details", {})
|
| 571 |
+
position_changes = results.get("position_changes", {})
|
| 572 |
+
|
| 573 |
+
# Filter positions if focus_tickers is provided
|
| 574 |
+
if focus_tickers:
|
| 575 |
+
filtered_details = {}
|
| 576 |
+
filtered_changes = {}
|
| 577 |
+
for ticker in focus_tickers:
|
| 578 |
+
if ticker in position_details:
|
| 579 |
+
filtered_details[ticker] = position_details[ticker]
|
| 580 |
+
if ticker in position_changes:
|
| 581 |
+
filtered_changes[ticker] = position_changes[ticker]
|
| 582 |
+
position_details = filtered_details
|
| 583 |
+
position_changes = filtered_changes
|
| 584 |
+
|
| 585 |
+
# Display position details
|
| 586 |
+
console.print("\n[bold cyan]Position Analysis[/bold cyan]")
|
| 587 |
+
|
| 588 |
+
for ticker, details in position_details.items():
|
| 589 |
+
# Create a panel for each position
|
| 590 |
+
position_table = Table(title=f"{ticker} Details", box=ROUNDED)
|
| 591 |
+
position_table.add_column("Metric", style="cyan")
|
| 592 |
+
position_table.add_column("Value", style="green")
|
| 593 |
+
|
| 594 |
+
# Add basic position details
|
| 595 |
+
position_table.add_row("Beta", f"{details.get('beta', 0):.2f}")
|
| 596 |
+
position_table.add_row("Current Value", format_currency(details.get('current_value', 0)))
|
| 597 |
+
position_table.add_row("Stock Value", format_currency(details.get('stock_value', 0)))
|
| 598 |
+
position_table.add_row("Option Value", format_currency(details.get('option_value', 0)))
|
| 599 |
+
|
| 600 |
+
# Add stock details if available
|
| 601 |
+
if details.get('has_stock'):
|
| 602 |
+
position_table.add_row("Stock Quantity", f"{details.get('stock_quantity', 0)}")
|
| 603 |
+
position_table.add_row("Stock Price", format_currency(details.get('stock_price', 0)))
|
| 604 |
+
|
| 605 |
+
# Add option details if available
|
| 606 |
+
if details.get('has_options'):
|
| 607 |
+
position_table.add_row("Option Count", f"{details.get('option_count', 0)}")
|
| 608 |
+
|
| 609 |
+
console.print(position_table)
|
| 610 |
+
|
| 611 |
+
# If we have change data, show it
|
| 612 |
+
if ticker in position_changes:
|
| 613 |
+
changes = position_changes[ticker]
|
| 614 |
+
|
| 615 |
+
# Create a table for position changes
|
| 616 |
+
changes_table = Table(title=f"{ticker} Changes with SPY", box=ROUNDED)
|
| 617 |
+
changes_table.add_column("SPY Change", style="yellow")
|
| 618 |
+
changes_table.add_column("Position Value", style="green")
|
| 619 |
+
changes_table.add_column("Change", style="cyan")
|
| 620 |
+
changes_table.add_column("% Change", style="magenta")
|
| 621 |
+
|
| 622 |
+
for i, spy_change in enumerate(results["spy_changes"]):
|
| 623 |
+
if i < len(changes["values"]):
|
| 624 |
+
value = changes["values"][i]
|
| 625 |
+
change = changes["changes"][i]
|
| 626 |
+
pct_change = changes["pct_changes"][i]
|
| 627 |
+
|
| 628 |
+
changes_table.add_row(
|
| 629 |
+
f"{spy_change * 100:.1f}%",
|
| 630 |
+
format_currency(value),
|
| 631 |
+
f"{format_currency(change, include_sign=True)}",
|
| 632 |
+
f"{pct_change:+.2f}%",
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
console.print(changes_table)
|
| 636 |
+
|
| 637 |
+
def display_position_details(group, detailed=True, console=None):
|
| 638 |
+
"""Display detailed information about a position group."""
|
| 639 |
+
if console is None:
|
| 640 |
+
console = Console()
|
| 641 |
+
|
| 642 |
+
ticker = group.ticker
|
| 643 |
+
console.print(f"\n[bold cyan]Position Details: {ticker}[/bold cyan]")
|
| 644 |
+
|
| 645 |
+
# Create a summary table
|
| 646 |
+
summary_table = Table(title=f"{ticker} Summary", box=ROUNDED)
|
| 647 |
+
summary_table.add_column("Metric", style="cyan")
|
| 648 |
+
summary_table.add_column("Value", style="green")
|
| 649 |
+
|
| 650 |
+
# Add basic position details
|
| 651 |
+
summary_table.add_row("Beta", f"{group.beta:.2f}")
|
| 652 |
+
summary_table.add_row("Net Exposure", format_currency(group.net_exposure))
|
| 653 |
+
summary_table.add_row("Beta-Adjusted Exposure", format_currency(group.beta_adjusted_exposure))
|
| 654 |
+
|
| 655 |
+
# Add stock details if available
|
| 656 |
+
if group.stock_position:
|
| 657 |
+
stock = group.stock_position
|
| 658 |
+
summary_table.add_row("Stock Quantity", f"{stock.quantity}")
|
| 659 |
+
summary_table.add_row("Stock Price", format_currency(stock.price))
|
| 660 |
+
summary_table.add_row("Stock Market Value", format_currency(stock.market_value))
|
| 661 |
+
|
| 662 |
+
# Add option summary if available
|
| 663 |
+
if group.option_positions:
|
| 664 |
+
summary_table.add_row("Option Count", f"{len(group.option_positions)}")
|
| 665 |
+
summary_table.add_row("Call Options", f"{group.call_count}")
|
| 666 |
+
summary_table.add_row("Put Options", f"{group.put_count}")
|
| 667 |
+
summary_table.add_row("Total Delta Exposure", format_currency(group.total_delta_exposure))
|
| 668 |
+
|
| 669 |
+
console.print(summary_table)
|
| 670 |
+
|
| 671 |
+
# If detailed and we have options, show option details
|
| 672 |
+
if detailed and group.option_positions:
|
| 673 |
+
options_table = Table(title=f"{ticker} Option Positions", box=ROUNDED)
|
| 674 |
+
options_table.add_column("Type", style="cyan")
|
| 675 |
+
options_table.add_column("Strike", style="green", justify="right")
|
| 676 |
+
options_table.add_column("Expiry", style="yellow")
|
| 677 |
+
options_table.add_column("Quantity", style="green", justify="right")
|
| 678 |
+
options_table.add_column("Delta", style="magenta", justify="right")
|
| 679 |
+
options_table.add_column("Value", style="green", justify="right")
|
| 680 |
+
|
| 681 |
+
for option in group.option_positions:
|
| 682 |
+
options_table.add_row(
|
| 683 |
+
option.option_type,
|
| 684 |
+
format_currency(option.strike),
|
| 685 |
+
option.expiry,
|
| 686 |
+
f"{option.quantity}",
|
| 687 |
+
f"{option.delta:.2f}",
|
| 688 |
+
format_currency(option.market_value),
|
| 689 |
+
)
|
| 690 |
+
|
| 691 |
+
console.print(options_table)
|
| 692 |
+
```
|
| 693 |
+
|
| 694 |
+
## Future Extensibility
|
| 695 |
+
|
| 696 |
+
While keeping the MVP simple, we'll ensure future extensibility by:
|
| 697 |
+
|
| 698 |
+
1. **Modular Design**
|
| 699 |
+
- Separate command processing from execution
|
| 700 |
+
- Use clear interfaces between components
|
| 701 |
+
|
| 702 |
+
2. **Extensible Command Structure**
|
| 703 |
+
- Design for easy addition of new commands
|
| 704 |
+
- Allow for command hierarchies in the future
|
| 705 |
+
|
| 706 |
+
3. **State Management**
|
| 707 |
+
- Implement a simple state manager that can be expanded
|
| 708 |
+
- Allow for saving and loading state
|
| 709 |
+
|
| 710 |
+
4. **Documentation**
|
| 711 |
+
- Document extension points
|
| 712 |
+
- Create clear examples for adding new commands
|
| 713 |
+
|
| 714 |
+
## Implementation Roadmap
|
| 715 |
+
|
| 716 |
+
### Week 1: Basic Interactive Shell
|
| 717 |
+
1. **Day 1-2**: Set up project structure and implement shell framework
|
| 718 |
+
- Create directory structure
|
| 719 |
+
- Implement basic REPL with command history
|
| 720 |
+
- Set up command registration system
|
| 721 |
+
|
| 722 |
+
2. **Day 3-4**: Implement portfolio loading and basic commands
|
| 723 |
+
- Create portfolio loading functionality
|
| 724 |
+
- Implement help command
|
| 725 |
+
- Add exit command with confirmation
|
| 726 |
+
|
| 727 |
+
3. **Day 5**: Port core simulator command
|
| 728 |
+
- Implement SPY simulation command
|
| 729 |
+
- Create basic formatters for simulation results
|
| 730 |
+
- Test with sample portfolio
|
| 731 |
+
|
| 732 |
+
### Week 2: Enhanced Interactivity
|
| 733 |
+
1. **Day 1-2**: Implement position-specific analysis
|
| 734 |
+
- Create position command
|
| 735 |
+
- Implement detailed position view
|
| 736 |
+
- Add option chain visualization
|
| 737 |
+
|
| 738 |
+
2. **Day 3-4**: Add filtering and state management
|
| 739 |
+
- Implement ticker filtering
|
| 740 |
+
- Create state management between commands
|
| 741 |
+
- Add parameter customization
|
| 742 |
+
|
| 743 |
+
3. **Day 5**: Testing and refinement
|
| 744 |
+
- Test with various portfolios
|
| 745 |
+
- Refine error handling
|
| 746 |
+
- Improve user feedback
|
| 747 |
+
|
| 748 |
+
### Week 3: Additional Commands and Polish
|
| 749 |
+
1. **Day 1-2**: Add supplementary commands
|
| 750 |
+
- Implement portfolio summary command
|
| 751 |
+
- Add "what-if" scenario analysis
|
| 752 |
+
- Create portfolio comparison tools
|
| 753 |
+
|
| 754 |
+
2. **Day 3-4**: Enhance command completion and documentation
|
| 755 |
+
- Implement context-aware command completion
|
| 756 |
+
- Add comprehensive help text
|
| 757 |
+
- Create user documentation
|
| 758 |
+
|
| 759 |
+
3. **Day 5**: Final testing and packaging
|
| 760 |
+
- Write unit tests
|
| 761 |
+
- Create integration tests
|
| 762 |
+
- Package for distribution
|
| 763 |
+
|
| 764 |
+
## Conclusion
|
| 765 |
+
|
| 766 |
+
This MVP approach focuses on creating a simple but effective interactive shell for Folio portfolio analysis. By directly leveraging the existing codebase, we can quickly create a working product that provides immediate value while setting the foundation for future enhancements.
|
| 767 |
+
|
| 768 |
+
The interactive shell will allow users to run multiple simulations, explore different options, and analyze portfolio data without restarting the application, significantly improving the user experience compared to the current script-based approach.
|
| 769 |
+
|
| 770 |
+
By following the phased implementation plan and detailed roadmap, we can ensure a systematic approach to development, with clear milestones and deliverables at each stage. The modular design will facilitate future extensions and enhancements as user needs evolve.
|
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Folio CLI - Interactive command-line interface for Folio portfolio management.
|
| 4 |
+
|
| 5 |
+
This script provides an interactive shell for running portfolio simulations,
|
| 6 |
+
analyzing positions, and exploring investment scenarios.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python scripts/focli.py
|
| 10 |
+
|
| 11 |
+
Commands:
|
| 12 |
+
help Show help information
|
| 13 |
+
simulate spy Simulate portfolio performance with SPY changes
|
| 14 |
+
position <ticker> Analyze a specific position group
|
| 15 |
+
portfolio list List all positions in the portfolio
|
| 16 |
+
portfolio summary Show a summary of the portfolio
|
| 17 |
+
portfolio load Load a portfolio from a CSV file
|
| 18 |
+
exit Exit the application
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import sys
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
# Add the src directory to the Python path
|
| 25 |
+
sys.path.append(str(Path(__file__).parent.parent))
|
| 26 |
+
|
| 27 |
+
from src.focli.shell import main
|
| 28 |
+
|
| 29 |
+
if __name__ == "__main__":
|
| 30 |
+
main()
|
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Folio CLI - Interactive command-line interface for Folio portfolio management.
|
| 3 |
+
|
| 4 |
+
This package provides an interactive shell for running portfolio simulations,
|
| 5 |
+
analyzing positions, and exploring investment scenarios.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Command registry and execution for the Folio CLI.
|
| 3 |
+
|
| 4 |
+
This module provides a central registry for all commands and handles command execution.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from collections.abc import Callable
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
# Import command modules
|
| 11 |
+
from .help import help_command
|
| 12 |
+
from .portfolio import portfolio_command
|
| 13 |
+
from .position import position_command
|
| 14 |
+
from .simulate import simulate_command
|
| 15 |
+
|
| 16 |
+
# Command registry
|
| 17 |
+
_COMMANDS = {}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def register_command(
|
| 21 |
+
name: str, handler: Callable, help_text: str, subcommands: list[str] | None = None
|
| 22 |
+
):
|
| 23 |
+
"""Register a command with the command registry.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
name: Command name
|
| 27 |
+
handler: Function that handles the command
|
| 28 |
+
help_text: Help text for the command
|
| 29 |
+
subcommands: List of subcommands (if any)
|
| 30 |
+
"""
|
| 31 |
+
_COMMANDS[name] = {
|
| 32 |
+
"handler": handler,
|
| 33 |
+
"help": help_text,
|
| 34 |
+
"subcommands": subcommands,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def get_command_registry():
|
| 39 |
+
"""Get the command registry.
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
Dictionary of registered commands
|
| 43 |
+
"""
|
| 44 |
+
return _COMMANDS
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def execute_command(command_line: str, state: dict[str, Any], console):
|
| 48 |
+
"""Execute a command from the command line.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
command_line: Full command line to execute
|
| 52 |
+
state: Application state dictionary
|
| 53 |
+
console: Rich console for output
|
| 54 |
+
"""
|
| 55 |
+
# Parse the command line
|
| 56 |
+
parts = command_line.strip().split()
|
| 57 |
+
if not parts:
|
| 58 |
+
return
|
| 59 |
+
|
| 60 |
+
command = parts[0].lower()
|
| 61 |
+
args = parts[1:]
|
| 62 |
+
|
| 63 |
+
# Check if the command exists
|
| 64 |
+
if command not in _COMMANDS:
|
| 65 |
+
console.print(f"[bold red]Unknown command:[/bold red] {command}")
|
| 66 |
+
console.print("Type 'help' to see available commands.")
|
| 67 |
+
return
|
| 68 |
+
|
| 69 |
+
# Execute the command
|
| 70 |
+
try:
|
| 71 |
+
_COMMANDS[command]["handler"](args, state, console)
|
| 72 |
+
except Exception as e:
|
| 73 |
+
console.print(
|
| 74 |
+
f"[bold red]Error executing command '{command}':[/bold red] {e!s}"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# Import command modules
|
| 79 |
+
|
| 80 |
+
# Register commands
|
| 81 |
+
register_command("help", help_command, "Show help information")
|
| 82 |
+
register_command(
|
| 83 |
+
"simulate",
|
| 84 |
+
simulate_command,
|
| 85 |
+
"Simulate portfolio performance with SPY changes",
|
| 86 |
+
["spy", "scenario"],
|
| 87 |
+
)
|
| 88 |
+
register_command("position", position_command, "Analyze a specific position group")
|
| 89 |
+
register_command(
|
| 90 |
+
"portfolio",
|
| 91 |
+
portfolio_command,
|
| 92 |
+
"View and analyze portfolio",
|
| 93 |
+
["list", "summary", "load"],
|
| 94 |
+
)
|
| 95 |
+
register_command("exit", lambda *args: None, "Exit the application")
|
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Help command for the Folio CLI.
|
| 3 |
+
|
| 4 |
+
This module provides the help command for displaying information about available commands.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from rich.box import ROUNDED
|
| 10 |
+
from rich.table import Table
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def help_command(args: list[str], state: dict[str, Any], console): # noqa: ARG001
|
| 14 |
+
"""Show help information.
|
| 15 |
+
|
| 16 |
+
Args:
|
| 17 |
+
args: Command arguments
|
| 18 |
+
state: Application state
|
| 19 |
+
console: Rich console for output
|
| 20 |
+
"""
|
| 21 |
+
from src.focli.commands import get_command_registry
|
| 22 |
+
|
| 23 |
+
# Get the command registry
|
| 24 |
+
commands = get_command_registry()
|
| 25 |
+
|
| 26 |
+
# Check if we're showing help for a specific command
|
| 27 |
+
if args and args[0] in commands:
|
| 28 |
+
command = args[0]
|
| 29 |
+
command_info = commands[command]
|
| 30 |
+
|
| 31 |
+
console.print(f"\n[bold]Help for command:[/bold] [cyan]{command}[/cyan]")
|
| 32 |
+
console.print(f"\n{command_info['help']}\n")
|
| 33 |
+
|
| 34 |
+
# Show subcommands if available
|
| 35 |
+
if command_info.get("subcommands"):
|
| 36 |
+
console.print("[bold]Subcommands:[/bold]")
|
| 37 |
+
for subcommand in command_info["subcommands"]:
|
| 38 |
+
console.print(f" [cyan]{subcommand}[/cyan]")
|
| 39 |
+
console.print("")
|
| 40 |
+
|
| 41 |
+
# Show usage examples based on the command
|
| 42 |
+
console.print("[bold]Usage examples:[/bold]")
|
| 43 |
+
|
| 44 |
+
if command == "simulate":
|
| 45 |
+
console.print(
|
| 46 |
+
" [green]simulate spy[/green] - Run a simulation with default parameters"
|
| 47 |
+
)
|
| 48 |
+
console.print(
|
| 49 |
+
" [green]simulate spy --range 10 --steps 21[/green] - Run a simulation with ±10% range and 21 steps"
|
| 50 |
+
)
|
| 51 |
+
console.print(
|
| 52 |
+
" [green]simulate spy --focus SPY,AAPL[/green] - Run a simulation focusing on specific tickers"
|
| 53 |
+
)
|
| 54 |
+
console.print(
|
| 55 |
+
" [green]simulate spy --detailed[/green] - Run a simulation with detailed position analysis"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
elif command == "position":
|
| 59 |
+
console.print(
|
| 60 |
+
" [green]position SPY[/green] - Show details for the SPY position"
|
| 61 |
+
)
|
| 62 |
+
console.print(
|
| 63 |
+
" [green]position AAPL --detailed[/green] - Show detailed information for the AAPL position"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
elif command == "portfolio":
|
| 67 |
+
console.print(
|
| 68 |
+
" [green]portfolio summary[/green] - Show a summary of the portfolio"
|
| 69 |
+
)
|
| 70 |
+
console.print(
|
| 71 |
+
" [green]portfolio load path/to/portfolio.csv[/green] - Load a portfolio from a CSV file"
|
| 72 |
+
)
|
| 73 |
+
console.print(
|
| 74 |
+
" [green]portfolio list[/green] - List all positions in the portfolio"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
elif command == "help":
|
| 78 |
+
console.print(" [green]help[/green] - Show this help message")
|
| 79 |
+
console.print(
|
| 80 |
+
" [green]help simulate[/green] - Show help for the simulate command"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
elif command == "exit":
|
| 84 |
+
console.print(" [green]exit[/green] - Exit the application")
|
| 85 |
+
|
| 86 |
+
console.print("")
|
| 87 |
+
|
| 88 |
+
else:
|
| 89 |
+
# Show general help
|
| 90 |
+
console.print("\n[bold cyan]Folio CLI Help[/bold cyan]")
|
| 91 |
+
console.print("\nAvailable commands:\n")
|
| 92 |
+
|
| 93 |
+
# Create a table of commands
|
| 94 |
+
table = Table(box=ROUNDED)
|
| 95 |
+
table.add_column("Command", style="cyan")
|
| 96 |
+
table.add_column("Description", style="green")
|
| 97 |
+
table.add_column("Subcommands", style="yellow")
|
| 98 |
+
|
| 99 |
+
# Add rows for each command
|
| 100 |
+
for name, info in commands.items():
|
| 101 |
+
subcommands = (
|
| 102 |
+
", ".join(info.get("subcommands", []))
|
| 103 |
+
if info.get("subcommands")
|
| 104 |
+
else ""
|
| 105 |
+
)
|
| 106 |
+
table.add_row(name, info["help"], subcommands)
|
| 107 |
+
|
| 108 |
+
console.print(table)
|
| 109 |
+
|
| 110 |
+
# Show general usage
|
| 111 |
+
console.print("\n[bold]General usage:[/bold]")
|
| 112 |
+
console.print(" Type a command followed by any arguments or options.")
|
| 113 |
+
console.print(
|
| 114 |
+
" Use [cyan]help <command>[/cyan] to get help for a specific command."
|
| 115 |
+
)
|
| 116 |
+
console.print(" Use [cyan]exit[/cyan] to exit the application.")
|
| 117 |
+
console.print("")
|
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Portfolio management commands for the Folio CLI.
|
| 3 |
+
|
| 4 |
+
This module provides commands for managing and analyzing portfolios.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from rich.box import ROUNDED
|
| 10 |
+
from rich.table import Table
|
| 11 |
+
|
| 12 |
+
from src.focli.formatters import display_portfolio_summary, format_currency
|
| 13 |
+
from src.focli.utils import load_portfolio
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def portfolio_command(args: list[str], state: dict[str, Any], console):
|
| 17 |
+
"""View and analyze portfolio.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
args: Command arguments
|
| 21 |
+
state: Application state
|
| 22 |
+
console: Rich console for output
|
| 23 |
+
"""
|
| 24 |
+
# Check if we have a subcommand
|
| 25 |
+
if not args:
|
| 26 |
+
console.print(
|
| 27 |
+
"[bold yellow]Usage:[/bold yellow] portfolio <subcommand> [options]"
|
| 28 |
+
)
|
| 29 |
+
console.print("Available subcommands: list, summary, load")
|
| 30 |
+
console.print("Type 'help portfolio' for more information.")
|
| 31 |
+
return
|
| 32 |
+
|
| 33 |
+
subcommand = args[0].lower()
|
| 34 |
+
subcommand_args = args[1:]
|
| 35 |
+
|
| 36 |
+
if subcommand == "list":
|
| 37 |
+
portfolio_list(subcommand_args, state, console)
|
| 38 |
+
elif subcommand == "summary":
|
| 39 |
+
portfolio_summary(subcommand_args, state, console)
|
| 40 |
+
elif subcommand == "load":
|
| 41 |
+
portfolio_load(subcommand_args, state, console)
|
| 42 |
+
else:
|
| 43 |
+
console.print(f"[bold red]Unknown subcommand:[/bold red] {subcommand}")
|
| 44 |
+
console.print("Available subcommands: list, summary, load")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def portfolio_list(args: list[str], state: dict[str, Any], console): # noqa: ARG001
|
| 48 |
+
"""List all positions in the portfolio.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
args: Command arguments
|
| 52 |
+
state: Application state
|
| 53 |
+
console: Rich console for output
|
| 54 |
+
"""
|
| 55 |
+
# Check if a portfolio is loaded
|
| 56 |
+
if not state.get("portfolio_groups"):
|
| 57 |
+
console.print("[bold red]Error:[/bold red] No portfolio loaded.")
|
| 58 |
+
console.print("Use 'portfolio load <path>' to load a portfolio.")
|
| 59 |
+
return
|
| 60 |
+
|
| 61 |
+
# Create a table of positions
|
| 62 |
+
table = Table(title="Portfolio Positions", box=ROUNDED)
|
| 63 |
+
table.add_column("Ticker", style="cyan")
|
| 64 |
+
table.add_column("Beta", style="yellow", justify="right")
|
| 65 |
+
table.add_column("Net Exposure", style="green", justify="right")
|
| 66 |
+
table.add_column("Stock Value", style="green", justify="right")
|
| 67 |
+
table.add_column("Option Value", style="green", justify="right")
|
| 68 |
+
table.add_column("Options", style="magenta", justify="right")
|
| 69 |
+
|
| 70 |
+
# Add rows for each position
|
| 71 |
+
for group in state["portfolio_groups"]:
|
| 72 |
+
stock_value = group.stock_position.market_value if group.stock_position else 0
|
| 73 |
+
option_value = (
|
| 74 |
+
sum(op.market_value for op in group.option_positions)
|
| 75 |
+
if group.option_positions
|
| 76 |
+
else 0
|
| 77 |
+
)
|
| 78 |
+
option_count = len(group.option_positions) if group.option_positions else 0
|
| 79 |
+
|
| 80 |
+
table.add_row(
|
| 81 |
+
group.ticker,
|
| 82 |
+
f"{group.beta:.2f}",
|
| 83 |
+
format_currency(group.net_exposure),
|
| 84 |
+
format_currency(stock_value),
|
| 85 |
+
format_currency(option_value),
|
| 86 |
+
f"{option_count}",
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
console.print(table)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def portfolio_summary(args: list[str], state: dict[str, Any], console): # noqa: ARG001
|
| 93 |
+
"""Show a summary of the portfolio.
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
args: Command arguments
|
| 97 |
+
state: Application state
|
| 98 |
+
console: Rich console for output
|
| 99 |
+
"""
|
| 100 |
+
# Check if a portfolio is loaded
|
| 101 |
+
if not state.get("portfolio_summary"):
|
| 102 |
+
console.print("[bold red]Error:[/bold red] No portfolio loaded.")
|
| 103 |
+
console.print("Use 'portfolio load <path>' to load a portfolio.")
|
| 104 |
+
return
|
| 105 |
+
|
| 106 |
+
# Display the portfolio summary
|
| 107 |
+
display_portfolio_summary(state["portfolio_summary"], console)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def portfolio_load(args: list[str], state: dict[str, Any], console):
|
| 111 |
+
"""Load a portfolio from a CSV file.
|
| 112 |
+
|
| 113 |
+
Args:
|
| 114 |
+
args: Command arguments
|
| 115 |
+
state: Application state
|
| 116 |
+
console: Rich console for output
|
| 117 |
+
"""
|
| 118 |
+
# Check if we have a path
|
| 119 |
+
if not args:
|
| 120 |
+
console.print("[bold yellow]Usage:[/bold yellow] portfolio load <path>")
|
| 121 |
+
console.print("Type 'help portfolio load' for more information.")
|
| 122 |
+
return
|
| 123 |
+
|
| 124 |
+
# Get the path
|
| 125 |
+
path = args[0]
|
| 126 |
+
|
| 127 |
+
try:
|
| 128 |
+
# Load the portfolio
|
| 129 |
+
load_portfolio(path, state, console)
|
| 130 |
+
|
| 131 |
+
except FileNotFoundError as e:
|
| 132 |
+
console.print(f"[bold red]Error:[/bold red] {e!s}")
|
| 133 |
+
except Exception as e:
|
| 134 |
+
console.print(f"[bold red]Error loading portfolio:[/bold red] {e!s}")
|
| 135 |
+
import traceback
|
| 136 |
+
|
| 137 |
+
console.print(traceback.format_exc())
|
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Position analysis commands for the Folio CLI.
|
| 3 |
+
|
| 4 |
+
This module provides commands for analyzing specific position groups.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from src.focli.formatters import display_position_details
|
| 10 |
+
from src.focli.utils import find_position_group, parse_args
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def position_command(args: list[str], state: dict[str, Any], console):
|
| 14 |
+
"""Analyze a specific position group.
|
| 15 |
+
|
| 16 |
+
Args:
|
| 17 |
+
args: Command arguments
|
| 18 |
+
state: Application state
|
| 19 |
+
console: Rich console for output
|
| 20 |
+
"""
|
| 21 |
+
# Check if a portfolio is loaded
|
| 22 |
+
if not state.get("portfolio_groups"):
|
| 23 |
+
console.print("[bold red]Error:[/bold red] No portfolio loaded.")
|
| 24 |
+
console.print("Use 'portfolio load <path>' to load a portfolio.")
|
| 25 |
+
return
|
| 26 |
+
|
| 27 |
+
# Check if we have a ticker
|
| 28 |
+
if not args:
|
| 29 |
+
console.print("[bold yellow]Usage:[/bold yellow] position <ticker> [options]")
|
| 30 |
+
console.print("Type 'help position' for more information.")
|
| 31 |
+
return
|
| 32 |
+
|
| 33 |
+
# Get the ticker
|
| 34 |
+
ticker = args[0].upper()
|
| 35 |
+
position_args = args[1:]
|
| 36 |
+
|
| 37 |
+
# Define argument specifications
|
| 38 |
+
arg_specs = {
|
| 39 |
+
'detailed': {
|
| 40 |
+
'type': bool,
|
| 41 |
+
'default': True,
|
| 42 |
+
'help': 'Show detailed information',
|
| 43 |
+
'aliases': ['-d', '--detailed', '--no-detailed']
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
# Parse arguments
|
| 49 |
+
parsed_args = parse_args(position_args, arg_specs)
|
| 50 |
+
|
| 51 |
+
detailed = parsed_args['detailed']
|
| 52 |
+
|
| 53 |
+
# Find the position group
|
| 54 |
+
group = find_position_group(ticker, state["portfolio_groups"])
|
| 55 |
+
|
| 56 |
+
if not group:
|
| 57 |
+
console.print(f"[bold red]Position not found:[/bold red] {ticker}")
|
| 58 |
+
return
|
| 59 |
+
|
| 60 |
+
# Display detailed position information
|
| 61 |
+
display_position_details(group, detailed, console)
|
| 62 |
+
|
| 63 |
+
except ValueError as e:
|
| 64 |
+
console.print(f"[bold red]Error:[/bold red] {e!s}")
|
| 65 |
+
except Exception as e:
|
| 66 |
+
console.print(f"[bold red]Error analyzing position:[/bold red] {e!s}")
|
| 67 |
+
import traceback
|
| 68 |
+
console.print(traceback.format_exc())
|
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simulation commands for the Folio CLI.
|
| 3 |
+
|
| 4 |
+
This module provides commands for simulating portfolio performance under different scenarios.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from src.focli.formatters import display_simulation_results
|
| 10 |
+
from src.focli.utils import generate_spy_changes, parse_args
|
| 11 |
+
from src.folio.simulator import simulate_portfolio_with_spy_changes
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def simulate_command(args: list[str], state: dict[str, Any], console):
|
| 15 |
+
"""Simulate portfolio performance with SPY changes.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
args: Command arguments
|
| 19 |
+
state: Application state
|
| 20 |
+
console: Rich console for output
|
| 21 |
+
"""
|
| 22 |
+
# Check if a portfolio is loaded
|
| 23 |
+
if not state.get("portfolio_groups"):
|
| 24 |
+
console.print("[bold red]Error:[/bold red] No portfolio loaded.")
|
| 25 |
+
console.print("Use 'portfolio load <path>' to load a portfolio.")
|
| 26 |
+
return
|
| 27 |
+
|
| 28 |
+
# Check if we have a subcommand
|
| 29 |
+
if not args:
|
| 30 |
+
console.print("[bold yellow]Usage:[/bold yellow] simulate <subcommand> [options]")
|
| 31 |
+
console.print("Available subcommands: spy, scenario")
|
| 32 |
+
console.print("Type 'help simulate' for more information.")
|
| 33 |
+
return
|
| 34 |
+
|
| 35 |
+
subcommand = args[0].lower()
|
| 36 |
+
subcommand_args = args[1:]
|
| 37 |
+
|
| 38 |
+
if subcommand == "spy":
|
| 39 |
+
simulate_spy(subcommand_args, state, console)
|
| 40 |
+
elif subcommand == "scenario":
|
| 41 |
+
console.print("[bold yellow]Note:[/bold yellow] Scenario simulation is not yet implemented.")
|
| 42 |
+
else:
|
| 43 |
+
console.print(f"[bold red]Unknown subcommand:[/bold red] {subcommand}")
|
| 44 |
+
console.print("Available subcommands: spy, scenario")
|
| 45 |
+
|
| 46 |
+
def simulate_spy(args: list[str], state: dict[str, Any], console):
|
| 47 |
+
"""Simulate portfolio performance with SPY changes.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
args: Command arguments
|
| 51 |
+
state: Application state
|
| 52 |
+
console: Rich console for output
|
| 53 |
+
"""
|
| 54 |
+
# Define argument specifications
|
| 55 |
+
arg_specs = {
|
| 56 |
+
'range': {
|
| 57 |
+
'type': float,
|
| 58 |
+
'default': 20.0,
|
| 59 |
+
'help': 'SPY change range in percent',
|
| 60 |
+
'aliases': ['-r', '--range']
|
| 61 |
+
},
|
| 62 |
+
'steps': {
|
| 63 |
+
'type': int,
|
| 64 |
+
'default': 13,
|
| 65 |
+
'help': 'Number of steps in the simulation',
|
| 66 |
+
'aliases': ['-s', '--steps']
|
| 67 |
+
},
|
| 68 |
+
'focus': {
|
| 69 |
+
'type': str,
|
| 70 |
+
'default': None,
|
| 71 |
+
'help': 'Comma-separated list of tickers to focus on',
|
| 72 |
+
'aliases': ['-f', '--focus']
|
| 73 |
+
},
|
| 74 |
+
'detailed': {
|
| 75 |
+
'type': bool,
|
| 76 |
+
'default': False,
|
| 77 |
+
'help': 'Show detailed analysis for all positions',
|
| 78 |
+
'aliases': ['-d', '--detailed']
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
# Parse arguments
|
| 84 |
+
parsed_args = parse_args(args, arg_specs)
|
| 85 |
+
|
| 86 |
+
range_pct = parsed_args['range']
|
| 87 |
+
steps = parsed_args['steps']
|
| 88 |
+
focus = parsed_args['focus']
|
| 89 |
+
detailed = parsed_args['detailed']
|
| 90 |
+
|
| 91 |
+
# Parse focus tickers if provided
|
| 92 |
+
focus_tickers = None
|
| 93 |
+
if focus:
|
| 94 |
+
focus_tickers = [ticker.strip().upper() for ticker in focus.split(",")]
|
| 95 |
+
|
| 96 |
+
# Generate SPY changes
|
| 97 |
+
spy_changes = generate_spy_changes(range_pct, steps)
|
| 98 |
+
|
| 99 |
+
# Run the simulation
|
| 100 |
+
console.print(f"[bold]Running simulation with range ±{range_pct}% and {steps} steps...[/bold]")
|
| 101 |
+
|
| 102 |
+
results = simulate_portfolio_with_spy_changes(
|
| 103 |
+
portfolio_groups=state["portfolio_groups"],
|
| 104 |
+
spy_changes=spy_changes,
|
| 105 |
+
cash_like_positions=state["portfolio_summary"].cash_like_positions,
|
| 106 |
+
pending_activity_value=state["portfolio_summary"].pending_activity_value,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# Store results for future reference
|
| 110 |
+
state["last_simulation"] = results
|
| 111 |
+
|
| 112 |
+
# Display the results
|
| 113 |
+
display_simulation_results(results, detailed, focus_tickers, console)
|
| 114 |
+
|
| 115 |
+
except ValueError as e:
|
| 116 |
+
console.print(f"[bold red]Error:[/bold red] {e!s}")
|
| 117 |
+
except Exception as e:
|
| 118 |
+
console.print(f"[bold red]Error running simulation:[/bold red] {e!s}")
|
| 119 |
+
import traceback
|
| 120 |
+
console.print(traceback.format_exc())
|
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Output formatting utilities for the Folio CLI.
|
| 3 |
+
|
| 4 |
+
This module provides functions for formatting CLI output using Rich.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from rich.box import ROUNDED
|
| 8 |
+
from rich.console import Console
|
| 9 |
+
from rich.table import Table
|
| 10 |
+
|
| 11 |
+
from src.folio.formatting import format_currency
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def display_simulation_results(results, detailed=False, focus_tickers=None, console=None):
|
| 15 |
+
"""Display simulation results using Rich.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
results: Simulation results from simulate_portfolio_with_spy_changes
|
| 19 |
+
detailed: Whether to show detailed position analysis
|
| 20 |
+
focus_tickers: List of tickers to focus on
|
| 21 |
+
console: Rich console for output
|
| 22 |
+
"""
|
| 23 |
+
if console is None:
|
| 24 |
+
console = Console()
|
| 25 |
+
|
| 26 |
+
# Get the current value (at 0% SPY change)
|
| 27 |
+
current_value = results["current_value"]
|
| 28 |
+
|
| 29 |
+
# Get min and max values
|
| 30 |
+
min_value = min(results["portfolio_values"])
|
| 31 |
+
max_value = max(results["portfolio_values"])
|
| 32 |
+
min_index = results["portfolio_values"].index(min_value)
|
| 33 |
+
max_index = results["portfolio_values"].index(max_value)
|
| 34 |
+
min_spy_change = results["spy_changes"][min_index] * 100 # Convert to percentage
|
| 35 |
+
max_spy_change = results["spy_changes"][max_index] * 100 # Convert to percentage
|
| 36 |
+
|
| 37 |
+
# Create a summary table
|
| 38 |
+
console.print("\n[bold cyan]Portfolio Simulation Results[/bold cyan]")
|
| 39 |
+
|
| 40 |
+
summary_table = Table(title="Portfolio Summary", box=ROUNDED)
|
| 41 |
+
summary_table.add_column("Metric", style="cyan")
|
| 42 |
+
summary_table.add_column("Value", style="green")
|
| 43 |
+
summary_table.add_column("SPY Change", style="yellow")
|
| 44 |
+
|
| 45 |
+
summary_table.add_row("Current Value", f"${current_value:,.2f}", "0.0%")
|
| 46 |
+
summary_table.add_row("Minimum Value", f"${min_value:,.2f}", f"{min_spy_change:.1f}%")
|
| 47 |
+
summary_table.add_row("Maximum Value", f"${max_value:,.2f}", f"{max_spy_change:.1f}%")
|
| 48 |
+
|
| 49 |
+
console.print(summary_table)
|
| 50 |
+
|
| 51 |
+
# Create a detailed table with all values
|
| 52 |
+
value_table = Table(title="Portfolio Values at Different SPY Changes", box=ROUNDED)
|
| 53 |
+
value_table.add_column("SPY Change", style="yellow")
|
| 54 |
+
value_table.add_column("Portfolio Value", style="green")
|
| 55 |
+
value_table.add_column("Change", style="cyan")
|
| 56 |
+
value_table.add_column("% Change", style="magenta")
|
| 57 |
+
|
| 58 |
+
for i, spy_change in enumerate(results["spy_changes"]):
|
| 59 |
+
portfolio_value = results["portfolio_values"][i]
|
| 60 |
+
value_change = portfolio_value - current_value
|
| 61 |
+
pct_change = (value_change / current_value) * 100 if current_value != 0 else 0
|
| 62 |
+
|
| 63 |
+
# Format the change with color based on positive/negative
|
| 64 |
+
change_str = f"${value_change:+,.2f}"
|
| 65 |
+
pct_change_str = f"{pct_change:+.2f}%"
|
| 66 |
+
|
| 67 |
+
value_table.add_row(
|
| 68 |
+
f"{spy_change * 100:.1f}%",
|
| 69 |
+
f"${portfolio_value:,.2f}",
|
| 70 |
+
change_str,
|
| 71 |
+
pct_change_str,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
console.print(value_table)
|
| 75 |
+
|
| 76 |
+
# If detailed is True, show position-level analysis
|
| 77 |
+
if detailed:
|
| 78 |
+
display_position_analysis(results, focus_tickers, console)
|
| 79 |
+
|
| 80 |
+
def display_position_analysis(results, focus_tickers=None, console=None):
|
| 81 |
+
"""Display position-level analysis.
|
| 82 |
+
|
| 83 |
+
Args:
|
| 84 |
+
results: Simulation results from simulate_portfolio_with_spy_changes
|
| 85 |
+
focus_tickers: List of tickers to focus on
|
| 86 |
+
console: Rich console for output
|
| 87 |
+
"""
|
| 88 |
+
if console is None:
|
| 89 |
+
console = Console()
|
| 90 |
+
|
| 91 |
+
# Get position details
|
| 92 |
+
position_details = results.get("position_details", {})
|
| 93 |
+
position_changes = results.get("position_changes", {})
|
| 94 |
+
|
| 95 |
+
# Filter positions if focus_tickers is provided
|
| 96 |
+
if focus_tickers:
|
| 97 |
+
filtered_details = {}
|
| 98 |
+
filtered_changes = {}
|
| 99 |
+
for ticker in focus_tickers:
|
| 100 |
+
if ticker in position_details:
|
| 101 |
+
filtered_details[ticker] = position_details[ticker]
|
| 102 |
+
if ticker in position_changes:
|
| 103 |
+
filtered_changes[ticker] = position_changes[ticker]
|
| 104 |
+
position_details = filtered_details
|
| 105 |
+
position_changes = filtered_changes
|
| 106 |
+
|
| 107 |
+
# Display position details
|
| 108 |
+
console.print("\n[bold cyan]Position Analysis[/bold cyan]")
|
| 109 |
+
|
| 110 |
+
for ticker, details in position_details.items():
|
| 111 |
+
# Create a panel for each position
|
| 112 |
+
position_table = Table(title=f"{ticker} Details", box=ROUNDED)
|
| 113 |
+
position_table.add_column("Metric", style="cyan")
|
| 114 |
+
position_table.add_column("Value", style="green")
|
| 115 |
+
|
| 116 |
+
# Add basic position details
|
| 117 |
+
position_table.add_row("Beta", f"{details.get('beta', 0):.2f}")
|
| 118 |
+
position_table.add_row("Current Value", format_currency(details.get('current_value', 0)))
|
| 119 |
+
position_table.add_row("Stock Value", format_currency(details.get('stock_value', 0)))
|
| 120 |
+
position_table.add_row("Option Value", format_currency(details.get('option_value', 0)))
|
| 121 |
+
|
| 122 |
+
# Add stock details if available
|
| 123 |
+
if details.get('has_stock'):
|
| 124 |
+
position_table.add_row("Stock Quantity", f"{details.get('stock_quantity', 0)}")
|
| 125 |
+
position_table.add_row("Stock Price", format_currency(details.get('stock_price', 0)))
|
| 126 |
+
|
| 127 |
+
# Add option details if available
|
| 128 |
+
if details.get('has_options'):
|
| 129 |
+
position_table.add_row("Option Count", f"{details.get('option_count', 0)}")
|
| 130 |
+
|
| 131 |
+
console.print(position_table)
|
| 132 |
+
|
| 133 |
+
# If we have change data, show it
|
| 134 |
+
if ticker in position_changes:
|
| 135 |
+
changes = position_changes[ticker]
|
| 136 |
+
|
| 137 |
+
# Create a table for position changes
|
| 138 |
+
changes_table = Table(title=f"{ticker} Changes with SPY", box=ROUNDED)
|
| 139 |
+
changes_table.add_column("SPY Change", style="yellow")
|
| 140 |
+
changes_table.add_column("Position Value", style="green")
|
| 141 |
+
changes_table.add_column("Change", style="cyan")
|
| 142 |
+
changes_table.add_column("% Change", style="magenta")
|
| 143 |
+
|
| 144 |
+
for i, spy_change in enumerate(results["spy_changes"]):
|
| 145 |
+
if i < len(changes["values"]):
|
| 146 |
+
value = changes["values"][i]
|
| 147 |
+
change = changes["changes"][i]
|
| 148 |
+
pct_change = changes["pct_changes"][i]
|
| 149 |
+
|
| 150 |
+
changes_table.add_row(
|
| 151 |
+
f"{spy_change * 100:.1f}%",
|
| 152 |
+
format_currency(value),
|
| 153 |
+
f"{format_currency(change, include_sign=True)}",
|
| 154 |
+
f"{pct_change:+.2f}%",
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
console.print(changes_table)
|
| 158 |
+
|
| 159 |
+
def display_position_details(group, detailed=True, console=None):
|
| 160 |
+
"""Display detailed information about a position group.
|
| 161 |
+
|
| 162 |
+
Args:
|
| 163 |
+
group: PortfolioGroup to display
|
| 164 |
+
detailed: Whether to show detailed option information
|
| 165 |
+
console: Rich console for output
|
| 166 |
+
"""
|
| 167 |
+
if console is None:
|
| 168 |
+
console = Console()
|
| 169 |
+
|
| 170 |
+
ticker = group.ticker
|
| 171 |
+
console.print(f"\n[bold cyan]Position Details: {ticker}[/bold cyan]")
|
| 172 |
+
|
| 173 |
+
# Create a summary table
|
| 174 |
+
summary_table = Table(title=f"{ticker} Summary", box=ROUNDED)
|
| 175 |
+
summary_table.add_column("Metric", style="cyan")
|
| 176 |
+
summary_table.add_column("Value", style="green")
|
| 177 |
+
|
| 178 |
+
# Add basic position details
|
| 179 |
+
summary_table.add_row("Beta", f"{group.beta:.2f}")
|
| 180 |
+
summary_table.add_row("Net Exposure", format_currency(group.net_exposure))
|
| 181 |
+
summary_table.add_row("Beta-Adjusted Exposure", format_currency(group.beta_adjusted_exposure))
|
| 182 |
+
|
| 183 |
+
# Add stock details if available
|
| 184 |
+
if group.stock_position:
|
| 185 |
+
stock = group.stock_position
|
| 186 |
+
summary_table.add_row("Stock Quantity", f"{stock.quantity}")
|
| 187 |
+
summary_table.add_row("Stock Price", format_currency(stock.price))
|
| 188 |
+
summary_table.add_row("Stock Market Value", format_currency(stock.market_value))
|
| 189 |
+
|
| 190 |
+
# Add option summary if available
|
| 191 |
+
if group.option_positions:
|
| 192 |
+
summary_table.add_row("Option Count", f"{len(group.option_positions)}")
|
| 193 |
+
summary_table.add_row("Call Options", f"{group.call_count}")
|
| 194 |
+
summary_table.add_row("Put Options", f"{group.put_count}")
|
| 195 |
+
summary_table.add_row("Total Delta Exposure", format_currency(group.total_delta_exposure))
|
| 196 |
+
|
| 197 |
+
console.print(summary_table)
|
| 198 |
+
|
| 199 |
+
# If detailed and we have options, show option details
|
| 200 |
+
if detailed and group.option_positions:
|
| 201 |
+
options_table = Table(title=f"{ticker} Option Positions", box=ROUNDED)
|
| 202 |
+
options_table.add_column("Type", style="cyan")
|
| 203 |
+
options_table.add_column("Strike", style="green", justify="right")
|
| 204 |
+
options_table.add_column("Expiry", style="yellow")
|
| 205 |
+
options_table.add_column("Quantity", style="green", justify="right")
|
| 206 |
+
options_table.add_column("Delta", style="magenta", justify="right")
|
| 207 |
+
options_table.add_column("Value", style="green", justify="right")
|
| 208 |
+
|
| 209 |
+
for option in group.option_positions:
|
| 210 |
+
options_table.add_row(
|
| 211 |
+
option.option_type,
|
| 212 |
+
format_currency(option.strike),
|
| 213 |
+
option.expiry,
|
| 214 |
+
f"{option.quantity}",
|
| 215 |
+
f"{option.delta:.2f}",
|
| 216 |
+
format_currency(option.market_value),
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
console.print(options_table)
|
| 220 |
+
|
| 221 |
+
def display_portfolio_summary(summary, console=None):
|
| 222 |
+
"""Display a summary of the portfolio.
|
| 223 |
+
|
| 224 |
+
Args:
|
| 225 |
+
summary: PortfolioSummary object
|
| 226 |
+
console: Rich console for output
|
| 227 |
+
"""
|
| 228 |
+
if console is None:
|
| 229 |
+
console = Console()
|
| 230 |
+
|
| 231 |
+
console.print("\n[bold cyan]Portfolio Summary[/bold cyan]")
|
| 232 |
+
|
| 233 |
+
# Create a summary table
|
| 234 |
+
summary_table = Table(title="Portfolio Overview", box=ROUNDED)
|
| 235 |
+
summary_table.add_column("Metric", style="cyan")
|
| 236 |
+
summary_table.add_column("Value", style="green")
|
| 237 |
+
|
| 238 |
+
# Add portfolio metrics
|
| 239 |
+
summary_table.add_row("Total Value", format_currency(summary.portfolio_estimate_value))
|
| 240 |
+
summary_table.add_row("Stock Value", format_currency(summary.stock_value))
|
| 241 |
+
summary_table.add_row("Option Value", format_currency(summary.option_value))
|
| 242 |
+
summary_table.add_row("Cash Value", format_currency(summary.cash_like_value))
|
| 243 |
+
summary_table.add_row("Portfolio Beta", f"{summary.portfolio_beta:.2f}")
|
| 244 |
+
summary_table.add_row("Net Market Exposure", format_currency(summary.net_market_exposure))
|
| 245 |
+
|
| 246 |
+
console.print(summary_table)
|
| 247 |
+
|
| 248 |
+
# Create an exposure table
|
| 249 |
+
exposure_table = Table(title="Exposure Breakdown", box=ROUNDED)
|
| 250 |
+
exposure_table.add_column("Category", style="cyan")
|
| 251 |
+
exposure_table.add_column("Value", style="green")
|
| 252 |
+
exposure_table.add_column("% of Portfolio", style="magenta")
|
| 253 |
+
|
| 254 |
+
# Add exposure metrics
|
| 255 |
+
total_value = summary.portfolio_estimate_value
|
| 256 |
+
if total_value > 0:
|
| 257 |
+
exposure_table.add_row(
|
| 258 |
+
"Long Exposure",
|
| 259 |
+
format_currency(summary.long_exposure.total_value),
|
| 260 |
+
f"{summary.long_exposure.total_value / total_value * 100:.1f}%"
|
| 261 |
+
)
|
| 262 |
+
exposure_table.add_row(
|
| 263 |
+
"Short Exposure",
|
| 264 |
+
format_currency(summary.short_exposure.total_value),
|
| 265 |
+
f"{summary.short_exposure.total_value / total_value * 100:.1f}%"
|
| 266 |
+
)
|
| 267 |
+
exposure_table.add_row(
|
| 268 |
+
"Options Exposure",
|
| 269 |
+
format_currency(summary.options_exposure.total_value),
|
| 270 |
+
f"{summary.options_exposure.total_value / total_value * 100:.1f}%"
|
| 271 |
+
)
|
| 272 |
+
exposure_table.add_row(
|
| 273 |
+
"Cash",
|
| 274 |
+
format_currency(summary.cash_like_value),
|
| 275 |
+
f"{summary.cash_percentage * 100:.1f}%"
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
console.print(exposure_table)
|
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Interactive shell for the Folio CLI.
|
| 3 |
+
|
| 4 |
+
This module provides the main entry point for the Folio CLI interactive shell.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
from prompt_toolkit import PromptSession
|
| 10 |
+
from prompt_toolkit.completion import NestedCompleter
|
| 11 |
+
from prompt_toolkit.history import FileHistory
|
| 12 |
+
from prompt_toolkit.shortcuts import confirm
|
| 13 |
+
from rich.console import Console
|
| 14 |
+
|
| 15 |
+
from src.focli.commands import execute_command, get_command_registry
|
| 16 |
+
from src.focli.utils import load_portfolio
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def create_completer():
|
| 20 |
+
"""Create a nested completer for command auto-completion.
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
NestedCompleter for command auto-completion
|
| 24 |
+
"""
|
| 25 |
+
# Build a nested completer from the command registry
|
| 26 |
+
commands = get_command_registry()
|
| 27 |
+
|
| 28 |
+
# Create completion dictionary with subcommands and parameters
|
| 29 |
+
completion_dict = {}
|
| 30 |
+
for cmd_name, cmd_info in commands.items():
|
| 31 |
+
if cmd_info.get("subcommands"):
|
| 32 |
+
completion_dict[cmd_name] = {
|
| 33 |
+
subcmd: None for subcmd in cmd_info["subcommands"]
|
| 34 |
+
}
|
| 35 |
+
else:
|
| 36 |
+
completion_dict[cmd_name] = None
|
| 37 |
+
|
| 38 |
+
return NestedCompleter.from_nested_dict(completion_dict)
|
| 39 |
+
|
| 40 |
+
def main():
|
| 41 |
+
"""Main entry point for the Folio CLI."""
|
| 42 |
+
console = Console()
|
| 43 |
+
console.print("[bold cyan]Folio Interactive Shell[/bold cyan]")
|
| 44 |
+
console.print("Type 'help' for available commands.")
|
| 45 |
+
|
| 46 |
+
# Create history file in user's home directory
|
| 47 |
+
history_file = os.path.expanduser("~/.folio_history")
|
| 48 |
+
|
| 49 |
+
# Create session with auto-completion and history
|
| 50 |
+
session = PromptSession(
|
| 51 |
+
completer=create_completer(),
|
| 52 |
+
history=FileHistory(history_file)
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# Initialize application state
|
| 56 |
+
state = {
|
| 57 |
+
"portfolio_groups": None,
|
| 58 |
+
"portfolio_summary": None,
|
| 59 |
+
"last_simulation": None,
|
| 60 |
+
"loaded_portfolio": None,
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
# Try to load default portfolio
|
| 64 |
+
default_portfolio = "private-data/portfolio-private.csv"
|
| 65 |
+
try:
|
| 66 |
+
load_portfolio(default_portfolio, state, console)
|
| 67 |
+
except Exception as e:
|
| 68 |
+
console.print(f"[yellow]Could not load default portfolio: {e}[/yellow]")
|
| 69 |
+
console.print("[yellow]Use 'portfolio load <path>' to load a portfolio.[/yellow]")
|
| 70 |
+
|
| 71 |
+
# Main REPL loop
|
| 72 |
+
while True:
|
| 73 |
+
try:
|
| 74 |
+
# Get user input
|
| 75 |
+
text = session.prompt("folio> ")
|
| 76 |
+
|
| 77 |
+
if not text.strip():
|
| 78 |
+
continue
|
| 79 |
+
|
| 80 |
+
# Handle exit command directly
|
| 81 |
+
if text.strip().lower() == "exit":
|
| 82 |
+
if confirm_exit():
|
| 83 |
+
break
|
| 84 |
+
continue
|
| 85 |
+
|
| 86 |
+
# Process the command
|
| 87 |
+
execute_command(text, state, console)
|
| 88 |
+
|
| 89 |
+
except KeyboardInterrupt:
|
| 90 |
+
# Handle Ctrl+C
|
| 91 |
+
console.print("\n[yellow]Use 'exit' to exit the application.[/yellow]")
|
| 92 |
+
continue
|
| 93 |
+
except EOFError:
|
| 94 |
+
# Handle Ctrl+D
|
| 95 |
+
console.print("\nGoodbye!")
|
| 96 |
+
break
|
| 97 |
+
except Exception as e:
|
| 98 |
+
# Handle unexpected errors
|
| 99 |
+
console.print(f"[bold red]Error:[/bold red] {e!s}")
|
| 100 |
+
|
| 101 |
+
console.print("Goodbye!")
|
| 102 |
+
|
| 103 |
+
def confirm_exit():
|
| 104 |
+
"""Confirm exit with the user.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
True if the user confirms, False otherwise
|
| 108 |
+
"""
|
| 109 |
+
return confirm("Are you sure you want to exit?")
|
| 110 |
+
|
| 111 |
+
if __name__ == "__main__":
|
| 112 |
+
main()
|
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility functions for the Folio CLI.
|
| 3 |
+
|
| 4 |
+
This module provides helper functions used across the CLI.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import pandas as pd
|
| 11 |
+
|
| 12 |
+
from src.folio.portfolio import process_portfolio_data
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def load_portfolio(path, state, console=None):
|
| 16 |
+
"""Load a portfolio from a CSV file.
|
| 17 |
+
|
| 18 |
+
Args:
|
| 19 |
+
path: Path to the portfolio CSV file
|
| 20 |
+
state: Application state dictionary
|
| 21 |
+
console: Rich console for output
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
Tuple of (groups, summary)
|
| 25 |
+
"""
|
| 26 |
+
from rich.console import Console
|
| 27 |
+
|
| 28 |
+
if console is None:
|
| 29 |
+
console = Console()
|
| 30 |
+
|
| 31 |
+
# Resolve the path
|
| 32 |
+
if not os.path.isabs(path):
|
| 33 |
+
# Try relative to current directory
|
| 34 |
+
resolved_path = Path(os.getcwd()) / path
|
| 35 |
+
if not resolved_path.exists():
|
| 36 |
+
# Try relative to project root
|
| 37 |
+
project_root = Path(__file__).parent.parent.parent
|
| 38 |
+
resolved_path = project_root / path
|
| 39 |
+
else:
|
| 40 |
+
resolved_path = Path(path)
|
| 41 |
+
|
| 42 |
+
# Check if the file exists
|
| 43 |
+
if not resolved_path.exists():
|
| 44 |
+
raise FileNotFoundError(f"Portfolio file not found: {path}")
|
| 45 |
+
|
| 46 |
+
# Load the portfolio
|
| 47 |
+
console.print(f"Loading portfolio from [cyan]{resolved_path}[/cyan]...")
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
df = pd.read_csv(resolved_path)
|
| 51 |
+
groups, summary, _ = process_portfolio_data(df, update_prices=True)
|
| 52 |
+
|
| 53 |
+
# Update the state
|
| 54 |
+
state["portfolio_groups"] = groups
|
| 55 |
+
state["portfolio_summary"] = summary
|
| 56 |
+
state["loaded_portfolio"] = str(resolved_path)
|
| 57 |
+
|
| 58 |
+
console.print(
|
| 59 |
+
f"Loaded portfolio with [green]{len(groups)}[/green] position groups."
|
| 60 |
+
)
|
| 61 |
+
return groups, summary
|
| 62 |
+
except Exception as e:
|
| 63 |
+
raise RuntimeError(f"Error loading portfolio: {e!s}") from e
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def generate_spy_changes(range_pct, steps):
|
| 67 |
+
"""Generate a list of SPY changes for simulation.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
range_pct: Range of SPY changes in percent (e.g., 20.0 for ±20%)
|
| 71 |
+
steps: Number of steps in the simulation
|
| 72 |
+
|
| 73 |
+
Returns:
|
| 74 |
+
List of SPY changes as decimals (e.g., [-0.2, -0.1, 0.0, 0.1, 0.2])
|
| 75 |
+
"""
|
| 76 |
+
# Calculate the step size
|
| 77 |
+
step_size = (2 * range_pct) / (steps - 1) if steps > 1 else 0
|
| 78 |
+
|
| 79 |
+
# Generate the SPY changes
|
| 80 |
+
spy_changes = [-range_pct + i * step_size for i in range(steps)]
|
| 81 |
+
|
| 82 |
+
# Ensure we have a zero point
|
| 83 |
+
if 0.0 not in spy_changes and steps > 2:
|
| 84 |
+
# Find the closest point to zero and replace it with zero
|
| 85 |
+
closest_to_zero = min(spy_changes, key=lambda x: abs(x))
|
| 86 |
+
zero_index = spy_changes.index(closest_to_zero)
|
| 87 |
+
spy_changes[zero_index] = 0.0
|
| 88 |
+
|
| 89 |
+
# Convert to percentages
|
| 90 |
+
spy_changes = [change / 100.0 for change in spy_changes]
|
| 91 |
+
|
| 92 |
+
return spy_changes
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def find_position_group(ticker, portfolio_groups):
|
| 96 |
+
"""Find a position group by ticker.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
ticker: Ticker symbol to find
|
| 100 |
+
portfolio_groups: List of portfolio groups
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
PortfolioGroup if found, None otherwise
|
| 104 |
+
"""
|
| 105 |
+
if not portfolio_groups:
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
# Normalize the ticker
|
| 109 |
+
ticker = ticker.upper()
|
| 110 |
+
|
| 111 |
+
# Find the group
|
| 112 |
+
for group in portfolio_groups:
|
| 113 |
+
if group.ticker == ticker:
|
| 114 |
+
return group
|
| 115 |
+
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def parse_args(args, arg_specs):
|
| 120 |
+
"""Parse command arguments according to specifications.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
args: List of argument strings
|
| 124 |
+
arg_specs: Dictionary mapping argument names to specifications
|
| 125 |
+
Each specification is a dictionary with:
|
| 126 |
+
- type: Type to convert to (float, int, str, bool)
|
| 127 |
+
- default: Default value
|
| 128 |
+
- help: Help text
|
| 129 |
+
- aliases: List of aliases (e.g., ['-r', '--range'])
|
| 130 |
+
|
| 131 |
+
Returns:
|
| 132 |
+
Dictionary of parsed arguments
|
| 133 |
+
"""
|
| 134 |
+
# Initialize with default values
|
| 135 |
+
result = {name: spec.get("default") for name, spec in arg_specs.items()}
|
| 136 |
+
|
| 137 |
+
# Parse arguments
|
| 138 |
+
i = 0
|
| 139 |
+
while i < len(args):
|
| 140 |
+
arg = args[i]
|
| 141 |
+
|
| 142 |
+
# Check if this is a flag/option
|
| 143 |
+
if arg.startswith("-"):
|
| 144 |
+
# Find the matching argument
|
| 145 |
+
found = False
|
| 146 |
+
for name, spec in arg_specs.items():
|
| 147 |
+
aliases = spec.get("aliases", [])
|
| 148 |
+
if arg in aliases:
|
| 149 |
+
# This is a match
|
| 150 |
+
found = True
|
| 151 |
+
|
| 152 |
+
# Handle boolean flags
|
| 153 |
+
if spec.get("type") is bool:
|
| 154 |
+
result[name] = True
|
| 155 |
+
i += 1
|
| 156 |
+
break
|
| 157 |
+
|
| 158 |
+
# Handle value arguments
|
| 159 |
+
if i + 1 < len(args):
|
| 160 |
+
try:
|
| 161 |
+
# Convert to the specified type
|
| 162 |
+
value = args[i + 1]
|
| 163 |
+
if spec.get("type") is float:
|
| 164 |
+
result[name] = float(value)
|
| 165 |
+
elif spec.get("type") is int:
|
| 166 |
+
result[name] = int(value)
|
| 167 |
+
else:
|
| 168 |
+
result[name] = value
|
| 169 |
+
i += 2
|
| 170 |
+
break
|
| 171 |
+
except ValueError as ve:
|
| 172 |
+
raise ValueError(
|
| 173 |
+
f"Invalid value for {arg}: {args[i + 1]}"
|
| 174 |
+
) from ve
|
| 175 |
+
else:
|
| 176 |
+
raise ValueError(f"Missing value for {arg}")
|
| 177 |
+
|
| 178 |
+
if not found:
|
| 179 |
+
raise ValueError(f"Unknown argument: {arg}")
|
| 180 |
+
else:
|
| 181 |
+
# This is a positional argument
|
| 182 |
+
# For now, we'll just skip it
|
| 183 |
+
i += 1
|
| 184 |
+
|
| 185 |
+
return result
|