dystomachina commited on
Commit
ce4bc73
·
0 Parent(s):

Initial commit for Folio project

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +50 -0
  2. .env.example +15 -0
  3. .gitattributes +1 -0
  4. .gitignore +84 -0
  5. .pre-commit-config.yaml +32 -0
  6. BEST-PRACTICES.md +249 -0
  7. DOCKER.md +128 -0
  8. Dockerfile +43 -0
  9. LICENSE +21 -0
  10. Makefile +296 -0
  11. README.md +122 -0
  12. activate-venv.sh +4 -0
  13. docker-compose.test.yml +20 -0
  14. docker-compose.yml +17 -0
  15. pyproject.toml +94 -0
  16. requirements-dev.txt +30 -0
  17. requirements.txt +25 -0
  18. scripts/README.md +77 -0
  19. scripts/check_beta.py +175 -0
  20. scripts/clean.sh +27 -0
  21. scripts/compare_exposures_ui.py +174 -0
  22. scripts/debug_portfolio.py +146 -0
  23. scripts/folio-simulator.py +903 -0
  24. scripts/install-reqs.sh +74 -0
  25. scripts/run_mlflow.py +44 -0
  26. scripts/setup-venv.sh +65 -0
  27. scripts/validate_pnl.py +414 -0
  28. src/__init__.py +8 -0
  29. src/fmp.py +243 -0
  30. src/folio/README.md +119 -0
  31. src/folio/__init__.py +3 -0
  32. src/folio/__main__.py +4 -0
  33. src/folio/ai_utils.py +111 -0
  34. src/folio/app.py +1073 -0
  35. src/folio/assets/components/ai.css +659 -0
  36. src/folio/assets/components/buttons.css +126 -0
  37. src/folio/assets/components/cards.css +158 -0
  38. src/folio/assets/components/charts.css +91 -0
  39. src/folio/assets/components/dash.css +75 -0
  40. src/folio/assets/components/forms.css +110 -0
  41. src/folio/assets/components/modals.css +109 -0
  42. src/folio/assets/components/tables.css +152 -0
  43. src/folio/assets/js/prevent_chart_scroll.js +44 -0
  44. src/folio/assets/layout.css +369 -0
  45. src/folio/assets/main.css +163 -0
  46. src/folio/assets/sample-portfolio.csv +31 -0
  47. src/folio/assets/theme.css +72 -0
  48. src/folio/callbacks/__init__.py +4 -0
  49. src/folio/cash_detection.py +117 -0
  50. src/folio/chart_data.py +564 -0
