Repository: virattt/ai-hedge-fund Branch: main Commit: 359cce737377 Files: 258 Total size: 1.5 MB Directory structure: gitextract_0t_9v5yp/ ├── .dockerignore ├── .github/ │ └── ISSUE_TEMPLATE/ │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── README.md ├── app/ │ ├── README.md │ ├── backend/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── alembic/ │ │ │ ├── README │ │ │ ├── env.py │ │ │ ├── script.py.mako │ │ │ └── versions/ │ │ │ ├── 1b1feba3d897_add_data_column_to_hedge_fund_flows.py │ │ │ ├── 2f8c5d9e4b1a_add_hedgefundflowrun_table.py │ │ │ ├── 3f9a6b7c8d2e_add_hedgefundflowruncycle_table.py │ │ │ ├── 5274886e5bee_add_hedgefundflow_table.py │ │ │ └── add_api_keys_table.py │ │ ├── alembic.ini │ │ ├── database/ │ │ │ ├── __init__.py │ │ │ ├── connection.py │ │ │ └── models.py │ │ ├── main.py │ │ ├── models/ │ │ │ ├── __init__.py │ │ │ ├── events.py │ │ │ └── schemas.py │ │ ├── repositories/ │ │ │ ├── __init__.py │ │ │ ├── api_key_repository.py │ │ │ ├── flow_repository.py │ │ │ └── flow_run_repository.py │ │ ├── routes/ │ │ │ ├── __init__.py │ │ │ ├── api_keys.py │ │ │ ├── flow_runs.py │ │ │ ├── flows.py │ │ │ ├── health.py │ │ │ ├── hedge_fund.py │ │ │ ├── language_models.py │ │ │ ├── ollama.py │ │ │ └── storage.py │ │ └── services/ │ │ ├── __init__.py │ │ ├── agent_service.py │ │ ├── api_key_service.py │ │ ├── backtest_service.py │ │ ├── graph.py │ │ ├── ollama_service.py │ │ └── portfolio.py │ ├── frontend/ │ │ ├── .eslintrc.cjs │ │ ├── .github/ │ │ │ └── dependabot.yml │ │ ├── .gitignore │ │ ├── LICENSE │ │ ├── README.md │ │ ├── components.json │ │ ├── index.html │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ ├── components/ │ │ │ │ ├── Flow.tsx │ │ │ │ ├── Layout.tsx │ │ │ │ ├── custom-controls.tsx │ │ │ │ ├── layout/ │ │ │ │ │ └── top-bar.tsx │ │ │ │ ├── panels/ │ │ │ │ │ ├── bottom/ │ │ │ │ │ │ ├── bottom-panel.tsx │ │ │ │ │ │ └── tabs/ │ │ │ │ │ │ ├── backtest-output.tsx │ │ │ │ │ │ ├── debug-console-tab.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── output-tab-utils.ts │ │ │ │ │ │ ├── output-tab.tsx │ │ │ │ │ │ ├── problems-tab.tsx │ │ │ │ │ │ ├── reasoning-content.tsx │ │ │ │ │ │ ├── regular-output.tsx │ │ │ │ │ │ └── terminal-tab.tsx │ │ │ │ │ ├── left/ │ │ │ │ │ │ ├── flow-actions.tsx │ │ │ │ │ │ ├── flow-context-menu.tsx │ │ │ │ │ │ ├── flow-create-dialog.tsx │ │ │ │ │ │ ├── flow-edit-dialog.tsx │ │ │ │ │ │ ├── flow-item-group.tsx │ │ │ │ │ │ ├── flow-item.tsx │ │ │ │ │ │ ├── flow-list.tsx │ │ │ │ │ │ └── left-sidebar.tsx │ │ │ │ │ ├── right/ │ │ │ │ │ │ ├── component-actions.tsx │ │ │ │ │ │ ├── component-item-group.tsx │ │ │ │ │ │ ├── component-item.tsx │ │ │ │ │ │ ├── component-list.tsx │ │ │ │ │ │ └── right-sidebar.tsx │ │ │ │ │ └── search-box.tsx │ │ │ │ ├── settings/ │ │ │ │ │ ├── api-keys.tsx │ │ │ │ │ ├── appearance.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── cloud.tsx │ │ │ │ │ │ └── ollama.tsx │ │ │ │ │ ├── models.tsx │ │ │ │ │ └── settings.tsx │ │ │ │ ├── tabs/ │ │ │ │ │ ├── flow-tab-content.tsx │ │ │ │ │ ├── tab-bar.tsx │ │ │ │ │ └── tab-content.tsx │ │ │ │ └── ui/ │ │ │ │ ├── accordion.tsx │ │ │ │ ├── badge.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── command.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── llm-selector.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── resizable.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── sheet.tsx │ │ │ │ ├── sidebar.tsx │ │ │ │ ├── skeleton.tsx │ │ │ │ ├── sonner.tsx │ │ │ │ ├── table.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ └── tooltip.tsx │ │ │ ├── contexts/ │ │ │ │ ├── flow-context.tsx │ │ │ │ ├── layout-context.tsx │ │ │ │ ├── node-context.tsx │ │ │ │ └── tabs-context.tsx │ │ │ ├── data/ │ │ │ │ ├── agents.ts │ │ │ │ ├── models.ts │ │ │ │ ├── multi-node-mappings.ts │ │ │ │ ├── node-mappings.ts │ │ │ │ └── sidebar-components.ts │ │ │ ├── edges/ │ │ │ │ └── index.ts │ │ │ ├── hooks/ │ │ │ │ ├── use-component-groups.ts │ │ │ │ ├── use-enhanced-flow-actions.ts │ │ │ │ ├── use-flow-connection.ts │ │ │ │ ├── use-flow-history.ts │ │ │ │ ├── use-flow-management-tabs.ts │ │ │ │ ├── use-flow-management.ts │ │ │ │ ├── use-keyboard-shortcuts.ts │ │ │ │ ├── use-mobile.tsx │ │ │ │ ├── use-node-state.ts │ │ │ │ ├── use-output-node-connection.ts │ │ │ │ ├── use-resizable.ts │ │ │ │ └── use-toast-manager.ts │ │ │ ├── index.css │ │ │ ├── lib/ │ │ │ │ └── utils.ts │ │ │ ├── main.tsx │ │ │ ├── nodes/ │ │ │ │ ├── components/ │ │ │ │ │ ├── agent-node.tsx │ │ │ │ │ ├── agent-output-dialog.tsx │ │ │ │ │ ├── investment-report-dialog.tsx │ │ │ │ │ ├── investment-report-node.tsx │ │ │ │ │ ├── json-output-dialog.tsx │ │ │ │ │ ├── json-output-node.tsx │ │ │ │ │ ├── node-shell.tsx │ │ │ │ │ ├── output-node-status.tsx │ │ │ │ │ ├── portfolio-manager-node.tsx │ │ │ │ │ ├── portfolio-start-node.tsx │ │ │ │ │ └── stock-analyzer-node.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ ├── providers/ │ │ │ │ └── theme-provider.tsx │ │ │ ├── services/ │ │ │ │ ├── api-keys-api.ts │ │ │ │ ├── api.ts │ │ │ │ ├── backtest-api.ts │ │ │ │ ├── flow-service.ts │ │ │ │ ├── sidebar-storage.ts │ │ │ │ ├── tab-service.ts │ │ │ │ └── types.ts │ │ │ ├── types/ │ │ │ │ └── flow.ts │ │ │ ├── utils/ │ │ │ │ ├── date-utils.ts │ │ │ │ └── text-utils.ts │ │ │ └── vite-env.d.ts │ │ ├── tailwind.config.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── run.bat │ └── run.sh ├── docker/ │ ├── .dockerignore │ ├── Dockerfile │ ├── README.md │ ├── docker-compose.yml │ ├── run.bat │ └── run.sh ├── pyproject.toml ├── src/ │ ├── __init__.py │ ├── agents/ │ │ ├── __init__.py │ │ ├── aswath_damodaran.py │ │ ├── ben_graham.py │ │ ├── bill_ackman.py │ │ ├── cathie_wood.py │ │ ├── charlie_munger.py │ │ ├── fundamentals.py │ │ ├── growth_agent.py │ │ ├── michael_burry.py │ │ ├── mohnish_pabrai.py │ │ ├── news_sentiment.py │ │ ├── peter_lynch.py │ │ ├── phil_fisher.py │ │ ├── portfolio_manager.py │ │ ├── rakesh_jhunjhunwala.py │ │ ├── risk_manager.py │ │ ├── sentiment.py │ │ ├── stanley_druckenmiller.py │ │ ├── technicals.py │ │ ├── valuation.py │ │ └── warren_buffett.py │ ├── backtester.py │ ├── backtesting/ │ │ ├── __init__.py │ │ ├── benchmarks.py │ │ ├── cli.py │ │ ├── controller.py │ │ ├── engine.py │ │ ├── metrics.py │ │ ├── output.py │ │ ├── portfolio.py │ │ ├── trader.py │ │ ├── types.py │ │ └── valuation.py │ ├── cli/ │ │ ├── __init__.py │ │ └── input.py │ ├── data/ │ │ ├── __init__.py │ │ ├── cache.py │ │ └── models.py │ ├── graph/ │ │ ├── __init__.py │ │ └── state.py │ ├── llm/ │ │ ├── __init__.py │ │ ├── api_models.json │ │ ├── models.py │ │ └── ollama_models.json │ ├── main.py │ ├── tools/ │ │ ├── __init__.py │ │ └── api.py │ └── utils/ │ ├── __init__.py │ ├── analysts.py │ ├── api_key.py │ ├── display.py │ ├── docker.py │ ├── llm.py │ ├── ollama.py │ ├── progress.py │ └── visualize.py └── tests/ ├── __init__.py ├── backtesting/ │ ├── conftest.py │ ├── integration/ │ │ ├── conftest.py │ │ ├── mocks.py │ │ ├── test_integration_long_only.py │ │ ├── test_integration_long_short.py │ │ └── test_integration_short_only.py │ ├── test_controller.py │ ├── test_execution.py │ ├── test_metrics.py │ ├── test_portfolio.py │ ├── test_results.py │ └── test_valuation.py ├── fixtures/ │ └── api/ │ ├── financial_metrics/ │ │ ├── AAPL_2024-03-01_2024-03-08.json │ │ ├── MSFT_2024-03-01_2024-03-08.json │ │ └── TSLA_2024-03-01_2024-03-08.json │ ├── insider_trades/ │ │ ├── AAPL_2024-03-01_2024-03-08.json │ │ ├── MSFT_2024-03-01_2024-03-08.json │ │ └── TSLA_2024-03-01_2024-03-08.json │ ├── news/ │ │ ├── AAPL_2024-03-01_2024-03-08.json │ │ ├── MSFT_2024-03-01_2024-03-08.json │ │ └── TSLA_2024-03-01_2024-03-08.json │ └── prices/ │ ├── AAPL_2024-03-01_2024-03-08.json │ ├── MSFT_2024-03-01_2024-03-08.json │ └── TSLA_2024-03-01_2024-03-08.json └── test_api_rate_limiting.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Git .git .gitignore # Poetry .venv __pycache__/ *.py[cod] *$py.class .pytest_cache/ # Environment .env # IDEs and editors .idea/ .vscode/ *.swp *.swo # Logs and data logs/ data/ *.log # OS specific .DS_Store Thumbs.db ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **Screenshot** Add a screenshot of the bug to help explain your problem. **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' --- **Describe the feature you'd like** A clear and concise description of what you want to happen. ================================================ FILE: .gitignore ================================================ # Python __pycache__/ *.py[cod] *$py.class *.so .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # Virtual Environment venv/ ENV/ # Environment Variables .env # IDE .idea/ .vscode/ *.swp *.swo .cursorrules .cursorignore .cursorindexingignore # OS .DS_Store Thumbs.db # graph *.png # Txt files *.txt # PDF files *.pdf # Frontend node_modules # Outputs outputs/ # Database files (users will have their own local databases) *.db *.db-journal *.db-wal *.db-shm *.sqlite *.sqlite3 # Alembic (keep migration files, but ignore generated/cache files) app/backend/alembic/versions/__pycache__/ ================================================ FILE: README.md ================================================ # AI Hedge Fund This is a proof of concept for an AI-powered hedge fund. The goal of this project is to explore the use of AI to make trading decisions. This project is for **educational** purposes only and is not intended for real trading or investment. This system employs several agents working together: 1. Aswath Damodaran Agent - The Dean of Valuation, focuses on story, numbers, and disciplined valuation 2. Ben Graham Agent - The godfather of value investing, only buys hidden gems with a margin of safety 3. Bill Ackman Agent - An activist investor, takes bold positions and pushes for change 4. Cathie Wood Agent - The queen of growth investing, believes in the power of innovation and disruption 5. Charlie Munger Agent - Warren Buffett's partner, only buys wonderful businesses at fair prices 6. Michael Burry Agent - The Big Short contrarian who hunts for deep value 7. Mohnish Pabrai Agent - The Dhandho investor, who looks for doubles at low risk 8. Peter Lynch Agent - Practical investor who seeks "ten-baggers" in everyday businesses 9. Phil Fisher Agent - Meticulous growth investor who uses deep "scuttlebutt" research 10. Rakesh Jhunjhunwala Agent - The Big Bull of India 11. Stanley Druckenmiller Agent - Macro legend who hunts for asymmetric opportunities with growth potential 12. Warren Buffett Agent - The oracle of Omaha, seeks wonderful companies at a fair price 13. Valuation Agent - Calculates the intrinsic value of a stock and generates trading signals 14. Sentiment Agent - Analyzes market sentiment and generates trading signals 15. Fundamentals Agent - Analyzes fundamental data and generates trading signals 16. Technicals Agent - Analyzes technical indicators and generates trading signals 17. Risk Manager - Calculates risk metrics and sets position limits 18. Portfolio Manager - Makes final trading decisions and generates orders Screenshot 2025-03-22 at 6 19 07 PM Note: the system does not actually make any trades. [![Twitter Follow](https://img.shields.io/twitter/follow/virattt?style=social)](https://twitter.com/virattt) ## Disclaimer This project is for **educational and research purposes only**. - Not intended for real trading or investment - No investment advice or guarantees provided - Creator assumes no liability for financial losses - Consult a financial advisor for investment decisions - Past performance does not indicate future results By using this software, you agree to use it solely for learning purposes. ## Table of Contents - [How to Install](#how-to-install) - [How to Run](#how-to-run) - [⌨️ Command Line Interface](#️-command-line-interface) - [🖥️ Web Application](#️-web-application) - [How to Contribute](#how-to-contribute) - [Feature Requests](#feature-requests) - [License](#license) ## How to Install Before you can run the AI Hedge Fund, you'll need to install it and set up your API keys. These steps are common to both the full-stack web application and command line interface. ### 1. Clone the Repository ```bash git clone https://github.com/virattt/ai-hedge-fund.git cd ai-hedge-fund ``` ### 2. Set up API keys Create a `.env` file for your API keys: ```bash # Create .env file for your API keys (in the root directory) cp .env.example .env ``` Open and edit the `.env` file to add your API keys: ```bash # For running LLMs hosted by openai (gpt-4o, gpt-4o-mini, etc.) OPENAI_API_KEY=your-openai-api-key # For getting financial data to power the hedge fund FINANCIAL_DATASETS_API_KEY=your-financial-datasets-api-key ``` **Important**: You must set at least one LLM API key (e.g. `OPENAI_API_KEY`, `GROQ_API_KEY`, `ANTHROPIC_API_KEY`, or `DEEPSEEK_API_KEY`) for the hedge fund to work. **Financial Data**: Data for AAPL, GOOGL, MSFT, NVDA, and TSLA is free and does not require an API key. For any other ticker, you will need to set the `FINANCIAL_DATASETS_API_KEY` in the .env file. ## How to Run ### ⌨️ Command Line Interface You can run the AI Hedge Fund directly via terminal. This approach offers more granular control and is useful for automation, scripting, and integration purposes. Screenshot 2025-01-06 at 5 50 17 PM #### Quick Start 1. Install Poetry (if not already installed): ```bash curl -sSL https://install.python-poetry.org | python3 - ``` 2. Install dependencies: ```bash poetry install ``` #### Run the AI Hedge Fund ```bash poetry run python src/main.py --ticker AAPL,MSFT,NVDA ``` You can also specify a `--ollama` flag to run the AI hedge fund using local LLMs. ```bash poetry run python src/main.py --ticker AAPL,MSFT,NVDA --ollama ``` You can optionally specify the start and end dates to make decisions over a specific time period. ```bash poetry run python src/main.py --ticker AAPL,MSFT,NVDA --start-date 2024-01-01 --end-date 2024-03-01 ``` #### Run the Backtester ```bash poetry run python src/backtester.py --ticker AAPL,MSFT,NVDA ``` **Example Output:** Screenshot 2025-01-06 at 5 47 52 PM Note: The `--ollama`, `--start-date`, and `--end-date` flags work for the backtester, as well! ### 🖥️ Web Application The new way to run the AI Hedge Fund is through our web application that provides a user-friendly interface. This is recommended for users who prefer visual interfaces over command line tools. Please see detailed instructions on how to install and run the web application [here](https://github.com/virattt/ai-hedge-fund/tree/main/app). Screenshot 2025-06-28 at 6 41 03 PM ## How to Contribute 1. Fork the repository 2. Create a feature branch 3. Commit your changes 4. Push to the branch 5. Create a Pull Request **Important**: Please keep your pull requests small and focused. This will make it easier to review and merge. ## Feature Requests If you have a feature request, please open an [issue](https://github.com/virattt/ai-hedge-fund/issues) and make sure it is tagged with `enhancement`. ## License This project is licensed under the MIT License - see the LICENSE file for details. ================================================ FILE: app/README.md ================================================ # Web Application The AI Hedge Fund app is a complete system with both frontend and backend components that enables you to run an AI-powered hedge fund trading system through a web interface on your own computer. Screenshot 2025-06-28 at 6 41 03 PM ## Overview The AI Hedge Fund consists of: - **Backend**: A FastAPI application that provides a REST API to run the hedge fund trading system and backtester - **Frontend**: A React/Vite application that offers a user-friendly interface to visualize and control the hedge fund operations ## Table of Contents - [🚀 Quick Start (For Non-Technical Users)](#-quick-start-for-non-technical-users) - [Option 1: Using 1-Line Shell Script (Recommended)](#option-1-using-1-line-shell-script-recommended) - [Option 2: Using npm (Alternative)](#option-2-using-npm-alternative) - [🛠️ Manual Setup (For Developers)](#️-manual-setup-for-developers) - [Prerequisites](#prerequisites) - [Installation](#installation) - [Running the Application](#running-the-application) - [Detailed Documentation](#detailed-documentation) - [Disclaimer](#disclaimer) - [Troubleshooting](#troubleshooting]) ## 🚀 Quick Start (For Non-Technical Users) **One-line setup and run command:** ### Option 1: Using 1-Line Shell Script (Recommended) #### For Mac/Linux: ```bash ./run.sh ``` If you get a "permission denied" error, run this first: ```bash chmod +x run.sh && ./run.sh ``` Or alternatively, you can run: ```bash bash run.sh ``` #### For Windows: ```cmd run.bat ``` ### Option 2: Using npm (Alternative) ```bash cd app && npm install && npm run setup ``` **That's it!** These scripts will: 1. Check for required dependencies (Node.js, Python, Poetry) 2. Install all dependencies automatically 3. Start both frontend and backend services 4. **Automatically open your web browser** to the application **Requirements:** - [Node.js](https://nodejs.org/) (includes npm) - [Python 3](https://python.org/) - [Poetry](https://python-poetry.org/) **After running, you can access:** - Frontend (Web Interface): http://localhost:5173 - Backend API: http://localhost:8000 - API Documentation: http://localhost:8000/docs --- ## 🛠️ Manual Setup (For Developers) If you prefer to set up each component manually or need more control: ### Prerequisites - Node.js and npm for the frontend - Python 3.8+ and Poetry for the backend ### Installation 1. Clone the repository: ```bash git clone https://github.com/virattt/ai-hedge-fund.git cd ai-hedge-fund ``` 2. Set up your environment variables: ```bash # Create .env file for your API keys (in the root directory) cp .env.example .env ``` 3. Edit the .env file to add your API keys: ```bash # For running LLMs hosted by openai (gpt-4o, gpt-4o-mini, etc.) OPENAI_API_KEY=your-openai-api-key # For running LLMs hosted by groq (deepseek, llama3, etc.) GROQ_API_KEY=your-groq-api-key # For getting financial data to power the hedge fund FINANCIAL_DATASETS_API_KEY=your-financial-datasets-api-key ``` 4. Install Poetry (if not already installed): ```bash curl -sSL https://install.python-poetry.org | python3 - ``` 5. Install root project dependencies: ```bash # From the root directory poetry install ``` 6. Install backend app dependencies: ```bash # Navigate to the backend directory cd app/backend pip install -r requirements.txt # If there's a requirements.txt file # OR poetry install # If there's a pyproject.toml in the backend directory ``` 7. Install frontend app dependencies: ```bash cd app/frontend npm install # or pnpm install or yarn install ``` ### Running the Application 1. Start the backend server: ```bash # In one terminal, from the backend directory cd app/backend poetry run uvicorn main:app --reload ``` 2. Start the frontend application: ```bash # In another terminal, from the frontend directory cd app/frontend npm run dev ``` You can now access: - Frontend application: http://localhost:5173 - Backend API: http://localhost:8000 - API Documentation: http://localhost:8000/docs ## Detailed Documentation For more detailed information: - [Backend Documentation](./backend/README.md) - [Frontend Documentation](./frontend/README.md) ## Disclaimer This project is for **educational and research purposes only**. - Not intended for real trading or investment - No warranties or guarantees provided - Creator assumes no liability for financial losses - Consult a financial advisor for investment decisions By using this software, you agree to use it solely for learning purposes. ## Troubleshooting ### Common Issues #### "Command not found: uvicorn" Error If you see this error when running the setup script: ```bash [ERROR] Backend failed to start. Check the logs: Command not found: uvicorn ``` **Solution:** 1. **Clean Poetry environment:** ```bash cd app/backend poetry env remove --all poetry install ``` 2. **Or force reinstall:** ```bash cd app/backend poetry install --sync ``` 3. **Verify installation:** ```bash cd app/backend poetry run python -c "import uvicorn; import fastapi" ``` #### Python Version Issues - **Use Python 3.11**: Python 3.13+ may have compatibility issues - **Check your Python version:** `python --version` - **Switch Python versions if needed** (using pyenv, conda, etc.) #### Environment Variable Issues - **Ensure .env file exists** in the project root directory - **Copy from template:** `cp .env.example .env` - **Add your API keys** to the .env file #### Permission Issues (Mac/Linux) If you get "permission denied": ```bash chmod +x run.sh ./run.sh ``` #### Port Already in Use If ports 8000 or 5173 are in use: - **Kill existing processes:** `pkill -f "uvicorn\|vite"` - **Or use different ports** by modifying the scripts ### Getting Help - Check the [GitHub Issues](https://github.com/virattt/ai-hedge-fund/issues) - Follow updates on [Twitter](https://x.com/virattt) ================================================ FILE: app/backend/README.md ================================================ # AI Hedge Fund - Backend [WIP] 🚧 This project is currently a work in progress. To track progress, please get updates [here](https://x.com/virattt). This is the backend server for the AI Hedge Fund project. It provides a simple REST API to interact with the AI Hedge Fund system, allowing you to run the hedge fund through a web interface. ## Overview This backend project is a FastAPI application that serves as the server-side component of the AI Hedge Fund system. It exposes endpoints for running the hedge fund trading system and backtester. This backend is designed to work with a future frontend application that will allow users to interact with the AI Hedge Fund system through their browser. ## Installation ### Using Poetry 1. Clone the repository: ```bash git clone https://github.com/virattt/ai-hedge-fund.git cd ai-hedge-fund ``` 2. Install Poetry (if not already installed): ```bash curl -sSL https://install.python-poetry.org | python3 - ``` 3. Install dependencies: ```bash # From the root directory poetry install ``` 4. Set up your environment variables: ```bash # Create .env file for your API keys (in the root directory) cp .env.example .env ``` 5. Edit the .env file to add your API keys: ```bash # For running LLMs hosted by openai (gpt-4o, gpt-4o-mini, etc.) OPENAI_API_KEY=your-openai-api-key # For running LLMs hosted by groq (deepseek, llama3, etc.) GROQ_API_KEY=your-groq-api-key # For getting financial data to power the hedge fund FINANCIAL_DATASETS_API_KEY=your-financial-datasets-api-key ``` ## Running the Server To run the development server: ```bash # Navigate to the backend directory cd app/backend # Start the FastAPI server with uvicorn poetry run uvicorn main:app --reload ``` This will start the FastAPI server with hot-reloading enabled. The API will be available at: - API Endpoint: http://localhost:8000 - API Documentation: http://localhost:8000/docs ## API Endpoints - `POST /hedge-fund/run`: Run the AI Hedge Fund with specified parameters - `GET /ping`: Simple endpoint to test server connectivity ## Project Structure ``` app/backend/ ├── api/ # API layer (future expansion) ├── models/ # Domain models │ ├── __init__.py │ └── schemas.py # Pydantic schema definitions ├── routes/ # API routes │ ├── __init__.py # Router registry │ ├── hedge_fund.py # Hedge fund endpoints │ └── health.py # Health check endpoints ├── services/ # Business logic │ ├── graph.py # Agent graph functionality │ └── portfolio.py # Portfolio management ├── __init__.py # Package initialization └── main.py # FastAPI application entry point ``` ## Disclaimer This project is for **educational and research purposes only**. - Not intended for real trading or investment - No warranties or guarantees provided - Creator assumes no liability for financial losses - Consult a financial advisor for investment decisions By using this software, you agree to use it solely for learning purposes. ================================================ FILE: app/backend/__init__.py ================================================ import sys from pathlib import Path # Add the src directory to Python path for imports # This is a temporary solution while we develop the backend src_path = str(Path(__file__).parent.parent.parent / "src") if src_path not in sys.path: sys.path.append(src_path) ================================================ FILE: app/backend/alembic/README ================================================ Generic single-database configuration. ================================================ FILE: app/backend/alembic/env.py ================================================ from logging.config import fileConfig from sqlalchemy import engine_from_config from sqlalchemy import pool from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. if config.config_file_name is not None: fileConfig(config.config_file_name) # add your model's MetaData object here # for 'autogenerate' support from app.backend.database.models import Base target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline() -> None: """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, ) with context.begin_transaction(): context.run_migrations() def run_migrations_online() -> None: """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ connectable = engine_from_config( config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", poolclass=pool.NullPool, ) with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: app/backend/alembic/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision: str = ${repr(up_revision)} down_revision: Union[str, None] = ${repr(down_revision)} branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} def upgrade() -> None: """Upgrade schema.""" ${upgrades if upgrades else "pass"} def downgrade() -> None: """Downgrade schema.""" ${downgrades if downgrades else "pass"} ================================================ FILE: app/backend/alembic/versions/1b1feba3d897_add_data_column_to_hedge_fund_flows.py ================================================ """add_data_column_to_hedge_fund_flows Revision ID: 1b1feba3d897 Revises: 5274886e5bee Create Date: 2025-06-22 17:30:50.992184 """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = '1b1feba3d897' down_revision: Union[str, None] = '5274886e5bee' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### op.add_column('hedge_fund_flows', sa.Column('data', sa.JSON(), nullable=True)) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### op.drop_column('hedge_fund_flows', 'data') # ### end Alembic commands ### ================================================ FILE: app/backend/alembic/versions/2f8c5d9e4b1a_add_hedgefundflowrun_table.py ================================================ """Add HedgeFundFlowRun table Revision ID: 2f8c5d9e4b1a Revises: 1b1feba3d897 Create Date: 2025-01-01 12:00:00.000000 """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = '2f8c5d9e4b1a' down_revision: Union[str, None] = '1b1feba3d897' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### op.create_table('hedge_fund_flow_runs', sa.Column('id', sa.Integer(), nullable=False), sa.Column('flow_id', sa.Integer(), nullable=False), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), sa.Column('status', sa.String(length=50), nullable=False, server_default='IDLE'), sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), sa.Column('request_data', sa.JSON(), nullable=True), sa.Column('results', sa.JSON(), nullable=True), sa.Column('error_message', sa.Text(), nullable=True), sa.Column('run_number', sa.Integer(), nullable=False, server_default='1'), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_hedge_fund_flow_runs_id'), 'hedge_fund_flow_runs', ['id'], unique=False) op.create_index(op.f('ix_hedge_fund_flow_runs_flow_id'), 'hedge_fund_flow_runs', ['flow_id'], unique=False) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### op.drop_index(op.f('ix_hedge_fund_flow_runs_flow_id'), table_name='hedge_fund_flow_runs') op.drop_index(op.f('ix_hedge_fund_flow_runs_id'), table_name='hedge_fund_flow_runs') op.drop_table('hedge_fund_flow_runs') # ### end Alembic commands ### ================================================ FILE: app/backend/alembic/versions/3f9a6b7c8d2e_add_hedgefundflowruncycle_table.py ================================================ """Add HedgeFundFlowRunCycle table and update HedgeFundFlowRun Revision ID: 3f9a6b7c8d2e Revises: 2f8c5d9e4b1a Create Date: 2024-11-27 10:00:00.000000 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '3f9a6b7c8d2e' down_revision = '2f8c5d9e4b1a' branch_labels = None depends_on = None def upgrade(): # Get the database connection to check existing columns conn = op.get_bind() # Check if columns already exist before adding them inspector = sa.inspect(conn) existing_columns = [col['name'] for col in inspector.get_columns('hedge_fund_flow_runs')] # Add new columns to hedge_fund_flow_runs table only if they don't exist if 'trading_mode' not in existing_columns: op.add_column('hedge_fund_flow_runs', sa.Column('trading_mode', sa.String(50), nullable=False, server_default='one-time')) if 'schedule' not in existing_columns: op.add_column('hedge_fund_flow_runs', sa.Column('schedule', sa.String(50), nullable=True)) if 'duration' not in existing_columns: op.add_column('hedge_fund_flow_runs', sa.Column('duration', sa.String(50), nullable=True)) if 'initial_portfolio' not in existing_columns: op.add_column('hedge_fund_flow_runs', sa.Column('initial_portfolio', sa.JSON, nullable=True)) if 'final_portfolio' not in existing_columns: op.add_column('hedge_fund_flow_runs', sa.Column('final_portfolio', sa.JSON, nullable=True)) # Create hedge_fund_flow_run_cycles table only if it doesn't exist existing_tables = inspector.get_table_names() if 'hedge_fund_flow_run_cycles' not in existing_tables: op.create_table( 'hedge_fund_flow_run_cycles', sa.Column('id', sa.Integer, primary_key=True, index=True), sa.Column('flow_run_id', sa.Integer, nullable=False, index=True), sa.Column('cycle_number', sa.Integer, nullable=False), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), sa.Column('started_at', sa.DateTime(timezone=True), nullable=False), sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), sa.Column('analyst_signals', sa.JSON, nullable=True), sa.Column('trading_decisions', sa.JSON, nullable=True), sa.Column('executed_trades', sa.JSON, nullable=True), sa.Column('portfolio_snapshot', sa.JSON, nullable=True), sa.Column('performance_metrics', sa.JSON, nullable=True), sa.Column('status', sa.String(50), nullable=False, server_default='IN_PROGRESS'), sa.Column('error_message', sa.Text, nullable=True), sa.Column('llm_calls_count', sa.Integer, nullable=True, server_default='0'), sa.Column('api_calls_count', sa.Integer, nullable=True, server_default='0'), sa.Column('estimated_cost', sa.String(20), nullable=True), sa.Column('trigger_reason', sa.String(100), nullable=True), sa.Column('market_conditions', sa.JSON, nullable=True), ) # Create indexes for the new table op.create_index('ix_hedge_fund_flow_run_cycles_flow_run_id', 'hedge_fund_flow_run_cycles', ['flow_run_id']) op.create_index('ix_hedge_fund_flow_run_cycles_cycle_number', 'hedge_fund_flow_run_cycles', ['cycle_number']) op.create_index('ix_hedge_fund_flow_run_cycles_status', 'hedge_fund_flow_run_cycles', ['status']) op.create_index('ix_hedge_fund_flow_run_cycles_started_at', 'hedge_fund_flow_run_cycles', ['started_at']) def downgrade(): # Check if table exists before dropping conn = op.get_bind() inspector = sa.inspect(conn) existing_tables = inspector.get_table_names() if 'hedge_fund_flow_run_cycles' in existing_tables: # Drop indexes if they exist try: op.drop_index('ix_hedge_fund_flow_run_cycles_started_at', 'hedge_fund_flow_run_cycles') op.drop_index('ix_hedge_fund_flow_run_cycles_status', 'hedge_fund_flow_run_cycles') op.drop_index('ix_hedge_fund_flow_run_cycles_cycle_number', 'hedge_fund_flow_run_cycles') op.drop_index('ix_hedge_fund_flow_run_cycles_flow_run_id', 'hedge_fund_flow_run_cycles') except: pass # Index may not exist # Drop hedge_fund_flow_run_cycles table op.drop_table('hedge_fund_flow_run_cycles') # Check existing columns before dropping existing_columns = [col['name'] for col in inspector.get_columns('hedge_fund_flow_runs')] # Remove columns from hedge_fund_flow_runs table only if they exist if 'final_portfolio' in existing_columns: op.drop_column('hedge_fund_flow_runs', 'final_portfolio') if 'initial_portfolio' in existing_columns: op.drop_column('hedge_fund_flow_runs', 'initial_portfolio') if 'duration' in existing_columns: op.drop_column('hedge_fund_flow_runs', 'duration') if 'schedule' in existing_columns: op.drop_column('hedge_fund_flow_runs', 'schedule') if 'trading_mode' in existing_columns: op.drop_column('hedge_fund_flow_runs', 'trading_mode') ================================================ FILE: app/backend/alembic/versions/5274886e5bee_add_hedgefundflow_table.py ================================================ """Add HedgeFundFlow table Revision ID: 5274886e5bee Revises: Create Date: 2025-06-20 14:50:24.736989 """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = '5274886e5bee' down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### op.create_table('hedge_fund_flows', sa.Column('id', sa.Integer(), nullable=False), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), sa.Column('name', sa.String(length=200), nullable=False), sa.Column('description', sa.Text(), nullable=True), sa.Column('nodes', sa.JSON(), nullable=False), sa.Column('edges', sa.JSON(), nullable=False), sa.Column('viewport', sa.JSON(), nullable=True), sa.Column('is_template', sa.Boolean(), nullable=True), sa.Column('tags', sa.JSON(), nullable=True), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_hedge_fund_flows_id'), 'hedge_fund_flows', ['id'], unique=False) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### op.drop_index(op.f('ix_hedge_fund_flows_id'), table_name='hedge_fund_flows') op.drop_table('hedge_fund_flows') # ### end Alembic commands ### ================================================ FILE: app/backend/alembic/versions/add_api_keys_table.py ================================================ """add_api_keys_table Revision ID: d5e78f9a1b2c Revises: 3f9a6b7c8d2e Create Date: 2025-07-27 15:20:00.000000 """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = 'd5e78f9a1b2c' down_revision: Union[str, None] = '3f9a6b7c8d2e' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # Create API keys table op.create_table('api_keys', sa.Column('id', sa.Integer(), nullable=False), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), sa.Column('provider', sa.String(length=100), nullable=False), sa.Column('key_value', sa.Text(), nullable=False), sa.Column('is_active', sa.Boolean(), nullable=True), sa.Column('description', sa.Text(), nullable=True), sa.Column('last_used', sa.DateTime(timezone=True), nullable=True), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('provider') ) op.create_index(op.f('ix_api_keys_id'), 'api_keys', ['id'], unique=False) op.create_index(op.f('ix_api_keys_provider'), 'api_keys', ['provider'], unique=False) def downgrade() -> None: """Downgrade schema.""" op.drop_index(op.f('ix_api_keys_provider'), table_name='api_keys') op.drop_index(op.f('ix_api_keys_id'), table_name='api_keys') op.drop_table('api_keys') ================================================ FILE: app/backend/alembic.ini ================================================ # A generic, single database configuration. [alembic] # path to migration scripts # Use forward slashes (/) also on windows to provide an os agnostic path script_location = alembic # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s # Uncomment the line below if you want the files to be prepended with date and time # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file # for all available tokens # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s # sys.path path, will be prepended to sys.path if present. # defaults to the current working directory. prepend_sys_path = . # timezone to use when rendering the date within the migration file # as well as the filename. # If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. # Any required deps can installed by adding `alembic[tz]` to the pip requirements # string value is passed to ZoneInfo() # leave blank for localtime # timezone = # max length of characters to apply to the "slug" field # truncate_slug_length = 40 # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # set to 'true' to allow .pyc and .pyo files without # a source .py file to be detected as revisions in the # versions/ directory # sourceless = false # version location specification; This defaults # to alembic/versions. When using multiple version # directories, initial revisions must be specified with --version-path. # The path separator used here should be the separator specified by "version_path_separator" below. # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions # version path separator; As mentioned above, this is the character used to split # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. # Valid values for version_path_separator are: # # version_path_separator = : # version_path_separator = ; # version_path_separator = space # version_path_separator = newline # # Use os.pathsep. Default configuration used for new projects. version_path_separator = os # set to 'true' to search source files recursively # in each "version_locations" directory # new in Alembic version 1.10 # recursive_version_locations = false # the output encoding used when revision files # are written from script.py.mako # output_encoding = utf-8 sqlalchemy.url = sqlite:///./hedge_fund.db [post_write_hooks] # post_write_hooks defines scripts or Python functions that are run # on newly generated revision scripts. See the documentation for further # detail and examples # format using "black" - use the console_scripts runner, against the "black" entrypoint # hooks = black # black.type = console_scripts # black.entrypoint = black # black.options = -l 79 REVISION_SCRIPT_FILENAME # lint with attempts to fix using "ruff" - use the exec runner, execute a binary # hooks = ruff # ruff.type = exec # ruff.executable = %(here)s/.venv/bin/ruff # ruff.options = check --fix REVISION_SCRIPT_FILENAME # Logging configuration [loggers] keys = root,sqlalchemy,alembic [handlers] keys = console [formatters] keys = generic [logger_root] level = WARNING handlers = console qualname = [logger_sqlalchemy] level = WARNING handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: app/backend/database/__init__.py ================================================ from .connection import get_db, engine, SessionLocal from .models import Base __all__ = ["get_db", "engine", "SessionLocal", "Base"] ================================================ FILE: app/backend/database/connection.py ================================================ from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker import os from pathlib import Path # Get the backend directory path BACKEND_DIR = Path(__file__).parent.parent DATABASE_PATH = BACKEND_DIR / "hedge_fund.db" # Database configuration - use absolute path DATABASE_URL = f"sqlite:///{DATABASE_PATH}" # Create SQLAlchemy engine engine = create_engine( DATABASE_URL, connect_args={"check_same_thread": False} # Needed for SQLite ) # Create SessionLocal class SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) # Create Base class for models Base = declarative_base() # Dependency for FastAPI def get_db(): db = SessionLocal() try: yield db finally: db.close() ================================================ FILE: app/backend/database/models.py ================================================ from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, JSON, ForeignKey from sqlalchemy.sql import func from .connection import Base class HedgeFundFlow(Base): """Table to store React Flow configurations (nodes, edges, viewport)""" __tablename__ = "hedge_fund_flows" id = Column(Integer, primary_key=True, index=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) # Flow metadata name = Column(String(200), nullable=False) description = Column(Text, nullable=True) # React Flow state nodes = Column(JSON, nullable=False) # Store React Flow nodes as JSON edges = Column(JSON, nullable=False) # Store React Flow edges as JSON viewport = Column(JSON, nullable=True) # Store viewport state (zoom, x, y) data = Column(JSON, nullable=True) # Store node internal states (tickers, models, etc.) # Additional metadata is_template = Column(Boolean, default=False) # Mark as template for reuse tags = Column(JSON, nullable=True) # Store tags for categorization class HedgeFundFlowRun(Base): """Table to track individual execution runs of a hedge fund flow""" __tablename__ = "hedge_fund_flow_runs" id = Column(Integer, primary_key=True, index=True) flow_id = Column(Integer, ForeignKey("hedge_fund_flows.id"), nullable=False, index=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) # Run execution tracking status = Column(String(50), nullable=False, default="IDLE") # IDLE, IN_PROGRESS, COMPLETE, ERROR started_at = Column(DateTime(timezone=True), nullable=True) completed_at = Column(DateTime(timezone=True), nullable=True) # Run configuration trading_mode = Column(String(50), nullable=False, default="one-time") # one-time, continuous, advisory schedule = Column(String(50), nullable=True) # hourly, daily, weekly (for continuous mode) duration = Column(String(50), nullable=True) # 1day, 1week, 1month (for continuous mode) # Run data request_data = Column(JSON, nullable=True) # Store the request parameters (tickers, agents, models, etc.) initial_portfolio = Column(JSON, nullable=True) # Store initial portfolio state final_portfolio = Column(JSON, nullable=True) # Store final portfolio state results = Column(JSON, nullable=True) # Store the output/results from the run error_message = Column(Text, nullable=True) # Store error details if run failed # Metadata run_number = Column(Integer, nullable=False, default=1) # Sequential run number for this flow class HedgeFundFlowRunCycle(Base): """Individual analysis cycles within a trading session""" __tablename__ = "hedge_fund_flow_run_cycles" id = Column(Integer, primary_key=True, index=True) flow_run_id = Column(Integer, ForeignKey("hedge_fund_flow_runs.id"), nullable=False, index=True) cycle_number = Column(Integer, nullable=False) # 1, 2, 3, etc. within the run # Timing created_at = Column(DateTime(timezone=True), server_default=func.now()) started_at = Column(DateTime(timezone=True), nullable=False) completed_at = Column(DateTime(timezone=True), nullable=True) # Analysis results analyst_signals = Column(JSON, nullable=True) # All agent decisions/signals trading_decisions = Column(JSON, nullable=True) # Portfolio manager decisions executed_trades = Column(JSON, nullable=True) # Actual trades executed (paper trading) # Portfolio state after this cycle portfolio_snapshot = Column(JSON, nullable=True) # Cash, positions, performance metrics # Performance metrics for this cycle performance_metrics = Column(JSON, nullable=True) # Returns, sharpe ratio, etc. # Execution tracking status = Column(String(50), nullable=False, default="IN_PROGRESS") # IN_PROGRESS, COMPLETED, ERROR error_message = Column(Text, nullable=True) # Store error details if cycle failed # Cost tracking llm_calls_count = Column(Integer, nullable=True, default=0) # Number of LLM calls made api_calls_count = Column(Integer, nullable=True, default=0) # Number of financial API calls made estimated_cost = Column(String(20), nullable=True) # Estimated cost in USD # Metadata trigger_reason = Column(String(100), nullable=True) # scheduled, manual, market_event, etc. market_conditions = Column(JSON, nullable=True) # Market data snapshot at cycle start class ApiKey(Base): """Table to store API keys for various services""" __tablename__ = "api_keys" id = Column(Integer, primary_key=True, index=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) # API key details provider = Column(String(100), nullable=False, unique=True, index=True) # e.g., "ANTHROPIC_API_KEY" key_value = Column(Text, nullable=False) # The actual API key (encrypted in production) is_active = Column(Boolean, default=True) # Enable/disable without deletion # Optional metadata description = Column(Text, nullable=True) # Human-readable description last_used = Column(DateTime(timezone=True), nullable=True) # Track usage ================================================ FILE: app/backend/main.py ================================================ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware import logging import asyncio from app.backend.routes import api_router from app.backend.database.connection import engine from app.backend.database.models import Base from app.backend.services.ollama_service import ollama_service # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) app = FastAPI(title="AI Hedge Fund API", description="Backend API for AI Hedge Fund", version="0.1.0") # Initialize database tables (this is safe to run multiple times) Base.metadata.create_all(bind=engine) # Configure CORS app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], # Frontend URLs allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Include all routes app.include_router(api_router) @app.on_event("startup") async def startup_event(): """Startup event to check Ollama availability.""" try: logger.info("Checking Ollama availability...") status = await ollama_service.check_ollama_status() if status["installed"]: if status["running"]: logger.info(f"✓ Ollama is installed and running at {status['server_url']}") if status["available_models"]: logger.info(f"✓ Available models: {', '.join(status['available_models'])}") else: logger.info("ℹ No models are currently downloaded") else: logger.info("ℹ Ollama is installed but not running") logger.info("ℹ You can start it from the Settings page or manually with 'ollama serve'") else: logger.info("ℹ Ollama is not installed. Install it to use local models.") logger.info("ℹ Visit https://ollama.com to download and install Ollama") except Exception as e: logger.warning(f"Could not check Ollama status: {e}") logger.info("ℹ Ollama integration is available if you install it later") ================================================ FILE: app/backend/models/__init__.py ================================================ ================================================ FILE: app/backend/models/events.py ================================================ from typing import Dict, Optional, Any, Literal from pydantic import BaseModel class BaseEvent(BaseModel): """Base class for all Server-Sent Event events""" type: str def to_sse(self) -> str: """Convert to Server-Sent Event format""" event_type = self.type.lower() return f"event: {event_type}\ndata: {self.model_dump_json()}\n\n" class StartEvent(BaseEvent): """Event indicating the start of processing""" type: Literal["start"] = "start" timestamp: Optional[str] = None class ProgressUpdateEvent(BaseEvent): """Event containing an agent's progress update""" type: Literal["progress"] = "progress" agent: str ticker: Optional[str] = None status: str timestamp: Optional[str] = None analysis: Optional[str] = None class ErrorEvent(BaseEvent): """Event indicating an error occurred""" type: Literal["error"] = "error" message: str timestamp: Optional[str] = None class CompleteEvent(BaseEvent): """Event indicating successful completion with results""" type: Literal["complete"] = "complete" data: Dict[str, Any] timestamp: Optional[str] = None ================================================ FILE: app/backend/models/schemas.py ================================================ from datetime import datetime, timedelta from pydantic import BaseModel, Field, field_validator from typing import List, Optional, Dict, Any from src.llm.models import ModelProvider from enum import Enum from app.backend.services.graph import extract_base_agent_key class FlowRunStatus(str, Enum): IDLE = "IDLE" IN_PROGRESS = "IN_PROGRESS" COMPLETE = "COMPLETE" ERROR = "ERROR" class AgentModelConfig(BaseModel): agent_id: str model_name: Optional[str] = None model_provider: Optional[ModelProvider] = None class PortfolioPosition(BaseModel): ticker: str quantity: float trade_price: float @field_validator('trade_price') @classmethod def price_must_be_positive(cls, v: float) -> float: if v <= 0: raise ValueError('Trade price must be positive!') return v class GraphNode(BaseModel): id: str type: Optional[str] = None data: Optional[Dict[str, Any]] = None position: Optional[Dict[str, Any]] = None class GraphEdge(BaseModel): id: str source: str target: str type: Optional[str] = None data: Optional[Dict[str, Any]] = None class HedgeFundResponse(BaseModel): decisions: dict analyst_signals: dict class ErrorResponse(BaseModel): message: str error: str | None = None # Base class for shared fields between HedgeFundRequest and BacktestRequest class BaseHedgeFundRequest(BaseModel): tickers: List[str] graph_nodes: List[GraphNode] graph_edges: List[GraphEdge] agent_models: Optional[List[AgentModelConfig]] = None model_name: Optional[str] = "gpt-4.1" model_provider: Optional[ModelProvider] = ModelProvider.OPENAI margin_requirement: float = 0.0 portfolio_positions: Optional[List[PortfolioPosition]] = None api_keys: Optional[Dict[str, str]] = None def get_agent_ids(self) -> List[str]: """Extract agent IDs from graph structure""" return [node.id for node in self.graph_nodes] def get_agent_model_config(self, agent_id: str) -> tuple[str, ModelProvider]: """Get model configuration for a specific agent""" if self.agent_models: # Extract base agent key from unique node ID for matching base_agent_key = extract_base_agent_key(agent_id) for config in self.agent_models: # Check both unique node ID and base agent key for matches config_base_key = extract_base_agent_key(config.agent_id) if config.agent_id == agent_id or config_base_key == base_agent_key: return ( config.model_name or self.model_name, config.model_provider or self.model_provider ) # Fallback to global model settings return self.model_name, self.model_provider class BacktestRequest(BaseHedgeFundRequest): start_date: str end_date: str initial_capital: float = 100000.0 class BacktestDayResult(BaseModel): date: str portfolio_value: float cash: float decisions: Dict[str, Any] executed_trades: Dict[str, int] analyst_signals: Dict[str, Any] current_prices: Dict[str, float] long_exposure: float short_exposure: float gross_exposure: float net_exposure: float long_short_ratio: Optional[float] = None class BacktestPerformanceMetrics(BaseModel): sharpe_ratio: Optional[float] = None sortino_ratio: Optional[float] = None max_drawdown: Optional[float] = None max_drawdown_date: Optional[str] = None long_short_ratio: Optional[float] = None gross_exposure: Optional[float] = None net_exposure: Optional[float] = None class BacktestResponse(BaseModel): results: List[BacktestDayResult] performance_metrics: BacktestPerformanceMetrics final_portfolio: Dict[str, Any] class HedgeFundRequest(BaseHedgeFundRequest): end_date: Optional[str] = Field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d")) start_date: Optional[str] = None initial_cash: float = 100000.0 def get_start_date(self) -> str: """Calculate start date if not provided""" if self.start_date: return self.start_date return (datetime.strptime(self.end_date, "%Y-%m-%d") - timedelta(days=90)).strftime("%Y-%m-%d") # Flow-related schemas class FlowCreateRequest(BaseModel): name: str = Field(..., min_length=1, max_length=200) description: Optional[str] = None nodes: List[Dict[str, Any]] edges: List[Dict[str, Any]] viewport: Optional[Dict[str, Any]] = None data: Optional[Dict[str, Any]] = None is_template: bool = False tags: Optional[List[str]] = None class FlowUpdateRequest(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=200) description: Optional[str] = None nodes: Optional[List[Dict[str, Any]]] = None edges: Optional[List[Dict[str, Any]]] = None viewport: Optional[Dict[str, Any]] = None data: Optional[Dict[str, Any]] = None is_template: Optional[bool] = None tags: Optional[List[str]] = None class FlowResponse(BaseModel): id: int name: str description: Optional[str] nodes: List[Dict[str, Any]] edges: List[Dict[str, Any]] viewport: Optional[Dict[str, Any]] data: Optional[Dict[str, Any]] is_template: bool tags: Optional[List[str]] created_at: datetime updated_at: Optional[datetime] class Config: from_attributes = True class FlowSummaryResponse(BaseModel): """Lightweight flow response without nodes/edges for listing""" id: int name: str description: Optional[str] is_template: bool tags: Optional[List[str]] created_at: datetime updated_at: Optional[datetime] class Config: from_attributes = True # Flow Run schemas class FlowRunCreateRequest(BaseModel): """Request to create a new flow run""" request_data: Optional[Dict[str, Any]] = None class FlowRunUpdateRequest(BaseModel): """Request to update an existing flow run""" status: Optional[FlowRunStatus] = None results: Optional[Dict[str, Any]] = None error_message: Optional[str] = None class FlowRunResponse(BaseModel): """Complete flow run response""" id: int flow_id: int status: FlowRunStatus run_number: int created_at: datetime updated_at: Optional[datetime] started_at: Optional[datetime] completed_at: Optional[datetime] request_data: Optional[Dict[str, Any]] results: Optional[Dict[str, Any]] error_message: Optional[str] class Config: from_attributes = True class FlowRunSummaryResponse(BaseModel): """Lightweight flow run response for listing""" id: int flow_id: int status: FlowRunStatus run_number: int created_at: datetime started_at: Optional[datetime] completed_at: Optional[datetime] error_message: Optional[str] class Config: from_attributes = True # API Key schemas class ApiKeyCreateRequest(BaseModel): """Request to create or update an API key""" provider: str = Field(..., min_length=1, max_length=100) key_value: str = Field(..., min_length=1) description: Optional[str] = None is_active: bool = True class ApiKeyUpdateRequest(BaseModel): """Request to update an existing API key""" key_value: Optional[str] = Field(None, min_length=1) description: Optional[str] = None is_active: Optional[bool] = None class ApiKeyResponse(BaseModel): """Complete API key response""" id: int provider: str key_value: str is_active: bool description: Optional[str] created_at: datetime updated_at: Optional[datetime] last_used: Optional[datetime] class Config: from_attributes = True class ApiKeySummaryResponse(BaseModel): """API key response without the actual key value""" id: int provider: str is_active: bool description: Optional[str] created_at: datetime updated_at: Optional[datetime] last_used: Optional[datetime] has_key: bool = True # Indicates if a key is set class Config: from_attributes = True class ApiKeyBulkUpdateRequest(BaseModel): """Request to update multiple API keys at once""" api_keys: List[ApiKeyCreateRequest] ================================================ FILE: app/backend/repositories/__init__.py ================================================ from .flow_repository import FlowRepository __all__ = ["FlowRepository"] ================================================ FILE: app/backend/repositories/api_key_repository.py ================================================ from sqlalchemy.orm import Session from sqlalchemy import func from typing import List, Optional from datetime import datetime from app.backend.database.models import ApiKey class ApiKeyRepository: """Repository for API key database operations""" def __init__(self, db: Session): self.db = db def create_or_update_api_key( self, provider: str, key_value: str, description: str = None, is_active: bool = True ) -> ApiKey: """Create a new API key or update existing one""" # Check if API key already exists for this provider existing_key = self.db.query(ApiKey).filter(ApiKey.provider == provider).first() if existing_key: # Update existing key existing_key.key_value = key_value existing_key.description = description existing_key.is_active = is_active existing_key.updated_at = func.now() self.db.commit() self.db.refresh(existing_key) return existing_key else: # Create new key api_key = ApiKey( provider=provider, key_value=key_value, description=description, is_active=is_active ) self.db.add(api_key) self.db.commit() self.db.refresh(api_key) return api_key def get_api_key_by_provider(self, provider: str) -> Optional[ApiKey]: """Get API key by provider name""" return self.db.query(ApiKey).filter( ApiKey.provider == provider, ApiKey.is_active == True ).first() def get_all_api_keys(self, include_inactive: bool = False) -> List[ApiKey]: """Get all API keys""" query = self.db.query(ApiKey) if not include_inactive: query = query.filter(ApiKey.is_active == True) return query.order_by(ApiKey.provider).all() def update_api_key( self, provider: str, key_value: str = None, description: str = None, is_active: bool = None ) -> Optional[ApiKey]: """Update an existing API key""" api_key = self.db.query(ApiKey).filter(ApiKey.provider == provider).first() if not api_key: return None if key_value is not None: api_key.key_value = key_value if description is not None: api_key.description = description if is_active is not None: api_key.is_active = is_active api_key.updated_at = func.now() self.db.commit() self.db.refresh(api_key) return api_key def delete_api_key(self, provider: str) -> bool: """Delete an API key by provider""" api_key = self.db.query(ApiKey).filter(ApiKey.provider == provider).first() if not api_key: return False self.db.delete(api_key) self.db.commit() return True def deactivate_api_key(self, provider: str) -> bool: """Deactivate an API key instead of deleting it""" api_key = self.db.query(ApiKey).filter(ApiKey.provider == provider).first() if not api_key: return False api_key.is_active = False api_key.updated_at = func.now() self.db.commit() return True def update_last_used(self, provider: str) -> bool: """Update the last_used timestamp for an API key""" api_key = self.db.query(ApiKey).filter( ApiKey.provider == provider, ApiKey.is_active == True ).first() if not api_key: return False api_key.last_used = func.now() self.db.commit() return True def bulk_create_or_update(self, api_keys_data: List[dict]) -> List[ApiKey]: """Bulk create or update multiple API keys""" results = [] for data in api_keys_data: api_key = self.create_or_update_api_key( provider=data['provider'], key_value=data['key_value'], description=data.get('description'), is_active=data.get('is_active', True) ) results.append(api_key) return results ================================================ FILE: app/backend/repositories/flow_repository.py ================================================ from typing import List, Optional from sqlalchemy.orm import Session from app.backend.database.models import HedgeFundFlow class FlowRepository: """Repository for HedgeFundFlow CRUD operations""" def __init__(self, db: Session): self.db = db def create_flow(self, name: str, nodes: dict, edges: dict, description: str = None, viewport: dict = None, data: dict = None, is_template: bool = False, tags: List[str] = None) -> HedgeFundFlow: """Create a new hedge fund flow""" flow = HedgeFundFlow( name=name, description=description, nodes=nodes, edges=edges, viewport=viewport, data=data, is_template=is_template, tags=tags or [] ) self.db.add(flow) self.db.commit() self.db.refresh(flow) return flow def get_flow_by_id(self, flow_id: int) -> Optional[HedgeFundFlow]: """Get a flow by its ID""" return self.db.query(HedgeFundFlow).filter(HedgeFundFlow.id == flow_id).first() def get_all_flows(self, include_templates: bool = True) -> List[HedgeFundFlow]: """Get all flows, optionally excluding templates""" query = self.db.query(HedgeFundFlow) if not include_templates: query = query.filter(HedgeFundFlow.is_template == False) return query.order_by(HedgeFundFlow.updated_at.desc()).all() def get_flows_by_name(self, name: str) -> List[HedgeFundFlow]: """Search flows by name (case-insensitive partial match)""" return self.db.query(HedgeFundFlow).filter( HedgeFundFlow.name.ilike(f"%{name}%") ).order_by(HedgeFundFlow.updated_at.desc()).all() def update_flow(self, flow_id: int, name: str = None, description: str = None, nodes: dict = None, edges: dict = None, viewport: dict = None, data: dict = None, is_template: bool = None, tags: List[str] = None) -> Optional[HedgeFundFlow]: """Update an existing flow""" flow = self.get_flow_by_id(flow_id) if not flow: return None if name is not None: flow.name = name if description is not None: flow.description = description if nodes is not None: flow.nodes = nodes if edges is not None: flow.edges = edges if viewport is not None: flow.viewport = viewport if data is not None: flow.data = data if is_template is not None: flow.is_template = is_template if tags is not None: flow.tags = tags self.db.commit() self.db.refresh(flow) return flow def delete_flow(self, flow_id: int) -> bool: """Delete a flow by ID""" flow = self.get_flow_by_id(flow_id) if not flow: return False self.db.delete(flow) self.db.commit() return True def duplicate_flow(self, flow_id: int, new_name: str = None) -> Optional[HedgeFundFlow]: """Create a copy of an existing flow""" original = self.get_flow_by_id(flow_id) if not original: return None copy_name = new_name or f"{original.name} (Copy)" return self.create_flow( name=copy_name, description=original.description, nodes=original.nodes, edges=original.edges, viewport=original.viewport, data=original.data, is_template=False, # Copies are not templates by default tags=original.tags ) ================================================ FILE: app/backend/repositories/flow_run_repository.py ================================================ from typing import List, Optional, Dict, Any from datetime import datetime from sqlalchemy.orm import Session from sqlalchemy import desc, func from app.backend.database.models import HedgeFundFlowRun from app.backend.models.schemas import FlowRunStatus class FlowRunRepository: """Repository for HedgeFundFlowRun CRUD operations""" def __init__(self, db: Session): self.db = db def create_flow_run(self, flow_id: int, request_data: Dict[str, Any] = None) -> HedgeFundFlowRun: """Create a new flow run""" # Get the next run number for this flow run_number = self._get_next_run_number(flow_id) flow_run = HedgeFundFlowRun( flow_id=flow_id, request_data=request_data, run_number=run_number, status=FlowRunStatus.IDLE.value ) self.db.add(flow_run) self.db.commit() self.db.refresh(flow_run) return flow_run def get_flow_run_by_id(self, run_id: int) -> Optional[HedgeFundFlowRun]: """Get a flow run by its ID""" return self.db.query(HedgeFundFlowRun).filter(HedgeFundFlowRun.id == run_id).first() def get_flow_runs_by_flow_id(self, flow_id: int, limit: int = 50, offset: int = 0) -> List[HedgeFundFlowRun]: """Get all runs for a specific flow, ordered by most recent first""" return ( self.db.query(HedgeFundFlowRun) .filter(HedgeFundFlowRun.flow_id == flow_id) .order_by(desc(HedgeFundFlowRun.created_at)) .limit(limit) .offset(offset) .all() ) def get_active_flow_run(self, flow_id: int) -> Optional[HedgeFundFlowRun]: """Get the current active (IN_PROGRESS) run for a flow""" return ( self.db.query(HedgeFundFlowRun) .filter( HedgeFundFlowRun.flow_id == flow_id, HedgeFundFlowRun.status == FlowRunStatus.IN_PROGRESS.value ) .first() ) def get_latest_flow_run(self, flow_id: int) -> Optional[HedgeFundFlowRun]: """Get the most recent run for a flow""" return ( self.db.query(HedgeFundFlowRun) .filter(HedgeFundFlowRun.flow_id == flow_id) .order_by(desc(HedgeFundFlowRun.created_at)) .first() ) def update_flow_run( self, run_id: int, status: Optional[FlowRunStatus] = None, results: Optional[Dict[str, Any]] = None, error_message: Optional[str] = None ) -> Optional[HedgeFundFlowRun]: """Update an existing flow run""" flow_run = self.get_flow_run_by_id(run_id) if not flow_run: return None # Update status and timing if status is not None: flow_run.status = status.value # Update timing based on status if status == FlowRunStatus.IN_PROGRESS and not flow_run.started_at: flow_run.started_at = datetime.utcnow() elif status in [FlowRunStatus.COMPLETE, FlowRunStatus.ERROR] and not flow_run.completed_at: flow_run.completed_at = datetime.utcnow() # Update results and error message if results is not None: flow_run.results = results if error_message is not None: flow_run.error_message = error_message self.db.commit() self.db.refresh(flow_run) return flow_run def delete_flow_run(self, run_id: int) -> bool: """Delete a flow run by ID""" flow_run = self.get_flow_run_by_id(run_id) if not flow_run: return False self.db.delete(flow_run) self.db.commit() return True def delete_flow_runs_by_flow_id(self, flow_id: int) -> int: """Delete all runs for a specific flow. Returns count of deleted runs.""" deleted_count = ( self.db.query(HedgeFundFlowRun) .filter(HedgeFundFlowRun.flow_id == flow_id) .delete() ) self.db.commit() return deleted_count def get_flow_run_count(self, flow_id: int) -> int: """Get total count of runs for a flow""" return ( self.db.query(HedgeFundFlowRun) .filter(HedgeFundFlowRun.flow_id == flow_id) .count() ) def _get_next_run_number(self, flow_id: int) -> int: """Get the next run number for a flow""" max_run_number = ( self.db.query(func.max(HedgeFundFlowRun.run_number)) .filter(HedgeFundFlowRun.flow_id == flow_id) .scalar() ) return (max_run_number or 0) + 1 ================================================ FILE: app/backend/routes/__init__.py ================================================ from fastapi import APIRouter from app.backend.routes.hedge_fund import router as hedge_fund_router from app.backend.routes.health import router as health_router from app.backend.routes.storage import router as storage_router from app.backend.routes.flows import router as flows_router from app.backend.routes.flow_runs import router as flow_runs_router from app.backend.routes.ollama import router as ollama_router from app.backend.routes.language_models import router as language_models_router from app.backend.routes.api_keys import router as api_keys_router # Main API router api_router = APIRouter() # Include sub-routers api_router.include_router(health_router, tags=["health"]) api_router.include_router(hedge_fund_router, tags=["hedge-fund"]) api_router.include_router(storage_router, tags=["storage"]) api_router.include_router(flows_router, tags=["flows"]) api_router.include_router(flow_runs_router, tags=["flow-runs"]) api_router.include_router(ollama_router, tags=["ollama"]) api_router.include_router(language_models_router, tags=["language-models"]) api_router.include_router(api_keys_router, tags=["api-keys"]) ================================================ FILE: app/backend/routes/api_keys.py ================================================ from fastapi import APIRouter, HTTPException, Depends from sqlalchemy.orm import Session from typing import List from app.backend.database import get_db from app.backend.repositories.api_key_repository import ApiKeyRepository from app.backend.models.schemas import ( ApiKeyCreateRequest, ApiKeyUpdateRequest, ApiKeyResponse, ApiKeySummaryResponse, ApiKeyBulkUpdateRequest, ErrorResponse ) router = APIRouter(prefix="/api-keys", tags=["api-keys"]) @router.post( "/", response_model=ApiKeyResponse, responses={ 400: {"model": ErrorResponse, "description": "Invalid request"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def create_or_update_api_key(request: ApiKeyCreateRequest, db: Session = Depends(get_db)): """Create a new API key or update existing one""" try: repo = ApiKeyRepository(db) api_key = repo.create_or_update_api_key( provider=request.provider, key_value=request.key_value, description=request.description, is_active=request.is_active ) return ApiKeyResponse.from_orm(api_key) except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to create/update API key: {str(e)}") @router.get( "/", response_model=List[ApiKeySummaryResponse], responses={ 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_api_keys(include_inactive: bool = False, db: Session = Depends(get_db)): """Get all API keys (without actual key values for security)""" try: repo = ApiKeyRepository(db) api_keys = repo.get_all_api_keys(include_inactive=include_inactive) return [ApiKeySummaryResponse.from_orm(key) for key in api_keys] except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to retrieve API keys: {str(e)}") @router.get( "/{provider}", response_model=ApiKeyResponse, responses={ 404: {"model": ErrorResponse, "description": "API key not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_api_key(provider: str, db: Session = Depends(get_db)): """Get a specific API key by provider""" try: repo = ApiKeyRepository(db) api_key = repo.get_api_key_by_provider(provider) if not api_key: raise HTTPException(status_code=404, detail="API key not found") return ApiKeyResponse.from_orm(api_key) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to retrieve API key: {str(e)}") @router.put( "/{provider}", response_model=ApiKeyResponse, responses={ 404: {"model": ErrorResponse, "description": "API key not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def update_api_key(provider: str, request: ApiKeyUpdateRequest, db: Session = Depends(get_db)): """Update an existing API key""" try: repo = ApiKeyRepository(db) api_key = repo.update_api_key( provider=provider, key_value=request.key_value, description=request.description, is_active=request.is_active ) if not api_key: raise HTTPException(status_code=404, detail="API key not found") return ApiKeyResponse.from_orm(api_key) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to update API key: {str(e)}") @router.delete( "/{provider}", responses={ 204: {"description": "API key deleted successfully"}, 404: {"model": ErrorResponse, "description": "API key not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def delete_api_key(provider: str, db: Session = Depends(get_db)): """Delete an API key""" try: repo = ApiKeyRepository(db) success = repo.delete_api_key(provider) if not success: raise HTTPException(status_code=404, detail="API key not found") return {"message": "API key deleted successfully"} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to delete API key: {str(e)}") @router.patch( "/{provider}/deactivate", response_model=ApiKeySummaryResponse, responses={ 404: {"model": ErrorResponse, "description": "API key not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def deactivate_api_key(provider: str, db: Session = Depends(get_db)): """Deactivate an API key without deleting it""" try: repo = ApiKeyRepository(db) success = repo.deactivate_api_key(provider) if not success: raise HTTPException(status_code=404, detail="API key not found") # Return the updated key api_key = repo.get_api_key_by_provider(provider) return ApiKeySummaryResponse.from_orm(api_key) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to deactivate API key: {str(e)}") @router.post( "/bulk", response_model=List[ApiKeyResponse], responses={ 400: {"model": ErrorResponse, "description": "Invalid request"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def bulk_update_api_keys(request: ApiKeyBulkUpdateRequest, db: Session = Depends(get_db)): """Bulk create or update multiple API keys""" try: repo = ApiKeyRepository(db) api_keys_data = [ { 'provider': key.provider, 'key_value': key.key_value, 'description': key.description, 'is_active': key.is_active } for key in request.api_keys ] api_keys = repo.bulk_create_or_update(api_keys_data) return [ApiKeyResponse.from_orm(key) for key in api_keys] except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to bulk update API keys: {str(e)}") @router.patch( "/{provider}/last-used", responses={ 200: {"description": "Last used timestamp updated"}, 404: {"model": ErrorResponse, "description": "API key not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def update_last_used(provider: str, db: Session = Depends(get_db)): """Update the last used timestamp for an API key""" try: repo = ApiKeyRepository(db) success = repo.update_last_used(provider) if not success: raise HTTPException(status_code=404, detail="API key not found") return {"message": "Last used timestamp updated"} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to update last used timestamp: {str(e)}") ================================================ FILE: app/backend/routes/flow_runs.py ================================================ from fastapi import APIRouter, HTTPException, Depends, Query from sqlalchemy.orm import Session from typing import List, Optional from app.backend.database import get_db from app.backend.repositories.flow_run_repository import FlowRunRepository from app.backend.repositories.flow_repository import FlowRepository from app.backend.models.schemas import ( FlowRunCreateRequest, FlowRunUpdateRequest, FlowRunResponse, FlowRunSummaryResponse, FlowRunStatus, ErrorResponse ) router = APIRouter(prefix="/flows/{flow_id}/runs", tags=["flow-runs"]) @router.post( "/", response_model=FlowRunResponse, responses={ 404: {"model": ErrorResponse, "description": "Flow not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def create_flow_run( flow_id: int, request: FlowRunCreateRequest, db: Session = Depends(get_db) ): """Create a new flow run for the specified flow""" try: # Verify flow exists flow_repo = FlowRepository(db) flow = flow_repo.get_flow_by_id(flow_id) if not flow: raise HTTPException(status_code=404, detail="Flow not found") # Create the flow run run_repo = FlowRunRepository(db) flow_run = run_repo.create_flow_run( flow_id=flow_id, request_data=request.request_data ) return FlowRunResponse.from_orm(flow_run) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to create flow run: {str(e)}") @router.get( "/", response_model=List[FlowRunSummaryResponse], responses={ 404: {"model": ErrorResponse, "description": "Flow not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_flow_runs( flow_id: int, limit: int = Query(50, ge=1, le=100, description="Maximum number of runs to return"), offset: int = Query(0, ge=0, description="Number of runs to skip"), db: Session = Depends(get_db) ): """Get all runs for the specified flow""" try: # Verify flow exists flow_repo = FlowRepository(db) flow = flow_repo.get_flow_by_id(flow_id) if not flow: raise HTTPException(status_code=404, detail="Flow not found") # Get flow runs run_repo = FlowRunRepository(db) flow_runs = run_repo.get_flow_runs_by_flow_id(flow_id, limit=limit, offset=offset) return [FlowRunSummaryResponse.from_orm(run) for run in flow_runs] except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to retrieve flow runs: {str(e)}") @router.get( "/active", response_model=Optional[FlowRunResponse], responses={ 404: {"model": ErrorResponse, "description": "Flow not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_active_flow_run(flow_id: int, db: Session = Depends(get_db)): """Get the current active (IN_PROGRESS) run for the specified flow""" try: # Verify flow exists flow_repo = FlowRepository(db) flow = flow_repo.get_flow_by_id(flow_id) if not flow: raise HTTPException(status_code=404, detail="Flow not found") # Get active flow run run_repo = FlowRunRepository(db) active_run = run_repo.get_active_flow_run(flow_id) return FlowRunResponse.from_orm(active_run) if active_run else None except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to retrieve active flow run: {str(e)}") @router.get( "/latest", response_model=Optional[FlowRunResponse], responses={ 404: {"model": ErrorResponse, "description": "Flow not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_latest_flow_run(flow_id: int, db: Session = Depends(get_db)): """Get the most recent run for the specified flow""" try: # Verify flow exists flow_repo = FlowRepository(db) flow = flow_repo.get_flow_by_id(flow_id) if not flow: raise HTTPException(status_code=404, detail="Flow not found") # Get latest flow run run_repo = FlowRunRepository(db) latest_run = run_repo.get_latest_flow_run(flow_id) return FlowRunResponse.from_orm(latest_run) if latest_run else None except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to retrieve latest flow run: {str(e)}") @router.get( "/{run_id}", response_model=FlowRunResponse, responses={ 404: {"model": ErrorResponse, "description": "Flow or run not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_flow_run(flow_id: int, run_id: int, db: Session = Depends(get_db)): """Get a specific flow run by ID""" try: # Verify flow exists flow_repo = FlowRepository(db) flow = flow_repo.get_flow_by_id(flow_id) if not flow: raise HTTPException(status_code=404, detail="Flow not found") # Get flow run run_repo = FlowRunRepository(db) flow_run = run_repo.get_flow_run_by_id(run_id) if not flow_run or flow_run.flow_id != flow_id: raise HTTPException(status_code=404, detail="Flow run not found") return FlowRunResponse.from_orm(flow_run) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to retrieve flow run: {str(e)}") @router.put( "/{run_id}", response_model=FlowRunResponse, responses={ 404: {"model": ErrorResponse, "description": "Flow or run not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def update_flow_run( flow_id: int, run_id: int, request: FlowRunUpdateRequest, db: Session = Depends(get_db) ): """Update an existing flow run""" try: # Verify flow exists flow_repo = FlowRepository(db) flow = flow_repo.get_flow_by_id(flow_id) if not flow: raise HTTPException(status_code=404, detail="Flow not found") # Update flow run run_repo = FlowRunRepository(db) # First verify the run exists and belongs to this flow existing_run = run_repo.get_flow_run_by_id(run_id) if not existing_run or existing_run.flow_id != flow_id: raise HTTPException(status_code=404, detail="Flow run not found") flow_run = run_repo.update_flow_run( run_id=run_id, status=request.status, results=request.results, error_message=request.error_message ) if not flow_run: raise HTTPException(status_code=404, detail="Flow run not found") return FlowRunResponse.from_orm(flow_run) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to update flow run: {str(e)}") @router.delete( "/{run_id}", responses={ 204: {"description": "Flow run deleted successfully"}, 404: {"model": ErrorResponse, "description": "Flow or run not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def delete_flow_run(flow_id: int, run_id: int, db: Session = Depends(get_db)): """Delete a flow run""" try: # Verify flow exists flow_repo = FlowRepository(db) flow = flow_repo.get_flow_by_id(flow_id) if not flow: raise HTTPException(status_code=404, detail="Flow not found") # Verify run exists and belongs to this flow run_repo = FlowRunRepository(db) existing_run = run_repo.get_flow_run_by_id(run_id) if not existing_run or existing_run.flow_id != flow_id: raise HTTPException(status_code=404, detail="Flow run not found") success = run_repo.delete_flow_run(run_id) if not success: raise HTTPException(status_code=404, detail="Flow run not found") return {"message": "Flow run deleted successfully"} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to delete flow run: {str(e)}") @router.delete( "/", responses={ 204: {"description": "All flow runs deleted successfully"}, 404: {"model": ErrorResponse, "description": "Flow not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def delete_all_flow_runs(flow_id: int, db: Session = Depends(get_db)): """Delete all runs for the specified flow""" try: # Verify flow exists flow_repo = FlowRepository(db) flow = flow_repo.get_flow_by_id(flow_id) if not flow: raise HTTPException(status_code=404, detail="Flow not found") # Delete all flow runs run_repo = FlowRunRepository(db) deleted_count = run_repo.delete_flow_runs_by_flow_id(flow_id) return {"message": f"Deleted {deleted_count} flow runs successfully"} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to delete flow runs: {str(e)}") @router.get( "/count", responses={ 200: {"description": "Flow run count"}, 404: {"model": ErrorResponse, "description": "Flow not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_flow_run_count(flow_id: int, db: Session = Depends(get_db)): """Get the total count of runs for the specified flow""" try: # Verify flow exists flow_repo = FlowRepository(db) flow = flow_repo.get_flow_by_id(flow_id) if not flow: raise HTTPException(status_code=404, detail="Flow not found") # Get run count run_repo = FlowRunRepository(db) count = run_repo.get_flow_run_count(flow_id) return {"flow_id": flow_id, "total_runs": count} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to get flow run count: {str(e)}") ================================================ FILE: app/backend/routes/flows.py ================================================ from fastapi import APIRouter, HTTPException, Depends from sqlalchemy.orm import Session from typing import List from app.backend.database import get_db from app.backend.repositories.flow_repository import FlowRepository from app.backend.models.schemas import ( FlowCreateRequest, FlowUpdateRequest, FlowResponse, FlowSummaryResponse, ErrorResponse ) router = APIRouter(prefix="/flows", tags=["flows"]) @router.post( "/", response_model=FlowResponse, responses={ 400: {"model": ErrorResponse, "description": "Invalid request"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def create_flow(request: FlowCreateRequest, db: Session = Depends(get_db)): """Create a new hedge fund flow""" try: repo = FlowRepository(db) flow = repo.create_flow( name=request.name, description=request.description, nodes=request.nodes, edges=request.edges, viewport=request.viewport, data=request.data, is_template=request.is_template, tags=request.tags ) return FlowResponse.from_orm(flow) except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to create flow: {str(e)}") @router.get( "/", response_model=List[FlowSummaryResponse], responses={ 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_flows(include_templates: bool = True, db: Session = Depends(get_db)): """Get all flows (summary view)""" try: repo = FlowRepository(db) flows = repo.get_all_flows(include_templates=include_templates) return [FlowSummaryResponse.from_orm(flow) for flow in flows] except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to retrieve flows: {str(e)}") @router.get( "/{flow_id}", response_model=FlowResponse, responses={ 404: {"model": ErrorResponse, "description": "Flow not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_flow(flow_id: int, db: Session = Depends(get_db)): """Get a specific flow by ID""" try: repo = FlowRepository(db) flow = repo.get_flow_by_id(flow_id) if not flow: raise HTTPException(status_code=404, detail="Flow not found") return FlowResponse.from_orm(flow) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to retrieve flow: {str(e)}") @router.put( "/{flow_id}", response_model=FlowResponse, responses={ 404: {"model": ErrorResponse, "description": "Flow not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def update_flow(flow_id: int, request: FlowUpdateRequest, db: Session = Depends(get_db)): """Update an existing flow""" try: repo = FlowRepository(db) flow = repo.update_flow( flow_id=flow_id, name=request.name, description=request.description, nodes=request.nodes, edges=request.edges, viewport=request.viewport, data=request.data, is_template=request.is_template, tags=request.tags ) if not flow: raise HTTPException(status_code=404, detail="Flow not found") return FlowResponse.from_orm(flow) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to update flow: {str(e)}") @router.delete( "/{flow_id}", responses={ 204: {"description": "Flow deleted successfully"}, 404: {"model": ErrorResponse, "description": "Flow not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def delete_flow(flow_id: int, db: Session = Depends(get_db)): """Delete a flow""" try: repo = FlowRepository(db) success = repo.delete_flow(flow_id) if not success: raise HTTPException(status_code=404, detail="Flow not found") return {"message": "Flow deleted successfully"} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to delete flow: {str(e)}") @router.post( "/{flow_id}/duplicate", response_model=FlowResponse, responses={ 404: {"model": ErrorResponse, "description": "Flow not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def duplicate_flow(flow_id: int, new_name: str = None, db: Session = Depends(get_db)): """Create a copy of an existing flow""" try: repo = FlowRepository(db) flow = repo.duplicate_flow(flow_id, new_name) if not flow: raise HTTPException(status_code=404, detail="Flow not found") return FlowResponse.from_orm(flow) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to duplicate flow: {str(e)}") @router.get( "/search/{name}", response_model=List[FlowSummaryResponse], responses={ 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def search_flows(name: str, db: Session = Depends(get_db)): """Search flows by name""" try: repo = FlowRepository(db) flows = repo.get_flows_by_name(name) return [FlowSummaryResponse.from_orm(flow) for flow in flows] except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to search flows: {str(e)}") ================================================ FILE: app/backend/routes/health.py ================================================ from fastapi import APIRouter from fastapi.responses import StreamingResponse import asyncio import json router = APIRouter() @router.get("/") async def root(): return {"message": "Welcome to AI Hedge Fund API"} @router.get("/ping") async def ping(): async def event_generator(): for i in range(5): # Create a JSON object for each ping data = {"ping": f"ping {i+1}/5", "timestamp": i + 1} # Format as SSE yield f"data: {json.dumps(data)}\n\n" # Wait 1 second await asyncio.sleep(1) return StreamingResponse(event_generator(), media_type="text/event-stream") ================================================ FILE: app/backend/routes/hedge_fund.py ================================================ from fastapi import APIRouter, HTTPException, Request, Depends from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session import asyncio from app.backend.database import get_db from app.backend.models.schemas import ErrorResponse, HedgeFundRequest, BacktestRequest, BacktestDayResult, BacktestPerformanceMetrics from app.backend.models.events import StartEvent, ProgressUpdateEvent, ErrorEvent, CompleteEvent from app.backend.services.graph import create_graph, parse_hedge_fund_response, run_graph_async from app.backend.services.portfolio import create_portfolio from app.backend.services.backtest_service import BacktestService from app.backend.services.api_key_service import ApiKeyService from src.utils.progress import progress from src.utils.analysts import get_agents_list router = APIRouter(prefix="/hedge-fund") @router.post( path="/run", responses={ 200: {"description": "Successful response with streaming updates"}, 400: {"model": ErrorResponse, "description": "Invalid request parameters"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def run(request_data: HedgeFundRequest, request: Request, db: Session = Depends(get_db)): try: # Hydrate API keys from database if not provided if not request_data.api_keys: api_key_service = ApiKeyService(db) request_data.api_keys = api_key_service.get_api_keys_dict() # Create the portfolio portfolio = create_portfolio(request_data.initial_cash, request_data.margin_requirement, request_data.tickers, request_data.portfolio_positions) # Construct agent graph using the React Flow graph structure graph = create_graph( graph_nodes=request_data.graph_nodes, graph_edges=request_data.graph_edges ) graph = graph.compile() # Log a test progress update for debugging progress.update_status("system", None, "Preparing hedge fund run") # Convert model_provider to string if it's an enum model_provider = request_data.model_provider if hasattr(model_provider, "value"): model_provider = model_provider.value # Function to detect client disconnection async def wait_for_disconnect(): """Wait for client disconnect and return True when it happens""" try: while True: message = await request.receive() if message["type"] == "http.disconnect": return True except Exception: return True # Set up streaming response async def event_generator(): # Queue for progress updates progress_queue = asyncio.Queue() run_task = None disconnect_task = None # Simple handler to add updates to the queue def progress_handler(agent_name, ticker, status, analysis, timestamp): event = ProgressUpdateEvent(agent=agent_name, ticker=ticker, status=status, timestamp=timestamp, analysis=analysis) progress_queue.put_nowait(event) # Register our handler with the progress tracker progress.register_handler(progress_handler) try: # Start the graph execution in a background task run_task = asyncio.create_task( run_graph_async( graph=graph, portfolio=portfolio, tickers=request_data.tickers, start_date=request_data.start_date, end_date=request_data.end_date, model_name=request_data.model_name, model_provider=model_provider, request=request_data, # Pass the full request for agent-specific model access ) ) # Start the disconnect detection task disconnect_task = asyncio.create_task(wait_for_disconnect()) # Send initial message yield StartEvent().to_sse() # Stream progress updates until run_task completes or client disconnects while not run_task.done(): # Check if client disconnected if disconnect_task.done(): print("Client disconnected, cancelling hedge fund execution") run_task.cancel() try: await run_task except asyncio.CancelledError: pass return # Either get a progress update or wait a bit try: event = await asyncio.wait_for(progress_queue.get(), timeout=1.0) yield event.to_sse() except asyncio.TimeoutError: # Just continue the loop pass # Get the final result try: result = await run_task except asyncio.CancelledError: print("Task was cancelled") return if not result or not result.get("messages"): yield ErrorEvent(message="Failed to generate hedge fund decisions").to_sse() return # Send the final result final_data = CompleteEvent( data={ "decisions": parse_hedge_fund_response(result.get("messages", [])[-1].content), "analyst_signals": result.get("data", {}).get("analyst_signals", {}), "current_prices": result.get("data", {}).get("current_prices", {}), } ) yield final_data.to_sse() except asyncio.CancelledError: print("Event generator cancelled") return finally: # Clean up progress.unregister_handler(progress_handler) if run_task and not run_task.done(): run_task.cancel() try: await run_task except asyncio.CancelledError: pass if disconnect_task and not disconnect_task.done(): disconnect_task.cancel() # Return a streaming response return StreamingResponse(event_generator(), media_type="text/event-stream") except HTTPException as e: raise e except Exception as e: raise HTTPException(status_code=500, detail=f"An error occurred while processing the request: {str(e)}") @router.post( path="/backtest", responses={ 200: {"description": "Successful response with streaming backtest updates"}, 400: {"model": ErrorResponse, "description": "Invalid request parameters"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def backtest(request_data: BacktestRequest, request: Request, db: Session = Depends(get_db)): """Run a continuous backtest over a time period with streaming updates.""" try: # Hydrate API keys from database if not provided if not request_data.api_keys: api_key_service = ApiKeyService(db) request_data.api_keys = api_key_service.get_api_keys_dict() # Convert model_provider to string if it's an enum model_provider = request_data.model_provider if hasattr(model_provider, "value"): model_provider = model_provider.value # Create the portfolio (same as /run endpoint) portfolio = create_portfolio( request_data.initial_capital, request_data.margin_requirement, request_data.tickers, request_data.portfolio_positions ) # Construct agent graph using the React Flow graph structure (same as /run endpoint) graph = create_graph(graph_nodes=request_data.graph_nodes, graph_edges=request_data.graph_edges) graph = graph.compile() # Create backtest service with the compiled graph backtest_service = BacktestService( graph=graph, portfolio=portfolio, tickers=request_data.tickers, start_date=request_data.start_date, end_date=request_data.end_date, initial_capital=request_data.initial_capital, model_name=request_data.model_name, model_provider=model_provider, request=request_data, # Pass the full request for agent-specific model access ) # Function to detect client disconnection async def wait_for_disconnect(): """Wait for client disconnect and return True when it happens""" try: while True: message = await request.receive() if message["type"] == "http.disconnect": return True except Exception: return True # Set up streaming response async def event_generator(): progress_queue = asyncio.Queue() backtest_task = None disconnect_task = None # Global progress handler to capture individual agent updates during backtest def progress_handler(agent_name, ticker, status, analysis, timestamp): event = ProgressUpdateEvent(agent=agent_name, ticker=ticker, status=status, timestamp=timestamp, analysis=analysis) progress_queue.put_nowait(event) # Progress callback to handle backtest-specific updates def progress_callback(update): if update["type"] == "progress": event = ProgressUpdateEvent( agent="backtest", ticker=None, status=f"Processing {update['current_date']} ({update['current_step']}/{update['total_dates']})", timestamp=None, analysis=None ) progress_queue.put_nowait(event) elif update["type"] == "backtest_result": # Convert day result to a streaming event backtest_result = BacktestDayResult(**update["data"]) # Send the full day result data as JSON in the analysis field import json analysis_data = json.dumps(update["data"]) event = ProgressUpdateEvent( agent="backtest", ticker=None, status=f"Completed {backtest_result.date} - Portfolio: ${backtest_result.portfolio_value:,.2f}", timestamp=None, analysis=analysis_data ) progress_queue.put_nowait(event) # Register our handler with the progress tracker to capture agent updates progress.register_handler(progress_handler) try: # Start the backtest in a background task backtest_task = asyncio.create_task( backtest_service.run_backtest_async(progress_callback=progress_callback) ) # Start the disconnect detection task disconnect_task = asyncio.create_task(wait_for_disconnect()) # Send initial message yield StartEvent().to_sse() # Stream progress updates until backtest_task completes or client disconnects while not backtest_task.done(): # Check if client disconnected if disconnect_task.done(): print("Client disconnected, cancelling backtest execution") backtest_task.cancel() try: await backtest_task except asyncio.CancelledError: pass return # Either get a progress update or wait a bit try: event = await asyncio.wait_for(progress_queue.get(), timeout=1.0) yield event.to_sse() except asyncio.TimeoutError: # Just continue the loop pass # Get the final result try: result = await backtest_task except asyncio.CancelledError: print("Backtest task was cancelled") return if not result: yield ErrorEvent(message="Failed to complete backtest").to_sse() return # Send the final result performance_metrics = BacktestPerformanceMetrics(**result["performance_metrics"]) final_data = CompleteEvent( data={ "performance_metrics": performance_metrics.model_dump(), "final_portfolio": result["final_portfolio"], "total_days": len(result["results"]), } ) yield final_data.to_sse() except asyncio.CancelledError: print("Backtest event generator cancelled") return finally: # Clean up progress.unregister_handler(progress_handler) if backtest_task and not backtest_task.done(): backtest_task.cancel() try: await backtest_task except asyncio.CancelledError: pass if disconnect_task and not disconnect_task.done(): disconnect_task.cancel() # Return a streaming response return StreamingResponse(event_generator(), media_type="text/event-stream") except HTTPException as e: raise e except Exception as e: raise HTTPException(status_code=500, detail=f"An error occurred while processing the backtest request: {str(e)}") @router.get( path="/agents", responses={ 200: {"description": "List of available agents"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_agents(): """Get the list of available agents.""" try: return {"agents": get_agents_list()} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to retrieve agents: {str(e)}") ================================================ FILE: app/backend/routes/language_models.py ================================================ from fastapi import APIRouter, HTTPException from typing import List, Dict, Any from app.backend.models.schemas import ErrorResponse from app.backend.services.ollama_service import OllamaService from src.llm.models import get_models_list router = APIRouter(prefix="/language-models") # Initialize Ollama service ollama_service = OllamaService() @router.get( path="/", responses={ 200: {"description": "List of available language models"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_language_models(): """Get the list of available cloud-based and Ollama language models.""" try: # Start with cloud models models = get_models_list() # Add available Ollama models (handles all checking internally) ollama_models = await ollama_service.get_available_models() models.extend(ollama_models) return {"models": models} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to retrieve models: {str(e)}") @router.get( path="/providers", responses={ 200: {"description": "List of available model providers"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_language_model_providers(): """Get the list of available model providers with their models grouped.""" try: models = get_models_list() # Group models by provider providers = {} for model in models: provider_name = model["provider"] if provider_name not in providers: providers[provider_name] = { "name": provider_name, "models": [] } providers[provider_name]["models"].append({ "display_name": model["display_name"], "model_name": model["model_name"] }) return {"providers": list(providers.values())} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to retrieve providers: {str(e)}") ================================================ FILE: app/backend/routes/ollama.py ================================================ from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel from typing import List, Dict, Any import logging from app.backend.models.schemas import ErrorResponse from app.backend.services.ollama_service import ollama_service logger = logging.getLogger(__name__) router = APIRouter(prefix="/ollama") class ModelRequest(BaseModel): model_name: str class OllamaStatusResponse(BaseModel): installed: bool running: bool available_models: List[str] server_url: str error: str | None = None class ActionResponse(BaseModel): success: bool message: str class RecommendedModel(BaseModel): display_name: str model_name: str provider: str class ProgressResponse(BaseModel): status: str percentage: float | None = None message: str | None = None phase: str | None = None bytes_downloaded: int | None = None total_bytes: int | None = None @router.get( "/status", response_model=OllamaStatusResponse, responses={ 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_ollama_status(): """Get Ollama installation and server status.""" try: status = await ollama_service.check_ollama_status() return OllamaStatusResponse(**status) except Exception as e: logger.error(f"Failed to check Ollama status: {e}") raise HTTPException(status_code=500, detail=f"Failed to check Ollama status: {str(e)}") @router.post( "/start", response_model=ActionResponse, responses={ 400: {"model": ErrorResponse, "description": "Bad request"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def start_ollama_server(): """Start the Ollama server.""" try: # First check if it's already running status = await ollama_service.check_ollama_status() if not status["installed"]: raise HTTPException(status_code=400, detail="Ollama is not installed on this system") if status["running"]: return ActionResponse(success=True, message="Ollama server is already running") result = await ollama_service.start_server() if not result["success"]: logger.error(f"Failed to start Ollama server: {result['message']}") raise HTTPException(status_code=500, detail=result["message"]) return ActionResponse(**result) except HTTPException: raise except Exception as e: logger.error(f"Unexpected error starting Ollama server: {e}") raise HTTPException(status_code=500, detail=f"Failed to start Ollama server: {str(e)}") @router.post( "/stop", response_model=ActionResponse, responses={ 400: {"model": ErrorResponse, "description": "Bad request"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def stop_ollama_server(): """Stop the Ollama server.""" try: # First check if it's installed status = await ollama_service.check_ollama_status() if not status["installed"]: raise HTTPException(status_code=400, detail="Ollama is not installed on this system") if not status["running"]: return ActionResponse(success=True, message="Ollama server is already stopped") result = await ollama_service.stop_server() if not result["success"]: logger.error(f"Failed to stop Ollama server: {result['message']}") raise HTTPException(status_code=500, detail=result["message"]) return ActionResponse(**result) except HTTPException: raise except Exception as e: logger.error(f"Unexpected error stopping Ollama server: {e}") raise HTTPException(status_code=500, detail=f"Failed to stop Ollama server: {str(e)}") @router.post( "/models/download", response_model=ActionResponse, responses={ 400: {"model": ErrorResponse, "description": "Bad request"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def download_model(request: ModelRequest): """Download an Ollama model (legacy endpoint).""" try: logger.info(f"Download request for model: {request.model_name}") # Check current status status = await ollama_service.check_ollama_status() logger.debug(f"Current Ollama status: installed={status['installed']}, running={status['running']}") if not status["installed"]: raise HTTPException(status_code=400, detail="Ollama is not installed on this system") if not status["running"]: raise HTTPException(status_code=400, detail="Ollama server is not running. Please start it first.") result = await ollama_service.download_model(request.model_name) if not result["success"]: logger.error(f"Failed to download model {request.model_name}: {result['message']}") raise HTTPException(status_code=500, detail=result["message"]) logger.info(f"Successfully downloaded model: {request.model_name}") return ActionResponse(**result) except HTTPException: raise except Exception as e: logger.error(f"Unexpected error downloading model {request.model_name}: {e}") raise HTTPException(status_code=500, detail=f"Failed to download model: {str(e)}") @router.post( "/models/download/progress", responses={ 400: {"model": ErrorResponse, "description": "Bad request"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def download_model_with_progress(request: ModelRequest): """Download an Ollama model with real-time progress updates via Server-Sent Events.""" try: logger.info(f"Progress download request for model: {request.model_name}") # Check current status status = await ollama_service.check_ollama_status() logger.debug(f"Current Ollama status: installed={status['installed']}, running={status['running']}") if not status["installed"]: raise HTTPException(status_code=400, detail="Ollama is not installed on this system") if not status["running"]: raise HTTPException(status_code=400, detail="Ollama server is not running. Please start it first.") # Return Server-Sent Events stream return StreamingResponse( ollama_service.download_model_with_progress(request.model_name), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*", } ) except HTTPException: raise except Exception as e: logger.error(f"Unexpected error setting up progress download for {request.model_name}: {e}") raise HTTPException(status_code=500, detail=f"Failed to start progress download: {str(e)}") @router.get( "/models/download/progress/{model_name}", response_model=ProgressResponse, responses={ 404: {"model": ErrorResponse, "description": "Model download not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_download_progress(model_name: str): """Get current download progress for a specific model.""" try: progress = ollama_service.get_download_progress(model_name) if progress is None: raise HTTPException(status_code=404, detail=f"No active download found for model: {model_name}") return ProgressResponse(**progress) except HTTPException: raise except Exception as e: logger.error(f"Error getting download progress for {model_name}: {e}") raise HTTPException(status_code=500, detail=f"Failed to get download progress: {str(e)}") @router.get( "/models/downloads/active", response_model=Dict[str, ProgressResponse], responses={ 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_active_downloads(): """Get all currently active model downloads.""" try: active_downloads = {} all_progress = ollama_service.get_all_download_progress() # Only return downloads that are actually active (not completed, error, or cancelled) for model_name, progress in all_progress.items(): if progress.get("status") in ["starting", "downloading"]: active_downloads[model_name] = ProgressResponse(**progress) return active_downloads except Exception as e: logger.error(f"Error getting active downloads: {e}") raise HTTPException(status_code=500, detail=f"Failed to get active downloads: {str(e)}") @router.delete( "/models/{model_name}", response_model=ActionResponse, responses={ 400: {"model": ErrorResponse, "description": "Bad request"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def delete_model(model_name: str): """Delete an Ollama model.""" try: logger.info(f"Delete request for model: {model_name}") # Check current status status = await ollama_service.check_ollama_status() logger.debug(f"Current Ollama status: installed={status['installed']}, running={status['running']}") if not status["installed"]: raise HTTPException(status_code=400, detail="Ollama is not installed on this system") if not status["running"]: raise HTTPException(status_code=400, detail="Ollama server is not running. Please start it first.") result = await ollama_service.delete_model(model_name) if not result["success"]: logger.error(f"Failed to delete model {model_name}: {result['message']}") raise HTTPException(status_code=500, detail=result["message"]) logger.info(f"Successfully deleted model: {model_name}") return ActionResponse(**result) except HTTPException: raise except Exception as e: logger.error(f"Unexpected error deleting model {model_name}: {e}") raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}") @router.get( "/models/recommended", response_model=List[RecommendedModel], responses={ 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def get_recommended_models(): """Get list of recommended Ollama models.""" try: models = await ollama_service.get_recommended_models() return [RecommendedModel(**model) for model in models] except Exception as e: logger.error(f"Failed to get recommended models: {e}") raise HTTPException(status_code=500, detail=f"Failed to get recommended models: {str(e)}") @router.delete( "/models/download/{model_name}", response_model=ActionResponse, responses={ 404: {"model": ErrorResponse, "description": "Download not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def cancel_download(model_name: str): """Cancel an active model download.""" try: logger.info(f"Cancel download request for model: {model_name}") success = ollama_service.cancel_download(model_name) if success: return ActionResponse(success=True, message=f"Download cancelled for {model_name}") else: raise HTTPException(status_code=404, detail=f"No active download found for model: {model_name}") except HTTPException: raise except Exception as e: logger.error(f"Unexpected error cancelling download for {model_name}: {e}") raise HTTPException(status_code=500, detail=f"Failed to cancel download: {str(e)}") ================================================ FILE: app/backend/routes/storage.py ================================================ from fastapi import APIRouter, HTTPException import json from pathlib import Path from pydantic import BaseModel from app.backend.models.schemas import ErrorResponse router = APIRouter(prefix="/storage") class SaveJsonRequest(BaseModel): filename: str data: dict @router.post( path="/save-json", responses={ 200: {"description": "File saved successfully"}, 400: {"model": ErrorResponse, "description": "Invalid request parameters"}, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) async def save_json_file(request: SaveJsonRequest): """Save JSON data to the project's /outputs directory.""" try: # Create outputs directory if it doesn't exist project_root = Path(__file__).parent.parent.parent.parent # Navigate to project root outputs_dir = project_root / "outputs" outputs_dir.mkdir(exist_ok=True) # Construct file path file_path = outputs_dir / request.filename # Save JSON data to file with open(file_path, 'w', encoding='utf-8') as f: json.dump(request.data, f, indent=2, ensure_ascii=False) return { "success": True, "message": f"File saved successfully to {file_path}", "filename": request.filename } except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}") ================================================ FILE: app/backend/services/__init__.py ================================================ ================================================ FILE: app/backend/services/agent_service.py ================================================ from functools import partial from typing import Callable from src.graph.state import AgentState def create_agent_function(agent_function: Callable, agent_id: str) -> Callable[[AgentState], dict]: """ Creates a new function from an agent function that accepts an agent_id. :param agent_function: The agent function to wrap. :param agent_id: The ID to be passed to the agent. :return: A new function that can be called by LangGraph. """ return partial(agent_function, agent_id=agent_id) ================================================ FILE: app/backend/services/api_key_service.py ================================================ from sqlalchemy.orm import Session from typing import Dict, Optional from app.backend.repositories.api_key_repository import ApiKeyRepository class ApiKeyService: """Simple service to load API keys for requests""" def __init__(self, db: Session): self.repository = ApiKeyRepository(db) def get_api_keys_dict(self) -> Dict[str, str]: """ Load all active API keys from database and return as a dictionary suitable for injecting into requests """ api_keys = self.repository.get_all_api_keys(include_inactive=False) return {key.provider: key.key_value for key in api_keys} def get_api_key(self, provider: str) -> Optional[str]: """Get a specific API key by provider""" api_key = self.repository.get_api_key_by_provider(provider) return api_key.key_value if api_key else None ================================================ FILE: app/backend/services/backtest_service.py ================================================ from datetime import datetime, timedelta from dateutil.relativedelta import relativedelta import pandas as pd import numpy as np from typing import Callable, Dict, List, Optional, Any import asyncio from src.tools.api import ( get_company_news, get_price_data, get_prices, get_financial_metrics, get_insider_trades, ) from app.backend.services.graph import run_graph_async, parse_hedge_fund_response from app.backend.services.portfolio import create_portfolio class BacktestService: """ Core backtesting service that focuses purely on backtesting logic. Uses a pre-compiled graph and portfolio for trading decisions. """ def __init__( self, graph, portfolio: dict, tickers: List[str], start_date: str, end_date: str, initial_capital: float, model_name: str = "gpt-4.1", model_provider: str = "OpenAI", request: dict = {}, ): """ Initialize the backtest service. :param graph: Pre-compiled LangGraph graph for trading decisions. :param portfolio: Initial portfolio state. :param tickers: List of tickers to backtest. :param start_date: Start date string (YYYY-MM-DD). :param end_date: End date string (YYYY-MM-DD). :param initial_capital: Starting portfolio cash. :param model_name: Which LLM model name to use. :param model_provider: Which LLM provider. :param request: Request object containing API keys and other metadata. """ self.graph = graph self.portfolio = portfolio self.tickers = tickers self.start_date = start_date self.end_date = end_date self.initial_capital = initial_capital self.model_name = model_name self.model_provider = model_provider self.request = request self.portfolio_values = [] def execute_trade(self, ticker: str, action: str, quantity: float, current_price: float) -> int: """ Execute trades with support for both long and short positions. Returns the actual quantity traded. """ if quantity <= 0: return 0 quantity = int(quantity) # force integer shares position = self.portfolio["positions"][ticker] if action == "buy": cost = quantity * current_price if cost <= self.portfolio["cash"]: # Weighted average cost basis for the new total old_shares = position["long"] old_cost_basis = position["long_cost_basis"] new_shares = quantity total_shares = old_shares + new_shares if total_shares > 0: total_old_cost = old_cost_basis * old_shares total_new_cost = cost position["long_cost_basis"] = (total_old_cost + total_new_cost) / total_shares position["long"] += quantity self.portfolio["cash"] -= cost return quantity else: # Calculate maximum affordable quantity max_quantity = int(self.portfolio["cash"] / current_price) if max_quantity > 0: cost = max_quantity * current_price old_shares = position["long"] old_cost_basis = position["long_cost_basis"] total_shares = old_shares + max_quantity if total_shares > 0: total_old_cost = old_cost_basis * old_shares total_new_cost = cost position["long_cost_basis"] = (total_old_cost + total_new_cost) / total_shares position["long"] += max_quantity self.portfolio["cash"] -= cost return max_quantity return 0 elif action == "sell": quantity = min(quantity, position["long"]) if quantity > 0: avg_cost_per_share = position["long_cost_basis"] if position["long"] > 0 else 0 realized_gain = (current_price - avg_cost_per_share) * quantity self.portfolio["realized_gains"][ticker]["long"] += realized_gain position["long"] -= quantity self.portfolio["cash"] += quantity * current_price if position["long"] == 0: position["long_cost_basis"] = 0.0 return quantity elif action == "short": proceeds = current_price * quantity margin_required = proceeds * self.portfolio["margin_requirement"] available_cash = max( 0.0, self.portfolio["cash"] - self.portfolio["margin_used"] ) if margin_required <= available_cash: # Weighted average short cost basis old_short_shares = position["short"] old_cost_basis = position["short_cost_basis"] new_shares = quantity total_shares = old_short_shares + new_shares if total_shares > 0: total_old_cost = old_cost_basis * old_short_shares total_new_cost = current_price * new_shares position["short_cost_basis"] = (total_old_cost + total_new_cost) / total_shares position["short"] += quantity position["short_margin_used"] += margin_required self.portfolio["margin_used"] += margin_required self.portfolio["cash"] += proceeds self.portfolio["cash"] -= margin_required return quantity else: margin_ratio = self.portfolio["margin_requirement"] if margin_ratio > 0: max_quantity = int(available_cash / (current_price * margin_ratio)) else: max_quantity = 0 if max_quantity > 0: proceeds = current_price * max_quantity margin_required = proceeds * margin_ratio old_short_shares = position["short"] old_cost_basis = position["short_cost_basis"] total_shares = old_short_shares + max_quantity if total_shares > 0: total_old_cost = old_cost_basis * old_short_shares total_new_cost = current_price * max_quantity position["short_cost_basis"] = (total_old_cost + total_new_cost) / total_shares position["short"] += max_quantity position["short_margin_used"] += margin_required self.portfolio["margin_used"] += margin_required self.portfolio["cash"] += proceeds self.portfolio["cash"] -= margin_required return max_quantity return 0 elif action == "cover": quantity = min(quantity, position["short"]) if quantity > 0: cover_cost = quantity * current_price avg_short_price = position["short_cost_basis"] if position["short"] > 0 else 0 realized_gain = (avg_short_price - current_price) * quantity if position["short"] > 0: portion = quantity / position["short"] else: portion = 1.0 margin_to_release = portion * position["short_margin_used"] position["short"] -= quantity position["short_margin_used"] -= margin_to_release self.portfolio["margin_used"] -= margin_to_release self.portfolio["cash"] += margin_to_release self.portfolio["cash"] -= cover_cost self.portfolio["realized_gains"][ticker]["short"] += realized_gain if position["short"] == 0: position["short_cost_basis"] = 0.0 position["short_margin_used"] = 0.0 return quantity return 0 def calculate_portfolio_value(self, current_prices: Dict[str, float]) -> float: """Calculate total portfolio value.""" total_value = self.portfolio["cash"] for ticker in self.tickers: position = self.portfolio["positions"][ticker] price = current_prices[ticker] # Long position value long_value = position["long"] * price total_value += long_value # Short position unrealized PnL if position["short"] > 0: total_value -= position["short"] * price return total_value def prefetch_data(self): """Pre-fetch all data needed for the backtest period.""" end_date_dt = datetime.strptime(self.end_date, "%Y-%m-%d") start_date_dt = end_date_dt - relativedelta(years=1) start_date_str = start_date_dt.strftime("%Y-%m-%d") api_key = self.request.api_keys.get("FINANCIAL_DATASETS_API_KEY") for ticker in self.tickers: get_prices(ticker, start_date_str, self.end_date, api_key=api_key) get_financial_metrics(ticker, self.end_date, limit=10, api_key=api_key) get_insider_trades(ticker, self.end_date, start_date=self.start_date, limit=1000, api_key=api_key) get_company_news(ticker, self.end_date, start_date=self.start_date, limit=1000, api_key=api_key) def _update_performance_metrics(self, performance_metrics: Dict[str, Any]): """Update performance metrics using daily returns.""" values_df = pd.DataFrame(self.portfolio_values).set_index("Date") values_df["Daily Return"] = values_df["Portfolio Value"].pct_change() clean_returns = values_df["Daily Return"].dropna() if len(clean_returns) < 2: return daily_risk_free_rate = 0.0434 / 252 excess_returns = clean_returns - daily_risk_free_rate mean_excess_return = excess_returns.mean() std_excess_return = excess_returns.std() # Sharpe ratio if std_excess_return > 1e-12: performance_metrics["sharpe_ratio"] = np.sqrt(252) * (mean_excess_return / std_excess_return) else: performance_metrics["sharpe_ratio"] = 0.0 # Sortino ratio negative_returns = excess_returns[excess_returns < 0] if len(negative_returns) > 0: downside_std = negative_returns.std() if downside_std > 1e-12: performance_metrics["sortino_ratio"] = np.sqrt(252) * (mean_excess_return / downside_std) else: performance_metrics["sortino_ratio"] = None if mean_excess_return > 0 else 0 else: performance_metrics["sortino_ratio"] = None if mean_excess_return > 0 else 0 # Maximum drawdown rolling_max = values_df["Portfolio Value"].cummax() drawdown = (values_df["Portfolio Value"] - rolling_max) / rolling_max if len(drawdown) > 0: min_drawdown = drawdown.min() performance_metrics["max_drawdown"] = min_drawdown * 100 if min_drawdown < 0: performance_metrics["max_drawdown_date"] = drawdown.idxmin().strftime("%Y-%m-%d") else: performance_metrics["max_drawdown_date"] = None else: performance_metrics["max_drawdown"] = 0.0 performance_metrics["max_drawdown_date"] = None async def run_backtest_async(self, progress_callback: Optional[Callable] = None) -> Dict[str, Any]: """ Run the backtest asynchronously with optional progress callbacks. Uses the pre-compiled graph for trading decisions. """ # Pre-fetch all data at the start self.prefetch_data() dates = pd.date_range(self.start_date, self.end_date, freq="B") performance_metrics = { "sharpe_ratio": 0.0, "sortino_ratio": 0.0, "max_drawdown": 0.0, "long_short_ratio": 0.0, "gross_exposure": 0.0, "net_exposure": 0.0, } # Initialize portfolio values if len(dates) > 0: self.portfolio_values = [{"Date": dates[0], "Portfolio Value": self.initial_capital}] else: self.portfolio_values = [] backtest_results = [] for i, current_date in enumerate(dates): # Allow other async operations to run await asyncio.sleep(0) lookback_start = (current_date - timedelta(days=30)).strftime("%Y-%m-%d") current_date_str = current_date.strftime("%Y-%m-%d") previous_date_str = (current_date - timedelta(days=1)).strftime("%Y-%m-%d") if lookback_start == current_date_str: continue # Send progress update if callback provided if progress_callback: progress_callback({ "type": "progress", "current_date": current_date_str, "progress": (i + 1) / len(dates), "total_dates": len(dates), "current_step": i + 1, }) # Get current prices try: current_prices = {} missing_data = False for ticker in self.tickers: try: price_data = get_price_data(ticker, previous_date_str, current_date_str) if price_data.empty: missing_data = True break current_prices[ticker] = price_data.iloc[-1]["close"] except Exception as e: missing_data = True break if missing_data: continue except Exception: continue # Create portfolio for this iteration portfolio_for_graph = create_portfolio( initial_cash=self.portfolio["cash"], margin_requirement=self.portfolio["margin_requirement"], tickers=self.tickers, portfolio_positions=[] # We'll handle positions manually ) # Copy current portfolio state to the graph portfolio portfolio_for_graph.update(self.portfolio) # Execute graph-based agent decisions try: result = await run_graph_async( graph=self.graph, portfolio=portfolio_for_graph, tickers=self.tickers, start_date=lookback_start, end_date=current_date_str, model_name=self.model_name, model_provider=self.model_provider, request=self.request, ) # Parse the decisions from the graph result if result and result.get("messages"): decisions = parse_hedge_fund_response(result["messages"][-1].content) analyst_signals = result.get("data", {}).get("analyst_signals", {}) else: decisions = {} analyst_signals = {} except Exception as e: print(f"Error running graph for {current_date_str}: {e}") decisions = {} analyst_signals = {} # Execute trades based on decisions executed_trades = {} for ticker in self.tickers: decision = decisions.get(ticker, {"action": "hold", "quantity": 0}) action, quantity = decision.get("action", "hold"), decision.get("quantity", 0) executed_quantity = self.execute_trade(ticker, action, quantity, current_prices[ticker]) executed_trades[ticker] = executed_quantity # Calculate portfolio value total_value = self.calculate_portfolio_value(current_prices) # Calculate exposures long_exposure = sum(self.portfolio["positions"][t]["long"] * current_prices[t] for t in self.tickers) short_exposure = sum(self.portfolio["positions"][t]["short"] * current_prices[t] for t in self.tickers) gross_exposure = long_exposure + short_exposure net_exposure = long_exposure - short_exposure long_short_ratio = long_exposure / short_exposure if short_exposure > 1e-9 else None # Track portfolio value self.portfolio_values.append({ "Date": current_date, "Portfolio Value": total_value, "Long Exposure": long_exposure, "Short Exposure": short_exposure, "Gross Exposure": gross_exposure, "Net Exposure": net_exposure, "Long/Short Ratio": long_short_ratio, }) # Calculate performance metrics for this day portfolio_return = (total_value / self.initial_capital - 1) * 100 # Update performance metrics if we have enough data if len(self.portfolio_values) > 2: self._update_performance_metrics(performance_metrics) # Build detailed result for this date (similar to CLI format) date_result = { "date": current_date_str, "portfolio_value": total_value, "cash": self.portfolio["cash"], "decisions": decisions, "executed_trades": executed_trades, "analyst_signals": analyst_signals, "current_prices": current_prices, "long_exposure": long_exposure, "short_exposure": short_exposure, "gross_exposure": gross_exposure, "net_exposure": net_exposure, "long_short_ratio": long_short_ratio, "portfolio_return": portfolio_return, "performance_metrics": performance_metrics.copy(), # Add detailed trading information for each ticker "ticker_details": [] } # Build ticker details (similar to CLI format_backtest_row) for ticker in self.tickers: ticker_signals = {} for agent_name, signals in analyst_signals.items(): if ticker in signals: ticker_signals[agent_name] = signals[ticker] bullish_count = len([s for s in ticker_signals.values() if s.get("signal", "").lower() == "bullish"]) bearish_count = len([s for s in ticker_signals.values() if s.get("signal", "").lower() == "bearish"]) neutral_count = len([s for s in ticker_signals.values() if s.get("signal", "").lower() == "neutral"]) # Calculate net position value pos = self.portfolio["positions"][ticker] long_val = pos["long"] * current_prices[ticker] short_val = pos["short"] * current_prices[ticker] net_position_value = long_val - short_val # Get the action and quantity from the decisions action = decisions.get(ticker, {}).get("action", "hold") quantity = executed_trades.get(ticker, 0) ticker_detail = { "ticker": ticker, "action": action, "quantity": quantity, "price": current_prices[ticker], "shares_owned": pos["long"] - pos["short"], # net shares "long_shares": pos["long"], "short_shares": pos["short"], "position_value": net_position_value, "bullish_count": bullish_count, "bearish_count": bearish_count, "neutral_count": neutral_count, } date_result["ticker_details"].append(ticker_detail) backtest_results.append(date_result) # Send intermediate result if callback provided if progress_callback: progress_callback({ "type": "backtest_result", "data": date_result, }) # Ensure final performance metrics are calculated if len(self.portfolio_values) > 1: self._update_performance_metrics(performance_metrics) # Calculate final exposures if we have results if backtest_results: final_result = backtest_results[-1] performance_metrics["gross_exposure"] = final_result["gross_exposure"] performance_metrics["net_exposure"] = final_result["net_exposure"] performance_metrics["long_short_ratio"] = final_result["long_short_ratio"] # Store final performance metrics self.performance_metrics = performance_metrics return { "results": backtest_results, "performance_metrics": performance_metrics, "portfolio_values": self.portfolio_values, "final_portfolio": self.portfolio, } def run_backtest_sync(self) -> Dict[str, Any]: """ Run the backtest synchronously. This version can be used by the CLI. """ # Use asyncio to run the async version loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: return loop.run_until_complete(self.run_backtest_async()) finally: loop.close() def analyze_performance(self) -> pd.DataFrame: """Analyze performance and return DataFrame with metrics.""" if not self.portfolio_values: return pd.DataFrame() performance_df = pd.DataFrame(self.portfolio_values).set_index("Date") if performance_df.empty: return performance_df # Calculate additional metrics performance_df["Daily Return"] = performance_df["Portfolio Value"].pct_change().fillna(0) return performance_df ================================================ FILE: app/backend/services/graph.py ================================================ import asyncio import json import re from langchain_core.messages import HumanMessage from langgraph.graph import END, StateGraph from app.backend.services.agent_service import create_agent_function from src.agents.portfolio_manager import portfolio_management_agent from src.agents.risk_manager import risk_management_agent from src.main import start from src.utils.analysts import ANALYST_CONFIG from src.graph.state import AgentState def extract_base_agent_key(unique_id: str) -> str: """ Extract the base agent key from a unique node ID. Args: unique_id: The unique node ID with suffix (e.g., "warren_buffett_abc123") Returns: The base agent key (e.g., "warren_buffett") """ # For agent nodes, remove the last underscore and 6-character suffix parts = unique_id.split('_') if len(parts) >= 2: last_part = parts[-1] # If the last part is a 6-character alphanumeric string, it's likely our suffix if len(last_part) == 6 and re.match(r'^[a-z0-9]+$', last_part): return '_'.join(parts[:-1]) return unique_id # Return original if no suffix pattern found # Helper function to create the agent graph def create_graph(graph_nodes: list, graph_edges: list) -> StateGraph: """Create the workflow based on the React Flow graph structure.""" graph = StateGraph(AgentState) graph.add_node("start_node", start) # Get analyst nodes from the configuration analyst_nodes = {key: (f"{key}_agent", config["agent_func"]) for key, config in ANALYST_CONFIG.items()} # Extract agent IDs from graph structure agent_ids = [node.id for node in graph_nodes] agent_ids_set = set(agent_ids) # Track which nodes are portfolio managers for special handling portfolio_manager_nodes = set() # Add agent nodes for unique_agent_id in agent_ids: base_agent_key = extract_base_agent_key(unique_agent_id) # Track portfolio manager nodes for special handling (before ANALYST_CONFIG check) if base_agent_key == "portfolio_manager": portfolio_manager_nodes.add(unique_agent_id) continue # Skip if the base agent key is not in our analyst configuration if base_agent_key not in ANALYST_CONFIG: continue node_name, node_func = analyst_nodes[base_agent_key] agent_function = create_agent_function(node_func, unique_agent_id) graph.add_node(unique_agent_id, agent_function) # Add portfolio manager nodes and their corresponding risk managers risk_manager_nodes = {} # Map portfolio manager ID to risk manager ID for portfolio_manager_id in portfolio_manager_nodes: portfolio_manager_function = create_agent_function(portfolio_management_agent, portfolio_manager_id) graph.add_node(portfolio_manager_id, portfolio_manager_function) # Create unique risk manager for this portfolio manager suffix = portfolio_manager_id.split('_')[-1] risk_manager_id = f"risk_management_agent_{suffix}" risk_manager_nodes[portfolio_manager_id] = risk_manager_id # Add the risk manager node risk_manager_function = create_agent_function(risk_management_agent, risk_manager_id) graph.add_node(risk_manager_id, risk_manager_function) # Build connections based on React Flow graph structure nodes_with_incoming_edges = set() nodes_with_outgoing_edges = set() direct_to_portfolio_managers = {} # Map analyst ID to portfolio manager ID for direct connections for edge in graph_edges: # Only consider edges between agent nodes (not from stock tickers) if edge.source in agent_ids_set and edge.target in agent_ids_set: source_base_key = extract_base_agent_key(edge.source) target_base_key = extract_base_agent_key(edge.target) nodes_with_incoming_edges.add(edge.target) nodes_with_outgoing_edges.add(edge.source) # Check if this is a direct connection from analyst to portfolio manager if (source_base_key in ANALYST_CONFIG and source_base_key != "portfolio_manager" and target_base_key == "portfolio_manager"): # Don't add direct edge to portfolio manager - we'll route through risk manager direct_to_portfolio_managers[edge.source] = edge.target else: # Add edge between agent nodes (but not direct to portfolio managers) graph.add_edge(edge.source, edge.target) # Connect start_node to nodes that don't have incoming edges from other agents for agent_id in agent_ids: if agent_id not in nodes_with_incoming_edges: base_agent_key = extract_base_agent_key(agent_id) if base_agent_key in ANALYST_CONFIG and base_agent_key != "portfolio_manager": graph.add_edge("start_node", agent_id) # Connect analysts that have direct connections to portfolio managers to their corresponding risk managers for analyst_id, portfolio_manager_id in direct_to_portfolio_managers.items(): risk_manager_id = risk_manager_nodes[portfolio_manager_id] graph.add_edge(analyst_id, risk_manager_id) # Connect each risk manager to its corresponding portfolio manager for portfolio_manager_id, risk_manager_id in risk_manager_nodes.items(): graph.add_edge(risk_manager_id, portfolio_manager_id) # Connect portfolio managers to END for portfolio_manager_id in portfolio_manager_nodes: graph.add_edge(portfolio_manager_id, END) # Set the entry point to the start node graph.set_entry_point("start_node") return graph async def run_graph_async(graph, portfolio, tickers, start_date, end_date, model_name, model_provider, request=None): """Async wrapper for run_graph to work with asyncio.""" # Use run_in_executor to run the synchronous function in a separate thread # so it doesn't block the event loop loop = asyncio.get_running_loop() result = await loop.run_in_executor(None, lambda: run_graph(graph, portfolio, tickers, start_date, end_date, model_name, model_provider, request)) # Use default executor return result def run_graph( graph: StateGraph, portfolio: dict, tickers: list[str], start_date: str, end_date: str, model_name: str, model_provider: str, request=None, ) -> dict: """ Run the graph with the given portfolio, tickers, start date, end date, show reasoning, model name, and model provider. """ return graph.invoke( { "messages": [ HumanMessage( content="Make trading decisions based on the provided data.", ) ], "data": { "tickers": tickers, "portfolio": portfolio, "start_date": start_date, "end_date": end_date, "analyst_signals": {}, }, "metadata": { "show_reasoning": False, "model_name": model_name, "model_provider": model_provider, "request": request, # Pass the request for agent-specific model access }, }, ) def parse_hedge_fund_response(response): """Parses a JSON string and returns a dictionary.""" try: return json.loads(response) except json.JSONDecodeError as e: print(f"JSON decoding error: {e}\nResponse: {repr(response)}") return None except TypeError as e: print(f"Invalid response type (expected string, got {type(response).__name__}): {e}") return None except Exception as e: print(f"Unexpected error while parsing response: {e}\nResponse: {repr(response)}") return None ================================================ FILE: app/backend/services/ollama_service.py ================================================ import asyncio import os import sys import platform import subprocess import time import re import json import queue import threading from pathlib import Path from typing import Dict, List, Optional, AsyncGenerator import logging import signal import ollama logger = logging.getLogger(__name__) class OllamaService: """Service for managing Ollama integration in the backend.""" def __init__(self): self._download_progress = {} self._download_processes = {} # Initialize async client self._async_client = ollama.AsyncClient() self._sync_client = ollama.Client() # ============================================================================= # PUBLIC API METHODS # ============================================================================= async def check_ollama_status(self) -> Dict[str, any]: """Check Ollama installation and server status.""" try: is_installed = await self._check_installation() is_running = await self._check_server_running() models, server_url = await self._get_server_info(is_running) status = { "installed": is_installed, "running": is_running, "server_running": is_running, # Backward compatibility "available_models": models, "server_url": server_url, "error": None } logger.debug(f"Ollama status: installed={is_installed}, running={is_running}, models={len(models)}") return status except Exception as e: logger.error(f"Error checking Ollama status: {e}") return self._create_error_status(str(e)) async def start_server(self) -> Dict[str, any]: """Start the Ollama server.""" try: success = await self._execute_server_start() message = "Ollama server started successfully" if success else "Failed to start Ollama server" return {"success": success, "message": message} except Exception as e: logger.error(f"Error starting Ollama server: {e}") return {"success": False, "message": f"Error starting server: {str(e)}"} async def stop_server(self) -> Dict[str, any]: """Stop the Ollama server.""" try: success = await self._execute_server_stop() message = "Ollama server stopped successfully" if success else "Failed to stop Ollama server" return {"success": success, "message": message} except Exception as e: logger.error(f"Error stopping Ollama server: {e}") return {"success": False, "message": f"Error stopping server: {str(e)}"} async def download_model(self, model_name: str) -> Dict[str, any]: """Download an Ollama model.""" try: success = await self._execute_model_download(model_name) message = f"Model {model_name} downloaded successfully" if success else f"Failed to download model {model_name}" return {"success": success, "message": message} except Exception as e: logger.error(f"Error downloading model {model_name}: {e}") return {"success": False, "message": f"Error downloading model: {str(e)}"} async def download_model_with_progress(self, model_name: str) -> AsyncGenerator[str, None]: """Download an Ollama model with progress streaming.""" async for progress_data in self._stream_model_download(model_name): yield progress_data async def delete_model(self, model_name: str) -> Dict[str, any]: """Delete an Ollama model.""" try: success = await self._execute_model_deletion(model_name) message = f"Model {model_name} deleted successfully" if success else f"Failed to delete model {model_name}" return {"success": success, "message": message} except Exception as e: logger.error(f"Error deleting model {model_name}: {e}") return {"success": False, "message": f"Error deleting model: {str(e)}"} async def get_recommended_models(self) -> List[Dict[str, str]]: """Get list of recommended Ollama models.""" try: models_path = self._get_ollama_models_path() if models_path.exists(): return self._load_models_from_file(models_path) else: return self._get_fallback_models() except Exception as e: logger.error(f"Error loading recommended models: {e}") return [] async def get_available_models(self) -> List[Dict[str, str]]: """Get available Ollama models formatted for the language models API. Returns only models that are: 1. Server is running 2. Model is downloaded locally 3. Model is in our recommended list (OLLAMA_MODELS) """ try: status = await self.check_ollama_status() if not status.get("server_running", False): logger.debug("Ollama server not running, returning no models for API") return [] downloaded_models = status.get("available_models", []) if not downloaded_models: logger.debug("No Ollama models downloaded, returning empty list for API") return [] api_models = self._format_models_for_api(downloaded_models) logger.debug(f"Returning {len(api_models)} Ollama models for language models API") return api_models except Exception as e: logger.error(f"Error getting available models for API: {e}") return [] # Return empty list on error to not break the API def get_download_progress(self, model_name: str) -> Optional[Dict[str, any]]: """Get current download progress for a model.""" return self._download_progress.get(model_name) def get_all_download_progress(self) -> Dict[str, Dict[str, any]]: """Get current download progress for all models.""" return self._download_progress.copy() def cancel_download(self, model_name: str) -> bool: """Cancel an active download.""" logger.warning(f"Download cancellation not directly supported by ollama client for model: {model_name}") if model_name in self._download_progress: self._download_progress[model_name] = { "status": "cancelled", "message": f"Download of {model_name} was cancelled", "error": "Download cancelled by user" } return True return False # ============================================================================= # PRIVATE HELPER METHODS # ============================================================================= def _create_error_status(self, error: str) -> Dict[str, any]: """Create error status response.""" return { "installed": False, "running": False, "server_running": False, "available_models": [], "server_url": "", "error": error } async def _check_installation(self) -> bool: """Check if Ollama CLI is installed.""" loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._is_ollama_installed) def _is_ollama_installed(self) -> bool: """Check if Ollama is installed on the system.""" system = platform.system().lower() command = ["which", "ollama"] if system in ["darwin", "linux"] else ["where", "ollama"] shell = system == "windows" try: result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, shell=shell) return result.returncode == 0 except Exception: return False async def _check_server_running(self) -> bool: """Check if the Ollama server is running using the ollama client.""" try: await self._async_client.list() logger.debug("Ollama server confirmed running via client") return True except Exception as e: logger.debug(f"Ollama server not reachable: {e}") return False async def _get_server_info(self, is_running: bool) -> tuple[List[str], str]: """Get server information (models and URL) if server is running.""" if not is_running: return [], "" try: response = await self._async_client.list() models = [model.model for model in response.models] server_url = getattr(self._async_client, 'host', 'http://localhost:11434') logger.debug(f"Found {len(models)} locally available models") return models, server_url except Exception as e: logger.debug(f"Failed to get server info: {e}") return [], "" async def _execute_server_start(self) -> bool: """Execute server start operation.""" # Check if already running try: self._sync_client.list() logger.info("Ollama server is already running") return True except Exception: pass # Server not running, continue to start it loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._start_ollama_process) def _start_ollama_process(self) -> bool: """Start the Ollama server process.""" system = platform.system().lower() try: command = ["ollama", "serve"] shell = system == "windows" subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=shell) return self._wait_for_server_start() except Exception as e: logger.error(f"Error starting Ollama server: {e}") return False def _wait_for_server_start(self) -> bool: """Wait for server to start and become ready.""" logger.info("Starting Ollama server, waiting for it to become ready...") for i in range(20): # Try for 20 seconds time.sleep(1) try: self._sync_client.list() logger.info(f"Ollama server started successfully after {i+1} seconds") return True except Exception: logger.debug(f"Waiting for Ollama server... ({i+1}/20)") continue logger.error("Ollama server failed to start within 20 seconds") return False async def _execute_server_stop(self) -> bool: """Execute server stop operation.""" # Check if already stopped try: self._sync_client.list() except Exception: logger.info("Ollama server is already stopped") return True loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._stop_ollama_process) def _stop_ollama_process(self) -> bool: """Stop the Ollama server process.""" system = platform.system().lower() try: if system in ["darwin", "linux"]: return self._stop_unix_process() elif system == "windows": return self._stop_windows_process() else: return False except Exception as e: logger.error(f"Error stopping Ollama server: {e}") return False def _stop_unix_process(self) -> bool: """Stop Ollama on Unix-like systems.""" try: result = subprocess.run( ["pgrep", "-f", "ollama serve"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) if result.returncode == 0: pids = [pid for pid in result.stdout.strip().split('\n') if pid] self._terminate_processes(pids) return self._verify_server_stopped() except Exception as e: logger.error(f"Error stopping Unix process: {e}") return False def _stop_windows_process(self) -> bool: """Stop Ollama on Windows.""" try: subprocess.run( ["taskkill", "/F", "/IM", "ollama.exe"], stdout=subprocess.PIPE, stderr=subprocess.PIPE ) return self._verify_server_stopped() except Exception as e: logger.error(f"Error stopping Windows process: {e}") return False def _terminate_processes(self, pids: List[str]) -> None: """Terminate processes gracefully, then forcefully if needed.""" # Try SIGTERM first for pid in pids: if pid: try: os.kill(int(pid), signal.SIGTERM) except (ValueError, ProcessLookupError, PermissionError): continue # Wait for graceful termination for _ in range(5): try: self._sync_client.list() time.sleep(1) except Exception: return # Server stopped # Force kill if still running for pid in pids: if pid: try: os.kill(int(pid), signal.SIGKILL) except (ValueError, ProcessLookupError, PermissionError): continue def _verify_server_stopped(self) -> bool: """Verify that the server has stopped.""" for _ in range(3): try: self._sync_client.list() time.sleep(1) except Exception: return True return False async def _execute_model_download(self, model_name: str) -> bool: """Execute model download operation.""" if not await self._check_server_running(): logger.error(f"Cannot download model {model_name}: Ollama server is not running") return False try: logger.info(f"Starting download of model: {model_name}") await self._async_client.pull(model_name) logger.info(f"Successfully downloaded model: {model_name}") return True except Exception as e: logger.error(f"Error downloading model {model_name}: {e}") return False async def _execute_model_deletion(self, model_name: str) -> bool: """Execute model deletion operation.""" if not await self._check_server_running(): logger.error(f"Cannot delete model {model_name}: Ollama server is not running") return False try: logger.info(f"Deleting model: {model_name}") await self._async_client.delete(model_name) logger.info(f"Successfully deleted model: {model_name}") return True except Exception as e: logger.error(f"Error deleting model {model_name}: {e}") return False async def _stream_model_download(self, model_name: str) -> AsyncGenerator[str, None]: """Stream model download with progress updates.""" try: if not await self._check_server_running(): yield f"data: {json.dumps({'status': 'error', 'error': 'Ollama server is not running'})}\n\n" return logger.info(f"Starting download of model: {model_name}") self._download_progress[model_name] = {"status": "starting", "percentage": 0} yield f"data: {json.dumps({'status': 'starting', 'percentage': 0, 'message': f'Starting download of {model_name}...'})}\n\n" # Await the pull method to get the async iterator pull_stream = await self._async_client.pull(model_name, stream=True) async for progress in pull_stream: progress_data = self._process_download_progress(progress, model_name) if progress_data: yield f"data: {json.dumps(progress_data)}\n\n" if progress_data.get("status") == "completed": logger.info(f"Successfully downloaded model: {model_name}") break except Exception as e: error_data = { "status": "error", "message": f"Error downloading model {model_name}", "error": str(e) } self._download_progress[model_name] = error_data yield f"data: {json.dumps(error_data)}\n\n" logger.error(f"Error downloading model {model_name}: {e}") finally: await asyncio.sleep(1) if model_name in self._download_progress: del self._download_progress[model_name] def _process_download_progress(self, progress, model_name: str) -> Optional[Dict[str, any]]: """Process download progress from ollama client.""" if not hasattr(progress, 'status'): return None progress_data = { "status": "downloading", "message": progress.status, "raw_output": progress.status } # Add completed/total info if available if (hasattr(progress, 'completed') and hasattr(progress, 'total') and progress.total is not None and progress.completed is not None and progress.total > 0): percentage = (progress.completed / progress.total) * 100 progress_data.update({ "percentage": percentage, "bytes_downloaded": progress.completed, "total_bytes": progress.total }) # Add digest info if available if hasattr(progress, 'digest'): progress_data["digest"] = progress.digest # Store in cache self._download_progress[model_name] = progress_data # Check if download is complete if (progress.status == "success" or (hasattr(progress, 'completed') and hasattr(progress, 'total') and progress.completed is not None and progress.total is not None and progress.completed == progress.total)): final_data = { "status": "completed", "percentage": 100, "message": f"Model {model_name} downloaded successfully!" } self._download_progress[model_name] = final_data return final_data return progress_data def _get_ollama_models_path(self) -> Path: """Get path to ollama_models.json file.""" return Path(__file__).parent.parent.parent.parent / "src" / "llm" / "ollama_models.json" def _load_models_from_file(self, models_path: Path) -> List[Dict[str, str]]: """Load models from JSON file.""" with open(models_path, 'r') as f: return json.load(f) def _get_fallback_models(self) -> List[Dict[str, str]]: """Get fallback models when file is not available.""" return [ {"display_name": "[meta] llama3.1 (8B)", "model_name": "llama3.1:latest", "provider": "Ollama"}, {"display_name": "[google] gemma3 (4B)", "model_name": "gemma3:4b", "provider": "Ollama"}, {"display_name": "[alibaba] qwen3 (4B)", "model_name": "qwen3:4b", "provider": "Ollama"}, ] def _format_models_for_api(self, downloaded_models: List[str]) -> List[Dict[str, str]]: """Format downloaded models for API response.""" # Import OLLAMA_MODELS here to avoid circular imports from src.llm.models import OLLAMA_MODELS api_models = [] for ollama_model in OLLAMA_MODELS: if ollama_model.model_name in downloaded_models: api_models.append({ "display_name": ollama_model.display_name, "model_name": ollama_model.model_name, "provider": "Ollama" }) return api_models # Global service instance ollama_service = OllamaService() ================================================ FILE: app/backend/services/portfolio.py ================================================ from typing import Optional, List from app.backend.models.schemas import PortfolioPosition def create_portfolio(initial_cash: float, margin_requirement: float, tickers: list[str], portfolio_positions: Optional[List[PortfolioPosition]] = None) -> dict: # Initialize base portfolio structure portfolio = { "cash": initial_cash, # Initial cash amount "margin_requirement": margin_requirement, # Initial margin requirement "margin_used": 0.0, # total margin usage across all short positions "positions": { ticker: { "long": 0, # Number of shares held long "short": 0, # Number of shares held short "long_cost_basis": 0.0, # Average cost basis for long positions "short_cost_basis": 0.0, # Average price at which shares were sold short "short_margin_used": 0.0, # Dollars of margin used for this ticker's short } for ticker in tickers }, "realized_gains": { ticker: { "long": 0.0, # Realized gains from long positions "short": 0.0, # Realized gains from short positions } for ticker in tickers }, } # If portfolio positions are provided, populate them if portfolio_positions: for position in portfolio_positions: ticker = position.ticker quantity = position.quantity trade_price = position.trade_price # Ensure ticker exists in portfolio (it should from tickers list) if ticker in portfolio["positions"]: if quantity > 0: # Positive quantity means long position portfolio["positions"][ticker]["long"] = quantity portfolio["positions"][ticker]["long_cost_basis"] = trade_price elif quantity < 0: # Negative quantity means short position portfolio["positions"][ticker]["short"] = abs(quantity) portfolio["positions"][ticker]["short_cost_basis"] = trade_price # Calculate margin used for short position portfolio["positions"][ticker]["short_margin_used"] = abs(quantity) * trade_price * margin_requirement portfolio["margin_used"] += portfolio["positions"][ticker]["short_margin_used"] return portfolio ================================================ FILE: app/frontend/.eslintrc.cjs ================================================ module.exports = { root: true, env: { browser: true, es2020: true }, extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', ], ignorePatterns: ['dist', '.eslintrc.cjs'], parser: '@typescript-eslint/parser', plugins: ['react-refresh'], rules: { 'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, ], }, } ================================================ FILE: app/frontend/.github/dependabot.yml ================================================ # docs: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: 'npm' directory: '/' schedule: interval: 'daily' allow: - dependency-name: '@xyflow/react' ================================================ FILE: app/frontend/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: app/frontend/LICENSE ================================================ MIT License Copyright (c) 2023 webkid GmbH Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: app/frontend/README.md ================================================ # AI Hedge Fund - Frontend [WIP] 🚧 This project is currently a work in progress. To track progress, please get updates [here](https://x.com/virattt). This is the frontend application for the AI Hedge Fund project. It provides a web interface to interact with the AI Hedge Fund system, allowing you to visualize and control the hedge fund operations. ## Overview This frontend project is built with React and Vite, serving as the client-side component of the AI Hedge Fund system. It connects to the backend API to provide a user-friendly interface for managing the hedge fund trading system and backtester. ## Installation The project contains the minimum dependencies to get up and running, and includes eslint with additional rules to help write clean React code: ```bash npm install # or `pnpm install` or `yarn install` ``` ## Running the Application Start the application with: ```bash npm run dev ``` While the application is running, changes made to the code will be automatically reflected in the browser! ## Disclaimer This project is for **educational and research purposes only**. - Not intended for real trading or investment - No warranties or guarantees provided - Creator assumes no liability for financial losses - Consult a financial advisor for investment decisions By using this software, you agree to use it solely for learning purposes. ================================================ FILE: app/frontend/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": false, "tsx": true, "tailwind": { "config": "tailwind.config.ts", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: app/frontend/index.html ================================================ AI Hedge Fund
================================================ FILE: app/frontend/package.json ================================================ { "name": "vite-react-flow-template", "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" }, "dependencies": { "@radix-ui/react-accordion": "^1.2.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.13", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-separator": "^1.1.6", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.6", "@types/react-syntax-highlighter": "^15.5.13", "@xyflow/react": "^12.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.507.0", "next-themes": "^0.4.6", "react": "^18.2.0", "react-dom": "^18.2.0", "react-resizable-panels": "^3.0.1", "react-syntax-highlighter": "^15.6.1", "shadcn-ui": "^0.9.5", "sonner": "^2.0.5", "tailwind-merge": "^3.2.0" }, "license": "MIT", "devDependencies": { "@tailwindcss/typography": "^0.5.16", "@types/node": "^22.15.3", "@types/react": "^18.2.53", "@types/react-dom": "^18.2.18", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.21", "eslint": "^8.56.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "postcss": "^8.5.3", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", "typescript": "^5.3.3", "vite": "^5.0.12" } } ================================================ FILE: app/frontend/postcss.config.mjs ================================================ /** @type {import('postcss-load-config').Config} */ const config = { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; export default config; ================================================ FILE: app/frontend/src/App.tsx ================================================ import { Layout } from './components/layout'; import { Toaster } from './components/ui/sonner'; export default function App() { return ( <> ); } ================================================ FILE: app/frontend/src/components/Flow.tsx ================================================ import { Background, BackgroundVariant, ColorMode, Connection, Edge, EdgeChange, MarkerType, NodeChange, ReactFlow, addEdge, useEdgesState, useNodesState } from '@xyflow/react'; import { useTheme } from 'next-themes'; import { useCallback, useEffect, useRef, useState } from 'react'; import '@xyflow/react/dist/style.css'; import { useFlowContext } from '@/contexts/flow-context'; import { useEnhancedFlowActions } from '@/hooks/use-enhanced-flow-actions'; import { useFlowHistory } from '@/hooks/use-flow-history'; import { useFlowKeyboardShortcuts, useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; import { useToastManager } from '@/hooks/use-toast-manager'; import { AppNode } from '@/nodes/types'; import { edgeTypes } from '../edges'; import { nodeTypes } from '../nodes'; import { TooltipProvider } from './ui/tooltip'; type FlowProps = { className?: string; }; export function Flow({ className = '' }: FlowProps) { const { theme, resolvedTheme } = useTheme(); // Use the resolved theme for ReactFlow ColorMode const colorMode: ColorMode = resolvedTheme === 'light' ? 'light' : 'dark'; const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [isInitialized, setIsInitialized] = useState(false); const proOptions = { hideAttribution: true }; // Get flow context for flow ID const { currentFlowId } = useFlowContext(); // Get enhanced flow actions for complete state persistence const { saveCurrentFlowWithCompleteState } = useEnhancedFlowActions(); // Get toast manager const { success, error } = useToastManager(); // Initialize flow history (each flow maintains its own separate history) const { takeSnapshot, undo, redo, canUndo, canRedo, clearHistory } = useFlowHistory({ flowId: currentFlowId }); // Create debounced auto-save function const autoSaveTimeoutRef = useRef(null); const lastSavedFlowIdRef = useRef(null); const autoSave = useCallback(async (flowIdToSave?: number | null) => { // Use the provided flowId or fall back to current flow ID const targetFlowId = flowIdToSave !== undefined ? flowIdToSave : currentFlowId; // Clear any existing timeout if (autoSaveTimeoutRef.current) { clearTimeout(autoSaveTimeoutRef.current); } // Set new timeout for debounced save autoSaveTimeoutRef.current = setTimeout(async () => { // Double-check that we're still saving to the correct flow if (!targetFlowId) { return; } // If the current flow has changed since this auto-save was scheduled, skip it if (targetFlowId !== currentFlowId) { return; } try { await saveCurrentFlowWithCompleteState(); lastSavedFlowIdRef.current = targetFlowId; } catch (error) { console.error(`[Auto-save] Failed to save flow ${targetFlowId}:`, error); } }, 1000); // 1 second debounce }, [currentFlowId, saveCurrentFlowWithCompleteState]); // Enhanced onNodesChange handler with auto-save for specific change types const handleNodesChange = useCallback((changes: NodeChange[]) => { // Apply the changes first onNodesChange(changes); // Check if any of the changes should trigger auto-save const shouldAutoSave = changes.some(change => { switch (change.type) { case 'add': return true; case 'remove': return true; case 'position': // Only auto-save position changes when dragging is complete if (!change.dragging) { return true; } return false; default: return false; } }); // Trigger auto-save if needed and flow is initialized // IMPORTANT: Capture the current flow ID at the time of the change if (shouldAutoSave && isInitialized && currentFlowId) { const flowIdAtTimeOfChange = currentFlowId; autoSave(flowIdAtTimeOfChange); } }, [onNodesChange, autoSave, isInitialized, currentFlowId]); // Enhanced onEdgesChange handler with auto-save for edge removal const handleEdgesChange = useCallback((changes: EdgeChange[]) => { // Apply the changes first onEdgesChange(changes); // Check if any of the changes should trigger auto-save const shouldAutoSave = changes.some(change => { switch (change.type) { case 'remove': return true; default: return false; } }); // Trigger auto-save if needed and flow is initialized // IMPORTANT: Capture the current flow ID at the time of the change if (shouldAutoSave && isInitialized && currentFlowId) { const flowIdAtTimeOfChange = currentFlowId; autoSave(flowIdAtTimeOfChange); } }, [onEdgesChange, autoSave, isInitialized, currentFlowId]); // Cleanup timeout on unmount useEffect(() => { return () => { if (autoSaveTimeoutRef.current) { clearTimeout(autoSaveTimeoutRef.current); } }; }, []); // Cancel pending auto-saves when flow changes to prevent cross-flow saves useEffect(() => { if (autoSaveTimeoutRef.current) { clearTimeout(autoSaveTimeoutRef.current); autoSaveTimeoutRef.current = null; } }, [currentFlowId]); // Take initial snapshot when flow is initialized useEffect(() => { if (isInitialized && nodes.length === 0 && edges.length === 0) { takeSnapshot(); } }, [isInitialized, takeSnapshot, nodes.length, edges.length]); // Take snapshot when nodes or edges change (debounced) useEffect(() => { if (!isInitialized) return; const timeoutId = setTimeout(() => { takeSnapshot(); }, 500); // Debounce snapshots by 500ms return () => clearTimeout(timeoutId); }, [nodes, edges, takeSnapshot, isInitialized]); // // Auto-save when nodes or edges change (debounced with longer delay) // useEffect(() => { // if (!isInitialized) return; // const timeoutId = setTimeout(async () => { // try { // await saveCurrentFlowWithCompleteState(); // // Don't show success toast for auto-save to avoid spam // } catch (err) { // // Only show error notifications for auto-save failures // error('Auto-save failed', 'auto-save-error'); // } // }, 1000); // Debounce auto-save by 1 second (longer than undo/redo) // return () => clearTimeout(timeoutId); // }, [nodes, edges, saveCurrentFlowWithCompleteState, error, isInitialized]); // Connect keyboard shortcuts to save flow with toast useFlowKeyboardShortcuts(async () => { try { const savedFlow = await saveCurrentFlowWithCompleteState(); if (savedFlow) { success(`"${savedFlow.name}" saved!`, 'flow-save'); } else { error('Failed to save flow', 'flow-save-error'); } } catch (err) { error('Failed to save flow', 'flow-save-error'); } }); // Add undo/redo keyboard shortcuts useKeyboardShortcuts({ shortcuts: [ { key: 'z', ctrlKey: true, metaKey: true, callback: undo, preventDefault: true, }, { key: 'z', ctrlKey: true, metaKey: true, shiftKey: true, callback: redo, preventDefault: true, }, ], }); // Initialize the flow when it first renders const onInit = useCallback(() => { if (!isInitialized) { setIsInitialized(true); } }, [isInitialized]); // Connect two nodes with marker const onConnect = useCallback( (connection: Connection) => { // Create a new edge with a marker and unique ID const newEdge: Edge = { ...connection, id: `edge-${Date.now()}`, // Add unique ID markerEnd: { type: MarkerType.ArrowClosed, }, }; setEdges((eds) => addEdge(newEdge, eds)); // Auto-save new connections immediately (structural change) if (currentFlowId) { // IMPORTANT: Capture the current flow ID at the time of the change const flowIdAtTimeOfChange = currentFlowId; // Clear any pending debounced saves and save immediately if (autoSaveTimeoutRef.current) { clearTimeout(autoSaveTimeoutRef.current); } // Use setTimeout to ensure the edge is added to state first setTimeout(async () => { // Double-check that we're still saving to the correct flow if (flowIdAtTimeOfChange !== currentFlowId) { return; } try { await saveCurrentFlowWithCompleteState(); } catch (error) { console.error(`[Auto-save] Failed to save new connection for flow ${flowIdAtTimeOfChange}:`, error); } }, 100); } }, [setEdges, currentFlowId, saveCurrentFlowWithCompleteState] ); // Theme-aware background colors const backgroundStyle = { backgroundColor: 'hsl(var(--background))' }; const gridColor = resolvedTheme === 'light' ? 'hsl(var(--foreground))' : 'hsl(var(--muted-foreground))'; return (
{/* */}
); } ================================================ FILE: app/frontend/src/components/Layout.tsx ================================================ import { BottomPanel } from '@/components/panels/bottom/bottom-panel'; import { LeftSidebar } from '@/components/panels/left/left-sidebar'; import { RightSidebar } from '@/components/panels/right/right-sidebar'; import { TabBar } from '@/components/tabs/tab-bar'; import { TabContent } from '@/components/tabs/tab-content'; import { SidebarProvider } from '@/components/ui/sidebar'; import { FlowProvider, useFlowContext } from '@/contexts/flow-context'; import { LayoutProvider, useLayoutContext } from '@/contexts/layout-context'; import { TabsProvider, useTabsContext } from '@/contexts/tabs-context'; import { useLayoutKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; import { cn } from '@/lib/utils'; import { SidebarStorageService } from '@/services/sidebar-storage'; import { TabService } from '@/services/tab-service'; import { ReactFlowProvider } from '@xyflow/react'; import { ReactNode, useEffect, useState } from 'react'; import { TopBar } from './layout/top-bar'; // Create a LayoutContent component to access the FlowContext, TabsContext, and LayoutContext function LayoutContent({ children }: { children: ReactNode }) { const { reactFlowInstance } = useFlowContext(); const { openTab } = useTabsContext(); const { isBottomCollapsed, expandBottomPanel, collapseBottomPanel, toggleBottomPanel } = useLayoutContext(); // Initialize sidebar states from storage service const [isLeftCollapsed, setIsLeftCollapsed] = useState(() => SidebarStorageService.loadLeftSidebarState(false) ); const [isRightCollapsed, setIsRightCollapsed] = useState(() => SidebarStorageService.loadRightSidebarState(false) ); // Track actual sidebar widths for dynamic positioning const [leftSidebarWidth, setLeftSidebarWidth] = useState(280); const [rightSidebarWidth, setRightSidebarWidth] = useState(280); const [bottomPanelHeight, setBottomPanelHeight] = useState(300); const handleSettingsClick = () => { const tabData = TabService.createSettingsTab(); openTab(tabData); }; // Add keyboard shortcuts for toggling sidebars and fit view useLayoutKeyboardShortcuts( () => setIsRightCollapsed(!isRightCollapsed), // Cmd+I for right sidebar () => setIsLeftCollapsed(!isLeftCollapsed), // Cmd+B for left sidebar () => reactFlowInstance.fitView({ padding: 0.1, duration: 500 }), // Cmd+O for fit view // Note: undo/redo will be handled directly in the Flow component for now undefined, // undo undefined, // redo toggleBottomPanel, // Cmd+J for bottom panel handleSettingsClick, // Shift+Cmd+J for settings ); // Save sidebar states whenever they change useEffect(() => { SidebarStorageService.saveLeftSidebarState(isLeftCollapsed); }, [isLeftCollapsed]); useEffect(() => { SidebarStorageService.saveRightSidebarState(isRightCollapsed); }, [isRightCollapsed]); // Calculate tab bar and bottom panel positioning based on actual sidebar widths const getSidebarBasedStyle = () => { let left = 0; let right = 0; if (!isLeftCollapsed) { left = leftSidebarWidth; } if (!isRightCollapsed) { right = rightSidebarWidth; } return { left: `${left}px`, right: `${right}px`, }; }; // Calculate main content positioning accounting for tab bar height const getMainContentStyle = () => { const tabBarHeight = 40; // Approximate tab bar height let top = tabBarHeight; let bottom = 0; if (!isBottomCollapsed) { bottom = bottomPanelHeight; } return { top: `${top}px`, bottom: `${bottom}px`, left: '0', right: '0', width: 'auto', height: 'auto', }; }; return (
{/* VSCode-style Top Bar */} setIsLeftCollapsed(!isLeftCollapsed)} onToggleRight={() => setIsRightCollapsed(!isRightCollapsed)} onToggleBottom={toggleBottomPanel} onSettingsClick={handleSettingsClick} /> {/* Tab Bar - positioned absolutely like bottom panel */}
{/* Main content area */}
{/* Floating left sidebar */}
setIsLeftCollapsed(true)} onExpand={() => setIsLeftCollapsed(false)} onWidthChange={setLeftSidebarWidth} />
{/* Floating right sidebar */}
setIsRightCollapsed(true)} onExpand={() => setIsRightCollapsed(false)} onWidthChange={setRightSidebarWidth} />
{/* Bottom panel */}
); } interface LayoutProps { children: ReactNode; } export function Layout({ children }: LayoutProps) { return ( {children} ); } ================================================ FILE: app/frontend/src/components/custom-controls.tsx ================================================ import { ResetIcon } from '@radix-ui/react-icons'; import { ControlButton, Controls } from '@xyflow/react'; type CustomControlsProps = { onReset: () => void; }; export function CustomControls({ onReset }: CustomControlsProps) { return ( ); } ================================================ FILE: app/frontend/src/components/layout/top-bar.tsx ================================================ import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { PanelBottom, PanelLeft, PanelRight, Settings } from 'lucide-react'; interface TopBarProps { isLeftCollapsed: boolean; isRightCollapsed: boolean; isBottomCollapsed: boolean; onToggleLeft: () => void; onToggleRight: () => void; onToggleBottom: () => void; onSettingsClick: () => void; } export function TopBar({ isLeftCollapsed, isRightCollapsed, isBottomCollapsed, onToggleLeft, onToggleRight, onToggleBottom, onSettingsClick, }: TopBarProps) { return (
{/* Left Sidebar Toggle */} {/* Bottom Panel Toggle */} {/* Right Sidebar Toggle */} {/* Divider */}
{/* Settings */}
); } ================================================ FILE: app/frontend/src/components/panels/bottom/bottom-panel.tsx ================================================ import { useLayoutContext } from '@/contexts/layout-context'; import { useResizable } from '@/hooks/use-resizable'; import { cn } from '@/lib/utils'; import { FileText, X } from 'lucide-react'; import { ReactNode, useEffect } from 'react'; import { Button } from '../../ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs'; import { OutputTab } from './tabs'; interface BottomPanelProps { children?: ReactNode; isCollapsed: boolean; onCollapse: () => void; onExpand: () => void; onToggleCollapse: () => void; onHeightChange?: (height: number) => void; } export function BottomPanel({ isCollapsed, onToggleCollapse, onHeightChange, }: BottomPanelProps) { const { currentBottomTab, setBottomPanelTab } = useLayoutContext(); // Use our custom hooks for vertical resizing const { height, isDragging, elementRef, startResize } = useResizable({ defaultHeight: 300, minHeight: 200, maxHeight: window.innerHeight, side: 'bottom', }); // Notify parent component of height changes useEffect(() => { onHeightChange?.(height); }, [height, onHeightChange]); if (isCollapsed) { return null; } return (
{/* Resize handle - on the top for bottom panel */} {!isDragging && (
)} {/* Header with tabs and close button */}
Output
{/* Content area */}
); } ================================================ FILE: app/frontend/src/components/panels/bottom/tabs/backtest-output.tsx ================================================ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { cn } from '@/lib/utils'; import { MoreHorizontal } from 'lucide-react'; import { getActionColor } from './output-tab-utils'; // Component for displaying backtest progress function BacktestProgress({ agentData }: { agentData: Record }) { const backtestAgent = agentData['backtest']; if (!backtestAgent) return null; // Get the latest backtest result from the backtest results array const backtestResults = backtestAgent.backtestResults || []; const latestBacktestResult = backtestResults.length > 0 ? backtestResults[backtestResults.length - 1] : null; return ( Backtest Progress
{/* Current Status */}
Backtest Runner {backtestAgent.message || backtestAgent.status}
); } // Component for displaying backtest trading table (similar to CLI) function BacktestTradingTable({ agentData }: { agentData: Record }) { const backtestAgent = agentData['backtest']; // console.log("backtestAgent", backtestAgent); if (!backtestAgent || !backtestAgent.backtestResults) { return null; } // Get the backtest results directly from the agent data const backtestResults = backtestAgent.backtestResults || []; if (backtestResults.length === 0) { return null; } // Build table rows similar to CLI format const tableRows: any[] = []; backtestResults.forEach((backtestResult: any) => { // Add ticker rows for this period if (backtestResult.ticker_details) { backtestResult.ticker_details.forEach((ticker: any) => { tableRows.push({ type: 'ticker', date: backtestResult.date, ticker: ticker.ticker, action: ticker.action, quantity: ticker.quantity, price: ticker.price, shares_owned: ticker.shares_owned, long_shares: ticker.long_shares, short_shares: ticker.short_shares, position_value: ticker.position_value, bullish_count: ticker.bullish_count, bearish_count: ticker.bearish_count, neutral_count: ticker.neutral_count, }); }); } // Add portfolio summary row for this period tableRows.push({ type: 'summary', date: backtestResult.date, portfolio_value: backtestResult.portfolio_value, cash: backtestResult.cash, portfolio_return: backtestResult.portfolio_return, total_position_value: backtestResult.portfolio_value - backtestResult.cash, performance_metrics: backtestResult.performance_metrics, }); }); // Sort by date descending (newest first) and show only the last 50 rows to avoid performance issues const recentRows = tableRows .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) .slice(0, 50); return ( Activity
Date Ticker Action Quantity Price Shares Position Value Bullish Bearish Neutral {recentRows.map((row: any, idx: number) => { if (row.type === 'ticker') { return ( {row.date} {row.ticker} {row.action?.toUpperCase() || 'HOLD'} {row.quantity?.toLocaleString() || 0} ${row.price?.toFixed(2) || '0.00'} {row.shares_owned?.toLocaleString() || 0} ${row.position_value?.toLocaleString() || '0'} {row.bullish_count || 0} {row.bearish_count || 0} {row.neutral_count || 0} ); } })}
); } // Component for displaying backtest results function BacktestResults({ outputData }: { outputData: any }) { if (!outputData) { return null; } console.log("outputData", outputData); if (!outputData.performance_metrics) { return ( Backtest Results
Backtest completed. Performance metrics will appear here.
); } const { performance_metrics, final_portfolio, total_days } = outputData; return ( Backtest Results
{/* Performance Metrics */}

Performance Metrics

{performance_metrics.sharpe_ratio !== null && performance_metrics.sharpe_ratio !== undefined && (
Sharpe Ratio: 1 ? "text-green-500" : "text-red-500")}> {performance_metrics.sharpe_ratio.toFixed(2)}
)} {performance_metrics.sortino_ratio !== null && performance_metrics.sortino_ratio !== undefined && (
Sortino Ratio: 1 ? "text-green-500" : "text-red-500")}> {performance_metrics.sortino_ratio.toFixed(2)}
)} {performance_metrics.max_drawdown !== null && performance_metrics.max_drawdown !== undefined && (
Max Drawdown: {Math.abs(performance_metrics.max_drawdown).toFixed(2)}%
)}
{/* Portfolio Summary */}

