dystomachina commited on
Commit
f56729d
·
1 Parent(s): 286e013

feat: Implement Folio CLI Phase 1 - Interactive Shell

Browse files

This 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 CHANGED
@@ -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
 
docs/focli-mvp-v2.md ADDED
@@ -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.
scripts/focli.py ADDED
@@ -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()
src/focli/__init__.py ADDED
@@ -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"
src/focli/commands/__init__.py ADDED
@@ -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")
src/focli/commands/help.py ADDED
@@ -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("")
src/focli/commands/portfolio.py ADDED
@@ -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())
src/focli/commands/position.py ADDED
@@ -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())
src/focli/commands/simulate.py ADDED
@@ -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())
src/focli/formatters.py ADDED
@@ -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)
src/focli/shell.py ADDED
@@ -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()
src/focli/utils.py ADDED
@@ -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