.dockerignore ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Version control
2
+ .git
3
+ .gitignore
4
+
5
+ # Python cache files
6
+ __pycache__/
7
+ *.py[cod]
8
+ *$py.class
9
+ .pytest_cache/
10
+ .coverage
11
+ htmlcov/
12
+
13
+ # Virtual environment
14
+ venv/
15
+ env/
16
+ ENV/
17
+
18
+ # IDE files
19
+ .vscode/
20
+ .idea/
21
+
22
+ # Logs
23
+ logs/
24
+ *.log
25
+
26
+ # Cache directories
27
+ .cache*/
28
+ .tmp/
29
+
30
+ # Local data
31
+ data/
32
+ *.csv
33
+
34
+ # Build artifacts
35
+ dist/
36
+ build/
37
+ *.egg-info/
38
+
39
+ # Docker files (not needed in build context)
40
+ Dockerfile.dev
41
+ docker-compose.dev.yml
42
+
43
+ # Documentation
44
+ # Keep docs/ for Hugging Face deployment
45
+ # docs/
46
+ *.md
47
+ !README.md
48
+
49
+ # personal notes
50
+ -executive.md
.env.example ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment variables for the Folio application
2
+
3
+ # API Keys
4
+ # FMP (FinancialModelingPrep) API - not required when using yfinance:
5
+ FMP_API_KEY=your_fmp_api_key_here
6
+
7
+ # Used for portfolio analysis premium feature
8
+ GEMINI_API_KEY=your_gemini_api_key_here
9
+
10
+ # Application Settings
11
+ PORT=8050
12
+ DEBUG=false
13
+
14
+ # Data Source Configuration
15
+ DATA_SOURCE=yfinance
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.pkl filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python virtual environments
2
+ venv/
3
+ env/
4
+ ENV/
5
+ .env
6
+
7
+ # Cache directories
8
+ .cache*/
9
+ __pycache__/
10
+ *.py[cod]
11
+ *$py.class
12
+ .pytest_cache/
13
+ .ruff_cache/
14
+ .mypy_cache/
15
+
16
+ # Distribution / packaging
17
+ dist/
18
+ build/
19
+ *.egg-info/
20
+
21
+ # Jupyter Notebook
22
+ .ipynb_checkpoints
23
+
24
+ # IDE specific files
25
+ .idea/
26
+ .vscode/
27
+ *.swp
28
+ *.swo
29
+
30
+ # OS specific files
31
+ .DS_Store
32
+ Thumbs.db
33
+
34
+ # Log files
35
+ *.log
36
+ logs/**
37
+ mlruns/**
38
+
39
+ # Local configuration
40
+ .env*
41
+ !.env.example
42
+ .env.local
43
+ .env.development.local
44
+ .env.test.local
45
+ .env.production.local
46
+
47
+ # Model files (optional, uncomment if you want to ignore model files)
48
+ # models/*.pkl
49
+
50
+ # Data files (optional, uncomment if you want to ignore data files)
51
+ # data/
52
+
53
+ # Test coverage
54
+ .coverage
55
+ htmlcov/
56
+ coverage.xml
57
+ .coverage.*
58
+
59
+ # this is where we store the models
60
+ models/
61
+ .archive/
62
+
63
+ # Temporary files
64
+ .tmp/
65
+ *.tmp
66
+ *~
67
+ *.bak
68
+
69
+ # Lab private files
70
+ src/lab/portfolio.csv
71
+ **/portfolio.csv # Ignore any portfolio.csv files in any directory
72
+
73
+ # augment specific
74
+ .augment/
75
+
76
+ # latest AI generated commit message here
77
+ .commit-msg.md
78
+
79
+ # Personal notes file
80
+ .executive.md
81
+ private-data/*
82
+
83
+ # Documentation folder (temporary exclusion)
84
+ docs/
.pre-commit-config.yaml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.11.4
4
+ hooks:
5
+ - id: ruff
6
+ args: [--fix, --unsafe-fixes]
7
+ - id: ruff-format
8
+
9
+ - repo: https://github.com/pre-commit/pre-commit-hooks
10
+ rev: v4.5.0
11
+ hooks:
12
+ - id: trailing-whitespace
13
+ - id: end-of-file-fixer
14
+ - id: check-yaml
15
+ - id: check-added-large-files
16
+ - id: check-toml
17
+ - id: check-json
18
+ - id: debug-statements
19
+ - id: check-merge-conflict
20
+
21
+ - repo: https://github.com/python-jsonschema/check-jsonschema
22
+ rev: 0.28.0
23
+ hooks:
24
+ - id: check-github-workflows
25
+ args: ["--verbose"]
26
+
27
+ - repo: https://github.com/python-poetry/poetry
28
+ rev: 1.8.2
29
+ hooks:
30
+ - id: poetry-check
31
+ stages: [commit]
32
+ files: ^pyproject.toml$
BEST-PRACTICES.md ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Best Practices for Folio Project
2
+
3
+ This document is the single source of truth for the Folio project's best practices. Keep it updated as new best practices emerge.
4
+
5
+ ---
6
+
7
+ # 📋 CORE TENETS
8
+ 0. NO FAKE DATA + FAIL FAST: if we hide errors by giving default or fake data, it could mislead hedge fund clients and cost tons of $$$. Stop using default values and fake data when handling errors. FAIL FAST and transparently when the app fails.
9
+ 1. **SIMPLICITY** - Prefer simple, focused solutions over complex ones. Question every bit of added complexity.
10
+ 2. **PRECISION** - Make the smallest necessary changes. Don't modify unrelated code or remove what you don't understand.
11
+ 3. **RELIABILITY** - Debug thoroughly and test rigorously. AVOID SWALLOW EXCEPTIONS or hiding errors
12
+ 4. **USABILITY** - Prioritize user experience. Design for clarity and ease of use, not technical elegance alone.
13
+ 5. **SAFETY** - Never modify Git history or hide Git commands in scripts. All version control operations must be explicit, visible, and performed manually by the user.
14
+
15
+ ---
16
+
17
+ ## Key Principles
18
+ *When in doubt, follow the CORE TENETS above.*
19
+ This section adds specific guidelines for various aspects of the project.
20
+
21
+ ### � Development Workflow
22
+ *Supports PRECISION and SIMPLICITY*
23
+ Follow consistent development practices to maintain code quality and developer productivity.
24
+
25
+ - **Code Changes**:
26
+ - **Incremental Changes**: Make small, focused changes rather than large rewrites
27
+ - **Performance**: Optimize only after identifying actual bottlenecks
28
+
29
+ - **File Management**:
30
+ - **Version Control**: Update .gitignore for new temporary files or directories
31
+ - **Temporary Files**: Store temporary files in the `.tmp` directory
32
+ - **Cache Files**: Use hidden directories (`.cache_*`) for cache files
33
+
34
+ - **Version Control**:
35
+ - **Never Commit Directly**: Let the user handle all git operations
36
+ - **⚠️ NEVER USE GIT IN SCRIPTS**: Do not write scripts that use git commands - this can lead to catastrophic data loss and irreversible history modification
37
+ - **Manual Git Operations**: All git operations must be performed manually by the user, never automated
38
+ - **NEVER CREATE PRs WITHOUT BEING ASKED**: Do not create pull requests unless explicitly requested by the user
39
+ - **Preserve Git History**: Never modify Git history without explicit user consent and understanding of the consequences
40
+ - **Transparent Operations**: All version control suggestions must be explicit, visible, and explained clearly
41
+ - **Preferred Diff Tool**: Use `git aidiff` for code reviews with AI agents - this outputs complete diffs without requiring scrolling
42
+ - If not available, set up with: `git config --global alias.aidiff "!git --no-pager diff --unified=3 --color=never"`
43
+ - This produces AI-friendly output that shows the entire diff at once
44
+
45
+ - **Commit Messages**:
46
+ - **Delivery Format**: When asked to write a commit message, provide it directly as a markdown code block (```...```) in the chat, not as a separate file
47
+ - **Conventional Format**: Follow the conventional commits format with a type prefix
48
+ - **Message Structure**: Use a concise title (50 chars max) followed by a blank line and then a detailed body
49
+ - **Title Format**: `<type>: <concise description in imperative mood>`
50
+ - **Types in Priority Order**: Always use the highest impact prefix when multiple types apply
51
+ 1. `feat`: New features or significant enhancements (highest priority)
52
+ 2. `fix`: Bug fixes or correcting errors
53
+ 3. `security`: Security-related changes
54
+ 4. `perf`: Performance improvements
55
+ 5. `refactor`: Code restructuring without changing functionality
56
+ 6. `test`: Adding or modifying tests
57
+ 7. `docs`: Documentation updates only
58
+ 8. `style`: Formatting, white-space, etc. (no code change)
59
+ 9. `build`: Build system or external dependency changes
60
+ 10. `ci`: CI configuration changes
61
+ 11. `chore`: Maintenance tasks, no production code change (lowest priority)
62
+ - **Examples**:
63
+ - `feat: add user authentication system`
64
+ - `fix: resolve database connection timeout issue`
65
+ - `docs: update deployment instructions`
66
+ - **Title Guidelines**:
67
+ - Use imperative mood ("Add feature" not "Added feature")
68
+ - Capitalize the first word after the type prefix
69
+ - Don't end with a period
70
+ - Keep under 50 characters
71
+ - Be specific about what changed
72
+ - **Body Guidelines**:
73
+ - Explain the "what" and "why" of the change, not the "how"
74
+ - Wrap text at 72 characters
75
+ - Use bullet points for multiple points
76
+ - Reference issues or tickets where applicable
77
+ - Don't use phrases like "This commit..."
78
+
79
+ ### �💻 Implementation
80
+ *Supports SIMPLICITY and RELIABILITY*
81
+ Write code that is clear, maintainable, and robust against edge cases.
82
+
83
+ - **Code Style**:
84
+ - **Imports at Top**: ALWAYS place all imports at the top of the file, never in the middle or at the bottom
85
+ - **Avoid Hardcoding**: Use pattern-based detection instead of hardcoding specific values
86
+ - **Generic Solutions**: Prefer solutions that work for all cases over special-case handling
87
+ - **Readability**: Prioritize readable code over clever optimizations
88
+ - **Code Quality**: Run `make lint` regularly to identify and fix code quality issues
89
+ - **Unused Code**: Avoid unused imports, functions, and variables; prefix intentionally unused variables with underscore
90
+ - **Clean Code**: Remove commented-out code and fix exception handling issues
91
+
92
+ - **Configuration**:
93
+ - **External Config**: Use configuration files for values that might change
94
+ - **Sensible Defaults**: Provide reasonable defaults for all configurable options
95
+ - **Validate Inputs**: Check and validate all external inputs and configuration
96
+
97
+ - **Framework-Specific Patterns**:
98
+ - **Dash Callback Registration**: Always register callbacks in a centralized location in the app creation process
99
+ - **Explicit Registration**: Never rely on side effects of imports for critical functionality like callback registration
100
+ - **Avoid Duplicate Registration**: Be careful about registering callbacks multiple times, which can cause conflicts
101
+ - **Component Documentation**: Document the relationship between components and their callbacks
102
+
103
+ - **Dependency Management**:
104
+ - **Update Requirements**: ALWAYS update `requirements.txt` when adding new imports or dependencies
105
+ - **Deployment Verification**: Test deployments after adding new dependencies to ensure they work in all environments
106
+ - **Minimal Dependencies**: Only add dependencies that are absolutely necessary
107
+ - **Version Pinning**: Pin versions for stability (`==`) or use minimum version constraints (`>=`) as appropriate
108
+ - **Document Dependencies**: Add comments explaining what each dependency is used for
109
+ - **Check Imports**: Regularly audit imports to ensure all are properly listed in requirements
110
+
111
+ ### 🛡️ Error Handling and Logging
112
+ *Supports RELIABILITY and PRECISION*
113
+ Handle errors gracefully and log information that helps diagnose issues quickly.
114
+
115
+ - **Exception Handling**:
116
+ - **Use Custom Exceptions**: Use application-specific exceptions from `src/folio/exceptions.py`
117
+ - **Don't Swallow Exceptions**: Never catch exceptions without proper handling or re-raising
118
+ - **Fail Fast**: Fail early and visibly for critical errors rather than continuing with incorrect behavior
119
+ - **Only Handle Errors You Can Handle**: Only catch exceptions that you can meaningfully handle; let others propagate
120
+ - **Specific Exception Types**: Catch specific exception types rather than using broad `except Exception`
121
+ - **Distinguish Error Types**: Treat programming errors (ImportError, NameError, AttributeError, TypeError, SyntaxError) differently from data/operational errors (ValueError, KeyError)
122
+ - **No Default Values for Critical Errors**: Don't use default values (like beta=1.0) for critical calculation errors; fail instead
123
+ - **Context in Exceptions**: Use `raise ... from e` to preserve exception context and chain
124
+ - **User-Friendly Messages**: Provide clear, actionable error messages to users while logging technical details
125
+ - **Use Error Utilities**: Leverage decorators and utilities in `src/folio/error_utils.py`
126
+
127
+ - **Logging Best Practices**:
128
+ - **Structured Messages**: Include context (e.g., "Failed to process AAPL: missing price data")
129
+ - **Include Stack Traces**: For unexpected errors, use `exc_info=True`
130
+ - **Distinguish States vs. Errors**: Log normal states as DEBUG/INFO, not as errors
131
+ - **Verification Logs**: Add logs that verify critical operations like callback registration by checking the app's callback_map
132
+
133
+ - **Log Levels**:
134
+ - **DEBUG**: Detailed flow information ("Processing portfolio entry 5 of 20")
135
+ - **INFO**: Normal application events ("Portfolio loaded successfully")
136
+ - **WARNING**: Potential issues requiring attention ("Using cached data: API unavailable")
137
+ - **ERROR**: Actual errors affecting functionality ("Failed to calculate beta for AAPL")
138
+ - **CRITICAL**: Severe errors preventing operation ("Database connection failed")
139
+
140
+ - **Log Monitoring**:
141
+ - **Check Latest Logs**: Always check the latest logs in the `logs/` directory after running tests or the application
142
+ - **Application Logs**: Review `logs/folio_latest.log` after running the application to identify errors
143
+ - **Test Logs**: Check `logs/test_latest.log` after running tests to catch test failures and errors
144
+ - **Error Investigation**: When errors occur, examine the logs first for detailed error messages and stack traces
145
+
146
+ - **Regression Analysis**:
147
+ - **Root Cause Investigation**: Always use `git blame` to understand what caused a regression
148
+ - **Document Findings**: Record regression analysis in devlogs to prevent repeating mistakes
149
+ - **Follow Process**: See [regression-analysis.md](docs/regression-analysis.md) for the complete process
150
+
151
+ ### 🚨 Testing
152
+ *Supports RELIABILITY and PRECISION*
153
+ Thorough testing prevents bugs and ensures code behaves as expected in all scenarios.
154
+
155
+ - **Testing Workflow**:
156
+ - **Run Linter Frequently**: Run `make lint` very frequently - it's cheap, fast, and effective at catching issues early
157
+ - **Always Test Before Completion**: Run `make test` before considering any work complete - this is essential
158
+ - **Check Test Logs**: Always review `logs/test_latest.log` after running tests to identify failures
159
+ - **Fix Linting Issues**: Address linting errors before committing code to maintain code quality
160
+ - **Automated vs. Manual Testing**:
161
+ - For AI assistants: Only run `make test` and `make lint` to verify changes
162
+ - NEVER launch the application with `make folio` or `make portfolio` - leave UI testing to human users
163
+ - Instead, provide detailed instructions on what UI changes to test and how to verify them
164
+ - Add unit tests for new functionality instead of manual testing when possible
165
+ - **Testing Instructions**:
166
+ - Provide clear, step-by-step instructions for the user to test changes
167
+ - Include specific UI elements to check and expected behavior
168
+ - Describe what success looks like and potential issues to watch for
169
+ - Format as a checklist that the user can follow easily
170
+ - **Review App Logs**: Check `logs/test_latest.log` after running tests to catch errors
171
+ - **Test Real Data**: Use `src/lab/portfolio.csv` for testing with real portfolio data
172
+
173
+ - **Test Organization**:
174
+ - **1:1 Test File Mapping**: Tests should be 1:1 with the code file they are testing
175
+ - For each source file (e.g., `util.py`), create a corresponding test file (e.g., `test_util.py`)
176
+ - Maintain the same directory structure in tests as in the source code
177
+ - This makes it easy to find tests for specific functionality
178
+ - **New Functionality, New Tests**: Always write tests for new functionality added to the codebase
179
+ - Tests should be written alongside the implementation, not as an afterthought
180
+ - No new feature is complete without corresponding tests
181
+
182
+ - **Testing Strategy**:
183
+ - **Test Behavior**: Focus on functionality, not implementation details
184
+ - **Edge Cases**: Test boundary conditions (empty inputs, maximum values, etc.)
185
+ - **Regression Tests**: Add tests for bugs to prevent recurrence
186
+ - **Test Coverage**: Aim for high coverage of critical paths and business logic
187
+ - **Test New Functionality**: Always write tests for new features and components
188
+ - **Test Naming**: Name tests specifically to the method/module being tested
189
+ - **Test Public API**: Test only public functions to avoid coupling tests to implementation details
190
+ - **Test Independence**: Each test should be independent and not rely on other tests
191
+ - **Fail-First Testing**: Write tests that fail first to confirm they're testing the right thing
192
+ - **Test Callback Registration**: For Dash apps, always include tests that verify callbacks are properly registered
193
+
194
+ - **Writing Tests**:
195
+ - **Test Structure**: Follow the Arrange-Act-Assert pattern
196
+ - **Mock External Dependencies**: Use mocks for external services, APIs, and databases
197
+ - **Test Edge Cases**: Include tests for error conditions and boundary cases
198
+ - **Parameterized Tests**: Use pytest's parameterize for testing multiple inputs
199
+ - **Fixtures**: Create fixtures for common test setup
200
+ - **Test Isolation**: Reset state between tests to prevent test interdependence
201
+ - **Never Change Test Logic**: Fix implementation to make tests pass, not the other way around
202
+ - **Simple Tests**: Keep tests simple and focused on specific behaviors
203
+ - **Avoid Implementation Details**: Don't test implementation details like DOM structure that might change
204
+
205
+ ### 🤖 Agent Interactions
206
+ *Supports PRECISION and USABILITY*
207
+ Effective communication with AI agents ensures efficient development and accurate implementation.
208
+
209
+ - **Question Handling**:
210
+ - **Direct Answers**: When asked a question, answer directly and honestly without writing code
211
+ - **Critical Thinking**: Be critical of assumptions or leading questions in the prompt
212
+ - **Concise Responses**: Provide straightforward responses without unnecessary elaboration
213
+ - **No Automatic Implementation**: Don't jump into implementation unless specifically requested
214
+ - **Clarify Ambiguity**: Ask for clarification when the question is unclear rather than making assumptions
215
+ - **Honest Assessment**: Provide honest feedback about technical feasibility and potential issues
216
+
217
+ - **Implementation Requests**:
218
+ - **Confirm Understanding**: Verify understanding of the request before implementing
219
+ - **Plan First**: Create a detailed plan before making changes
220
+ - **Focused Changes**: Make only the changes requested, not additional "improvements"
221
+ - **Test Verification**: Always suggest testing after implementation
222
+
223
+ ### 📝 Documentation
224
+ *Supports USABILITY and RELIABILITY*
225
+ Clear, accurate documentation helps onboard new developers and maintain institutional knowledge.
226
+
227
+ - **Documentation Standards**:
228
+ - **Date Accuracy**: Always use the `date` command for current dates in documents
229
+ - **Consistent Format**: Follow existing formats and naming conventions
230
+ - **Single Source of Truth**: Maintain one authoritative source for each type of documentation
231
+
232
+ - **Documentation Maintenance**:
233
+ - **Keep Updated**: Update documentation when code changes
234
+ - **Code Comments**: Document complex logic and "why" decisions, not obvious code
235
+ - **README Files**: Ensure each major component has a clear, concise README
236
+ - **Component Documentation**: Document component relationships and callback dependencies
237
+
238
+ - **Development Planning**:
239
+ - **Create Devplans**: Document detailed plans in `docs/devplan/` for significant features, design changes, or deployments
240
+ - **Plan Structure**: Include overview, implementation steps, timeline, and considerations
241
+ - **Phased Approach**: Break complex changes into manageable phases with testing checkpoints
242
+ - **Deployment Plans**: For deployment-related changes, include hosting options, infrastructure requirements, and security considerations
243
+
244
+ - **Development Logging**:
245
+ - **Update Devlogs**: Document completed changes in `docs/devlog/` after implementing major features or changes
246
+ - **Devlog Format**: Include date, summary, implementation details, and lessons learned
247
+ - **Technical Details**: Document key technical decisions, challenges overcome, and solutions implemented
248
+ - **Future Considerations**: Note any follow-up tasks or potential improvements identified during implementation
249
+ - **Retrospectives**: Create retrospectives in `docs/retrospective/` for significant issues or challenges to document lessons learned
DOCKER.md ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker Setup for Folio
2
+
3
+ This document provides instructions for running the Folio application using Docker.
4
+
5
+ ## Prerequisites
6
+
7
+ - [Docker](https://docs.docker.com/get-docker/) installed on your system
8
+ - [Docker Compose](https://docs.docker.com/compose/install/) (usually included with Docker Desktop)
9
+
10
+ ## Quick Start
11
+
12
+ 1. **Create a .env file** (optional)
13
+
14
+ Copy the example environment file if you want to customize settings:
15
+
16
+ ```bash
17
+ cp .env.example .env
18
+ # Edit .env to customize settings if needed
19
+ ```
20
+
21
+ Note: Since we're using yfinance as the default data source, no API keys are required.
22
+
23
+ 2. **Build and run with Docker Compose**
24
+
25
+ ```bash
26
+ # Build and start the container in detached mode
27
+ make docker-up
28
+ ```
29
+
30
+ After successful startup, you'll see a message with the URL where you can access the application.
31
+
32
+ 3. **Access the application**
33
+
34
+ Open your browser and navigate to:
35
+
36
+ ```
37
+ http://localhost:8050
38
+ ```
39
+
40
+ 4. **View logs**
41
+
42
+ To monitor the application logs in real-time:
43
+
44
+ ```bash
45
+ make docker-logs
46
+ ```
47
+
48
+ Press Ctrl+C to stop viewing logs.
49
+
50
+ 5. **Stop the application**
51
+
52
+ ```bash
53
+ make docker-down
54
+ ```
55
+
56
+ ## Docker Commands Reference
57
+
58
+ The following Make commands are available for working with Docker:
59
+
60
+ | Command | Description |
61
+ |---------|-------------|
62
+ | `make docker-build` | Build the Docker image |
63
+ | `make docker-run` | Run the Docker container |
64
+ | `make docker-up` | Start the application with docker-compose |
65
+ | `make docker-down` | Stop the docker-compose services |
66
+ | `make docker-logs` | Tail the Docker logs |
67
+ | `make docker-test` | Run tests in a Docker container |
68
+
69
+ ### Manual Docker Commands
70
+
71
+ If you prefer to use Docker directly without Make:
72
+
73
+ 1. **Build the Docker image**
74
+
75
+ ```bash
76
+ docker build -t folio:latest .
77
+ ```
78
+
79
+ 2. **Run the Docker container**
80
+
81
+ ```bash
82
+ docker run -p 8050:8050 --env-file .env folio:latest
83
+ ```
84
+
85
+ 3. **Use Docker Compose directly**
86
+
87
+ ```bash
88
+ # Start services
89
+ docker-compose up -d
90
+
91
+ # View logs
92
+ docker-compose logs -f
93
+
94
+ # Stop services
95
+ docker-compose down
96
+ ```
97
+
98
+ ## Troubleshooting
99
+
100
+ - **Port conflicts**: If port 8050 is already in use, modify the `PORT` environment variable in your `.env` file and update the port mapping in `docker-compose.yml`.
101
+
102
+ - **Data source issues**: By default, the application uses yfinance as the data source. If you want to use FMP instead, you'll need to set the FMP_API_KEY in your `.env` file and change DATA_SOURCE to 'fmp'.
103
+
104
+ - **Volume mounting**: If you're making changes to the code and want to see them reflected immediately, ensure the volumes in `docker-compose.yml` are correctly mapping your local directories.
105
+
106
+ - **Dependencies**: The Docker image uses `requirements.txt` for its dependencies. If you need to add or update dependencies, modify this file instead of editing the Dockerfile directly.
107
+
108
+ - **Development dependencies**: For development and testing, the Docker image can also install dependencies from `requirements-dev.txt`. These are installed automatically when running `make docker-test`.
109
+
110
+ - **API Keys**: Sensitive data like API keys should be passed at runtime using environment variables or the `.env` file, not hardcoded in the Dockerfile.
111
+
112
+ ## Testing in Docker
113
+
114
+ To run tests in a Docker container:
115
+
116
+ ```bash
117
+ make docker-test
118
+ ```
119
+
120
+ This will build a Docker image with development dependencies and run the test suite inside the container.
121
+
122
+ ## Next Steps
123
+
124
+ After successfully running the application locally with Docker, you can consider:
125
+
126
+ 1. Setting up CI/CD with GitHub Actions
127
+ 2. Deploying to a hosting platform like Hugging Face Spaces
128
+ 3. Implementing additional Docker configurations for production environments
Dockerfile ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Set environment variables
6
+ ENV PYTHONPATH=/app
7
+ # Use PORT 7860 for Hugging Face Spaces, 8050 for local development
8
+ # The application will check for HF_SPACE environment variable to determine the environment
9
+ ENV PORT=8050
10
+ ENV HF_SPACE=1
11
+ # No need to set LOG_LEVEL as it will be determined from folio.yaml based on environment
12
+ # Note: Sensitive environment variables like GEMINI_API_KEY should be passed at runtime
13
+ # rather than build time for security reasons
14
+
15
+ # Flag to install development dependencies
16
+ ARG INSTALL_DEV=false
17
+
18
+ # Install only the necessary system dependencies
19
+ RUN apt-get update && \
20
+ apt-get install -y --no-install-recommends gcc && \
21
+ rm -rf /var/lib/apt/lists/*
22
+
23
+ # Copy requirements files
24
+ COPY requirements.txt .
25
+ COPY requirements-dev.txt .
26
+
27
+ # Install required packages
28
+ RUN pip install --no-cache-dir -r requirements.txt && \
29
+ if [ "$INSTALL_DEV" = "true" ]; then \
30
+ echo "Installing development dependencies..." && \
31
+ pip install --no-cache-dir -r requirements-dev.txt; \
32
+ fi
33
+
34
+ # Copy all necessary application code
35
+ COPY src ./src
36
+ COPY config ./config
37
+
38
+ # Expose both ports (7860 for Hugging Face, 8050 for local)
39
+ EXPOSE 7860 8050
40
+
41
+ # Run the application with Gunicorn for production deployment
42
+ # The command will determine the correct port based on environment
43
+ CMD ["sh", "-c", "if [ -n \"$HF_SPACE\" ]; then PORT=7860; fi && gunicorn --bind 0.0.0.0:$PORT --workers 2 --timeout 60 src.folio.app:server"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 omninmo Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
Makefile ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Makefile for folio project
2
+
3
+ # Variables
4
+ SHELL := /bin/bash
5
+ PYTHON := python3
6
+ VENV_DIR := venv
7
+ SCRIPTS_DIR := scripts
8
+ LOGS_DIR := logs
9
+ SRC_DIR := src.v2
10
+ TIMESTAMP := $(shell date +%Y%m%d_%H%M%S)
11
+ PORT := 5000
12
+
13
+ # Default target
14
+ .PHONY: help
15
+ help:
16
+ @echo "Available targets:"
17
+ @echo " help - Show this help message"
18
+ @echo " env - Set up and activate a virtual environment"
19
+ @echo " install - Install dependencies and set script permissions"
20
+ @echo " train - Train the model (use --sample for training with sample data)"
21
+ @echo " predict - Run predictions using console app (usage: make predict [NVDA])"
22
+ @echo " mlflow - Start the MLflow UI to view training results (optional: make mlflow PORT=5001)"
23
+ @echo " folio - Start the portfolio dashboard with debug mode enabled"
24
+ @echo " Options: portfolio=path/to/file.csv (use custom portfolio file)"
25
+ @echo " log=LEVEL (set logging level: DEBUG, INFO, WARNING, ERROR)"
26
+ @echo " portfolio - Start the portfolio dashboard with sample portfolio and debug mode"
27
+ @echo " Options: log=LEVEL (set logging level: DEBUG, INFO, WARNING, ERROR)"
28
+ @echo " simulator - Run the SPY simulator to analyze portfolio behavior under different market conditions"
29
+ @echo " Options: range=VALUE (SPY change range, default: 20.0)"
30
+ @echo " steps=VALUE (number of steps, default: 41)"
31
+ @echo " focus=TICKER (focus on specific ticker(s), comma-separated)"
32
+ @echo " detailed=1 (show detailed analysis for all positions)"
33
+ @echo " clean - Clean up generated files and caches"
34
+ @echo " Options: --cache (also clear data cache)"
35
+ @echo " lint - Run type checker and linter"
36
+ @echo " Options: --fix (auto-fix linting issues)"
37
+ @echo " test - Run all unit tests in the tests directory"
38
+ @echo " test-e2e - Run end-to-end tests against real portfolio data"
39
+ @echo " docker-build - Build the Docker image"
40
+ @echo " docker-run - Run the Docker container"
41
+ @echo " docker-up - Start the application with docker-compose"
42
+ @echo " docker-down - Stop the docker-compose services"
43
+ @echo " docker-logs - Tail the Docker logs"
44
+ @echo " docker-test - Run tests in a Docker container"
45
+
46
+ # Set up virtual environment
47
+ .PHONY: env
48
+ env:
49
+ @echo "Setting up virtual environment..."
50
+ @bash $(SCRIPTS_DIR)/setup-venv.sh
51
+ @echo "Activating virtual environment..."
52
+ @echo "NOTE: To use the virtual environment in your current shell, run: source activate-venv.sh"
53
+ @echo "The virtual environment will be automatically activated for all make commands."
54
+
55
+ # Install dependencies
56
+ .PHONY: install
57
+ install:
58
+ @echo "Installing dependencies..."
59
+ @if [ ! -d "$(VENV_DIR)" ]; then \
60
+ echo "Virtual environment not found. Please run 'make env' first."; \
61
+ exit 1; \
62
+ fi
63
+ @mkdir -p $(LOGS_DIR)
64
+ @(echo "=== Installation Log $(TIMESTAMP) ===" && \
65
+ echo "Starting installation at: $$(date)" && \
66
+ (source $(VENV_DIR)/bin/activate && \
67
+ $(PYTHON) -m pip install --upgrade pip && \
68
+ bash $(SCRIPTS_DIR)/install-reqs.sh) 2>&1 && \
69
+ echo "Setting script permissions..." && \
70
+ chmod +x $(SCRIPTS_DIR)/*.sh && \
71
+ chmod +x $(SCRIPTS_DIR)/*.py && \
72
+ echo "Installation complete at: $$(date)") | tee $(LOGS_DIR)/install_$(TIMESTAMP).log
73
+ @echo "Installation log saved to: $(LOGS_DIR)/install_$(TIMESTAMP).log"
74
+
75
+ # Train the model
76
+ .PHONY: train
77
+ train:
78
+ @echo "Training the model..."
79
+ @source $(VENV_DIR)/bin/activate && \
80
+ PYTHONPATH=. $(PYTHON) -m $(SRC_DIR).train $(if $(findstring --sample,$(MAKECMDGOALS)),--force-sample,)
81
+
82
+ # Run predictions
83
+ .PHONY: predict
84
+ predict:
85
+ @echo "Running predictions..."
86
+ @source $(VENV_DIR)/bin/activate && \
87
+ if [ -n "$(filter-out $@,$(MAKECMDGOALS))" ]; then \
88
+ TICKERS=$$(echo "$(filter-out $@,$(MAKECMDGOALS))" | tr ',' ' '); \
89
+ echo "Predicting for: $$TICKERS"; \
90
+ PYTHONPATH=. $(PYTHON) -m $(SRC_DIR).console_app --tickers $$TICKERS; \
91
+ else \
92
+ echo "Running predictions on watchlist..."; \
93
+ PYTHONPATH=. $(PYTHON) -m $(SRC_DIR).console_app; \
94
+ fi
95
+
96
+ # Start the MLflow UI
97
+ .PHONY: mlflow
98
+ mlflow:
99
+ @echo "Starting MLflow UI on http://127.0.0.1:$(PORT)..."
100
+ @echo "Press Ctrl+C to stop the server."
101
+ @source $(VENV_DIR)/bin/activate && \
102
+ $(PYTHON) $(SCRIPTS_DIR)/run_mlflow.py $(if $(PORT),--port $(PORT),)
103
+
104
+ # Clean up generated files
105
+ .PHONY: clean
106
+ clean:
107
+ @echo "Cleaning up generated files..."
108
+ @bash $(SCRIPTS_DIR)/clean.sh
109
+ @find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
110
+ @find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
111
+ @find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true
112
+ @if [ "$(findstring --cache,$(MAKECMDGOALS))" != "" ]; then \
113
+ echo "Clearing data cache..."; \
114
+ rm -rf cache/*; \
115
+ mkdir -p cache; \
116
+ echo "Cache cleared."; \
117
+ fi
118
+
119
+ # Lint Python code
120
+ .PHONY: lint
121
+ lint:
122
+ @echo "Running linter (ruff)..."
123
+ @if [ ! -d "$(VENV_DIR)" ]; then \
124
+ echo "Virtual environment not found. Please run 'make env' first."; \
125
+ exit 1; \
126
+ fi
127
+ @mkdir -p $(LOGS_DIR)
128
+ @(echo "=== Code Check Log $(TIMESTAMP) ===" && \
129
+ echo "Starting checks at: $$(date)" && \
130
+ (source $(VENV_DIR)/bin/activate && \
131
+ echo "Running linter (ruff)..." && \
132
+ ruff check --fix --unsafe-fixes .) \
133
+ 2>&1) | tee $(LOGS_DIR)/code_check_latest.log
134
+ @echo "Check log saved to: $(LOGS_DIR)/code_check_latest.log"
135
+
136
+ # Allow --fix as target without actions
137
+ .PHONY: --fix
138
+ --fix:
139
+
140
+ # Lab Projects
141
+ .PHONY: portfolio folio stop-folio port simulator
142
+
143
+ # Docker targets
144
+ .PHONY: docker-build docker-run docker-up docker-down docker-logs docker-compose-up docker-compose-down docker-test deploy-hf
145
+
146
+ portfolio:
147
+ @echo "Starting portfolio dashboard with sample portfolio.csv and debug mode..."
148
+ @if [ ! -d "$(VENV_DIR)" ]; then \
149
+ echo "Virtual environment not found. Please run 'make env' first."; \
150
+ exit 1; \
151
+ fi
152
+ @source $(VENV_DIR)/bin/activate && \
153
+ LOG_LEVEL=$(if $(log),$(log),INFO) \
154
+ PYTHONPATH=. python3 -m src.folio.app --port 8051 --debug --portfolio src/folio/assets/sample-portfolio.csv
155
+
156
+ port:
157
+ @echo "Running portfolio analysis..."
158
+ @if [ ! -d "$(VENV_DIR)" ]; then \
159
+ echo "Virtual environment not found. Please run 'make env' first."; \
160
+ exit 1; \
161
+ fi
162
+ @source $(VENV_DIR)/bin/activate && \
163
+ PYTHONPATH=. python3 src/lab/portfolio.py "$(if $(csv),$(csv),src/folio/assets/sample-portfolio.csv)"
164
+
165
+ folio:
166
+ @echo "Starting portfolio dashboard with debug mode..."
167
+ @if [ ! -d "$(VENV_DIR)" ]; then \
168
+ echo "Virtual environment not found. Please run 'make env' first."; \
169
+ exit 1; \
170
+ fi
171
+ @source $(VENV_DIR)/bin/activate && \
172
+ LOG_LEVEL=$(if $(log),$(log),INFO) \
173
+ PYTHONPATH=. python3 -m src.folio.app --port 8051 --debug $(if $(portfolio),--portfolio $(portfolio),)
174
+
175
+ stop-folio:
176
+ @echo "Stopping portfolio dashboard..."
177
+ @PIDS=$$(ps aux | grep "[p]ython.*folio" | awk '{print $$2}'); \
178
+ if [ -n "$$PIDS" ]; then \
179
+ echo "Found folio processes with PIDs: $$PIDS"; \
180
+ for PID in $$PIDS; do \
181
+ echo "Killing process $$PID..."; \
182
+ kill -9 $$PID 2>/dev/null || echo "Failed to kill process $$PID (might require sudo)"; \
183
+ done; \
184
+ echo "All folio processes have been terminated."; \
185
+ else \
186
+ echo "No running folio processes found."; \
187
+ fi
188
+
189
+ simulator:
190
+ @echo "Running SPY simulator..."
191
+ @if [ ! -d "$(VENV_DIR)" ]; then \
192
+ echo "Virtual environment not found. Please run 'make env' first."; \
193
+ exit 1; \
194
+ fi
195
+ @source $(VENV_DIR)/bin/activate && \
196
+ PYTHONPATH=. ./scripts/folio-simulator.py $(if $(range),--range $(range),) $(if $(steps),--steps $(steps),) $(if $(focus),--focus $(focus),) $(if $(detailed),--detailed,)
197
+
198
+ # Test targets
199
+ .PHONY: test test-e2e
200
+ test:
201
+ @echo "Running unit tests..."
202
+ @if [ ! -d "$(VENV_DIR)" ]; then \
203
+ echo "Virtual environment not found. Please run 'make env' first."; \
204
+ exit 1; \
205
+ fi
206
+ @mkdir -p $(LOGS_DIR)
207
+ @(echo "=== Test Run Log $(TIMESTAMP) ===" && \
208
+ echo "Starting tests at: $$(date)" && \
209
+ (source $(VENV_DIR)/bin/activate && \
210
+ PYTHONPATH=. pytest tests/ -v) 2>&1) | tee $(LOGS_DIR)/test_latest.log
211
+ @echo "Test log saved to: $(LOGS_DIR)/test_latest.log"
212
+
213
+ test-e2e:
214
+ @echo "Running end-to-end tests..."
215
+ @if [ ! -d "$(VENV_DIR)" ]; then \
216
+ echo "Virtual environment not found. Please run 'make env' first."; \
217
+ exit 1; \
218
+ fi
219
+ @if [ ! -f "private-data/test/test-portfolio.csv" ]; then \
220
+ echo "Warning: Test portfolio file not found at private-data/test/test-portfolio.csv"; \
221
+ echo "E2E tests will try to use sample-data/sample-portfolio.csv instead."; \
222
+ fi
223
+ @mkdir -p $(LOGS_DIR)
224
+ @(echo "=== E2E Test Run Log $(TIMESTAMP) ===" && \
225
+ echo "Starting E2E tests at: $$(date)" && \
226
+ (source $(VENV_DIR)/bin/activate && \
227
+ PYTHONPATH=. pytest tests/e2e/ -v) 2>&1) | tee $(LOGS_DIR)/test_e2e_latest.log
228
+ @echo "E2E test log saved to: $(LOGS_DIR)/test_e2e_latest.log"
229
+
230
+ # Docker commands
231
+ docker-build:
232
+ @echo "Building Docker image..."
233
+ docker build --debug -t folio:latest .
234
+
235
+ # Run the Docker container
236
+ docker-run:
237
+ @echo "Running Docker container..."
238
+ docker run -p 8050:8050 --env-file .env folio:latest
239
+
240
+ # Start with docker-compose
241
+ docker-up:
242
+ @echo "Starting with docker-compose..."
243
+ docker-compose up -d
244
+ @echo "Folio app launched successfully!"
245
+ @echo "Access the app at: http://localhost:8050"
246
+
247
+ # Stop docker-compose services
248
+ docker-down:
249
+ @echo "Stopping docker-compose services..."
250
+ docker-compose down
251
+
252
+ # Alias for backward compatibility
253
+ docker-compose-up: docker-up
254
+ docker-compose-down: docker-down
255
+
256
+ # Tail Docker logs
257
+ docker-logs:
258
+ @echo "Tailing Docker logs..."
259
+ docker-compose logs -f
260
+
261
+ # Run tests in Docker container
262
+ docker-test:
263
+ @echo "Running tests in Docker container..."
264
+ @if [ -z "$$GEMINI_API_KEY" ]; then \
265
+ echo "Warning: GEMINI_API_KEY environment variable not set. Some tests may fail."; \
266
+ fi
267
+ @docker-compose -f docker-compose.test.yml build --build-arg INSTALL_DEV=true
268
+ @docker-compose -f docker-compose.test.yml run --rm folio
269
+
270
+ # Deploy to Hugging Face Spaces
271
+ deploy-hf:
272
+ @echo "Deploying to Hugging Face Spaces..."
273
+ @echo "Checking for Git LFS..."
274
+ @if ! command -v git-lfs &> /dev/null; then \
275
+ echo "Error: Git LFS is not installed. Please install it first."; \
276
+ echo " macOS: brew install git-lfs"; \
277
+ echo " Linux: apt-get install git-lfs"; \
278
+ exit 1; \
279
+ fi
280
+ @echo "Checking if Hugging Face Space remote exists..."
281
+ @if ! git remote | grep -q "space"; then \
282
+ echo "Adding Hugging Face Space remote..."; \
283
+ git remote add space git@huggingface.co:mingdom/folio; \
284
+ fi
285
+ @echo "Ensuring Git LFS is tracking .pkl files..."
286
+ @grep -q "*.pkl filter=lfs diff=lfs merge=lfs -text" .gitattributes || \
287
+ echo "*.pkl filter=lfs diff=lfs merge=lfs -text" >> .gitattributes
288
+ @echo "Initializing Git LFS..."
289
+ @git lfs install
290
+ @echo "Pushing to Hugging Face Space..."
291
+ @git push space main:main
292
+ @echo "\n✅ Deployment to Hugging Face Space completed!"
293
+ @echo "Your application is now available at: https://huggingface.co/spaces/mingdom/folio"
294
+
295
+ %:
296
+ @:
README.md ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Folio - Financial Portfolio Dashboard
3
+ emoji: 📊
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ sdk_version: "latest"
8
+ app_file: app.py
9
+ pinned: false
10
+ ---
11
+
12
+ # Folio - Financial Portfolio Dashboard
13
+
14
+ Folio is a powerful web-based dashboard for analyzing and optimizing your investment portfolio. Get professional-grade insights into your stocks, options, and other financial instruments with an intuitive, user-friendly interface.
15
+
16
+ ## Why Folio?
17
+
18
+ - **Complete Portfolio Visibility**: See your entire financial picture in one place
19
+ - **Smart Risk Assessment**: Understand your portfolio's risk profile with beta analysis
20
+ - **AI-Powered Insights**: Get personalized investment advice from our AI portfolio advisor
21
+ - **Cash & Equivalents Detection**: Automatically identifies money market and cash-like positions
22
+ - **Option Analytics**: Detailed metrics for options including implied volatility and Greeks
23
+ - **Zero Cost**: Free to use, with no hidden fees or subscriptions
24
+
25
+ ## Key Features
26
+
27
+ - **Portfolio Summary**: View total exposure, beta, and allocation breakdown
28
+ - **Position Details**: Analyze individual positions with detailed metrics
29
+ - **AI Portfolio Advisor**: Get personalized investment advice powered by Google's Gemini AI
30
+ - **Filtering & Sorting**: Filter by position type and sort by various metrics
31
+ - **Real-time Data**: Uses Yahoo Finance API for up-to-date market data
32
+ - **Responsive Design**: Works seamlessly on desktop and mobile devices
33
+ - **Dark Mode**: Easy on the eyes for late-night financial analysis
34
+
35
+ ## Getting Started
36
+
37
+ ### Try It Online
38
+
39
+ The easiest way to try Folio is through our Hugging Face Spaces deployment:
40
+ [https://huggingface.co/spaces/mingdom/folio](https://huggingface.co/spaces/mingdom/folio)
41
+
42
+ ### Local Installation
43
+
44
+ 1. Clone the repository:
45
+ ```bash
46
+ git clone https://github.com/mingdom/folio.git
47
+ cd folio
48
+ ```
49
+
50
+ 2. Set up the environment and install dependencies:
51
+ ```bash
52
+ make env
53
+ make install
54
+ ```
55
+
56
+ 3. Run with sample portfolio:
57
+ ```bash
58
+ make portfolio
59
+ ```
60
+
61
+ 4. Or start with a blank slate:
62
+ ```bash
63
+ make folio
64
+ ```
65
+
66
+ ### Development Setup
67
+
68
+ 1. Install development dependencies:
69
+ ```bash
70
+ pip install -r requirements-dev.txt
71
+ ```
72
+
73
+ 2. Set up pre-commit hooks:
74
+ ```bash
75
+ pre-commit install
76
+ ```
77
+
78
+ 3. Run linting and tests:
79
+ ```bash
80
+ make lint
81
+ make test
82
+ ```
83
+
84
+ ### Docker Deployment
85
+
86
+ 1. Start the application with Docker:
87
+ ```bash
88
+ make docker-up
89
+ ```
90
+
91
+ 2. Access the dashboard at http://localhost:8050
92
+
93
+ 3. View logs (if needed):
94
+ ```bash
95
+ make docker-logs
96
+ ```
97
+
98
+ For more Docker commands and options, see [DOCKER.md](DOCKER.md).
99
+
100
+ For information about logging configuration, see [docs/logging.md](docs/logging.md).
101
+
102
+ ## Using Folio
103
+
104
+ 1. **Upload Your Portfolio**: Use the upload button to import a CSV file with your holdings
105
+ 2. **Explore Your Data**: View summary metrics and detailed breakdowns of your investments
106
+ 3. **Filter and Sort**: Focus on specific asset types or metrics that matter to you
107
+ 4. **Get AI Insights**: Click the "Robot Advisor" button to get personalized advice about your portfolio
108
+ 5. **Export or Share**: Save your analysis or share insights with your financial advisor
109
+
110
+ ## Sample Portfolio
111
+
112
+ Not ready to upload your own data? Click the "Load Sample Portfolio" button to explore Folio with our demo data.
113
+
114
+ ## Privacy & Security
115
+
116
+ - **Your Data Stays Private**: All analysis happens in your browser or local environment
117
+ - **No Account Required**: Use Folio without creating an account or sharing personal information
118
+ - **Open Source**: All code is transparent and available for review
119
+
120
+ ## License
121
+
122
+ This project is licensed under the MIT License - see the LICENSE file for details.
activate-venv.sh ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Script to activate the virtual environment
3
+ source "/Users/dongming/projects/omninmo/venv/bin/activate"
4
+ echo "Virtual environment activated. Run 'deactivate' to exit."
docker-compose.test.yml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ folio:
5
+ build:
6
+ context: .
7
+ dockerfile: Dockerfile
8
+ args:
9
+ - INSTALL_DEV=true
10
+ environment:
11
+ - PYTHONPATH=/app
12
+ - DATA_SOURCE=yfinance
13
+ - LOG_LEVEL=DEBUG
14
+ - GEMINI_API_KEY=${GEMINI_API_KEY}
15
+ volumes:
16
+ - ./src:/app/src
17
+ - ./tests:/app/tests
18
+ - ./config:/app/config
19
+ - ./.env:/app/.env
20
+ command: pytest tests/ -v
docker-compose.yml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ folio:
3
+ image: folio:latest
4
+ ports:
5
+ - "8050:8050"
6
+ environment:
7
+ - PORT=8050
8
+ - HF_SPACE=
9
+ - PYTHONPATH=/app
10
+ - DATA_SOURCE=yfinance
11
+ - LOG_LEVEL=INFO
12
+ - GEMINI_API_KEY=${GEMINI_API_KEY}
13
+ volumes:
14
+ - ./config:/app/config
15
+ - ./data:/app/data
16
+ - ./.env:/app/.env
17
+ restart: unless-stopped
pyproject.toml ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [tool.mypy]
2
+ python_version = "3.11"
3
+ warn_return_any = true
4
+ warn_unused_configs = true
5
+ disallow_untyped_defs = true
6
+ disallow_incomplete_defs = true
7
+ check_untyped_defs = true
8
+ disallow_untyped_decorators = true
9
+ no_implicit_optional = true
10
+ warn_redundant_casts = true
11
+ warn_unused_ignores = true
12
+ warn_no_return = true
13
+ warn_unreachable = true
14
+ strict_optional = true
15
+ plugins = ["numpy.typing.mypy_plugin"]
16
+
17
+ [[tool.mypy.overrides]]
18
+ module = "dash.*"
19
+ ignore_missing_imports = true
20
+
21
+ [[tool.mypy.overrides]]
22
+ module = "pandas.*"
23
+ ignore_missing_imports = true
24
+
25
+ [tool.ruff]
26
+ # Line length and target version are top-level
27
+ line-length = 88
28
+ target-version = "py311"
29
+
30
+ [tool.ruff.lint]
31
+ # Enable recommended rules + specific ones useful for data science projects
32
+ select = [
33
+ "E", # pycodestyle errors
34
+ "F", # pyflakes
35
+ "I", # isort
36
+ "N", # pep8-naming
37
+ "UP", # pyupgrade
38
+ "PL", # pylint
39
+ "RUF", # ruff-specific rules
40
+ "W", # pycodestyle warnings
41
+ "F401", # Module imported but unused
42
+ "F841", # Local variable is assigned to but never used
43
+ "F821", # Undefined name
44
+ "F811", # Redefined name
45
+ "F822", # Undefined name in __all__
46
+ "PLC0414", # Useless import alias
47
+ "PLE0101", # Function defined outside __init__
48
+ "PLE0604", # Invalid object in __all__, or invalid __all__ format
49
+ "PLE0605", # Invalid format for __all__
50
+ "A", # Unused functions... etc.
51
+ "ARG001", # Unused function argument
52
+ "ARG002", # Unused function argument
53
+ "B", # flake8-bugbear rules (includes B007 for unused loop variables)
54
+ "ERA", # eradicate (commented out code)
55
+ "F", # pyflakes (includes F401 for unused imports, F841 for unused variables)
56
+ "T201", # print statements
57
+ ]
58
+
59
+ # Ignore specific rules
60
+ ignore = [
61
+ "E501", # line too long - let's handle line length more flexibly for data science code
62
+ "N803", # argument name should be lowercase - common in ML to use X, y
63
+ "N806", # variable name should be lowercase - common in ML to use X_train, y_test
64
+ "PLR0913", # too many arguments - common in ML functions with many parameters
65
+ "PLR0912", # too many branches - common in ML data processing and training loops
66
+ "PLR0915", # too many statements - common in ML training and evaluation functions
67
+ "PLR2004", # magic value used in comparison - common in data processing code
68
+ ]
69
+
70
+ # Allow autofix for all enabled rules (when `--fix`) is provided
71
+ fixable = ["ALL"]
72
+
73
+ # Exclude a variety of commonly ignored directories
74
+ exclude = [
75
+ ".git",
76
+ ".venv",
77
+ "venv",
78
+ "__pycache__",
79
+ "build",
80
+ "dist",
81
+ "mlruns",
82
+ ".pytest_cache",
83
+ ".archive",
84
+ ]
85
+
86
+ # Allow unused variables when underscore-prefixed
87
+ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
88
+
89
+ [tool.ruff.lint.per-file-ignores]
90
+ # DO NOT IGNORE UNLESS IT'S A REALLY GOOD REASON!
91
+ "__init__.py" = ["F401"] # Re-exports are common in __init__.py files
92
+
93
+ [tool.ruff.lint.isort]
94
+ known-first-party = ["src"]
requirements-dev.txt ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## Development Dependencies
2
+ # * Always use latest version
3
+
4
+ # Code Quality Tools
5
+ # -----------------
6
+
7
+ # ruff - Fast Python linter and formatter written in Rust
8
+ # Used for code quality checks, style enforcement, and automatic formatting
9
+ # Version 0.5.3+ required for native LSP server support in editors
10
+ # Used in: make lint, CI/CD pipeline, editor integrations
11
+ # Configuration: pyproject.toml [tool.ruff] section
12
+ ruff
13
+
14
+ # Testing Tools
15
+ # ------------
16
+
17
+ # pytest - Testing framework for writing and running unit and integration tests
18
+ # Used for automated testing of application functionality
19
+ # Used in: make test, CI/CD pipeline
20
+ # Configuration: pytest.ini (implicit)
21
+ pytest
22
+
23
+ # Terminal UI Tools
24
+ # ----------------
25
+
26
+ # rich - Library for rich text and beautiful formatting in the terminal
27
+ # Used for creating beautiful, interactive terminal output in scripts
28
+ # Used in: scripts/folio-simulator.py
29
+ # Configuration: None (used directly in code)
30
+ rich>=13.9.0
requirements.txt ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core dependencies - only what's needed for the Folio app
2
+ # Note: Developer Dependencies in requirements-dev.txt
3
+ pandas==2.2.1
4
+ numpy==1.26.4
5
+ # scipy removed - no longer needed after migrating to QuantLib
6
+ QuantLib>=1.30 # For option calculations
7
+
8
+ # Utilities
9
+ requests>=2.32.0
10
+ PyYAML==6.0.1
11
+
12
+ # Data source
13
+ yfinance>=0.2.37 # For portfolio beta calculation
14
+
15
+ # Web application - using latest versions for security updates
16
+ dash>=2.14.2
17
+ dash-bootstrap-components>=1.5.0
18
+ dash-bootstrap-templates>=1.1.1 # For Plotly figure templates
19
+
20
+ # WSGI server for production - always use latest for security
21
+ gunicorn>=21.2.0
22
+
23
+ # AI/ML dependencies
24
+ google-generativeai>=0.3.0 # For Gemini AI integration
25
+
scripts/README.md ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Diagnostic Scripts
2
+
3
+ This directory contains diagnostic scripts used to troubleshoot issues with the Folio application, particularly when running in a Docker container.
4
+
5
+ ## Available Scripts
6
+
7
+ ### `run_diagnostics.sh`
8
+
9
+ A shell script that runs all diagnostic scripts on a running Docker container.
10
+
11
+ **Usage:**
12
+ ```bash
13
+ ./run_diagnostics.sh [container_name]
14
+ ```
15
+
16
+ If `container_name` is not provided, it defaults to "omninmo-folio-1".
17
+
18
+ ### `check_modules.py`
19
+
20
+ Checks for the availability and versions of Python modules required by the Folio application.
21
+
22
+ **Usage:**
23
+ ```bash
24
+ python check_modules.py
25
+ ```
26
+
27
+ **In Docker:**
28
+ ```bash
29
+ docker exec omninmo-folio-1 python /app/scripts/check_modules.py
30
+ ```
31
+
32
+ ### `check_network.py`
33
+
34
+ Tests network connectivity from inside a Docker container, including binding to different interfaces and testing external connectivity.
35
+
36
+ **Usage:**
37
+ ```bash
38
+ python check_network.py [--bind-test] [--external-test]
39
+ ```
40
+
41
+ **In Docker:**
42
+ ```bash
43
+ docker exec omninmo-folio-1 python /app/scripts/check_network.py
44
+ ```
45
+
46
+ ### `test_imports.py`
47
+
48
+ Tests importing various modules used by the Folio application to diagnose import-related issues.
49
+
50
+ **Usage:**
51
+ ```bash
52
+ python test_imports.py
53
+ ```
54
+
55
+ **In Docker:**
56
+ ```bash
57
+ docker exec omninmo-folio-1 python /app/scripts/test_imports.py
58
+ ```
59
+
60
+ ## Common Issues
61
+
62
+ These scripts were created to diagnose the following common issues:
63
+
64
+ 1. **Module Import Errors**: Problems with Python module imports, particularly with the project structure in a Docker container.
65
+
66
+ 2. **Network Binding Issues**: Issues with the application binding to the correct network interface inside the container.
67
+
68
+ 3. **Dependency Problems**: Missing or incompatible dependencies.
69
+
70
+ ## Adding New Scripts
71
+
72
+ When adding new diagnostic scripts, please follow these guidelines:
73
+
74
+ 1. Include a detailed docstring explaining the purpose of the script
75
+ 2. Add usage examples, both for local execution and in Docker
76
+ 3. Make the script executable (`chmod +x script_name.py`)
77
+ 4. Update this README with information about the new script
scripts/check_beta.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Beta Calculation Validation Script
3
+
4
+ This script fetches historical data and calculates beta values for a predefined list of symbols
5
+ to validate how the beta calculation works in practice. It uses raw beta calculation without
6
+ any of the special case handling found in the portfolio processing code.
7
+
8
+ Beta measures the volatility of a security in relation to the market (using SPY as proxy).
9
+ - Beta > 1: More volatile than the market
10
+ - Beta = 1: Same volatility as the market
11
+ - Beta < 1: Less volatile than the market
12
+ - Beta < 0: Moves in the opposite direction as the market
13
+
14
+ Latest Beta Values (as of 2025-04-01):
15
+ SPAXX**: Not available (money market fund, no market data)
16
+ FMPXX: Not available (money market fund, no market data)
17
+ FFRHX: 0.0553 (money market fund)
18
+ TLT: -0.0145 (long-term treasury ETF, negative correlation)
19
+ SHY: 0.0107 (short-term treasury ETF)
20
+ BIL: 0.0005 (1-3 month T-bill ETF, extremely low beta)
21
+ MCHI: 0.7130 (China ETF, significant market exposure)
22
+ IEFA: 0.7862 (International ETF, significant market exposure)
23
+ SPY: 1.0000 (S&P 500 ETF, market benchmark)
24
+ AAPL: 1.2029 (Tech stock, higher volatility than market)
25
+ GOOGL: 1.2695 (Tech stock, higher volatility than market)
26
+ INVALID: Not available (invalid symbol for testing error handling)
27
+
28
+ Usage:
29
+ python scripts/check_beta.py
30
+
31
+ Note: This script calculates raw beta values without the additional logic that might be
32
+ applied in the main application, such as fallbacks for cash-like positions or special
33
+ handling of missing data.
34
+ """
35
+
36
+ import os
37
+ import sys
38
+
39
+ import pandas as pd
40
+
41
+ # Adjust path to import from src
42
+ if __name__ == "__main__":
43
+ script_dir = os.path.dirname(__file__)
44
+ project_root = os.path.abspath(os.path.join(script_dir, ".."))
45
+ sys.path.insert(0, project_root)
46
+
47
+ from src.fmp import DataFetcher
48
+ from src.folio.logger import logger # Use the same logger if desired
49
+ from src.folio.utils import is_cash_or_short_term
50
+
51
+
52
+ def calculate_raw_beta(
53
+ ticker: str, fetcher: DataFetcher, market_data: pd.DataFrame | None
54
+ ) -> float | str:
55
+ """Fetches data and calculates raw beta without special handling."""
56
+ # Early validation
57
+ if market_data is None:
58
+ return "Error: Market data not available"
59
+
60
+ try:
61
+ # Fetch and validate stock data
62
+ logger.info(f"Fetching data for {ticker}...")
63
+ stock_data = fetcher.fetch_data(ticker)
64
+
65
+ # Data validation checks
66
+ error_msg = _validate_data(ticker, stock_data)
67
+ if error_msg:
68
+ return error_msg
69
+
70
+ # Calculate returns
71
+ logger.info(f"Calculating returns for {ticker}...")
72
+ stock_returns = stock_data["Close"].pct_change().dropna()
73
+ market_returns = market_data["Close"].pct_change().dropna()
74
+
75
+ # Align data by index
76
+ aligned_stock, aligned_market = stock_returns.align(
77
+ market_returns, join="inner"
78
+ )
79
+
80
+ # Validate aligned data
81
+ if aligned_stock.empty or len(aligned_stock) < 2:
82
+ return f"Error: Not enough overlapping data points after alignment for {ticker} (need >= 2)"
83
+
84
+ # Calculate beta
85
+ logger.info(f"Calculating variance/covariance for {ticker}...")
86
+ market_variance = aligned_market.var()
87
+ covariance = aligned_stock.cov(aligned_market)
88
+
89
+ # Validate variance and covariance
90
+ error_msg = _validate_variance_covariance(market_variance, covariance)
91
+ if error_msg:
92
+ return error_msg
93
+
94
+ # Calculate and return beta
95
+ beta = covariance / market_variance
96
+ return beta
97
+
98
+ except Exception as e:
99
+ return f"Error calculating beta for {ticker}: {e}"
100
+
101
+
102
+ def _validate_data(ticker: str, stock_data: pd.DataFrame | None) -> str | None:
103
+ """Validates stock data and returns error message if invalid."""
104
+ if stock_data is None or stock_data.empty:
105
+ return f"Error: No data fetched for {ticker}"
106
+ if len(stock_data) < 2:
107
+ return f"Error: Not enough data points for {ticker} (need >= 2)"
108
+ return None
109
+
110
+
111
+ def _validate_variance_covariance(
112
+ market_variance: float, covariance: float
113
+ ) -> str | None:
114
+ """Validates variance and covariance calculations and returns error message if invalid."""
115
+ if pd.isna(market_variance) or abs(market_variance) < 1e-12:
116
+ return f"Error: Market variance is zero or near-zero ({market_variance})"
117
+ if pd.isna(covariance):
118
+ return "Error: Covariance calculation resulted in NaN"
119
+ return None
120
+
121
+
122
+ if __name__ == "__main__":
123
+ symbols_to_check = [
124
+ "SPAXX**",
125
+ "FMPXX",
126
+ "FFRHX",
127
+ "TLT", # 20+ Year Treasury Bond ETF
128
+ "SHY", # 1-3 Year Treasury Bond ETF
129
+ "BIL", # 1-3 Month T-Bill ETF
130
+ "MCHI", # iShares MSCI China ETF
131
+ "IEFA", # iShares Core MSCI EAFE ETF
132
+ "SPY", # S&P 500 ETF
133
+ "AAPL", # Apple Stock
134
+ "GOOGL", # Google Stock
135
+ "INVALID", # Test an invalid ticker
136
+ ]
137
+
138
+ try:
139
+ fetcher = DataFetcher()
140
+ if fetcher is None:
141
+ raise RuntimeError("Fetcher initialization failed")
142
+ # Fetch market data once
143
+ market_data = (
144
+ fetcher.fetch_market_data()
145
+ ) # Assumes this fetches S&P500 or similar
146
+ if market_data is None or market_data.empty:
147
+ sys.exit(1)
148
+
149
+ except Exception:
150
+ sys.exit(1)
151
+
152
+ # Calculate beta for each symbol and store results
153
+ results = {}
154
+ for symbol in symbols_to_check:
155
+ beta_result = calculate_raw_beta(symbol, fetcher, market_data)
156
+ results[symbol] = beta_result
157
+
158
+ # Display results in a formatted table
159
+
160
+ for symbol, result in results.items():
161
+ if isinstance(result, float):
162
+ is_cash = is_cash_or_short_term(symbol, beta=result)
163
+ classification = "CASH-LIKE" if is_cash else "MARKET-CORRELATED"
164
+ else:
165
+ # Error case
166
+ logger.error(f"Error for {symbol}: {result}")
167
+
168
+ # Summary statistics
169
+ success_count = sum(1 for r in results.values() if isinstance(r, float))
170
+ error_count = len(results) - success_count
171
+ cash_like_count = sum(
172
+ 1
173
+ for s, r in results.items()
174
+ if isinstance(r, float) and is_cash_or_short_term(s, beta=r)
175
+ )
scripts/clean.sh ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Script to clean up generated files
3
+
4
+ echo "Cleaning up generated files..."
5
+
6
+ # Get the directory of this script
7
+ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
8
+
9
+ # Get the project root directory
10
+ PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." &> /dev/null && pwd )"
11
+
12
+ # Remove Python cache files
13
+ echo "Removing Python cache files..."
14
+ find "$PROJECT_ROOT" -type d -name "__pycache__" -exec rm -rf {} +
15
+ find "$PROJECT_ROOT" -type f -name "*.pyc" -delete
16
+ find "$PROJECT_ROOT" -type f -name "*.pyo" -delete
17
+ find "$PROJECT_ROOT" -type f -name "*.pyd" -delete
18
+
19
+ # Remove model files
20
+ echo "Removing model files..."
21
+ rm -rf "$PROJECT_ROOT/models"/*.pkl
22
+
23
+ # Remove cache files
24
+ echo "Removing cache files..."
25
+ rm -rf "$PROJECT_ROOT/cache"/*
26
+
27
+ echo "Cleanup complete!"
scripts/compare_exposures_ui.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Script to compare summary card exposures with position details exposures,
4
+ using the same methods as the UI components.
5
+
6
+ This script loads portfolio data from a CSV file, calculates the summary card values,
7
+ and compares them with the sum of exposures from the position details as they would
8
+ appear in the UI.
9
+ """
10
+
11
+ import os
12
+ import sys
13
+
14
+ import pandas as pd
15
+
16
+ # Add the src directory to the Python path
17
+ sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
18
+
19
+ from src.folio.components.summary_cards import format_summary_card_values
20
+ from src.folio.portfolio import process_portfolio_data
21
+
22
+
23
+ def load_portfolio_data(csv_path):
24
+ """Load portfolio data from a CSV file."""
25
+ try:
26
+ df = pd.read_csv(csv_path)
27
+ return df
28
+ except Exception:
29
+ return None
30
+
31
+
32
+ def compare_exposures():
33
+ """Compare summary card exposures with position details exposures."""
34
+ # Try to load the private portfolio data first
35
+ private_csv_path = "private-data/portfolio-private.csv"
36
+ sample_csv_path = "sample-data/sample-portfolio.csv"
37
+
38
+ if os.path.exists(private_csv_path):
39
+ df = load_portfolio_data(private_csv_path)
40
+ elif os.path.exists(sample_csv_path):
41
+ df = load_portfolio_data(sample_csv_path)
42
+ else:
43
+ return
44
+
45
+ if df is None:
46
+ return
47
+
48
+ # Process the portfolio data
49
+ result = process_portfolio_data(df)
50
+
51
+ # Check the structure of the result
52
+ if isinstance(result, tuple):
53
+ if len(result) == 3:
54
+ # Newer version: (groups, summary, cash_like_positions)
55
+ groups, summary, cash_like_positions = result
56
+ elif len(result) == 2:
57
+ # Possible alternative: (groups, cash_like_positions)
58
+ groups, cash_like_positions = result
59
+ from src.folio.portfolio import calculate_portfolio_summary
60
+
61
+ summary = calculate_portfolio_summary(groups, cash_like_positions, 0.0)
62
+ else:
63
+ # If result is not a tuple, it's likely just the groups
64
+ groups = result
65
+ from src.folio.portfolio import calculate_portfolio_summary
66
+
67
+ summary = calculate_portfolio_summary(groups, [], 0.0)
68
+
69
+ # Ensure we have a valid summary object
70
+ if not hasattr(summary, "to_dict"):
71
+ # Create a minimal summary for testing
72
+ from src.folio.data_model import ExposureBreakdown, PortfolioSummary
73
+
74
+ empty_exposure = ExposureBreakdown()
75
+ summary = PortfolioSummary(
76
+ net_market_exposure=0.0,
77
+ portfolio_beta=0.0,
78
+ long_exposure=empty_exposure,
79
+ short_exposure=empty_exposure,
80
+ options_exposure=empty_exposure,
81
+ )
82
+
83
+ # Get the summary card values
84
+ summary_dict = summary.to_dict()
85
+ formatted_values = format_summary_card_values(summary_dict)
86
+
87
+ # Extract the values from the formatted values
88
+ formatted_values[0]
89
+ net_exposure = formatted_values[1]
90
+ formatted_values[3]
91
+ beta_adjusted_net_exposure = formatted_values[4]
92
+ long_exposure = formatted_values[5]
93
+ short_exposure = formatted_values[7]
94
+ options_exposure = formatted_values[9]
95
+ formatted_values[11]
96
+
97
+ # Calculate the sum of exposures from the position details as they would appear in the UI
98
+
99
+ # Initialize counters for UI exposures
100
+ total_ui_market_value = 0.0
101
+ total_ui_beta_adjusted_exposure = 0.0
102
+ total_ui_delta_exposure = 0.0
103
+
104
+ # Process each group as it would be displayed in the UI
105
+
106
+ for group in groups:
107
+ # Get values as they would be displayed in the UI
108
+ market_value = (
109
+ group.net_exposure
110
+ ) # This is what's shown as "Total Value" in the UI
111
+ beta_adjusted = (
112
+ group.beta_adjusted_exposure
113
+ ) # This is what's shown as "Beta-Adjusted Exposure" in the UI
114
+ delta_exposure = (
115
+ group.total_delta_exposure
116
+ ) # This is what's shown as "Total Delta Exposure" in the UI
117
+
118
+ # Print the values for this group
119
+
120
+ # Add to totals
121
+ total_ui_market_value += market_value
122
+ total_ui_beta_adjusted_exposure += beta_adjusted
123
+ total_ui_delta_exposure += delta_exposure
124
+
125
+ # Extract numeric values from formatted strings
126
+ def extract_numeric(value):
127
+ return float(value.replace("$", "").replace(",", ""))
128
+
129
+ summary_net_exposure = extract_numeric(net_exposure)
130
+ summary_beta_adjusted_net_exposure = extract_numeric(beta_adjusted_net_exposure)
131
+ extract_numeric(long_exposure)
132
+ extract_numeric(short_exposure)
133
+ summary_options_exposure = extract_numeric(options_exposure)
134
+
135
+ # Compare with summary card values
136
+
137
+ # Calculate long and short exposures from UI values
138
+ ui_long_exposure = 0.0
139
+ ui_short_exposure = 0.0
140
+
141
+ for group in groups:
142
+ if group.stock_position:
143
+ stock = group.stock_position
144
+ if stock.quantity >= 0: # Long position
145
+ ui_long_exposure += stock.market_value
146
+ else: # Short position
147
+ ui_short_exposure += stock.market_value # Already negative
148
+
149
+ # Process option positions
150
+ for opt in group.option_positions:
151
+ if opt.delta_exposure >= 0: # Long position
152
+ ui_long_exposure += opt.delta_exposure
153
+ else: # Short position
154
+ ui_short_exposure += opt.delta_exposure # Already negative
155
+
156
+ # Determine if the summary card values match the UI values
157
+ if abs(summary_net_exposure - total_ui_market_value) < 0.01:
158
+ pass
159
+ else:
160
+ pass
161
+
162
+ if abs(summary_beta_adjusted_net_exposure - total_ui_beta_adjusted_exposure) < 0.01:
163
+ pass
164
+ else:
165
+ pass
166
+
167
+ if abs(summary_options_exposure - total_ui_delta_exposure) < 0.01:
168
+ pass
169
+ else:
170
+ pass
171
+
172
+
173
+ if __name__ == "__main__":
174
+ compare_exposures()
scripts/debug_portfolio.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Portfolio Exposure Debugging Script
4
+
5
+ This script loads a portfolio CSV file and prints out detailed exposure calculations.
6
+ It can be used to debug exposure calculations for any portfolio.
7
+
8
+ Usage:
9
+ python debug_portfolio.py [path_to_portfolio.csv]
10
+
11
+ If no path is provided, it will use the default sample portfolio.
12
+ """
13
+
14
+ import argparse
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ import pandas as pd
20
+
21
+ # Add the src directory to the Python path
22
+ script_dir = Path(__file__).resolve().parent
23
+ src_dir = script_dir.parent
24
+ sys.path.append(str(src_dir))
25
+
26
+ # Import after adding to path
27
+ from src.folio.portfolio import process_portfolio_data # noqa: E402
28
+
29
+
30
+ def print_section_header(_title):
31
+ """Print a section header with formatting."""
32
+
33
+
34
+ def print_exposure_breakdown(_name, breakdown):
35
+ """Print details of an exposure breakdown."""
36
+
37
+ # Calculate percentages
38
+ if breakdown.total_exposure > 0:
39
+ (breakdown.stock_exposure / breakdown.total_exposure) * 100
40
+ (breakdown.option_delta_exposure / breakdown.total_exposure) * 100
41
+
42
+ # Print components if available
43
+ if hasattr(breakdown, "components") and breakdown.components:
44
+ for _key, _value in breakdown.components.items():
45
+ pass
46
+
47
+
48
+ def print_portfolio_summary(summary):
49
+ """Print a detailed portfolio summary."""
50
+ print_section_header("PORTFOLIO SUMMARY")
51
+
52
+ # Calculate options metrics
53
+ long_options = summary.long_exposure.option_delta_exposure
54
+ short_options = summary.short_exposure.option_delta_exposure
55
+ long_options - short_options
56
+
57
+ # Calculate stock metrics
58
+ long_stocks = summary.long_exposure.stock_exposure
59
+ short_stocks = summary.short_exposure.stock_exposure
60
+
61
+ # Calculate percentages
62
+ total_exposure = (
63
+ summary.long_exposure.total_exposure + summary.short_exposure.total_exposure
64
+ )
65
+ if total_exposure > 0:
66
+ options_exposure = long_options + short_options
67
+ (options_exposure / total_exposure) * 100
68
+ ((long_stocks + short_stocks) / total_exposure) * 100
69
+
70
+ # Calculate options exposure (absolute sum of long and short)
71
+ options_exposure = long_options + short_options
72
+
73
+ # Print exposure breakdowns
74
+ print_exposure_breakdown("LONG EXPOSURE", summary.long_exposure)
75
+ print_exposure_breakdown("SHORT EXPOSURE", summary.short_exposure)
76
+ print_exposure_breakdown("OPTIONS EXPOSURE", summary.options_exposure)
77
+
78
+
79
+ def print_portfolio_groups(groups):
80
+ """Print details of portfolio groups."""
81
+ print_section_header("PORTFOLIO GROUPS")
82
+
83
+ for _i, group in enumerate(groups):
84
+ # Print stock position if available
85
+ if group.stock_position:
86
+ pass
87
+
88
+ # Print option positions if available
89
+ if group.option_positions:
90
+ for _j, _option in enumerate(group.option_positions):
91
+ pass
92
+
93
+
94
+ def print_cash_like_positions(positions):
95
+ """Print details of cash-like positions."""
96
+ print_section_header("CASH-LIKE POSITIONS")
97
+
98
+ if not positions:
99
+ return
100
+
101
+ for _i, _pos in enumerate(positions):
102
+ pass
103
+
104
+
105
+ def main():
106
+ """Main function to load portfolio and print exposure calculations."""
107
+ parser = argparse.ArgumentParser(
108
+ description="Debug portfolio exposure calculations"
109
+ )
110
+ parser.add_argument(
111
+ "portfolio_path",
112
+ nargs="?",
113
+ default=os.path.join(src_dir, "src", "folio", "assets", "sample-portfolio.csv"),
114
+ help="Path to portfolio CSV file",
115
+ )
116
+ args = parser.parse_args()
117
+
118
+ # Check if file exists
119
+ if not os.path.exists(args.portfolio_path):
120
+ sys.exit(1)
121
+
122
+ try:
123
+ # Load portfolio data
124
+ df = pd.read_csv(args.portfolio_path)
125
+
126
+ # Process portfolio data
127
+ groups, summary, _ = process_portfolio_data(df)
128
+
129
+ # Print portfolio groups
130
+ print_portfolio_groups(groups)
131
+
132
+ # Print cash-like positions
133
+ print_cash_like_positions(summary.cash_like_positions)
134
+
135
+ # Print portfolio summary (at the end for easy reference)
136
+ print_portfolio_summary(summary)
137
+
138
+ except Exception:
139
+ import traceback
140
+
141
+ traceback.print_exc()
142
+ sys.exit(1)
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
scripts/folio-simulator.py ADDED
@@ -0,0 +1,903 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # pylint: disable=print-statement,print-used
3
+ # ruff: noqa: E402, F401
4
+ """
5
+ Portfolio SPY Simulator
6
+
7
+ This script simulates how a portfolio would respond to changes in SPY (S&P 500 ETF).
8
+ It provides detailed analysis of the portfolio's behavior under different SPY price
9
+ scenarios, helping investors understand their market exposure and risk profile.
10
+
11
+ The script uses the Rich library to create beautiful, interactive terminal output
12
+ with colorful tables and charts for better data visualization.
13
+
14
+ Configuration:
15
+ The default parameters can be adjusted by modifying the constants at the top of this file.
16
+ - DEFAULT_SPY_RANGE: The default range of SPY changes to simulate (e.g., 20.0 for ±20%)
17
+ - DEFAULT_STEPS: The default number of steps in the simulation
18
+ - CHART_WIDTH: Width of the chart visualization
19
+ - CHART_HEIGHT: Height of the chart visualization
20
+ - PORTFOLIO_PATH: Path to the portfolio CSV file
21
+
22
+ Usage:
23
+ # Recommended: Use the make target (activates virtual environment automatically)
24
+ make simulator [range=5] [steps=11] [focus=SPY,QQQ] [detailed=1]
25
+
26
+ # Alternative: Activate virtual environment first, then run the script
27
+ source venv/bin/activate
28
+ python scripts/folio-simulator.py [options]
29
+
30
+ # Show help
31
+ python scripts/folio-simulator.py --help
32
+
33
+ Options:
34
+ --focus TICKERS Comma-separated list of tickers to focus on (e.g., "SPY,QQQ,AAPL")
35
+ --range PERCENT SPY change range in percent (default: 20.0)
36
+ --steps N Number of steps in the simulation (default: 13)
37
+ --detailed Show detailed analysis for all positions
38
+
39
+ Examples:
40
+ # Run with default settings (±20% SPY range with 13 steps)
41
+ make simulator
42
+
43
+ # Run with a narrower range of ±5% SPY with 11 steps (1% increments)
44
+ make simulator range=5 steps=11
45
+
46
+ # Focus on a specific ticker with default range
47
+ make simulator focus=SPY
48
+
49
+ # Focus on multiple tickers with a custom range
50
+ make simulator focus=SPY,QQQ,AAPL range=15 steps=31
51
+
52
+ # Show detailed analysis for all positions
53
+ make simulator detailed=1
54
+
55
+ Output:
56
+ The script provides several sections of output:
57
+
58
+ 1. Portfolio Summary - A table showing current, minimum, and maximum portfolio values.
59
+
60
+ 2. Portfolio Values Table - A detailed table showing portfolio values,
61
+ absolute changes, and percentage changes at each SPY change point.
62
+
63
+ 3. Portfolio Value Chart - A visual chart showing how the
64
+ portfolio value changes across the SPY range.
65
+
66
+ 4. Portfolio Value Summary - Key metrics including worst and best case scenarios.
67
+
68
+ 5. Correlation Analysis - Tables showing positions with negative correlation to SPY
69
+ (lose value when SPY goes up) and inverse correlation (gain value when SPY goes down).
70
+
71
+ 6. Portfolio Beta Analysis - The portfolio's beta for up and down moves, average
72
+ beta, and a note about non-linear behavior if applicable.
73
+
74
+ Notes:
75
+ - The script requires a portfolio CSV file at 'private-data/portfolio-private.csv'.
76
+ - The script updates prices for all positions before running the simulation.
77
+ - When focusing on specific tickers, only those positions are included in the simulation.
78
+ - The script calculates implied beta based on the portfolio's response to SPY changes.
79
+ """
80
+
81
+ import logging
82
+ import os
83
+ import sys
84
+ from pathlib import Path
85
+
86
+
87
+ # Check if running in the correct environment
88
+ # pylint: disable=import-outside-toplevel,unused-import
89
+ def check_environment():
90
+ """Check if the script is running in the correct environment with all dependencies."""
91
+ try:
92
+ # Try to import pandas to check if dependencies are installed
93
+ import pandas
94
+
95
+ return True
96
+ except ImportError:
97
+ # If pandas is not found, provide helpful error message
98
+ # pylint: disable=multiple-statements
99
+ return False
100
+
101
+
102
+ # Exit if not in the correct environment
103
+ if not check_environment():
104
+ sys.exit(1)
105
+
106
+ # Add the src directory to the Python path
107
+ sys.path.append(str(Path(__file__).parent.parent))
108
+
109
+ # pylint: disable=wrong-import-position
110
+ import pandas as pd
111
+ from rich import box
112
+ from rich.console import Console
113
+ from rich.panel import Panel
114
+ from rich.table import Table
115
+
116
+ from src.folio.formatting import format_currency
117
+ from src.folio.portfolio import process_portfolio_data
118
+ from src.folio.simulator import simulate_portfolio_with_spy_changes
119
+
120
+ console = Console()
121
+
122
+ # Configurable constants
123
+ DEFAULT_SPY_RANGE = 20.0 # Default range of SPY changes to simulate (±20%)
124
+ DEFAULT_STEPS = 13 # Default number of steps (gives 1% increments for default range)
125
+ CHART_WIDTH = 50 # Width of the ASCII chart visualization
126
+ CHART_HEIGHT = 10 # Height of the ASCII chart visualization
127
+ PORTFOLIO_PATH = "private-data/portfolio-private.csv" # Path to the portfolio CSV file
128
+
129
+ # Configure logging
130
+ logging.basicConfig(
131
+ level=logging.DEBUG,
132
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
133
+ handlers=[
134
+ logging.FileHandler("spy_simulator_debug.log", mode="w"),
135
+ logging.StreamHandler(),
136
+ ],
137
+ )
138
+ # Set specific loggers to INFO to reduce noise
139
+ logging.getLogger("matplotlib").setLevel(logging.INFO)
140
+ logging.getLogger("PIL").setLevel(logging.INFO)
141
+
142
+
143
+ def debug_simulate_portfolio(
144
+ portfolio_groups,
145
+ cash_like_positions=None,
146
+ pending_activity_value=0.0,
147
+ spy_range=10.0,
148
+ steps=21,
149
+ ):
150
+ """Run the portfolio simulator with detailed logging for debugging.
151
+
152
+ Args:
153
+ portfolio_groups: Dictionary of portfolio position groups
154
+ cash_like_positions: List of cash-like positions
155
+ pending_activity_value: Value of pending activity
156
+ spy_range: Range of SPY changes to simulate (e.g., 10.0 for ±10%)
157
+ steps: Number of steps in the simulation (default: 21)
158
+ """
159
+ logger = logging.getLogger("debug_simulator")
160
+ logger.info("Starting detailed portfolio simulation")
161
+ logger.info(f"Using SPY range of ±{spy_range}% with {steps} steps")
162
+
163
+ # Calculate the step size
164
+ step_size = (2 * spy_range) / (steps - 1) if steps > 1 else 0
165
+
166
+ # Generate the SPY changes
167
+ spy_changes = [-spy_range + i * step_size for i in range(steps)]
168
+
169
+ # Ensure we have a zero point
170
+ if 0.0 not in spy_changes and steps > 2:
171
+ # Find the closest point to zero and replace it with zero
172
+ closest_to_zero = min(spy_changes, key=lambda x: abs(x))
173
+ zero_index = spy_changes.index(closest_to_zero)
174
+ spy_changes[zero_index] = 0.0
175
+
176
+ # Convert to percentages
177
+ spy_changes = [change / 100.0 for change in spy_changes]
178
+
179
+ logger.info(f"SPY changes: {[f'{change:.1%}' for change in spy_changes]}")
180
+
181
+ # Get the original results from the simulator
182
+ results = simulate_portfolio_with_spy_changes(
183
+ portfolio_groups=portfolio_groups,
184
+ spy_changes=spy_changes,
185
+ cash_like_positions=cash_like_positions,
186
+ pending_activity_value=pending_activity_value,
187
+ )
188
+
189
+ # Add position-by-position analysis
190
+ logger.info("Analyzing position-by-position behavior")
191
+
192
+ # Track original values for each position
193
+ original_values = {}
194
+ zero_index = spy_changes.index(0.0)
195
+
196
+ # For each position, track how it changes with SPY
197
+ for group in portfolio_groups:
198
+ ticker = group.ticker
199
+ logger.info(f"Analyzing position: {ticker}")
200
+
201
+ # Calculate total market value
202
+ stock_value = group.stock_position.market_value if group.stock_position else 0
203
+ option_value = (
204
+ sum(op.market_value for op in group.option_positions)
205
+ if group.option_positions
206
+ else 0
207
+ )
208
+ total_market_value = stock_value + option_value
209
+
210
+ # Skip positions with no market value
211
+ if total_market_value == 0:
212
+ logger.info(f" Skipping {ticker} - zero market value")
213
+ continue
214
+
215
+ # Log position details
216
+ logger.info(f" Beta: {group.beta:.2f}")
217
+ logger.info(f" Market Value: {format_currency(total_market_value)}")
218
+
219
+ if group.stock_position:
220
+ logger.info(
221
+ f" Stock: {group.stock_position.quantity} shares @ {group.stock_position.price:.2f}"
222
+ )
223
+ logger.info(
224
+ f" Stock Value: {format_currency(group.stock_position.market_value)}"
225
+ )
226
+
227
+ if group.option_positions:
228
+ logger.info(f" Options: {len(group.option_positions)}")
229
+ for i, option in enumerate(group.option_positions):
230
+ logger.info(
231
+ f" Option {i + 1}: {option.quantity} {option.option_type} @ {option.strike:.2f}, expires {option.expiry}"
232
+ )
233
+ logger.info(
234
+ f" Price: {option.price:.4f}, Delta: {option.delta:.4f}"
235
+ )
236
+ logger.info(f" Value: {format_currency(option.market_value)}")
237
+
238
+ # Calculate total market value
239
+ stock_value = group.stock_position.market_value if group.stock_position else 0
240
+ option_value = (
241
+ sum(op.market_value for op in group.option_positions)
242
+ if group.option_positions
243
+ else 0
244
+ )
245
+ total_market_value = stock_value + option_value
246
+
247
+ # Calculate expected behavior based on beta
248
+ expected_change_at_10pct = group.beta * 0.1 * total_market_value
249
+ logger.info(
250
+ f" Expected change at +10% SPY: {format_currency(expected_change_at_10pct)}"
251
+ )
252
+
253
+ # Store original value
254
+ original_values[ticker] = total_market_value
255
+
256
+ # Analyze the results
257
+ logger.info("\nAnalyzing simulation results")
258
+
259
+ # Find positions with unexpected behavior
260
+
261
+ # Find the indices for min, max, and zero values
262
+ zero_index = next(
263
+ (i for i, change in enumerate(spy_changes) if abs(change) < 0.001), 0
264
+ )
265
+ min_index = 0 # First element (most negative)
266
+ max_index = len(spy_changes) - 1 # Last element (most positive)
267
+
268
+ # Calculate the total portfolio change
269
+ total_min = results["portfolio_values"][min_index]
270
+ total_zero = results["portfolio_values"][zero_index]
271
+ total_max = results["portfolio_values"][max_index]
272
+
273
+ min_change = spy_changes[min_index]
274
+ max_change = spy_changes[max_index]
275
+
276
+ logger.info(
277
+ f"Portfolio value at {min_change:.1%} SPY: {format_currency(total_min)}"
278
+ )
279
+ logger.info(f"Portfolio value at 0% SPY: {format_currency(total_zero)}")
280
+ logger.info(
281
+ f"Portfolio value at {max_change:.1%} SPY: {format_currency(total_max)}"
282
+ )
283
+
284
+ down_change = total_min - total_zero
285
+ up_change = total_max - total_zero
286
+
287
+ logger.info(
288
+ f"Change on {min_change:.1%} SPY: {format_currency(down_change)} ({down_change / total_zero * 100:.2f}%)"
289
+ )
290
+ logger.info(
291
+ f"Change on {max_change:.1%} SPY: {format_currency(up_change)} ({up_change / total_zero * 100:.2f}%)"
292
+ )
293
+
294
+ # Calculate implied beta
295
+ down_beta = -down_change / (total_zero * 0.1)
296
+ up_beta = up_change / (total_zero * 0.1)
297
+
298
+ logger.info(f"Implied beta on down moves: {down_beta:.2f}")
299
+ logger.info(f"Implied beta on up moves: {up_beta:.2f}")
300
+
301
+ # Add position-level analysis
302
+ if "position_values" in results:
303
+ logger.info("\nAnalyzing position-level contributions:")
304
+
305
+ # Calculate position-level changes
306
+ position_changes = {}
307
+ for ticker, values in results["position_values"].items():
308
+ if len(values) > zero_index:
309
+ base_value = values[zero_index]
310
+ if base_value == 0:
311
+ continue
312
+
313
+ # Calculate changes at min and max SPY values
314
+ min_spy_value = values[min_index] if min_index < len(values) else 0
315
+ max_spy_value = values[max_index] if max_index < len(values) else 0
316
+
317
+ down_change = min_spy_value - base_value
318
+ up_change = max_spy_value - base_value
319
+
320
+ # Calculate percentage changes
321
+ down_pct = (down_change / base_value) * 100 if base_value != 0 else 0
322
+ up_pct = (up_change / base_value) * 100 if base_value != 0 else 0
323
+
324
+ # Store the changes
325
+ position_changes[ticker] = {
326
+ "base_value": base_value,
327
+ "min_spy_value": min_spy_value,
328
+ "max_spy_value": max_spy_value,
329
+ "down_change": down_change,
330
+ "up_change": up_change,
331
+ "down_pct": down_pct,
332
+ "up_pct": up_pct,
333
+ }
334
+
335
+ logger.info(f" {ticker}:")
336
+ logger.info(f" Base Value: {format_currency(base_value)}")
337
+ logger.info(
338
+ f" Change at -10% SPY: {format_currency(down_change)} ({down_pct:.2f}%)"
339
+ )
340
+ logger.info(
341
+ f" Change at +10% SPY: {format_currency(up_change)} ({up_pct:.2f}%)"
342
+ )
343
+
344
+ # Find positions with largest contributions
345
+ sorted_down = sorted(
346
+ position_changes.items(), key=lambda x: x[1]["down_change"]
347
+ )
348
+ sorted_up = sorted(
349
+ position_changes.items(), key=lambda x: x[1]["up_change"], reverse=True
350
+ )
351
+
352
+ logger.info("\nLargest contributors to downside moves:")
353
+ for ticker, data in sorted_down[:5]:
354
+ logger.info(
355
+ f" {ticker}: {format_currency(data['down_change'])} ({data['down_pct']:.2f}%)"
356
+ )
357
+
358
+ logger.info("\nLargest contributors to upside moves:")
359
+ for ticker, data in sorted_up[:5]:
360
+ logger.info(
361
+ f" {ticker}: {format_currency(data['up_change'])} ({data['up_pct']:.2f}%)"
362
+ )
363
+
364
+ # Add the analysis to the results
365
+ results["analysis"] = {
366
+ "down_beta": down_beta,
367
+ "up_beta": up_beta,
368
+ "original_values": original_values,
369
+ "position_changes": position_changes if "position_values" in results else {},
370
+ }
371
+
372
+ # Add the portfolio groups to the results for position type identification
373
+ results["portfolio_groups"] = portfolio_groups
374
+
375
+ return results
376
+
377
+
378
+ def main():
379
+ # Parse command line arguments
380
+ import argparse
381
+
382
+ parser = argparse.ArgumentParser(
383
+ description="Portfolio SPY Simulator - Analyze portfolio behavior under different SPY price scenarios",
384
+ formatter_class=argparse.RawDescriptionHelpFormatter,
385
+ epilog="""
386
+ Examples:
387
+ # Run with default settings (±20% SPY range with 13 steps)
388
+ python scripts/folio-simulator.py
389
+
390
+ # Run with a narrower range of ±5% SPY with 11 steps (1% increments)
391
+ python scripts/folio-simulator.py --range 5 --steps 11
392
+
393
+ # Focus on a specific ticker with default range
394
+ python scripts/folio-simulator.py --focus SPY
395
+
396
+ # Focus on multiple tickers with a custom range
397
+ python scripts/folio-simulator.py --focus SPY,QQQ,AAPL --range 15 --steps 31
398
+
399
+ # Show detailed analysis for all positions
400
+ python scripts/folio-simulator.py --detailed
401
+ """,
402
+ )
403
+
404
+ # Add a group for simulation parameters
405
+ sim_group = parser.add_argument_group("Simulation Parameters")
406
+ sim_group.add_argument(
407
+ "--range",
408
+ type=float,
409
+ default=DEFAULT_SPY_RANGE,
410
+ help=f"SPY change range in percent (default: ±{DEFAULT_SPY_RANGE}%%)",
411
+ metavar="PERCENT",
412
+ )
413
+ sim_group.add_argument(
414
+ "--steps",
415
+ type=int,
416
+ default=DEFAULT_STEPS,
417
+ help=f"Number of steps in the simulation (default: {DEFAULT_STEPS}, which gives {DEFAULT_SPY_RANGE / (DEFAULT_STEPS - 1) * 2:.1f}%% increments for default range)",
418
+ metavar="N",
419
+ )
420
+
421
+ # Add a group for filtering and display options
422
+ filter_group = parser.add_argument_group("Filtering and Display Options")
423
+ filter_group.add_argument(
424
+ "--focus",
425
+ type=str,
426
+ help="Comma-separated list of tickers to focus on (e.g., 'SPY,QQQ,AAPL')",
427
+ metavar="TICKERS",
428
+ )
429
+ filter_group.add_argument(
430
+ "--detailed",
431
+ action="store_true",
432
+ help="Show detailed analysis for all positions",
433
+ )
434
+
435
+ args = parser.parse_args()
436
+
437
+ # Path to the portfolio CSV file
438
+ csv_path = Path(os.getcwd()) / PORTFOLIO_PATH
439
+
440
+ if not csv_path.exists():
441
+ sys.exit(1)
442
+
443
+ try:
444
+ # Read the CSV file
445
+ df = pd.read_csv(csv_path)
446
+
447
+ # Process the portfolio data with price updates
448
+ groups, summary, _ = process_portfolio_data(df, update_prices=True)
449
+
450
+ # Filter portfolio if focus is specified
451
+ focus_tickers = []
452
+ if args.focus:
453
+ focus_tickers = [ticker.strip().upper() for ticker in args.focus.split(",")]
454
+
455
+ # Create a filtered portfolio with only the specified positions
456
+ filtered_groups = {}
457
+
458
+ # Convert list to dictionary if needed
459
+ if isinstance(groups, list):
460
+ groups_dict = {group.ticker: group for group in groups}
461
+ else:
462
+ groups_dict = groups
463
+
464
+ for ticker, group in groups_dict.items():
465
+ if ticker in focus_tickers:
466
+ filtered_groups[ticker] = group
467
+
468
+ if not filtered_groups:
469
+ pass
470
+ # Use the filtered groups
471
+ elif isinstance(groups, list):
472
+ groups = list(filtered_groups.values())
473
+ else:
474
+ groups = filtered_groups
475
+
476
+ # Run the simulation with detailed debugging
477
+ results = debug_simulate_portfolio(
478
+ portfolio_groups=groups,
479
+ cash_like_positions=summary.cash_like_positions,
480
+ pending_activity_value=getattr(summary, "pending_activity_value", 0.0),
481
+ spy_range=args.range,
482
+ steps=args.steps,
483
+ )
484
+
485
+ # Print the results
486
+ # Get the current value (at 0% SPY change)
487
+ current_value = results["current_value"]
488
+
489
+ # Get min and max values
490
+ min_value = min(results["portfolio_values"])
491
+ max_value = max(results["portfolio_values"])
492
+ min_index = results["portfolio_values"].index(min_value)
493
+ max_index = results["portfolio_values"].index(max_value)
494
+ min_spy_change = (
495
+ results["spy_changes"][min_index] * 100
496
+ ) # Convert to percentage
497
+ max_spy_change = (
498
+ results["spy_changes"][max_index] * 100
499
+ ) # Convert to percentage
500
+
501
+ # Create a table of all SPY changes and portfolio values
502
+ if True:
503
+ console.print("\n[bold cyan]Portfolio Simulation Results[/bold cyan]")
504
+
505
+ # Create a summary table
506
+ summary_table = Table(title="Portfolio Summary", box=box.ROUNDED)
507
+ summary_table.add_column("Metric", style="cyan")
508
+ summary_table.add_column("Value", style="green")
509
+ summary_table.add_column("SPY Change", style="yellow")
510
+
511
+ summary_table.add_row("Current Value", f"${current_value:,.2f}", "0.0%")
512
+ summary_table.add_row(
513
+ "Minimum Value", f"${min_value:,.2f}", f"{min_spy_change:.1f}%"
514
+ )
515
+ summary_table.add_row(
516
+ "Maximum Value", f"${max_value:,.2f}", f"{max_spy_change:.1f}%"
517
+ )
518
+
519
+ console.print(summary_table)
520
+
521
+ # Create a detailed table with all values
522
+ value_table = Table(
523
+ title="Portfolio Values at Different SPY Changes", box=box.ROUNDED
524
+ )
525
+ value_table.add_column("SPY Change", style="yellow")
526
+ value_table.add_column("Portfolio Value", style="green")
527
+ value_table.add_column("Change", style="cyan")
528
+ value_table.add_column("% Change", style="magenta")
529
+
530
+ for i, spy_change in enumerate(results["spy_changes"]):
531
+ portfolio_value = results["portfolio_values"][i]
532
+ value_change = portfolio_value - current_value
533
+ pct_change = (
534
+ (value_change / current_value) * 100 if current_value != 0 else 0
535
+ )
536
+
537
+ # Format the change with color based on positive/negative
538
+ change_str = f"${value_change:+,.2f}"
539
+ pct_change_str = f"{pct_change:+.2f}%"
540
+
541
+ value_table.add_row(
542
+ f"{spy_change * 100:.1f}%",
543
+ f"${portfolio_value:,.2f}",
544
+ change_str,
545
+ pct_change_str,
546
+ )
547
+
548
+ console.print(value_table)
549
+
550
+ # Create a visualization of the portfolio value curve
551
+
552
+ # Find min and max values for scaling
553
+ min_value = min(results["portfolio_values"])
554
+ max_value = max(results["portfolio_values"])
555
+ value_range = max_value - min_value
556
+
557
+ if True:
558
+ # Create a panel for the chart
559
+ chart_title = f"Portfolio Value vs SPY Change (Min: ${min_value:,.2f}, Max: ${max_value:,.2f})"
560
+
561
+ # Create the visualization using Unicode block characters for a smoother chart
562
+ chart = [" " * CHART_WIDTH for _ in range(CHART_HEIGHT)]
563
+
564
+ # Map SPY changes to x positions
565
+ x_positions = []
566
+ for spy_change in results["spy_changes"]:
567
+ # Map from min to max SPY change to 0 to CHART_WIDTH-1
568
+ min_spy = results["spy_changes"][0]
569
+ max_spy = results["spy_changes"][-1]
570
+ spy_range = max_spy - min_spy
571
+ x_pos = int((spy_change - min_spy) / spy_range * (CHART_WIDTH - 1))
572
+ x_positions.append(max(0, min(CHART_WIDTH - 1, x_pos)))
573
+
574
+ # Map portfolio values to y positions
575
+ y_positions = []
576
+ for value in results["portfolio_values"]:
577
+ if value_range > 0:
578
+ # Map from min_value to max_value to 0 to CHART_HEIGHT-1
579
+ y_pos = int((value - min_value) / value_range * (CHART_HEIGHT - 1))
580
+ y_positions.append(max(0, min(CHART_HEIGHT - 1, y_pos)))
581
+ else:
582
+ y_positions.append(CHART_HEIGHT // 2)
583
+
584
+ # Plot the points with different characters based on position
585
+ for x, y in zip(x_positions, y_positions, strict=False):
586
+ row = chart[y]
587
+ # Use different characters for different parts of the curve
588
+ if y == 0: # Top of chart
589
+ char = "▲"
590
+ elif y == CHART_HEIGHT - 1: # Bottom of chart
591
+ char = "▼"
592
+ else:
593
+ char = "●"
594
+ chart[y] = row[:x] + char + row[x + 1 :]
595
+
596
+ # Create the chart string
597
+ chart_str = "\n".join(
598
+ ["|" + row + "|" for row in chart[::-1]]
599
+ ) # Reverse to show bottom to top
600
+
601
+ # Add a border
602
+ border = "+" + "-" * CHART_WIDTH + "+"
603
+ chart_str = border + "\n" + chart_str + "\n" + border
604
+
605
+ # Add axis labels
606
+ min_spy_label = f"{results['spy_changes'][0] * 100:.1f}%"
607
+ max_spy_label = f"{results['spy_changes'][-1] * 100:.1f}%"
608
+ zero_spy_index = next(
609
+ (
610
+ i
611
+ for i, change in enumerate(results["spy_changes"])
612
+ if abs(change) < 0.001
613
+ ),
614
+ None,
615
+ )
616
+ zero_spy_label = "0.0%" if zero_spy_index is not None else ""
617
+
618
+ axis_labels = f"{min_spy_label}{' ' * (CHART_WIDTH - len(min_spy_label) - len(max_spy_label))}{max_spy_label}"
619
+ if zero_spy_index is not None:
620
+ zero_pos = int(
621
+ zero_spy_index / (len(results["spy_changes"]) - 1) * CHART_WIDTH
622
+ )
623
+ axis_labels = (
624
+ axis_labels[:zero_pos]
625
+ + zero_spy_label
626
+ + axis_labels[zero_pos + len(zero_spy_label) :]
627
+ )
628
+
629
+ chart_str += "\n" + axis_labels
630
+
631
+ # Create a panel with the chart
632
+ chart_panel = Panel(chart_str, title=chart_title, border_style="cyan")
633
+ console.print(chart_panel)
634
+
635
+ # Add value scale
636
+ value_range / 4 if value_range > 0 else 0
637
+
638
+ # Print summary analysis
639
+ min_value = min(results["portfolio_values"])
640
+ max_value = max(results["portfolio_values"])
641
+ min_change = min_value - current_value
642
+ max_change = max_value - current_value
643
+ min_pct_change = (min_change / current_value) * 100 if current_value != 0 else 0
644
+ max_pct_change = (max_change / current_value) * 100 if current_value != 0 else 0
645
+
646
+ min_index = results["portfolio_values"].index(min_value)
647
+ max_index = results["portfolio_values"].index(max_value)
648
+ min_spy_change = (
649
+ results["spy_changes"][min_index] * 100
650
+ ) # Convert to percentage
651
+ max_spy_change = (
652
+ results["spy_changes"][max_index] * 100
653
+ ) # Convert to percentage
654
+
655
+ # Print summary analysis
656
+ if True:
657
+ summary_table = Table(title="Portfolio Value Summary", box=box.ROUNDED)
658
+ summary_table.add_column("Scenario", style="cyan")
659
+ summary_table.add_column("Value", style="green")
660
+ summary_table.add_column("Change", style="magenta")
661
+ summary_table.add_column("SPY Change", style="yellow")
662
+
663
+ summary_table.add_row(
664
+ "Worst Case",
665
+ f"${min_value:,.2f}",
666
+ f"{min_pct_change:+.2f}%",
667
+ f"{min_spy_change:.1f}%",
668
+ )
669
+ summary_table.add_row(
670
+ "Best Case",
671
+ f"${max_value:,.2f}",
672
+ f"{max_pct_change:+.2f}%",
673
+ f"{max_spy_change:.1f}%",
674
+ )
675
+
676
+ console.print(summary_table)
677
+
678
+ # Print position-level analysis
679
+ if "position_values" in results:
680
+ # Find the indices for min, max, and zero values
681
+ try:
682
+ zero_index = next(
683
+ (
684
+ i
685
+ for i, change in enumerate(results["spy_changes"])
686
+ if abs(change) < 0.001
687
+ ),
688
+ 0,
689
+ )
690
+ min_index = 0 # First element (most negative)
691
+ max_index = (
692
+ len(results["spy_changes"]) - 1
693
+ ) # Last element (most positive)
694
+
695
+ # Calculate position-level changes
696
+ position_changes = {}
697
+ for ticker, values in results["position_values"].items():
698
+ if len(values) > zero_index:
699
+ base_value = values[zero_index]
700
+ if base_value == 0:
701
+ continue
702
+
703
+ # Calculate changes at min and max SPY values
704
+ min_spy_value = (
705
+ values[min_index] if min_index < len(values) else 0
706
+ )
707
+ max_spy_value = (
708
+ values[max_index] if max_index < len(values) else 0
709
+ )
710
+
711
+ down_change = min_spy_value - base_value
712
+ up_change = max_spy_value - base_value
713
+
714
+ # Calculate percentage changes
715
+ down_pct = (
716
+ (down_change / base_value) * 100 if base_value != 0 else 0
717
+ )
718
+ up_pct = (
719
+ (up_change / base_value) * 100 if base_value != 0 else 0
720
+ )
721
+
722
+ # Store the changes
723
+ position_changes[ticker] = {
724
+ "base_value": base_value,
725
+ "min_spy_value": min_spy_value,
726
+ "max_spy_value": max_spy_value,
727
+ "down_change": down_change,
728
+ "up_change": up_change,
729
+ "down_pct": down_pct,
730
+ "up_pct": up_pct,
731
+ }
732
+
733
+ # Get position details for comparison
734
+ position_details = results.get("position_details", {})
735
+
736
+ # Find positions with largest contributions
737
+ sorted_down = sorted(
738
+ position_changes.items(), key=lambda x: x[1]["down_change"]
739
+ )
740
+ sorted_up = sorted(
741
+ position_changes.items(),
742
+ key=lambda x: x[1]["up_change"],
743
+ reverse=True,
744
+ )
745
+
746
+ # Print positions with largest downside contributions
747
+ for ticker, data in sorted_down[:5]:
748
+ # Calculate expected change based on beta
749
+ beta = position_details.get(ticker, {}).get("beta", 0)
750
+ base_value = data["base_value"]
751
+ expected_change = (
752
+ beta * min_spy_change * base_value
753
+ ) # Expected change at min SPY
754
+ actual_change = data["down_change"]
755
+ difference = actual_change - expected_change
756
+ (
757
+ (difference / abs(expected_change)) * 100
758
+ if expected_change != 0
759
+ else 0
760
+ )
761
+
762
+ # Print positions with largest upside contributions
763
+ for ticker, data in sorted_up[:5]:
764
+ # Calculate expected change based on beta
765
+ beta = position_details.get(ticker, {}).get("beta", 0)
766
+ base_value = data["base_value"]
767
+ expected_change = (
768
+ beta * max_spy_change * base_value
769
+ ) # Expected change at max SPY
770
+ actual_change = data["up_change"]
771
+ difference = actual_change - expected_change
772
+ (
773
+ (difference / abs(expected_change)) * 100
774
+ if expected_change != 0
775
+ else 0
776
+ )
777
+
778
+ # Find positions that lose value when SPY goes up (negative correlation)
779
+ negative_correlation = []
780
+ for ticker, data in position_changes.items():
781
+ # If position value decreases when SPY increases
782
+ if data["up_change"] < 0:
783
+ # For any ticker, provide detailed position analysis when requested
784
+ if args.detailed:
785
+ pass
786
+ negative_correlation.append((ticker, data))
787
+
788
+ if negative_correlation:
789
+ # Sort by the magnitude of negative impact
790
+ negative_correlation.sort(key=lambda x: x[1]["up_change"])
791
+
792
+ if True:
793
+ # Create a table for positions that lose value when SPY goes up
794
+ neg_corr_table = Table(
795
+ title="Positions that LOSE value when SPY goes UP (negative correlation)",
796
+ box=box.ROUNDED,
797
+ )
798
+ neg_corr_table.add_column("Ticker", style="cyan")
799
+ neg_corr_table.add_column("Change", style="red")
800
+ neg_corr_table.add_column("% Change", style="red")
801
+ neg_corr_table.add_column("Beta", style="yellow")
802
+
803
+ for ticker, data in negative_correlation:
804
+ beta = position_details.get(ticker, {}).get("beta", 0)
805
+ neg_corr_table.add_row(
806
+ ticker,
807
+ f"${data['up_change']:+,.2f}",
808
+ f"{data['up_pct']:+.2f}%",
809
+ f"{beta:+.2f}",
810
+ )
811
+
812
+ console.print(neg_corr_table)
813
+
814
+ # Find positions that gain value when SPY goes down (inverse correlation)
815
+ inverse_correlation = []
816
+ for ticker, data in position_changes.items():
817
+ # If position value increases when SPY decreases
818
+ if data["down_change"] > 0:
819
+ inverse_correlation.append((ticker, data))
820
+
821
+ if inverse_correlation:
822
+ # Sort by the magnitude of positive impact
823
+ inverse_correlation.sort(
824
+ key=lambda x: x[1]["down_change"], reverse=True
825
+ )
826
+
827
+ if True:
828
+ # Create a table for positions that gain value when SPY goes down
829
+ inv_corr_table = Table(
830
+ title="Positions that GAIN value when SPY goes DOWN (inverse correlation)",
831
+ box=box.ROUNDED,
832
+ )
833
+ inv_corr_table.add_column("Ticker", style="cyan")
834
+ inv_corr_table.add_column("Change", style="green")
835
+ inv_corr_table.add_column("% Change", style="green")
836
+ inv_corr_table.add_column("Beta", style="yellow")
837
+
838
+ for ticker, data in inverse_correlation:
839
+ beta = position_details.get(ticker, {}).get("beta", 0)
840
+ inv_corr_table.add_row(
841
+ ticker,
842
+ f"${data['down_change']:+,.2f}",
843
+ f"{data['down_pct']:+.2f}%",
844
+ f"{beta:+.2f}",
845
+ )
846
+
847
+ console.print(inv_corr_table)
848
+
849
+ except (StopIteration, IndexError):
850
+ pass
851
+
852
+ # Calculate the portfolio's beta based on the simulation results
853
+ try:
854
+ # Get the min and max values
855
+ min_index = 0 # First element (most negative)
856
+ max_index = len(results["spy_changes"]) - 1 # Last element (most positive)
857
+
858
+ min_value = results["portfolio_values"][min_index]
859
+ max_value = results["portfolio_values"][max_index]
860
+
861
+ min_spy_change = results["spy_changes"][min_index]
862
+ max_spy_change = results["spy_changes"][max_index]
863
+
864
+ min_pct_change = (min_value / current_value - 1) * 100
865
+ max_pct_change = (max_value / current_value - 1) * 100
866
+
867
+ # Calculate beta for up and down moves
868
+ beta_up = max_pct_change / (
869
+ max_spy_change * 100
870
+ ) # How much portfolio changes per 1% SPY up move
871
+ beta_down = min_pct_change / (
872
+ min_spy_change * 100
873
+ ) # How much portfolio changes per 1% SPY down move
874
+ (beta_up + beta_down) / 2
875
+
876
+ # Print beta analysis
877
+ if True:
878
+ beta_table = Table(title="Portfolio Beta Analysis", box=box.ROUNDED)
879
+ beta_table.add_column("Direction", style="cyan")
880
+ beta_table.add_column("Beta", style="yellow")
881
+
882
+ beta_table.add_row("Up Moves", f"{beta_up:.2f}")
883
+ beta_table.add_row("Down Moves", f"{beta_down:.2f}")
884
+ beta_table.add_row("Average", f"{(beta_up + beta_down) / 2:.2f}")
885
+
886
+ console.print(beta_table)
887
+
888
+ if abs(beta_up - beta_down) > 0.5:
889
+ console.print(
890
+ f"[bold yellow]Note:[/bold yellow] Beta difference of {abs(beta_up - beta_down):.2f} indicates non-linear behavior"
891
+ )
892
+ except (StopIteration, IndexError):
893
+ pass
894
+
895
+ except Exception:
896
+ import traceback
897
+
898
+ traceback.print_exc()
899
+ sys.exit(1)
900
+
901
+
902
+ if __name__ == "__main__":
903
+ main()
scripts/install-reqs.sh ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Script to install all required dependencies for the omninmo project
3
+
4
+ echo "Installing dependencies for omninmo..."
5
+
6
+ # Get the directory of this script
7
+ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
8
+
9
+ # Get the project root directory
10
+ PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." &> /dev/null && pwd )"
11
+
12
+ # Check if we're in a virtual environment
13
+ if [ -z "$VIRTUAL_ENV" ]; then
14
+ echo "Error: Not in a virtual environment. Please activate the virtual environment first."
15
+ exit 1
16
+ fi
17
+
18
+ # Function to check if a package is installed
19
+ is_package_installed() {
20
+ python3 -m pip show "$1" &> /dev/null
21
+ }
22
+
23
+ # Install packages with proper flags to avoid system conflicts
24
+ install_package() {
25
+ echo "Installing $1..."
26
+ python3 -m pip install --no-cache-dir "$1" --no-warn-script-location
27
+ }
28
+
29
+ # Install matplotlib separately first with specific flags
30
+ echo "Installing matplotlib..."
31
+ if ! is_package_installed "matplotlib"; then
32
+ CFLAGS="-I/opt/homebrew/include -I/opt/homebrew/include/freetype2" \
33
+ LDFLAGS="-L/opt/homebrew/lib" \
34
+ python3 -m pip install --no-cache-dir matplotlib --no-warn-script-location
35
+ fi
36
+
37
+ # Install Folio app dependencies from requirements.txt
38
+ echo "Installing Folio app dependencies from requirements.txt..."
39
+ while IFS= read -r package || [ -n "$package" ]; do
40
+ # Skip empty lines and comments
41
+ if [[ -z "$package" || "$package" =~ ^# ]]; then
42
+ continue
43
+ fi
44
+ install_package "$package"
45
+ done < "$PROJECT_ROOT/requirements.txt"
46
+
47
+ # Install additional development dependencies from requirements.txt
48
+ echo "Installing additional development dependencies from requirements.txt..."
49
+ while IFS= read -r package || [ -n "$package" ]; do
50
+ # Skip empty lines and comments
51
+ if [[ -z "$package" || "$package" =~ ^# ]]; then
52
+ continue
53
+ fi
54
+ # Skip matplotlib as we've already installed it
55
+ if [[ "$package" != matplotlib* ]]; then
56
+ install_package "$package"
57
+ fi
58
+ done < "$PROJECT_ROOT/requirements.txt"
59
+
60
+ # Install development tools from requirements-dev.txt
61
+ echo "Installing development tools from requirements-dev.txt..."
62
+ while IFS= read -r package || [ -n "$package" ]; do
63
+ # Skip empty lines and comments
64
+ if [[ -z "$package" || "$package" =~ ^# ]]; then
65
+ continue
66
+ fi
67
+ install_package "$package"
68
+ done < "$PROJECT_ROOT/requirements-dev.txt"
69
+
70
+ # Make all Python scripts executable
71
+ echo "Making Python scripts executable..."
72
+ chmod +x "$SCRIPT_DIR"/*.py
73
+
74
+ echo "Installation complete!"
scripts/run_mlflow.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Script to start the MLflow UI for viewing model training results.
4
+ """
5
+
6
+ import argparse
7
+ import os
8
+ import subprocess
9
+ import sys
10
+
11
+
12
+ def main():
13
+ """Start the MLflow UI server"""
14
+ parser = argparse.ArgumentParser(description='Start the MLflow UI server')
15
+ parser.add_argument('--port', type=int, default=5000, help='Port to run the server on (default: 5000)')
16
+ parser.add_argument('--host', type=str, default='127.0.0.1', help='Host to run the server on (default: 127.0.0.1)')
17
+ args = parser.parse_args()
18
+
19
+ # Get the project root directory
20
+ script_dir = os.path.dirname(os.path.abspath(__file__))
21
+ project_root = os.path.dirname(script_dir)
22
+
23
+ # Set the MLflow tracking URI
24
+ mlruns_dir = os.path.join(project_root, 'mlruns')
25
+ tracking_uri = f"file:{mlruns_dir}"
26
+
27
+
28
+ # Start the MLflow UI
29
+ cmd = [
30
+ "mlflow", "ui",
31
+ "--backend-store-uri", tracking_uri,
32
+ "--host", args.host,
33
+ "--port", str(args.port)
34
+ ]
35
+
36
+ try:
37
+ subprocess.run(cmd, check=False)
38
+ except KeyboardInterrupt:
39
+ pass
40
+ except Exception:
41
+ sys.exit(1)
42
+
43
+ if __name__ == "__main__":
44
+ main()
scripts/setup-venv.sh ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Script to set up and use a virtual environment for the omninmo project
3
+
4
+ echo "Setting up virtual environment for omninmo..."
5
+
6
+ # Get the directory of this script
7
+ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
8
+
9
+ # Get the project root directory
10
+ PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." &> /dev/null && pwd )"
11
+
12
+ # Define the virtual environment directory
13
+ VENV_DIR="$PROJECT_ROOT/venv"
14
+
15
+ # Check if python3 is installed
16
+ if ! command -v python3 &> /dev/null; then
17
+ echo "Error: python3 is not installed. Please install python3 first."
18
+ exit 1
19
+ fi
20
+
21
+ # Check if venv module is available
22
+ if ! python3 -m venv --help &> /dev/null; then
23
+ echo "Error: python3-venv is not installed. Please install it first."
24
+ echo "On Ubuntu/Debian: sudo apt-get install python3-venv"
25
+ echo "On Fedora: sudo dnf install python3-venv"
26
+ echo "On macOS: python3 -m pip install --user virtualenv"
27
+ exit 1
28
+ fi
29
+
30
+ # Create virtual environment if it doesn't exist
31
+ if [ ! -d "$VENV_DIR" ]; then
32
+ echo "Creating virtual environment in $VENV_DIR..."
33
+ python3 -m venv "$VENV_DIR"
34
+ if [ $? -ne 0 ]; then
35
+ echo "Error: Failed to create virtual environment."
36
+ exit 1
37
+ fi
38
+ echo "Virtual environment created successfully."
39
+ else
40
+ echo "Virtual environment already exists at $VENV_DIR."
41
+ fi
42
+
43
+ # Activate the virtual environment
44
+ echo "Activating virtual environment..."
45
+ source "$VENV_DIR/bin/activate"
46
+
47
+ # Upgrade pip
48
+ echo "Upgrading pip..."
49
+ pip3 install --upgrade pip
50
+
51
+ # Create a script to activate the virtual environment
52
+ ACTIVATE_SCRIPT="$PROJECT_ROOT/activate-venv.sh"
53
+ echo "Creating activation script at $ACTIVATE_SCRIPT..."
54
+ cat > "$ACTIVATE_SCRIPT" << EOF
55
+ #!/bin/bash
56
+ # Script to activate the virtual environment
57
+ source "$VENV_DIR/bin/activate"
58
+ echo "Virtual environment activated. Run 'deactivate' to exit."
59
+ EOF
60
+
61
+ chmod +x "$ACTIVATE_SCRIPT"
62
+
63
+ echo "Virtual environment setup complete!"
64
+ echo "To activate the virtual environment, run: source $ACTIVATE_SCRIPT"
65
+ echo "To install dependencies in the virtual environment, run: make install"
scripts/validate_pnl.py ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Validation script for P&L calculations using real portfolio data.
4
+
5
+ This script loads portfolio data from private-data/, processes the options,
6
+ and validates P&L calculations for a position group (e.g., SPY options).
7
+ """
8
+
9
+ import logging
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import matplotlib.pyplot as plt
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ # Add the project root to the Python path
19
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
+
21
+ from src.folio.pnl import (
22
+ calculate_strategy_pnl,
23
+ determine_price_range,
24
+ summarize_strategy_pnl,
25
+ )
26
+ from src.folio.portfolio import process_portfolio_data
27
+
28
+ # Configure logging
29
+ logging.basicConfig(
30
+ level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
31
+ )
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ def print_position_details(positions):
36
+ """
37
+ Print details of positions for debugging.
38
+
39
+ Args:
40
+ positions: List of positions
41
+ """
42
+
43
+ for _i, pos in enumerate(positions):
44
+ position_type = getattr(pos, "position_type", "unknown")
45
+ if position_type == "stock":
46
+ pass
47
+ elif position_type == "option":
48
+ pass
49
+ else:
50
+ pass
51
+
52
+
53
+ def validate_pnl_for_group(group):
54
+ """
55
+ Validate P&L calculations for a position group.
56
+
57
+ Args:
58
+ group: Position group
59
+
60
+ Returns:
61
+ P&L data
62
+ """
63
+ # Collect all positions in the group
64
+ all_positions = []
65
+ if group.stock_position:
66
+ all_positions.append(group.stock_position)
67
+
68
+ all_positions.extend(group.option_positions)
69
+
70
+ if not all_positions:
71
+ logger.warning(f"No positions found in group {group.ticker}")
72
+ return None
73
+
74
+ # Print position details for debugging
75
+ print_position_details(all_positions)
76
+
77
+ # Get current price from stock position or first option position
78
+ current_price = None
79
+ if group.stock_position:
80
+ current_price = group.stock_position.price
81
+ elif group.option_positions:
82
+ # Try to estimate underlying price from notional value and quantity
83
+ first_option = group.option_positions[0]
84
+ if hasattr(first_option, "notional_value") and hasattr(
85
+ first_option, "quantity"
86
+ ):
87
+ # Notional value is 100 * underlying price * abs(quantity)
88
+ abs_quantity = abs(first_option.quantity)
89
+ if abs_quantity > 0:
90
+ current_price = first_option.notional_value / (100 * abs_quantity)
91
+
92
+ if current_price is None:
93
+ # Fallback to a default value
94
+ current_price = 100.0
95
+ logger.warning(
96
+ f"Could not determine current price for {group.ticker}, using default: ${current_price:.2f}"
97
+ )
98
+ else:
99
+ logger.info(f"Using current price for {group.ticker}: ${current_price:.2f}")
100
+
101
+ # Calculate price range
102
+ price_range = determine_price_range(all_positions, current_price)
103
+ logger.info(f"Price range: ${price_range[0]:.2f} to ${price_range[1]:.2f}")
104
+
105
+ # Calculate P&L using current price as entry price (default mode)
106
+ pnl_data_default = calculate_strategy_pnl(
107
+ all_positions, price_range=price_range, num_points=100, use_cost_basis=False
108
+ )
109
+
110
+ # Generate summary for default mode
111
+ summary_default = summarize_strategy_pnl(pnl_data_default, current_price)
112
+
113
+ # Calculate P&L using cost basis as entry price
114
+ pnl_data_cost_basis = calculate_strategy_pnl(
115
+ all_positions, price_range=price_range, num_points=100, use_cost_basis=True
116
+ )
117
+
118
+ # Generate summary for cost basis mode
119
+ summary_cost_basis = summarize_strategy_pnl(pnl_data_cost_basis, current_price)
120
+
121
+ # Return both sets of data
122
+ return {
123
+ "default": (pnl_data_default, summary_default),
124
+ "cost_basis": (pnl_data_cost_basis, summary_cost_basis),
125
+ }, current_price
126
+
127
+
128
+ def plot_pnl(
129
+ pnl_data, summary, current_price, ticker, mode="default", output_dir=".tmp"
130
+ ):
131
+ """
132
+ Plot P&L data and save to file.
133
+
134
+ Args:
135
+ pnl_data: P&L data from calculate_strategy_pnl
136
+ summary: Summary data from summarize_strategy_pnl
137
+ current_price: Current price of the underlying
138
+ ticker: Ticker symbol
139
+ mode: Mode used for P&L calculation ("default" or "cost_basis")
140
+ output_dir: Directory to save plot
141
+ """
142
+ # Create output directory if it doesn't exist
143
+ os.makedirs(output_dir, exist_ok=True)
144
+
145
+ # Create figure
146
+ plt.figure(figsize=(12, 8))
147
+
148
+ # Plot combined P&L
149
+ plt.plot(
150
+ pnl_data["price_points"],
151
+ pnl_data["pnl_values"],
152
+ "b-",
153
+ linewidth=2,
154
+ label=f"{ticker} Strategy P&L",
155
+ )
156
+
157
+ # Plot individual position P&Ls
158
+ if "individual_pnls" in pnl_data:
159
+ for i, pos_pnl in enumerate(pnl_data["individual_pnls"]):
160
+ pos_desc = pos_pnl.get("position", {}).get("ticker", f"Position {i + 1}")
161
+ plt.plot(
162
+ pos_pnl["price_points"],
163
+ pos_pnl["pnl_values"],
164
+ "--",
165
+ linewidth=1,
166
+ alpha=0.5,
167
+ label=pos_desc,
168
+ )
169
+
170
+ # Add reference lines
171
+ plt.axhline(y=0, color="r", linestyle="-", alpha=0.3)
172
+ plt.axvline(
173
+ x=current_price,
174
+ color="g",
175
+ linestyle="--",
176
+ alpha=0.5,
177
+ label=f"Current Price: ${current_price:.2f}",
178
+ )
179
+
180
+ # Add breakeven points
181
+ for bp in summary["breakeven_points"]:
182
+ plt.axvline(x=bp, color="orange", linestyle=":", alpha=0.5)
183
+ plt.text(bp, 0, f"BE: ${bp:.2f}", rotation=90, verticalalignment="center")
184
+
185
+ # Add max profit/loss points
186
+ max_profit_price = summary["max_profit_price"]
187
+ max_profit = summary["max_profit"]
188
+ plt.plot(max_profit_price, max_profit, "go", markersize=8)
189
+ plt.text(
190
+ max_profit_price,
191
+ max_profit,
192
+ f"Max Profit: ${max_profit:.2f}",
193
+ verticalalignment="bottom",
194
+ horizontalalignment="center",
195
+ )
196
+
197
+ max_loss_price = summary["max_loss_price"]
198
+ max_loss = summary["max_loss"]
199
+ plt.plot(max_loss_price, max_loss, "ro", markersize=8)
200
+ plt.text(
201
+ max_loss_price,
202
+ max_loss,
203
+ f"Max Loss: ${max_loss:.2f}",
204
+ verticalalignment="top",
205
+ horizontalalignment="center",
206
+ )
207
+
208
+ # Add current P&L
209
+ current_pnl = summary["current_pnl"]
210
+ plt.plot(current_price, current_pnl, "yo", markersize=8)
211
+ plt.text(
212
+ current_price,
213
+ current_pnl,
214
+ f"Current P&L: ${current_pnl:.2f}",
215
+ verticalalignment="bottom",
216
+ horizontalalignment="right",
217
+ )
218
+
219
+ # Set labels and title
220
+ mode_label = "Using Cost Basis" if mode == "cost_basis" else "Using Current Price"
221
+ plt.title(f"P&L Analysis for {ticker} Position Group ({mode_label})")
222
+ plt.xlabel(f"{ticker} Price")
223
+ plt.ylabel("P&L ($)")
224
+ plt.grid(True, alpha=0.3)
225
+ plt.legend(loc="best")
226
+
227
+ # Save the plot
228
+ output_file = os.path.join(
229
+ output_dir, f"{ticker.lower()}_pnl_validation_{mode}.png"
230
+ )
231
+ plt.savefig(output_file)
232
+ logger.info(f"P&L plot saved to {output_file}")
233
+
234
+ # Close the figure to free memory
235
+ plt.close()
236
+
237
+
238
+ def main():
239
+ """
240
+ Load portfolio data, process options, and validate P&L calculations.
241
+ """
242
+ # Find the most recent portfolio file in private-data/
243
+ private_data_dir = Path("private-data")
244
+ if not private_data_dir.exists():
245
+ logger.error(f"Private data directory not found: {private_data_dir}")
246
+ return
247
+
248
+ portfolio_files = list(private_data_dir.glob("pf-*.csv"))
249
+ if not portfolio_files:
250
+ logger.error(f"No portfolio files found in {private_data_dir}")
251
+ return
252
+
253
+ # Sort by filename (assuming format pf-YYYYMMDD.csv)
254
+ portfolio_files.sort(reverse=True)
255
+ portfolio_file = portfolio_files[0]
256
+ logger.info(f"Using portfolio file: {portfolio_file}")
257
+
258
+ # Load portfolio data
259
+ df = pd.read_csv(portfolio_file)
260
+ logger.info(f"Loaded portfolio data with {len(df)} positions")
261
+
262
+ # Process portfolio data
263
+ try:
264
+ # process_portfolio_data returns (groups, summary, cash_positions)
265
+ result = process_portfolio_data(df)
266
+ if isinstance(result, tuple) and len(result) >= 2:
267
+ groups, summary = result[0], result[1]
268
+ logger.info(
269
+ f"Portfolio data processed successfully with {len(groups)} groups"
270
+ )
271
+ else:
272
+ logger.error("Unexpected result format from process_portfolio_data")
273
+ return
274
+ except Exception as e:
275
+ logger.error(f"Error processing portfolio data: {e}")
276
+ return
277
+
278
+ # Find position groups to analyze
279
+ tickers_to_analyze = ["META", "AMZN", "GOOGL", "NVDA", "QQQ", "SPY"]
280
+
281
+ found_group = False
282
+ for ticker in tickers_to_analyze:
283
+ # Find the group with matching ticker
284
+ matching_groups = [g for g in groups if g.ticker == ticker]
285
+
286
+ if not matching_groups:
287
+ logger.warning(f"No {ticker} position group found in portfolio")
288
+ continue
289
+
290
+ group = matching_groups[0]
291
+ found_group = True
292
+
293
+ logger.info(f"Found {ticker} position group")
294
+ if group.option_positions:
295
+ logger.info(f" Option positions: {len(group.option_positions)}")
296
+ if group.stock_position:
297
+ logger.info(f" Stock position: {group.stock_position.quantity} shares")
298
+
299
+ # Validate P&L calculations
300
+ try:
301
+ pnl_results, current_price = validate_pnl_for_group(group)
302
+ if pnl_results:
303
+ # Process default mode
304
+ pnl_data, summary = pnl_results["default"]
305
+
306
+ # Print P&L data structure validation
307
+
308
+ # Check if pnl_data has the expected structure
309
+ for key in pnl_data.keys():
310
+ if key == "individual_pnls":
311
+ for _i, _pos_pnl in enumerate(pnl_data[key]):
312
+ pass
313
+
314
+ # Print individual position contributions at max profit price
315
+ max_profit_price = summary["max_profit_price"]
316
+ max_profit_idx = 0
317
+ for i, price in enumerate(pnl_data["price_points"]):
318
+ if abs(price - max_profit_price) < 0.01: # Find closest price point
319
+ max_profit_idx = i
320
+ break
321
+
322
+ total_pnl = 0
323
+ for i, pos_pnl in enumerate(pnl_data["individual_pnls"]):
324
+ pos_desc = pos_pnl.get("position", {}).get(
325
+ "ticker", f"Position {i + 1}"
326
+ )
327
+ pos_type = pos_pnl.get("position", {}).get(
328
+ "position_type", "unknown"
329
+ )
330
+ pos_pnl.get("position", {}).get("quantity", 0)
331
+
332
+ if pos_type == "option":
333
+ option_type = pos_pnl.get("position", {}).get("option_type", "")
334
+ strike = pos_pnl.get("position", {}).get("strike", 0)
335
+ pos_desc = f"{pos_desc} {option_type} {strike}"
336
+
337
+ pnl_at_max = pos_pnl["pnl_values"][max_profit_idx]
338
+ total_pnl += pnl_at_max
339
+
340
+ # Also check at current price
341
+ # Find the closest price point to current price
342
+ price_points = np.array(pnl_data["price_points"])
343
+ current_idx = np.abs(price_points - current_price).argmin()
344
+
345
+ total_current_pnl = 0
346
+ for i, pos_pnl in enumerate(pnl_data["individual_pnls"]):
347
+ pos_desc = pos_pnl.get("position", {}).get(
348
+ "ticker", f"Position {i + 1}"
349
+ )
350
+ pos_type = pos_pnl.get("position", {}).get(
351
+ "position_type", "unknown"
352
+ )
353
+ pos_pnl.get("position", {}).get("quantity", 0)
354
+
355
+ if pos_type == "option":
356
+ option_type = pos_pnl.get("position", {}).get("option_type", "")
357
+ strike = pos_pnl.get("position", {}).get("strike", 0)
358
+ pos_desc = f"{pos_desc} {option_type} {strike}"
359
+
360
+ pnl_at_current = pos_pnl["pnl_values"][current_idx]
361
+ total_current_pnl += pnl_at_current
362
+
363
+ # Debug the current P&L calculation
364
+
365
+ # Get the cost basis summary
366
+ pnl_data_cost_basis, summary_cost_basis = pnl_results["cost_basis"]
367
+
368
+ # Print individual position P&Ls at current price
369
+ for i, pos_pnl in enumerate(pnl_data["individual_pnls"]):
370
+ pos_desc = pos_pnl.get("position", {}).get(
371
+ "ticker", f"Position {i + 1}"
372
+ )
373
+ pos_type = pos_pnl.get("position", {}).get(
374
+ "position_type", "unknown"
375
+ )
376
+
377
+ if pos_type == "option":
378
+ option_type = pos_pnl.get("position", {}).get("option_type", "")
379
+ strike = pos_pnl.get("position", {}).get("strike", 0)
380
+ pos_desc = f"{pos_desc} {option_type} {strike}"
381
+
382
+ pnl_at_current = pos_pnl["pnl_values"][current_idx]
383
+
384
+ # Print summary
385
+
386
+ # Plot P&L for default mode
387
+ plot_pnl(pnl_data, summary, current_price, ticker, mode="default")
388
+
389
+ # We already got the cost basis data above
390
+
391
+ # Plot P&L for cost basis mode
392
+ plot_pnl(
393
+ pnl_data_cost_basis,
394
+ summary_cost_basis,
395
+ current_price,
396
+ ticker,
397
+ mode="cost_basis",
398
+ )
399
+
400
+ logger.info(f"P&L validation completed for {ticker} in both modes")
401
+ break # Process only the first valid group
402
+ else:
403
+ logger.warning(f"No P&L data generated for {ticker}")
404
+ except Exception as e:
405
+ logger.error(f"Error validating P&L for {ticker}: {e}", exc_info=True)
406
+
407
+ if not found_group:
408
+ logger.error(
409
+ "No valid position groups found for analysis. Please check the portfolio data."
410
+ )
411
+
412
+
413
+ if __name__ == "__main__":
414
+ main()
src/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # src package initialization
2
+ """
3
+ omninmo source package.
4
+ """
5
+
6
+ """
7
+ Package initialization for src.
8
+ """
src/fmp.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data fetcher for stock data using Financial Modeling Prep API
3
+ """
4
+
5
+ import logging
6
+ import os
7
+ from datetime import datetime, timedelta
8
+
9
+ import pandas as pd
10
+ import requests
11
+
12
+ from src.stockdata import DataFetcherInterface
13
+
14
+ # Setup logging
15
+ logging.basicConfig(level=logging.INFO)
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Constants
19
+ HTTP_SUCCESS = 200
20
+
21
+
22
+ class DataFetcher(DataFetcherInterface):
23
+ """Class to fetch stock data from Financial Modeling Prep API"""
24
+
25
+ # Default period for beta calculations
26
+ beta_period = "3m"
27
+
28
+ def __init__(self, cache_dir=".cache_fmp"):
29
+ """Initialize with cache directory"""
30
+ self.cache_dir = cache_dir
31
+ self.api_key = os.environ.get("FMP_API_KEY")
32
+
33
+ # If not in environment, try to get from config
34
+ if not self.api_key:
35
+ try:
36
+ from src.v2.config import config
37
+
38
+ self.api_key = config.get("data.fmp.api_key")
39
+ except ImportError:
40
+ logger.warning(
41
+ "Could not import config from src.v2.config, will rely on environment variable"
42
+ )
43
+
44
+ self.cache_ttl = 86400 # Default to 1 day
45
+
46
+ # Try to get cache TTL from config if available
47
+ try:
48
+ from src.v2.config import config
49
+
50
+ self.cache_ttl = config.get("app.cache.ttl", 86400)
51
+ except ImportError:
52
+ logger.warning(
53
+ "Could not import config from src.v2.config, using default cache TTL"
54
+ )
55
+
56
+ # Create cache directory if it doesn't exist
57
+ os.makedirs(cache_dir, exist_ok=True)
58
+
59
+ # Check for API key
60
+ if not self.api_key:
61
+ raise ValueError(
62
+ "No API key found. Please set the FMP_API_KEY environment variable or "
63
+ "configure it in the config file."
64
+ )
65
+
66
+ def fetch_data(self, ticker, period="3m", interval="1d"):
67
+ """
68
+ Fetch stock data for a ticker
69
+
70
+ Args:
71
+ ticker (str): Stock ticker symbol
72
+ period (str): Time period ('3m', '6m', '1y', etc.)
73
+ interval (str): Data interval ('1d', '1wk', etc.)
74
+
75
+ Returns:
76
+ pandas.DataFrame: DataFrame with stock data
77
+
78
+ Raises:
79
+ ValueError: If no data is returned from API
80
+ """
81
+ # Check cache first
82
+ cache_file = os.path.join(self.cache_dir, f"{ticker}_{period}_{interval}.csv")
83
+
84
+ # Use the centralized cache validation logic
85
+ from src.stockdata import should_use_cache
86
+
87
+ should_use, reason = should_use_cache(cache_file, self.cache_ttl)
88
+
89
+ if should_use:
90
+ logger.debug(f"Loading cached data for {ticker}: {reason}")
91
+ return pd.read_csv(cache_file, index_col=0, parse_dates=True)
92
+ else:
93
+ logger.debug(f"Cache for {ticker} is not valid: {reason}")
94
+
95
+ # Try to fetch from API
96
+ try:
97
+ logger.info(f"Fetching data for {ticker} from API")
98
+ df = self._fetch_from_api(ticker, period)
99
+
100
+ if df is not None and not df.empty:
101
+ # Save to cache
102
+ df.to_csv(cache_file)
103
+ return df
104
+ else:
105
+ # This is a valid case - API returned no data for a valid ticker
106
+ logger.warning(f"No data returned from API for {ticker}")
107
+ # Raise a specific error instead of returning an empty DataFrame
108
+ raise ValueError(f"No historical data found for {ticker}")
109
+ except (ValueError, requests.exceptions.RequestException) as e:
110
+ # These are expected errors that can happen with valid inputs
111
+ # For example, a valid ticker that has no data available or network issues
112
+ logger.warning(f"Data fetch error for {ticker}: {e}")
113
+
114
+ # Only use expired cache for expected data errors, not for programming errors
115
+ if os.path.exists(cache_file):
116
+ logger.warning(f"Using expired cache for {ticker} as fallback")
117
+ try:
118
+ return pd.read_csv(cache_file, index_col=0, parse_dates=True)
119
+ except (pd.errors.ParserError, pd.errors.EmptyDataError) as cache_e:
120
+ logger.error(f"Error reading cache for {ticker}: {cache_e}")
121
+ # If we can't read the cache, re-raise the original error
122
+ raise e from cache_e
123
+
124
+ # If this is a "No historical data" error and we have no cache,
125
+ # it's reasonable to return an empty DataFrame with the expected structure
126
+ if "No historical data found" in str(e):
127
+ logger.warning(
128
+ f"No historical data found for {ticker} and no cache available"
129
+ )
130
+ return pd.DataFrame(columns=["Open", "High", "Low", "Close", "Volume"])
131
+
132
+ # For other data errors with no cache, re-raise
133
+ raise
134
+ except (ImportError, NameError, AttributeError, TypeError, SyntaxError) as e:
135
+ # These are programming errors that should never be caught silently
136
+ logger.critical(f"Critical error in data fetcher: {e}", exc_info=True)
137
+ raise
138
+ except Exception as e:
139
+ # For other unexpected errors, log and re-raise
140
+ logger.error(
141
+ f"Unexpected error fetching data for {ticker}: {e}", exc_info=True
142
+ )
143
+ raise
144
+
145
+ def fetch_market_data(self, market_index="SPY", period=None, interval="1d"):
146
+ """
147
+ Fetch market index data for beta calculations.
148
+
149
+ Args:
150
+ market_index (str): Market index ticker symbol (default: 'SPY' for S&P 500 ETF)
151
+ period (str, optional): Time period. If None, uses beta_period.
152
+ interval (str): Data interval ('1d', '1wk', etc.)
153
+
154
+ Returns:
155
+ pandas.DataFrame: DataFrame with market index data
156
+ """
157
+ # Use the class beta_period if period is None
158
+ if period is None:
159
+ period = self.beta_period
160
+ logger.info(f"Using default beta period: {period}")
161
+
162
+ logger.debug(f"Fetching market data for {market_index}")
163
+ return self.fetch_data(market_index, period, interval)
164
+
165
+ def _fetch_from_api(self, ticker, period="5y"):
166
+ """Fetch data from Financial Modeling Prep API"""
167
+ # Determine date range based on period
168
+ end_date = datetime.now()
169
+
170
+ if period.endswith("y"):
171
+ years = int(period[:-1])
172
+ start_date = end_date - timedelta(days=365 * years)
173
+ elif period.endswith("m"):
174
+ months = int(period[:-1])
175
+ start_date = end_date - timedelta(days=30 * months)
176
+ else:
177
+ # Default to 1 year
178
+ start_date = end_date - timedelta(days=365)
179
+
180
+ # Format dates for API
181
+ start_str = start_date.strftime("%Y-%m-%d")
182
+ end_str = end_date.strftime("%Y-%m-%d")
183
+
184
+ # Construct API URL
185
+ base_url = "https://financialmodelingprep.com/api/v3/historical-price-full"
186
+ url = f"{base_url}/{ticker}?from={start_str}&to={end_str}&apikey={self.api_key}"
187
+
188
+ # Make request
189
+ response = requests.get(url)
190
+
191
+ if response.status_code != HTTP_SUCCESS:
192
+ raise ValueError(
193
+ f"API request failed with status code {response.status_code}: {response.text}"
194
+ )
195
+
196
+ # Parse response
197
+ data = response.json()
198
+
199
+ if "historical" not in data:
200
+ # This is not a critical error - just log a warning and return empty DataFrame
201
+ logger.warning(f"No historical data found for {ticker}")
202
+ return pd.DataFrame(columns=["Open", "High", "Low", "Close", "Volume"])
203
+
204
+ # Convert to DataFrame
205
+ df = pd.DataFrame(data["historical"])
206
+
207
+ # Convert date to datetime and set as index
208
+ df["date"] = pd.to_datetime(df["date"])
209
+ df = df.set_index("date")
210
+
211
+ # Sort by date (ascending)
212
+ df = df.sort_index()
213
+
214
+ # Rename columns to match expected format
215
+ df = df.rename(
216
+ columns={
217
+ "open": "Open",
218
+ "high": "High",
219
+ "low": "Low",
220
+ "close": "Close",
221
+ "volume": "Volume",
222
+ }
223
+ )
224
+
225
+ return df
226
+
227
+ def _fetch_data(self, url, params=None):
228
+ try:
229
+ response = requests.get(url, params=params)
230
+ if response.status_code == HTTP_SUCCESS:
231
+ return response.json()
232
+ else:
233
+ logger.error(f"Failed to fetch data: {response.status_code}")
234
+ return None
235
+ except Exception as e:
236
+ logger.error(f"Error fetching data: {e}")
237
+ return None
238
+
239
+
240
+ if __name__ == "__main__":
241
+ # Simple test
242
+ fetcher = DataFetcher()
243
+ data = fetcher.fetch_data("AAPL", period="1y")
src/folio/README.md ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Folio - Portfolio Dashboard
2
+
3
+ ## Overview
4
+
5
+ Folio is a web-based dashboard for analyzing and visualizing investment portfolios. It provides a comprehensive view of your portfolio's composition, risk metrics, and exposure analysis with a focus on stocks and options.
6
+
7
+ ## Features
8
+
9
+ - **Portfolio Analysis**: View your entire portfolio with key metrics like value, beta, and exposure
10
+ - **Position Grouping**: Automatically groups stocks with their related options
11
+ - **Risk Metrics**: Calculates beta and beta-adjusted exposure for all positions
12
+ - **Options Analysis**: Provides delta exposure and other option-specific metrics
13
+ - **Interactive UI**: Filter, sort, and search your portfolio with real-time updates
14
+ - **Position Details**: Drill down into specific positions for detailed analysis
15
+ - **CSV Import**: Upload portfolio data from CSV exports (compatible with Fidelity exports)
16
+ - **Auto-Refresh**: Periodically refreshes data to keep metrics current
17
+
18
+ ## Getting Started
19
+
20
+ ### Prerequisites
21
+
22
+ - Python 3.9+
23
+ - Required packages (see `requirements.txt` in the project root)
24
+
25
+ ### Running the Dashboard
26
+
27
+ ```bash
28
+ # From the project root directory:
29
+
30
+ # Start with default settings (will prompt for file upload)
31
+ make folio
32
+
33
+ # Start with a specific portfolio file
34
+ make folio portfolio=path/to/portfolio.csv
35
+
36
+ # Or run directly with Python
37
+ python -m src.folio --portfolio path/to/portfolio.csv --port 8051
38
+ ```
39
+
40
+ The dashboard will be available at http://127.0.0.1:8051/ (or your specified port).
41
+
42
+ ## Project Structure
43
+
44
+ ```
45
+ src/folio/
46
+ ├── __init__.py # Package initialization
47
+ ├── __main__.py # Entry point for running as a module
48
+ ├── app.py # Main Dash application setup and callbacks
49
+ ├── components/ # UI components
50
+ │ ├── __init__.py
51
+ │ ├── portfolio_table.py # Portfolio table component
52
+ │ └── position_details.py # Position details modal
53
+ ├── data_model.py # Data models and type definitions
54
+ ├── logger.py # Logging configuration
55
+ └── utils.py # Utility functions for data processing
56
+ ```
57
+
58
+ ## Data Model
59
+
60
+ The application uses the following key data structures:
61
+
62
+ - **Position**: Base class for all positions (stocks and options)
63
+ - **StockPosition**: Represents a stock position
64
+ - **OptionPosition**: Represents an option position with strike, expiry, etc.
65
+ - **PortfolioGroup**: Groups a stock with its related options
66
+ - **PortfolioSummary**: Contains aggregated metrics for the entire portfolio
67
+ - **ExposureBreakdown**: Detailed breakdown of exposure metrics
68
+
69
+ ## Development Guide
70
+
71
+ ### Adding New Features
72
+
73
+ 1. **UI Components**: Add new components in the `components/` directory
74
+ 2. **Data Processing**: Extend the data model in `data_model.py` and processing logic in `utils.py`
75
+ 3. **Callbacks**: Add new callbacks in `app.py` to handle user interactions
76
+
77
+ ### Coding Standards
78
+
79
+ - Use type hints for all functions and methods
80
+ - Document functions with docstrings (Google style)
81
+ - Log important operations and errors using the logger
82
+ - Handle exceptions gracefully with appropriate error messages
83
+ - Follow the existing pattern for callback registration
84
+
85
+ ### Testing
86
+
87
+ While there's no formal test suite yet, you can test your changes by:
88
+
89
+ 1. Running the application with a sample portfolio
90
+ 2. Verifying that all UI components render correctly
91
+ 3. Checking that calculations produce expected results
92
+ 4. Testing edge cases (empty portfolio, invalid data, etc.)
93
+
94
+ ## Troubleshooting
95
+
96
+ ### Common Issues
97
+
98
+ - **Missing Data**: Ensure your CSV has all required columns (Symbol, Description, Quantity, etc.)
99
+ - **Port Conflicts**: If the default port is in use, specify a different port with `--port`
100
+ - **Data Fetching Errors**: Check network connectivity for beta data retrieval
101
+
102
+ ### Logging
103
+
104
+ Logs are stored in the `logs/` directory with timestamps. Check these logs for detailed error information.
105
+
106
+ ## Future Improvements
107
+
108
+ - Add unit tests for core functionality
109
+ - Implement additional portfolio metrics (Sharpe ratio, VaR, etc.)
110
+ - Add visualization components (charts, graphs)
111
+ - Support for additional data sources beyond CSV
112
+ - Enhanced options analytics with Greeks (gamma, theta, vega)
113
+
114
+ ## Contributing
115
+
116
+ 1. Follow the existing code style and patterns
117
+ 2. Document your changes thoroughly
118
+ 3. Test your changes with various portfolio data
119
+ 4. Submit a pull request with a clear description of your changes
src/folio/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Folio - Portfolio Dashboard"""
2
+
3
+ __version__ = "0.1.0"
src/folio/__main__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .app import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
src/folio/ai_utils.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Utility functions for AI portfolio analysis."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from .data_model import PortfolioGroup, PortfolioSummary
7
+ from .portfolio import calculate_position_weight
8
+ from .portfolio_value import (
9
+ calculate_component_percentages,
10
+ get_portfolio_component_values,
11
+ )
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # System prompt for the AI portfolio advisor
16
+ PORTFOLIO_ADVISOR_SYSTEM_PROMPT = """
17
+ You are a professional financial advisor specializing in portfolio analysis. Your role is strictly limited to:
18
+
19
+ 1. Analyzing the client's investment portfolio
20
+ 2. Providing insights on portfolio composition, risk, diversification, and performance
21
+ 3. Offering investment advice related to the client's holdings
22
+ 4. Answering questions about financial markets, investment strategies, and specific securities
23
+
24
+ Important guidelines:
25
+ - ONLY respond to questions related to investing, finance, and the client's portfolio
26
+ - REFUSE to answer any questions unrelated to finance or investments
27
+ - If asked about non-financial topics, politely redirect the conversation back to the portfolio
28
+ - Maintain a professional, knowledgeable tone
29
+ - Base your analysis on the portfolio data provided
30
+ - Be transparent about limitations in your analysis
31
+ - When discussing portfolio allocation, refer to the detailed breakdown provided in the context
32
+ - When discussing exposure, consider both the raw exposure values and beta-adjusted values
33
+ - Pay attention to the percentage of portfolio for each metric to provide context
34
+
35
+ Your goal is to help clients understand their investments and make informed decisions about their portfolio.
36
+ """
37
+
38
+
39
+ def prepare_portfolio_data_for_analysis(
40
+ groups: list[PortfolioGroup], summary: PortfolioSummary
41
+ ) -> dict[str, Any]:
42
+ """
43
+ Prepare portfolio data for AI analysis.
44
+
45
+ Args:
46
+ groups: List of portfolio groups
47
+ summary: Portfolio summary object
48
+
49
+ Returns:
50
+ Dictionary with formatted portfolio data
51
+ """
52
+ positions = []
53
+
54
+ # Process each portfolio group
55
+ for group in groups:
56
+ # Add stock position if present
57
+ if group.stock_position:
58
+ stock = group.stock_position
59
+ positions.append(
60
+ {
61
+ "ticker": stock.ticker,
62
+ "position_type": "stock",
63
+ "market_value": stock.market_exposure,
64
+ "beta": stock.beta,
65
+ "weight": calculate_position_weight(
66
+ stock.market_exposure, summary.net_market_exposure
67
+ ),
68
+ "quantity": stock.quantity,
69
+ }
70
+ )
71
+
72
+ # Add option positions if present
73
+ for option in group.option_positions:
74
+ positions.append(
75
+ {
76
+ "ticker": option.ticker,
77
+ "position_type": "option",
78
+ "market_value": option.market_exposure,
79
+ "beta": option.beta,
80
+ "weight": calculate_position_weight(
81
+ option.market_exposure, summary.net_market_exposure
82
+ ),
83
+ "option_type": option.option_type,
84
+ "strike": option.strike,
85
+ "expiry": option.expiry,
86
+ "delta": option.delta,
87
+ }
88
+ )
89
+
90
+ # Enhanced summary data with portfolio value
91
+ summary_data = {
92
+ "portfolio_value": summary.portfolio_estimate_value,
93
+ "net_market_exposure": summary.net_market_exposure,
94
+ "long_exposure": summary.long_exposure.to_dict(),
95
+ "short_exposure": summary.short_exposure.to_dict(),
96
+ "options_exposure": summary.options_exposure.to_dict(),
97
+ "cash_like_value": summary.cash_like_value,
98
+ }
99
+
100
+ # Get allocation data from existing portfolio_value functions
101
+ values = get_portfolio_component_values(summary)
102
+ percentages = calculate_component_percentages(values)
103
+
104
+ # Create allocation data structure
105
+ allocation_data = {"values": values, "percentages": percentages}
106
+
107
+ return {
108
+ "positions": positions,
109
+ "summary": summary_data,
110
+ "allocations": allocation_data,
111
+ }
src/folio/app.py ADDED
@@ -0,0 +1,1073 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Main application module for the Folio app.
3
+
4
+ This module contains the main application logic for the Folio app.
5
+ """
6
+
7
+ import argparse
8
+ import base64
9
+ import io
10
+ import json
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import dash
16
+ import dash_bootstrap_components as dbc
17
+ import pandas as pd
18
+ from dash import ALL, Input, Output, State, dcc, html
19
+ from dash_bootstrap_templates import load_figure_template
20
+
21
+ from . import portfolio
22
+ from .components import create_premium_chat_component, register_premium_chat_callbacks
23
+ from .components.charts import create_dashboard_section
24
+ from .components.charts import register_callbacks as register_chart_callbacks
25
+ from .components.pnl_chart import create_pnl_modal
26
+ from .components.pnl_chart import register_callbacks as register_pnl_callbacks
27
+ from .components.portfolio_table import create_portfolio_table
28
+ from .components.summary_cards import create_summary_cards
29
+ from .data_model import OptionPosition, PortfolioGroup, StockPosition
30
+ from .error_utils import handle_callback_error
31
+ from .logger import logger
32
+ from .security import sanitize_dataframe, validate_csv_upload
33
+
34
+ # Load the Bootstrap template for Plotly figures
35
+ load_figure_template("bootstrap")
36
+
37
+
38
+ def create_header() -> dbc.Card:
39
+ """Create the header section with summary cards"""
40
+ # Use the create_summary_cards function from summary_cards.py
41
+ return create_summary_cards()
42
+
43
+
44
+ def create_empty_state() -> html.Div:
45
+ """Create the empty state with instructions"""
46
+ # Check if private portfolio exists
47
+ private_path = Path(os.getcwd()) / "private-data" / "portfolio-private.csv"
48
+ button_label = (
49
+ "Load Private Portfolio" if private_path.exists() else "Load Sample Portfolio"
50
+ )
51
+
52
+ return html.Div(
53
+ [
54
+ html.H4("Welcome to Folio", className="text-center mb-3"),
55
+ html.P(
56
+ "Upload your portfolio CSV file to get started", className="text-center"
57
+ ),
58
+ html.Div(
59
+ [
60
+ html.I(className="fas fa-upload fa-3x"),
61
+ ],
62
+ className="text-center my-4",
63
+ ),
64
+ html.P(
65
+ "Or try a sample portfolio to explore the features",
66
+ className="text-center mt-3",
67
+ ),
68
+ dbc.Button(
69
+ button_label,
70
+ id="load-sample",
71
+ color="primary",
72
+ className="mx-auto d-block",
73
+ ),
74
+ # Keyboard shortcut hint removed
75
+ ],
76
+ className="empty-state py-5",
77
+ )
78
+
79
+
80
+ def create_upload_section() -> dbc.Card:
81
+ """Create the file upload section with collapsible functionality"""
82
+ return dbc.Card(
83
+ [
84
+ dbc.CardHeader(
85
+ dbc.Button(
86
+ [
87
+ html.I(className="fas fa-upload me-2"),
88
+ html.Span("Upload Portfolio"),
89
+ html.I(
90
+ className="fas fa-chevron-down ms-2", id="collapse-icon"
91
+ ),
92
+ ],
93
+ id="upload-collapse-button",
94
+ color="link",
95
+ className="text-decoration-none text-dark p-0 d-flex align-items-center",
96
+ ),
97
+ className="d-flex justify-content-between align-items-center",
98
+ ),
99
+ dbc.Collapse(
100
+ dbc.CardBody(
101
+ [
102
+ dcc.Upload(
103
+ id="upload-portfolio",
104
+ children=html.Div(
105
+ [
106
+ html.I(className="fas fa-file-upload me-2"),
107
+ "Drag and Drop or ",
108
+ html.A(
109
+ "Select a CSV File", className="text-primary"
110
+ ),
111
+ ]
112
+ ),
113
+ style={
114
+ "width": "100%",
115
+ "height": "60px",
116
+ "lineHeight": "60px",
117
+ "textAlign": "center",
118
+ "margin": "10px 0",
119
+ },
120
+ # Filter for CSV files
121
+ accept=".csv",
122
+ multiple=False,
123
+ ),
124
+ dcc.Loading(
125
+ id="upload-loading",
126
+ type="circle",
127
+ children=[html.Div(id="upload-status")],
128
+ ),
129
+ ]
130
+ ),
131
+ id="upload-collapse",
132
+ is_open=True, # Initially open
133
+ ),
134
+ ],
135
+ className="mb-3",
136
+ )
137
+
138
+
139
+ def create_filters() -> dbc.InputGroup:
140
+ """Create the search and filter controls"""
141
+ return dbc.InputGroup(
142
+ [
143
+ dbc.Input(
144
+ id="search-input",
145
+ type="text",
146
+ placeholder="Search positions...",
147
+ className="border-end-0",
148
+ ),
149
+ dbc.InputGroupText(
150
+ html.I(className="fas fa-search"),
151
+ className="bg-transparent border-start-0",
152
+ ),
153
+ dbc.Button(
154
+ "All",
155
+ id="filter-all",
156
+ color="primary",
157
+ outline=True,
158
+ className="ms-2",
159
+ ),
160
+ dbc.Button(
161
+ "Stocks",
162
+ id="filter-stocks",
163
+ color="primary",
164
+ outline=True,
165
+ className="ms-2",
166
+ ),
167
+ dbc.Button(
168
+ "Options",
169
+ id="filter-options",
170
+ color="primary",
171
+ outline=True,
172
+ className="ms-2",
173
+ ),
174
+ dbc.Button(
175
+ "Cash",
176
+ id="filter-cash",
177
+ color="primary",
178
+ outline=True,
179
+ className="ms-2",
180
+ ),
181
+ ],
182
+ className="mb-3",
183
+ )
184
+
185
+
186
+ def create_main_table() -> html.Div:
187
+ """Create the main portfolio table"""
188
+ return html.Div(
189
+ [
190
+ html.Div(
191
+ id="portfolio-table",
192
+ className="portfolio-table-container",
193
+ ),
194
+ # Add a Store to track sort state
195
+ dcc.Store(id="sort-state", data={"column": "value", "direction": "desc"}),
196
+ ]
197
+ )
198
+
199
+
200
+ def create_position_modal() -> dbc.Modal:
201
+ """Create the position details modal"""
202
+ return dbc.Modal(
203
+ [
204
+ dbc.ModalHeader(
205
+ dbc.ModalTitle("Position Details"),
206
+ close_button=True,
207
+ ),
208
+ dbc.ModalBody(id="position-modal-body"),
209
+ ],
210
+ id="position-modal",
211
+ size="lg",
212
+ )
213
+
214
+
215
+ def create_app(portfolio_file: str | None = None, _debug: bool = False) -> dash.Dash:
216
+ """Create and configure the Dash application"""
217
+ logger.debug("Initializing Dash application")
218
+
219
+ # Create Dash app
220
+ app = dash.Dash(
221
+ __name__,
222
+ external_stylesheets=[
223
+ dbc.themes.BOOTSTRAP,
224
+ "https://use.fontawesome.com/releases/v5.15.4/css/all.css",
225
+ ],
226
+ title="Folio",
227
+ # Enable async callbacks
228
+ use_pages=False,
229
+ suppress_callback_exceptions=True,
230
+ )
231
+
232
+ # CSS files are automatically loaded from the assets folder
233
+ # main.css imports all other CSS files and applies the theme
234
+
235
+ # Use a simpler index_string without inline styles
236
+ app.index_string = """
237
+ <!DOCTYPE html>
238
+ <html>
239
+ <head>
240
+ {%metas%}
241
+ <title>{%title%}</title>
242
+ {%favicon%}
243
+ {%css%}
244
+ </head>
245
+ <body>
246
+ {%app_entry%}
247
+ <footer>
248
+ {%config%}
249
+ {%scripts%}
250
+ {%renderer%}
251
+ </footer>
252
+ </body>
253
+ </html>
254
+ """
255
+
256
+ # Store portfolio file path
257
+ app.portfolio_file = portfolio_file
258
+ logger.debug(f"Portfolio file path set to: {portfolio_file}")
259
+
260
+ # Define layout
261
+ app.layout = dbc.Container(
262
+ [
263
+ dcc.Location(id="url", refresh=False), # Add URL component
264
+ html.Div(html.H2("Folio"), className="app-header my-3"),
265
+ create_upload_section(), # Always show upload section
266
+ # Wrap the main content in a loading component with gradient border
267
+ html.Div(
268
+ dcc.Loading(
269
+ id="main-loading",
270
+ type="circle",
271
+ children=[
272
+ html.Div(
273
+ [
274
+ # Add visualization dashboard section near the top
275
+ create_dashboard_section(),
276
+ # Add filters below visualizations
277
+ create_filters(),
278
+ # Move table to the end
279
+ dbc.Card(
280
+ [
281
+ dbc.CardHeader(
282
+ dbc.Button(
283
+ [
284
+ html.I(
285
+ className="fas fa-table me-2"
286
+ ),
287
+ html.Span("Portfolio Positions"),
288
+ html.I(
289
+ className="fas fa-chevron-down ms-2",
290
+ id="positions-collapse-icon",
291
+ ),
292
+ ],
293
+ id="positions-collapse-button",
294
+ color="link",
295
+ className="text-decoration-none text-dark p-0 d-flex align-items-center w-100 justify-content-between",
296
+ ),
297
+ ),
298
+ dbc.Collapse(
299
+ dbc.CardBody(
300
+ create_main_table(),
301
+ ),
302
+ id="positions-collapse",
303
+ is_open=True, # Initially open
304
+ ),
305
+ ],
306
+ className="mb-3",
307
+ ),
308
+ ],
309
+ id="main-content",
310
+ )
311
+ ],
312
+ ),
313
+ className="gradient-border",
314
+ ),
315
+ create_pnl_modal(),
316
+ # Empty state container (shown when no data is loaded)
317
+ html.Div(id="empty-state-container"),
318
+ # Add keyboard shortcut listener
319
+ html.Div(id="keyboard-shortcut-listener"),
320
+ # Premium AI Chat Interface
321
+ create_premium_chat_component(),
322
+ # AI Modal
323
+ dbc.Modal(
324
+ [
325
+ dbc.ModalHeader("AI Portfolio Advisor"),
326
+ dbc.ModalBody(
327
+ [
328
+ html.P("What would you like to know about your portfolio?"),
329
+ dbc.Textarea(
330
+ id="ai-query-input",
331
+ placeholder="Ask about your portfolio...",
332
+ rows=3,
333
+ className="mb-3",
334
+ ),
335
+ dbc.Button(
336
+ "Analyze",
337
+ id="analyze-portfolio-button",
338
+ color="primary",
339
+ className="mb-3",
340
+ ),
341
+ html.Div(id="ai-analysis-result"),
342
+ ]
343
+ ),
344
+ dbc.ModalFooter(
345
+ dbc.Button(
346
+ "Close",
347
+ id="close-ai-modal",
348
+ className="ms-auto",
349
+ n_clicks=0,
350
+ )
351
+ ),
352
+ ],
353
+ id="ai-modal",
354
+ size="lg",
355
+ is_open=False,
356
+ ),
357
+ # Stores
358
+ dcc.Store(id="portfolio-data"),
359
+ dcc.Store(id="portfolio-summary"), # Add portfolio summary store
360
+ dcc.Store(id="portfolio-groups"), # Add portfolio groups store
361
+ dcc.Store(id="selected-position"),
362
+ dcc.Store(id="loading-output"), # Add loading output store
363
+ dcc.Store(id="theme-store", storage_type="local"), # Theme preference store
364
+ dcc.Store(id="ai-analysis-data"), # Store for AI analysis results
365
+ dcc.Store(
366
+ id="portfolio-table-active-cell"
367
+ ), # Store for tracking active cell in portfolio table
368
+ # Add initial trigger
369
+ dcc.Store(id="initial-trigger", data=True),
370
+ ],
371
+ fluid=True,
372
+ className="px-4",
373
+ )
374
+
375
+ # Add clientside callback to ensure initial trigger fires
376
+ app.clientside_callback(
377
+ """
378
+ function(pathname) {
379
+ return true;
380
+ }
381
+ """,
382
+ Output("initial-trigger", "data"),
383
+ Input("url", "pathname"),
384
+ )
385
+
386
+ # Add clientside callback to log portfolio summary data
387
+ app.clientside_callback(
388
+ """
389
+ function(data) {
390
+ console.log("Portfolio Summary Data:", data);
391
+ return window.dash_clientside.no_update;
392
+ }
393
+ """,
394
+ Output("portfolio-summary", "data", allow_duplicate=True),
395
+ Input("portfolio-summary", "data"),
396
+ prevent_initial_call=True,
397
+ )
398
+
399
+ # Keyboard shortcut functionality removed as it was causing issues
400
+
401
+ # Toggle upload section collapse
402
+ @app.callback(
403
+ [
404
+ Output("upload-collapse", "is_open", allow_duplicate=True),
405
+ Output("collapse-icon", "className"),
406
+ ],
407
+ [Input("upload-collapse-button", "n_clicks")],
408
+ [State("upload-collapse", "is_open")],
409
+ prevent_initial_call=True,
410
+ )
411
+ def toggle_upload_collapse(n_clicks, is_open):
412
+ """Toggle the upload section collapse state"""
413
+ if n_clicks:
414
+ # Toggle the collapse state
415
+ new_state = not is_open
416
+ # Update the icon based on the new state
417
+ icon_class = (
418
+ "fas fa-chevron-up ms-2" if new_state else "fas fa-chevron-down ms-2"
419
+ )
420
+ return new_state, icon_class
421
+ return is_open, "fas fa-chevron-down ms-2"
422
+
423
+ # Show/hide empty state based on portfolio data
424
+ @app.callback(
425
+ [
426
+ Output("empty-state-container", "children"),
427
+ Output("main-content", "style"),
428
+ # Also collapse the upload section when data is loaded
429
+ Output("upload-collapse", "is_open"),
430
+ ],
431
+ [Input("portfolio-groups", "data")],
432
+ )
433
+ def toggle_empty_state(groups_data):
434
+ """Show empty state when no data is loaded and collapse upload section when data is loaded"""
435
+ logger.debug(f"TOGGLE_EMPTY_STATE called with groups_data: {bool(groups_data)}")
436
+
437
+ if not groups_data:
438
+ # No data, show empty state, hide main content, keep upload open
439
+ logger.debug(
440
+ "TOGGLE_EMPTY_STATE: No data, showing empty state, hiding main content"
441
+ )
442
+ return create_empty_state(), {"display": "none"}, True
443
+ else:
444
+ # Data loaded, hide empty state, show main content, collapse upload
445
+ logger.debug(
446
+ "TOGGLE_EMPTY_STATE: Data loaded, hiding empty state, showing main content"
447
+ )
448
+ return None, {"display": "block"}, False
449
+
450
+ # Handle sample portfolio loading
451
+ @app.callback(
452
+ [
453
+ Output("upload-portfolio", "contents"),
454
+ Output("upload-portfolio", "filename"),
455
+ ],
456
+ Input("load-sample", "n_clicks"),
457
+ prevent_initial_call=True,
458
+ )
459
+ def load_sample_portfolio(n_clicks):
460
+ """Load a sample portfolio when the button is clicked"""
461
+ logger.debug(f"LOAD_SAMPLE_PORTFOLIO: Button clicked: {n_clicks}")
462
+ if n_clicks:
463
+ logger.debug("LOAD_SAMPLE_PORTFOLIO: Processing sample portfolio")
464
+ try:
465
+ # First check if private portfolio exists for local debugging
466
+ private_path = (
467
+ Path(os.getcwd()) / "private-data" / "portfolio-private.csv"
468
+ )
469
+ sample_path = Path(__file__).parent / "assets" / "sample-portfolio.csv"
470
+
471
+ # Determine which file to use
472
+ if private_path.exists():
473
+ logger.debug(f"Found private portfolio at: {private_path}")
474
+ portfolio_path = private_path
475
+ filename = "portfolio-private.csv"
476
+ elif sample_path.exists():
477
+ logger.debug(f"Using sample portfolio at: {sample_path}")
478
+ portfolio_path = sample_path
479
+ filename = "sample-portfolio.csv"
480
+ else:
481
+ logger.warning("Neither private nor sample portfolio found")
482
+ return None, None
483
+
484
+ logger.debug(f"Loading portfolio from: {portfolio_path}")
485
+
486
+ # Debug the file content
487
+ with open(portfolio_path) as f:
488
+ content = f.read()
489
+ logger.debug(
490
+ f"Portfolio content (first 100 chars): {content[:100]}"
491
+ )
492
+
493
+ # Read the portfolio file
494
+ with open(portfolio_path, "rb") as f:
495
+ file_content = f.read()
496
+ logger.debug(f"Read {len(file_content)} bytes from portfolio file")
497
+
498
+ # Validate the file content
499
+ # We'll sanitize it during the normal processing flow
500
+ df = pd.read_csv(portfolio_path)
501
+
502
+ # Check for any potentially dangerous content
503
+ df = sanitize_dataframe(df)
504
+
505
+ # Re-encode the sanitized dataframe
506
+ csv_buffer = io.StringIO()
507
+ df.to_csv(csv_buffer, index=False)
508
+ csv_str = csv_buffer.getvalue()
509
+
510
+ # Encode the content as base64 for the upload component
511
+ content_type = "text/csv"
512
+ content_string = base64.b64encode(csv_str.encode("utf-8")).decode(
513
+ "utf-8"
514
+ )
515
+
516
+ # Return both the contents and the filename
517
+ return (
518
+ f"data:{content_type};base64,{content_string}",
519
+ filename,
520
+ )
521
+ except Exception as e:
522
+ logger.error(f"Error loading sample portfolio: {e}", exc_info=True)
523
+ return None, None
524
+ return None, None
525
+
526
+ @app.callback(
527
+ [
528
+ Output("portfolio-data", "data"),
529
+ Output("portfolio-summary", "data"),
530
+ Output("portfolio-groups", "data"),
531
+ Output("loading-output", "data"), # Changed from "children" to "data"
532
+ Output("upload-status", "children"),
533
+ Output(
534
+ "portfolio-table-active-cell", "data"
535
+ ), # Changed from portfolio-table.active_cell
536
+ ],
537
+ [
538
+ Input("initial-trigger", "data"),
539
+ Input("url", "pathname"),
540
+ Input("upload-portfolio", "contents"),
541
+ ],
542
+ [
543
+ State("upload-portfolio", "filename"),
544
+ ],
545
+ )
546
+ def update_portfolio_data(_initial_trigger, _pathname, contents, filename):
547
+ """Update portfolio data when triggered"""
548
+ try:
549
+ logger.debug("Loading portfolio data...")
550
+ ctx = dash.callback_context
551
+ trigger_id = ctx.triggered[0]["prop_id"] if ctx.triggered else ""
552
+ logger.debug(f"Trigger: {trigger_id}")
553
+
554
+ # Handle file upload if provided
555
+ if contents and "upload-portfolio.contents" in trigger_id:
556
+ try:
557
+ logger.debug(f"Processing uploaded file: {filename}")
558
+ # Validate and sanitize the CSV file
559
+ df, error = validate_csv_upload(contents, filename)
560
+ if error:
561
+ raise ValueError(error)
562
+
563
+ logger.debug(
564
+ f"Successfully read and validated {len(df)} rows from uploaded file {filename}"
565
+ )
566
+ status = html.Div(
567
+ f"Successfully loaded {filename}", className="text-success"
568
+ )
569
+ except ValueError as e:
570
+ logger.error(f"CSV validation error: {e}")
571
+ error_msg = f"Error loading file: {e!s}"
572
+ error_div = html.Div(error_msg, className="text-danger")
573
+ return [], {}, [], error_msg, error_div, None
574
+ elif app.portfolio_file:
575
+ # Use default portfolio file
576
+ try:
577
+ # Try to read the CSV file with standard settings
578
+ df = pd.read_csv(app.portfolio_file)
579
+ except pd.errors.ParserError as e:
580
+ logger.warning(f"Parser error with standard settings: {e}")
581
+ # Try again with more flexible quoting to handle commas in option symbols
582
+ df = pd.read_csv(app.portfolio_file, quoting=3) # QUOTE_NONE
583
+ logger.debug(f"Successfully read {len(df)} rows from portfolio file")
584
+ status = html.Div(
585
+ "Using default portfolio file", className="text-muted"
586
+ )
587
+ else:
588
+ # No data available
589
+ return (
590
+ [],
591
+ {},
592
+ [],
593
+ "",
594
+ html.Div("Please upload a portfolio file", className="text-muted"),
595
+ None,
596
+ )
597
+
598
+ # Process portfolio data with automatic price updates
599
+ groups, summary, cash_like_positions = portfolio.process_portfolio_data(
600
+ df, update_prices=True
601
+ )
602
+ logger.debug(
603
+ f"Successfully processed {len(groups)} portfolio groups and {len(cash_like_positions)} cash-like positions"
604
+ )
605
+ # Continue with the original summary if price update fails
606
+
607
+ # Convert to Dash-compatible format
608
+ groups_data = [g.to_dict() for g in groups]
609
+ summary_data = summary.to_dict()
610
+ logger.debug(f"Summary data keys: {list(summary_data.keys())}")
611
+ logger.debug(
612
+ f"Portfolio estimate value: {summary_data.get('portfolio_estimate_value', 'NOT FOUND')}"
613
+ )
614
+ portfolio_data = df.to_dict("records")
615
+
616
+ return portfolio_data, summary_data, groups_data, "", status, None
617
+
618
+ except (ValueError, pd.errors.ParserError, pd.errors.EmptyDataError) as e:
619
+ # Handle expected data errors with user-friendly messages
620
+ logger.error(f"Data error loading portfolio: {e}", exc_info=True)
621
+ error_msg = f"Error loading portfolio: {e!s}"
622
+ error_div = html.Div(error_msg, className="text-danger")
623
+ return [], {}, [], error_msg, error_div, None
624
+ except (ImportError, NameError, AttributeError, TypeError) as e:
625
+ # These are programming errors that should be fixed, not handled
626
+ logger.critical(f"Critical programming error: {e}", exc_info=True)
627
+ error_msg = f"A critical error occurred. Please report this issue: {e!s}"
628
+ error_div = html.Div(error_msg, className="text-danger")
629
+ # Re-raise for development environments to see the full traceback
630
+ if app.debug:
631
+ raise
632
+ return [], {}, [], error_msg, error_div, None
633
+ except Exception as e:
634
+ # Unexpected errors should be logged and reported
635
+ logger.critical(
636
+ f"Unexpected error updating portfolio data: {e}", exc_info=True
637
+ )
638
+ error_msg = f"An unexpected error occurred: {e!s}"
639
+ error_div = html.Div(error_msg, className="text-danger")
640
+ # Re-raise for development environments to see the full traceback
641
+ if app.debug:
642
+ raise
643
+ return [], {}, [], error_msg, error_div, None
644
+
645
+ # Register summary cards callbacks
646
+ from .components.summary_cards import register_callbacks
647
+
648
+ register_callbacks(app)
649
+
650
+ # Register P&L chart callbacks
651
+ register_pnl_callbacks(app)
652
+
653
+ @app.callback(
654
+ Output("portfolio-table", "children"),
655
+ [
656
+ Input("portfolio-groups", "data"),
657
+ Input("search-input", "value"),
658
+ Input("filter-all", "n_clicks"),
659
+ Input("filter-stocks", "n_clicks"),
660
+ Input("filter-options", "n_clicks"),
661
+ Input("filter-cash", "n_clicks"),
662
+ Input("sort-state", "data"), # Add sort state input
663
+ ],
664
+ [State("portfolio-summary", "data")], # Add portfolio summary as state
665
+ )
666
+ def update_portfolio_table(
667
+ groups_data,
668
+ search,
669
+ _all_clicks,
670
+ _stocks_clicks,
671
+ _options_clicks,
672
+ _cash_clicks,
673
+ sort_state,
674
+ summary_data,
675
+ ):
676
+ """Update portfolio table based on filters and sorting"""
677
+ logger.debug("Updating portfolio table")
678
+ try:
679
+ if not groups_data:
680
+ return html.Tr(
681
+ html.Td(
682
+ "No portfolio data available",
683
+ colSpan=6,
684
+ className="text-center",
685
+ )
686
+ )
687
+
688
+ # Convert data back to PortfolioGroup objects
689
+ groups = []
690
+ for g in groups_data:
691
+ # Filter out attributes that don't exist in Position class
692
+ stock_position = None
693
+ if g["stock_position"]:
694
+ stock_position_data = g["stock_position"].copy()
695
+ # Remove attributes that don't exist in StockPosition class
696
+ if "sector" in stock_position_data:
697
+ stock_position_data.pop("sector")
698
+ if "is_cash_like" in stock_position_data:
699
+ stock_position_data.pop("is_cash_like")
700
+ if "position_type" in stock_position_data:
701
+ stock_position_data.pop("position_type")
702
+ stock_position = StockPosition(**stock_position_data)
703
+ option_positions = [
704
+ OptionPosition(**opt) for opt in g["option_positions"]
705
+ ]
706
+ group = PortfolioGroup(
707
+ ticker=g["ticker"],
708
+ stock_position=stock_position,
709
+ option_positions=option_positions,
710
+ net_exposure=g["net_exposure"],
711
+ beta=g["beta"],
712
+ beta_adjusted_exposure=g["beta_adjusted_exposure"],
713
+ total_delta_exposure=g["total_delta_exposure"],
714
+ options_delta_exposure=g["options_delta_exposure"],
715
+ )
716
+ groups.append(group)
717
+
718
+ # Determine which filter button was clicked
719
+ ctx = dash.callback_context
720
+ if ctx.triggered:
721
+ button_id = ctx.triggered[0]["prop_id"].split(".")[0]
722
+ logger.debug(f"Filter button clicked: {button_id}")
723
+ else:
724
+ button_id = "filter-all" # Default to showing all
725
+
726
+ # Get cash-like positions from summary data
727
+ cash_like_positions = []
728
+ if summary_data and "cash_like_positions" in summary_data:
729
+ # Convert cash-like positions to PortfolioGroup objects
730
+ for pos in summary_data["cash_like_positions"]:
731
+ # Create a StockPosition
732
+ stock_pos = StockPosition(
733
+ ticker=pos["ticker"],
734
+ quantity=pos["quantity"],
735
+ beta=pos["beta"],
736
+ market_exposure=pos.get(
737
+ "market_exposure", pos.get("market_value", 0.0)
738
+ ), # Use market_exposure or fall back to market_value
739
+ beta_adjusted_exposure=pos["beta_adjusted_exposure"],
740
+ )
741
+
742
+ # Create a PortfolioGroup with just this stock position
743
+ cash_group = PortfolioGroup(
744
+ ticker=pos["ticker"],
745
+ stock_position=stock_pos,
746
+ option_positions=[],
747
+ net_exposure=pos.get(
748
+ "market_exposure", pos.get("market_value", 0.0)
749
+ ),
750
+ beta=pos["beta"],
751
+ beta_adjusted_exposure=pos["beta_adjusted_exposure"],
752
+ total_delta_exposure=0.0,
753
+ options_delta_exposure=0.0,
754
+ )
755
+ cash_like_positions.append(cash_group)
756
+
757
+ # Apply filters based on button clicked
758
+ filtered_groups = []
759
+ if button_id == "filter-all":
760
+ # Include all positions (including cash-like)
761
+ filtered_groups = groups + cash_like_positions
762
+ elif button_id == "filter-stocks":
763
+ # Only include groups with stock positions (excluding cash-like)
764
+ filtered_groups = [g for g in groups if g.stock_position]
765
+ elif button_id == "filter-options":
766
+ # Only include groups with option positions
767
+ filtered_groups = [g for g in groups if g.option_positions]
768
+ elif button_id == "filter-cash":
769
+ # Only include cash-like positions
770
+ filtered_groups = cash_like_positions
771
+ else:
772
+ # Default to all positions
773
+ filtered_groups = groups + cash_like_positions
774
+
775
+ # Apply search filter if provided
776
+ if search:
777
+ search = search.lower()
778
+ filtered_groups = [
779
+ g
780
+ for g in filtered_groups
781
+ if (
782
+ (g.stock_position and search in g.stock_position.ticker.lower())
783
+ or any(
784
+ search in opt.ticker.lower() for opt in g.option_positions
785
+ )
786
+ )
787
+ ]
788
+
789
+ # Get sort information
790
+ sort_column = sort_state.get("column", "value")
791
+ sort_direction = sort_state.get("direction", "desc")
792
+ sort_by = f"{sort_column}-{sort_direction}"
793
+
794
+ # Create table with sorting applied
795
+ return create_portfolio_table(filtered_groups, search, sort_by)
796
+
797
+ except (ValueError, KeyError) as e:
798
+ # Handle expected data errors
799
+ logger.error(f"Data error updating portfolio table: {e}", exc_info=True)
800
+ return html.Tr(
801
+ html.Td(
802
+ f"Error loading portfolio data: {e!s}",
803
+ colSpan=6,
804
+ className="text-center text-danger",
805
+ )
806
+ )
807
+ except (ImportError, NameError, AttributeError, TypeError) as e:
808
+ # These are programming errors that should be fixed, not handled
809
+ logger.critical(
810
+ f"Critical programming error in table update: {e}", exc_info=True
811
+ )
812
+ # Re-raise for development environments to see the full traceback
813
+ if app.debug:
814
+ raise
815
+ return html.Tr(
816
+ html.Td(
817
+ f"A critical error occurred. Please report this issue: {e!s}",
818
+ colSpan=6,
819
+ className="text-center text-danger",
820
+ )
821
+ )
822
+ except Exception as e:
823
+ # Unexpected errors should be logged and reported
824
+ logger.critical(
825
+ f"Unexpected error updating portfolio table: {e}", exc_info=True
826
+ )
827
+ # Re-raise for development environments to see the full traceback
828
+ if app.debug:
829
+ raise
830
+ return html.Tr(
831
+ html.Td(
832
+ f"An unexpected error occurred: {e!s}",
833
+ colSpan=6,
834
+ className="text-center text-danger",
835
+ )
836
+ )
837
+
838
+ # Position details modal callbacks removed - functionality integrated into P&L modal
839
+
840
+ def _create_portfolio_group_from_data(position_data):
841
+ """Helper function to create a PortfolioGroup from position data"""
842
+ stock_position = None
843
+ if position_data["stock_position"]:
844
+ stock_position_data = position_data["stock_position"].copy()
845
+ # Remove attributes that don't exist in Position class
846
+ if "sector" in stock_position_data:
847
+ stock_position_data.pop("sector")
848
+ if "is_cash_like" in stock_position_data:
849
+ stock_position_data.pop("is_cash_like")
850
+ stock_position = StockPosition(**stock_position_data)
851
+
852
+ option_positions = [
853
+ OptionPosition(**opt) for opt in position_data["option_positions"]
854
+ ]
855
+
856
+ return PortfolioGroup(
857
+ ticker=position_data["ticker"],
858
+ stock_position=stock_position,
859
+ option_positions=option_positions,
860
+ net_exposure=position_data["net_exposure"],
861
+ beta=position_data["beta"],
862
+ beta_adjusted_exposure=position_data["beta_adjusted_exposure"],
863
+ total_delta_exposure=position_data["total_delta_exposure"],
864
+ options_delta_exposure=position_data["options_delta_exposure"],
865
+ )
866
+
867
+ # Old chat panel toggle callback removed - using premium chat component instead
868
+
869
+ # Old dash-chat callback removed - using premium chat component instead
870
+
871
+ # Add callback to handle column sorting
872
+ @app.callback(
873
+ Output("sort-state", "data"),
874
+ [Input({"type": "sort-header", "column": ALL}, "n_clicks")],
875
+ [State("sort-state", "data")],
876
+ )
877
+ @handle_callback_error(
878
+ default_return=None, error_message="Error updating sort state"
879
+ )
880
+ def update_sort_state(_header_clicks, current_sort_state):
881
+ """Update sort state when a column header is clicked"""
882
+ ctx = dash.callback_context
883
+
884
+ if not ctx.triggered:
885
+ return current_sort_state
886
+
887
+ trigger_id = ctx.triggered[0]["prop_id"]
888
+ if not trigger_id or "sort-header" not in trigger_id:
889
+ return current_sort_state
890
+
891
+ # Extract column name from trigger ID (in format {"type":"sort-header","column":"value"}.n_clicks)
892
+
893
+ column_data = json.loads(trigger_id.split(".")[0])
894
+ clicked_column = column_data.get("column")
895
+
896
+ if not clicked_column:
897
+ logger.debug(f"No column found in trigger data: {column_data}")
898
+ return current_sort_state
899
+
900
+ # Update sort direction if same column clicked, otherwise reset to descending
901
+ if clicked_column == current_sort_state.get("column"):
902
+ new_direction = (
903
+ "asc" if current_sort_state.get("direction") == "desc" else "desc"
904
+ )
905
+ else:
906
+ new_direction = "desc"
907
+
908
+ logger.debug(f"Sorting by {clicked_column} in {new_direction} order")
909
+ return {"column": clicked_column, "direction": new_direction}
910
+
911
+ # Add callbacks for collapsible chart sections
912
+ @app.callback(
913
+ [
914
+ Output("dashboard-collapse", "is_open"),
915
+ Output("dashboard-collapse-icon", "className"),
916
+ ],
917
+ [Input("dashboard-collapse-button", "n_clicks")],
918
+ [State("dashboard-collapse", "is_open")],
919
+ prevent_initial_call=True,
920
+ )
921
+ def toggle_dashboard_collapse(n_clicks, is_open):
922
+ """Toggle the dashboard section collapse state"""
923
+ if n_clicks:
924
+ # Toggle the collapse state
925
+ new_state = not is_open
926
+ # Update the icon based on the new state
927
+ icon_class = (
928
+ "fas fa-chevron-up ms-2" if new_state else "fas fa-chevron-down ms-2"
929
+ )
930
+ return new_state, icon_class
931
+ return is_open, "fas fa-chevron-down ms-2"
932
+
933
+ # Add callbacks for main section collapses
934
+ for section in ["summary", "charts"]:
935
+
936
+ @app.callback(
937
+ [
938
+ Output(f"{section}-collapse", "is_open"),
939
+ Output(f"{section}-collapse-icon", "className"),
940
+ ],
941
+ [Input(f"{section}-collapse-button", "n_clicks")],
942
+ [State(f"{section}-collapse", "is_open")],
943
+ prevent_initial_call=True,
944
+ )
945
+ def toggle_chart_collapse(n_clicks, is_open, _section=section):
946
+ """Toggle the chart section collapse state"""
947
+ if n_clicks:
948
+ # Toggle the collapse state
949
+ new_state = not is_open
950
+ # Update the icon based on the new state
951
+ icon_class = (
952
+ "fas fa-chevron-up ms-2"
953
+ if new_state
954
+ else "fas fa-chevron-down ms-2"
955
+ )
956
+ return new_state, icon_class
957
+ return is_open, "fas fa-chevron-down ms-2"
958
+
959
+ # Add callback for positions collapse
960
+ @app.callback(
961
+ [
962
+ Output("positions-collapse", "is_open"),
963
+ Output("positions-collapse-icon", "className"),
964
+ ],
965
+ [Input("positions-collapse-button", "n_clicks")],
966
+ [State("positions-collapse", "is_open")],
967
+ prevent_initial_call=True,
968
+ )
969
+ def toggle_positions_collapse(n_clicks, is_open):
970
+ """Toggle the positions section collapse state"""
971
+ if n_clicks:
972
+ # Toggle the collapse state
973
+ new_state = not is_open
974
+ # Update the icon based on the new state
975
+ icon_class = (
976
+ "fas fa-chevron-up ms-2" if new_state else "fas fa-chevron-down ms-2"
977
+ )
978
+ return new_state, icon_class
979
+ return is_open, "fas fa-chevron-down ms-2"
980
+
981
+ # Register chart callbacks
982
+ register_chart_callbacks(app)
983
+
984
+ # Register premium chat callbacks
985
+ register_premium_chat_callbacks(app) # This is now an alias for register_callbacks
986
+
987
+ return app
988
+
989
+
990
+ def main():
991
+ """Main entry point"""
992
+ parser = argparse.ArgumentParser(description="Folio - Portfolio Dashboard")
993
+ parser.add_argument(
994
+ "--portfolio",
995
+ type=str,
996
+ help="Path to portfolio CSV file",
997
+ )
998
+ parser.add_argument(
999
+ "--debug",
1000
+ action="store_true",
1001
+ help="Enable debug mode",
1002
+ )
1003
+ parser.add_argument(
1004
+ "--port",
1005
+ type=int,
1006
+ default=8050,
1007
+ help="Port to run the server on",
1008
+ )
1009
+ parser.add_argument(
1010
+ "--host",
1011
+ type=str,
1012
+ default="127.0.0.1",
1013
+ help="Host to run the server on",
1014
+ )
1015
+ args = parser.parse_args()
1016
+
1017
+ # Validate portfolio file if provided
1018
+ portfolio_file = None
1019
+ if args.portfolio:
1020
+ portfolio_file = Path(args.portfolio)
1021
+ if not portfolio_file.exists():
1022
+ logger.error(f"Portfolio file not found: {portfolio_file}")
1023
+ return 1
1024
+ portfolio_file = str(portfolio_file)
1025
+
1026
+ # Initialize and run app
1027
+ app = AppHolder.init_app(portfolio_file, args.debug)
1028
+
1029
+ # Display a helpful message about where to access the app
1030
+ is_docker = os.path.exists("/.dockerenv")
1031
+ is_huggingface = (
1032
+ os.environ.get("HF_SPACE") == "1" or os.environ.get("SPACE_ID") is not None
1033
+ )
1034
+
1035
+ if is_huggingface:
1036
+ logger.info("\n\n🚀 Folio is running on Hugging Face Spaces!")
1037
+ logger.info("📊 Access the dashboard at the URL provided by Hugging Face\n")
1038
+ elif is_docker and args.host == "0.0.0.0":
1039
+ logger.info("\n\n🚀 Folio is running inside a Docker container!")
1040
+ logger.info(f"📊 Access the dashboard at: http://localhost:{args.port}")
1041
+ logger.info(
1042
+ f"💻 (The app is bound to {args.host}:{args.port} inside the container)\n"
1043
+ )
1044
+ else:
1045
+ logger.info("\n\n🚀 Folio is running!")
1046
+ logger.info(f"📊 Access the dashboard at: http://localhost:{args.port}\n")
1047
+
1048
+ app.run_server(debug=args.debug, port=args.port, host=args.host)
1049
+ return 0
1050
+
1051
+
1052
+ # Create a class to hold the app instance
1053
+ class AppHolder:
1054
+ """Class to hold the app instance"""
1055
+
1056
+ app = None
1057
+ server = None
1058
+
1059
+ @classmethod
1060
+ def init_app(cls, portfolio_file: str | None = None, debug: bool = False):
1061
+ """Initialize the app for WSGI servers"""
1062
+ if cls.app is None:
1063
+ cls.app = create_app(portfolio_file, debug)
1064
+ cls.server = cls.app.server
1065
+ return cls.app
1066
+
1067
+
1068
+ # Create the app instance for Uvicorn to use
1069
+ app = AppHolder.init_app()
1070
+ server = AppHolder.server
1071
+
1072
+ if __name__ == "__main__":
1073
+ sys.exit(main())
src/folio/assets/components/ai.css ADDED
@@ -0,0 +1,659 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * AI Component Styles
3
+ * This file defines styles for all AI-related components
4
+ */
5
+
6
+ /* AI Analysis Section */
7
+ .analysis-section {
8
+ margin-bottom: var(--spacing-md);
9
+ padding: var(--spacing-sm);
10
+ background-color: rgba(0, 0, 0, 0.03);
11
+ border-radius: var(--border-radius-sm);
12
+ line-height: var(--line-height-base);
13
+ }
14
+
15
+ .ai-loading {
16
+ position: relative;
17
+ }
18
+
19
+ .ai-loading::after {
20
+ content: "Analyzing...";
21
+ position: absolute;
22
+ top: 0;
23
+ left: 0;
24
+ width: 100%;
25
+ height: 100%;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: center;
29
+ background-color: rgba(255, 255, 255, 0.8);
30
+ font-weight: var(--font-weight-bold);
31
+ z-index: 10;
32
+ }
33
+
34
+ /* AI Analysis Collapse Section */
35
+ #ai-analysis-collapse .card {
36
+ border: none;
37
+ box-shadow: var(--shadow-md);
38
+ transition: all var(--transition-fast);
39
+ }
40
+
41
+ #ai-analysis-collapse .card-header {
42
+ background: var(--gradient-light);
43
+ border-bottom: 1px solid var(--light-gray);
44
+ font-weight: var(--font-weight-bold);
45
+ }
46
+
47
+ #ai-analysis-collapse h5 {
48
+ color: var(--primary-color);
49
+ margin-top: var(--spacing-xs);
50
+ margin-bottom: var(--spacing-xs);
51
+ font-weight: var(--font-weight-bold);
52
+ }
53
+
54
+ #ai-analysis-collapse.collapsing {
55
+ transition: height 0.35s ease;
56
+ }
57
+
58
+ /* Analyze Button */
59
+ #analyze-portfolio-button {
60
+ background: var(--gradient-primary);
61
+ border: none;
62
+ box-shadow: 0 4px 10px rgba(42, 0, 75, 0.4);
63
+ transition: all var(--transition-fast);
64
+ font-size: var(--font-size-lg);
65
+ font-weight: var(--font-weight-bold);
66
+ padding: var(--spacing-md) var(--spacing-lg);
67
+ margin-top: var(--spacing-md);
68
+ margin-bottom: var(--spacing-md);
69
+ border-radius: var(--border-radius-md);
70
+ position: relative;
71
+ overflow: hidden;
72
+ }
73
+
74
+ #analyze-portfolio-button:hover {
75
+ box-shadow: 0 6px 15px rgba(42, 0, 75, 0.5);
76
+ transform: translateY(-1px);
77
+ }
78
+
79
+ #analyze-portfolio-button:active {
80
+ transform: translateY(1px);
81
+ box-shadow: 0 2px 5px rgba(42, 0, 75, 0.4);
82
+ }
83
+
84
+ /* Pulsing Animation */
85
+ @keyframes pulse {
86
+ 0% {
87
+ box-shadow: 0 0 0 0 rgba(75, 0, 130, 0.7);
88
+ }
89
+
90
+ 70% {
91
+ box-shadow: 0 0 0 10px rgba(75, 0, 130, 0);
92
+ }
93
+
94
+ 100% {
95
+ box-shadow: 0 0 0 0 rgba(75, 0, 130, 0);
96
+ }
97
+ }
98
+
99
+ .ai-analyze-button {
100
+ animation: pulse 2s infinite;
101
+ }
102
+
103
+ /* AI Analysis Container */
104
+ .ai-analysis-container {
105
+ background: linear-gradient(to right, rgba(255, 255, 255, 0.9), rgba(240, 249, 255, 0.9));
106
+ border: 1px solid rgba(75, 0, 130, 0.3) !important;
107
+ border-radius: var(--border-radius-md) !important;
108
+ box-shadow: var(--shadow-md);
109
+ }
110
+
111
+ /* AI Chat Panel */
112
+
113
+ /* Chat container positioning */
114
+ .ai-chat-container {
115
+ position: fixed;
116
+ bottom: var(--spacing-lg);
117
+ right: var(--spacing-lg);
118
+ z-index: 1000;
119
+ }
120
+
121
+ /* Toggle button */
122
+ .ai-toggle-button {
123
+ background: var(--gradient-primary);
124
+ border: none;
125
+ box-shadow: 0 4px 10px rgba(42, 0, 75, 0.4);
126
+ transition: all var(--transition-fast);
127
+ border-radius: var(--border-radius-lg);
128
+ padding: var(--spacing-sm) var(--spacing-md);
129
+ }
130
+
131
+ .ai-toggle-button:hover {
132
+ box-shadow: 0 6px 15px rgba(42, 0, 75, 0.5);
133
+ transform: translateY(-2px);
134
+ }
135
+
136
+ .ai-toggle-button:active {
137
+ transform: translateY(1px);
138
+ box-shadow: 0 2px 5px rgba(42, 0, 75, 0.4);
139
+ }
140
+
141
+ .ai-toggle-container {
142
+ animation: pulse 2s infinite;
143
+ }
144
+
145
+ /* Chat panel */
146
+ .ai-chat-panel {
147
+ width: 350px;
148
+ height: 500px;
149
+ background-color: var(--white);
150
+ border-radius: var(--border-radius-md);
151
+ box-shadow: var(--shadow-lg);
152
+ display: flex;
153
+ flex-direction: column;
154
+ overflow: hidden;
155
+ }
156
+
157
+ /* Chat container */
158
+ .chat-container {
159
+ display: flex;
160
+ flex-direction: column;
161
+ flex: 1;
162
+ overflow: hidden;
163
+ }
164
+
165
+ /* Chat messages area */
166
+ .chat-messages {
167
+ flex: 1;
168
+ overflow-y: auto;
169
+ display: flex;
170
+ flex-direction: column;
171
+ gap: var(--spacing-md);
172
+ }
173
+
174
+ /* Message bubbles */
175
+ .ai-message,
176
+ .user-message {
177
+ display: flex;
178
+ align-items: flex-start;
179
+ margin-bottom: var(--spacing-sm);
180
+ max-width: 100%;
181
+ }
182
+
183
+ .user-message {
184
+ flex-direction: row-reverse;
185
+ }
186
+
187
+ .ai-message-bubble,
188
+ .user-message-bubble {
189
+ padding: var(--spacing-sm) var(--spacing-md);
190
+ border-radius: var(--border-radius-lg);
191
+ max-width: 80%;
192
+ word-wrap: break-word;
193
+ }
194
+
195
+ .ai-message-bubble {
196
+ background-color: #f0f7ff;
197
+ border: 1px solid #e0eeff;
198
+ border-top-left-radius: 4px;
199
+ margin-left: var(--spacing-xs);
200
+ }
201
+
202
+ .user-message-bubble {
203
+ background-color: #e9f9ff;
204
+ border: 1px solid #d0f0ff;
205
+ border-top-right-radius: 4px;
206
+ margin-right: var(--spacing-xs);
207
+ }
208
+
209
+ /* Avatar icons */
210
+ .ai-avatar,
211
+ .user-avatar {
212
+ width: 30px;
213
+ height: 30px;
214
+ border-radius: 50%;
215
+ display: flex;
216
+ align-items: center;
217
+ justify-content: center;
218
+ flex-shrink: 0;
219
+ }
220
+
221
+ .ai-avatar {
222
+ background-color: var(--primary-color);
223
+ color: var(--white);
224
+ font-size: var(--font-size-sm);
225
+ }
226
+
227
+ .user-avatar {
228
+ background-color: var(--primary-light);
229
+ color: var(--white);
230
+ font-size: var(--font-size-sm);
231
+ }
232
+
233
+ /* Input area */
234
+ .chat-input {
235
+ border-radius: var(--border-radius-lg);
236
+ padding: var(--spacing-sm) var(--spacing-md);
237
+ border: 1px solid var(--light-gray);
238
+ flex: 1;
239
+ }
240
+
241
+ .chat-input:focus {
242
+ outline: none;
243
+ border-color: var(--primary-color);
244
+ box-shadow: 0 0 0 2px rgba(75, 0, 130, 0.2);
245
+ }
246
+
247
+ .send-button {
248
+ border-radius: 50%;
249
+ width: 38px;
250
+ height: 38px;
251
+ padding: 0;
252
+ display: flex;
253
+ align-items: center;
254
+ justify-content: center;
255
+ background: var(--gradient-primary);
256
+ border: none;
257
+ }
258
+
259
+ /* Loading indicator */
260
+ .chat-loading {
261
+ display: flex;
262
+ justify-content: center;
263
+ }
264
+
265
+ /* Markdown styling */
266
+ .ai-message-content p,
267
+ .user-message-content p {
268
+ margin-bottom: var(--spacing-xs);
269
+ }
270
+
271
+ .ai-message-content p:last-child,
272
+ .user-message-content p:last-child {
273
+ margin-bottom: 0;
274
+ }
275
+
276
+ .ai-message-content ul,
277
+ .user-message-content ul {
278
+ padding-left: var(--spacing-lg);
279
+ margin-bottom: var(--spacing-xs);
280
+ }
281
+
282
+ .ai-message-content h2,
283
+ .user-message-content h2 {
284
+ font-size: var(--font-size-lg);
285
+ font-weight: var(--font-weight-bold);
286
+ margin-top: var(--spacing-sm);
287
+ margin-bottom: var(--spacing-xs);
288
+ color: var(--primary-color);
289
+ }
290
+
291
+ .ai-message-content code,
292
+ .user-message-content code {
293
+ background-color: rgba(0, 0, 0, 0.05);
294
+ padding: 2px 4px;
295
+ border-radius: var(--border-radius-sm);
296
+ font-family: monospace;
297
+ }
298
+
299
+ /* Animation for new messages */
300
+ @keyframes fadeIn {
301
+ from {
302
+ opacity: 0;
303
+ transform: translateY(10px);
304
+ }
305
+
306
+ to {
307
+ opacity: 1;
308
+ transform: translateY(0);
309
+ }
310
+ }
311
+
312
+ .ai-message,
313
+ .user-message {
314
+ animation: fadeIn 0.3s ease-out forwards;
315
+ }
316
+
317
+ /* Premium Chat Styles */
318
+
319
+ /* Chat toggle button */
320
+ .premium-chat-toggle {
321
+ position: fixed;
322
+ bottom: var(--spacing-lg);
323
+ right: var(--spacing-lg);
324
+ z-index: 1000;
325
+ border-radius: 50%;
326
+ width: 60px;
327
+ height: 60px;
328
+ display: flex;
329
+ align-items: center;
330
+ justify-content: center;
331
+ background: var(--gradient-primary);
332
+ border: none;
333
+ box-shadow: 0 4px 15px rgba(42, 0, 75, 0.4);
334
+ transition: all var(--transition-medium);
335
+ font-size: 24px;
336
+ color: var(--white);
337
+ }
338
+
339
+ .premium-chat-toggle:hover {
340
+ transform: scale(1.05);
341
+ box-shadow: 0 6px 20px rgba(42, 0, 75, 0.5);
342
+ }
343
+
344
+ /* Pulsing animation for the toggle button */
345
+ @keyframes premium-pulse {
346
+ 0% {
347
+ box-shadow: 0 0 0 0 rgba(75, 0, 130, 0.7);
348
+ }
349
+
350
+ 70% {
351
+ box-shadow: 0 0 0 10px rgba(75, 0, 130, 0);
352
+ }
353
+
354
+ 100% {
355
+ box-shadow: 0 0 0 0 rgba(75, 0, 130, 0);
356
+ }
357
+ }
358
+
359
+ .premium-pulse {
360
+ animation: premium-pulse 2s infinite;
361
+ }
362
+
363
+ /* Main chat panel */
364
+ .premium-chat-panel {
365
+ position: fixed;
366
+ top: 0;
367
+ right: 0;
368
+ width: 0;
369
+ height: 100vh;
370
+ background-color: var(--white);
371
+ box-shadow: -5px 0 25px rgba(0, 0, 0, 0.1);
372
+ z-index: 999;
373
+ transition: width var(--transition-medium);
374
+ display: flex;
375
+ flex-direction: column;
376
+ overflow: hidden;
377
+ }
378
+
379
+ .premium-chat-panel.open {
380
+ width: 50%;
381
+ }
382
+
383
+ /* Main content shifting styles moved to layout.css */
384
+
385
+ /* Chat header */
386
+ .premium-chat-header {
387
+ background: var(--gradient-primary);
388
+ color: var(--white) !important;
389
+ /* Ensure text is white */
390
+ padding: var(--spacing-lg);
391
+ display: flex;
392
+ justify-content: space-between;
393
+ align-items: center;
394
+ box-shadow: var(--shadow-md);
395
+ position: relative;
396
+ z-index: 10;
397
+ }
398
+
399
+ .premium-chat-title {
400
+ font-size: var(--font-size-xl);
401
+ font-weight: var(--font-weight-bold);
402
+ margin: 0;
403
+ display: flex;
404
+ align-items: center;
405
+ gap: var(--spacing-sm);
406
+ }
407
+
408
+ .premium-chat-close {
409
+ background: none;
410
+ border: none;
411
+ color: var(--white);
412
+ font-size: var(--font-size-xl);
413
+ cursor: pointer;
414
+ transition: transform var(--transition-fast);
415
+ }
416
+
417
+ .premium-chat-close:hover {
418
+ transform: scale(1.1);
419
+ }
420
+
421
+ /* Chat messages container */
422
+ .premium-chat-messages {
423
+ flex: 1;
424
+ overflow-y: auto;
425
+ padding: var(--spacing-lg);
426
+ display: flex;
427
+ flex-direction: column;
428
+ gap: var(--spacing-lg);
429
+ background-color: var(--off-white);
430
+ background-image: linear-gradient(rgba(255, 255, 255, 0.7) 1px, transparent 1px),
431
+ linear-gradient(90deg, rgba(255, 255, 255, 0.7) 1px, transparent 1px);
432
+ background-size: 20px 20px;
433
+ background-position: -1px -1px;
434
+ }
435
+
436
+ /* Message bubbles */
437
+ .premium-ai-message,
438
+ .premium-user-message {
439
+ display: flex;
440
+ gap: var(--spacing-sm);
441
+ max-width: 85%;
442
+ }
443
+
444
+ .premium-ai-message {
445
+ align-self: flex-start;
446
+ }
447
+
448
+ .premium-user-message {
449
+ align-self: flex-end;
450
+ flex-direction: row-reverse;
451
+ }
452
+
453
+ .premium-avatar {
454
+ width: 40px;
455
+ height: 40px;
456
+ border-radius: 50%;
457
+ display: flex;
458
+ align-items: center;
459
+ justify-content: center;
460
+ flex-shrink: 0;
461
+ }
462
+
463
+ .premium-ai-avatar {
464
+ background: var(--gradient-primary);
465
+ color: var(--white);
466
+ }
467
+
468
+ .premium-user-avatar {
469
+ background: linear-gradient(135deg, var(--primary-light), var(--primary-color));
470
+ color: var(--white);
471
+ }
472
+
473
+ .premium-message-bubble {
474
+ padding: var(--spacing-md);
475
+ border-radius: var(--border-radius-lg);
476
+ box-shadow: var(--shadow-sm);
477
+ transition: all var(--transition-fast);
478
+ }
479
+
480
+ .premium-message-bubble:hover {
481
+ box-shadow: var(--shadow-md);
482
+ transform: translateY(-2px);
483
+ }
484
+
485
+ .premium-ai-bubble {
486
+ background-color: var(--white);
487
+ border-top-left-radius: 4px;
488
+ }
489
+
490
+ .premium-user-bubble {
491
+ background: var(--gradient-primary);
492
+ color: var(--white);
493
+ border-top-right-radius: 4px;
494
+ }
495
+
496
+ .premium-message-content {
497
+ margin: 0;
498
+ line-height: var(--line-height-base);
499
+ }
500
+
501
+ .premium-message-content p {
502
+ margin-bottom: var(--spacing-sm);
503
+ }
504
+
505
+ .premium-message-content p:last-child {
506
+ margin-bottom: 0;
507
+ }
508
+
509
+ /* Code blocks in messages */
510
+ .premium-message-content pre {
511
+ background-color: var(--light-gray);
512
+ padding: var(--spacing-sm);
513
+ border-radius: var(--border-radius-sm);
514
+ overflow-x: auto;
515
+ margin: var(--spacing-sm) 0;
516
+ }
517
+
518
+ .premium-user-bubble .premium-message-content pre {
519
+ background-color: rgba(255, 255, 255, 0.1);
520
+ }
521
+
522
+ /* Chat input area */
523
+ .premium-chat-input-container {
524
+ padding: var(--spacing-lg);
525
+ background-color: var(--white);
526
+ border-top: 1px solid var(--light-gray);
527
+ box-shadow: 0 -4px 10px rgba(0, 0, 0, 0.05);
528
+ position: relative;
529
+ z-index: 5;
530
+ }
531
+
532
+ .premium-chat-input-group {
533
+ display: flex;
534
+ gap: var(--spacing-sm);
535
+ }
536
+
537
+ .premium-chat-input {
538
+ flex: 1;
539
+ border: 1px solid var(--light-gray);
540
+ border-radius: 24px;
541
+ padding: var(--spacing-sm) var(--spacing-lg);
542
+ font-size: var(--font-size-base);
543
+ transition: all var(--transition-medium);
544
+ background-color: var(--off-white);
545
+ }
546
+
547
+ .premium-chat-input:focus {
548
+ outline: none;
549
+ border-color: var(--primary-color);
550
+ box-shadow: 0 0 0 3px rgba(75, 0, 130, 0.2);
551
+ background-color: var(--white);
552
+ transform: translateY(-2px);
553
+ }
554
+
555
+ .premium-chat-send {
556
+ background: var(--gradient-primary);
557
+ color: var(--white);
558
+ border: none;
559
+ border-radius: 50%;
560
+ width: 48px;
561
+ height: 48px;
562
+ display: flex;
563
+ align-items: center;
564
+ justify-content: center;
565
+ cursor: pointer;
566
+ transition: all var(--transition-medium);
567
+ box-shadow: 0 4px 8px rgba(42, 0, 75, 0.3);
568
+ }
569
+
570
+ .premium-chat-send:hover {
571
+ transform: scale(1.1) rotate(15deg);
572
+ box-shadow: 0 6px 12px rgba(42, 0, 75, 0.4);
573
+ }
574
+
575
+ .premium-chat-send:active {
576
+ transform: scale(0.95);
577
+ box-shadow: 0 2px 4px rgba(42, 0, 75, 0.3);
578
+ }
579
+
580
+ /* Loading indicator */
581
+ .premium-chat-loading {
582
+ align-self: center;
583
+ margin: var(--spacing-lg) 0;
584
+ display: flex;
585
+ align-items: center;
586
+ justify-content: center;
587
+ padding: var(--spacing-sm) var(--spacing-lg);
588
+ background-color: rgba(75, 0, 130, 0.1);
589
+ border-radius: var(--border-radius-lg);
590
+ box-shadow: var(--shadow-sm);
591
+ animation: pulse-loading 1.5s infinite ease-in-out;
592
+ }
593
+
594
+ /* Loading animation */
595
+ @keyframes pulse-loading {
596
+ 0% {
597
+ opacity: 0.6;
598
+ transform: scale(0.95);
599
+ }
600
+
601
+ 50% {
602
+ opacity: 1;
603
+ transform: scale(1.05);
604
+ }
605
+
606
+ 100% {
607
+ opacity: 0.6;
608
+ transform: scale(0.95);
609
+ }
610
+ }
611
+
612
+ /* Hide loading indicator when not needed */
613
+ .premium-chat-loading.d-none {
614
+ display: none;
615
+ }
616
+
617
+ /* Responsive adjustments */
618
+ @media (max-width: 992px) {
619
+ .premium-chat-panel.open {
620
+ width: 70%;
621
+ }
622
+ }
623
+
624
+ @media (max-width: 768px) {
625
+ .premium-chat-panel.open {
626
+ width: 90%;
627
+ }
628
+ }
629
+
630
+ @media (max-width: 576px) {
631
+ .premium-chat-panel.open {
632
+ width: 100%;
633
+ }
634
+ }
635
+
636
+ /* AI Advisor Header */
637
+ .ai-advisor-panel h4,
638
+ .premium-chat-title {
639
+ color: var(--white);
640
+ }
641
+
642
+ /* AI Advisor Content */
643
+ .ai-advisor-content {
644
+ display: flex;
645
+ flex-direction: column;
646
+ height: 100%;
647
+ background-color: var(--white);
648
+ overflow-y: auto;
649
+ }
650
+
651
+ /* Add a header style for the AI advisor panel */
652
+ .ai-advisor-panel .mb-3 {
653
+ background: var(--gradient-primary);
654
+ color: var(--white);
655
+ padding: var(--spacing-md);
656
+ margin-top: 0 !important;
657
+ margin-bottom: 0 !important;
658
+ border-radius: 0;
659
+ }
src/folio/assets/components/buttons.css ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Button Styles
3
+ * This file defines styles for all button components
4
+ */
5
+
6
+ /* Base button styles */
7
+ .btn {
8
+ border-radius: var(--border-radius-md);
9
+ transition: all var(--transition-fast);
10
+ font-weight: var(--font-weight-normal);
11
+ padding: var(--spacing-sm) var(--spacing-md);
12
+ }
13
+
14
+ /* Primary buttons */
15
+ .btn-primary {
16
+ background: var(--gradient-primary);
17
+ border-color: var(--primary-color);
18
+ color: var(--white);
19
+ box-shadow: 0 4px 10px rgba(42, 0, 75, 0.4);
20
+ }
21
+
22
+ .btn-primary:hover {
23
+ background: linear-gradient(135deg, var(--primary-light) 0%, var(--primary-dark) 100%);
24
+ border-color: var(--primary-light);
25
+ box-shadow: 0 6px 15px rgba(42, 0, 75, 0.5);
26
+ transform: translateY(-1px);
27
+ }
28
+
29
+ .btn-primary:active,
30
+ .btn-primary:focus {
31
+ background: linear-gradient(135deg, var(--primary-dark) 0%, var(--black) 100%);
32
+ border-color: var(--primary-dark);
33
+ box-shadow: 0 2px 5px rgba(42, 0, 75, 0.4);
34
+ }
35
+
36
+ /* Outline buttons */
37
+ .btn-outline-primary {
38
+ color: var(--primary-color);
39
+ border-color: var(--primary-color);
40
+ background-color: transparent;
41
+ }
42
+
43
+ .btn-outline-primary:hover {
44
+ background-color: var(--primary-color);
45
+ border-color: var(--primary-color);
46
+ color: var(--white);
47
+ }
48
+
49
+ /* Link buttons */
50
+ .btn-link {
51
+ color: var(--primary-color);
52
+ text-decoration: none;
53
+ padding: var(--spacing-xs) var(--spacing-sm);
54
+ }
55
+
56
+ .btn-link:hover {
57
+ color: var(--primary-light);
58
+ text-decoration: underline;
59
+ }
60
+
61
+ /* Button sizes */
62
+ .btn-sm {
63
+ padding: var(--spacing-xs) var(--spacing-sm);
64
+ font-size: var(--font-size-sm);
65
+ }
66
+
67
+ .btn-lg {
68
+ padding: var(--spacing-md) var(--spacing-lg);
69
+ font-size: var(--font-size-lg);
70
+ }
71
+
72
+ /* Button positioning */
73
+ .btn.mx-auto.d-block {
74
+ display: block;
75
+ margin-left: auto;
76
+ margin-right: auto;
77
+ }
78
+
79
+ /* Special buttons */
80
+ .load-sample-button {
81
+ /* Specific styling for the load sample button */
82
+ font-weight: var(--font-weight-bold);
83
+ }
84
+
85
+ /* Icon buttons */
86
+ .btn-icon {
87
+ width: 2.2rem;
88
+ height: 2.2rem;
89
+ padding: 0;
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: center;
93
+ border-radius: 50%;
94
+ }
95
+
96
+ .btn-icon.btn-sm {
97
+ width: 1.8rem;
98
+ height: 1.8rem;
99
+ font-size: 0.8rem;
100
+ }
101
+
102
+ .btn-icon i {
103
+ line-height: 1;
104
+ }
105
+
106
+ /* Button groups */
107
+ .btn-group .btn {
108
+ border-radius: 0;
109
+ }
110
+
111
+ /* Chart toggle buttons */
112
+ .chart-toggle-buttons {
113
+ box-shadow: var(--shadow-sm);
114
+ border-radius: var(--border-radius-sm);
115
+ overflow: hidden;
116
+ }
117
+
118
+ .btn-group .btn:first-child {
119
+ border-top-left-radius: var(--border-radius-md);
120
+ border-bottom-left-radius: var(--border-radius-md);
121
+ }
122
+
123
+ .btn-group .btn:last-child {
124
+ border-top-right-radius: var(--border-radius-md);
125
+ border-bottom-right-radius: var(--border-radius-md);
126
+ }
src/folio/assets/components/cards.css ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Card Styles
3
+ * This file defines styles for all card components
4
+ */
5
+
6
+ /* Base card styles */
7
+ .card {
8
+ border: none;
9
+ border-radius: var(--border-radius-md);
10
+ box-shadow: var(--shadow-md);
11
+ background-color: var(--white);
12
+ transition: transform var(--transition-fast), box-shadow var(--transition-fast);
13
+ margin-bottom: var(--spacing-md);
14
+ }
15
+
16
+ .card:hover {
17
+ transform: translateY(-2px);
18
+ box-shadow: var(--shadow-lg);
19
+ }
20
+
21
+ /* Card header */
22
+ .card-header {
23
+ background: var(--gradient-light);
24
+ border-bottom: 1px solid var(--light-gray);
25
+ border-radius: var(--border-radius-md) var(--border-radius-md) 0 0 !important;
26
+ padding: var(--spacing-md);
27
+ }
28
+
29
+ /* Collapsible section headers - automatically applied to all collapsible sections */
30
+ .card .card-header button span {
31
+ font-size: 1.5rem;
32
+ font-weight: 500;
33
+ margin-bottom: 0;
34
+ line-height: 1.2;
35
+ }
36
+
37
+ /* Card body */
38
+ .card-body {
39
+ padding: var(--spacing-md);
40
+ }
41
+
42
+ /* Center the Portfolio Summary header */
43
+ #summary-card .card-body>h4 {
44
+ text-align: center;
45
+ }
46
+
47
+ /* Card footer */
48
+ .card-footer {
49
+ background-color: var(--off-white);
50
+ border-top: 1px solid var(--light-gray);
51
+ border-radius: 0 0 var(--border-radius-md) var(--border-radius-md);
52
+ padding: var(--spacing-md);
53
+ }
54
+
55
+ /* Card title and subtitle */
56
+ .card-title {
57
+ font-weight: var(--font-weight-bold);
58
+ margin-bottom: var(--spacing-xs);
59
+ color: var(--black);
60
+ }
61
+
62
+ .card-subtitle {
63
+ color: var(--dark-gray);
64
+ font-size: var(--font-size-sm);
65
+ margin-bottom: var(--spacing-sm);
66
+ }
67
+
68
+ /* Card text */
69
+ .card-text {
70
+ color: var(--black);
71
+ margin-bottom: var(--spacing-md);
72
+ }
73
+
74
+ .card-text:last-child {
75
+ margin-bottom: 0;
76
+ }
77
+
78
+ /* Card with gradient border */
79
+ .gradient-border {
80
+ position: relative;
81
+ border-radius: var(--border-radius-md);
82
+ padding: var(--spacing-lg);
83
+ margin-bottom: var(--spacing-lg);
84
+ background: var(--white);
85
+ box-shadow: var(--shadow-md);
86
+ border: 2px solid transparent;
87
+ background-clip: padding-box;
88
+ }
89
+
90
+ .gradient-border::before {
91
+ content: "";
92
+ position: absolute;
93
+ top: 0;
94
+ left: 0;
95
+ right: 0;
96
+ bottom: 0;
97
+ z-index: -1;
98
+ margin: -2px;
99
+ border-radius: inherit;
100
+ background: var(--gradient-primary);
101
+ pointer-events: none;
102
+ }
103
+
104
+ /* Metric cards */
105
+ .metric-card {
106
+ background-color: var(--white);
107
+ border-radius: var(--border-radius-md);
108
+ padding: var(--spacing-md);
109
+ box-shadow: var(--shadow-sm);
110
+ text-align: center;
111
+ height: 100%;
112
+ transition: transform var(--transition-fast), box-shadow var(--transition-fast);
113
+ }
114
+
115
+ .metric-card:hover {
116
+ transform: translateY(-2px);
117
+ box-shadow: var(--shadow-md);
118
+ }
119
+
120
+ .metric-title {
121
+ font-size: var(--font-size-sm);
122
+ font-weight: var(--font-weight-bold);
123
+ color: var(--dark-gray);
124
+ margin-bottom: var(--spacing-xs);
125
+ }
126
+
127
+ .metric-value {
128
+ font-size: var(--font-size-xl);
129
+ font-weight: var(--font-weight-bold);
130
+ margin-bottom: var(--spacing-xs);
131
+ }
132
+
133
+ /* Chart cards */
134
+ .chart-card {
135
+ border: none;
136
+ border-radius: var(--border-radius-md);
137
+ box-shadow: var(--shadow-md);
138
+ transition: all var(--transition-medium);
139
+ overflow: hidden;
140
+ background-color: var(--white);
141
+ }
142
+
143
+ .chart-card:hover {
144
+ box-shadow: var(--shadow-lg);
145
+ transform: translateY(-2px);
146
+ }
147
+
148
+ .chart-card .card-header {
149
+ background: var(--gradient-light);
150
+ border-bottom: 1px solid var(--light-gray);
151
+ padding: var(--spacing-md);
152
+ border-radius: var(--border-radius-md) var(--border-radius-md) 0 0 !important;
153
+ }
154
+
155
+ .chart-card .card-body {
156
+ padding: var(--spacing-md);
157
+ overflow: hidden;
158
+ }
src/folio/assets/components/charts.css ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Chart Styles
3
+ * This file defines styles for all chart components
4
+ */
5
+
6
+ /* Chart container */
7
+ /* Note: Main dash-chart styles moved to components/dash.css */
8
+
9
+ /* Chart controls */
10
+ .chart-controls {
11
+ margin-top: var(--spacing-md);
12
+ display: flex;
13
+ justify-content: center;
14
+ }
15
+
16
+ /* Chart toggle buttons moved to buttons.css */
17
+
18
+ /* Chart tooltips */
19
+ .plotly-tooltip {
20
+ background-color: rgba(255, 255, 255, 0.95) !important;
21
+ border: 1px solid rgba(0, 0, 0, 0.1) !important;
22
+ border-radius: var(--border-radius-sm) !important;
23
+ box-shadow: var(--shadow-md) !important;
24
+ padding: var(--spacing-sm) var(--spacing-md) !important;
25
+ font-family: var(--font-family) !important;
26
+ font-size: var(--font-size-sm) !important;
27
+ }
28
+
29
+ /* Chart legends */
30
+ .legend {
31
+ font-family: var(--font-family) !important;
32
+ font-size: var(--font-size-sm) !important;
33
+ }
34
+
35
+ /* Chart axes */
36
+ .xtick text,
37
+ .ytick text {
38
+ font-family: var(--font-family) !important;
39
+ font-size: var(--font-size-sm) !important;
40
+ color: var(--dark-gray) !important;
41
+ }
42
+
43
+ .xgrid,
44
+ .ygrid {
45
+ stroke: var(--light-gray) !important;
46
+ stroke-width: 1 !important;
47
+ }
48
+
49
+ /* Chart title */
50
+ .gtitle {
51
+ font-family: var(--font-family) !important;
52
+ font-weight: var(--font-weight-bold) !important;
53
+ font-size: var(--font-size-lg) !important;
54
+ color: var(--black) !important;
55
+ }
56
+
57
+ /* Chart modebar */
58
+ .modebar {
59
+ opacity: 0.3 !important;
60
+ transition: opacity var(--transition-fast) !important;
61
+ }
62
+
63
+ .modebar:hover {
64
+ opacity: 1 !important;
65
+ }
66
+
67
+ .modebar-btn {
68
+ color: var(--primary-color) !important;
69
+ }
70
+
71
+ /* Chart loading */
72
+ .js-plotly-plot .plot-container .plotly .loader {
73
+ border-color: var(--primary-color) !important;
74
+ }
75
+
76
+ /* Responsive charts */
77
+ @media (max-width: 768px) {
78
+ .dash-chart {
79
+ min-height: 250px;
80
+ max-height: 300px;
81
+ }
82
+
83
+ /* Ensure charts don't overflow on mobile */
84
+ .js-plotly-plot,
85
+ .plotly,
86
+ .plot-container {
87
+ width: 100% !important;
88
+ max-width: 100% !important;
89
+ overflow: hidden !important;
90
+ }
91
+ }
src/folio/assets/components/dash.css ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Dash-specific Styles
3
+ * This file defines styles for Dash-specific components
4
+ */
5
+
6
+ /* Loading spinner */
7
+ ._dash-loading {
8
+ background-color: rgba(255, 255, 255, 0.8) !important;
9
+ }
10
+
11
+ ._dash-loading-callback {
12
+ border-color: var(--primary-color) !important;
13
+ }
14
+
15
+ /* Dash charts */
16
+ .dash-chart {
17
+ width: 100% !important;
18
+ max-width: 100% !important;
19
+ height: auto !important;
20
+ min-height: 350px;
21
+ max-height: 450px;
22
+ overflow: visible !important;
23
+ /* Changed from hidden to visible */
24
+ border-radius: 0 0 var(--border-radius-md) var(--border-radius-md);
25
+ padding: var(--spacing-sm);
26
+ position: relative;
27
+ /* Ensure proper positioning */
28
+ display: block;
29
+ /* Force block display */
30
+ }
31
+
32
+ /* Ensure charts don't overflow but can still render properly */
33
+ .js-plotly-plot,
34
+ .plotly,
35
+ .plot-container {
36
+ width: 100% !important;
37
+ max-width: 100% !important;
38
+ overflow: visible !important;
39
+ /* Changed from hidden to visible */
40
+ position: relative;
41
+ /* Ensure proper positioning */
42
+ display: block;
43
+ /* Force block display */
44
+ }
45
+
46
+ /* Force chart visibility */
47
+ .dash-chart>div {
48
+ visibility: visible !important;
49
+ opacity: 1 !important;
50
+ }
51
+
52
+ /* Prevent charts from capturing scroll events */
53
+ .dash-chart .plotly,
54
+ .dash-chart .js-plotly-plot,
55
+ .dash-chart .plot-container {
56
+ pointer-events: auto !important;
57
+ /* Allow clicks but not scroll */
58
+ }
59
+
60
+ /* Only allow pointer events on specific interactive elements */
61
+ .dash-chart .drag,
62
+ .dash-chart .zoom,
63
+ .dash-chart .pan,
64
+ .dash-chart .select,
65
+ .dash-chart .lasso {
66
+ pointer-events: auto !important;
67
+ }
68
+
69
+ /* Responsive adjustments */
70
+ @media (max-width: 768px) {
71
+ .dash-chart {
72
+ min-height: 250px;
73
+ max-height: 300px;
74
+ }
75
+ }
src/folio/assets/components/forms.css ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Form Styles
3
+ * This file defines styles for all form components
4
+ */
5
+
6
+ /* Form controls */
7
+ .form-control {
8
+ border-radius: var(--border-radius-sm);
9
+ border: var(--border-width) solid var(--medium-gray);
10
+ padding: var(--spacing-sm);
11
+ font-size: var(--font-size-base);
12
+ line-height: var(--line-height-base);
13
+ color: var(--black);
14
+ background-color: var(--white);
15
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
16
+ }
17
+
18
+ .form-control:focus {
19
+ box-shadow: 0 0 0 3px rgba(75, 0, 130, 0.2);
20
+ border-color: var(--primary-color);
21
+ outline: 0;
22
+ }
23
+
24
+ /* Form labels */
25
+ .form-label {
26
+ font-weight: var(--font-weight-bold);
27
+ margin-bottom: var(--spacing-xs);
28
+ color: var(--black);
29
+ }
30
+
31
+ /* Form groups */
32
+ .form-group {
33
+ margin-bottom: var(--spacing-md);
34
+ }
35
+
36
+ /* Input groups */
37
+ .input-group {
38
+ display: flex;
39
+ position: relative;
40
+ }
41
+
42
+ .input-group .form-control {
43
+ position: relative;
44
+ flex: 1 1 auto;
45
+ width: 1%;
46
+ min-width: 0;
47
+ }
48
+
49
+ .input-group-text {
50
+ display: flex;
51
+ align-items: center;
52
+ padding: var(--spacing-sm);
53
+ font-size: var(--font-size-base);
54
+ font-weight: var(--font-weight-normal);
55
+ line-height: var(--line-height-base);
56
+ color: var(--dark-gray);
57
+ text-align: center;
58
+ white-space: nowrap;
59
+ background-color: var(--off-white);
60
+ border: var(--border-width) solid var(--medium-gray);
61
+ border-radius: var(--border-radius-sm);
62
+ }
63
+
64
+ /* Upload area */
65
+ #upload-portfolio {
66
+ border: 2px dashed var(--primary-color);
67
+ background-color: rgba(75, 0, 130, 0.03);
68
+ border-radius: var(--border-radius-md);
69
+ padding: var(--spacing-lg);
70
+ text-align: center;
71
+ transition: all var(--transition-fast);
72
+ cursor: pointer;
73
+ }
74
+
75
+ #upload-portfolio:hover {
76
+ border-color: var(--primary-light);
77
+ background-color: rgba(75, 0, 130, 0.05);
78
+ }
79
+
80
+ /* Checkboxes and radios */
81
+ .form-check {
82
+ display: block;
83
+ min-height: 1.5rem;
84
+ padding-left: 1.5rem;
85
+ margin-bottom: var(--spacing-sm);
86
+ }
87
+
88
+ .form-check-input {
89
+ width: 1rem;
90
+ height: 1rem;
91
+ margin-top: 0.25rem;
92
+ margin-left: -1.5rem;
93
+ background-color: var(--white);
94
+ border: var(--border-width) solid var(--medium-gray);
95
+ }
96
+
97
+ .form-check-input:checked {
98
+ background-color: var(--primary-color);
99
+ border-color: var(--primary-color);
100
+ }
101
+
102
+ .form-check-label {
103
+ margin-bottom: 0;
104
+ }
105
+
106
+ /* Textarea */
107
+ textarea.form-control {
108
+ min-height: 100px;
109
+ resize: vertical;
110
+ }
src/folio/assets/components/modals.css ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Modal Styles
3
+ * This file defines styles for all modal components
4
+ */
5
+
6
+ /* Modal container */
7
+ .modal-content {
8
+ border: none;
9
+ border-radius: var(--border-radius-lg);
10
+ box-shadow: var(--shadow-lg);
11
+ background-color: var(--white);
12
+ overflow: hidden;
13
+ }
14
+
15
+ /* Modal header */
16
+ .modal-header {
17
+ border-bottom: 1px solid var(--light-gray);
18
+ background: var(--gradient-light);
19
+ padding: var(--spacing-md) var(--spacing-lg);
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: space-between;
23
+ }
24
+
25
+ .modal-title {
26
+ margin: 0;
27
+ font-weight: var(--font-weight-bold);
28
+ color: var(--black);
29
+ }
30
+
31
+ .modal-header .close {
32
+ padding: var(--spacing-sm);
33
+ margin: calc(-1 * var(--spacing-sm)) calc(-1 * var(--spacing-sm)) calc(-1 * var(--spacing-sm)) auto;
34
+ background-color: transparent;
35
+ border: 0;
36
+ font-size: 1.5rem;
37
+ color: var(--dark-gray);
38
+ opacity: 0.5;
39
+ transition: opacity var(--transition-fast);
40
+ }
41
+
42
+ .modal-header .close:hover {
43
+ opacity: 1;
44
+ }
45
+
46
+ /* Modal body */
47
+ .modal-body {
48
+ padding: var(--spacing-lg);
49
+ }
50
+
51
+ /* Modal footer */
52
+ .modal-footer {
53
+ border-top: 1px solid var(--light-gray);
54
+ padding: var(--spacing-md) var(--spacing-lg);
55
+ display: flex;
56
+ align-items: center;
57
+ justify-content: flex-end;
58
+ }
59
+
60
+ .modal-footer > * {
61
+ margin: 0 var(--spacing-xs);
62
+ }
63
+
64
+ /* Modal sizes */
65
+ .modal-sm {
66
+ max-width: 300px;
67
+ }
68
+
69
+ .modal-lg {
70
+ max-width: 800px;
71
+ }
72
+
73
+ .modal-xl {
74
+ max-width: 1140px;
75
+ }
76
+
77
+ /* Position details modal */
78
+ #position-modal .modal-body {
79
+ padding: var(--spacing-md);
80
+ }
81
+
82
+ #position-modal .table {
83
+ margin-bottom: 0;
84
+ }
85
+
86
+ /* P&L modal */
87
+ #pnl-modal .modal-body {
88
+ padding: var(--spacing-md);
89
+ }
90
+
91
+ /* AI modal */
92
+ #ai-modal .modal-body {
93
+ padding: var(--spacing-lg);
94
+ }
95
+
96
+ /* Modal backdrop */
97
+ .modal-backdrop {
98
+ background-color: rgba(0, 0, 0, 0.5);
99
+ }
100
+
101
+ /* Modal animations */
102
+ .modal.fade .modal-dialog {
103
+ transition: transform var(--transition-medium);
104
+ transform: translate(0, -50px);
105
+ }
106
+
107
+ .modal.show .modal-dialog {
108
+ transform: none;
109
+ }
src/folio/assets/components/tables.css ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Table Styles
3
+ * This file defines styles for all table components
4
+ */
5
+
6
+ /* Base table styles */
7
+ .table {
8
+ width: 100% !important;
9
+ background-color: var(--white);
10
+ border-radius: var(--border-radius-md);
11
+ overflow: hidden;
12
+ border-collapse: separate;
13
+ border-spacing: 0;
14
+ margin-bottom: var(--spacing-lg);
15
+ table-layout: fixed !important;
16
+ /* Use fixed table layout for more predictable column widths */
17
+ box-sizing: border-box !important;
18
+ }
19
+
20
+ /* Table header */
21
+ .table thead th {
22
+ background: var(--gradient-light);
23
+ border-bottom: 1px solid var(--light-gray);
24
+ font-weight: var(--font-weight-bold);
25
+ color: var(--black);
26
+ padding: var(--spacing-md);
27
+ text-align: left;
28
+ white-space: nowrap;
29
+ /* Prevent wrapping in headers */
30
+ }
31
+
32
+ /* Table body */
33
+ .table tbody {
34
+ /* No border needed */
35
+ }
36
+
37
+ .table tbody tr {
38
+ transition: background-color var(--transition-fast);
39
+ width: 100% !important;
40
+ margin: 0 !important;
41
+ display: table-row !important;
42
+ }
43
+
44
+ .table tbody tr:hover {
45
+ background-color: rgba(75, 0, 130, 0.05);
46
+ }
47
+
48
+ .table tbody td {
49
+ padding: var(--spacing-md);
50
+ border-bottom: 1px solid var(--light-gray);
51
+ vertical-align: middle;
52
+ word-wrap: break-word;
53
+ /* Allow long text to wrap */
54
+ overflow-wrap: break-word;
55
+ white-space: normal;
56
+ /* Allow text to wrap */
57
+ box-sizing: border-box !important;
58
+ }
59
+
60
+ /* Sortable headers */
61
+ .sort-header {
62
+ cursor: pointer;
63
+ transition: background-color var(--transition-fast);
64
+ padding: var(--spacing-xs) var(--spacing-sm);
65
+ border-radius: var(--border-radius-sm);
66
+ display: flex;
67
+ align-items: center;
68
+ justify-content: space-between;
69
+ }
70
+
71
+ .sort-header:hover {
72
+ background-color: rgba(0, 0, 0, 0.05);
73
+ }
74
+
75
+ .sort-header i {
76
+ margin-left: var(--spacing-xs);
77
+ opacity: 0.5;
78
+ }
79
+
80
+ /* Portfolio table specific */
81
+ .portfolio-table {
82
+ box-shadow: var(--shadow-md);
83
+ width: 100% !important;
84
+ border: 1px solid var(--light-gray);
85
+ border-radius: var(--border-radius-md);
86
+ overflow: hidden;
87
+ box-sizing: border-box !important;
88
+ table-layout: fixed !important;
89
+ }
90
+
91
+ .portfolio-table-container {
92
+ margin-bottom: var(--spacing-lg);
93
+ overflow-x: auto;
94
+ /* Add horizontal scrolling if needed */
95
+ padding: 0 var(--spacing-md);
96
+
97
+ width: 100% !important;
98
+ box-sizing: border-box !important;
99
+ }
100
+
101
+ .position-row {
102
+ transition: background-color var(--transition-fast);
103
+ cursor: pointer;
104
+
105
+ width: 100% !important;
106
+ margin: 0 !important;
107
+ }
108
+
109
+ .position-row:hover {
110
+ background-color: rgba(75, 0, 130, 0.08) !important;
111
+ }
112
+
113
+ /* Ensure columns in portfolio table have proper spacing */
114
+ .position-row>[class*="col-"],
115
+ .g-0>[class*="col-"] {
116
+ overflow: hidden;
117
+ text-overflow: ellipsis;
118
+ }
119
+
120
+ .header-row {
121
+
122
+ width: 100% !important;
123
+ margin: 0 !important;
124
+ }
125
+
126
+ /* Responsive tables */
127
+ @media (max-width: 768px) {
128
+ .table {
129
+ font-size: var(--font-size-sm);
130
+ }
131
+
132
+ .table thead th,
133
+ .table tbody td {
134
+ padding: var(--spacing-sm);
135
+ }
136
+ }
137
+
138
+ /* Tooltip behavior for portfolio table */
139
+ .tooltip {
140
+ opacity: 0;
141
+ transition: opacity var(--transition-fast);
142
+ z-index: var(--z-index-tooltip);
143
+ }
144
+
145
+ .tooltip.show {
146
+ opacity: 1;
147
+ }
148
+
149
+ /* Ensure only one tooltip is visible at a time */
150
+ .position-row:not(:hover) .tooltip {
151
+ display: none !important;
152
+ }
src/folio/assets/js/prevent_chart_scroll.js ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Prevent chart scroll interference with page scrolling
3
+ *
4
+ * This script prevents the charts from capturing wheel events when users
5
+ * are trying to scroll through the page. It adds event listeners to all
6
+ * chart elements to stop wheel events from propagating.
7
+ */
8
+
9
+ // Wait for the document to be fully loaded
10
+ document.addEventListener('DOMContentLoaded', function() {
11
+ // Function to prevent wheel events on charts
12
+ function preventChartScroll() {
13
+ // Find all chart containers
14
+ const chartElements = document.querySelectorAll('.dash-chart');
15
+
16
+ // Add event listeners to each chart
17
+ chartElements.forEach(function(chart) {
18
+ chart.addEventListener('wheel', function(e) {
19
+ // Prevent the wheel event from being captured by the chart
20
+ e.stopPropagation();
21
+ }, true);
22
+ });
23
+
24
+ console.log('Chart scroll prevention initialized');
25
+ }
26
+
27
+ // Initialize on page load
28
+ preventChartScroll();
29
+
30
+ // Also run when the page content changes (for dynamically loaded charts)
31
+ const observer = new MutationObserver(function(mutations) {
32
+ mutations.forEach(function(mutation) {
33
+ if (mutation.addedNodes.length > 0) {
34
+ preventChartScroll();
35
+ }
36
+ });
37
+ });
38
+
39
+ // Observe the entire document for changes
40
+ observer.observe(document.body, {
41
+ childList: true,
42
+ subtree: true
43
+ });
44
+ });
src/folio/assets/layout.css ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Layout Styles
3
+ * This file defines layout-specific styles
4
+ */
5
+
6
+ /* Container */
7
+ .container-fluid {
8
+ padding: var(--spacing-md);
9
+ width: 100%;
10
+ margin-right: auto;
11
+ margin-left: auto;
12
+ }
13
+
14
+ /* Spacing utilities */
15
+ .my-1 {
16
+ margin-top: var(--spacing-xs);
17
+ margin-bottom: var(--spacing-xs);
18
+ }
19
+
20
+ .my-2 {
21
+ margin-top: var(--spacing-sm);
22
+ margin-bottom: var(--spacing-sm);
23
+ }
24
+
25
+ .my-3 {
26
+ margin-top: var(--spacing-md);
27
+ margin-bottom: var(--spacing-md);
28
+ }
29
+
30
+ .my-4 {
31
+ margin-top: var(--spacing-lg);
32
+ margin-bottom: var(--spacing-lg);
33
+ }
34
+
35
+ .my-5 {
36
+ margin-top: var(--spacing-xl);
37
+ margin-bottom: var(--spacing-xl);
38
+ }
39
+
40
+ .mx-1 {
41
+ margin-left: var(--spacing-xs);
42
+ margin-right: var(--spacing-xs);
43
+ }
44
+
45
+ .mx-2 {
46
+ margin-left: var(--spacing-sm);
47
+ margin-right: var(--spacing-sm);
48
+ }
49
+
50
+ .mx-3 {
51
+ margin-left: var(--spacing-md);
52
+ margin-right: var(--spacing-md);
53
+ }
54
+
55
+ .mx-4 {
56
+ margin-left: var(--spacing-lg);
57
+ margin-right: var(--spacing-lg);
58
+ }
59
+
60
+ .mx-5 {
61
+ margin-left: var(--spacing-xl);
62
+ margin-right: var(--spacing-xl);
63
+ }
64
+
65
+ .py-1 {
66
+ padding-top: var(--spacing-xs);
67
+ padding-bottom: var(--spacing-xs);
68
+ }
69
+
70
+ .py-2 {
71
+ padding-top: var(--spacing-sm);
72
+ padding-bottom: var(--spacing-sm);
73
+ }
74
+
75
+ .py-3 {
76
+ padding-top: var(--spacing-md);
77
+ padding-bottom: var(--spacing-md);
78
+ }
79
+
80
+ .py-4 {
81
+ padding-top: var(--spacing-lg);
82
+ padding-bottom: var(--spacing-lg);
83
+ }
84
+
85
+ .py-5 {
86
+ padding-top: var(--spacing-xl);
87
+ padding-bottom: var(--spacing-xl);
88
+ }
89
+
90
+ .px-1 {
91
+ padding-left: var(--spacing-xs);
92
+ padding-right: var(--spacing-xs);
93
+ }
94
+
95
+ .px-2 {
96
+ padding-left: var(--spacing-sm);
97
+ padding-right: var(--spacing-sm);
98
+ }
99
+
100
+ .px-3 {
101
+ padding-left: var(--spacing-md);
102
+ padding-right: var(--spacing-md);
103
+ }
104
+
105
+ .px-4 {
106
+ padding-left: var(--spacing-lg);
107
+ padding-right: var(--spacing-lg);
108
+ }
109
+
110
+ .px-5 {
111
+ padding-left: var(--spacing-xl);
112
+ padding-right: var(--spacing-xl);
113
+ }
114
+
115
+ /* Grid system enhancements */
116
+ .row {
117
+ display: flex;
118
+ flex-wrap: wrap;
119
+ margin-left: calc(-1 * var(--spacing-md));
120
+ margin-right: calc(-1 * var(--spacing-md));
121
+ }
122
+
123
+ .col,
124
+ .col-1,
125
+ .col-2,
126
+ .col-3,
127
+ .col-4,
128
+ .col-5,
129
+ .col-6,
130
+ .col-7,
131
+ .col-8,
132
+ .col-9,
133
+ .col-10,
134
+ .col-11,
135
+ .col-12,
136
+ .col-sm,
137
+ .col-md,
138
+ .col-lg,
139
+ .col-xl {
140
+ padding-left: var(--spacing-md);
141
+ padding-right: var(--spacing-md);
142
+ }
143
+
144
+ /* App header */
145
+ .app-header {
146
+ margin-bottom: var(--spacing-lg);
147
+ padding-bottom: var(--spacing-sm);
148
+ border-bottom: 1px solid var(--light-gray);
149
+ }
150
+
151
+ .app-header h2 {
152
+ background: var(--gradient-primary);
153
+ -webkit-background-clip: text;
154
+ background-clip: text;
155
+ color: transparent;
156
+ /* Standard approach */
157
+ -webkit-text-fill-color: transparent;
158
+ /* For older webkit browsers */
159
+ font-weight: var(--font-weight-bold);
160
+ margin: 0;
161
+ }
162
+
163
+ /* Empty state */
164
+ .empty-state {
165
+ background: linear-gradient(135deg, var(--off-white) 0%, var(--white) 100%);
166
+ border-radius: var(--border-radius-lg);
167
+ padding: var(--spacing-xl);
168
+ box-shadow: var(--shadow-md);
169
+ text-align: center;
170
+ margin-top: var(--spacing-lg);
171
+ }
172
+
173
+ .empty-state i {
174
+ background: var(--gradient-primary);
175
+ -webkit-background-clip: text;
176
+ background-clip: text;
177
+ color: transparent;
178
+ /* Standard approach */
179
+ -webkit-text-fill-color: transparent;
180
+ /* For older webkit browsers */
181
+ font-size: 3rem;
182
+ margin-bottom: var(--spacing-lg);
183
+ }
184
+
185
+ /* Flexbox utilities */
186
+ .d-flex {
187
+ display: flex;
188
+ }
189
+
190
+ .flex-row {
191
+ flex-direction: row;
192
+ }
193
+
194
+ .flex-column {
195
+ flex-direction: column;
196
+ }
197
+
198
+ .justify-content-start {
199
+ justify-content: flex-start;
200
+ }
201
+
202
+ .justify-content-end {
203
+ justify-content: flex-end;
204
+ }
205
+
206
+ .justify-content-center {
207
+ justify-content: center;
208
+ }
209
+
210
+ .justify-content-between {
211
+ justify-content: space-between;
212
+ }
213
+
214
+ .justify-content-around {
215
+ justify-content: space-around;
216
+ }
217
+
218
+ .align-items-start {
219
+ align-items: flex-start;
220
+ }
221
+
222
+ .align-items-end {
223
+ align-items: flex-end;
224
+ }
225
+
226
+ .align-items-center {
227
+ align-items: center;
228
+ }
229
+
230
+ .align-items-baseline {
231
+ align-items: baseline;
232
+ }
233
+
234
+ .align-items-stretch {
235
+ align-items: stretch;
236
+ }
237
+
238
+ /* Text utilities */
239
+ .text-center {
240
+ text-align: center;
241
+ }
242
+
243
+ .text-left {
244
+ text-align: left;
245
+ }
246
+
247
+ .text-right {
248
+ text-align: right;
249
+ }
250
+
251
+ .text-justify {
252
+ text-align: justify;
253
+ }
254
+
255
+ .text-primary {
256
+ color: var(--primary-color) !important;
257
+ }
258
+
259
+ .text-success {
260
+ color: var(--success-color) !important;
261
+ }
262
+
263
+ .text-danger {
264
+ color: var(--danger-color) !important;
265
+ }
266
+
267
+ .text-warning {
268
+ color: var(--warning-color) !important;
269
+ }
270
+
271
+ .text-info {
272
+ color: var(--info-color) !important;
273
+ }
274
+
275
+ .text-muted {
276
+ color: var(--dark-gray) !important;
277
+ }
278
+
279
+ /* Display utilities */
280
+ .d-none {
281
+ display: none;
282
+ }
283
+
284
+ .d-block {
285
+ display: block;
286
+ }
287
+
288
+ .d-inline {
289
+ display: inline;
290
+ }
291
+
292
+ .d-inline-block {
293
+ display: inline-block;
294
+ }
295
+
296
+ /* Loading spinner styles moved to components/dash.css */
297
+
298
+ /* Content shifting for chat panels */
299
+ .main-content-shifted {
300
+ transition: width var(--transition-medium), margin-right var(--transition-medium);
301
+ width: 100%;
302
+ margin-right: 0;
303
+ }
304
+
305
+ .main-content-shifted.chat-open {
306
+ width: 50%;
307
+ margin-right: 50%;
308
+ }
309
+
310
+ /* Responsive adjustments */
311
+ @media (max-width: 992px) {
312
+ .main-content-shifted.chat-open {
313
+ width: 70%;
314
+ margin-right: 30%;
315
+ }
316
+ }
317
+
318
+ @media (max-width: 768px) {
319
+ .container-fluid {
320
+ padding: var(--spacing-sm);
321
+ }
322
+
323
+ .app-header {
324
+ margin-bottom: var(--spacing-md);
325
+ }
326
+
327
+ .empty-state {
328
+ padding: var(--spacing-lg);
329
+ }
330
+
331
+ .main-content-shifted.chat-open {
332
+ width: 90%;
333
+ margin-right: 10%;
334
+ }
335
+ }
336
+
337
+ @media (max-width: 576px) {
338
+ .main-content-shifted.chat-open {
339
+ width: 100%;
340
+ margin-right: 0;
341
+ overflow: hidden;
342
+ }
343
+ }
344
+
345
+ .row {
346
+ margin-left: calc(-1 * var(--spacing-sm));
347
+ margin-right: calc(-1 * var(--spacing-sm));
348
+ }
349
+
350
+ .col,
351
+ .col-1,
352
+ .col-2,
353
+ .col-3,
354
+ .col-4,
355
+ .col-5,
356
+ .col-6,
357
+ .col-7,
358
+ .col-8,
359
+ .col-9,
360
+ .col-10,
361
+ .col-11,
362
+ .col-12,
363
+ .col-sm,
364
+ .col-md,
365
+ .col-lg,
366
+ .col-xl {
367
+ padding-left: var(--spacing-sm);
368
+ padding-right: var(--spacing-sm);
369
+ }
src/folio/assets/main.css ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Main CSS File
3
+ * This file imports all other CSS files and defines global styles
4
+ */
5
+
6
+ /* Import theme variables */
7
+ @import 'theme.css';
8
+
9
+ /* Import layout styles */
10
+ @import 'layout.css';
11
+
12
+ /* Import component styles */
13
+ @import 'components/buttons.css';
14
+ @import 'components/cards.css';
15
+ @import 'components/tables.css';
16
+ @import 'components/forms.css';
17
+ @import 'components/modals.css';
18
+ @import 'components/charts.css';
19
+ @import 'components/dash.css';
20
+ @import 'components/ai.css';
21
+
22
+ /* Base styles */
23
+ body {
24
+ font-family: var(--font-family);
25
+ font-size: var(--font-size-base);
26
+ line-height: var(--line-height-base);
27
+ color: var(--black);
28
+ background-color: var(--off-white);
29
+ margin: 0;
30
+ padding: 0;
31
+ }
32
+
33
+ h1,
34
+ h2,
35
+ h3,
36
+ h4,
37
+ h5,
38
+ h6 {
39
+ font-weight: var(--font-weight-bold);
40
+ line-height: 1.2;
41
+ margin-top: 0;
42
+ margin-bottom: var(--spacing-md);
43
+ color: var(--black);
44
+ }
45
+
46
+ h1 {
47
+ font-size: 2.5rem;
48
+ }
49
+
50
+ h2 {
51
+ font-size: 2rem;
52
+ }
53
+
54
+ h3 {
55
+ font-size: 1.75rem;
56
+ }
57
+
58
+ h4 {
59
+ font-size: 1.5rem;
60
+ }
61
+
62
+ h5 {
63
+ font-size: 1.25rem;
64
+ }
65
+
66
+ h6 {
67
+ font-size: 1rem;
68
+ }
69
+
70
+ p {
71
+ margin-top: 0;
72
+ margin-bottom: var(--spacing-md);
73
+ }
74
+
75
+ a {
76
+ color: var(--primary-color);
77
+ text-decoration: none;
78
+ transition: color var(--transition-fast);
79
+ }
80
+
81
+ a:hover {
82
+ color: var(--primary-light);
83
+ text-decoration: underline;
84
+ }
85
+
86
+ /* Code and keyboard shortcuts */
87
+ code,
88
+ kbd,
89
+ pre,
90
+ samp {
91
+ font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
92
+ font-size: 0.875em;
93
+ }
94
+
95
+ kbd {
96
+ display: inline-block;
97
+ padding: 0.2em 0.4em;
98
+ font-size: 0.85em;
99
+ font-weight: var(--font-weight-bold);
100
+ line-height: 1;
101
+ color: var(--white);
102
+ background-color: var(--black);
103
+ border-radius: var(--border-radius-sm);
104
+ box-shadow: 0 2px 0 rgba(0, 0, 0, 0.2);
105
+ }
106
+
107
+ /* Lists */
108
+ ul,
109
+ ol {
110
+ margin-top: 0;
111
+ margin-bottom: var(--spacing-md);
112
+ padding-left: var(--spacing-lg);
113
+ }
114
+
115
+ /* Images */
116
+ img {
117
+ max-width: 100%;
118
+ height: auto;
119
+ }
120
+
121
+ /* Horizontal rule */
122
+ hr {
123
+ margin: var(--spacing-md) 0;
124
+ border: 0;
125
+ border-top: 1px solid var(--light-gray);
126
+ }
127
+
128
+ /* Focus outline */
129
+ :focus {
130
+ outline: 0;
131
+ box-shadow: 0 0 0 3px rgba(75, 0, 130, 0.25);
132
+ }
133
+
134
+ /* Selection */
135
+ ::selection {
136
+ background-color: var(--primary-color);
137
+ color: var(--white);
138
+ }
139
+
140
+ /* Scrollbar - Standard approach for Firefox */
141
+ * {
142
+ scrollbar-width: thin;
143
+ scrollbar-color: var(--medium-gray) var(--off-white);
144
+ }
145
+
146
+ /* Scrollbar - For webkit browsers */
147
+ ::-webkit-scrollbar {
148
+ width: 8px;
149
+ height: 8px;
150
+ }
151
+
152
+ ::-webkit-scrollbar-track {
153
+ background: var(--off-white);
154
+ }
155
+
156
+ ::-webkit-scrollbar-thumb {
157
+ background: var(--medium-gray);
158
+ border-radius: 4px;
159
+ }
160
+
161
+ ::-webkit-scrollbar-thumb:hover {
162
+ background: var(--dark-gray);
163
+ }
src/folio/assets/sample-portfolio.csv ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type
2
+ SPAXX**,"HELD IN MONEY MARKET",,,,"$12,345,670.00",,,,,5.00%,,,Cash
3
+ AAPL,"APPLE INC",1500,$225.50 ,($2.10),"$33,825.00 ",($315.00),-0.92%,"$6,150.00 ",22.15%,1.25%,"$27,675.00 ",$184.50 ,Margin
4
+ -AAPL250417C220,"AAPL APR 17 2025 $220 CALL",-15,$5.50 ,($1.50),($1100.00),$300.00 ,21.43%,($200.00),-15.38%,-0.04%,"$1,300.00 ",$6.50 ,Margin
5
+ AMZN,"AMAZON.COM INC",15000,$195.00 ,($3.50),"$292,500.00 ","($5,250.00)",-1.77%,"$60,500.00 ",26.09%,10.05%,"$232,000.00 ",$154.67 ,Margin
6
+ -AMZN250417C200,"AMZN APR 17 2025 $200 CALL",-100,$3.00 ,($2.00),($3000.00),"$2,000.00 ",40.00%,$500.00 ,20.00%,-0.10%,"$3,500.00 ",$3.50 ,Margin
7
+ -AMZN250516P200,"AMZN MAY 16 2025 $200 PUT",-50,$14.00 ,$2.00 ,($7000.00),($1000.00),-16.67%,($4000.00),-133.33%,-0.24%,"$3,000.00 ",$6.00 ,Margin
8
+ BKNG,"BOOKING HOLDINGS INC COM",200,"$4,600.00 ",($100.00),"$92,000.00 ","($2,000.00)",-2.13%,"$21,000.00 ",29.58%,3.16%,"$71,000.00 ","$3,550.00 ",Margin
9
+ -CRM250620C350,"CRM JUN 20 2025 $350 CALL",-20,$1.20 ,($0.40),($240.00),$80.00 ,25.00%,$1040.00 ,80.62%,-0.01%,"$1,280.00 ",$6.40 ,Margin
10
+ -CRM250620P290,"CRM JUN 20 2025 $290 PUT",-20,$30.00 ,$5.00 ,($6000.00),($1000.00),-20.00%,($1800.00),-42.86%,-0.21%,"$4,200.00 ",$21.00 ,Margin
11
+ -CRM260618C240,"CRM JUN 18 2026 $240 CALL",20,$65.00 ,($6.00),"$13,000.00 ",($1200.00),-8.45%,($4500.00),-25.71%,0.45%,"$17,500.00 ",$87.50 ,Margin
12
+ FFRHX,"FIDELITY FLOATING RATE HIGH INCOME",200000,$9.20 ,($0.01),"$184,000.00 ",($200.00),-0.11%,($2000.00),-1.08%,6.32%,"$186,000.00 ",$9.30 ,Cash
13
+ GOOGL,"ALPHABET INC CAP STK CL A",20000,$155.00 ,($5.00),"$310,000.00 ","($10,000.00)",-3.13%,"$90,000.00 ",40.91%,10.65%,"$220,000.00 ",$110.00 ,Margin
14
+ -GOOGL250516C170,"GOOGL MAY 16 2025 $170 CALL",-100,$3.00 ,($2.00),($3000.00),"$2,000.00 ",40.00%,$500.00 ,14.29%,-0.10%,"$3,500.00 ",$3.50 ,Margin
15
+ -GOOGL250516P150,"GOOGL MAY 16 2025 $150 PUT",-50,$6.00 ,$2.50 ,($3000.00),($1250.00),-71.43%,($500.00),-20.00%,-0.10%,"$2,500.00 ",$5.00 ,Margin
16
+ META,"META PLATFORMS INC CLASS A COMMON STOCK",4000,$575.00 ,($25.00),"$230,000.00 ","($10,000.00)",-4.17%,"$50,000.00 ",27.78%,7.90%,"$180,000.00 ",$450.00 ,Margin
17
+ -META250417C650,"META APR 17 2025 $650 CALL",-30,$1.50 ,($2.50),($450.00),$750.00 ,62.50%,"$4,500.00 ",90.91%,-0.02%,"$4,950.00 ",$16.50 ,Margin
18
+ -META270115P630,"META JAN 15 2027 $630 PUT",-30,$125.00 ,$15.00 ,($100000.00),($12000.00),-13.64%,($15000.00),-17.65%,-3.44%,"$85,000.00 ",$106.25 ,Margin
19
+ MSFT,"MICROSOFT CORP",400,$380.00 ,($10.00),"$15,200.00 ",($400.00),-2.56%,($300.00),-1.94%,0.52%,"$15,500.00 ",$387.50 ,Margin
20
+ NVDA,"NVIDIA CORPORATION COM",15000,$110.00 ,($2.00),"$165,000.00 ","($3,000.00)",-1.79%,"$57,000.00 ",52.78%,5.67%,"$108,000.00 ",$72.00 ,Margin
21
+ -NVDA250417C120,"NVDA APR 17 2025 $120 CALL",-50,$1.30 ,($0.40),($650.00),$200.00 ,23.53%,"$1,350.00 ",67.50%,-0.02%,"$2,000.00 ",$4.00 ,Margin
22
+ -NVDA250417P110,"NVDA APR 17 2025 $110 PUT",-20,$5.00 ,$0.75 ,($1000.00),($150.00),-17.65%,($300.00),-42.86%,-0.03%,$700.00 ,$3.50 ,Margin
23
+ SMH,"VANECK ETF TRUST SEMICONDUCTR ETF",-13000,$210.00 ,($5.00),($273000.00),"$6,500.00 ",2.33%,"$12,000.00 ",4.21%,-9.38%,"$285,000.00 ",$219.23 ,Short
24
+ -SPY250620C580,SPY JUN 20 2025 $580 CALL,-30,$5.37,+$3.23,-$16110.00,+$669.15,+3.98%,+$669.15,+3.98%,-0.60%,$16779.15,$5.59,Margin
25
+ -SPY250620P470,SPY JUN 20 2025 $470 PUT,-30,$7.29,-$12.75,-$21870.00,-$680.97,-3.22%,-$680.97,-3.22%,-0.81%,$21189.03,$7.06,Margin
26
+ -SPY250620P525,SPY JUN 20 2025 $525 PUT,30,$17.76,-$24.36,$53280.00,+$49.63,+0.09%,+$49.63,+0.09%,1.98%,$53230.37,$17.74,Margin
27
+ TCEHY,"TENCENT HOLDINGS LIMITED UNSPON ADR",8000,$65.00 ,$0.00 ,"$52,000.00 ",$0.00 ,0.00%,"$7,000.00 ",15.56%,1.79%,"$45,000.00 ",$56.25 ,Margin
28
+ TLT,"ISHARES TR 20 YR TR BD ETF",10800,$90.00 ,$1.25 ,"$162,000.00 ",$2250.00 ,1.41%,--,--,5.57%,--,--,Cash
29
+ UBER,"UBER TECHNOLOGIES INC COM",20000,$72.50 ,($2.00),"$145,000.00 ","($4,000.00)",-2.68%,"$11,000.00 ",8.21%,4.98%,"$134,000.00 ",$67.00 ,Margin
30
+ -UBER260116C50,"UBER JAN 16 2026 $50 CALL",100,$26.50 ,($2.00),"$26,500.00 ",($2000.00),-7.02%,"$5,000.00 ",23.26%,0.91%,"$21,500.00 ",$21.50 ,Margin
31
+ UPRO,"PROSHARES ULTRAPRO S&P500",-20000,$72.50 ,($4.50),($145000.00),"$9,000.00 ",5.84%,"$16,500.00 ",10.22%,-4.98%,"$161,500.00 ",$80.75 ,Short
src/folio/assets/theme.css ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Folio Theme Variables
3
+ * This file defines all the CSS variables used throughout the application
4
+ */
5
+
6
+ :root {
7
+ /* Primary colors */
8
+ --primary-color: #6936d0cc;
9
+ /* Darker Royal Purple */
10
+ --primary-light: #6936d0;
11
+ --primary-dark: #6936d049;
12
+
13
+ /* Neutral colors */
14
+ --white: #FFFFFF;
15
+ --off-white: #F8F9FA;
16
+ --light-gray: #E9ECEF;
17
+ --medium-gray: #CED4DA;
18
+ --dark-gray: #6C757D;
19
+ --black: #212529;
20
+
21
+ /* Semantic colors */
22
+ --success-color: #28A745;
23
+ --danger-color: #DC3545;
24
+ --info-color: #17A2B8;
25
+ --warning-color: #FFC107;
26
+
27
+ /* Typography */
28
+ --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
29
+ --font-size-base: 1rem;
30
+ --font-size-sm: 0.875rem;
31
+ --font-size-lg: 1.25rem;
32
+ --font-size-xl: 1.5rem;
33
+ --font-weight-normal: 400;
34
+ --font-weight-bold: 700;
35
+ --line-height-base: 1.5;
36
+
37
+ /* Spacing */
38
+ --spacing-xs: 0.25rem;
39
+ --spacing-sm: 0.5rem;
40
+ --spacing-md: 1rem;
41
+ --spacing-lg: 1.5rem;
42
+ --spacing-xl: 2rem;
43
+
44
+ /* Borders */
45
+ --border-radius-sm: 4px;
46
+ --border-radius-md: 8px;
47
+ --border-radius-lg: 12px;
48
+ --border-width: 1px;
49
+
50
+ /* Shadows */
51
+ --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.05);
52
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.05);
53
+ --shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.1);
54
+
55
+ /* Transitions */
56
+ --transition-fast: 0.2s;
57
+ --transition-medium: 0.3s;
58
+ --transition-slow: 0.5s;
59
+
60
+ /* Z-index layers */
61
+ --z-index-dropdown: 1000;
62
+ --z-index-sticky: 1020;
63
+ --z-index-fixed: 1030;
64
+ --z-index-modal-backdrop: 1040;
65
+ --z-index-modal: 1050;
66
+ --z-index-popover: 1060;
67
+ --z-index-tooltip: 1070;
68
+
69
+ /* Gradients */
70
+ --gradient-primary: linear-gradient(135deg, var(--primary-color) 0%, var(--black) 100%);
71
+ --gradient-light: linear-gradient(to right, var(--off-white), var(--white));
72
+ }
src/folio/callbacks/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """Callback modules for the Folio application."""
2
+
3
+ # Import callback registration functions
4
+ from ..components.charts import register_callbacks as register_chart_callbacks
src/folio/cash_detection.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ """Cash detection functionality for portfolio analysis.
4
+
5
+ This module provides functions for identifying cash-like positions in a portfolio.
6
+ """
7
+
8
+
9
+ def _is_likely_money_market(
10
+ ticker: str | float | None, description: str | float | None = ""
11
+ ) -> bool:
12
+ """Determine if a position is likely a money market fund based on patterns and keywords.
13
+
14
+ This function uses pattern matching on the ticker symbol and description to identify
15
+ common money market funds and cash-like instruments.
16
+
17
+ Args:
18
+ ticker: The ticker symbol to check
19
+ description: The description of the security
20
+
21
+ Returns:
22
+ True if the position is likely a money market fund, False otherwise
23
+ """
24
+ # Handle None or non-string inputs
25
+ if ticker is None or not isinstance(ticker, str):
26
+ return False
27
+ if description is None or not isinstance(description, str):
28
+ description = ""
29
+
30
+ # Convert to uppercase for case-insensitive matching
31
+ ticker = ticker.upper()
32
+ description = description.upper()
33
+
34
+ # Pattern 1: Common money market fund symbol patterns (ending with XX)
35
+ if re.search(r"[A-Z]{2,4}XX$", ticker):
36
+ return True
37
+
38
+ # Pattern 2: Description contains money market related terms
39
+ money_market_terms = [
40
+ "MONEY MARKET",
41
+ "CASH RESERVES",
42
+ "TREASURY ONLY",
43
+ "GOVERNMENT LIQUIDITY",
44
+ "CASH MANAGEMENT",
45
+ "LIQUID ASSETS",
46
+ "CASH EQUIVALENT",
47
+ "TREASURY FUND",
48
+ "LIQUIDITY FUND",
49
+ "CASH FUND",
50
+ "RESERVE FUND",
51
+ ]
52
+
53
+ for term in money_market_terms:
54
+ if term in description:
55
+ return True
56
+
57
+ # Pattern 3: Common prefixes for money market funds
58
+ money_market_prefixes = ["SPAXX", "FMPXX", "VMFXX", "SWVXX"]
59
+ for prefix in money_market_prefixes:
60
+ if ticker.startswith(prefix):
61
+ return True
62
+
63
+ # Pattern 4: Common short-term treasury ETFs
64
+ short_term_treasury_etfs = ["BIL", "SHY", "SGOV", "GBIL"]
65
+ if ticker in short_term_treasury_etfs:
66
+ return True
67
+
68
+ return False
69
+
70
+
71
+ def is_cash_or_short_term(
72
+ ticker: str | float | None,
73
+ beta: float | None = None,
74
+ description: str | float | None = "",
75
+ ) -> bool:
76
+ """Determine if a position should be considered cash or cash-like.
77
+
78
+ This function checks if a position is likely cash or a cash-like instrument
79
+ based on its ticker, beta, and description. Cash-like instruments include
80
+ money market funds, short-term bond funds, and other low-volatility assets.
81
+
82
+ Args:
83
+ ticker: The ticker symbol to check
84
+ beta: The calculated beta value for the position
85
+ description: The description of the security
86
+
87
+ Returns:
88
+ True if the position is likely cash or cash-like, False otherwise
89
+ """
90
+ # Handle None or non-string inputs for ticker and description
91
+ if ticker is None or not isinstance(ticker, str):
92
+ return False
93
+ if description is None or not isinstance(description, str):
94
+ description = ""
95
+
96
+ # Convert to uppercase for case-insensitive matching
97
+ ticker = ticker.upper()
98
+ description = description.upper()
99
+
100
+ # Initialize result
101
+ is_cash_like = False
102
+
103
+ # Check various conditions that would make this a cash-like position
104
+
105
+ # 1. Check if it's a cash symbol
106
+ if ticker in ["CASH", "USD"]:
107
+ is_cash_like = True
108
+
109
+ # 2. Check if it's a money market fund
110
+ elif _is_likely_money_market(ticker, description):
111
+ is_cash_like = True
112
+
113
+ # 3. Check for very low beta (near zero)
114
+ elif beta is not None and abs(beta) < 0.1:
115
+ is_cash_like = True
116
+
117
+ return is_cash_like
src/folio/chart_data.py ADDED
@@ -0,0 +1,564 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chart data transformation utilities.
2
+
3
+ This module provides functions to transform portfolio data into formats
4
+ suitable for visualization with Plotly charts.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from .data_model import PortfolioGroup, PortfolioSummary
10
+ from .formatting import format_compact_currency, format_currency
11
+ from .logger import logger
12
+ from .portfolio import calculate_beta_adjusted_net_exposure
13
+ from .portfolio_value import (
14
+ calculate_component_percentages,
15
+ get_portfolio_component_values,
16
+ )
17
+
18
+
19
+ class ChartColors:
20
+ """Chart color constants for consistent visualization.
21
+
22
+ This class defines the color palette used across all charts in the application.
23
+ Use these constants instead of hardcoded hex values to ensure consistency.
24
+
25
+ IMPORTANT: These colors are used across all charts and should be kept consistent.
26
+ When adding a new chart, always use these constants instead of defining new colors.
27
+ """
28
+
29
+ # Core color palette - used across all charts
30
+ LONG = "#1E8449" # Green for long positions
31
+ SHORT = "#2F3136" # Dark gray/black for short positions
32
+ OPTIONS = "#8E44AD" # Purple for options
33
+ NET = "#2980B9" # Blue for net values
34
+ CASH = "#8E44AD" # Same purple as OPTIONS
35
+ PENDING = "#2980B9" # Same blue as NET
36
+
37
+
38
+ # transform_for_asset_allocation function has been removed in favor of the more accurate Exposure Chart
39
+
40
+
41
+ def transform_for_exposure_chart(
42
+ portfolio_summary: PortfolioSummary, use_beta_adjusted: bool = False
43
+ ) -> dict[str, Any]:
44
+ """Transform portfolio summary data for the exposure chart.
45
+
46
+ Args:
47
+ portfolio_summary: Portfolio summary data from the data model
48
+ use_beta_adjusted: Whether to use beta-adjusted values
49
+
50
+ Returns:
51
+ Dict containing data and layout for the bar chart
52
+ """
53
+ logger.debug("Transforming data for exposure chart")
54
+
55
+ # Log portfolio summary fields for debugging
56
+ logger.debug(
57
+ f"Portfolio summary net_market_exposure: {portfolio_summary.net_market_exposure}"
58
+ )
59
+ logger.debug(f"Portfolio summary long_exposure: {portfolio_summary.long_exposure}")
60
+ logger.debug(
61
+ f"Portfolio summary short_exposure: {portfolio_summary.short_exposure}"
62
+ )
63
+ logger.debug(
64
+ f"Portfolio summary options_exposure: {portfolio_summary.options_exposure}"
65
+ )
66
+
67
+ # Extract values based on whether we want beta-adjusted or not
68
+ if use_beta_adjusted:
69
+ logger.debug("Using beta-adjusted values for exposure chart")
70
+ long_value = portfolio_summary.long_exposure.total_beta_adjusted
71
+ # Show short exposure as negative
72
+ short_value = portfolio_summary.short_exposure.total_beta_adjusted
73
+ options_value = portfolio_summary.options_exposure.total_beta_adjusted
74
+ # Use the utility function for beta-adjusted net exposure
75
+ net_value = calculate_beta_adjusted_net_exposure(
76
+ portfolio_summary.long_exposure.total_beta_adjusted,
77
+ portfolio_summary.short_exposure.total_beta_adjusted,
78
+ )
79
+ logger.debug(
80
+ f"Beta-adjusted values - Long: {long_value}, Short: {short_value}, Options: {options_value}, Net: {net_value}"
81
+ )
82
+ else:
83
+ logger.debug("Using net exposure values for exposure chart")
84
+ long_value = portfolio_summary.long_exposure.total_exposure
85
+ # Show short exposure as negative
86
+ short_value = portfolio_summary.short_exposure.total_exposure
87
+ options_value = portfolio_summary.options_exposure.total_exposure
88
+ # Use the pre-calculated net market exposure
89
+ net_value = portfolio_summary.net_market_exposure
90
+ logger.debug(
91
+ f"Net exposure values - Long: {long_value}, Short: {short_value}, Options: {options_value}, Net: {net_value}"
92
+ )
93
+
94
+ # Categories and values for the chart
95
+ categories = ["Long", "Short", "Options", "Net"]
96
+ values = [long_value, short_value, options_value, net_value]
97
+
98
+ # Format values for display
99
+ text_values = [format_currency(value) for value in values]
100
+
101
+ # Colors for the bars - using ChartColors constants
102
+ colors = [
103
+ ChartColors.LONG,
104
+ ChartColors.SHORT,
105
+ ChartColors.OPTIONS,
106
+ ChartColors.NET,
107
+ ]
108
+
109
+ # Create the chart data
110
+ chart_data = {
111
+ "data": [
112
+ {
113
+ "type": "bar",
114
+ "x": categories,
115
+ "y": values,
116
+ "text": text_values,
117
+ "textposition": "auto",
118
+ "hoverinfo": "text",
119
+ "hovertemplate": "<b>%{x}</b><br>%{text}<extra></extra>",
120
+ "marker": {"color": colors, "line": {"width": 0}, "opacity": 0.9},
121
+ }
122
+ ],
123
+ "layout": {
124
+ "title": {
125
+ "text": "Market Exposure"
126
+ + (" (Beta-Adjusted)" if use_beta_adjusted else ""),
127
+ "font": {"size": 16, "color": "#2C3E50"},
128
+ "x": 0.5, # Center the title
129
+ "xanchor": "center",
130
+ },
131
+ "xaxis": {
132
+ "title": "Exposure Type",
133
+ "titlefont": {"size": 12, "color": "#7F8C8D"},
134
+ "tickfont": {"size": 11},
135
+ },
136
+ "yaxis": {
137
+ "title": "Exposure ($)",
138
+ "titlefont": {"size": 12, "color": "#7F8C8D"},
139
+ "tickfont": {"size": 11},
140
+ "gridcolor": "#ECF0F1",
141
+ "zerolinecolor": "#BDC3C7",
142
+ },
143
+ "margin": {"l": 50, "r": 20, "t": 50, "b": 50, "pad": 4},
144
+ "autosize": True, # Allow the chart to resize with its container
145
+ "plot_bgcolor": "white",
146
+ "paper_bgcolor": "white",
147
+ "font": {
148
+ "family": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
149
+ },
150
+ },
151
+ }
152
+
153
+ return chart_data
154
+
155
+
156
+ def transform_for_treemap(
157
+ portfolio_groups: list[PortfolioGroup], _group_by: str = "ticker"
158
+ ) -> dict[str, Any]:
159
+ """Transform portfolio groups data for the treemap chart.
160
+
161
+ Args:
162
+ portfolio_groups: List of portfolio groups from the data model
163
+ _group_by: Unused parameter (kept for backward compatibility)
164
+
165
+ Returns:
166
+ Dict containing data and layout for the treemap chart
167
+ """
168
+ logger.debug("Transforming data for treemap chart (grouped by ticker)")
169
+ logger.debug(f"Number of portfolio groups: {len(portfolio_groups)}")
170
+
171
+ # Log some details about the first few groups for debugging
172
+ for i, group in enumerate(portfolio_groups[:3]):
173
+ logger.debug(f"Group {i} ticker: {group.ticker}")
174
+ logger.debug(f"Group {i} net_exposure: {group.net_exposure}")
175
+ if group.stock_position:
176
+ logger.debug(
177
+ f"Group {i} stock position market_exposure: {group.stock_position.market_exposure}"
178
+ )
179
+ logger.debug(f"Group {i} has {len(group.option_positions)} option positions")
180
+
181
+ # Initialize lists for treemap data
182
+ labels = []
183
+ parents = []
184
+ values = []
185
+ texts = []
186
+ colors = []
187
+
188
+ # Add root node
189
+ labels.append("Portfolio")
190
+ parents.append("")
191
+ values.append(0) # Will be sum of children
192
+ texts.append("Portfolio")
193
+ colors.append("#FFFFFF") # White for root
194
+
195
+ # Group by ticker
196
+ # First, collect all unique tickers and calculate their total exposure
197
+ ticker_exposures = {}
198
+ for group in portfolio_groups:
199
+ ticker = group.ticker
200
+ exposure = 0
201
+
202
+ # Add stock position exposure
203
+ stock_exposure = 0
204
+ if group.stock_position:
205
+ # Use market exposure for stocks
206
+ stock_exposure = group.stock_position.market_exposure
207
+ logger.debug(f"Ticker {ticker} stock exposure: {stock_exposure}")
208
+ exposure += stock_exposure
209
+
210
+ # Add option positions exposure (delta exposure)
211
+ option_exposure = 0
212
+ for option in group.option_positions:
213
+ logger.debug(
214
+ f"Ticker {ticker} option {option.option_type} delta_exposure: {option.delta_exposure}"
215
+ )
216
+ option_exposure += option.delta_exposure
217
+
218
+ exposure += option_exposure
219
+ logger.debug(
220
+ f"Ticker {ticker} total exposure: {exposure} (stock: {stock_exposure}, options: {option_exposure})"
221
+ )
222
+
223
+ # Store the net exposure value for sizing (not absolute)
224
+ ticker_exposures[ticker] = exposure
225
+
226
+ # Sort tickers by absolute exposure (largest first)
227
+ sorted_tickers = sorted(
228
+ ticker_exposures.keys(), key=lambda t: abs(ticker_exposures[t]), reverse=True
229
+ )
230
+
231
+ # Add ticker nodes
232
+ for ticker in sorted_tickers:
233
+ # Skip tickers with zero exposure
234
+ if ticker_exposures[ticker] == 0:
235
+ continue
236
+
237
+ exposure = ticker_exposures[ticker]
238
+ labels.append(ticker)
239
+ parents.append("Portfolio")
240
+ # Note: We still use abs() here for sizing because treemap requires positive values
241
+ # But we maintain the sign information in the text and color
242
+ values.append(abs(exposure))
243
+ texts.append(f"{ticker}: {format_currency(exposure)}")
244
+
245
+ # Color based on long/short - using ChartColors constants
246
+ color = ChartColors.LONG if exposure > 0 else ChartColors.SHORT
247
+ colors.append(color)
248
+
249
+ # We don't need to add individual positions anymore - just show the ticker level
250
+
251
+ # Create the chart data
252
+ chart_data = {
253
+ "data": [
254
+ {
255
+ "type": "treemap",
256
+ "labels": labels,
257
+ "parents": parents,
258
+ "values": values,
259
+ "text": texts,
260
+ "hoverinfo": "text",
261
+ "marker": {
262
+ "colors": colors,
263
+ "line": {"width": 1, "color": "#FFFFFF"},
264
+ "pad": 3,
265
+ },
266
+ "textfont": {
267
+ "family": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
268
+ "size": 12,
269
+ "color": "#FFFFFF",
270
+ },
271
+ "hovertemplate": "%{text}<extra></extra>",
272
+ }
273
+ ],
274
+ "layout": {
275
+ "title": {
276
+ "text": "Position Size by Exposure",
277
+ "font": {"size": 16, "color": "#2C3E50"},
278
+ "x": 0.5, # Center the title
279
+ "xanchor": "center",
280
+ },
281
+ "margin": {"l": 0, "r": 0, "t": 50, "b": 0, "pad": 4},
282
+ "autosize": True, # Allow the chart to resize with its container
283
+ "paper_bgcolor": "white",
284
+ "font": {
285
+ "family": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
286
+ },
287
+ },
288
+ }
289
+
290
+ return chart_data
291
+
292
+
293
+ # Sector allocation chart has been removed as it's not currently supported
294
+
295
+
296
+ def transform_for_allocations_chart(
297
+ portfolio_summary: PortfolioSummary,
298
+ ) -> dict[str, Any]:
299
+ """Transform portfolio summary data for the allocations chart.
300
+
301
+ This function takes a portfolio summary and transforms it into a format
302
+ suitable for a bar chart showing portfolio allocations. The chart
303
+ has four main categories:
304
+ - Long: Total long exposure (stocks + options combined)
305
+ - Short: Total short exposure (stocks + options combined)
306
+ - Cash: Cash-like positions
307
+ - Pending: Pending activity
308
+
309
+ IMPORTANT: Short values are stored as negative numbers in the portfolio summary.
310
+ We maintain these negative values in the chart to show short positions below
311
+ the x-axis, providing a more intuitive visualization of long vs short positions.
312
+
313
+ Args:
314
+ portfolio_summary: The portfolio summary to transform
315
+
316
+ Returns:
317
+ A dictionary with 'data' and 'layout' keys suitable for a Plotly chart
318
+ """
319
+ logger.debug("Transforming data for allocations chart")
320
+
321
+ # Skip empty portfolios
322
+ if portfolio_summary.portfolio_estimate_value == 0:
323
+ logger.warning("Empty portfolio - no data for allocations chart")
324
+ return {
325
+ "data": [],
326
+ "layout": {
327
+ "height": 400,
328
+ "annotations": [
329
+ {
330
+ "text": "No portfolio data available",
331
+ "showarrow": False,
332
+ "font": {"color": "#7F8C8D"},
333
+ }
334
+ ],
335
+ },
336
+ }
337
+
338
+ # Get component values (short values are negative)
339
+ values = get_portfolio_component_values(portfolio_summary)
340
+
341
+ # Calculate percentages
342
+ percentages = calculate_component_percentages(values)
343
+
344
+ # Calculate combined values
345
+ long_total = values["long_stock"] + values["long_option"]
346
+ short_total = values["short_stock"] + values["short_option"]
347
+
348
+ # Format detailed breakdown for hover text
349
+ long_text = (
350
+ f"Long Total: {format_currency(long_total)} ({percentages['long_total']:.1f}%)<br>"
351
+ f"• Stocks: {format_currency(values['long_stock'])} ({percentages['long_stock']:.1f}%)<br>"
352
+ f"• Options: {format_currency(values['long_option'])} ({percentages['long_option']:.1f}%)"
353
+ )
354
+
355
+ short_text = (
356
+ f"Short Total: {format_currency(short_total)} ({percentages['short_total']:.1f}%)<br>"
357
+ f"• Stocks: {format_currency(values['short_stock'])} ({percentages['short_stock']:.1f}%)<br>"
358
+ f"• Options: {format_currency(values['short_option'])} ({percentages['short_option']:.1f}%)"
359
+ )
360
+
361
+ cash_text = f"Cash: {format_currency(values['cash'])} ({percentages['cash']:.1f}%)"
362
+ pending_text = (
363
+ f"Pending: {format_currency(values['pending'])} ({percentages['pending']:.1f}%)"
364
+ )
365
+
366
+ # Create compact text labels for display on bars
367
+ long_display_text = format_compact_currency(long_total)
368
+ short_display_text = format_compact_currency(short_total)
369
+ cash_display_text = format_compact_currency(values["cash"])
370
+ pending_display_text = format_compact_currency(values["pending"])
371
+
372
+ # Create the bar chart data with a single bar for each category
373
+ chart_data = {
374
+ "data": [
375
+ # Long position (single bar with combined value)
376
+ {
377
+ "name": "Long",
378
+ "x": ["Long"],
379
+ "y": [long_total],
380
+ "type": "bar",
381
+ "marker": {"color": ChartColors.LONG},
382
+ "text": [long_display_text], # Compact text for display
383
+ "textposition": "inside", # Show text inside bars
384
+ "insidetextanchor": "middle", # Center text
385
+ "hoverinfo": "text",
386
+ "hovertemplate": "%{text}<br>" + long_text + "<extra></extra>",
387
+ "textfont": {"color": "white", "size": 12}, # Ensure text is visible
388
+ },
389
+ # Short position (single bar with combined value)
390
+ {
391
+ "name": "Short",
392
+ "x": ["Short"],
393
+ "y": [short_total], # Already negative
394
+ "type": "bar",
395
+ "marker": {"color": ChartColors.SHORT},
396
+ "text": [short_display_text], # Compact text for display
397
+ "textposition": "inside", # Show text inside bars
398
+ "insidetextanchor": "middle", # Center text
399
+ "hoverinfo": "text",
400
+ "hovertemplate": "%{text}<br>" + short_text + "<extra></extra>",
401
+ "textfont": {"color": "white", "size": 12}, # Ensure text is visible
402
+ },
403
+ # Cash (single bar)
404
+ {
405
+ "name": "Cash",
406
+ "x": ["Cash"],
407
+ "y": [values["cash"]],
408
+ "type": "bar",
409
+ "marker": {"color": ChartColors.CASH},
410
+ "text": [cash_display_text], # Compact text for display
411
+ "textposition": "inside", # Show text inside bars
412
+ "insidetextanchor": "middle", # Center text
413
+ "hoverinfo": "text",
414
+ "hovertemplate": "%{text}<br>" + cash_text + "<extra></extra>",
415
+ "textfont": {"color": "white", "size": 12}, # Ensure text is visible
416
+ },
417
+ # Pending (single bar)
418
+ {
419
+ "name": "Pending",
420
+ "x": ["Pending"],
421
+ "y": [values["pending"]],
422
+ "type": "bar",
423
+ "marker": {"color": ChartColors.PENDING},
424
+ "text": [pending_display_text], # Compact text for display
425
+ "textposition": "inside", # Show text inside bars
426
+ "insidetextanchor": "middle", # Center text
427
+ "hoverinfo": "text",
428
+ "hovertemplate": "%{text}<br>" + pending_text + "<extra></extra>",
429
+ "textfont": {"color": "white", "size": 12}, # Ensure text is visible
430
+ },
431
+ ],
432
+ "layout": {
433
+ "title": {
434
+ "text": "Portfolio Allocation",
435
+ "font": {"size": 16, "color": "#2C3E50"},
436
+ "x": 0.5, # Center the title
437
+ "xanchor": "center",
438
+ },
439
+ "barmode": "relative", # Use relative mode instead of stack
440
+ "margin": {"l": 60, "r": 60, "t": 50, "b": 40, "pad": 4},
441
+ "autosize": True, # Allow the chart to resize with its container
442
+ "paper_bgcolor": "white",
443
+ "plot_bgcolor": "white",
444
+ "font": {
445
+ "family": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
446
+ },
447
+ "showlegend": False, # Hide legend since bar labels are clear
448
+ "yaxis": {
449
+ "title": "Value ($)",
450
+ "type": "linear",
451
+ "tickformat": "$,.1s", # Use compact format (K, M, B)
452
+ "gridcolor": "#E5E5E5",
453
+ "exponentformat": "none", # Hide exponent notation
454
+ "showticklabels": True,
455
+ "nticks": 10, # More tick marks for better readability
456
+ "showgrid": True,
457
+ "zeroline": True,
458
+ "zerolinecolor": "#000000",
459
+ "zerolinewidth": 2,
460
+ "automargin": True, # Ensure labels don't get cut off
461
+ "ticklen": 5, # Longer tick marks
462
+ "tickwidth": 1, # Slightly thicker ticks
463
+ "tickcolor": "#777777", # Darker tick color
464
+ },
465
+ "height": 400, # Increased height for better visualization
466
+ },
467
+ }
468
+
469
+ # Calculate the maximum and minimum y-values for setting the y-axis range
470
+ max_value = max(
471
+ long_total,
472
+ values["cash"],
473
+ values["pending"],
474
+ 1, # Ensure we have a non-zero range
475
+ )
476
+
477
+ min_value = min(
478
+ short_total,
479
+ 0, # Ensure we include zero in the range
480
+ )
481
+
482
+ # Add padding for better visualization
483
+ top_padding = 0.1 # 10% padding on top
484
+ bottom_padding = 0.2 # 20% padding on bottom (more space for negative values)
485
+
486
+ # Determine the appropriate y-axis range based on the data
487
+ if abs(min_value) < 0.001:
488
+ # No significant short positions, focus on positive values only
489
+ chart_data["layout"]["yaxis"]["range"] = [
490
+ -max_value * 0.05, # Small negative space for zero line visibility
491
+ max_value * (1 + top_padding),
492
+ ]
493
+ elif abs(min_value) < max_value * 0.1:
494
+ # Short positions are small (less than 10% of long)
495
+ # Use asymmetric scale with just enough room for short positions
496
+ chart_data["layout"]["yaxis"]["range"] = [
497
+ min_value * (1 + bottom_padding),
498
+ max_value * (1 + top_padding),
499
+ ]
500
+ elif abs(min_value) < max_value * 0.3:
501
+ # Short positions are moderate (10-30% of long)
502
+ # Use moderately asymmetric scale
503
+ chart_data["layout"]["yaxis"]["range"] = [
504
+ min_value * (1 + bottom_padding),
505
+ max_value * (1 + top_padding),
506
+ ]
507
+ else:
508
+ # Short positions are significant (>30% of long)
509
+ # Use a more balanced scale, but still optimized for the actual data
510
+ max_abs = max(abs(max_value), abs(min_value))
511
+ chart_data["layout"]["yaxis"]["range"] = [
512
+ -max_abs * (1 + bottom_padding) if min_value < 0 else -max_value * 0.05,
513
+ max_abs * (1 + top_padding),
514
+ ]
515
+
516
+ # Add more tick marks for better readability
517
+ chart_data["layout"]["yaxis"]["nticks"] = 12
518
+
519
+ return chart_data
520
+
521
+
522
+ def create_dashboard_metrics(
523
+ portfolio_summary: PortfolioSummary,
524
+ ) -> list[dict[str, str]]:
525
+ """Create a list of key metrics for the dashboard.
526
+
527
+ Args:
528
+ portfolio_summary: Portfolio summary data from the data model
529
+
530
+ Returns:
531
+ List of dictionaries with title, value, and help_text for each metric
532
+ """
533
+ logger.debug("Creating dashboard metrics")
534
+
535
+ # Define the metrics to display
536
+ metrics = [
537
+ {
538
+ "title": "Total Value",
539
+ "value": format_currency(portfolio_summary.total_value_net),
540
+ "help_text": portfolio_summary.help_text.get("total_value_net", ""),
541
+ },
542
+ {
543
+ "title": "Portfolio Beta",
544
+ "value": f"{portfolio_summary.portfolio_beta:.2f}",
545
+ "help_text": portfolio_summary.help_text.get("portfolio_beta", ""),
546
+ },
547
+ {
548
+ "title": "Long Exposure",
549
+ "value": format_currency(portfolio_summary.long_exposure.total_value),
550
+ "help_text": portfolio_summary.help_text.get("long_exposure", ""),
551
+ },
552
+ {
553
+ "title": "Short Exposure",
554
+ "value": format_currency(portfolio_summary.short_exposure.total_value),
555
+ "help_text": portfolio_summary.help_text.get("short_exposure", ""),
556
+ },
557
+ {
558
+ "title": "Options Exposure",
559
+ "value": format_currency(portfolio_summary.options_exposure.total_value),
560
+ "help_text": portfolio_summary.help_text.get("options_exposure", ""),
561
+ },
562
+ ]
563
+
564
+ return metrics