Portfolio Summary

Total Days: {total_days}
Final Cash: ${final_portfolio.cash.toLocaleString()}
Margin Used: ${final_portfolio.margin_used.toLocaleString()}
{/* Exposure Metrics */}

Exposure Metrics

{performance_metrics.gross_exposure !== null && performance_metrics.gross_exposure !== undefined && (
Gross Exposure: ${performance_metrics.gross_exposure.toLocaleString()}
)} {performance_metrics.net_exposure !== null && performance_metrics.net_exposure !== undefined && (
Net Exposure: ${performance_metrics.net_exposure.toLocaleString()}
)} {performance_metrics.long_short_ratio !== null && performance_metrics.long_short_ratio !== undefined && (
Long/Short Ratio: {performance_metrics.long_short_ratio === Infinity || performance_metrics.long_short_ratio === null ? '∞' : performance_metrics.long_short_ratio.toFixed(2)}
)}
{/* Final Positions */} {final_portfolio.positions && (

Final Positions

Ticker Long Shares Short Shares Long Cost Basis Short Cost Basis {Object.entries(final_portfolio.positions).map(([ticker, position]: [string, any]) => ( {ticker} 0 ? "text-green-500" : "text-muted-foreground")}> {position.long} 0 ? "text-red-500" : "text-muted-foreground")}> {position.short} ${position.long_cost_basis.toFixed(2)} ${position.short_cost_basis.toFixed(2)} ))}
)}
); } // Component for displaying real-time backtest performance function BacktestPerformanceMetrics({ agentData }: { agentData: Record }) { const backtestAgent = agentData['backtest']; if (!backtestAgent || !backtestAgent.backtestResults) return null; // Get the backtest results directly from the agent data const backtestResults = backtestAgent.backtestResults || []; if (backtestResults.length === 0) return null; const firstPeriod = backtestResults[0]; const latestPeriod = backtestResults[backtestResults.length - 1]; // Calculate performance metrics const initialValue = firstPeriod.portfolio_value; const currentValue = latestPeriod.portfolio_value; const totalReturn = ((currentValue - initialValue) / initialValue) * 100; // Calculate win rate (periods with positive returns) const periodReturns = backtestResults.slice(1).map((period: any, idx: number) => { const prevPeriod = backtestResults[idx]; return ((period.portfolio_value - prevPeriod.portfolio_value) / prevPeriod.portfolio_value) * 100; }); const winningPeriods = periodReturns.filter((ret: number) => ret > 0).length; const winRate = periodReturns.length > 0 ? (winningPeriods / periodReturns.length) * 100 : 0; // Calculate max drawdown let maxDrawdown = 0; let peak = initialValue; backtestResults.forEach((period: any) => { if (period.portfolio_value > peak) { peak = period.portfolio_value; } const drawdown = ((period.portfolio_value - peak) / peak) * 100; if (drawdown < maxDrawdown) { maxDrawdown = drawdown; } }); return ( Performance
Total Return
= 0 ? "text-green-500" : "text-red-500")}> {totalReturn >= 0 ? '+' : ''}{totalReturn.toFixed(2)}%
Win Rate
{winRate.toFixed(1)}%
Max Drawdown
{Math.abs(maxDrawdown).toFixed(2)}%
Periods Traded
{backtestResults.length}
{/* Additional metrics */}
Current Value
${currentValue?.toLocaleString()}
Initial Value
${initialValue?.toLocaleString()}
P&L
= 0 ? "text-green-500" : "text-red-500")}> ${(currentValue - initialValue).toLocaleString()}
Long/Short Ratio
{latestPeriod.long_short_ratio === Infinity || latestPeriod.long_short_ratio === null ? '∞' : latestPeriod.long_short_ratio?.toFixed(2)}
); } // Main component for backtest output export function BacktestOutput({ agentData, outputData }: { agentData: Record; outputData: any; }) { return ( <> {outputData && } {agentData && agentData['backtest'] && ( )} ); } ================================================ FILE: app/frontend/src/components/panels/bottom/tabs/debug-console-tab.tsx ================================================ interface DebugConsoleTabProps { className?: string; } export function DebugConsoleTab({ className }: DebugConsoleTabProps) { return (
Debug console is ready...
); } ================================================ FILE: app/frontend/src/components/panels/bottom/tabs/index.ts ================================================ export { DebugConsoleTab } from '@/components/panels/bottom/tabs/debug-console-tab'; export { OutputTab } from '@/components/panels/bottom/tabs/output-tab'; export { ProblemsTab } from '@/components/panels/bottom/tabs/problems-tab'; export { TerminalTab } from '@/components/panels/bottom/tabs/terminal-tab'; ================================================ FILE: app/frontend/src/components/panels/bottom/tabs/output-tab-utils.ts ================================================ import { CheckCircle, Clock, MoreHorizontal, XCircle } from 'lucide-react'; // Helper function to detect if content is JSON export function isJsonString(str: string): boolean { try { const parsed = JSON.parse(str); return typeof parsed === 'object' && parsed !== null; } catch { return false; } } // Helper function to get display name for agent export function getDisplayName(agentName: string): string { // Remove _agent suffix first let name = agentName.replace("_agent", ""); // Remove ID suffix (everything after the last underscore if it looks like an ID) const lastUnderscoreIndex = name.lastIndexOf("_"); if (lastUnderscoreIndex !== -1) { const potentialId = name.substring(lastUnderscoreIndex + 1); // If the part after the last underscore looks like an ID (alphanumeric, 5+ chars), remove it if (/^[a-zA-Z0-9]{5,}$/.test(potentialId)) { name = name.substring(0, lastUnderscoreIndex); } } // Replace remaining underscores with spaces and title case return name.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase()); } // Helper function to get status icon and color export function getStatusIcon(status: string) { switch (status.toLowerCase()) { case 'complete': return { icon: CheckCircle, color: 'text-green-500' }; case 'error': return { icon: XCircle, color: 'text-red-500' }; case 'in_progress': return { icon: MoreHorizontal, color: 'text-yellow-500' }; default: return { icon: Clock, color: 'text-muted-foreground' }; } } // Helper function to get signal color export function getSignalColor(signal: string): string { switch (signal.toUpperCase()) { case 'BULLISH': return 'text-green-500'; case 'BEARISH': return 'text-red-500'; case 'NEUTRAL': return 'text-primary'; default: return 'text-muted-foreground'; } } // Helper function to get action color export function getActionColor(action: string): string { switch (action.toUpperCase()) { case 'BUY': case 'COVER': return 'text-green-500'; case 'SELL': case 'SHORT': return 'text-red-500'; case 'HOLD': return 'text-primary'; default: return 'text-muted-foreground'; } } // Helper function to sort agents in display order export function sortAgents(agents: [string, any][]): [string, any][] { return agents.sort(([agentA, dataA], [agentB, dataB]) => { // First, sort by agent type priority (Risk Management and Portfolio Management at bottom) const getPriority = (agentName: string) => { if (agentName.includes("risk_management")) return 3; if (agentName.includes("portfolio_management")) return 4; return 1; }; const priorityA = getPriority(agentA); const priorityB = getPriority(agentB); // If different priorities, sort by priority if (priorityA !== priorityB) { return priorityA - priorityB; } // If same priority, sort by timestamp (ascending - oldest first) const timestampA = dataA.timestamp ? new Date(dataA.timestamp).getTime() : 0; const timestampB = dataB.timestamp ? new Date(dataB.timestamp).getTime() : 0; if (timestampA !== timestampB) { return timestampA - timestampB; } // If no timestamp difference, sort alphabetically return agentA.localeCompare(agentB); }); } ================================================ FILE: app/frontend/src/components/panels/bottom/tabs/output-tab.tsx ================================================ import { useFlowContext } from '@/contexts/flow-context'; import { useNodeContext } from '@/contexts/node-context'; import { cn } from '@/lib/utils'; import { useEffect, useState } from 'react'; import { BacktestOutput } from './backtest-output'; import { sortAgents } from './output-tab-utils'; import { RegularOutput } from './regular-output'; interface OutputTabProps { className?: string; } export function OutputTab({ className }: OutputTabProps) { const { currentFlowId } = useFlowContext(); const { getAgentNodeDataForFlow, getOutputNodeDataForFlow } = useNodeContext(); const [updateTrigger, setUpdateTrigger] = useState(0); // Get current flow data const agentData = getAgentNodeDataForFlow(currentFlowId?.toString() || null); const outputData = getOutputNodeDataForFlow(currentFlowId?.toString() || null); // Force re-render periodically to show real-time updates useEffect(() => { const interval = setInterval(() => { setUpdateTrigger(prev => prev + 1); }, 1000); return () => clearInterval(interval); }, []); // Detect if this is a backtest run const isBacktestRun = agentData && agentData['backtest']; // Sort agents for display (exclude backtest agent from regular agent list) const sortedAgents = sortAgents(Object.entries(agentData).filter(([agentId]) => agentId !== 'backtest')); return (
{/* Render backtest output if this is a backtest run */} {isBacktestRun && ( )} {/* Render regular output if not a backtest run */} {!isBacktestRun && ( )} {/* Empty State */} {!outputData && sortedAgents.length === 0 && !isBacktestRun && (
No output to display. Run an analysis to see progress and results.
)}
); } ================================================ FILE: app/frontend/src/components/panels/bottom/tabs/problems-tab.tsx ================================================ interface ProblemsTabProps { className?: string; } export function ProblemsTab({ className }: ProblemsTabProps) { return (
No problems detected
); } ================================================ FILE: app/frontend/src/components/panels/bottom/tabs/reasoning-content.tsx ================================================ import { Copy } from 'lucide-react'; import { useState } from 'react'; import { isJsonString } from './output-tab-utils'; // Component to render reasoning content with JSON formatting and copy button export function ReasoningContent({ content }: { content: any }) { const [copySuccess, setCopySuccess] = useState(false); if (!content) return null; const contentString = typeof content === 'string' ? content : JSON.stringify(content, null, 2); const isJson = isJsonString(contentString); const copyToClipboard = () => { navigator.clipboard.writeText(contentString) .then(() => { setCopySuccess(true); setTimeout(() => setCopySuccess(false), 2000); }) .catch(err => { console.error('Failed to copy text: ', err); }); }; return (
{isJson ? (
            {contentString}
          
) : (
{contentString.split('\n').map((paragraph, idx) => (

{paragraph}

))}
)}
); } ================================================ FILE: app/frontend/src/components/panels/bottom/tabs/regular-output.tsx ================================================ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { cn } from '@/lib/utils'; import { useEffect, useState } from 'react'; import { getActionColor, getDisplayName, getSignalColor, getStatusIcon } from './output-tab-utils'; import { ReasoningContent } from './reasoning-content'; // Progress Section Component function ProgressSection({ sortedAgents }: { sortedAgents: [string, any][] }) { if (sortedAgents.length === 0) return null; return ( Progress
{sortedAgents.map(([agentId, data]) => { const { icon: StatusIcon, color } = getStatusIcon(data.status); const displayName = getDisplayName(agentId); return (
{displayName} {data.ticker && ( [{data.ticker}] )} {data.message || data.status} {data.timestamp && ( {new Date(data.timestamp).toLocaleTimeString()} )}
); })}
); } // Summary Section Component function SummarySection({ outputData }: { outputData: any }) { if (!outputData) return null; return ( Summary Ticker Action Quantity Confidence {Object.entries(outputData.decisions).map(([ticker, decision]: [string, any]) => ( {ticker} {decision.action?.toUpperCase() || 'UNKNOWN'} {decision.quantity || 0} {decision.confidence?.toFixed(1) || 0}% ))}
); } // Analysis Results Section Component function AnalysisResultsSection({ outputData }: { outputData: any }) { // Always call hooks at the top of the function const [selectedTicker, setSelectedTicker] = useState(''); // Calculate tickers (safe to do even if outputData is null) const tickers = outputData?.decisions ? Object.keys(outputData.decisions) : []; // Set default selected ticker useEffect(() => { if (tickers.length > 0 && !selectedTicker) { setSelectedTicker(tickers[0]); } }, [tickers, selectedTicker]); // Early returns after all hooks are called if (!outputData) return null; if (tickers.length === 0) return null; return ( Analysis {tickers.map((ticker) => ( {ticker} ))} {tickers.map((ticker) => { const decision = outputData.decisions![ticker]; return ( {/* Agent Analysis */} Agent Signal Confidence Reasoning {Object.entries(outputData.analyst_signals || {}) .filter(([agent, signals]: [string, any]) => ticker in signals && !agent.includes("risk_management") ) .sort(([agentA], [agentB]) => agentA.localeCompare(agentB)) .map(([agent, signals]: [string, any]) => { const signal = signals[ticker]; const signalType = signal.signal?.toUpperCase() || 'UNKNOWN'; const signalColor = getSignalColor(signalType); return ( {getDisplayName(agent)} {signalType} {signal.confidence || 0}% ); })}
{/* Trading Decision */} Property Value Action {decision.action?.toUpperCase() || 'UNKNOWN'} Quantity {decision.quantity || 0} Confidence {decision.confidence?.toFixed(1) || 0}% {decision.reasoning && ( Reasoning )}
); })}
); } // Main component for regular output export function RegularOutput({ sortedAgents, outputData }: { sortedAgents: [string, any][]; outputData: any; }) { return ( <> ); } ================================================ FILE: app/frontend/src/components/panels/bottom/tabs/terminal-tab.tsx ================================================ interface TerminalTabProps { className?: string; } export function TerminalTab({ className }: TerminalTabProps) { return (
$ Welcome to AI Hedge Fund Terminal {'\n'} Type commands here... {'\n'} $ _
); } ================================================ FILE: app/frontend/src/components/panels/left/flow-actions.tsx ================================================ import { Button } from '@/components/ui/button'; import { useFlowContext } from '@/contexts/flow-context'; import { cn } from '@/lib/utils'; import { Plus, Save } from 'lucide-react'; interface FlowActionsProps { onSave: () => Promise; onCreate: () => void; } export function FlowActions({ onSave, onCreate }: FlowActionsProps) { const { currentFlowName, isUnsaved } = useFlowContext(); return (
Flows {isUnsaved && *}
); } ================================================ FILE: app/frontend/src/components/panels/left/flow-context-menu.tsx ================================================ import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { Copy, Edit, Trash2 } from 'lucide-react'; import { useEffect, useRef } from 'react'; interface FlowContextMenuProps { isOpen: boolean; position: { x: number; y: number }; onClose: () => void; onEdit: () => void; onDuplicate: () => void; onDelete: () => void; } export function FlowContextMenu({ isOpen, position, onClose, onEdit, onDuplicate, onDelete }: FlowContextMenuProps) { const menuRef = useRef(null); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { onClose(); } }; const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { onClose(); } }; if (isOpen) { document.addEventListener('mousedown', handleClickOutside); document.addEventListener('keydown', handleEscape); } return () => { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleEscape); }; }, [isOpen, onClose]); if (!isOpen) return null; const handleAction = (action: () => void) => { action(); onClose(); }; return (
); } ================================================ FILE: app/frontend/src/components/panels/left/flow-create-dialog.tsx ================================================ import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { useToastManager } from '@/hooks/use-toast-manager'; import { flowService } from '@/services/flow-service'; import { Flow } from '@/types/flow'; import { useEffect, useState } from 'react'; interface FlowCreateDialogProps { isOpen: boolean; onClose: () => void; onFlowCreated: (flow: Flow) => void; } export function FlowCreateDialog({ isOpen, onClose, onFlowCreated }: FlowCreateDialogProps) { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [isLoading, setIsLoading] = useState(false); const { success, error } = useToastManager(); // Reset form when dialog opens useEffect(() => { if (isOpen) { setName(''); setDescription(''); } }, [isOpen]); const handleCreate = async () => { if (!name.trim()) { error('Flow name is required'); return; } setIsLoading(true); try { const newFlow = await flowService.createFlow({ name: name.trim(), description: description.trim() || undefined, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 }, }); success(`"${newFlow.name}" created!`); onFlowCreated(newFlow); onClose(); } catch (err) { console.error('Failed to create flow:', err); error('Failed to create flow'); } finally { setIsLoading(false); } }; const handleCancel = () => { setName(''); setDescription(''); onClose(); }; const handleKeyDown = (e: React.KeyboardEvent) => { // Handle Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux) if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); if (name.trim()) { handleCreate(); } } }; return ( Create New Flow Create a new flow with a custom name and description.
setName(e.target.value)} onKeyDown={handleKeyDown} placeholder="Enter flow name" className="col-span-3" autoFocus />
setDescription(e.target.value)} onKeyDown={handleKeyDown} placeholder="Enter flow description (optional)" className="col-span-3" />
); } ================================================ FILE: app/frontend/src/components/panels/left/flow-edit-dialog.tsx ================================================ import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { useTabsContext } from '@/contexts/tabs-context'; import { useToastManager } from '@/hooks/use-toast-manager'; import { flowService } from '@/services/flow-service'; import { Flow } from '@/types/flow'; import { useEffect, useState } from 'react'; interface FlowEditDialogProps { flow: Flow | null; isOpen: boolean; onClose: () => void; onFlowUpdated: () => void; } export function FlowEditDialog({ flow, isOpen, onClose, onFlowUpdated }: FlowEditDialogProps) { const [name, setName] = useState(flow?.name || ''); const [description, setDescription] = useState(flow?.description || ''); const [isLoading, setIsLoading] = useState(false); const { success, error } = useToastManager(); const { updateFlowTabTitle } = useTabsContext(); // Update form when flow changes useEffect(() => { if (flow) { setName(flow.name); setDescription(flow.description || ''); } }, [flow]); const handleSave = async () => { if (!flow || !name.trim()) { error('Flow name is required'); return; } setIsLoading(true); try { await flowService.updateFlow(flow.id, { name: name.trim(), description: description.trim() || undefined, }); // Update the tab title if it's currently open updateFlowTabTitle(flow.id, name.trim()); success(`"${name}" updated!`); onFlowUpdated(); onClose(); } catch (err) { console.error('Failed to update flow:', err); error('Failed to update flow'); } finally { setIsLoading(false); } }; const handleCancel = () => { if (flow) { setName(flow.name); setDescription(flow.description || ''); } onClose(); }; const handleKeyDown = (e: React.KeyboardEvent) => { // Handle Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux) if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); if (name.trim()) { handleSave(); } } }; return ( Edit Flow Update the name and description for your flow.
setName(e.target.value)} onKeyDown={handleKeyDown} placeholder="Enter flow name" className="col-span-3" />
setDescription(e.target.value)} onKeyDown={handleKeyDown} placeholder="Enter flow description (optional)" className="col-span-3" />
); } ================================================ FILE: app/frontend/src/components/panels/left/flow-item-group.tsx ================================================ import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { Separator } from '@/components/ui/separator'; import { Flow } from '@/types/flow'; import FlowItem from './flow-item'; interface FlowItemGroupProps { title: string; flows: Flow[]; onLoadFlow: (flow: Flow) => Promise; onDeleteFlow: (flow: Flow) => Promise; onRefresh: () => Promise; currentFlowId?: number | null; } export function FlowItemGroup({ title, flows, onLoadFlow, onDeleteFlow, onRefresh, currentFlowId }: FlowItemGroupProps) { const groupId = title.toLowerCase().replace(/\s+/g, '-'); return (
{title} ({flows.length})
{flows.map((flow, index) => (
{index < flows.length - 1 && ( )}
))}
); } ================================================ FILE: app/frontend/src/components/panels/left/flow-item.tsx ================================================ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { useFlowConnectionState } from '@/hooks/use-flow-connection'; import { cn } from '@/lib/utils'; import { flowService } from '@/services/flow-service'; import { Flow } from '@/types/flow'; import { Calendar, FileText, Layout, MoreHorizontal, Zap } from 'lucide-react'; import { useState } from 'react'; import { FlowContextMenu } from './flow-context-menu'; import { FlowEditDialog } from './flow-edit-dialog'; interface FlowItemProps { flow: Flow; onLoadFlow: (flow: Flow) => Promise; onDeleteFlow: (flow: Flow) => Promise; onRefresh: () => Promise; isActive?: boolean; } export default function FlowItem({ flow, onLoadFlow, onDeleteFlow, onRefresh, isActive = false }: FlowItemProps) { const [contextMenu, setContextMenu] = useState<{ isOpen: boolean; position: { x: number; y: number } }>({ isOpen: false, position: { x: 0, y: 0 } }); const [editDialog, setEditDialog] = useState(false); // Check if this flow has an active connection const connectionState = useFlowConnectionState(flow.id.toString()); const hasActiveConnection = connectionState && (connectionState.state === 'connecting' || connectionState.state === 'connected'); const handleLoadFlow = async () => { await onLoadFlow(flow); }; const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ isOpen: true, position: { x: e.clientX, y: e.clientY } }); }; const handleMenuClick = (e: React.MouseEvent) => { e.stopPropagation(); // Get the button's position for the menu const rect = e.currentTarget.getBoundingClientRect(); setContextMenu({ isOpen: true, position: { x: rect.right - 160, y: rect.bottom } // Offset menu to the left of the button }); }; const closeContextMenu = () => { setContextMenu(prev => ({ ...prev, isOpen: false })); }; const handleEdit = () => { setEditDialog(true); }; const handleDuplicateFlow = async () => { try { await flowService.duplicateFlow(flow.id); onRefresh(); } catch (error) { console.error('Failed to duplicate flow:', error); } }; const handleDeleteFlow = async () => { if (window.confirm(`Are you sure you want to delete "${flow.name}"?`)) { try { await onDeleteFlow(flow); } catch (error) { console.error('Failed to delete flow:', error); } } }; const formatDateTime = (dateString: string) => { return new Date(dateString).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true }); }; // Filter out "default" tag const filteredTags = flow.tags?.filter(tag => tag !== 'default') || []; return ( <>
{flow.is_template ? ( ) : ( )} {flow.name}
{/* Active connection indicator - right aligned */} {hasActiveConnection && (
Running
)}
{formatDateTime(flow.created_at)}
{filteredTags.length > 0 && (
{filteredTags.slice(0, 2).map(tag => ( {tag} ))} {filteredTags.length > 2 && ( +{filteredTags.length - 2} )}
)}
setEditDialog(false)} onFlowUpdated={onRefresh} /> ); } ================================================ FILE: app/frontend/src/components/panels/left/flow-list.tsx ================================================ import { FlowItemGroup } from '@/components/panels/left/flow-item-group'; import { SearchBox } from '@/components/panels/search-box'; import { Accordion } from '@/components/ui/accordion'; import { useTabsContext } from '@/contexts/tabs-context'; import { Flow } from '@/types/flow'; import { FolderOpen } from 'lucide-react'; interface FlowListProps { flows: Flow[]; searchQuery: string; isLoading: boolean; openGroups: string[]; filteredFlows: Flow[]; recentFlows: Flow[]; templateFlows: Flow[]; onSearchChange: (query: string) => void; onAccordionChange: (value: string[]) => void; onLoadFlow: (flow: Flow) => Promise; onDeleteFlow: (flow: Flow) => Promise; onRefresh: () => Promise; } export function FlowList({ flows, searchQuery, isLoading, openGroups, filteredFlows, recentFlows, templateFlows, onSearchChange, onAccordionChange, onLoadFlow, onDeleteFlow, onRefresh, }: FlowListProps) { const { tabs, activeTabId } = useTabsContext(); // Only consider a flow active if the current active tab is a flow tab with that flow's ID const getActiveFlowId = (): number | null => { const activeTab = tabs.find(tab => tab.id === activeTabId); // If no active tab or active tab is not a flow tab, no flow should be active if (!activeTab || activeTab.type !== 'flow') { return null; } // Return the flow ID from the active flow tab return activeTab.flow?.id || null; }; const activeFlowId = getActiveFlowId(); return (
{isLoading ? (
Loading flows...
) : ( {recentFlows.length > 0 && ( )} {templateFlows.length > 0 && ( )} )} {!isLoading && filteredFlows.length === 0 && (
{flows.length === 0 ? (
No flows saved yet
Create your first flow to get started
) : ( 'No flows match your search' )}
)}
); } ================================================ FILE: app/frontend/src/components/panels/left/left-sidebar.tsx ================================================ import { useFlowManagementTabs } from '@/hooks/use-flow-management-tabs'; import { useResizable } from '@/hooks/use-resizable'; import { cn } from '@/lib/utils'; import { ReactNode, useEffect } from 'react'; import { FlowActions } from './flow-actions'; import { FlowCreateDialog } from './flow-create-dialog'; import { FlowList } from './flow-list'; interface LeftSidebarProps { children?: ReactNode; isCollapsed: boolean; onCollapse: () => void; onExpand: () => void; onWidthChange?: (width: number) => void; } export function LeftSidebar({ isCollapsed, onWidthChange, }: LeftSidebarProps) { // Use our custom hooks const { width, isDragging, elementRef, startResize } = useResizable({ defaultWidth: 280, minWidth: 200, maxWidth: window.innerWidth * .90, side: 'left', }); // Notify parent component of width changes useEffect(() => { onWidthChange?.(width); }, [width, onWidthChange]); // Use flow management hook with tabs const { flows, searchQuery, isLoading, openGroups, createDialogOpen, filteredFlows, recentFlows, templateFlows, setSearchQuery, setCreateDialogOpen, handleAccordionChange, handleCreateNewFlow, handleFlowCreated, handleSaveCurrentFlow, handleOpenFlowInTab, handleDeleteFlow, handleRefresh, } = useFlowManagementTabs(); return (
{/* Resize handle - on the right side for left sidebar */} {!isDragging && (
)} setCreateDialogOpen(false)} onFlowCreated={handleFlowCreated} />
); } ================================================ FILE: app/frontend/src/components/panels/right/component-actions.tsx ================================================ interface ComponentActionsProps { } export function ComponentActions({ }: ComponentActionsProps) { return (
Components {/*
*/}
); } ================================================ FILE: app/frontend/src/components/panels/right/component-item-group.tsx ================================================ import ComponentItem from '@/components/panels/right/component-item'; import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { useFlowContext } from '@/contexts/flow-context'; import { ComponentGroup } from '@/data/sidebar-components'; interface ComponentItemGroupProps { group: ComponentGroup; activeItem: string | null; } export function ComponentItemGroup({ group, activeItem }: ComponentItemGroupProps) { const { name, icon: Icon, iconColor, items } = group; const { addComponentToFlow } = useFlowContext(); const handleItemClick = async (componentName: string) => { try { await addComponentToFlow(componentName); } catch (error) { console.error('Failed to add component to flow:', error); } }; return (
{name}
{items.map((item) => ( handleItemClick(item.name)} /> ))}
); } ================================================ FILE: app/frontend/src/components/panels/right/component-item.tsx ================================================ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { LucideIcon, Plus } from "lucide-react"; import { useState } from "react"; interface ComponentItemProps { icon: LucideIcon; label: string; onClick?: () => void; className?: string; isActive?: boolean; } export default function ComponentItem({ icon: Icon, label, onClick, className, isActive = false }: ComponentItemProps) { const [isHovered, setIsHovered] = useState(false); const handlePlusClick = (e: React.MouseEvent) => { e.stopPropagation(); // Prevent triggering the parent onClick if (onClick) onClick(); }; return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' && onClick) { onClick(); } }} >
{label} {/* Add button using shadcn Button */}
); } ================================================ FILE: app/frontend/src/components/panels/right/component-list.tsx ================================================ import { Accordion } from '@/components/ui/accordion'; import { ComponentGroup } from '@/data/sidebar-components'; import { SearchBox } from '../search-box'; import { ComponentItemGroup } from './component-item-group'; interface ComponentListProps { componentGroups: ComponentGroup[]; searchQuery: string; isLoading: boolean; openGroups: string[]; filteredGroups: ComponentGroup[]; activeItem: string | null; onSearchChange: (query: string) => void; onAccordionChange: (value: string[]) => void; } export function ComponentList({ componentGroups, searchQuery, isLoading, openGroups, filteredGroups, activeItem, onSearchChange, onAccordionChange, }: ComponentListProps) { return (
{isLoading ? (
Loading components...
) : ( {filteredGroups.map(group => ( ))} )} {!isLoading && filteredGroups.length === 0 && (
{componentGroups.length === 0 ? (
No components available
Components will appear here when loaded
) : ( 'No components match your search' )}
)}
); } ================================================ FILE: app/frontend/src/components/panels/right/right-sidebar.tsx ================================================ import { ComponentGroup, getComponentGroups } from '@/data/sidebar-components'; import { useComponentGroups } from '@/hooks/use-component-groups'; import { useResizable } from '@/hooks/use-resizable'; import { cn } from '@/lib/utils'; import { ReactNode, useEffect, useState } from 'react'; import { ComponentActions } from './component-actions'; import { ComponentList } from './component-list'; interface RightSidebarProps { children?: ReactNode; isCollapsed: boolean; onCollapse: () => void; onExpand: () => void; onWidthChange?: (width: number) => void; } export function RightSidebar({ isCollapsed, onWidthChange, }: RightSidebarProps) { // Use our custom hooks const { width, isDragging, elementRef, startResize } = useResizable({ defaultWidth: 280, minWidth: 200, maxWidth: window.innerWidth * .90, side: 'right', }); // Notify parent component of width changes useEffect(() => { onWidthChange?.(width); }, [width, onWidthChange]); // State for loading component groups const [componentGroups, setComponentGroups] = useState([]); const [isLoading, setIsLoading] = useState(true); // Load component groups on mount useEffect(() => { const loadComponentGroups = async () => { try { setIsLoading(true); const groups = await getComponentGroups(); setComponentGroups(groups); } catch (error) { console.error('Failed to load component groups:', error); } finally { setIsLoading(false); } }; loadComponentGroups(); }, []); const { searchQuery, setSearchQuery, activeItem, openGroups, filteredGroups, handleAccordionChange } = useComponentGroups(componentGroups); return (
{/* Resize handle - on the left side for right sidebar */} {!isDragging && (
)}
); } ================================================ FILE: app/frontend/src/components/panels/search-box.tsx ================================================ import { Button } from '@/components/ui/button'; import { Search } from 'lucide-react'; interface SearchBoxProps { value: string; onChange: (value: string) => void; placeholder?: string; } export function SearchBox({ value, onChange, placeholder = "Search components..." }: SearchBoxProps) { return (
onChange(e.target.value)} /> {value && ( )}
); } ================================================ FILE: app/frontend/src/components/settings/api-keys.tsx ================================================ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { apiKeysService } from '@/services/api-keys-api'; import { Eye, EyeOff, Key, Trash2 } from 'lucide-react'; import { useEffect, useState } from 'react'; interface ApiKey { key: string; label: string; description: string; url: string; placeholder: string; } const FINANCIAL_API_KEYS: ApiKey[] = [ { key: 'FINANCIAL_DATASETS_API_KEY', label: 'Financial Datasets API', description: 'For getting financial data to power the hedge fund', url: 'https://financialdatasets.ai/', placeholder: 'your-financial-datasets-api-key' } ]; const LLM_API_KEYS: ApiKey[] = [ { key: 'ANTHROPIC_API_KEY', label: 'Anthropic API', description: 'For Claude models (claude-4-sonnet, claude-4.1-opus, etc.)', url: 'https://anthropic.com/', placeholder: 'your-anthropic-api-key' }, { key: 'DEEPSEEK_API_KEY', label: 'DeepSeek API', description: 'For DeepSeek models (deepseek-chat, deepseek-reasoner, etc.)', url: 'https://deepseek.com/', placeholder: 'your-deepseek-api-key' }, { key: 'GROQ_API_KEY', label: 'Groq API', description: 'For Groq-hosted models (deepseek, llama3, etc.)', url: 'https://groq.com/', placeholder: 'your-groq-api-key' }, { key: 'GOOGLE_API_KEY', label: 'Google API', description: 'For Gemini models (gemini-2.5-flash, gemini-2.5-pro)', url: 'https://ai.dev/', placeholder: 'your-google-api-key' }, { key: 'OPENAI_API_KEY', label: 'OpenAI API', description: 'For OpenAI models (gpt-4o, gpt-4o-mini, etc.)', url: 'https://platform.openai.com/', placeholder: 'your-openai-api-key' }, { key: 'OPENROUTER_API_KEY', label: 'OpenRouter API', description: 'For OpenRouter models (gpt-4o, gpt-4o-mini, etc.)', url: 'https://openrouter.ai/', placeholder: 'your-openrouter-api-key' }, { key: 'GIGACHAT_API_KEY', label: 'GigaChat API', description: 'For GigaChat models (GigaChat-2-Max, etc.)', url: 'https://github.com/ai-forever/gigachat', placeholder: 'your-gigachat-api-key' } ]; export function ApiKeysSettings() { const [apiKeys, setApiKeys] = useState>({}); const [visibleKeys, setVisibleKeys] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Load API keys from backend on component mount useEffect(() => { loadApiKeys(); }, []); const loadApiKeys = async () => { try { setLoading(true); setError(null); const apiKeysSummary = await apiKeysService.getAllApiKeys(); // Load actual key values for existing keys const keysData: Record = {}; for (const summary of apiKeysSummary) { try { const fullKey = await apiKeysService.getApiKey(summary.provider); keysData[summary.provider] = fullKey.key_value; } catch (err) { console.warn(`Failed to load key for ${summary.provider}:`, err); } } setApiKeys(keysData); } catch (err) { console.error('Failed to load API keys:', err); setError('Failed to load API keys. Please try again.'); } finally { setLoading(false); } }; const handleKeyChange = async (key: string, value: string) => { // Update local state immediately for responsive UI setApiKeys(prev => ({ ...prev, [key]: value })); // Auto-save with debouncing try { if (value.trim()) { await apiKeysService.createOrUpdateApiKey({ provider: key, key_value: value.trim(), is_active: true }); } else { // If value is empty, delete the key try { await apiKeysService.deleteApiKey(key); } catch (err) { // Key might not exist, which is fine console.log(`Key ${key} not found for deletion, which is expected`); } } } catch (err) { console.error(`Failed to save API key ${key}:`, err); setError(`Failed to save ${key}. Please try again.`); } }; const toggleKeyVisibility = (key: string) => { setVisibleKeys(prev => ({ ...prev, [key]: !prev[key] })); }; const clearKey = async (key: string) => { try { await apiKeysService.deleteApiKey(key); setApiKeys(prev => { const newKeys = { ...prev }; delete newKeys[key]; return newKeys; }); } catch (err) { console.error(`Failed to delete API key ${key}:`, err); setError(`Failed to delete ${key}. Please try again.`); } }; const renderApiKeySection = (title: string, description: string, keys: ApiKey[], icon: React.ReactNode) => ( {icon} {title}

{description}

{keys.map((apiKey) => (
handleKeyChange(apiKey.key, e.target.value)} className="pr-20" />
{apiKeys[apiKey.key] && ( )}
))}
); if (loading) { return (

API Keys

Loading API keys...

Please wait while we load your API keys...
); } return (

API Keys

Configure API endpoints and authentication credentials for financial data and language models. Changes are automatically saved.

{/* Error Message */} {error && (

Error

{error}

)} {/* Financial Data API Keys */} {renderApiKeySection( 'Financial Data', 'API keys for accessing financial market data and datasets.', FINANCIAL_API_KEYS, )} {/* LLM API Keys */} {renderApiKeySection( 'Language Models', 'API keys for accessing various large language model providers.', LLM_API_KEYS, )} {/* Security Note */}

Security Note

API keys are stored securely on your local system and changes are automatically saved. Keep your API keys secure and don't share them with others.

); } ================================================ FILE: app/frontend/src/components/settings/appearance.tsx ================================================ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { cn } from '@/lib/utils'; import { Monitor, Moon, Sun } from 'lucide-react'; import { useTheme } from 'next-themes'; export function ThemeSettings() { const { theme, setTheme } = useTheme(); const themes = [ { id: 'light', name: 'Light', description: 'A clean, bright interface', icon: Sun, }, { id: 'dark', name: 'Dark', description: 'A comfortable dark interface', icon: Moon, }, { id: 'system', name: 'System', description: 'Use your system preference', icon: Monitor, }, ]; return (

Theme

Customize the look and feel of your application.

Theme

Select your preferred theme or use system setting to automatically switch between light and dark modes.

{themes.map((themeOption) => { const Icon = themeOption.icon; const isSelected = theme === themeOption.id; return ( ); })}
); } ================================================ FILE: app/frontend/src/components/settings/index.ts ================================================ export { ApiKeysSettings } from './api-keys'; export { ThemeSettings } from './appearance'; export { Models } from './models'; export { CloudModels } from './models/cloud'; export { OllamaSettings } from './models/ollama'; ================================================ FILE: app/frontend/src/components/settings/models/cloud.tsx ================================================ import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { Cloud, RefreshCw } from 'lucide-react'; import { useEffect, useState } from 'react'; interface CloudModelsProps { className?: string; } interface CloudModel { display_name: string; model_name: string; provider: string; } interface ModelProvider { name: string; models: Array<{ display_name: string; model_name: string; }>; } export function CloudModels({ className }: CloudModelsProps) { const [providers, setProviders] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const fetchProviders = async () => { setLoading(true); setError(null); try { const response = await fetch('http://localhost:8000/language-models/providers'); if (response.ok) { const data = await response.json(); setProviders(data.providers); } else { const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })); setError(`Failed to fetch providers: ${errorData.detail}`); } } catch (error) { console.error('Failed to fetch cloud model providers:', error); setError('Failed to connect to backend service'); } setLoading(false); }; useEffect(() => { fetchProviders(); }, []); // Flatten all models from all providers into a single array const allModels: CloudModel[] = providers.flatMap(provider => provider.models.map(model => ({ ...model, provider: provider.name })) ).sort((a, b) => a.provider.localeCompare(b.provider)); return (
{error && (

Error

{error}

)}

