Commit
·
ce4bc73
0
Parent(s):
Initial commit for Folio project
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +50 -0
- .env.example +15 -0
- .gitattributes +1 -0
- .gitignore +84 -0
- .pre-commit-config.yaml +32 -0
- BEST-PRACTICES.md +249 -0
- DOCKER.md +128 -0
- Dockerfile +43 -0
- LICENSE +21 -0
- Makefile +296 -0
- README.md +122 -0
- activate-venv.sh +4 -0
- docker-compose.test.yml +20 -0
- docker-compose.yml +17 -0
- pyproject.toml +94 -0
- requirements-dev.txt +30 -0
- requirements.txt +25 -0
- scripts/README.md +77 -0
- scripts/check_beta.py +175 -0
- scripts/clean.sh +27 -0
- scripts/compare_exposures_ui.py +174 -0
- scripts/debug_portfolio.py +146 -0
- scripts/folio-simulator.py +903 -0
- scripts/install-reqs.sh +74 -0
- scripts/run_mlflow.py +44 -0
- scripts/setup-venv.sh +65 -0
- scripts/validate_pnl.py +414 -0
- src/__init__.py +8 -0
- src/fmp.py +243 -0
- src/folio/README.md +119 -0
- src/folio/__init__.py +3 -0
- src/folio/__main__.py +4 -0
- src/folio/ai_utils.py +111 -0
- src/folio/app.py +1073 -0
- src/folio/assets/components/ai.css +659 -0
- src/folio/assets/components/buttons.css +126 -0
- src/folio/assets/components/cards.css +158 -0
- src/folio/assets/components/charts.css +91 -0
- src/folio/assets/components/dash.css +75 -0
- src/folio/assets/components/forms.css +110 -0
- src/folio/assets/components/modals.css +109 -0
- src/folio/assets/components/tables.css +152 -0
- src/folio/assets/js/prevent_chart_scroll.js +44 -0
- src/folio/assets/layout.css +369 -0
- src/folio/assets/main.css +163 -0
- src/folio/assets/sample-portfolio.csv +31 -0
- src/folio/assets/theme.css +72 -0
- src/folio/callbacks/__init__.py +4 -0
- src/folio/cash_detection.py +117 -0
- 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
|