Available Models

{allModels.length} models from {providers.length} providers
{loading ? (

Loading cloud models...

) : allModels.length > 0 ? (
{allModels.map((model) => (
{model.display_name} {model.model_name !== model.display_name && ( {model.model_name} )}
{model.provider}
))}
) : ( !loading && (

No models available

) )}
); } ================================================ FILE: app/frontend/src/components/settings/models/ollama.tsx ================================================ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { cn } from '@/lib/utils'; import { AlertTriangle, Brain, CheckCircle, Download, Play, RefreshCw, Server, Square, Trash2, X } from 'lucide-react'; import { useEffect, useState } from 'react'; interface OllamaStatus { installed: boolean; running: boolean; available_models: string[]; server_url: string; error?: string; } interface RecommendedModel { display_name: string; model_name: string; provider: string; } interface ModelWithStatus extends RecommendedModel { isDownloaded: boolean; } interface DownloadProgress { status: string; percentage?: number; message?: string; phase?: string; bytes_downloaded?: number; total_bytes?: number; error?: string; } export function OllamaSettings() { const [ollamaStatus, setOllamaStatus] = useState(null); const [recommendedModels, setRecommendedModels] = useState([]); const [loading, setLoading] = useState(false); const [actionLoading, setActionLoading] = useState(null); const [error, setError] = useState(null); const [downloadProgress, setDownloadProgress] = useState>({}); const [activeDownloads, setActiveDownloads] = useState>(new Set()); const [pollIntervals, setPollIntervals] = useState>(new Set()); const [deleteConfirmation, setDeleteConfirmation] = useState<{ isOpen: boolean; modelName: string; displayName: string; }>({ isOpen: false, modelName: '', displayName: '' }); const [cancellingDownloads, setCancellingDownloads] = useState>(new Set()); const [cancelConfirmation, setCancelConfirmation] = useState<{ isOpen: boolean; modelName: string; displayName: string; }>({ isOpen: false, modelName: '', displayName: '' }); const fetchOllamaStatus = async () => { try { const response = await fetch('http://localhost:8000/ollama/status'); if (response.ok) { const status = await response.json(); setOllamaStatus(status); setError(null); } else { const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })); setError(`Failed to get status: ${errorData.detail}`); } } catch (error) { console.error('Failed to fetch Ollama status:', error); setError('Failed to connect to backend service'); } }; const fetchRecommendedModels = async () => { try { const response = await fetch('http://localhost:8000/ollama/models/recommended'); if (response.ok) { const models = await response.json(); setRecommendedModels(models); } else { console.error('Failed to fetch recommended models'); } } catch (error) { console.error('Failed to fetch recommended models:', error); } }; const startOllamaServer = async () => { setActionLoading('start-server'); setError(null); try { const response = await fetch('http://localhost:8000/ollama/start', { method: 'POST', }); if (response.ok) { await fetchOllamaStatus(); } else { const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })); setError(`Failed to start server: ${errorData.detail}`); } } catch (error) { console.error('Failed to start Ollama server:', error); setError('Failed to start Ollama server'); } setActionLoading(null); }; const stopOllamaServer = async () => { setActionLoading('stop-server'); setError(null); try { const response = await fetch('http://localhost:8000/ollama/stop', { method: 'POST', }); if (response.ok) { await fetchOllamaStatus(); } else { const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })); setError(`Failed to stop server: ${errorData.detail}`); } } catch (error) { console.error('Failed to stop Ollama server:', error); setError('Failed to stop Ollama server'); } setActionLoading(null); }; const downloadModelWithProgress = async (modelName: string) => { setError(null); setActiveDownloads(prev => new Set(prev).add(modelName)); setDownloadProgress(prev => ({ ...prev, [modelName]: { status: 'starting', percentage: 0, message: 'Initializing download...' } })); try { // Make a POST request to the progress endpoint which returns a streaming response const response = await fetch('http://localhost:8000/ollama/models/download/progress', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ model_name: modelName }), }); if (!response.ok) { const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })); setError(`Failed to start download for ${modelName}: ${errorData.detail}`); setActiveDownloads(prev => { const newSet = new Set(prev); newSet.delete(modelName); return newSet; }); return; } // Read the streaming response const reader = response.body?.getReader(); const decoder = new TextDecoder(); if (reader) { try { while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { try { const jsonData = line.slice(6).trim(); if (jsonData) { const data = JSON.parse(jsonData); setDownloadProgress(prev => ({ ...prev, [modelName]: data })); // Check if download is complete or failed if (data.status === 'completed') { setActiveDownloads(prev => { const newSet = new Set(prev); newSet.delete(modelName); return newSet; }); // Immediately clean up progress display for completed downloads setDownloadProgress(prev => { const newProgress = { ...prev }; delete newProgress[modelName]; return newProgress; }); // Refresh status to show the new model with retry logic const refreshWithRetry = async (attempts = 0) => { try { const response = await fetch('http://localhost:8000/ollama/status'); if (response.ok) { const status = await response.json(); setOllamaStatus(status); setError(null); // Check if the model is now in the available models list if (attempts < 5 && status && !status.available_models.includes(modelName)) { // Wait a bit longer and try again setTimeout(() => refreshWithRetry(attempts + 1), 2000); } } else if (attempts < 5) { // If fetch failed, retry setTimeout(() => refreshWithRetry(attempts + 1), 2000); } } catch (error) { console.error('Failed to refresh status:', error); if (attempts < 5) { setTimeout(() => refreshWithRetry(attempts + 1), 2000); } } }; setTimeout(() => refreshWithRetry(), 2000); return; // Exit the function } else if (data.status === 'error' || data.status === 'cancelled') { setActiveDownloads(prev => { const newSet = new Set(prev); newSet.delete(modelName); return newSet; }); if (data.status === 'error') { setError(`Download failed for ${modelName}: ${data.message || data.error}`); } // Clean up progress display after 3 seconds for errors/cancellations setTimeout(() => { setDownloadProgress(prev => { const newProgress = { ...prev }; delete newProgress[modelName]; return newProgress; }); }, 3000); return; // Exit the function } } } catch (e) { console.error('Error parsing progress data:', e, 'Line:', line); } } } } } finally { reader.releaseLock(); } } } catch (error) { console.error('Failed to download model with progress:', error); setError(`Failed to download ${modelName}: ${error instanceof Error ? error.message : 'Unknown error'}`); setActiveDownloads(prev => { const newSet = new Set(prev); newSet.delete(modelName); return newSet; }); } }; const performCancelDownload = async (modelName: string) => { setError(null); setCancellingDownloads(prev => new Set(prev).add(modelName)); try { // Call the backend to cancel the download const response = await fetch(`http://localhost:8000/ollama/models/download/${encodeURIComponent(modelName)}`, { method: 'DELETE', }); if (response.ok) { console.log(`Successfully cancelled download for ${modelName}`); } else { const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })); console.warn(`Failed to cancel download for ${modelName}: ${errorData.detail}`); // Don't show error to user since the UI cleanup will still happen } } catch (error) { console.error('Failed to cancel download:', error); // Don't show error to user since the UI cleanup will still happen } // Always clean up the UI state regardless of backend response setActiveDownloads(prev => { const newSet = new Set(prev); newSet.delete(modelName); return newSet; }); setDownloadProgress(prev => { const newProgress = { ...prev }; delete newProgress[modelName]; return newProgress; }); setCancellingDownloads(prev => { const newSet = new Set(prev); newSet.delete(modelName); return newSet; }); console.log(`Cancelled download tracking for ${modelName}`); }; const cancelDownload = (modelName: string) => { const displayName = recommendedModels.find(m => m.model_name === modelName)?.display_name || modelName; setCancelConfirmation({ isOpen: true, modelName, displayName }); }; const deleteModel = async (modelName: string) => { setActionLoading(`delete-${modelName}`); setError(null); try { const response = await fetch(`http://localhost:8000/ollama/models/${encodeURIComponent(modelName)}`, { method: 'DELETE', }); if (response.ok) { await fetchOllamaStatus(); } else { const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })); setError(`Failed to delete ${modelName}: ${errorData.detail}`); } } catch (error) { console.error('Failed to delete model:', error); setError(`Failed to delete ${modelName}`); } setActionLoading(null); }; const confirmDeleteModel = async () => { const { modelName } = deleteConfirmation; setDeleteConfirmation({ isOpen: false, modelName: '', displayName: '' }); await deleteModel(modelName); }; const cancelDeleteModel = () => { setDeleteConfirmation({ isOpen: false, modelName: '', displayName: '' }); }; const confirmCancelDownload = async () => { const { modelName } = cancelConfirmation; setCancelConfirmation({ isOpen: false, modelName: '', displayName: '' }); await performCancelDownload(modelName); }; const cancelCancelDownload = () => { setCancelConfirmation({ isOpen: false, modelName: '', displayName: '' }); }; const refreshStatus = async () => { setLoading(true); setError(null); await Promise.all([fetchOllamaStatus(), fetchRecommendedModels()]); setLoading(false); }; const checkForActiveDownloads = async () => { // Check if Ollama is running if (!ollamaStatus?.running) return; try { // Get all active downloads in one call instead of checking each model individually const response = await fetch('http://localhost:8000/ollama/models/downloads/active'); if (response.ok) { const activeDownloads = await response.json(); // Update state with any active downloads found (only downloading/starting status) Object.entries(activeDownloads).forEach(([modelName, progress]) => { const progressData = progress as DownloadProgress; // Only add truly active downloads to avoid showing completed ones on refresh if (progressData.status === 'downloading' || progressData.status === 'starting') { setActiveDownloads(prev => new Set(prev).add(modelName)); setDownloadProgress(prev => ({ ...prev, [modelName]: progressData })); // Start monitoring this download reconnectToDownload(modelName); } }); } } catch (error) { // Ignore errors - probably no active downloads or server not available console.debug('No active downloads found or error checking:', error); } }; const reconnectToDownload = async (modelName: string) => { // Don't reconnect if we're already tracking this download if (activeDownloads.has(modelName)) { console.debug(`Already tracking download for ${modelName}`); return; } console.log(`Monitoring existing download for ${modelName}`); // Poll for progress updates instead of starting a new stream const pollProgress = async () => { try { // Check all active downloads instead of individual model const response = await fetch('http://localhost:8000/ollama/models/downloads/active'); if (response.ok) { const activeDownloads = await response.json(); const progress = activeDownloads[modelName]; if (progress) { setDownloadProgress(prev => ({ ...prev, [modelName]: progress })); // Check if download is complete or failed if (progress.status === 'completed') { setActiveDownloads(prev => { const newSet = new Set(prev); newSet.delete(modelName); return newSet; }); // Immediately clean up progress display for completed downloads setDownloadProgress(prev => { const newProgress = { ...prev }; delete newProgress[modelName]; return newProgress; }); // Refresh status to show the new model with retry logic const refreshWithRetry = async (attempts = 0) => { try { const response = await fetch('http://localhost:8000/ollama/status'); if (response.ok) { const status = await response.json(); setOllamaStatus(status); setError(null); // Check if the model is now in the available models list if (attempts < 5 && status && !status.available_models.includes(modelName)) { // Wait a bit longer and try again setTimeout(() => refreshWithRetry(attempts + 1), 2000); } } else if (attempts < 5) { // If fetch failed, retry setTimeout(() => refreshWithRetry(attempts + 1), 2000); } } catch (error) { console.error('Failed to refresh status:', error); if (attempts < 5) { setTimeout(() => refreshWithRetry(attempts + 1), 2000); } } }; setTimeout(() => refreshWithRetry(), 2000); return false; // Stop polling } else if (progress.status === 'error' || progress.status === 'cancelled') { setActiveDownloads(prev => { const newSet = new Set(prev); newSet.delete(modelName); return newSet; }); if (progress.status === 'error') { setError(`Download failed for ${modelName}: ${progress.message || progress.error}`); } // Clean up progress display after 3 seconds for errors/cancellations setTimeout(() => { setDownloadProgress(prev => { const newProgress = { ...prev }; delete newProgress[modelName]; return newProgress; }); }, 3000); return false; // Stop polling } return true; // Continue polling } else { // Model not in active downloads, remove from tracking setActiveDownloads(prev => { const newSet = new Set(prev); newSet.delete(modelName); return newSet; }); return false; // Stop polling } } else { // Error getting active downloads, stop polling console.error(`Error getting active downloads: ${response.status}`); return false; // Stop polling } } catch (error) { console.error(`Error polling progress for ${modelName}:`, error); return false; // Stop polling on error } }; // Start polling every 2 seconds const pollInterval = setInterval(async () => { const shouldContinue = await pollProgress(); if (!shouldContinue) { clearInterval(pollInterval); setPollIntervals(prev => { const newSet = new Set(prev); newSet.delete(pollInterval); return newSet; }); } }, 2000); // Track the interval for cleanup setPollIntervals(prev => new Set(prev).add(pollInterval)); // Do an initial poll const shouldContinue = await pollProgress(); if (!shouldContinue) { clearInterval(pollInterval); setPollIntervals(prev => { const newSet = new Set(prev); newSet.delete(pollInterval); return newSet; }); } }; useEffect(() => { refreshStatus(); }, []); // Check for active downloads after we have status and models data useEffect(() => { if (ollamaStatus?.running && recommendedModels.length > 0) { checkForActiveDownloads(); } }, [ollamaStatus?.running, recommendedModels.length]); // Only depend on running status and whether we have models // Cleanup polling intervals on unmount useEffect(() => { return () => { pollIntervals.forEach(interval => clearInterval(interval)); }; }, [pollIntervals]); const getStatusIcon = () => { if (!ollamaStatus) return ; if (!ollamaStatus.installed) return ; if (!ollamaStatus.running) return ; return ; }; const getStatusText = () => { if (!ollamaStatus) return "Checking..."; if (!ollamaStatus.installed) return "Not Installed"; if (!ollamaStatus.running) return "Not Running"; return "Running"; }; const getStatusColor = (): "secondary" | "destructive" | "outline" | "warning" | "success" | null | undefined => { return "secondary"; }; const formatBytes = (bytes: number): string => { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; // Create a unified list of all models (downloaded first, then available for download) const allModels: ModelWithStatus[] = []; // Add downloaded models first (sorted alphabetically) if (ollamaStatus?.available_models) { const sortedDownloaded = [...ollamaStatus.available_models].sort(); sortedDownloaded.forEach(modelName => { // Try to find the model in recommended list for display name const recommendedModel = recommendedModels.find(m => m.model_name === modelName); allModels.push({ model_name: modelName, display_name: recommendedModel?.display_name || modelName, provider: 'Ollama', isDownloaded: true }); }); } // Add non-downloaded recommended models (sorted alphabetically by display name) // Exclude models that are already downloaded OR currently being downloaded const nonDownloadedModels = recommendedModels .filter(model => !ollamaStatus?.available_models?.includes(model.model_name) && !activeDownloads.has(model.model_name) ) .sort((a, b) => a.display_name.localeCompare(b.display_name)) .map(model => ({ ...model, isDownloaded: false })); allModels.push(...nonDownloadedModels); return (

Ollama

Manage local AI models with Ollama for enhanced privacy and performance.

{getStatusIcon()} {getStatusText()}
{error && (

Error

{error}

)} {!ollamaStatus?.installed && (

Ollama Not Installed

Install Ollama to use local AI models. Visit{' '} ollama.com {' '} to download and install.

)} {ollamaStatus?.installed && !ollamaStatus.running && (

Ollama Server

Ollama is installed but not currently running.

)} {ollamaStatus?.running && (
Ollama Server Running

Server available at {ollamaStatus.server_url}

)} {ollamaStatus?.running && (

Available Models

{ollamaStatus.available_models.length} downloaded
{/* Show active downloads */} {Object.entries(downloadProgress).map(([modelName, progress]) => (
{recommendedModels.find(m => m.model_name === modelName)?.display_name || modelName} {progress.status === 'downloading' && 'Downloading'} {progress.status === 'completed' && 'Completed'} {progress.status === 'error' && 'Failed'} {progress.status === 'cancelled' && 'Cancelled'} {!['downloading', 'completed', 'error', 'cancelled'].includes(progress.status) && progress.status}
{/* Progress bar */}
{progress.phase || progress.status} {progress.percentage ? `${progress.percentage.toFixed(1)}%` : '...'}
{progress.bytes_downloaded && progress.total_bytes && (
{formatBytes(progress.bytes_downloaded)} / {formatBytes(progress.total_bytes)}
)} {progress.message && (
{progress.message}
)}
))} {allModels.length > 0 ? (
{allModels.map((model) => (
{model.display_name} {model.model_name !== model.display_name && ( {model.model_name} )}
{model.isDownloaded && ( <> )} {!model.isDownloaded && !activeDownloads.has(model.model_name) && ( <> )}
))}
) : (

No models available

)}
)} {/* Delete Confirmation Dialog */} { if (!open) cancelDeleteModel(); }}> Delete Model Are you sure you want to delete {deleteConfirmation.displayName}?
Model: {deleteConfirmation.modelName}
This action cannot be undone. You will need to download the model again to use it.
{/* Cancel Download Confirmation Dialog */} { if (!open) cancelCancelDownload(); }}> Cancel Download Are you sure you want to cancel the download of {cancelConfirmation.displayName}?
Model: {cancelConfirmation.modelName}
Any progress will be lost and you'll need to start the download again.
); } ================================================ FILE: app/frontend/src/components/settings/models.tsx ================================================ import { cn } from '@/lib/utils'; import { Cloud, Server } from 'lucide-react'; import { useState } from 'react'; import { CloudModels } from './models/cloud'; import { OllamaSettings } from './models/ollama'; interface ModelsProps { className?: string; } interface ModelSection { id: string; label: string; icon: React.ComponentType<{ className?: string }>; description: string; component: React.ComponentType; } export function Models({ className }: ModelsProps) { const [selectedSection, setSelectedSection] = useState('cloud'); const modelSections: ModelSection[] = [ { id: 'cloud', label: 'Cloud', icon: Cloud, description: 'API-based models from cloud providers', component: CloudModels, }, { id: 'local', label: 'Ollama', icon: Server, description: 'Ollama models running locally on your machine', component: OllamaSettings, }, ]; const renderContent = () => { const section = modelSections.find(s => s.id === selectedSection); if (!section) return null; const Component = section.component; return ; }; return (

Models

Manage your AI models from local and cloud providers.

{/* Model Type Navigation */}
{modelSections.map((section) => { const Icon = section.icon; const isSelected = selectedSection === section.id; const isDisabled = false; // Enable all tabs now that cloud models is functional return ( ); })}
{/* Content Area */}
{renderContent()}
); } ================================================ FILE: app/frontend/src/components/settings/settings.tsx ================================================ import { cn } from '@/lib/utils'; import { CubeIcon } from '@radix-ui/react-icons'; import { Key, Palette } from 'lucide-react'; import { useState } from 'react'; import { ApiKeysSettings, Models } from './'; import { ThemeSettings } from './appearance'; interface SettingsProps { className?: string; } interface SettingsNavItem { id: string; label: string; icon: React.ComponentType<{ className?: string }>; description?: string; } export function Settings({ className }: SettingsProps) { const [selectedSection, setSelectedSection] = useState('api'); const navigationItems: SettingsNavItem[] = [ { id: 'api', label: 'API Keys', icon: Key, description: 'API endpoints and authentication', }, { id: 'models', label: 'Models', icon: CubeIcon, description: 'Local and cloud AI models', }, { id: 'theme', label: 'Theme', icon: Palette, description: 'Theme and display preferences', }, ]; const renderContent = () => { switch (selectedSection) { case 'models': return ; case 'theme': return ; case 'api': return ; default: return ; } }; return (
{/* Left Navigation Pane */}

Settings

{/* Right Content Pane */}
{renderContent()}
); } ================================================ FILE: app/frontend/src/components/tabs/flow-tab-content.tsx ================================================ import { Flow } from '@/components/Flow'; import { useFlowContext } from '@/contexts/flow-context'; import { useTabsContext } from '@/contexts/tabs-context'; import { setNodeInternalState, setCurrentFlowId as setNodeStateFlowId } from '@/hooks/use-node-state'; import { cn } from '@/lib/utils'; import { flowService } from '@/services/flow-service'; import { Flow as FlowType } from '@/types/flow'; import { useEffect } from 'react'; // Import the flow connection manager to check if flow is actively running interface FlowTabContentProps { flow: FlowType; className?: string; } export function FlowTabContent({ flow, className }: FlowTabContentProps) { const { loadFlow } = useFlowContext(); const { activeTabId } = useTabsContext(); // Enhanced load function that restores both use-node-state and node context data const loadFlowWithCompleteState = async (flowToLoad: FlowType) => { try { const flowId = flowToLoad.id.toString(); // First, set the flow ID for node state isolation setNodeStateFlowId(flowId); // DO NOT clear configuration state when switching tabs - useNodeState handles flow isolation automatically // DO NOT reset runtime data when switching tabs - preserve all runtime state // Runtime data should only be reset when explicitly starting a new run via the Play button console.log(`[FlowTabContent] Loading flow ${flowId}, preserving all state (configuration + runtime)`); // Load the flow using the basic context function (handles React Flow state) await loadFlow(flowToLoad); // Then restore internal states for each node (use-node-state data) if (flowToLoad.nodes) { flowToLoad.nodes.forEach((node: any) => { if (node.data?.internal_state) { setNodeInternalState(node.id, node.data.internal_state); } }); } // NOTE: We intentionally do NOT restore nodeContextData here // Runtime execution data (messages, analysis, agent status) should start fresh // Only configuration data (tickers, model selections) is restored above } catch (error) { console.error('Failed to load flow with complete state:', error); throw error; } }; // Fetch the latest flow state when this tab becomes active useEffect(() => { const isThisTabActive = activeTabId === `flow-${flow.id}`; if (isThisTabActive) { const fetchAndLoadFlow = async () => { try { // Fetch the latest flow data from the backend const latestFlow = await flowService.getFlow(flow.id); // Load the fresh flow data with complete state restoration await loadFlowWithCompleteState(latestFlow); } catch (error) { console.error('Failed to fetch latest flow state:', error); // Fallback to loading the cached flow data with complete state restoration await loadFlowWithCompleteState(flow); } }; fetchAndLoadFlow(); } }, [activeTabId, flow.id, flow, loadFlow]); return (
); } ================================================ FILE: app/frontend/src/components/tabs/tab-bar.tsx ================================================ import { Button } from '@/components/ui/button'; import { useTabsContext } from '@/contexts/tabs-context'; import { cn } from '@/lib/utils'; import { FileText, Layout, Settings, X } from 'lucide-react'; import { ReactNode, useState } from 'react'; interface TabBarProps { className?: string; } // Get icon for tab type const getTabIcon = (type: string): ReactNode => { switch (type) { case 'flow': return ; case 'settings': return ; default: return ; } }; export function TabBar({ className }: TabBarProps) { const { tabs, activeTabId, setActiveTab, closeTab, reorderTabs } = useTabsContext(); const [draggedIndex, setDraggedIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); if (tabs.length === 0) { return null; } const handleDragStart = (e: React.DragEvent, index: number) => { setDraggedIndex(index); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/html', ''); // Required for some browsers }; const handleDragOver = (e: React.DragEvent, index: number) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (draggedIndex !== null && draggedIndex !== index) { setDragOverIndex(index); } }; const handleDragLeave = () => { setDragOverIndex(null); }; const handleDrop = (e: React.DragEvent, dropIndex: number) => { e.preventDefault(); if (draggedIndex !== null && draggedIndex !== dropIndex) { reorderTabs(draggedIndex, dropIndex); } setDraggedIndex(null); setDragOverIndex(null); }; const handleDragEnd = () => { setDraggedIndex(null); setDragOverIndex(null); }; return (
{tabs.map((tab, index) => (
handleDragStart(e, index)} onDragOver={(e) => handleDragOver(e, index)} onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, index)} onDragEnd={handleDragEnd} className={cn( "group relative flex items-center gap-2 px-4 py-2.5 cursor-pointer transition-all duration-150 min-w-0 max-w-52 select-none last:border-r-0", // Active tab styling - VSCode style activeTabId === tab.id ? "bg-panel before:absolute before:bottom-0 before:left-0 before:right-0 before:h-0.5 before:content-['']" : "bg-panel hover:bg-[var(--tab-hover-background)]", // Drag states draggedIndex === index && "opacity-60 scale-[0.98]", dragOverIndex === index && "ring-1 ring-[var(--tab-accent)]/30", "hover:cursor-grab active:cursor-grabbing" )} style={{ borderRight: `1px solid var(--tab-border)`, color: activeTabId === tab.id ? 'var(--tab-active-text)' : 'var(--tab-inactive-text)', backgroundColor: dragOverIndex === index ? 'var(--tab-hover-background)' : undefined, }} onMouseEnter={(e) => { if (activeTabId !== tab.id) { e.currentTarget.style.color = 'var(--tab-hover-text)'; } }} onMouseLeave={(e) => { if (activeTabId !== tab.id) { e.currentTarget.style.color = 'var(--tab-inactive-text)'; } }} onClick={() => setActiveTab(tab.id)} > {/* Active tab accent bar */} {activeTabId === tab.id && (
)} {/* Tab Icon */}
{getTabIcon(tab.type)}
{/* Tab Title */} {tab.title} {/* Close Button */} {/* Modified indicator dot for unsaved changes - VSCode style */} {/* You can add this when you implement unsaved changes tracking */} {/*
*/}
))}
); } ================================================ FILE: app/frontend/src/components/tabs/tab-content.tsx ================================================ import { useTabsContext } from '@/contexts/tabs-context'; import { cn } from '@/lib/utils'; import { TabService } from '@/services/tab-service'; import { FileText, FolderOpen } from 'lucide-react'; import { useEffect } from 'react'; interface TabContentProps { className?: string; } export function TabContent({ className }: TabContentProps) { const { tabs, activeTabId, openTab } = useTabsContext(); const activeTab = tabs.find(tab => tab.id === activeTabId); // Restore content for tabs that don't have it (from localStorage restoration) useEffect(() => { if (activeTab && !activeTab.content) { try { const restoredTab = TabService.restoreTab({ type: activeTab.type, title: activeTab.title, flow: activeTab.flow, metadata: activeTab.metadata, }); // Update the tab with restored content openTab({ id: activeTab.id, type: restoredTab.type, title: restoredTab.title, content: restoredTab.content, flow: restoredTab.flow, metadata: restoredTab.metadata, }); } catch (error) { console.error('Failed to restore tab content:', error); } } }, [activeTab, openTab]); if (!activeTab) { return (
Welcome to the AI Hedge Fund
Create a flow from the left sidebar (⌘B) to open it in a tab, or open settings (⌘,) to configure your preferences.
Flows now open in tabs
); } // Show loading state if content is being restored if (!activeTab.content) { return (
Loading {activeTab.title}...
); } return (
{activeTab.content}
); } ================================================ FILE: app/frontend/src/components/ui/accordion.tsx ================================================ import * as React from "react" import * as AccordionPrimitive from "@radix-ui/react-accordion" import { ChevronDown } from "lucide-react" import { cn } from "@/lib/utils" const Accordion = AccordionPrimitive.Root const AccordionItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) AccordionItem.displayName = "AccordionItem" const AccordionTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( svg]:rotate-180", className )} {...props} > {children} )) AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName const AccordionContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => (
{children}
)) AccordionContent.displayName = AccordionPrimitive.Content.displayName export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } ================================================ FILE: app/frontend/src/components/ui/badge.tsx ================================================ import { cva, type VariantProps } from "class-variance-authority" import * as React from "react" import { cn } from "@/lib/utils" const badgeVariants = cva( "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", { variants: { variant: { secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", destructive: "border-transparent bg-red-500 text-destructive-foreground shadow hover:bg-destructive/80", warning: "border-transparent bg-yellow-500 text-primary shadow hover:bg-yellow-500/80", success: "border-transparent bg-blue-500 text-primary shadow hover:bg-blue-500/80", outline: "text-foreground", }, } } ) export interface BadgeProps extends React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { return (
) } export { Badge, badgeVariants } ================================================ FILE: app/frontend/src/components/ui/button.tsx ================================================ import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" import * as React from "react" import { cn } from "@/lib/utils" const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2", sm: "h-8 rounded-md px-3 text-xs", lg: "h-10 rounded-md px-8", icon: "h-9 w-9", }, }, defaultVariants: { variant: "default", size: "default", }, } ) export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean } const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button" return ( ) } ) Button.displayName = "Button" export { Button, buttonVariants } ================================================ FILE: app/frontend/src/components/ui/card.tsx ================================================ import * as React from "react" import { cn } from "@/lib/utils" const Card = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) Card.displayName = "Card" const CardHeader = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) CardHeader.displayName = "CardHeader" const CardTitle = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) CardTitle.displayName = "CardTitle" const CardDescription = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) CardDescription.displayName = "CardDescription" const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) CardContent.displayName = "CardContent" const CardFooter = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) CardFooter.displayName = "CardFooter" export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } ================================================ FILE: app/frontend/src/components/ui/checkbox.tsx ================================================ import * as React from "react" import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import { Check } from "lucide-react" import { cn } from "@/lib/utils" const Checkbox = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) Checkbox.displayName = CheckboxPrimitive.Root.displayName export { Checkbox } ================================================ FILE: app/frontend/src/components/ui/command.tsx ================================================ import { type DialogProps } from "@radix-ui/react-dialog" import { Command as CommandPrimitive } from "cmdk" import { Search } from "lucide-react" import * as React from "react" import { Dialog, DialogContent } from "@/components/ui/dialog" import { cn } from "@/lib/utils" const Command = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) Command.displayName = CommandPrimitive.displayName const CommandDialog = ({ children, ...props }: DialogProps) => { return ( {children} ) } const CommandInput = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => (
)) CommandInput.displayName = CommandPrimitive.Input.displayName const CommandList = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) CommandList.displayName = CommandPrimitive.List.displayName const CommandEmpty = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >((props, ref) => ( )) CommandEmpty.displayName = CommandPrimitive.Empty.displayName const CommandGroup = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) CommandGroup.displayName = CommandPrimitive.Group.displayName const CommandSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) CommandSeparator.displayName = CommandPrimitive.Separator.displayName const CommandItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) CommandItem.displayName = CommandPrimitive.Item.displayName const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { return ( ) } CommandShortcut.displayName = "CommandShortcut" export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut } ================================================ FILE: app/frontend/src/components/ui/dialog.tsx ================================================ import * as DialogPrimitive from "@radix-ui/react-dialog" import { X } from "lucide-react" import * as React from "react" import { cn } from "@/lib/utils" const Dialog = DialogPrimitive.Root const DialogTrigger = DialogPrimitive.Trigger const DialogPortal = DialogPrimitive.Portal const DialogClose = DialogPrimitive.Close const DialogOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) DialogOverlay.displayName = DialogPrimitive.Overlay.displayName const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( {children} Close )) DialogContent.displayName = DialogPrimitive.Content.displayName const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
) DialogHeader.displayName = "DialogHeader" const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
) DialogFooter.displayName = "DialogFooter" const DialogTitle = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) DialogTitle.displayName = DialogPrimitive.Title.displayName const DialogDescription = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) DialogDescription.displayName = DialogPrimitive.Description.displayName export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } ================================================ FILE: app/frontend/src/components/ui/input.tsx ================================================ import * as React from "react" import { cn } from "@/lib/utils" const Input = React.forwardRef>( ({ className, type, ...props }, ref) => { return ( ) } ) Input.displayName = "Input" export { Input } ================================================ FILE: app/frontend/src/components/ui/llm-selector.tsx ================================================ import { ChevronsUpDown } from "lucide-react" import * as React from "react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import { type LanguageModel } from "@/data/models" import { cn } from "@/lib/utils" interface ModelSelectorProps { models: LanguageModel[]; value: string; onChange: (item: LanguageModel | null) => void; placeholder?: string; } export function ModelSelector({ models, value, onChange, placeholder = "Select a model..." }: ModelSelectorProps) { const [open, setOpen] = React.useState(false) return ( No model found. {models.map((model) => ( { if (currentValue === value) { onChange(null); } else { const selectedModel = models.find(m => m.model_name === currentValue); if (selectedModel) { onChange(selectedModel); } } setOpen(false); }} >
{model.display_name} {model.model_name}
{model.provider}
))}
) } ================================================ FILE: app/frontend/src/components/ui/popover.tsx ================================================ "use client" import * as PopoverPrimitive from "@radix-ui/react-popover" import * as React from "react" import { cn } from "@/lib/utils" const Popover = PopoverPrimitive.Root const PopoverTrigger = PopoverPrimitive.Trigger const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( )) PopoverContent.displayName = PopoverPrimitive.Content.displayName export { Popover, PopoverContent, PopoverTrigger } ================================================ FILE: app/frontend/src/components/ui/resizable.tsx ================================================ import { GripVertical } from "lucide-react" import * as ResizablePrimitive from "react-resizable-panels" import { cn } from "@/lib/utils" const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps) => ( ) const ResizablePanel = ResizablePrimitive.Panel const ResizableHandle = ({ withHandle, className, ...props }: React.ComponentProps & { withHandle?: boolean }) => ( div]:rotate-90", className )} {...props} > {withHandle && (
)}
) export { ResizablePanelGroup, ResizablePanel, ResizableHandle } ================================================ FILE: app/frontend/src/components/ui/separator.tsx ================================================ import * as React from "react" import * as SeparatorPrimitive from "@radix-ui/react-separator" import { cn } from "@/lib/utils" const Separator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >( ( { className, orientation = "horizontal", decorative = true, ...props }, ref ) => ( ) ) Separator.displayName = SeparatorPrimitive.Root.displayName export { Separator } ================================================ FILE: app/frontend/src/components/ui/sheet.tsx ================================================ "use client" import * as React from "react" import * as SheetPrimitive from "@radix-ui/react-dialog" import { cva, type VariantProps } from "class-variance-authority" import { X } from "lucide-react" import { cn } from "@/lib/utils" const Sheet = SheetPrimitive.Root const SheetTrigger = SheetPrimitive.Trigger const SheetClose = SheetPrimitive.Close const SheetPortal = SheetPrimitive.Portal const SheetOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) SheetOverlay.displayName = SheetPrimitive.Overlay.displayName const sheetVariants = cva( "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", { variants: { side: { top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", bottom: "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", right: "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", }, }, defaultVariants: { side: "right", }, } ) interface SheetContentProps extends React.ComponentPropsWithoutRef, VariantProps {} const SheetContent = React.forwardRef< React.ElementRef, SheetContentProps >(({ side = "right", className, children, ...props }, ref) => ( Close {children} )) SheetContent.displayName = SheetPrimitive.Content.displayName const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => (
) SheetHeader.displayName = "SheetHeader" const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => (
) SheetFooter.displayName = "SheetFooter" const SheetTitle = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) SheetTitle.displayName = SheetPrimitive.Title.displayName const SheetDescription = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) SheetDescription.displayName = SheetPrimitive.Description.displayName export { Sheet, SheetPortal, SheetOverlay, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription, } ================================================ FILE: app/frontend/src/components/ui/sidebar.tsx ================================================ import { Slot } from "@radix-ui/react-slot" import { VariantProps, cva } from "class-variance-authority" import { PanelLeft } from "lucide-react" import * as React from "react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from "@/components/ui/sheet" import { Skeleton } from "@/components/ui/skeleton" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" import { useIsMobile } from "@/hooks/use-mobile" import { cn } from "@/lib/utils" const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_KEYBOARD_SHORTCUT = "b" type SidebarContextProps = { state: "expanded" | "collapsed" open: boolean setOpen: (open: boolean) => void openMobile: boolean setOpenMobile: (open: boolean) => void isMobile: boolean toggleSidebar: () => void } const SidebarContext = React.createContext(null) function useSidebar() { const context = React.useContext(SidebarContext) if (!context) { throw new Error("useSidebar must be used within a SidebarProvider.") } return context } const SidebarProvider = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & { defaultOpen?: boolean open?: boolean onOpenChange?: (open: boolean) => void } >( ( { defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref ) => { const isMobile = useIsMobile() const [openMobile, setOpenMobile] = React.useState(false) // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. const [_open, _setOpen] = React.useState(defaultOpen) const open = openProp ?? _open const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { const openState = typeof value === "function" ? value(open) : value if (setOpenProp) { setOpenProp(openState) } else { _setOpen(openState) } // This sets the cookie to keep the sidebar state. document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` }, [setOpenProp, open] ) // Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) }, [isMobile, setOpen, setOpenMobile]) // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ( event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey) ) { event.preventDefault() toggleSidebar() } } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) }, [toggleSidebar]) // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. const state = open ? "expanded" : "collapsed" const contextValue = React.useMemo( () => ({ state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, }), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] ) return (
{children}
) } ) SidebarProvider.displayName = "SidebarProvider" const Sidebar = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & { side?: "left" | "right" variant?: "sidebar" | "floating" | "inset" collapsible?: "offcanvas" | "icon" | "none" } >( ( { side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref ) => { const { isMobile, state, openMobile, setOpenMobile } = useSidebar() if (collapsible === "none") { return (
{children}
) } if (isMobile) { return ( Sidebar Displays the mobile sidebar.
{children}
) } return (
{/* This is what handles the sidebar gap on desktop */}
) } ) Sidebar.displayName = "Sidebar" const SidebarTrigger = React.forwardRef< React.ElementRef, React.ComponentProps >(({ className, onClick, ...props }, ref) => { const { toggleSidebar } = useSidebar() return ( ) }) SidebarTrigger.displayName = "SidebarTrigger" const SidebarRail = React.forwardRef< HTMLButtonElement, React.ComponentProps<"button"> >(({ className, ...props }, ref) => { const { toggleSidebar } = useSidebar() return (