[
  {
    "path": ".dockerignore",
    "content": "# Git\n.git\n.gitignore\n\n# Poetry\n.venv\n__pycache__/\n*.py[cod]\n*$py.class\n.pytest_cache/\n\n# Environment\n.env\n\n# IDEs and editors\n.idea/\n.vscode/\n*.swp\n*.swo\n\n# Logs and data\nlogs/\ndata/\n*.log\n\n# OS specific\n.DS_Store\nThumbs.db \n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: bug\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**Screenshot**\nAdd a screenshot of the bug to help explain your problem.\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: enhancement\nassignees: ''\n\n---\n\n**Describe the feature you'd like**\nA clear and concise description of what you want to happen.\n"
  },
  {
    "path": ".gitignore",
    "content": "# Python\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\n.Python\nenv/\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\n*.egg-info/\n.installed.cfg\n*.egg\n\n# Virtual Environment\nvenv/\nENV/\n\n# Environment Variables\n.env\n\n# IDE\n.idea/\n.vscode/\n*.swp\n*.swo\n.cursorrules\n.cursorignore\n.cursorindexingignore\n\n# OS\n.DS_Store\nThumbs.db\n\n# graph\n*.png\n\n# Txt files\n*.txt\n\n# PDF files\n*.pdf\n\n# Frontend\nnode_modules\n\n# Outputs\noutputs/\n\n# Database files (users will have their own local databases)\n*.db\n*.db-journal\n*.db-wal\n*.db-shm\n*.sqlite\n*.sqlite3\n\n# Alembic (keep migration files, but ignore generated/cache files)\napp/backend/alembic/versions/__pycache__/"
  },
  {
    "path": "README.md",
    "content": "# AI Hedge Fund\n\nThis 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.\n\nThis system employs several agents working together:\n\n1. Aswath Damodaran Agent - The Dean of Valuation, focuses on story, numbers, and disciplined valuation\n2. Ben Graham Agent - The godfather of value investing, only buys hidden gems with a margin of safety\n3. Bill Ackman Agent - An activist investor, takes bold positions and pushes for change\n4. Cathie Wood Agent - The queen of growth investing, believes in the power of innovation and disruption\n5. Charlie Munger Agent - Warren Buffett's partner, only buys wonderful businesses at fair prices\n6. Michael Burry Agent - The Big Short contrarian who hunts for deep value\n7. Mohnish Pabrai Agent - The Dhandho investor, who looks for doubles at low risk\n8. Peter Lynch Agent - Practical investor who seeks \"ten-baggers\" in everyday businesses\n9. Phil Fisher Agent - Meticulous growth investor who uses deep \"scuttlebutt\" research \n10. Rakesh Jhunjhunwala Agent - The Big Bull of India\n11. Stanley Druckenmiller Agent - Macro legend who hunts for asymmetric opportunities with growth potential\n12. Warren Buffett Agent - The oracle of Omaha, seeks wonderful companies at a fair price\n13. Valuation Agent - Calculates the intrinsic value of a stock and generates trading signals\n14. Sentiment Agent - Analyzes market sentiment and generates trading signals\n15. Fundamentals Agent - Analyzes fundamental data and generates trading signals\n16. Technicals Agent - Analyzes technical indicators and generates trading signals\n17. Risk Manager - Calculates risk metrics and sets position limits\n18. Portfolio Manager - Makes final trading decisions and generates orders\n\n<img width=\"1042\" alt=\"Screenshot 2025-03-22 at 6 19 07 PM\" src=\"https://github.com/user-attachments/assets/cbae3dcf-b571-490d-b0ad-3f0f035ac0d4\" />\n\nNote: the system does not actually make any trades.\n\n[![Twitter Follow](https://img.shields.io/twitter/follow/virattt?style=social)](https://twitter.com/virattt)\n\n## Disclaimer\n\nThis project is for **educational and research purposes only**.\n\n- Not intended for real trading or investment\n- No investment advice or guarantees provided\n- Creator assumes no liability for financial losses\n- Consult a financial advisor for investment decisions\n- Past performance does not indicate future results\n\nBy using this software, you agree to use it solely for learning purposes.\n\n## Table of Contents\n- [How to Install](#how-to-install)\n- [How to Run](#how-to-run)\n  - [⌨️ Command Line Interface](#️-command-line-interface)\n  - [🖥️ Web Application](#️-web-application)\n- [How to Contribute](#how-to-contribute)\n- [Feature Requests](#feature-requests)\n- [License](#license)\n\n## How to Install\n\nBefore 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.\n\n### 1. Clone the Repository\n\n```bash\ngit clone https://github.com/virattt/ai-hedge-fund.git\ncd ai-hedge-fund\n```\n\n### 2. Set up API keys\n\nCreate a `.env` file for your API keys:\n```bash\n# Create .env file for your API keys (in the root directory)\ncp .env.example .env\n```\n\nOpen and edit the `.env` file to add your API keys:\n```bash\n# For running LLMs hosted by openai (gpt-4o, gpt-4o-mini, etc.)\nOPENAI_API_KEY=your-openai-api-key\n\n# For getting financial data to power the hedge fund\nFINANCIAL_DATASETS_API_KEY=your-financial-datasets-api-key\n```\n\n**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. \n\n**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.\n\n## How to Run\n\n### ⌨️ Command Line Interface\n\nYou can run the AI Hedge Fund directly via terminal. This approach offers more granular control and is useful for automation, scripting, and integration purposes.\n\n<img width=\"992\" alt=\"Screenshot 2025-01-06 at 5 50 17 PM\" src=\"https://github.com/user-attachments/assets/e8ca04bf-9989-4a7d-a8b4-34e04666663b\" />\n\n#### Quick Start\n\n1. Install Poetry (if not already installed):\n```bash\ncurl -sSL https://install.python-poetry.org | python3 -\n```\n\n2. Install dependencies:\n```bash\npoetry install\n```\n\n#### Run the AI Hedge Fund\n```bash\npoetry run python src/main.py --ticker AAPL,MSFT,NVDA\n```\n\nYou can also specify a `--ollama` flag to run the AI hedge fund using local LLMs.\n\n```bash\npoetry run python src/main.py --ticker AAPL,MSFT,NVDA --ollama\n```\n\nYou can optionally specify the start and end dates to make decisions over a specific time period.\n\n```bash\npoetry run python src/main.py --ticker AAPL,MSFT,NVDA --start-date 2024-01-01 --end-date 2024-03-01\n```\n\n#### Run the Backtester\n```bash\npoetry run python src/backtester.py --ticker AAPL,MSFT,NVDA\n```\n\n**Example Output:**\n<img width=\"941\" alt=\"Screenshot 2025-01-06 at 5 47 52 PM\" src=\"https://github.com/user-attachments/assets/00e794ea-8628-44e6-9a84-8f8a31ad3b47\" />\n\n\nNote: The `--ollama`, `--start-date`, and `--end-date` flags work for the backtester, as well!\n\n### 🖥️ Web Application\n\nThe 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.\n\nPlease see detailed instructions on how to install and run the web application [here](https://github.com/virattt/ai-hedge-fund/tree/main/app).\n\n<img width=\"1721\" alt=\"Screenshot 2025-06-28 at 6 41 03 PM\" src=\"https://github.com/user-attachments/assets/b95ab696-c9f4-416c-9ad1-51feb1f5374b\" />\n\n\n## How to Contribute\n\n1. Fork the repository\n2. Create a feature branch\n3. Commit your changes\n4. Push to the branch\n5. Create a Pull Request\n\n**Important**: Please keep your pull requests small and focused.  This will make it easier to review and merge.\n\n## Feature Requests\n\nIf 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`.\n\n## License\n\nThis project is licensed under the MIT License - see the LICENSE file for details.\n"
  },
  {
    "path": "app/README.md",
    "content": "# Web Application\nThe 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.\n\n<img width=\"1721\" alt=\"Screenshot 2025-06-28 at 6 41 03 PM\" src=\"https://github.com/user-attachments/assets/b95ab696-c9f4-416c-9ad1-51feb1f5374b\" />\n\n\n## Overview\n\nThe AI Hedge Fund consists of:\n\n- **Backend**: A FastAPI application that provides a REST API to run the hedge fund trading system and backtester\n- **Frontend**: A React/Vite application that offers a user-friendly interface to visualize and control the hedge fund operations\n\n## Table of Contents\n\n- [🚀 Quick Start (For Non-Technical Users)](#-quick-start-for-non-technical-users)\n  - [Option 1: Using 1-Line Shell Script (Recommended)](#option-1-using-1-line-shell-script-recommended)\n  - [Option 2: Using npm (Alternative)](#option-2-using-npm-alternative)\n- [🛠️ Manual Setup (For Developers)](#️-manual-setup-for-developers)\n  - [Prerequisites](#prerequisites)\n  - [Installation](#installation)\n  - [Running the Application](#running-the-application)\n- [Detailed Documentation](#detailed-documentation)\n- [Disclaimer](#disclaimer)\n- [Troubleshooting](#troubleshooting])\n\n## 🚀 Quick Start (For Non-Technical Users)\n\n**One-line setup and run command:**\n\n### Option 1: Using 1-Line Shell Script (Recommended)\n\n#### For Mac/Linux:\n```bash\n./run.sh\n```\n\nIf you get a \"permission denied\" error, run this first:\n```bash\nchmod +x run.sh && ./run.sh\n```\n\nOr alternatively, you can run:\n```bash\nbash run.sh\n```\n\n#### For Windows:\n```cmd\nrun.bat\n```\n\n### Option 2: Using npm (Alternative)\n```bash\ncd app && npm install && npm run setup\n```\n\n**That's it!** These scripts will:\n1. Check for required dependencies (Node.js, Python, Poetry)\n2. Install all dependencies automatically\n3. Start both frontend and backend services\n4. **Automatically open your web browser** to the application\n\n**Requirements:**\n- [Node.js](https://nodejs.org/) (includes npm)\n- [Python 3](https://python.org/)\n- [Poetry](https://python-poetry.org/)\n\n**After running, you can access:**\n- Frontend (Web Interface): http://localhost:5173\n- Backend API: http://localhost:8000\n- API Documentation: http://localhost:8000/docs\n\n---\n\n## 🛠️ Manual Setup (For Developers)\n\nIf you prefer to set up each component manually or need more control:\n\n### Prerequisites\n\n- Node.js and npm for the frontend\n- Python 3.8+ and Poetry for the backend\n\n### Installation\n\n1. Clone the repository:\n```bash\ngit clone https://github.com/virattt/ai-hedge-fund.git\ncd ai-hedge-fund\n```\n\n2. Set up your environment variables:\n```bash\n# Create .env file for your API keys (in the root directory)\ncp .env.example .env\n```\n\n3. Edit the .env file to add your API keys:\n```bash\n# For running LLMs hosted by openai (gpt-4o, gpt-4o-mini, etc.)\nOPENAI_API_KEY=your-openai-api-key\n\n# For running LLMs hosted by groq (deepseek, llama3, etc.)\nGROQ_API_KEY=your-groq-api-key\n\n# For getting financial data to power the hedge fund\nFINANCIAL_DATASETS_API_KEY=your-financial-datasets-api-key\n```\n\n4. Install Poetry (if not already installed):\n```bash\ncurl -sSL https://install.python-poetry.org | python3 -\n```\n\n5. Install root project dependencies:\n```bash\n# From the root directory\npoetry install\n```\n\n6. Install backend app dependencies:\n```bash\n# Navigate to the backend directory\ncd app/backend\npip install -r requirements.txt  # If there's a requirements.txt file\n# OR\npoetry install  # If there's a pyproject.toml in the backend directory\n```\n\n7. Install frontend app dependencies:\n```bash\ncd app/frontend\nnpm install  # or pnpm install or yarn install\n```\n\n### Running the Application\n\n1. Start the backend server:\n```bash\n# In one terminal, from the backend directory\ncd app/backend\npoetry run uvicorn main:app --reload\n```\n\n2. Start the frontend application:\n```bash\n# In another terminal, from the frontend directory\ncd app/frontend\nnpm run dev\n```\n\nYou can now access:\n- Frontend application: http://localhost:5173\n- Backend API: http://localhost:8000\n- API Documentation: http://localhost:8000/docs\n\n## Detailed Documentation\n\nFor more detailed information:\n- [Backend Documentation](./backend/README.md)\n- [Frontend Documentation](./frontend/README.md)\n\n## Disclaimer\n\nThis project is for **educational and research purposes only**.\n\n- Not intended for real trading or investment\n- No warranties or guarantees provided\n- Creator assumes no liability for financial losses\n- Consult a financial advisor for investment decisions\n\nBy using this software, you agree to use it solely for learning purposes.\n\n## Troubleshooting\n\n### Common Issues\n\n#### \"Command not found: uvicorn\" Error\nIf you see this error when running the setup script:\n\n```bash\n[ERROR] Backend failed to start. Check the logs:\nCommand not found: uvicorn\n```\n\n**Solution:**\n1. **Clean Poetry environment:**\n   ```bash\n   cd app/backend\n   poetry env remove --all\n   poetry install\n   ```\n\n2. **Or force reinstall:**\n   ```bash\n   cd app/backend\n   poetry install --sync\n   ```\n\n3. **Verify installation:**\n   ```bash\n   cd app/backend\n   poetry run python -c \"import uvicorn; import fastapi\"\n   ```\n\n#### Python Version Issues\n- **Use Python 3.11**: Python 3.13+ may have compatibility issues\n- **Check your Python version:** `python --version`\n- **Switch Python versions if needed** (using pyenv, conda, etc.)\n\n#### Environment Variable Issues\n- **Ensure .env file exists** in the project root directory\n- **Copy from template:** `cp .env.example .env`\n- **Add your API keys** to the .env file\n\n#### Permission Issues (Mac/Linux)\nIf you get \"permission denied\":\n```bash\nchmod +x run.sh\n./run.sh\n```\n\n#### Port Already in Use\nIf ports 8000 or 5173 are in use:\n- **Kill existing processes:** `pkill -f \"uvicorn\\|vite\"`\n- **Or use different ports** by modifying the scripts\n\n### Getting Help\n- Check the [GitHub Issues](https://github.com/virattt/ai-hedge-fund/issues)\n- Follow updates on [Twitter](https://x.com/virattt) \n"
  },
  {
    "path": "app/backend/README.md",
    "content": "# AI Hedge Fund - Backend [WIP] 🚧\nThis project is currently a work in progress.  To track progress, please get updates [here](https://x.com/virattt).\n\nThis 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.\n\n## Overview\n\nThis 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.\n\nThis 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.\n\n## Installation\n\n### Using Poetry\n\n1. Clone the repository:\n```bash\ngit clone https://github.com/virattt/ai-hedge-fund.git\ncd ai-hedge-fund\n```\n\n2. Install Poetry (if not already installed):\n```bash\ncurl -sSL https://install.python-poetry.org | python3 -\n```\n\n3. Install dependencies:\n```bash\n# From the root directory\npoetry install\n```\n\n4. Set up your environment variables:\n```bash\n# Create .env file for your API keys (in the root directory)\ncp .env.example .env\n```\n\n5. Edit the .env file to add your API keys:\n```bash\n# For running LLMs hosted by openai (gpt-4o, gpt-4o-mini, etc.)\nOPENAI_API_KEY=your-openai-api-key\n\n# For running LLMs hosted by groq (deepseek, llama3, etc.)\nGROQ_API_KEY=your-groq-api-key\n\n# For getting financial data to power the hedge fund\nFINANCIAL_DATASETS_API_KEY=your-financial-datasets-api-key\n```\n\n## Running the Server\n\nTo run the development server:\n\n```bash\n# Navigate to the backend directory\ncd app/backend\n\n# Start the FastAPI server with uvicorn\npoetry run uvicorn main:app --reload\n```\n\nThis will start the FastAPI server with hot-reloading enabled.\n\nThe API will be available at:\n- API Endpoint: http://localhost:8000\n- API Documentation: http://localhost:8000/docs\n\n## API Endpoints\n\n- `POST /hedge-fund/run`: Run the AI Hedge Fund with specified parameters\n- `GET /ping`: Simple endpoint to test server connectivity\n\n## Project Structure\n\n```\napp/backend/\n├── api/                      # API layer (future expansion)\n├── models/                   # Domain models\n│   ├── __init__.py\n│   └── schemas.py            # Pydantic schema definitions\n├── routes/                   # API routes\n│   ├── __init__.py           # Router registry\n│   ├── hedge_fund.py         # Hedge fund endpoints\n│   └── health.py             # Health check endpoints\n├── services/                 # Business logic\n│   ├── graph.py              # Agent graph functionality\n│   └── portfolio.py          # Portfolio management\n├── __init__.py               # Package initialization\n└── main.py                   # FastAPI application entry point\n```\n\n## Disclaimer\n\nThis project is for **educational and research purposes only**.\n\n- Not intended for real trading or investment\n- No warranties or guarantees provided\n- Creator assumes no liability for financial losses\n- Consult a financial advisor for investment decisions\n\nBy using this software, you agree to use it solely for learning purposes."
  },
  {
    "path": "app/backend/__init__.py",
    "content": "import sys\nfrom pathlib import Path\n\n# Add the src directory to Python path for imports\n# This is a temporary solution while we develop the backend\nsrc_path = str(Path(__file__).parent.parent.parent / \"src\")\nif src_path not in sys.path:\n    sys.path.append(src_path)\n"
  },
  {
    "path": "app/backend/alembic/README",
    "content": "Generic single-database configuration."
  },
  {
    "path": "app/backend/alembic/env.py",
    "content": "from logging.config import fileConfig\n\nfrom sqlalchemy import engine_from_config\nfrom sqlalchemy import pool\n\nfrom alembic import context\n\n# this is the Alembic Config object, which provides\n# access to the values within the .ini file in use.\nconfig = context.config\n\n# Interpret the config file for Python logging.\n# This line sets up loggers basically.\nif config.config_file_name is not None:\n    fileConfig(config.config_file_name)\n\n# add your model's MetaData object here\n# for 'autogenerate' support\nfrom app.backend.database.models import Base\ntarget_metadata = Base.metadata\n\n# other values from the config, defined by the needs of env.py,\n# can be acquired:\n# my_important_option = config.get_main_option(\"my_important_option\")\n# ... etc.\n\n\ndef run_migrations_offline() -> None:\n    \"\"\"Run migrations in 'offline' mode.\n\n    This configures the context with just a URL\n    and not an Engine, though an Engine is acceptable\n    here as well.  By skipping the Engine creation\n    we don't even need a DBAPI to be available.\n\n    Calls to context.execute() here emit the given string to the\n    script output.\n\n    \"\"\"\n    url = config.get_main_option(\"sqlalchemy.url\")\n    context.configure(\n        url=url,\n        target_metadata=target_metadata,\n        literal_binds=True,\n        dialect_opts={\"paramstyle\": \"named\"},\n    )\n\n    with context.begin_transaction():\n        context.run_migrations()\n\n\ndef run_migrations_online() -> None:\n    \"\"\"Run migrations in 'online' mode.\n\n    In this scenario we need to create an Engine\n    and associate a connection with the context.\n\n    \"\"\"\n    connectable = engine_from_config(\n        config.get_section(config.config_ini_section, {}),\n        prefix=\"sqlalchemy.\",\n        poolclass=pool.NullPool,\n    )\n\n    with connectable.connect() as connection:\n        context.configure(\n            connection=connection, target_metadata=target_metadata\n        )\n\n        with context.begin_transaction():\n            context.run_migrations()\n\n\nif context.is_offline_mode():\n    run_migrations_offline()\nelse:\n    run_migrations_online()\n"
  },
  {
    "path": "app/backend/alembic/script.py.mako",
    "content": "\"\"\"${message}\n\nRevision ID: ${up_revision}\nRevises: ${down_revision | comma,n}\nCreate Date: ${create_date}\n\n\"\"\"\nfrom typing import Sequence, Union\n\nfrom alembic import op\nimport sqlalchemy as sa\n${imports if imports else \"\"}\n\n# revision identifiers, used by Alembic.\nrevision: str = ${repr(up_revision)}\ndown_revision: Union[str, None] = ${repr(down_revision)}\nbranch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}\ndepends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}\n\n\ndef upgrade() -> None:\n    \"\"\"Upgrade schema.\"\"\"\n    ${upgrades if upgrades else \"pass\"}\n\n\ndef downgrade() -> None:\n    \"\"\"Downgrade schema.\"\"\"\n    ${downgrades if downgrades else \"pass\"}\n"
  },
  {
    "path": "app/backend/alembic/versions/1b1feba3d897_add_data_column_to_hedge_fund_flows.py",
    "content": "\"\"\"add_data_column_to_hedge_fund_flows\n\nRevision ID: 1b1feba3d897\nRevises: 5274886e5bee\nCreate Date: 2025-06-22 17:30:50.992184\n\n\"\"\"\nfrom typing import Sequence, Union\n\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision: str = '1b1feba3d897'\ndown_revision: Union[str, None] = '5274886e5bee'\nbranch_labels: Union[str, Sequence[str], None] = None\ndepends_on: Union[str, Sequence[str], None] = None\n\n\ndef upgrade() -> None:\n    \"\"\"Upgrade schema.\"\"\"\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.add_column('hedge_fund_flows', sa.Column('data', sa.JSON(), nullable=True))\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    \"\"\"Downgrade schema.\"\"\"\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.drop_column('hedge_fund_flows', 'data')\n    # ### end Alembic commands ###\n"
  },
  {
    "path": "app/backend/alembic/versions/2f8c5d9e4b1a_add_hedgefundflowrun_table.py",
    "content": "\"\"\"Add HedgeFundFlowRun table\n\nRevision ID: 2f8c5d9e4b1a\nRevises: 1b1feba3d897\nCreate Date: 2025-01-01 12:00:00.000000\n\n\"\"\"\nfrom typing import Sequence, Union\n\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision: str = '2f8c5d9e4b1a'\ndown_revision: Union[str, None] = '1b1feba3d897'\nbranch_labels: Union[str, Sequence[str], None] = None\ndepends_on: Union[str, Sequence[str], None] = None\n\n\ndef upgrade() -> None:\n    \"\"\"Upgrade schema.\"\"\"\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.create_table('hedge_fund_flow_runs',\n    sa.Column('id', sa.Integer(), nullable=False),\n    sa.Column('flow_id', sa.Integer(), nullable=False),\n    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),\n    sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),\n    sa.Column('status', sa.String(length=50), nullable=False, server_default='IDLE'),\n    sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),\n    sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),\n    sa.Column('request_data', sa.JSON(), nullable=True),\n    sa.Column('results', sa.JSON(), nullable=True),\n    sa.Column('error_message', sa.Text(), nullable=True),\n    sa.Column('run_number', sa.Integer(), nullable=False, server_default='1'),\n    sa.PrimaryKeyConstraint('id')\n    )\n    op.create_index(op.f('ix_hedge_fund_flow_runs_id'), 'hedge_fund_flow_runs', ['id'], unique=False)\n    op.create_index(op.f('ix_hedge_fund_flow_runs_flow_id'), 'hedge_fund_flow_runs', ['flow_id'], unique=False)\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    \"\"\"Downgrade schema.\"\"\"\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.drop_index(op.f('ix_hedge_fund_flow_runs_flow_id'), table_name='hedge_fund_flow_runs')\n    op.drop_index(op.f('ix_hedge_fund_flow_runs_id'), table_name='hedge_fund_flow_runs')\n    op.drop_table('hedge_fund_flow_runs')\n    # ### end Alembic commands ### "
  },
  {
    "path": "app/backend/alembic/versions/3f9a6b7c8d2e_add_hedgefundflowruncycle_table.py",
    "content": "\"\"\"Add HedgeFundFlowRunCycle table and update HedgeFundFlowRun\n\nRevision ID: 3f9a6b7c8d2e\nRevises: 2f8c5d9e4b1a\nCreate Date: 2024-11-27 10:00:00.000000\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n# revision identifiers, used by Alembic.\nrevision = '3f9a6b7c8d2e'\ndown_revision = '2f8c5d9e4b1a'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade():\n    # Get the database connection to check existing columns\n    conn = op.get_bind()\n    \n    # Check if columns already exist before adding them\n    inspector = sa.inspect(conn)\n    existing_columns = [col['name'] for col in inspector.get_columns('hedge_fund_flow_runs')]\n    \n    # Add new columns to hedge_fund_flow_runs table only if they don't exist\n    if 'trading_mode' not in existing_columns:\n        op.add_column('hedge_fund_flow_runs', sa.Column('trading_mode', sa.String(50), nullable=False, server_default='one-time'))\n    if 'schedule' not in existing_columns:\n        op.add_column('hedge_fund_flow_runs', sa.Column('schedule', sa.String(50), nullable=True))\n    if 'duration' not in existing_columns:\n        op.add_column('hedge_fund_flow_runs', sa.Column('duration', sa.String(50), nullable=True))\n    if 'initial_portfolio' not in existing_columns:\n        op.add_column('hedge_fund_flow_runs', sa.Column('initial_portfolio', sa.JSON, nullable=True))\n    if 'final_portfolio' not in existing_columns:\n        op.add_column('hedge_fund_flow_runs', sa.Column('final_portfolio', sa.JSON, nullable=True))\n    \n    # Create hedge_fund_flow_run_cycles table only if it doesn't exist\n    existing_tables = inspector.get_table_names()\n    if 'hedge_fund_flow_run_cycles' not in existing_tables:\n        op.create_table(\n            'hedge_fund_flow_run_cycles',\n            sa.Column('id', sa.Integer, primary_key=True, index=True),\n            sa.Column('flow_run_id', sa.Integer, nullable=False, index=True),\n            sa.Column('cycle_number', sa.Integer, nullable=False),\n            sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),\n            sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),\n            sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),\n            sa.Column('analyst_signals', sa.JSON, nullable=True),\n            sa.Column('trading_decisions', sa.JSON, nullable=True),\n            sa.Column('executed_trades', sa.JSON, nullable=True),\n            sa.Column('portfolio_snapshot', sa.JSON, nullable=True),\n            sa.Column('performance_metrics', sa.JSON, nullable=True),\n            sa.Column('status', sa.String(50), nullable=False, server_default='IN_PROGRESS'),\n            sa.Column('error_message', sa.Text, nullable=True),\n            sa.Column('llm_calls_count', sa.Integer, nullable=True, server_default='0'),\n            sa.Column('api_calls_count', sa.Integer, nullable=True, server_default='0'),\n            sa.Column('estimated_cost', sa.String(20), nullable=True),\n            sa.Column('trigger_reason', sa.String(100), nullable=True),\n            sa.Column('market_conditions', sa.JSON, nullable=True),\n        )\n        \n        # Create indexes for the new table\n        op.create_index('ix_hedge_fund_flow_run_cycles_flow_run_id', 'hedge_fund_flow_run_cycles', ['flow_run_id'])\n        op.create_index('ix_hedge_fund_flow_run_cycles_cycle_number', 'hedge_fund_flow_run_cycles', ['cycle_number'])\n        op.create_index('ix_hedge_fund_flow_run_cycles_status', 'hedge_fund_flow_run_cycles', ['status'])\n        op.create_index('ix_hedge_fund_flow_run_cycles_started_at', 'hedge_fund_flow_run_cycles', ['started_at'])\n\n\ndef downgrade():\n    # Check if table exists before dropping\n    conn = op.get_bind()\n    inspector = sa.inspect(conn)\n    existing_tables = inspector.get_table_names()\n    \n    if 'hedge_fund_flow_run_cycles' in existing_tables:\n        # Drop indexes if they exist\n        try:\n            op.drop_index('ix_hedge_fund_flow_run_cycles_started_at', 'hedge_fund_flow_run_cycles')\n            op.drop_index('ix_hedge_fund_flow_run_cycles_status', 'hedge_fund_flow_run_cycles')\n            op.drop_index('ix_hedge_fund_flow_run_cycles_cycle_number', 'hedge_fund_flow_run_cycles')\n            op.drop_index('ix_hedge_fund_flow_run_cycles_flow_run_id', 'hedge_fund_flow_run_cycles')\n        except:\n            pass  # Index may not exist\n        \n        # Drop hedge_fund_flow_run_cycles table\n        op.drop_table('hedge_fund_flow_run_cycles')\n    \n    # Check existing columns before dropping\n    existing_columns = [col['name'] for col in inspector.get_columns('hedge_fund_flow_runs')]\n    \n    # Remove columns from hedge_fund_flow_runs table only if they exist\n    if 'final_portfolio' in existing_columns:\n        op.drop_column('hedge_fund_flow_runs', 'final_portfolio')\n    if 'initial_portfolio' in existing_columns:\n        op.drop_column('hedge_fund_flow_runs', 'initial_portfolio')\n    if 'duration' in existing_columns:\n        op.drop_column('hedge_fund_flow_runs', 'duration')\n    if 'schedule' in existing_columns:\n        op.drop_column('hedge_fund_flow_runs', 'schedule')\n    if 'trading_mode' in existing_columns:\n        op.drop_column('hedge_fund_flow_runs', 'trading_mode') "
  },
  {
    "path": "app/backend/alembic/versions/5274886e5bee_add_hedgefundflow_table.py",
    "content": "\"\"\"Add HedgeFundFlow table\n\nRevision ID: 5274886e5bee\nRevises: \nCreate Date: 2025-06-20 14:50:24.736989\n\n\"\"\"\nfrom typing import Sequence, Union\n\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision: str = '5274886e5bee'\ndown_revision: Union[str, None] = None\nbranch_labels: Union[str, Sequence[str], None] = None\ndepends_on: Union[str, Sequence[str], None] = None\n\n\ndef upgrade() -> None:\n    \"\"\"Upgrade schema.\"\"\"\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.create_table('hedge_fund_flows',\n    sa.Column('id', sa.Integer(), nullable=False),\n    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),\n    sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),\n    sa.Column('name', sa.String(length=200), nullable=False),\n    sa.Column('description', sa.Text(), nullable=True),\n    sa.Column('nodes', sa.JSON(), nullable=False),\n    sa.Column('edges', sa.JSON(), nullable=False),\n    sa.Column('viewport', sa.JSON(), nullable=True),\n    sa.Column('is_template', sa.Boolean(), nullable=True),\n    sa.Column('tags', sa.JSON(), nullable=True),\n    sa.PrimaryKeyConstraint('id')\n    )\n    op.create_index(op.f('ix_hedge_fund_flows_id'), 'hedge_fund_flows', ['id'], unique=False)\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    \"\"\"Downgrade schema.\"\"\"\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.drop_index(op.f('ix_hedge_fund_flows_id'), table_name='hedge_fund_flows')\n    op.drop_table('hedge_fund_flows')\n    # ### end Alembic commands ###\n"
  },
  {
    "path": "app/backend/alembic/versions/add_api_keys_table.py",
    "content": "\"\"\"add_api_keys_table\n\nRevision ID: d5e78f9a1b2c\nRevises: 3f9a6b7c8d2e\nCreate Date: 2025-07-27 15:20:00.000000\n\n\"\"\"\nfrom typing import Sequence, Union\n\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision: str = 'd5e78f9a1b2c'\ndown_revision: Union[str, None] = '3f9a6b7c8d2e'\nbranch_labels: Union[str, Sequence[str], None] = None\ndepends_on: Union[str, Sequence[str], None] = None\n\n\ndef upgrade() -> None:\n    \"\"\"Upgrade schema.\"\"\"\n    # Create API keys table\n    op.create_table('api_keys',\n        sa.Column('id', sa.Integer(), nullable=False),\n        sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),\n        sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),\n        sa.Column('provider', sa.String(length=100), nullable=False),\n        sa.Column('key_value', sa.Text(), nullable=False),\n        sa.Column('is_active', sa.Boolean(), nullable=True),\n        sa.Column('description', sa.Text(), nullable=True),\n        sa.Column('last_used', sa.DateTime(timezone=True), nullable=True),\n        sa.PrimaryKeyConstraint('id'),\n        sa.UniqueConstraint('provider')\n    )\n    op.create_index(op.f('ix_api_keys_id'), 'api_keys', ['id'], unique=False)\n    op.create_index(op.f('ix_api_keys_provider'), 'api_keys', ['provider'], unique=False)\n\n\ndef downgrade() -> None:\n    \"\"\"Downgrade schema.\"\"\"\n    op.drop_index(op.f('ix_api_keys_provider'), table_name='api_keys')\n    op.drop_index(op.f('ix_api_keys_id'), table_name='api_keys')\n    op.drop_table('api_keys') "
  },
  {
    "path": "app/backend/alembic.ini",
    "content": "# A generic, single database configuration.\n\n[alembic]\n# path to migration scripts\n# Use forward slashes (/) also on windows to provide an os agnostic path\nscript_location = alembic\n\n# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s\n# Uncomment the line below if you want the files to be prepended with date and time\n# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file\n# for all available tokens\n# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s\n\n# sys.path path, will be prepended to sys.path if present.\n# defaults to the current working directory.\nprepend_sys_path = .\n\n# timezone to use when rendering the date within the migration file\n# as well as the filename.\n# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.\n# Any required deps can installed by adding `alembic[tz]` to the pip requirements\n# string value is passed to ZoneInfo()\n# leave blank for localtime\n# timezone =\n\n# max length of characters to apply to the \"slug\" field\n# truncate_slug_length = 40\n\n# set to 'true' to run the environment during\n# the 'revision' command, regardless of autogenerate\n# revision_environment = false\n\n# set to 'true' to allow .pyc and .pyo files without\n# a source .py file to be detected as revisions in the\n# versions/ directory\n# sourceless = false\n\n# version location specification; This defaults\n# to alembic/versions.  When using multiple version\n# directories, initial revisions must be specified with --version-path.\n# The path separator used here should be the separator specified by \"version_path_separator\" below.\n# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions\n\n# version path separator; As mentioned above, this is the character used to split\n# version_locations. The default within new alembic.ini files is \"os\", which uses os.pathsep.\n# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.\n# Valid values for version_path_separator are:\n#\n# version_path_separator = :\n# version_path_separator = ;\n# version_path_separator = space\n# version_path_separator = newline\n#\n# Use os.pathsep. Default configuration used for new projects.\nversion_path_separator = os\n\n# set to 'true' to search source files recursively\n# in each \"version_locations\" directory\n# new in Alembic version 1.10\n# recursive_version_locations = false\n\n# the output encoding used when revision files\n# are written from script.py.mako\n# output_encoding = utf-8\n\nsqlalchemy.url = sqlite:///./hedge_fund.db\n\n\n[post_write_hooks]\n# post_write_hooks defines scripts or Python functions that are run\n# on newly generated revision scripts.  See the documentation for further\n# detail and examples\n\n# format using \"black\" - use the console_scripts runner, against the \"black\" entrypoint\n# hooks = black\n# black.type = console_scripts\n# black.entrypoint = black\n# black.options = -l 79 REVISION_SCRIPT_FILENAME\n\n# lint with attempts to fix using \"ruff\" - use the exec runner, execute a binary\n# hooks = ruff\n# ruff.type = exec\n# ruff.executable = %(here)s/.venv/bin/ruff\n# ruff.options = check --fix REVISION_SCRIPT_FILENAME\n\n# Logging configuration\n[loggers]\nkeys = root,sqlalchemy,alembic\n\n[handlers]\nkeys = console\n\n[formatters]\nkeys = generic\n\n[logger_root]\nlevel = WARNING\nhandlers = console\nqualname =\n\n[logger_sqlalchemy]\nlevel = WARNING\nhandlers =\nqualname = sqlalchemy.engine\n\n[logger_alembic]\nlevel = INFO\nhandlers =\nqualname = alembic\n\n[handler_console]\nclass = StreamHandler\nargs = (sys.stderr,)\nlevel = NOTSET\nformatter = generic\n\n[formatter_generic]\nformat = %(levelname)-5.5s [%(name)s] %(message)s\ndatefmt = %H:%M:%S\n"
  },
  {
    "path": "app/backend/database/__init__.py",
    "content": "from .connection import get_db, engine, SessionLocal\nfrom .models import Base\n\n__all__ = [\"get_db\", \"engine\", \"SessionLocal\", \"Base\"] "
  },
  {
    "path": "app/backend/database/connection.py",
    "content": "from sqlalchemy import create_engine\nfrom sqlalchemy.ext.declarative import declarative_base\nfrom sqlalchemy.orm import sessionmaker\nimport os\nfrom pathlib import Path\n\n# Get the backend directory path\nBACKEND_DIR = Path(__file__).parent.parent\nDATABASE_PATH = BACKEND_DIR / \"hedge_fund.db\"\n\n# Database configuration - use absolute path\nDATABASE_URL = f\"sqlite:///{DATABASE_PATH}\"\n\n# Create SQLAlchemy engine\nengine = create_engine(\n    DATABASE_URL,\n    connect_args={\"check_same_thread\": False}  # Needed for SQLite\n)\n\n# Create SessionLocal class\nSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)\n\n# Create Base class for models\nBase = declarative_base()\n\n# Dependency for FastAPI\ndef get_db():\n    db = SessionLocal()\n    try:\n        yield db\n    finally:\n        db.close() "
  },
  {
    "path": "app/backend/database/models.py",
    "content": "from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, JSON, ForeignKey\nfrom sqlalchemy.sql import func\nfrom .connection import Base\n\n\nclass HedgeFundFlow(Base):\n    \"\"\"Table to store React Flow configurations (nodes, edges, viewport)\"\"\"\n    __tablename__ = \"hedge_fund_flows\"\n    \n    id = Column(Integer, primary_key=True, index=True)\n    created_at = Column(DateTime(timezone=True), server_default=func.now())\n    updated_at = Column(DateTime(timezone=True), onupdate=func.now())\n    \n    # Flow metadata\n    name = Column(String(200), nullable=False)\n    description = Column(Text, nullable=True)\n    \n    # React Flow state\n    nodes = Column(JSON, nullable=False)  # Store React Flow nodes as JSON\n    edges = Column(JSON, nullable=False)  # Store React Flow edges as JSON\n    viewport = Column(JSON, nullable=True)  # Store viewport state (zoom, x, y)\n    data = Column(JSON, nullable=True)  # Store node internal states (tickers, models, etc.)\n    \n    # Additional metadata\n    is_template = Column(Boolean, default=False)  # Mark as template for reuse\n    tags = Column(JSON, nullable=True)  # Store tags for categorization\n\n\nclass HedgeFundFlowRun(Base):\n    \"\"\"Table to track individual execution runs of a hedge fund flow\"\"\"\n    __tablename__ = \"hedge_fund_flow_runs\"\n    \n    id = Column(Integer, primary_key=True, index=True)\n    flow_id = Column(Integer, ForeignKey(\"hedge_fund_flows.id\"), nullable=False, index=True)\n    created_at = Column(DateTime(timezone=True), server_default=func.now())\n    updated_at = Column(DateTime(timezone=True), onupdate=func.now())\n    \n    # Run execution tracking\n    status = Column(String(50), nullable=False, default=\"IDLE\")  # IDLE, IN_PROGRESS, COMPLETE, ERROR\n    started_at = Column(DateTime(timezone=True), nullable=True)\n    completed_at = Column(DateTime(timezone=True), nullable=True)\n    \n    # Run configuration\n    trading_mode = Column(String(50), nullable=False, default=\"one-time\")  # one-time, continuous, advisory\n    schedule = Column(String(50), nullable=True)  # hourly, daily, weekly (for continuous mode)\n    duration = Column(String(50), nullable=True)  # 1day, 1week, 1month (for continuous mode)\n    \n    # Run data\n    request_data = Column(JSON, nullable=True)  # Store the request parameters (tickers, agents, models, etc.)\n    initial_portfolio = Column(JSON, nullable=True)  # Store initial portfolio state\n    final_portfolio = Column(JSON, nullable=True)  # Store final portfolio state\n    results = Column(JSON, nullable=True)  # Store the output/results from the run\n    error_message = Column(Text, nullable=True)  # Store error details if run failed\n    \n    # Metadata\n    run_number = Column(Integer, nullable=False, default=1)  # Sequential run number for this flow\n\n\nclass HedgeFundFlowRunCycle(Base):\n    \"\"\"Individual analysis cycles within a trading session\"\"\"\n    __tablename__ = \"hedge_fund_flow_run_cycles\"\n    \n    id = Column(Integer, primary_key=True, index=True)\n    flow_run_id = Column(Integer, ForeignKey(\"hedge_fund_flow_runs.id\"), nullable=False, index=True)\n    cycle_number = Column(Integer, nullable=False)  # 1, 2, 3, etc. within the run\n    \n    # Timing\n    created_at = Column(DateTime(timezone=True), server_default=func.now())\n    started_at = Column(DateTime(timezone=True), nullable=False)\n    completed_at = Column(DateTime(timezone=True), nullable=True)\n    \n    # Analysis results\n    analyst_signals = Column(JSON, nullable=True)  # All agent decisions/signals\n    trading_decisions = Column(JSON, nullable=True)  # Portfolio manager decisions\n    executed_trades = Column(JSON, nullable=True)  # Actual trades executed (paper trading)\n    \n    # Portfolio state after this cycle\n    portfolio_snapshot = Column(JSON, nullable=True)  # Cash, positions, performance metrics\n    \n    # Performance metrics for this cycle\n    performance_metrics = Column(JSON, nullable=True)  # Returns, sharpe ratio, etc.\n    \n    # Execution tracking\n    status = Column(String(50), nullable=False, default=\"IN_PROGRESS\")  # IN_PROGRESS, COMPLETED, ERROR\n    error_message = Column(Text, nullable=True)  # Store error details if cycle failed\n    \n    # Cost tracking\n    llm_calls_count = Column(Integer, nullable=True, default=0)  # Number of LLM calls made\n    api_calls_count = Column(Integer, nullable=True, default=0)  # Number of financial API calls made\n    estimated_cost = Column(String(20), nullable=True)  # Estimated cost in USD\n    \n    # Metadata\n    trigger_reason = Column(String(100), nullable=True)  # scheduled, manual, market_event, etc.\n    market_conditions = Column(JSON, nullable=True)  # Market data snapshot at cycle start\n\n\nclass ApiKey(Base):\n    \"\"\"Table to store API keys for various services\"\"\"\n    __tablename__ = \"api_keys\"\n    \n    id = Column(Integer, primary_key=True, index=True)\n    created_at = Column(DateTime(timezone=True), server_default=func.now())\n    updated_at = Column(DateTime(timezone=True), onupdate=func.now())\n    \n    # API key details\n    provider = Column(String(100), nullable=False, unique=True, index=True)  # e.g., \"ANTHROPIC_API_KEY\"\n    key_value = Column(Text, nullable=False)  # The actual API key (encrypted in production)\n    is_active = Column(Boolean, default=True)  # Enable/disable without deletion\n    \n    # Optional metadata\n    description = Column(Text, nullable=True)  # Human-readable description\n    last_used = Column(DateTime(timezone=True), nullable=True)  # Track usage\n\n\n "
  },
  {
    "path": "app/backend/main.py",
    "content": "from fastapi import FastAPI\nfrom fastapi.middleware.cors import CORSMiddleware\nimport logging\nimport asyncio\n\nfrom app.backend.routes import api_router\nfrom app.backend.database.connection import engine\nfrom app.backend.database.models import Base\nfrom app.backend.services.ollama_service import ollama_service\n\n# Configure logging\nlogging.basicConfig(level=logging.INFO)\nlogger = logging.getLogger(__name__)\n\napp = FastAPI(title=\"AI Hedge Fund API\", description=\"Backend API for AI Hedge Fund\", version=\"0.1.0\")\n\n# Initialize database tables (this is safe to run multiple times)\nBase.metadata.create_all(bind=engine)\n\n# Configure CORS\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=[\"http://localhost:5173\", \"http://127.0.0.1:5173\"],  # Frontend URLs\n    allow_credentials=True,\n    allow_methods=[\"*\"],\n    allow_headers=[\"*\"],\n)\n\n# Include all routes\napp.include_router(api_router)\n\n@app.on_event(\"startup\")\nasync def startup_event():\n    \"\"\"Startup event to check Ollama availability.\"\"\"\n    try:\n        logger.info(\"Checking Ollama availability...\")\n        status = await ollama_service.check_ollama_status()\n        \n        if status[\"installed\"]:\n            if status[\"running\"]:\n                logger.info(f\"✓ Ollama is installed and running at {status['server_url']}\")\n                if status[\"available_models\"]:\n                    logger.info(f\"✓ Available models: {', '.join(status['available_models'])}\")\n                else:\n                    logger.info(\"ℹ No models are currently downloaded\")\n            else:\n                logger.info(\"ℹ Ollama is installed but not running\")\n                logger.info(\"ℹ You can start it from the Settings page or manually with 'ollama serve'\")\n        else:\n            logger.info(\"ℹ Ollama is not installed. Install it to use local models.\")\n            logger.info(\"ℹ Visit https://ollama.com to download and install Ollama\")\n            \n    except Exception as e:\n        logger.warning(f\"Could not check Ollama status: {e}\")\n        logger.info(\"ℹ Ollama integration is available if you install it later\")\n"
  },
  {
    "path": "app/backend/models/__init__.py",
    "content": ""
  },
  {
    "path": "app/backend/models/events.py",
    "content": "from typing import Dict, Optional, Any, Literal\nfrom pydantic import BaseModel\n\n\nclass BaseEvent(BaseModel):\n    \"\"\"Base class for all Server-Sent Event events\"\"\"\n\n    type: str\n\n    def to_sse(self) -> str:\n        \"\"\"Convert to Server-Sent Event format\"\"\"\n        event_type = self.type.lower()\n        return f\"event: {event_type}\\ndata: {self.model_dump_json()}\\n\\n\"\n\n\nclass StartEvent(BaseEvent):\n    \"\"\"Event indicating the start of processing\"\"\"\n\n    type: Literal[\"start\"] = \"start\"\n    timestamp: Optional[str] = None\n\nclass ProgressUpdateEvent(BaseEvent):\n    \"\"\"Event containing an agent's progress update\"\"\"\n\n    type: Literal[\"progress\"] = \"progress\"\n    agent: str\n    ticker: Optional[str] = None\n    status: str\n    timestamp: Optional[str] = None\n    analysis: Optional[str] = None\n\nclass ErrorEvent(BaseEvent):\n    \"\"\"Event indicating an error occurred\"\"\"\n\n    type: Literal[\"error\"] = \"error\"\n    message: str\n    timestamp: Optional[str] = None\n\n\nclass CompleteEvent(BaseEvent):\n    \"\"\"Event indicating successful completion with results\"\"\"\n\n    type: Literal[\"complete\"] = \"complete\"\n    data: Dict[str, Any]\n    timestamp: Optional[str] = None\n"
  },
  {
    "path": "app/backend/models/schemas.py",
    "content": "from datetime import datetime, timedelta\nfrom pydantic import BaseModel, Field, field_validator\nfrom typing import List, Optional, Dict, Any\nfrom src.llm.models import ModelProvider\nfrom enum import Enum\nfrom app.backend.services.graph import extract_base_agent_key\n\n\nclass FlowRunStatus(str, Enum):\n    IDLE = \"IDLE\"\n    IN_PROGRESS = \"IN_PROGRESS\"\n    COMPLETE = \"COMPLETE\"\n    ERROR = \"ERROR\"\n\n\nclass AgentModelConfig(BaseModel):\n    agent_id: str\n    model_name: Optional[str] = None\n    model_provider: Optional[ModelProvider] = None\n\n\nclass PortfolioPosition(BaseModel):\n    ticker: str\n    quantity: float\n    trade_price: float\n\n    @field_validator('trade_price')\n    @classmethod\n    def price_must_be_positive(cls, v: float) -> float:\n        if v <= 0:\n            raise ValueError('Trade price must be positive!')\n        return v\n\n\nclass GraphNode(BaseModel):\n    id: str\n    type: Optional[str] = None\n    data: Optional[Dict[str, Any]] = None\n    position: Optional[Dict[str, Any]] = None\n\n\nclass GraphEdge(BaseModel):\n    id: str\n    source: str\n    target: str\n    type: Optional[str] = None\n    data: Optional[Dict[str, Any]] = None\n\n\nclass HedgeFundResponse(BaseModel):\n    decisions: dict\n    analyst_signals: dict\n\n\nclass ErrorResponse(BaseModel):\n    message: str\n    error: str | None = None\n\n\n# Base class for shared fields between HedgeFundRequest and BacktestRequest\nclass BaseHedgeFundRequest(BaseModel):\n    tickers: List[str]\n    graph_nodes: List[GraphNode]\n    graph_edges: List[GraphEdge]\n    agent_models: Optional[List[AgentModelConfig]] = None\n    model_name: Optional[str] = \"gpt-4.1\"\n    model_provider: Optional[ModelProvider] = ModelProvider.OPENAI\n    margin_requirement: float = 0.0\n    portfolio_positions: Optional[List[PortfolioPosition]] = None\n    api_keys: Optional[Dict[str, str]] = None\n\n    def get_agent_ids(self) -> List[str]:\n        \"\"\"Extract agent IDs from graph structure\"\"\"\n        return [node.id for node in self.graph_nodes]\n\n    def get_agent_model_config(self, agent_id: str) -> tuple[str, ModelProvider]:\n        \"\"\"Get model configuration for a specific agent\"\"\"\n        if self.agent_models:\n            # Extract base agent key from unique node ID for matching\n            base_agent_key = extract_base_agent_key(agent_id)\n            \n            for config in self.agent_models:\n                # Check both unique node ID and base agent key for matches\n                config_base_key = extract_base_agent_key(config.agent_id)\n                if config.agent_id == agent_id or config_base_key == base_agent_key:\n                    return (\n                        config.model_name or self.model_name,\n                        config.model_provider or self.model_provider\n                    )\n        # Fallback to global model settings\n        return self.model_name, self.model_provider\n\n\nclass BacktestRequest(BaseHedgeFundRequest):\n    start_date: str\n    end_date: str\n    initial_capital: float = 100000.0\n\n\nclass BacktestDayResult(BaseModel):\n    date: str\n    portfolio_value: float\n    cash: float\n    decisions: Dict[str, Any]\n    executed_trades: Dict[str, int]\n    analyst_signals: Dict[str, Any]\n    current_prices: Dict[str, float]\n    long_exposure: float\n    short_exposure: float\n    gross_exposure: float\n    net_exposure: float\n    long_short_ratio: Optional[float] = None\n\n\nclass BacktestPerformanceMetrics(BaseModel):\n    sharpe_ratio: Optional[float] = None\n    sortino_ratio: Optional[float] = None\n    max_drawdown: Optional[float] = None\n    max_drawdown_date: Optional[str] = None\n    long_short_ratio: Optional[float] = None\n    gross_exposure: Optional[float] = None\n    net_exposure: Optional[float] = None\n\n\nclass BacktestResponse(BaseModel):\n    results: List[BacktestDayResult]\n    performance_metrics: BacktestPerformanceMetrics\n    final_portfolio: Dict[str, Any]\n\n\nclass HedgeFundRequest(BaseHedgeFundRequest):\n    end_date: Optional[str] = Field(default_factory=lambda: datetime.now().strftime(\"%Y-%m-%d\"))\n    start_date: Optional[str] = None\n    initial_cash: float = 100000.0\n\n    def get_start_date(self) -> str:\n        \"\"\"Calculate start date if not provided\"\"\"\n        if self.start_date:\n            return self.start_date\n        return (datetime.strptime(self.end_date, \"%Y-%m-%d\") - timedelta(days=90)).strftime(\"%Y-%m-%d\")\n\n\n# Flow-related schemas\nclass FlowCreateRequest(BaseModel):\n    name: str = Field(..., min_length=1, max_length=200)\n    description: Optional[str] = None\n    nodes: List[Dict[str, Any]]\n    edges: List[Dict[str, Any]]\n    viewport: Optional[Dict[str, Any]] = None\n    data: Optional[Dict[str, Any]] = None\n    is_template: bool = False\n    tags: Optional[List[str]] = None\n\n\nclass FlowUpdateRequest(BaseModel):\n    name: Optional[str] = Field(None, min_length=1, max_length=200)\n    description: Optional[str] = None\n    nodes: Optional[List[Dict[str, Any]]] = None\n    edges: Optional[List[Dict[str, Any]]] = None\n    viewport: Optional[Dict[str, Any]] = None\n    data: Optional[Dict[str, Any]] = None\n    is_template: Optional[bool] = None\n    tags: Optional[List[str]] = None\n\n\nclass FlowResponse(BaseModel):\n    id: int\n    name: str\n    description: Optional[str]\n    nodes: List[Dict[str, Any]]\n    edges: List[Dict[str, Any]]\n    viewport: Optional[Dict[str, Any]]\n    data: Optional[Dict[str, Any]]\n    is_template: bool\n    tags: Optional[List[str]]\n    created_at: datetime\n    updated_at: Optional[datetime]\n\n    class Config:\n        from_attributes = True\n\n\nclass FlowSummaryResponse(BaseModel):\n    \"\"\"Lightweight flow response without nodes/edges for listing\"\"\"\n    id: int\n    name: str\n    description: Optional[str]\n    is_template: bool\n    tags: Optional[List[str]]\n    created_at: datetime\n    updated_at: Optional[datetime]\n\n    class Config:\n        from_attributes = True\n\n\n# Flow Run schemas\nclass FlowRunCreateRequest(BaseModel):\n    \"\"\"Request to create a new flow run\"\"\"\n    request_data: Optional[Dict[str, Any]] = None\n\n\nclass FlowRunUpdateRequest(BaseModel):\n    \"\"\"Request to update an existing flow run\"\"\"\n    status: Optional[FlowRunStatus] = None\n    results: Optional[Dict[str, Any]] = None\n    error_message: Optional[str] = None\n\n\nclass FlowRunResponse(BaseModel):\n    \"\"\"Complete flow run response\"\"\"\n    id: int\n    flow_id: int\n    status: FlowRunStatus\n    run_number: int\n    created_at: datetime\n    updated_at: Optional[datetime]\n    started_at: Optional[datetime]\n    completed_at: Optional[datetime]\n    request_data: Optional[Dict[str, Any]]\n    results: Optional[Dict[str, Any]]\n    error_message: Optional[str]\n\n    class Config:\n        from_attributes = True\n\n\nclass FlowRunSummaryResponse(BaseModel):\n    \"\"\"Lightweight flow run response for listing\"\"\"\n    id: int\n    flow_id: int\n    status: FlowRunStatus\n    run_number: int\n    created_at: datetime\n    started_at: Optional[datetime]\n    completed_at: Optional[datetime]\n    error_message: Optional[str]\n\n    class Config:\n        from_attributes = True\n\n\n# API Key schemas\nclass ApiKeyCreateRequest(BaseModel):\n    \"\"\"Request to create or update an API key\"\"\"\n    provider: str = Field(..., min_length=1, max_length=100)\n    key_value: str = Field(..., min_length=1)\n    description: Optional[str] = None\n    is_active: bool = True\n\n\nclass ApiKeyUpdateRequest(BaseModel):\n    \"\"\"Request to update an existing API key\"\"\"\n    key_value: Optional[str] = Field(None, min_length=1)\n    description: Optional[str] = None\n    is_active: Optional[bool] = None\n\n\nclass ApiKeyResponse(BaseModel):\n    \"\"\"Complete API key response\"\"\"\n    id: int\n    provider: str\n    key_value: str\n    is_active: bool\n    description: Optional[str]\n    created_at: datetime\n    updated_at: Optional[datetime]\n    last_used: Optional[datetime]\n\n    class Config:\n        from_attributes = True\n\n\nclass ApiKeySummaryResponse(BaseModel):\n    \"\"\"API key response without the actual key value\"\"\"\n    id: int\n    provider: str\n    is_active: bool\n    description: Optional[str]\n    created_at: datetime\n    updated_at: Optional[datetime]\n    last_used: Optional[datetime]\n    has_key: bool = True  # Indicates if a key is set\n\n    class Config:\n        from_attributes = True\n\n\nclass ApiKeyBulkUpdateRequest(BaseModel):\n    \"\"\"Request to update multiple API keys at once\"\"\"\n    api_keys: List[ApiKeyCreateRequest]\n"
  },
  {
    "path": "app/backend/repositories/__init__.py",
    "content": "from .flow_repository import FlowRepository\n\n__all__ = [\"FlowRepository\"] "
  },
  {
    "path": "app/backend/repositories/api_key_repository.py",
    "content": "from sqlalchemy.orm import Session\nfrom sqlalchemy import func\nfrom typing import List, Optional\nfrom datetime import datetime\n\nfrom app.backend.database.models import ApiKey\n\n\nclass ApiKeyRepository:\n    \"\"\"Repository for API key database operations\"\"\"\n    \n    def __init__(self, db: Session):\n        self.db = db\n\n    def create_or_update_api_key(\n        self, \n        provider: str, \n        key_value: str, \n        description: str = None, \n        is_active: bool = True\n    ) -> ApiKey:\n        \"\"\"Create a new API key or update existing one\"\"\"\n        # Check if API key already exists for this provider\n        existing_key = self.db.query(ApiKey).filter(ApiKey.provider == provider).first()\n        \n        if existing_key:\n            # Update existing key\n            existing_key.key_value = key_value\n            existing_key.description = description\n            existing_key.is_active = is_active\n            existing_key.updated_at = func.now()\n            self.db.commit()\n            self.db.refresh(existing_key)\n            return existing_key\n        else:\n            # Create new key\n            api_key = ApiKey(\n                provider=provider,\n                key_value=key_value,\n                description=description,\n                is_active=is_active\n            )\n            self.db.add(api_key)\n            self.db.commit()\n            self.db.refresh(api_key)\n            return api_key\n\n    def get_api_key_by_provider(self, provider: str) -> Optional[ApiKey]:\n        \"\"\"Get API key by provider name\"\"\"\n        return self.db.query(ApiKey).filter(\n            ApiKey.provider == provider,\n            ApiKey.is_active == True\n        ).first()\n\n    def get_all_api_keys(self, include_inactive: bool = False) -> List[ApiKey]:\n        \"\"\"Get all API keys\"\"\"\n        query = self.db.query(ApiKey)\n        if not include_inactive:\n            query = query.filter(ApiKey.is_active == True)\n        return query.order_by(ApiKey.provider).all()\n\n    def update_api_key(\n        self, \n        provider: str, \n        key_value: str = None, \n        description: str = None, \n        is_active: bool = None\n    ) -> Optional[ApiKey]:\n        \"\"\"Update an existing API key\"\"\"\n        api_key = self.db.query(ApiKey).filter(ApiKey.provider == provider).first()\n        if not api_key:\n            return None\n\n        if key_value is not None:\n            api_key.key_value = key_value\n        if description is not None:\n            api_key.description = description\n        if is_active is not None:\n            api_key.is_active = is_active\n        \n        api_key.updated_at = func.now()\n        self.db.commit()\n        self.db.refresh(api_key)\n        return api_key\n\n    def delete_api_key(self, provider: str) -> bool:\n        \"\"\"Delete an API key by provider\"\"\"\n        api_key = self.db.query(ApiKey).filter(ApiKey.provider == provider).first()\n        if not api_key:\n            return False\n        \n        self.db.delete(api_key)\n        self.db.commit()\n        return True\n\n    def deactivate_api_key(self, provider: str) -> bool:\n        \"\"\"Deactivate an API key instead of deleting it\"\"\"\n        api_key = self.db.query(ApiKey).filter(ApiKey.provider == provider).first()\n        if not api_key:\n            return False\n        \n        api_key.is_active = False\n        api_key.updated_at = func.now()\n        self.db.commit()\n        return True\n\n    def update_last_used(self, provider: str) -> bool:\n        \"\"\"Update the last_used timestamp for an API key\"\"\"\n        api_key = self.db.query(ApiKey).filter(\n            ApiKey.provider == provider,\n            ApiKey.is_active == True\n        ).first()\n        if not api_key:\n            return False\n        \n        api_key.last_used = func.now()\n        self.db.commit()\n        return True\n\n    def bulk_create_or_update(self, api_keys_data: List[dict]) -> List[ApiKey]:\n        \"\"\"Bulk create or update multiple API keys\"\"\"\n        results = []\n        for data in api_keys_data:\n            api_key = self.create_or_update_api_key(\n                provider=data['provider'],\n                key_value=data['key_value'],\n                description=data.get('description'),\n                is_active=data.get('is_active', True)\n            )\n            results.append(api_key)\n        return results "
  },
  {
    "path": "app/backend/repositories/flow_repository.py",
    "content": "from typing import List, Optional\nfrom sqlalchemy.orm import Session\nfrom app.backend.database.models import HedgeFundFlow\n\n\nclass FlowRepository:\n    \"\"\"Repository for HedgeFundFlow CRUD operations\"\"\"\n    \n    def __init__(self, db: Session):\n        self.db = db\n    \n    def create_flow(self, name: str, nodes: dict, edges: dict, description: str = None, \n                   viewport: dict = None, data: dict = None, is_template: bool = False, tags: List[str] = None) -> HedgeFundFlow:\n        \"\"\"Create a new hedge fund flow\"\"\"\n        flow = HedgeFundFlow(\n            name=name,\n            description=description,\n            nodes=nodes,\n            edges=edges,\n            viewport=viewport,\n            data=data,\n            is_template=is_template,\n            tags=tags or []\n        )\n        self.db.add(flow)\n        self.db.commit()\n        self.db.refresh(flow)\n        return flow\n    \n    def get_flow_by_id(self, flow_id: int) -> Optional[HedgeFundFlow]:\n        \"\"\"Get a flow by its ID\"\"\"\n        return self.db.query(HedgeFundFlow).filter(HedgeFundFlow.id == flow_id).first()\n    \n    def get_all_flows(self, include_templates: bool = True) -> List[HedgeFundFlow]:\n        \"\"\"Get all flows, optionally excluding templates\"\"\"\n        query = self.db.query(HedgeFundFlow)\n        if not include_templates:\n            query = query.filter(HedgeFundFlow.is_template == False)\n        return query.order_by(HedgeFundFlow.updated_at.desc()).all()\n    \n    def get_flows_by_name(self, name: str) -> List[HedgeFundFlow]:\n        \"\"\"Search flows by name (case-insensitive partial match)\"\"\"\n        return self.db.query(HedgeFundFlow).filter(\n            HedgeFundFlow.name.ilike(f\"%{name}%\")\n        ).order_by(HedgeFundFlow.updated_at.desc()).all()\n    \n    def update_flow(self, flow_id: int, name: str = None, description: str = None,\n                   nodes: dict = None, edges: dict = None, viewport: dict = None, data: dict = None,\n                   is_template: bool = None, tags: List[str] = None) -> Optional[HedgeFundFlow]:\n        \"\"\"Update an existing flow\"\"\"\n        flow = self.get_flow_by_id(flow_id)\n        if not flow:\n            return None\n        \n        if name is not None:\n            flow.name = name\n        if description is not None:\n            flow.description = description\n        if nodes is not None:\n            flow.nodes = nodes\n        if edges is not None:\n            flow.edges = edges\n        if viewport is not None:\n            flow.viewport = viewport\n        if data is not None:\n            flow.data = data\n        if is_template is not None:\n            flow.is_template = is_template\n        if tags is not None:\n            flow.tags = tags\n        \n        self.db.commit()\n        self.db.refresh(flow)\n        return flow\n    \n    def delete_flow(self, flow_id: int) -> bool:\n        \"\"\"Delete a flow by ID\"\"\"\n        flow = self.get_flow_by_id(flow_id)\n        if not flow:\n            return False\n        \n        self.db.delete(flow)\n        self.db.commit()\n        return True\n    \n    def duplicate_flow(self, flow_id: int, new_name: str = None) -> Optional[HedgeFundFlow]:\n        \"\"\"Create a copy of an existing flow\"\"\"\n        original = self.get_flow_by_id(flow_id)\n        if not original:\n            return None\n        \n        copy_name = new_name or f\"{original.name} (Copy)\"\n        \n        return self.create_flow(\n            name=copy_name,\n            description=original.description,\n            nodes=original.nodes,\n            edges=original.edges,\n            viewport=original.viewport,\n            data=original.data,\n            is_template=False,  # Copies are not templates by default\n            tags=original.tags\n        ) "
  },
  {
    "path": "app/backend/repositories/flow_run_repository.py",
    "content": "from typing import List, Optional, Dict, Any\nfrom datetime import datetime\nfrom sqlalchemy.orm import Session\nfrom sqlalchemy import desc, func\nfrom app.backend.database.models import HedgeFundFlowRun\nfrom app.backend.models.schemas import FlowRunStatus\n\n\nclass FlowRunRepository:\n    \"\"\"Repository for HedgeFundFlowRun CRUD operations\"\"\"\n    \n    def __init__(self, db: Session):\n        self.db = db\n    \n    def create_flow_run(self, flow_id: int, request_data: Dict[str, Any] = None) -> HedgeFundFlowRun:\n        \"\"\"Create a new flow run\"\"\"\n        # Get the next run number for this flow\n        run_number = self._get_next_run_number(flow_id)\n        \n        flow_run = HedgeFundFlowRun(\n            flow_id=flow_id,\n            request_data=request_data,\n            run_number=run_number,\n            status=FlowRunStatus.IDLE.value\n        )\n        self.db.add(flow_run)\n        self.db.commit()\n        self.db.refresh(flow_run)\n        return flow_run\n    \n    def get_flow_run_by_id(self, run_id: int) -> Optional[HedgeFundFlowRun]:\n        \"\"\"Get a flow run by its ID\"\"\"\n        return self.db.query(HedgeFundFlowRun).filter(HedgeFundFlowRun.id == run_id).first()\n    \n    def get_flow_runs_by_flow_id(self, flow_id: int, limit: int = 50, offset: int = 0) -> List[HedgeFundFlowRun]:\n        \"\"\"Get all runs for a specific flow, ordered by most recent first\"\"\"\n        return (\n            self.db.query(HedgeFundFlowRun)\n            .filter(HedgeFundFlowRun.flow_id == flow_id)\n            .order_by(desc(HedgeFundFlowRun.created_at))\n            .limit(limit)\n            .offset(offset)\n            .all()\n        )\n    \n    def get_active_flow_run(self, flow_id: int) -> Optional[HedgeFundFlowRun]:\n        \"\"\"Get the current active (IN_PROGRESS) run for a flow\"\"\"\n        return (\n            self.db.query(HedgeFundFlowRun)\n            .filter(\n                HedgeFundFlowRun.flow_id == flow_id,\n                HedgeFundFlowRun.status == FlowRunStatus.IN_PROGRESS.value\n            )\n            .first()\n        )\n    \n    def get_latest_flow_run(self, flow_id: int) -> Optional[HedgeFundFlowRun]:\n        \"\"\"Get the most recent run for a flow\"\"\"\n        return (\n            self.db.query(HedgeFundFlowRun)\n            .filter(HedgeFundFlowRun.flow_id == flow_id)\n            .order_by(desc(HedgeFundFlowRun.created_at))\n            .first()\n        )\n    \n    def update_flow_run(\n        self,\n        run_id: int,\n        status: Optional[FlowRunStatus] = None,\n        results: Optional[Dict[str, Any]] = None,\n        error_message: Optional[str] = None\n    ) -> Optional[HedgeFundFlowRun]:\n        \"\"\"Update an existing flow run\"\"\"\n        flow_run = self.get_flow_run_by_id(run_id)\n        if not flow_run:\n            return None\n        \n        # Update status and timing\n        if status is not None:\n            flow_run.status = status.value\n            \n            # Update timing based on status\n            if status == FlowRunStatus.IN_PROGRESS and not flow_run.started_at:\n                flow_run.started_at = datetime.utcnow()\n            elif status in [FlowRunStatus.COMPLETE, FlowRunStatus.ERROR] and not flow_run.completed_at:\n                flow_run.completed_at = datetime.utcnow()\n        \n        # Update results and error message\n        if results is not None:\n            flow_run.results = results\n        if error_message is not None:\n            flow_run.error_message = error_message\n        \n        self.db.commit()\n        self.db.refresh(flow_run)\n        return flow_run\n    \n    def delete_flow_run(self, run_id: int) -> bool:\n        \"\"\"Delete a flow run by ID\"\"\"\n        flow_run = self.get_flow_run_by_id(run_id)\n        if not flow_run:\n            return False\n        \n        self.db.delete(flow_run)\n        self.db.commit()\n        return True\n    \n    def delete_flow_runs_by_flow_id(self, flow_id: int) -> int:\n        \"\"\"Delete all runs for a specific flow. Returns count of deleted runs.\"\"\"\n        deleted_count = (\n            self.db.query(HedgeFundFlowRun)\n            .filter(HedgeFundFlowRun.flow_id == flow_id)\n            .delete()\n        )\n        self.db.commit()\n        return deleted_count\n    \n    def get_flow_run_count(self, flow_id: int) -> int:\n        \"\"\"Get total count of runs for a flow\"\"\"\n        return (\n            self.db.query(HedgeFundFlowRun)\n            .filter(HedgeFundFlowRun.flow_id == flow_id)\n            .count()\n        )\n    \n    def _get_next_run_number(self, flow_id: int) -> int:\n        \"\"\"Get the next run number for a flow\"\"\"\n        max_run_number = (\n            self.db.query(func.max(HedgeFundFlowRun.run_number))\n            .filter(HedgeFundFlowRun.flow_id == flow_id)\n            .scalar()\n        )\n        return (max_run_number or 0) + 1 "
  },
  {
    "path": "app/backend/routes/__init__.py",
    "content": "from fastapi import APIRouter\n\nfrom app.backend.routes.hedge_fund import router as hedge_fund_router\nfrom app.backend.routes.health import router as health_router\nfrom app.backend.routes.storage import router as storage_router\nfrom app.backend.routes.flows import router as flows_router\nfrom app.backend.routes.flow_runs import router as flow_runs_router\nfrom app.backend.routes.ollama import router as ollama_router\nfrom app.backend.routes.language_models import router as language_models_router\nfrom app.backend.routes.api_keys import router as api_keys_router\n\n# Main API router\napi_router = APIRouter()\n\n# Include sub-routers\napi_router.include_router(health_router, tags=[\"health\"])\napi_router.include_router(hedge_fund_router, tags=[\"hedge-fund\"])\napi_router.include_router(storage_router, tags=[\"storage\"])\napi_router.include_router(flows_router, tags=[\"flows\"])\napi_router.include_router(flow_runs_router, tags=[\"flow-runs\"])\napi_router.include_router(ollama_router, tags=[\"ollama\"])\napi_router.include_router(language_models_router, tags=[\"language-models\"])\napi_router.include_router(api_keys_router, tags=[\"api-keys\"])\n"
  },
  {
    "path": "app/backend/routes/api_keys.py",
    "content": "from fastapi import APIRouter, HTTPException, Depends\nfrom sqlalchemy.orm import Session\nfrom typing import List\n\nfrom app.backend.database import get_db\nfrom app.backend.repositories.api_key_repository import ApiKeyRepository\nfrom app.backend.models.schemas import (\n    ApiKeyCreateRequest,\n    ApiKeyUpdateRequest,\n    ApiKeyResponse,\n    ApiKeySummaryResponse,\n    ApiKeyBulkUpdateRequest,\n    ErrorResponse\n)\n\nrouter = APIRouter(prefix=\"/api-keys\", tags=[\"api-keys\"])\n\n\n@router.post(\n    \"/\",\n    response_model=ApiKeyResponse,\n    responses={\n        400: {\"model\": ErrorResponse, \"description\": \"Invalid request\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def create_or_update_api_key(request: ApiKeyCreateRequest, db: Session = Depends(get_db)):\n    \"\"\"Create a new API key or update existing one\"\"\"\n    try:\n        repo = ApiKeyRepository(db)\n        api_key = repo.create_or_update_api_key(\n            provider=request.provider,\n            key_value=request.key_value,\n            description=request.description,\n            is_active=request.is_active\n        )\n        return ApiKeyResponse.from_orm(api_key)\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to create/update API key: {str(e)}\")\n\n\n@router.get(\n    \"/\",\n    response_model=List[ApiKeySummaryResponse],\n    responses={\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_api_keys(include_inactive: bool = False, db: Session = Depends(get_db)):\n    \"\"\"Get all API keys (without actual key values for security)\"\"\"\n    try:\n        repo = ApiKeyRepository(db)\n        api_keys = repo.get_all_api_keys(include_inactive=include_inactive)\n        return [ApiKeySummaryResponse.from_orm(key) for key in api_keys]\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to retrieve API keys: {str(e)}\")\n\n\n@router.get(\n    \"/{provider}\",\n    response_model=ApiKeyResponse,\n    responses={\n        404: {\"model\": ErrorResponse, \"description\": \"API key not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_api_key(provider: str, db: Session = Depends(get_db)):\n    \"\"\"Get a specific API key by provider\"\"\"\n    try:\n        repo = ApiKeyRepository(db)\n        api_key = repo.get_api_key_by_provider(provider)\n        if not api_key:\n            raise HTTPException(status_code=404, detail=\"API key not found\")\n        return ApiKeyResponse.from_orm(api_key)\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to retrieve API key: {str(e)}\")\n\n\n@router.put(\n    \"/{provider}\",\n    response_model=ApiKeyResponse,\n    responses={\n        404: {\"model\": ErrorResponse, \"description\": \"API key not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def update_api_key(provider: str, request: ApiKeyUpdateRequest, db: Session = Depends(get_db)):\n    \"\"\"Update an existing API key\"\"\"\n    try:\n        repo = ApiKeyRepository(db)\n        api_key = repo.update_api_key(\n            provider=provider,\n            key_value=request.key_value,\n            description=request.description,\n            is_active=request.is_active\n        )\n        if not api_key:\n            raise HTTPException(status_code=404, detail=\"API key not found\")\n        return ApiKeyResponse.from_orm(api_key)\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to update API key: {str(e)}\")\n\n\n@router.delete(\n    \"/{provider}\",\n    responses={\n        204: {\"description\": \"API key deleted successfully\"},\n        404: {\"model\": ErrorResponse, \"description\": \"API key not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def delete_api_key(provider: str, db: Session = Depends(get_db)):\n    \"\"\"Delete an API key\"\"\"\n    try:\n        repo = ApiKeyRepository(db)\n        success = repo.delete_api_key(provider)\n        if not success:\n            raise HTTPException(status_code=404, detail=\"API key not found\")\n        return {\"message\": \"API key deleted successfully\"}\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to delete API key: {str(e)}\")\n\n\n@router.patch(\n    \"/{provider}/deactivate\",\n    response_model=ApiKeySummaryResponse,\n    responses={\n        404: {\"model\": ErrorResponse, \"description\": \"API key not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def deactivate_api_key(provider: str, db: Session = Depends(get_db)):\n    \"\"\"Deactivate an API key without deleting it\"\"\"\n    try:\n        repo = ApiKeyRepository(db)\n        success = repo.deactivate_api_key(provider)\n        if not success:\n            raise HTTPException(status_code=404, detail=\"API key not found\")\n        \n        # Return the updated key\n        api_key = repo.get_api_key_by_provider(provider)\n        return ApiKeySummaryResponse.from_orm(api_key)\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to deactivate API key: {str(e)}\")\n\n\n@router.post(\n    \"/bulk\",\n    response_model=List[ApiKeyResponse],\n    responses={\n        400: {\"model\": ErrorResponse, \"description\": \"Invalid request\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def bulk_update_api_keys(request: ApiKeyBulkUpdateRequest, db: Session = Depends(get_db)):\n    \"\"\"Bulk create or update multiple API keys\"\"\"\n    try:\n        repo = ApiKeyRepository(db)\n        api_keys_data = [\n            {\n                'provider': key.provider,\n                'key_value': key.key_value,\n                'description': key.description,\n                'is_active': key.is_active\n            }\n            for key in request.api_keys\n        ]\n        api_keys = repo.bulk_create_or_update(api_keys_data)\n        return [ApiKeyResponse.from_orm(key) for key in api_keys]\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to bulk update API keys: {str(e)}\")\n\n\n@router.patch(\n    \"/{provider}/last-used\",\n    responses={\n        200: {\"description\": \"Last used timestamp updated\"},\n        404: {\"model\": ErrorResponse, \"description\": \"API key not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def update_last_used(provider: str, db: Session = Depends(get_db)):\n    \"\"\"Update the last used timestamp for an API key\"\"\"\n    try:\n        repo = ApiKeyRepository(db)\n        success = repo.update_last_used(provider)\n        if not success:\n            raise HTTPException(status_code=404, detail=\"API key not found\")\n        return {\"message\": \"Last used timestamp updated\"}\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to update last used timestamp: {str(e)}\") "
  },
  {
    "path": "app/backend/routes/flow_runs.py",
    "content": "from fastapi import APIRouter, HTTPException, Depends, Query\nfrom sqlalchemy.orm import Session\nfrom typing import List, Optional\n\nfrom app.backend.database import get_db\nfrom app.backend.repositories.flow_run_repository import FlowRunRepository\nfrom app.backend.repositories.flow_repository import FlowRepository\nfrom app.backend.models.schemas import (\n    FlowRunCreateRequest,\n    FlowRunUpdateRequest,\n    FlowRunResponse,\n    FlowRunSummaryResponse,\n    FlowRunStatus,\n    ErrorResponse\n)\n\nrouter = APIRouter(prefix=\"/flows/{flow_id}/runs\", tags=[\"flow-runs\"])\n\n\n@router.post(\n    \"/\",\n    response_model=FlowRunResponse,\n    responses={\n        404: {\"model\": ErrorResponse, \"description\": \"Flow not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def create_flow_run(\n    flow_id: int, \n    request: FlowRunCreateRequest, \n    db: Session = Depends(get_db)\n):\n    \"\"\"Create a new flow run for the specified flow\"\"\"\n    try:\n        # Verify flow exists\n        flow_repo = FlowRepository(db)\n        flow = flow_repo.get_flow_by_id(flow_id)\n        if not flow:\n            raise HTTPException(status_code=404, detail=\"Flow not found\")\n        \n        # Create the flow run\n        run_repo = FlowRunRepository(db)\n        flow_run = run_repo.create_flow_run(\n            flow_id=flow_id,\n            request_data=request.request_data\n        )\n        return FlowRunResponse.from_orm(flow_run)\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to create flow run: {str(e)}\")\n\n\n@router.get(\n    \"/\",\n    response_model=List[FlowRunSummaryResponse],\n    responses={\n        404: {\"model\": ErrorResponse, \"description\": \"Flow not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_flow_runs(\n    flow_id: int,\n    limit: int = Query(50, ge=1, le=100, description=\"Maximum number of runs to return\"),\n    offset: int = Query(0, ge=0, description=\"Number of runs to skip\"),\n    db: Session = Depends(get_db)\n):\n    \"\"\"Get all runs for the specified flow\"\"\"\n    try:\n        # Verify flow exists\n        flow_repo = FlowRepository(db)\n        flow = flow_repo.get_flow_by_id(flow_id)\n        if not flow:\n            raise HTTPException(status_code=404, detail=\"Flow not found\")\n        \n        # Get flow runs\n        run_repo = FlowRunRepository(db)\n        flow_runs = run_repo.get_flow_runs_by_flow_id(flow_id, limit=limit, offset=offset)\n        return [FlowRunSummaryResponse.from_orm(run) for run in flow_runs]\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to retrieve flow runs: {str(e)}\")\n\n\n@router.get(\n    \"/active\",\n    response_model=Optional[FlowRunResponse],\n    responses={\n        404: {\"model\": ErrorResponse, \"description\": \"Flow not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_active_flow_run(flow_id: int, db: Session = Depends(get_db)):\n    \"\"\"Get the current active (IN_PROGRESS) run for the specified flow\"\"\"\n    try:\n        # Verify flow exists\n        flow_repo = FlowRepository(db)\n        flow = flow_repo.get_flow_by_id(flow_id)\n        if not flow:\n            raise HTTPException(status_code=404, detail=\"Flow not found\")\n        \n        # Get active flow run\n        run_repo = FlowRunRepository(db)\n        active_run = run_repo.get_active_flow_run(flow_id)\n        return FlowRunResponse.from_orm(active_run) if active_run else None\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to retrieve active flow run: {str(e)}\")\n\n\n@router.get(\n    \"/latest\",\n    response_model=Optional[FlowRunResponse],\n    responses={\n        404: {\"model\": ErrorResponse, \"description\": \"Flow not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_latest_flow_run(flow_id: int, db: Session = Depends(get_db)):\n    \"\"\"Get the most recent run for the specified flow\"\"\"\n    try:\n        # Verify flow exists\n        flow_repo = FlowRepository(db)\n        flow = flow_repo.get_flow_by_id(flow_id)\n        if not flow:\n            raise HTTPException(status_code=404, detail=\"Flow not found\")\n        \n        # Get latest flow run\n        run_repo = FlowRunRepository(db)\n        latest_run = run_repo.get_latest_flow_run(flow_id)\n        return FlowRunResponse.from_orm(latest_run) if latest_run else None\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to retrieve latest flow run: {str(e)}\")\n\n\n@router.get(\n    \"/{run_id}\",\n    response_model=FlowRunResponse,\n    responses={\n        404: {\"model\": ErrorResponse, \"description\": \"Flow or run not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_flow_run(flow_id: int, run_id: int, db: Session = Depends(get_db)):\n    \"\"\"Get a specific flow run by ID\"\"\"\n    try:\n        # Verify flow exists\n        flow_repo = FlowRepository(db)\n        flow = flow_repo.get_flow_by_id(flow_id)\n        if not flow:\n            raise HTTPException(status_code=404, detail=\"Flow not found\")\n        \n        # Get flow run\n        run_repo = FlowRunRepository(db)\n        flow_run = run_repo.get_flow_run_by_id(run_id)\n        if not flow_run or flow_run.flow_id != flow_id:\n            raise HTTPException(status_code=404, detail=\"Flow run not found\")\n        \n        return FlowRunResponse.from_orm(flow_run)\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to retrieve flow run: {str(e)}\")\n\n\n@router.put(\n    \"/{run_id}\",\n    response_model=FlowRunResponse,\n    responses={\n        404: {\"model\": ErrorResponse, \"description\": \"Flow or run not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def update_flow_run(\n    flow_id: int, \n    run_id: int, \n    request: FlowRunUpdateRequest, \n    db: Session = Depends(get_db)\n):\n    \"\"\"Update an existing flow run\"\"\"\n    try:\n        # Verify flow exists\n        flow_repo = FlowRepository(db)\n        flow = flow_repo.get_flow_by_id(flow_id)\n        if not flow:\n            raise HTTPException(status_code=404, detail=\"Flow not found\")\n        \n        # Update flow run\n        run_repo = FlowRunRepository(db)\n        # First verify the run exists and belongs to this flow\n        existing_run = run_repo.get_flow_run_by_id(run_id)\n        if not existing_run or existing_run.flow_id != flow_id:\n            raise HTTPException(status_code=404, detail=\"Flow run not found\")\n        \n        flow_run = run_repo.update_flow_run(\n            run_id=run_id,\n            status=request.status,\n            results=request.results,\n            error_message=request.error_message\n        )\n        \n        if not flow_run:\n            raise HTTPException(status_code=404, detail=\"Flow run not found\")\n        \n        return FlowRunResponse.from_orm(flow_run)\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to update flow run: {str(e)}\")\n\n\n@router.delete(\n    \"/{run_id}\",\n    responses={\n        204: {\"description\": \"Flow run deleted successfully\"},\n        404: {\"model\": ErrorResponse, \"description\": \"Flow or run not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def delete_flow_run(flow_id: int, run_id: int, db: Session = Depends(get_db)):\n    \"\"\"Delete a flow run\"\"\"\n    try:\n        # Verify flow exists\n        flow_repo = FlowRepository(db)\n        flow = flow_repo.get_flow_by_id(flow_id)\n        if not flow:\n            raise HTTPException(status_code=404, detail=\"Flow not found\")\n        \n        # Verify run exists and belongs to this flow\n        run_repo = FlowRunRepository(db)\n        existing_run = run_repo.get_flow_run_by_id(run_id)\n        if not existing_run or existing_run.flow_id != flow_id:\n            raise HTTPException(status_code=404, detail=\"Flow run not found\")\n        \n        success = run_repo.delete_flow_run(run_id)\n        if not success:\n            raise HTTPException(status_code=404, detail=\"Flow run not found\")\n        \n        return {\"message\": \"Flow run deleted successfully\"}\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to delete flow run: {str(e)}\")\n\n\n@router.delete(\n    \"/\",\n    responses={\n        204: {\"description\": \"All flow runs deleted successfully\"},\n        404: {\"model\": ErrorResponse, \"description\": \"Flow not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def delete_all_flow_runs(flow_id: int, db: Session = Depends(get_db)):\n    \"\"\"Delete all runs for the specified flow\"\"\"\n    try:\n        # Verify flow exists\n        flow_repo = FlowRepository(db)\n        flow = flow_repo.get_flow_by_id(flow_id)\n        if not flow:\n            raise HTTPException(status_code=404, detail=\"Flow not found\")\n        \n        # Delete all flow runs\n        run_repo = FlowRunRepository(db)\n        deleted_count = run_repo.delete_flow_runs_by_flow_id(flow_id)\n        \n        return {\"message\": f\"Deleted {deleted_count} flow runs successfully\"}\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to delete flow runs: {str(e)}\")\n\n\n@router.get(\n    \"/count\",\n    responses={\n        200: {\"description\": \"Flow run count\"},\n        404: {\"model\": ErrorResponse, \"description\": \"Flow not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_flow_run_count(flow_id: int, db: Session = Depends(get_db)):\n    \"\"\"Get the total count of runs for the specified flow\"\"\"\n    try:\n        # Verify flow exists\n        flow_repo = FlowRepository(db)\n        flow = flow_repo.get_flow_by_id(flow_id)\n        if not flow:\n            raise HTTPException(status_code=404, detail=\"Flow not found\")\n        \n        # Get run count\n        run_repo = FlowRunRepository(db)\n        count = run_repo.get_flow_run_count(flow_id)\n        \n        return {\"flow_id\": flow_id, \"total_runs\": count}\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to get flow run count: {str(e)}\") "
  },
  {
    "path": "app/backend/routes/flows.py",
    "content": "from fastapi import APIRouter, HTTPException, Depends\nfrom sqlalchemy.orm import Session\nfrom typing import List\n\nfrom app.backend.database import get_db\nfrom app.backend.repositories.flow_repository import FlowRepository\nfrom app.backend.models.schemas import (\n    FlowCreateRequest, \n    FlowUpdateRequest, \n    FlowResponse, \n    FlowSummaryResponse,\n    ErrorResponse\n)\n\nrouter = APIRouter(prefix=\"/flows\", tags=[\"flows\"])\n\n\n@router.post(\n    \"/\",\n    response_model=FlowResponse,\n    responses={\n        400: {\"model\": ErrorResponse, \"description\": \"Invalid request\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def create_flow(request: FlowCreateRequest, db: Session = Depends(get_db)):\n    \"\"\"Create a new hedge fund flow\"\"\"\n    try:\n        repo = FlowRepository(db)\n        flow = repo.create_flow(\n            name=request.name,\n            description=request.description,\n            nodes=request.nodes,\n            edges=request.edges,\n            viewport=request.viewport,\n            data=request.data,\n            is_template=request.is_template,\n            tags=request.tags\n        )\n        return FlowResponse.from_orm(flow)\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to create flow: {str(e)}\")\n\n\n@router.get(\n    \"/\",\n    response_model=List[FlowSummaryResponse],\n    responses={\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_flows(include_templates: bool = True, db: Session = Depends(get_db)):\n    \"\"\"Get all flows (summary view)\"\"\"\n    try:\n        repo = FlowRepository(db)\n        flows = repo.get_all_flows(include_templates=include_templates)\n        return [FlowSummaryResponse.from_orm(flow) for flow in flows]\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to retrieve flows: {str(e)}\")\n\n\n@router.get(\n    \"/{flow_id}\",\n    response_model=FlowResponse,\n    responses={\n        404: {\"model\": ErrorResponse, \"description\": \"Flow not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_flow(flow_id: int, db: Session = Depends(get_db)):\n    \"\"\"Get a specific flow by ID\"\"\"\n    try:\n        repo = FlowRepository(db)\n        flow = repo.get_flow_by_id(flow_id)\n        if not flow:\n            raise HTTPException(status_code=404, detail=\"Flow not found\")\n        return FlowResponse.from_orm(flow)\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to retrieve flow: {str(e)}\")\n\n\n@router.put(\n    \"/{flow_id}\",\n    response_model=FlowResponse,\n    responses={\n        404: {\"model\": ErrorResponse, \"description\": \"Flow not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def update_flow(flow_id: int, request: FlowUpdateRequest, db: Session = Depends(get_db)):\n    \"\"\"Update an existing flow\"\"\"\n    try:\n        repo = FlowRepository(db)\n        flow = repo.update_flow(\n            flow_id=flow_id,\n            name=request.name,\n            description=request.description,\n            nodes=request.nodes,\n            edges=request.edges,\n            viewport=request.viewport,\n            data=request.data,\n            is_template=request.is_template,\n            tags=request.tags\n        )\n        if not flow:\n            raise HTTPException(status_code=404, detail=\"Flow not found\")\n        return FlowResponse.from_orm(flow)\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to update flow: {str(e)}\")\n\n\n@router.delete(\n    \"/{flow_id}\",\n    responses={\n        204: {\"description\": \"Flow deleted successfully\"},\n        404: {\"model\": ErrorResponse, \"description\": \"Flow not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def delete_flow(flow_id: int, db: Session = Depends(get_db)):\n    \"\"\"Delete a flow\"\"\"\n    try:\n        repo = FlowRepository(db)\n        success = repo.delete_flow(flow_id)\n        if not success:\n            raise HTTPException(status_code=404, detail=\"Flow not found\")\n        return {\"message\": \"Flow deleted successfully\"}\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to delete flow: {str(e)}\")\n\n\n@router.post(\n    \"/{flow_id}/duplicate\",\n    response_model=FlowResponse,\n    responses={\n        404: {\"model\": ErrorResponse, \"description\": \"Flow not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def duplicate_flow(flow_id: int, new_name: str = None, db: Session = Depends(get_db)):\n    \"\"\"Create a copy of an existing flow\"\"\"\n    try:\n        repo = FlowRepository(db)\n        flow = repo.duplicate_flow(flow_id, new_name)\n        if not flow:\n            raise HTTPException(status_code=404, detail=\"Flow not found\")\n        return FlowResponse.from_orm(flow)\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to duplicate flow: {str(e)}\")\n\n\n@router.get(\n    \"/search/{name}\",\n    response_model=List[FlowSummaryResponse],\n    responses={\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def search_flows(name: str, db: Session = Depends(get_db)):\n    \"\"\"Search flows by name\"\"\"\n    try:\n        repo = FlowRepository(db)\n        flows = repo.get_flows_by_name(name)\n        return [FlowSummaryResponse.from_orm(flow) for flow in flows]\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to search flows: {str(e)}\") "
  },
  {
    "path": "app/backend/routes/health.py",
    "content": "from fastapi import APIRouter\nfrom fastapi.responses import StreamingResponse\nimport asyncio\nimport json\n\nrouter = APIRouter()\n\n\n@router.get(\"/\")\nasync def root():\n    return {\"message\": \"Welcome to AI Hedge Fund API\"}\n\n\n@router.get(\"/ping\")\nasync def ping():\n    async def event_generator():\n        for i in range(5):\n            # Create a JSON object for each ping\n            data = {\"ping\": f\"ping {i+1}/5\", \"timestamp\": i + 1}\n\n            # Format as SSE\n            yield f\"data: {json.dumps(data)}\\n\\n\"\n\n            # Wait 1 second\n            await asyncio.sleep(1)\n\n    return StreamingResponse(event_generator(), media_type=\"text/event-stream\")\n"
  },
  {
    "path": "app/backend/routes/hedge_fund.py",
    "content": "from fastapi import APIRouter, HTTPException, Request, Depends\nfrom fastapi.responses import StreamingResponse\nfrom sqlalchemy.orm import Session\nimport asyncio\n\nfrom app.backend.database import get_db\nfrom app.backend.models.schemas import ErrorResponse, HedgeFundRequest, BacktestRequest, BacktestDayResult, BacktestPerformanceMetrics\nfrom app.backend.models.events import StartEvent, ProgressUpdateEvent, ErrorEvent, CompleteEvent\nfrom app.backend.services.graph import create_graph, parse_hedge_fund_response, run_graph_async\nfrom app.backend.services.portfolio import create_portfolio\nfrom app.backend.services.backtest_service import BacktestService\nfrom app.backend.services.api_key_service import ApiKeyService\nfrom src.utils.progress import progress\nfrom src.utils.analysts import get_agents_list\n\nrouter = APIRouter(prefix=\"/hedge-fund\")\n\n@router.post(\n    path=\"/run\",\n    responses={\n        200: {\"description\": \"Successful response with streaming updates\"},\n        400: {\"model\": ErrorResponse, \"description\": \"Invalid request parameters\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def run(request_data: HedgeFundRequest, request: Request, db: Session = Depends(get_db)):\n    try:\n        # Hydrate API keys from database if not provided\n        if not request_data.api_keys:\n            api_key_service = ApiKeyService(db)\n            request_data.api_keys = api_key_service.get_api_keys_dict()\n\n        # Create the portfolio\n        portfolio = create_portfolio(request_data.initial_cash, request_data.margin_requirement, request_data.tickers, request_data.portfolio_positions)\n\n        # Construct agent graph using the React Flow graph structure\n        graph = create_graph(\n            graph_nodes=request_data.graph_nodes,\n            graph_edges=request_data.graph_edges\n        )\n        graph = graph.compile()\n\n        # Log a test progress update for debugging\n        progress.update_status(\"system\", None, \"Preparing hedge fund run\")\n\n        # Convert model_provider to string if it's an enum\n        model_provider = request_data.model_provider\n        if hasattr(model_provider, \"value\"):\n            model_provider = model_provider.value\n\n        # Function to detect client disconnection\n        async def wait_for_disconnect():\n            \"\"\"Wait for client disconnect and return True when it happens\"\"\"\n            try:\n                while True:\n                    message = await request.receive()\n                    if message[\"type\"] == \"http.disconnect\":\n                        return True\n            except Exception:\n                return True\n\n        # Set up streaming response\n        async def event_generator():\n            # Queue for progress updates\n            progress_queue = asyncio.Queue()\n            run_task = None\n            disconnect_task = None\n\n            # Simple handler to add updates to the queue\n            def progress_handler(agent_name, ticker, status, analysis, timestamp):\n                event = ProgressUpdateEvent(agent=agent_name, ticker=ticker, status=status, timestamp=timestamp, analysis=analysis)\n                progress_queue.put_nowait(event)\n\n            # Register our handler with the progress tracker\n            progress.register_handler(progress_handler)\n\n            try:\n                # Start the graph execution in a background task\n                run_task = asyncio.create_task(\n                    run_graph_async(\n                        graph=graph,\n                        portfolio=portfolio,\n                        tickers=request_data.tickers,\n                        start_date=request_data.start_date,\n                        end_date=request_data.end_date,\n                        model_name=request_data.model_name,\n                        model_provider=model_provider,\n                        request=request_data,  # Pass the full request for agent-specific model access\n                    )\n                )\n                \n                # Start the disconnect detection task\n                disconnect_task = asyncio.create_task(wait_for_disconnect())\n                \n                # Send initial message\n                yield StartEvent().to_sse()\n\n                # Stream progress updates until run_task completes or client disconnects\n                while not run_task.done():\n                    # Check if client disconnected\n                    if disconnect_task.done():\n                        print(\"Client disconnected, cancelling hedge fund execution\")\n                        run_task.cancel()\n                        try:\n                            await run_task\n                        except asyncio.CancelledError:\n                            pass\n                        return\n\n                    # Either get a progress update or wait a bit\n                    try:\n                        event = await asyncio.wait_for(progress_queue.get(), timeout=1.0)\n                        yield event.to_sse()\n                    except asyncio.TimeoutError:\n                        # Just continue the loop\n                        pass\n\n                # Get the final result\n                try:\n                    result = await run_task\n                except asyncio.CancelledError:\n                    print(\"Task was cancelled\")\n                    return\n\n                if not result or not result.get(\"messages\"):\n                    yield ErrorEvent(message=\"Failed to generate hedge fund decisions\").to_sse()\n                    return\n\n                # Send the final result\n                final_data = CompleteEvent(\n                    data={\n                        \"decisions\": parse_hedge_fund_response(result.get(\"messages\", [])[-1].content),\n                        \"analyst_signals\": result.get(\"data\", {}).get(\"analyst_signals\", {}),\n                        \"current_prices\": result.get(\"data\", {}).get(\"current_prices\", {}),\n                    }\n                )\n                yield final_data.to_sse()\n\n            except asyncio.CancelledError:\n                print(\"Event generator cancelled\")\n                return\n            finally:\n                # Clean up\n                progress.unregister_handler(progress_handler)\n                if run_task and not run_task.done():\n                    run_task.cancel()\n                    try:\n                        await run_task\n                    except asyncio.CancelledError:\n                        pass\n                if disconnect_task and not disconnect_task.done():\n                    disconnect_task.cancel()\n\n        # Return a streaming response\n        return StreamingResponse(event_generator(), media_type=\"text/event-stream\")\n\n    except HTTPException as e:\n        raise e\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"An error occurred while processing the request: {str(e)}\")\n\n@router.post(\n    path=\"/backtest\",\n    responses={\n        200: {\"description\": \"Successful response with streaming backtest updates\"},\n        400: {\"model\": ErrorResponse, \"description\": \"Invalid request parameters\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def backtest(request_data: BacktestRequest, request: Request, db: Session = Depends(get_db)):\n    \"\"\"Run a continuous backtest over a time period with streaming updates.\"\"\"\n    try:\n        # Hydrate API keys from database if not provided\n        if not request_data.api_keys:\n            api_key_service = ApiKeyService(db)\n            request_data.api_keys = api_key_service.get_api_keys_dict()\n\n        # Convert model_provider to string if it's an enum\n        model_provider = request_data.model_provider\n        if hasattr(model_provider, \"value\"):\n            model_provider = model_provider.value\n\n        # Create the portfolio (same as /run endpoint)\n        portfolio = create_portfolio(\n            request_data.initial_capital, \n            request_data.margin_requirement, \n            request_data.tickers, \n            request_data.portfolio_positions\n        )\n\n        # Construct agent graph using the React Flow graph structure (same as /run endpoint)\n        graph = create_graph(graph_nodes=request_data.graph_nodes, graph_edges=request_data.graph_edges)\n        graph = graph.compile()\n\n        # Create backtest service with the compiled graph\n        backtest_service = BacktestService(\n            graph=graph,\n            portfolio=portfolio,\n            tickers=request_data.tickers,\n            start_date=request_data.start_date,\n            end_date=request_data.end_date,\n            initial_capital=request_data.initial_capital,\n            model_name=request_data.model_name,\n            model_provider=model_provider,\n            request=request_data,  # Pass the full request for agent-specific model access\n        )\n\n        # Function to detect client disconnection\n        async def wait_for_disconnect():\n            \"\"\"Wait for client disconnect and return True when it happens\"\"\"\n            try:\n                while True:\n                    message = await request.receive()\n                    if message[\"type\"] == \"http.disconnect\":\n                        return True\n            except Exception:\n                return True\n\n        # Set up streaming response\n        async def event_generator():\n            progress_queue = asyncio.Queue()\n            backtest_task = None\n            disconnect_task = None\n\n            # Global progress handler to capture individual agent updates during backtest\n            def progress_handler(agent_name, ticker, status, analysis, timestamp):\n                event = ProgressUpdateEvent(agent=agent_name, ticker=ticker, status=status, timestamp=timestamp, analysis=analysis)\n                progress_queue.put_nowait(event)\n\n            # Progress callback to handle backtest-specific updates\n            def progress_callback(update):\n                if update[\"type\"] == \"progress\":\n                    event = ProgressUpdateEvent(\n                        agent=\"backtest\",\n                        ticker=None,\n                        status=f\"Processing {update['current_date']} ({update['current_step']}/{update['total_dates']})\",\n                        timestamp=None,\n                        analysis=None\n                    )\n                    progress_queue.put_nowait(event)\n                elif update[\"type\"] == \"backtest_result\":\n                    # Convert day result to a streaming event\n                    backtest_result = BacktestDayResult(**update[\"data\"])\n                    \n                    # Send the full day result data as JSON in the analysis field\n                    import json\n                    analysis_data = json.dumps(update[\"data\"])\n                    \n                    event = ProgressUpdateEvent(\n                        agent=\"backtest\",\n                        ticker=None,\n                        status=f\"Completed {backtest_result.date} - Portfolio: ${backtest_result.portfolio_value:,.2f}\",\n                        timestamp=None,\n                        analysis=analysis_data\n                    )\n                    progress_queue.put_nowait(event)\n\n            # Register our handler with the progress tracker to capture agent updates\n            progress.register_handler(progress_handler)\n            \n            try:\n                # Start the backtest in a background task\n                backtest_task = asyncio.create_task(\n                    backtest_service.run_backtest_async(progress_callback=progress_callback)\n                )\n                \n                # Start the disconnect detection task\n                disconnect_task = asyncio.create_task(wait_for_disconnect())\n                \n                # Send initial message\n                yield StartEvent().to_sse()\n\n                # Stream progress updates until backtest_task completes or client disconnects\n                while not backtest_task.done():\n                    # Check if client disconnected\n                    if disconnect_task.done():\n                        print(\"Client disconnected, cancelling backtest execution\")\n                        backtest_task.cancel()\n                        try:\n                            await backtest_task\n                        except asyncio.CancelledError:\n                            pass\n                        return\n\n                    # Either get a progress update or wait a bit\n                    try:\n                        event = await asyncio.wait_for(progress_queue.get(), timeout=1.0)\n                        yield event.to_sse()\n                    except asyncio.TimeoutError:\n                        # Just continue the loop\n                        pass\n\n                # Get the final result\n                try:\n                    result = await backtest_task\n                except asyncio.CancelledError:\n                    print(\"Backtest task was cancelled\")\n                    return\n\n                if not result:\n                    yield ErrorEvent(message=\"Failed to complete backtest\").to_sse()\n                    return\n\n                # Send the final result\n                performance_metrics = BacktestPerformanceMetrics(**result[\"performance_metrics\"])\n                final_data = CompleteEvent(\n                    data={\n                        \"performance_metrics\": performance_metrics.model_dump(),\n                        \"final_portfolio\": result[\"final_portfolio\"],\n                        \"total_days\": len(result[\"results\"]),\n                    }\n                )\n                yield final_data.to_sse()\n\n            except asyncio.CancelledError:\n                print(\"Backtest event generator cancelled\")\n                return\n            finally:\n                # Clean up\n                progress.unregister_handler(progress_handler)\n                if backtest_task and not backtest_task.done():\n                    backtest_task.cancel()\n                    try:\n                        await backtest_task\n                    except asyncio.CancelledError:\n                        pass\n                if disconnect_task and not disconnect_task.done():\n                    disconnect_task.cancel()\n\n        # Return a streaming response\n        return StreamingResponse(event_generator(), media_type=\"text/event-stream\")\n\n    except HTTPException as e:\n        raise e\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"An error occurred while processing the backtest request: {str(e)}\")\n\n\n@router.get(\n    path=\"/agents\",\n    responses={\n        200: {\"description\": \"List of available agents\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_agents():\n    \"\"\"Get the list of available agents.\"\"\"\n    try:\n        return {\"agents\": get_agents_list()}\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to retrieve agents: {str(e)}\")\n\n"
  },
  {
    "path": "app/backend/routes/language_models.py",
    "content": "from fastapi import APIRouter, HTTPException\nfrom typing import List, Dict, Any\n\nfrom app.backend.models.schemas import ErrorResponse\nfrom app.backend.services.ollama_service import OllamaService\nfrom src.llm.models import get_models_list\n\nrouter = APIRouter(prefix=\"/language-models\")\n\n# Initialize Ollama service\nollama_service = OllamaService()\n\n@router.get(\n    path=\"/\",\n    responses={\n        200: {\"description\": \"List of available language models\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_language_models():\n    \"\"\"Get the list of available cloud-based and Ollama language models.\"\"\"\n    try:\n        # Start with cloud models\n        models = get_models_list()\n        \n        # Add available Ollama models (handles all checking internally)\n        ollama_models = await ollama_service.get_available_models()\n        models.extend(ollama_models)\n        \n        return {\"models\": models}\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to retrieve models: {str(e)}\")\n\n@router.get(\n    path=\"/providers\",\n    responses={\n        200: {\"description\": \"List of available model providers\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_language_model_providers():\n    \"\"\"Get the list of available model providers with their models grouped.\"\"\"\n    try:\n        models = get_models_list()\n        \n        # Group models by provider\n        providers = {}\n        for model in models:\n            provider_name = model[\"provider\"]\n            if provider_name not in providers:\n                providers[provider_name] = {\n                    \"name\": provider_name,\n                    \"models\": []\n                }\n            providers[provider_name][\"models\"].append({\n                \"display_name\": model[\"display_name\"],\n                \"model_name\": model[\"model_name\"]\n            })\n        \n        return {\"providers\": list(providers.values())}\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to retrieve providers: {str(e)}\") "
  },
  {
    "path": "app/backend/routes/ollama.py",
    "content": "from fastapi import APIRouter, HTTPException\nfrom fastapi.responses import StreamingResponse\nfrom pydantic import BaseModel\nfrom typing import List, Dict, Any\nimport logging\n\nfrom app.backend.models.schemas import ErrorResponse\nfrom app.backend.services.ollama_service import ollama_service\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/ollama\")\n\nclass ModelRequest(BaseModel):\n    model_name: str\n\nclass OllamaStatusResponse(BaseModel):\n    installed: bool\n    running: bool\n    available_models: List[str]\n    server_url: str\n    error: str | None = None\n\nclass ActionResponse(BaseModel):\n    success: bool\n    message: str\n\nclass RecommendedModel(BaseModel):\n    display_name: str\n    model_name: str\n    provider: str\n\nclass ProgressResponse(BaseModel):\n    status: str\n    percentage: float | None = None\n    message: str | None = None\n    phase: str | None = None\n    bytes_downloaded: int | None = None\n    total_bytes: int | None = None\n\n@router.get(\n    \"/status\",\n    response_model=OllamaStatusResponse,\n    responses={\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_ollama_status():\n    \"\"\"Get Ollama installation and server status.\"\"\"\n    try:\n        status = await ollama_service.check_ollama_status()\n        return OllamaStatusResponse(**status)\n    except Exception as e:\n        logger.error(f\"Failed to check Ollama status: {e}\")\n        raise HTTPException(status_code=500, detail=f\"Failed to check Ollama status: {str(e)}\")\n\n@router.post(\n    \"/start\",\n    response_model=ActionResponse,\n    responses={\n        400: {\"model\": ErrorResponse, \"description\": \"Bad request\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def start_ollama_server():\n    \"\"\"Start the Ollama server.\"\"\"\n    try:\n        # First check if it's already running\n        status = await ollama_service.check_ollama_status()\n        if not status[\"installed\"]:\n            raise HTTPException(status_code=400, detail=\"Ollama is not installed on this system\")\n        \n        if status[\"running\"]:\n            return ActionResponse(success=True, message=\"Ollama server is already running\")\n        \n        result = await ollama_service.start_server()\n        \n        if not result[\"success\"]:\n            logger.error(f\"Failed to start Ollama server: {result['message']}\")\n            raise HTTPException(status_code=500, detail=result[\"message\"])\n        \n        return ActionResponse(**result)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Unexpected error starting Ollama server: {e}\")\n        raise HTTPException(status_code=500, detail=f\"Failed to start Ollama server: {str(e)}\")\n\n@router.post(\n    \"/stop\",\n    response_model=ActionResponse,\n    responses={\n        400: {\"model\": ErrorResponse, \"description\": \"Bad request\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def stop_ollama_server():\n    \"\"\"Stop the Ollama server.\"\"\"\n    try:\n        # First check if it's installed\n        status = await ollama_service.check_ollama_status()\n        if not status[\"installed\"]:\n            raise HTTPException(status_code=400, detail=\"Ollama is not installed on this system\")\n        \n        if not status[\"running\"]:\n            return ActionResponse(success=True, message=\"Ollama server is already stopped\")\n        \n        result = await ollama_service.stop_server()\n        \n        if not result[\"success\"]:\n            logger.error(f\"Failed to stop Ollama server: {result['message']}\")\n            raise HTTPException(status_code=500, detail=result[\"message\"])\n        \n        return ActionResponse(**result)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Unexpected error stopping Ollama server: {e}\")\n        raise HTTPException(status_code=500, detail=f\"Failed to stop Ollama server: {str(e)}\")\n\n@router.post(\n    \"/models/download\",\n    response_model=ActionResponse,\n    responses={\n        400: {\"model\": ErrorResponse, \"description\": \"Bad request\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def download_model(request: ModelRequest):\n    \"\"\"Download an Ollama model (legacy endpoint).\"\"\"\n    try:\n        logger.info(f\"Download request for model: {request.model_name}\")\n        \n        # Check current status\n        status = await ollama_service.check_ollama_status()\n        logger.debug(f\"Current Ollama status: installed={status['installed']}, running={status['running']}\")\n        \n        if not status[\"installed\"]:\n            raise HTTPException(status_code=400, detail=\"Ollama is not installed on this system\")\n        \n        if not status[\"running\"]:\n            raise HTTPException(status_code=400, detail=\"Ollama server is not running. Please start it first.\")\n        \n        result = await ollama_service.download_model(request.model_name)\n        \n        if not result[\"success\"]:\n            logger.error(f\"Failed to download model {request.model_name}: {result['message']}\")\n            raise HTTPException(status_code=500, detail=result[\"message\"])\n        \n        logger.info(f\"Successfully downloaded model: {request.model_name}\")\n        return ActionResponse(**result)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Unexpected error downloading model {request.model_name}: {e}\")\n        raise HTTPException(status_code=500, detail=f\"Failed to download model: {str(e)}\")\n\n@router.post(\n    \"/models/download/progress\",\n    responses={\n        400: {\"model\": ErrorResponse, \"description\": \"Bad request\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def download_model_with_progress(request: ModelRequest):\n    \"\"\"Download an Ollama model with real-time progress updates via Server-Sent Events.\"\"\"\n    try:\n        logger.info(f\"Progress download request for model: {request.model_name}\")\n        \n        # Check current status\n        status = await ollama_service.check_ollama_status()\n        logger.debug(f\"Current Ollama status: installed={status['installed']}, running={status['running']}\")\n        \n        if not status[\"installed\"]:\n            raise HTTPException(status_code=400, detail=\"Ollama is not installed on this system\")\n        \n        if not status[\"running\"]:\n            raise HTTPException(status_code=400, detail=\"Ollama server is not running. Please start it first.\")\n        \n        # Return Server-Sent Events stream\n        return StreamingResponse(\n            ollama_service.download_model_with_progress(request.model_name),\n            media_type=\"text/event-stream\",\n            headers={\n                \"Cache-Control\": \"no-cache\",\n                \"Connection\": \"keep-alive\",\n                \"Access-Control-Allow-Origin\": \"*\",\n                \"Access-Control-Allow-Headers\": \"*\",\n            }\n        )\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Unexpected error setting up progress download for {request.model_name}: {e}\")\n        raise HTTPException(status_code=500, detail=f\"Failed to start progress download: {str(e)}\")\n\n@router.get(\n    \"/models/download/progress/{model_name}\",\n    response_model=ProgressResponse,\n    responses={\n        404: {\"model\": ErrorResponse, \"description\": \"Model download not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_download_progress(model_name: str):\n    \"\"\"Get current download progress for a specific model.\"\"\"\n    try:\n        progress = ollama_service.get_download_progress(model_name)\n        if progress is None:\n            raise HTTPException(status_code=404, detail=f\"No active download found for model: {model_name}\")\n        \n        return ProgressResponse(**progress)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error getting download progress for {model_name}: {e}\")\n        raise HTTPException(status_code=500, detail=f\"Failed to get download progress: {str(e)}\")\n\n@router.get(\n    \"/models/downloads/active\",\n    response_model=Dict[str, ProgressResponse],\n    responses={\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_active_downloads():\n    \"\"\"Get all currently active model downloads.\"\"\"\n    try:\n        active_downloads = {}\n        all_progress = ollama_service.get_all_download_progress()\n        \n        # Only return downloads that are actually active (not completed, error, or cancelled)\n        for model_name, progress in all_progress.items():\n            if progress.get(\"status\") in [\"starting\", \"downloading\"]:\n                active_downloads[model_name] = ProgressResponse(**progress)\n        \n        return active_downloads\n    except Exception as e:\n        logger.error(f\"Error getting active downloads: {e}\")\n        raise HTTPException(status_code=500, detail=f\"Failed to get active downloads: {str(e)}\")\n\n@router.delete(\n    \"/models/{model_name}\",\n    response_model=ActionResponse,\n    responses={\n        400: {\"model\": ErrorResponse, \"description\": \"Bad request\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def delete_model(model_name: str):\n    \"\"\"Delete an Ollama model.\"\"\"\n    try:\n        logger.info(f\"Delete request for model: {model_name}\")\n        \n        # Check current status\n        status = await ollama_service.check_ollama_status()\n        logger.debug(f\"Current Ollama status: installed={status['installed']}, running={status['running']}\")\n        \n        if not status[\"installed\"]:\n            raise HTTPException(status_code=400, detail=\"Ollama is not installed on this system\")\n        \n        if not status[\"running\"]:\n            raise HTTPException(status_code=400, detail=\"Ollama server is not running. Please start it first.\")\n        \n        result = await ollama_service.delete_model(model_name)\n        \n        if not result[\"success\"]:\n            logger.error(f\"Failed to delete model {model_name}: {result['message']}\")\n            raise HTTPException(status_code=500, detail=result[\"message\"])\n        \n        logger.info(f\"Successfully deleted model: {model_name}\")\n        return ActionResponse(**result)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Unexpected error deleting model {model_name}: {e}\")\n        raise HTTPException(status_code=500, detail=f\"Failed to delete model: {str(e)}\")\n\n@router.get(\n    \"/models/recommended\",\n    response_model=List[RecommendedModel],\n    responses={\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def get_recommended_models():\n    \"\"\"Get list of recommended Ollama models.\"\"\"\n    try:\n        models = await ollama_service.get_recommended_models()\n        return [RecommendedModel(**model) for model in models]\n    except Exception as e:\n        logger.error(f\"Failed to get recommended models: {e}\")\n        raise HTTPException(status_code=500, detail=f\"Failed to get recommended models: {str(e)}\")\n\n@router.delete(\n    \"/models/download/{model_name}\",\n    response_model=ActionResponse,\n    responses={\n        404: {\"model\": ErrorResponse, \"description\": \"Download not found\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def cancel_download(model_name: str):\n    \"\"\"Cancel an active model download.\"\"\"\n    try:\n        logger.info(f\"Cancel download request for model: {model_name}\")\n        \n        success = ollama_service.cancel_download(model_name)\n        \n        if success:\n            return ActionResponse(success=True, message=f\"Download cancelled for {model_name}\")\n        else:\n            raise HTTPException(status_code=404, detail=f\"No active download found for model: {model_name}\")\n            \n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Unexpected error cancelling download for {model_name}: {e}\")\n        raise HTTPException(status_code=500, detail=f\"Failed to cancel download: {str(e)}\") "
  },
  {
    "path": "app/backend/routes/storage.py",
    "content": "from fastapi import APIRouter, HTTPException\nimport json\nfrom pathlib import Path\nfrom pydantic import BaseModel\n\nfrom app.backend.models.schemas import ErrorResponse\n\nrouter = APIRouter(prefix=\"/storage\")\n\nclass SaveJsonRequest(BaseModel):\n    filename: str\n    data: dict\n\n@router.post(\n    path=\"/save-json\",\n    responses={\n        200: {\"description\": \"File saved successfully\"},\n        400: {\"model\": ErrorResponse, \"description\": \"Invalid request parameters\"},\n        500: {\"model\": ErrorResponse, \"description\": \"Internal server error\"},\n    },\n)\nasync def save_json_file(request: SaveJsonRequest):\n    \"\"\"Save JSON data to the project's /outputs directory.\"\"\"\n    try:\n        # Create outputs directory if it doesn't exist\n        project_root = Path(__file__).parent.parent.parent.parent  # Navigate to project root\n        outputs_dir = project_root / \"outputs\"\n        outputs_dir.mkdir(exist_ok=True)\n        \n        # Construct file path\n        file_path = outputs_dir / request.filename\n        \n        # Save JSON data to file\n        with open(file_path, 'w', encoding='utf-8') as f:\n            json.dump(request.data, f, indent=2, ensure_ascii=False)\n        \n        return {\n            \"success\": True,\n            \"message\": f\"File saved successfully to {file_path}\",\n            \"filename\": request.filename\n        }\n        \n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to save file: {str(e)}\") "
  },
  {
    "path": "app/backend/services/__init__.py",
    "content": ""
  },
  {
    "path": "app/backend/services/agent_service.py",
    "content": "from functools import partial\nfrom typing import Callable\nfrom src.graph.state import AgentState\n\ndef create_agent_function(agent_function: Callable, agent_id: str) -> Callable[[AgentState], dict]:\n    \"\"\"\n    Creates a new function from an agent function that accepts an agent_id.\n\n    :param agent_function: The agent function to wrap.\n    :param agent_id: The ID to be passed to the agent.\n    :return: A new function that can be called by LangGraph.\n    \"\"\"\n    return partial(agent_function, agent_id=agent_id) "
  },
  {
    "path": "app/backend/services/api_key_service.py",
    "content": "from sqlalchemy.orm import Session\nfrom typing import Dict, Optional\nfrom app.backend.repositories.api_key_repository import ApiKeyRepository\n\n\nclass ApiKeyService:\n    \"\"\"Simple service to load API keys for requests\"\"\"\n    \n    def __init__(self, db: Session):\n        self.repository = ApiKeyRepository(db)\n    \n    def get_api_keys_dict(self) -> Dict[str, str]:\n        \"\"\"\n        Load all active API keys from database and return as a dictionary\n        suitable for injecting into requests\n        \"\"\"\n        api_keys = self.repository.get_all_api_keys(include_inactive=False)\n        return {key.provider: key.key_value for key in api_keys}\n    \n    def get_api_key(self, provider: str) -> Optional[str]:\n        \"\"\"Get a specific API key by provider\"\"\"\n        api_key = self.repository.get_api_key_by_provider(provider)\n        return api_key.key_value if api_key else None "
  },
  {
    "path": "app/backend/services/backtest_service.py",
    "content": "from datetime import datetime, timedelta\nfrom dateutil.relativedelta import relativedelta\nimport pandas as pd\nimport numpy as np\nfrom typing import Callable, Dict, List, Optional, Any\nimport asyncio\n\nfrom src.tools.api import (\n    get_company_news,\n    get_price_data,\n    get_prices,\n    get_financial_metrics,\n    get_insider_trades,\n)\nfrom app.backend.services.graph import run_graph_async, parse_hedge_fund_response\nfrom app.backend.services.portfolio import create_portfolio\n\nclass BacktestService:\n    \"\"\"\n    Core backtesting service that focuses purely on backtesting logic.\n    Uses a pre-compiled graph and portfolio for trading decisions.\n    \"\"\"\n\n    def __init__(\n        self,\n        graph,\n        portfolio: dict,\n        tickers: List[str],\n        start_date: str,\n        end_date: str,\n        initial_capital: float,\n        model_name: str = \"gpt-4.1\",\n        model_provider: str = \"OpenAI\",\n        request: dict = {},\n    ):\n        \"\"\"\n        Initialize the backtest service.\n        \n        :param graph: Pre-compiled LangGraph graph for trading decisions.\n        :param portfolio: Initial portfolio state.\n        :param tickers: List of tickers to backtest.\n        :param start_date: Start date string (YYYY-MM-DD).\n        :param end_date: End date string (YYYY-MM-DD).\n        :param initial_capital: Starting portfolio cash.\n        :param model_name: Which LLM model name to use.\n        :param model_provider: Which LLM provider.\n        :param request: Request object containing API keys and other metadata.\n        \"\"\"\n        self.graph = graph\n        self.portfolio = portfolio\n        self.tickers = tickers\n        self.start_date = start_date\n        self.end_date = end_date\n        self.initial_capital = initial_capital\n        self.model_name = model_name\n        self.model_provider = model_provider\n        self.request = request\n        self.portfolio_values = []\n\n    def execute_trade(self, ticker: str, action: str, quantity: float, current_price: float) -> int:\n        \"\"\"\n        Execute trades with support for both long and short positions.\n        Returns the actual quantity traded.\n        \"\"\"\n        if quantity <= 0:\n            return 0\n\n        quantity = int(quantity)  # force integer shares\n        position = self.portfolio[\"positions\"][ticker]\n\n        if action == \"buy\":\n            cost = quantity * current_price\n            if cost <= self.portfolio[\"cash\"]:\n                # Weighted average cost basis for the new total\n                old_shares = position[\"long\"]\n                old_cost_basis = position[\"long_cost_basis\"]\n                new_shares = quantity\n                total_shares = old_shares + new_shares\n\n                if total_shares > 0:\n                    total_old_cost = old_cost_basis * old_shares\n                    total_new_cost = cost\n                    position[\"long_cost_basis\"] = (total_old_cost + total_new_cost) / total_shares\n\n                position[\"long\"] += quantity\n                self.portfolio[\"cash\"] -= cost\n                return quantity\n            else:\n                # Calculate maximum affordable quantity\n                max_quantity = int(self.portfolio[\"cash\"] / current_price)\n                if max_quantity > 0:\n                    cost = max_quantity * current_price\n                    old_shares = position[\"long\"]\n                    old_cost_basis = position[\"long_cost_basis\"]\n                    total_shares = old_shares + max_quantity\n\n                    if total_shares > 0:\n                        total_old_cost = old_cost_basis * old_shares\n                        total_new_cost = cost\n                        position[\"long_cost_basis\"] = (total_old_cost + total_new_cost) / total_shares\n\n                    position[\"long\"] += max_quantity\n                    self.portfolio[\"cash\"] -= cost\n                    return max_quantity\n                return 0\n\n        elif action == \"sell\":\n            quantity = min(quantity, position[\"long\"])\n            if quantity > 0:\n                avg_cost_per_share = position[\"long_cost_basis\"] if position[\"long\"] > 0 else 0\n                realized_gain = (current_price - avg_cost_per_share) * quantity\n                self.portfolio[\"realized_gains\"][ticker][\"long\"] += realized_gain\n\n                position[\"long\"] -= quantity\n                self.portfolio[\"cash\"] += quantity * current_price\n\n                if position[\"long\"] == 0:\n                    position[\"long_cost_basis\"] = 0.0\n\n                return quantity\n\n        elif action == \"short\":\n            proceeds = current_price * quantity\n            margin_required = proceeds * self.portfolio[\"margin_requirement\"]\n            available_cash = max(\n                0.0, self.portfolio[\"cash\"] - self.portfolio[\"margin_used\"]\n            )\n            if margin_required <= available_cash:\n                # Weighted average short cost basis\n                old_short_shares = position[\"short\"]\n                old_cost_basis = position[\"short_cost_basis\"]\n                new_shares = quantity\n                total_shares = old_short_shares + new_shares\n\n                if total_shares > 0:\n                    total_old_cost = old_cost_basis * old_short_shares\n                    total_new_cost = current_price * new_shares\n                    position[\"short_cost_basis\"] = (total_old_cost + total_new_cost) / total_shares\n\n                position[\"short\"] += quantity\n                position[\"short_margin_used\"] += margin_required\n                self.portfolio[\"margin_used\"] += margin_required\n\n                self.portfolio[\"cash\"] += proceeds\n                self.portfolio[\"cash\"] -= margin_required\n                return quantity\n            else:\n                margin_ratio = self.portfolio[\"margin_requirement\"]\n                if margin_ratio > 0:\n                    max_quantity = int(available_cash / (current_price * margin_ratio))\n                else:\n                    max_quantity = 0\n\n                if max_quantity > 0:\n                    proceeds = current_price * max_quantity\n                    margin_required = proceeds * margin_ratio\n\n                    old_short_shares = position[\"short\"]\n                    old_cost_basis = position[\"short_cost_basis\"]\n                    total_shares = old_short_shares + max_quantity\n\n                    if total_shares > 0:\n                        total_old_cost = old_cost_basis * old_short_shares\n                        total_new_cost = current_price * max_quantity\n                        position[\"short_cost_basis\"] = (total_old_cost + total_new_cost) / total_shares\n\n                    position[\"short\"] += max_quantity\n                    position[\"short_margin_used\"] += margin_required\n                    self.portfolio[\"margin_used\"] += margin_required\n\n                    self.portfolio[\"cash\"] += proceeds\n                    self.portfolio[\"cash\"] -= margin_required\n                    return max_quantity\n                return 0\n\n        elif action == \"cover\":\n            quantity = min(quantity, position[\"short\"])\n            if quantity > 0:\n                cover_cost = quantity * current_price\n                avg_short_price = position[\"short_cost_basis\"] if position[\"short\"] > 0 else 0\n                realized_gain = (avg_short_price - current_price) * quantity\n\n                if position[\"short\"] > 0:\n                    portion = quantity / position[\"short\"]\n                else:\n                    portion = 1.0\n\n                margin_to_release = portion * position[\"short_margin_used\"]\n\n                position[\"short\"] -= quantity\n                position[\"short_margin_used\"] -= margin_to_release\n                self.portfolio[\"margin_used\"] -= margin_to_release\n\n                self.portfolio[\"cash\"] += margin_to_release\n                self.portfolio[\"cash\"] -= cover_cost\n\n                self.portfolio[\"realized_gains\"][ticker][\"short\"] += realized_gain\n\n                if position[\"short\"] == 0:\n                    position[\"short_cost_basis\"] = 0.0\n                    position[\"short_margin_used\"] = 0.0\n\n                return quantity\n\n        return 0\n\n    def calculate_portfolio_value(self, current_prices: Dict[str, float]) -> float:\n        \"\"\"Calculate total portfolio value.\"\"\"\n        total_value = self.portfolio[\"cash\"]\n\n        for ticker in self.tickers:\n            position = self.portfolio[\"positions\"][ticker]\n            price = current_prices[ticker]\n\n            # Long position value\n            long_value = position[\"long\"] * price\n            total_value += long_value\n\n            # Short position unrealized PnL\n            if position[\"short\"] > 0:\n                total_value -= position[\"short\"] * price\n\n        return total_value\n\n    def prefetch_data(self):\n        \"\"\"Pre-fetch all data needed for the backtest period.\"\"\"\n        end_date_dt = datetime.strptime(self.end_date, \"%Y-%m-%d\")\n        start_date_dt = end_date_dt - relativedelta(years=1)\n        start_date_str = start_date_dt.strftime(\"%Y-%m-%d\")\n        api_key = self.request.api_keys.get(\"FINANCIAL_DATASETS_API_KEY\")\n\n        for ticker in self.tickers:\n            get_prices(ticker, start_date_str, self.end_date, api_key=api_key)\n            get_financial_metrics(ticker, self.end_date, limit=10, api_key=api_key)\n            get_insider_trades(ticker, self.end_date, start_date=self.start_date, limit=1000, api_key=api_key)\n            get_company_news(ticker, self.end_date, start_date=self.start_date, limit=1000, api_key=api_key)\n\n    def _update_performance_metrics(self, performance_metrics: Dict[str, Any]):\n        \"\"\"Update performance metrics using daily returns.\"\"\"\n        values_df = pd.DataFrame(self.portfolio_values).set_index(\"Date\")\n        values_df[\"Daily Return\"] = values_df[\"Portfolio Value\"].pct_change()\n        clean_returns = values_df[\"Daily Return\"].dropna()\n\n        if len(clean_returns) < 2:\n            return\n\n        daily_risk_free_rate = 0.0434 / 252\n        excess_returns = clean_returns - daily_risk_free_rate\n        mean_excess_return = excess_returns.mean()\n        std_excess_return = excess_returns.std()\n\n        # Sharpe ratio\n        if std_excess_return > 1e-12:\n            performance_metrics[\"sharpe_ratio\"] = np.sqrt(252) * (mean_excess_return / std_excess_return)\n        else:\n            performance_metrics[\"sharpe_ratio\"] = 0.0\n\n        # Sortino ratio\n        negative_returns = excess_returns[excess_returns < 0]\n        if len(negative_returns) > 0:\n            downside_std = negative_returns.std()\n            if downside_std > 1e-12:\n                performance_metrics[\"sortino_ratio\"] = np.sqrt(252) * (mean_excess_return / downside_std)\n            else:\n                performance_metrics[\"sortino_ratio\"] = None if mean_excess_return > 0 else 0\n        else:\n            performance_metrics[\"sortino_ratio\"] = None if mean_excess_return > 0 else 0\n\n        # Maximum drawdown\n        rolling_max = values_df[\"Portfolio Value\"].cummax()\n        drawdown = (values_df[\"Portfolio Value\"] - rolling_max) / rolling_max\n\n        if len(drawdown) > 0:\n            min_drawdown = drawdown.min()\n            performance_metrics[\"max_drawdown\"] = min_drawdown * 100\n\n            if min_drawdown < 0:\n                performance_metrics[\"max_drawdown_date\"] = drawdown.idxmin().strftime(\"%Y-%m-%d\")\n            else:\n                performance_metrics[\"max_drawdown_date\"] = None\n        else:\n            performance_metrics[\"max_drawdown\"] = 0.0\n            performance_metrics[\"max_drawdown_date\"] = None\n\n    async def run_backtest_async(self, progress_callback: Optional[Callable] = None) -> Dict[str, Any]:\n        \"\"\"\n        Run the backtest asynchronously with optional progress callbacks.\n        Uses the pre-compiled graph for trading decisions.\n        \"\"\"\n        # Pre-fetch all data at the start\n        self.prefetch_data()\n\n        dates = pd.date_range(self.start_date, self.end_date, freq=\"B\")\n        performance_metrics = {\n            \"sharpe_ratio\": 0.0,\n            \"sortino_ratio\": 0.0,\n            \"max_drawdown\": 0.0,\n            \"long_short_ratio\": 0.0,\n            \"gross_exposure\": 0.0,\n            \"net_exposure\": 0.0,\n        }\n\n        # Initialize portfolio values\n        if len(dates) > 0:\n            self.portfolio_values = [{\"Date\": dates[0], \"Portfolio Value\": self.initial_capital}]\n        else:\n            self.portfolio_values = []\n\n        backtest_results = []\n\n        for i, current_date in enumerate(dates):\n            # Allow other async operations to run\n            await asyncio.sleep(0)\n\n            lookback_start = (current_date - timedelta(days=30)).strftime(\"%Y-%m-%d\")\n            current_date_str = current_date.strftime(\"%Y-%m-%d\")\n            previous_date_str = (current_date - timedelta(days=1)).strftime(\"%Y-%m-%d\")\n\n            if lookback_start == current_date_str:\n                continue\n\n            # Send progress update if callback provided\n            if progress_callback:\n                progress_callback({\n                    \"type\": \"progress\",\n                    \"current_date\": current_date_str,\n                    \"progress\": (i + 1) / len(dates),\n                    \"total_dates\": len(dates),\n                    \"current_step\": i + 1,\n                })\n\n            # Get current prices\n            try:\n                current_prices = {}\n                missing_data = False\n\n                for ticker in self.tickers:\n                    try:\n                        price_data = get_price_data(ticker, previous_date_str, current_date_str)\n                        if price_data.empty:\n                            missing_data = True\n                            break\n                        current_prices[ticker] = price_data.iloc[-1][\"close\"]\n                    except Exception as e:\n                        missing_data = True\n                        break\n\n                if missing_data:\n                    continue\n\n            except Exception:\n                continue\n\n            # Create portfolio for this iteration\n            portfolio_for_graph = create_portfolio(\n                initial_cash=self.portfolio[\"cash\"],\n                margin_requirement=self.portfolio[\"margin_requirement\"],\n                tickers=self.tickers,\n                portfolio_positions=[]  # We'll handle positions manually\n            )\n            \n            # Copy current portfolio state to the graph portfolio\n            portfolio_for_graph.update(self.portfolio)\n\n            # Execute graph-based agent decisions\n            try:\n                result = await run_graph_async(\n                    graph=self.graph,\n                    portfolio=portfolio_for_graph,\n                    tickers=self.tickers,\n                    start_date=lookback_start,\n                    end_date=current_date_str,\n                    model_name=self.model_name,\n                    model_provider=self.model_provider,\n                    request=self.request,\n                )\n                \n                # Parse the decisions from the graph result\n                if result and result.get(\"messages\"):\n                    decisions = parse_hedge_fund_response(result[\"messages\"][-1].content)\n                    analyst_signals = result.get(\"data\", {}).get(\"analyst_signals\", {})\n                else:\n                    decisions = {}\n                    analyst_signals = {}\n                    \n            except Exception as e:\n                print(f\"Error running graph for {current_date_str}: {e}\")\n                decisions = {}\n                analyst_signals = {}\n\n            # Execute trades based on decisions\n            executed_trades = {}\n            for ticker in self.tickers:\n                decision = decisions.get(ticker, {\"action\": \"hold\", \"quantity\": 0})\n                action, quantity = decision.get(\"action\", \"hold\"), decision.get(\"quantity\", 0)\n                executed_quantity = self.execute_trade(ticker, action, quantity, current_prices[ticker])\n                executed_trades[ticker] = executed_quantity\n\n            # Calculate portfolio value\n            total_value = self.calculate_portfolio_value(current_prices)\n\n            # Calculate exposures\n            long_exposure = sum(self.portfolio[\"positions\"][t][\"long\"] * current_prices[t] for t in self.tickers)\n            short_exposure = sum(self.portfolio[\"positions\"][t][\"short\"] * current_prices[t] for t in self.tickers)\n            gross_exposure = long_exposure + short_exposure\n            net_exposure = long_exposure - short_exposure\n            long_short_ratio = long_exposure / short_exposure if short_exposure > 1e-9 else None\n\n            # Track portfolio value\n            self.portfolio_values.append({\n                \"Date\": current_date,\n                \"Portfolio Value\": total_value,\n                \"Long Exposure\": long_exposure,\n                \"Short Exposure\": short_exposure,\n                \"Gross Exposure\": gross_exposure,\n                \"Net Exposure\": net_exposure,\n                \"Long/Short Ratio\": long_short_ratio,\n            })\n\n            # Calculate performance metrics for this day\n            portfolio_return = (total_value / self.initial_capital - 1) * 100\n            \n            # Update performance metrics if we have enough data\n            if len(self.portfolio_values) > 2:\n                self._update_performance_metrics(performance_metrics)\n\n            # Build detailed result for this date (similar to CLI format)\n            date_result = {\n                \"date\": current_date_str,\n                \"portfolio_value\": total_value,\n                \"cash\": self.portfolio[\"cash\"],\n                \"decisions\": decisions,\n                \"executed_trades\": executed_trades,\n                \"analyst_signals\": analyst_signals,\n                \"current_prices\": current_prices,\n                \"long_exposure\": long_exposure,\n                \"short_exposure\": short_exposure,\n                \"gross_exposure\": gross_exposure,\n                \"net_exposure\": net_exposure,\n                \"long_short_ratio\": long_short_ratio,\n                \"portfolio_return\": portfolio_return,\n                \"performance_metrics\": performance_metrics.copy(),\n                # Add detailed trading information for each ticker\n                \"ticker_details\": []\n            }\n\n            # Build ticker details (similar to CLI format_backtest_row)\n            for ticker in self.tickers:\n                ticker_signals = {}\n                for agent_name, signals in analyst_signals.items():\n                    if ticker in signals:\n                        ticker_signals[agent_name] = signals[ticker]\n\n                bullish_count = len([s for s in ticker_signals.values() if s.get(\"signal\", \"\").lower() == \"bullish\"])\n                bearish_count = len([s for s in ticker_signals.values() if s.get(\"signal\", \"\").lower() == \"bearish\"])\n                neutral_count = len([s for s in ticker_signals.values() if s.get(\"signal\", \"\").lower() == \"neutral\"])\n\n                # Calculate net position value\n                pos = self.portfolio[\"positions\"][ticker]\n                long_val = pos[\"long\"] * current_prices[ticker]\n                short_val = pos[\"short\"] * current_prices[ticker]\n                net_position_value = long_val - short_val\n\n                # Get the action and quantity from the decisions\n                action = decisions.get(ticker, {}).get(\"action\", \"hold\")\n                quantity = executed_trades.get(ticker, 0)\n\n                ticker_detail = {\n                    \"ticker\": ticker,\n                    \"action\": action,\n                    \"quantity\": quantity,\n                    \"price\": current_prices[ticker],\n                    \"shares_owned\": pos[\"long\"] - pos[\"short\"],  # net shares\n                    \"long_shares\": pos[\"long\"],\n                    \"short_shares\": pos[\"short\"],\n                    \"position_value\": net_position_value,\n                    \"bullish_count\": bullish_count,\n                    \"bearish_count\": bearish_count,\n                    \"neutral_count\": neutral_count,\n                }\n                \n                date_result[\"ticker_details\"].append(ticker_detail)\n\n            backtest_results.append(date_result)\n\n            # Send intermediate result if callback provided\n            if progress_callback:\n                progress_callback({\n                    \"type\": \"backtest_result\",\n                    \"data\": date_result,\n                })\n\n        # Ensure final performance metrics are calculated\n        if len(self.portfolio_values) > 1:\n            self._update_performance_metrics(performance_metrics)\n\n        # Calculate final exposures if we have results\n        if backtest_results:\n            final_result = backtest_results[-1]\n            performance_metrics[\"gross_exposure\"] = final_result[\"gross_exposure\"]\n            performance_metrics[\"net_exposure\"] = final_result[\"net_exposure\"]\n            performance_metrics[\"long_short_ratio\"] = final_result[\"long_short_ratio\"]\n\n        # Store final performance metrics\n        self.performance_metrics = performance_metrics\n\n        return {\n            \"results\": backtest_results,\n            \"performance_metrics\": performance_metrics,\n            \"portfolio_values\": self.portfolio_values,\n            \"final_portfolio\": self.portfolio,\n        }\n\n    def run_backtest_sync(self) -> Dict[str, Any]:\n        \"\"\"\n        Run the backtest synchronously.\n        This version can be used by the CLI.\n        \"\"\"\n        # Use asyncio to run the async version\n        loop = asyncio.new_event_loop()\n        asyncio.set_event_loop(loop)\n        try:\n            return loop.run_until_complete(self.run_backtest_async())\n        finally:\n            loop.close()\n\n    def analyze_performance(self) -> pd.DataFrame:\n        \"\"\"Analyze performance and return DataFrame with metrics.\"\"\"\n        if not self.portfolio_values:\n            return pd.DataFrame()\n\n        performance_df = pd.DataFrame(self.portfolio_values).set_index(\"Date\")\n        if performance_df.empty:\n            return performance_df\n\n        # Calculate additional metrics\n        performance_df[\"Daily Return\"] = performance_df[\"Portfolio Value\"].pct_change().fillna(0)\n        \n        return performance_df "
  },
  {
    "path": "app/backend/services/graph.py",
    "content": "import asyncio\nimport json\nimport re\nfrom langchain_core.messages import HumanMessage\nfrom langgraph.graph import END, StateGraph\n\nfrom app.backend.services.agent_service import create_agent_function\nfrom src.agents.portfolio_manager import portfolio_management_agent\nfrom src.agents.risk_manager import risk_management_agent\nfrom src.main import start\nfrom src.utils.analysts import ANALYST_CONFIG\nfrom src.graph.state import AgentState\n\n\ndef extract_base_agent_key(unique_id: str) -> str:\n    \"\"\"\n    Extract the base agent key from a unique node ID.\n    \n    Args:\n        unique_id: The unique node ID with suffix (e.g., \"warren_buffett_abc123\")\n    \n    Returns:\n        The base agent key (e.g., \"warren_buffett\")\n    \"\"\"\n    # For agent nodes, remove the last underscore and 6-character suffix\n    parts = unique_id.split('_')\n    if len(parts) >= 2:\n        last_part = parts[-1]\n        # If the last part is a 6-character alphanumeric string, it's likely our suffix\n        if len(last_part) == 6 and re.match(r'^[a-z0-9]+$', last_part):\n            return '_'.join(parts[:-1])\n    return unique_id  # Return original if no suffix pattern found\n\n\n# Helper function to create the agent graph\ndef create_graph(graph_nodes: list, graph_edges: list) -> StateGraph:\n    \"\"\"Create the workflow based on the React Flow graph structure.\"\"\"\n    graph = StateGraph(AgentState)\n    graph.add_node(\"start_node\", start)\n\n    # Get analyst nodes from the configuration\n    analyst_nodes = {key: (f\"{key}_agent\", config[\"agent_func\"]) for key, config in ANALYST_CONFIG.items()}\n    \n    # Extract agent IDs from graph structure\n    agent_ids = [node.id for node in graph_nodes]\n    agent_ids_set = set(agent_ids)\n    \n    # Track which nodes are portfolio managers for special handling\n    portfolio_manager_nodes = set()\n    \n    # Add agent nodes\n    for unique_agent_id in agent_ids:\n        base_agent_key = extract_base_agent_key(unique_agent_id)\n        \n        # Track portfolio manager nodes for special handling (before ANALYST_CONFIG check)\n        if base_agent_key == \"portfolio_manager\":\n            portfolio_manager_nodes.add(unique_agent_id)\n            continue\n            \n        # Skip if the base agent key is not in our analyst configuration\n        if base_agent_key not in ANALYST_CONFIG:\n            continue\n            \n        node_name, node_func = analyst_nodes[base_agent_key]\n        agent_function = create_agent_function(node_func, unique_agent_id)\n        graph.add_node(unique_agent_id, agent_function)\n    \n    # Add portfolio manager nodes and their corresponding risk managers\n    risk_manager_nodes = {}  # Map portfolio manager ID to risk manager ID\n    for portfolio_manager_id in portfolio_manager_nodes:\n        portfolio_manager_function = create_agent_function(portfolio_management_agent, portfolio_manager_id)\n        graph.add_node(portfolio_manager_id, portfolio_manager_function)\n        \n        # Create unique risk manager for this portfolio manager\n        suffix = portfolio_manager_id.split('_')[-1]\n        risk_manager_id = f\"risk_management_agent_{suffix}\"\n        risk_manager_nodes[portfolio_manager_id] = risk_manager_id\n        \n        # Add the risk manager node\n        risk_manager_function = create_agent_function(risk_management_agent, risk_manager_id)\n        graph.add_node(risk_manager_id, risk_manager_function)\n\n    # Build connections based on React Flow graph structure\n    nodes_with_incoming_edges = set()\n    nodes_with_outgoing_edges = set()\n    direct_to_portfolio_managers = {}  # Map analyst ID to portfolio manager ID for direct connections\n    \n    for edge in graph_edges:\n        # Only consider edges between agent nodes (not from stock tickers)\n        if edge.source in agent_ids_set and edge.target in agent_ids_set:\n            source_base_key = extract_base_agent_key(edge.source)\n            target_base_key = extract_base_agent_key(edge.target)\n            \n            nodes_with_incoming_edges.add(edge.target)\n            nodes_with_outgoing_edges.add(edge.source)\n            \n            # Check if this is a direct connection from analyst to portfolio manager\n            if (source_base_key in ANALYST_CONFIG and \n                source_base_key != \"portfolio_manager\" and \n                target_base_key == \"portfolio_manager\"):\n                # Don't add direct edge to portfolio manager - we'll route through risk manager\n                direct_to_portfolio_managers[edge.source] = edge.target\n            else:\n                # Add edge between agent nodes (but not direct to portfolio managers)\n                graph.add_edge(edge.source, edge.target)\n    \n    # Connect start_node to nodes that don't have incoming edges from other agents\n    for agent_id in agent_ids:\n        if agent_id not in nodes_with_incoming_edges:\n            base_agent_key = extract_base_agent_key(agent_id)\n            if base_agent_key in ANALYST_CONFIG and base_agent_key != \"portfolio_manager\":\n                graph.add_edge(\"start_node\", agent_id)\n    \n    # Connect analysts that have direct connections to portfolio managers to their corresponding risk managers\n    for analyst_id, portfolio_manager_id in direct_to_portfolio_managers.items():\n        risk_manager_id = risk_manager_nodes[portfolio_manager_id]\n        graph.add_edge(analyst_id, risk_manager_id)\n    \n    # Connect each risk manager to its corresponding portfolio manager\n    for portfolio_manager_id, risk_manager_id in risk_manager_nodes.items():\n        graph.add_edge(risk_manager_id, portfolio_manager_id)\n    \n    # Connect portfolio managers to END\n    for portfolio_manager_id in portfolio_manager_nodes:\n        graph.add_edge(portfolio_manager_id, END)\n\n    # Set the entry point to the start node\n    graph.set_entry_point(\"start_node\")\n    return graph\n\n\nasync def run_graph_async(graph, portfolio, tickers, start_date, end_date, model_name, model_provider, request=None):\n    \"\"\"Async wrapper for run_graph to work with asyncio.\"\"\"\n    # Use run_in_executor to run the synchronous function in a separate thread\n    # so it doesn't block the event loop\n    loop = asyncio.get_running_loop()\n    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\n    return result\n\n\ndef run_graph(\n    graph: StateGraph,\n    portfolio: dict,\n    tickers: list[str],\n    start_date: str,\n    end_date: str,\n    model_name: str,\n    model_provider: str,\n    request=None,\n) -> dict:\n    \"\"\"\n    Run the graph with the given portfolio, tickers,\n    start date, end date, show reasoning, model name,\n    and model provider.\n    \"\"\"\n    return graph.invoke(\n        {\n            \"messages\": [\n                HumanMessage(\n                    content=\"Make trading decisions based on the provided data.\",\n                )\n            ],\n            \"data\": {\n                \"tickers\": tickers,\n                \"portfolio\": portfolio,\n                \"start_date\": start_date,\n                \"end_date\": end_date,\n                \"analyst_signals\": {},\n            },\n            \"metadata\": {\n                \"show_reasoning\": False,\n                \"model_name\": model_name,\n                \"model_provider\": model_provider,\n                \"request\": request,  # Pass the request for agent-specific model access\n            },\n        },\n    )\n\n\ndef parse_hedge_fund_response(response):\n    \"\"\"Parses a JSON string and returns a dictionary.\"\"\"\n    try:\n        return json.loads(response)\n    except json.JSONDecodeError as e:\n        print(f\"JSON decoding error: {e}\\nResponse: {repr(response)}\")\n        return None\n    except TypeError as e:\n        print(f\"Invalid response type (expected string, got {type(response).__name__}): {e}\")\n        return None\n    except Exception as e:\n        print(f\"Unexpected error while parsing response: {e}\\nResponse: {repr(response)}\")\n        return None\n"
  },
  {
    "path": "app/backend/services/ollama_service.py",
    "content": "import asyncio\nimport os\nimport sys\nimport platform\nimport subprocess\nimport time\nimport re\nimport json\nimport queue\nimport threading\nfrom pathlib import Path\nfrom typing import Dict, List, Optional, AsyncGenerator\nimport logging\nimport signal\nimport ollama\n\nlogger = logging.getLogger(__name__)\n\nclass OllamaService:\n    \"\"\"Service for managing Ollama integration in the backend.\"\"\"\n    \n    def __init__(self):\n        self._download_progress = {}\n        self._download_processes = {}\n        \n        # Initialize async client\n        self._async_client = ollama.AsyncClient()\n        self._sync_client = ollama.Client()\n    \n    # =============================================================================\n    # PUBLIC API METHODS\n    # =============================================================================\n    \n    async def check_ollama_status(self) -> Dict[str, any]:\n        \"\"\"Check Ollama installation and server status.\"\"\"\n        try:\n            is_installed = await self._check_installation()\n            is_running = await self._check_server_running()\n            models, server_url = await self._get_server_info(is_running)\n            \n            status = {\n                \"installed\": is_installed,\n                \"running\": is_running,\n                \"server_running\": is_running,  # Backward compatibility\n                \"available_models\": models,\n                \"server_url\": server_url,\n                \"error\": None\n            }\n            \n            logger.debug(f\"Ollama status: installed={is_installed}, running={is_running}, models={len(models)}\")\n            return status\n            \n        except Exception as e:\n            logger.error(f\"Error checking Ollama status: {e}\")\n            return self._create_error_status(str(e))\n    \n    async def start_server(self) -> Dict[str, any]:\n        \"\"\"Start the Ollama server.\"\"\"\n        try:\n            success = await self._execute_server_start()\n            \n            message = \"Ollama server started successfully\" if success else \"Failed to start Ollama server\"\n            return {\"success\": success, \"message\": message}\n                \n        except Exception as e:\n            logger.error(f\"Error starting Ollama server: {e}\")\n            return {\"success\": False, \"message\": f\"Error starting server: {str(e)}\"}\n    \n    async def stop_server(self) -> Dict[str, any]:\n        \"\"\"Stop the Ollama server.\"\"\"\n        try:\n            success = await self._execute_server_stop()\n            \n            message = \"Ollama server stopped successfully\" if success else \"Failed to stop Ollama server\"\n            return {\"success\": success, \"message\": message}\n                \n        except Exception as e:\n            logger.error(f\"Error stopping Ollama server: {e}\")\n            return {\"success\": False, \"message\": f\"Error stopping server: {str(e)}\"}\n    \n    async def download_model(self, model_name: str) -> Dict[str, any]:\n        \"\"\"Download an Ollama model.\"\"\"\n        try:\n            success = await self._execute_model_download(model_name)\n            \n            message = f\"Model {model_name} downloaded successfully\" if success else f\"Failed to download model {model_name}\"\n            return {\"success\": success, \"message\": message}\n                \n        except Exception as e:\n            logger.error(f\"Error downloading model {model_name}: {e}\")\n            return {\"success\": False, \"message\": f\"Error downloading model: {str(e)}\"}\n    \n    async def download_model_with_progress(self, model_name: str) -> AsyncGenerator[str, None]:\n        \"\"\"Download an Ollama model with progress streaming.\"\"\"\n        async for progress_data in self._stream_model_download(model_name):\n            yield progress_data\n    \n    async def delete_model(self, model_name: str) -> Dict[str, any]:\n        \"\"\"Delete an Ollama model.\"\"\"\n        try:\n            success = await self._execute_model_deletion(model_name)\n            \n            message = f\"Model {model_name} deleted successfully\" if success else f\"Failed to delete model {model_name}\"\n            return {\"success\": success, \"message\": message}\n                \n        except Exception as e:\n            logger.error(f\"Error deleting model {model_name}: {e}\")\n            return {\"success\": False, \"message\": f\"Error deleting model: {str(e)}\"}\n    \n    async def get_recommended_models(self) -> List[Dict[str, str]]:\n        \"\"\"Get list of recommended Ollama models.\"\"\"\n        try:\n            models_path = self._get_ollama_models_path()\n            \n            if models_path.exists():\n                return self._load_models_from_file(models_path)\n            else:\n                return self._get_fallback_models()\n                \n        except Exception as e:\n            logger.error(f\"Error loading recommended models: {e}\")\n            return []\n    \n    async def get_available_models(self) -> List[Dict[str, str]]:\n        \"\"\"Get available Ollama models formatted for the language models API.\n        \n        Returns only models that are:\n        1. Server is running\n        2. Model is downloaded locally  \n        3. Model is in our recommended list (OLLAMA_MODELS)\n        \"\"\"\n        try:\n            status = await self.check_ollama_status()\n            \n            if not status.get(\"server_running\", False):\n                logger.debug(\"Ollama server not running, returning no models for API\")\n                return []\n            \n            downloaded_models = status.get(\"available_models\", [])\n            if not downloaded_models:\n                logger.debug(\"No Ollama models downloaded, returning empty list for API\")\n                return []\n            \n            api_models = self._format_models_for_api(downloaded_models)\n            logger.debug(f\"Returning {len(api_models)} Ollama models for language models API\")\n            return api_models\n            \n        except Exception as e:\n            logger.error(f\"Error getting available models for API: {e}\")\n            return []  # Return empty list on error to not break the API\n    \n    def get_download_progress(self, model_name: str) -> Optional[Dict[str, any]]:\n        \"\"\"Get current download progress for a model.\"\"\"\n        return self._download_progress.get(model_name)\n    \n    def get_all_download_progress(self) -> Dict[str, Dict[str, any]]:\n        \"\"\"Get current download progress for all models.\"\"\"\n        return self._download_progress.copy()\n    \n    def cancel_download(self, model_name: str) -> bool:\n        \"\"\"Cancel an active download.\"\"\"\n        logger.warning(f\"Download cancellation not directly supported by ollama client for model: {model_name}\")\n        \n        if model_name in self._download_progress:\n            self._download_progress[model_name] = {\n                \"status\": \"cancelled\",\n                \"message\": f\"Download of {model_name} was cancelled\",\n                \"error\": \"Download cancelled by user\"\n            }\n            return True\n        \n        return False\n    \n    # =============================================================================\n    # PRIVATE HELPER METHODS\n    # =============================================================================\n    \n\n    def _create_error_status(self, error: str) -> Dict[str, any]:\n        \"\"\"Create error status response.\"\"\"\n        return {\n            \"installed\": False,\n            \"running\": False,\n            \"server_running\": False,\n            \"available_models\": [],\n            \"server_url\": \"\",\n            \"error\": error\n        }\n    \n    async def _check_installation(self) -> bool:\n        \"\"\"Check if Ollama CLI is installed.\"\"\"\n        loop = asyncio.get_event_loop()\n        return await loop.run_in_executor(None, self._is_ollama_installed)\n    \n    def _is_ollama_installed(self) -> bool:\n        \"\"\"Check if Ollama is installed on the system.\"\"\"\n        system = platform.system().lower()\n        command = [\"which\", \"ollama\"] if system in [\"darwin\", \"linux\"] else [\"where\", \"ollama\"]\n        shell = system == \"windows\"\n        \n        try:\n            result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, shell=shell)\n            return result.returncode == 0\n        except Exception:\n            return False\n    \n    async def _check_server_running(self) -> bool:\n        \"\"\"Check if the Ollama server is running using the ollama client.\"\"\"\n        try:\n            await self._async_client.list()\n            logger.debug(\"Ollama server confirmed running via client\")\n            return True\n        except Exception as e:\n            logger.debug(f\"Ollama server not reachable: {e}\")\n            return False\n    \n    async def _get_server_info(self, is_running: bool) -> tuple[List[str], str]:\n        \"\"\"Get server information (models and URL) if server is running.\"\"\"\n        if not is_running:\n            return [], \"\"\n        \n        try:\n            response = await self._async_client.list()\n            models = [model.model for model in response.models]\n            server_url = getattr(self._async_client, 'host', 'http://localhost:11434')\n            logger.debug(f\"Found {len(models)} locally available models\")\n            return models, server_url\n        except Exception as e:\n            logger.debug(f\"Failed to get server info: {e}\")\n            return [], \"\"\n    \n    async def _execute_server_start(self) -> bool:\n        \"\"\"Execute server start operation.\"\"\"\n        # Check if already running\n        try:\n            self._sync_client.list()\n            logger.info(\"Ollama server is already running\")\n            return True\n        except Exception:\n            pass  # Server not running, continue to start it\n        \n        loop = asyncio.get_event_loop()\n        return await loop.run_in_executor(None, self._start_ollama_process)\n    \n    def _start_ollama_process(self) -> bool:\n        \"\"\"Start the Ollama server process.\"\"\"\n        system = platform.system().lower()\n        \n        try:\n            command = [\"ollama\", \"serve\"]\n            shell = system == \"windows\"\n            subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=shell)\n            \n            return self._wait_for_server_start()\n            \n        except Exception as e:\n            logger.error(f\"Error starting Ollama server: {e}\")\n            return False\n    \n    def _wait_for_server_start(self) -> bool:\n        \"\"\"Wait for server to start and become ready.\"\"\"\n        logger.info(\"Starting Ollama server, waiting for it to become ready...\")\n        \n        for i in range(20):  # Try for 20 seconds\n            time.sleep(1)\n            try:\n                self._sync_client.list()\n                logger.info(f\"Ollama server started successfully after {i+1} seconds\")\n                return True\n            except Exception:\n                logger.debug(f\"Waiting for Ollama server... ({i+1}/20)\")\n                continue\n        \n        logger.error(\"Ollama server failed to start within 20 seconds\")\n        return False\n    \n    async def _execute_server_stop(self) -> bool:\n        \"\"\"Execute server stop operation.\"\"\"\n        # Check if already stopped\n        try:\n            self._sync_client.list()\n        except Exception:\n            logger.info(\"Ollama server is already stopped\")\n            return True\n        \n        loop = asyncio.get_event_loop()\n        return await loop.run_in_executor(None, self._stop_ollama_process)\n    \n    def _stop_ollama_process(self) -> bool:\n        \"\"\"Stop the Ollama server process.\"\"\"\n        system = platform.system().lower()\n        \n        try:\n            if system in [\"darwin\", \"linux\"]:\n                return self._stop_unix_process()\n            elif system == \"windows\":\n                return self._stop_windows_process()\n            else:\n                return False\n                \n        except Exception as e:\n            logger.error(f\"Error stopping Ollama server: {e}\")\n            return False\n    \n    def _stop_unix_process(self) -> bool:\n        \"\"\"Stop Ollama on Unix-like systems.\"\"\"\n        try:\n            result = subprocess.run(\n                [\"pgrep\", \"-f\", \"ollama serve\"], \n                stdout=subprocess.PIPE, \n                stderr=subprocess.PIPE, \n                text=True\n            )\n            \n            if result.returncode == 0:\n                pids = [pid for pid in result.stdout.strip().split('\\n') if pid]\n                self._terminate_processes(pids)\n            \n            return self._verify_server_stopped()\n            \n        except Exception as e:\n            logger.error(f\"Error stopping Unix process: {e}\")\n            return False\n    \n    def _stop_windows_process(self) -> bool:\n        \"\"\"Stop Ollama on Windows.\"\"\"\n        try:\n            subprocess.run(\n                [\"taskkill\", \"/F\", \"/IM\", \"ollama.exe\"], \n                stdout=subprocess.PIPE, \n                stderr=subprocess.PIPE\n            )\n            return self._verify_server_stopped()\n            \n        except Exception as e:\n            logger.error(f\"Error stopping Windows process: {e}\")\n            return False\n    \n    def _terminate_processes(self, pids: List[str]) -> None:\n        \"\"\"Terminate processes gracefully, then forcefully if needed.\"\"\"\n        # Try SIGTERM first\n        for pid in pids:\n            if pid:\n                try:\n                    os.kill(int(pid), signal.SIGTERM)\n                except (ValueError, ProcessLookupError, PermissionError):\n                    continue\n        \n        # Wait for graceful termination\n        for _ in range(5):\n            try:\n                self._sync_client.list()\n                time.sleep(1)\n            except Exception:\n                return  # Server stopped\n        \n        # Force kill if still running\n        for pid in pids:\n            if pid:\n                try:\n                    os.kill(int(pid), signal.SIGKILL)\n                except (ValueError, ProcessLookupError, PermissionError):\n                    continue\n    \n    def _verify_server_stopped(self) -> bool:\n        \"\"\"Verify that the server has stopped.\"\"\"\n        for _ in range(3):\n            try:\n                self._sync_client.list()\n                time.sleep(1)\n            except Exception:\n                return True\n        return False\n    \n    async def _execute_model_download(self, model_name: str) -> bool:\n        \"\"\"Execute model download operation.\"\"\"\n        if not await self._check_server_running():\n            logger.error(f\"Cannot download model {model_name}: Ollama server is not running\")\n            return False\n        \n        try:\n            logger.info(f\"Starting download of model: {model_name}\")\n            await self._async_client.pull(model_name)\n            logger.info(f\"Successfully downloaded model: {model_name}\")\n            return True\n        except Exception as e:\n            logger.error(f\"Error downloading model {model_name}: {e}\")\n            return False\n    \n    async def _execute_model_deletion(self, model_name: str) -> bool:\n        \"\"\"Execute model deletion operation.\"\"\"\n        if not await self._check_server_running():\n            logger.error(f\"Cannot delete model {model_name}: Ollama server is not running\")\n            return False\n        \n        try:\n            logger.info(f\"Deleting model: {model_name}\")\n            await self._async_client.delete(model_name)\n            logger.info(f\"Successfully deleted model: {model_name}\")\n            return True\n        except Exception as e:\n            logger.error(f\"Error deleting model {model_name}: {e}\")\n            return False\n    \n    async def _stream_model_download(self, model_name: str) -> AsyncGenerator[str, None]:\n        \"\"\"Stream model download with progress updates.\"\"\"\n        try:\n            if not await self._check_server_running():\n                yield f\"data: {json.dumps({'status': 'error', 'error': 'Ollama server is not running'})}\\n\\n\"\n                return\n            \n            logger.info(f\"Starting download of model: {model_name}\")\n            self._download_progress[model_name] = {\"status\": \"starting\", \"percentage\": 0}\n            \n            yield f\"data: {json.dumps({'status': 'starting', 'percentage': 0, 'message': f'Starting download of {model_name}...'})}\\n\\n\"\n            \n            # Await the pull method to get the async iterator\n            pull_stream = await self._async_client.pull(model_name, stream=True)\n            async for progress in pull_stream:\n                progress_data = self._process_download_progress(progress, model_name)\n                if progress_data:\n                    yield f\"data: {json.dumps(progress_data)}\\n\\n\"\n                    \n                    if progress_data.get(\"status\") == \"completed\":\n                        logger.info(f\"Successfully downloaded model: {model_name}\")\n                        break\n                        \n        except Exception as e:\n            error_data = {\n                \"status\": \"error\",\n                \"message\": f\"Error downloading model {model_name}\",\n                \"error\": str(e)\n            }\n            self._download_progress[model_name] = error_data\n            yield f\"data: {json.dumps(error_data)}\\n\\n\"\n            logger.error(f\"Error downloading model {model_name}: {e}\")\n        finally:\n            await asyncio.sleep(1)\n            if model_name in self._download_progress:\n                del self._download_progress[model_name]\n    \n    def _process_download_progress(self, progress, model_name: str) -> Optional[Dict[str, any]]:\n        \"\"\"Process download progress from ollama client.\"\"\"\n        if not hasattr(progress, 'status'):\n            return None\n        \n        progress_data = {\n            \"status\": \"downloading\",\n            \"message\": progress.status,\n            \"raw_output\": progress.status\n        }\n        \n        # Add completed/total info if available\n        if (hasattr(progress, 'completed') and hasattr(progress, 'total') and \n            progress.total is not None and progress.completed is not None and progress.total > 0):\n            percentage = (progress.completed / progress.total) * 100\n            progress_data.update({\n                \"percentage\": percentage,\n                \"bytes_downloaded\": progress.completed,\n                \"total_bytes\": progress.total\n            })\n        \n        # Add digest info if available\n        if hasattr(progress, 'digest'):\n            progress_data[\"digest\"] = progress.digest\n        \n        # Store in cache\n        self._download_progress[model_name] = progress_data\n        \n        # Check if download is complete\n        if (progress.status == \"success\" or \n            (hasattr(progress, 'completed') and hasattr(progress, 'total') and \n             progress.completed is not None and progress.total is not None and\n             progress.completed == progress.total)):\n            final_data = {\n                \"status\": \"completed\",\n                \"percentage\": 100,\n                \"message\": f\"Model {model_name} downloaded successfully!\"\n            }\n            self._download_progress[model_name] = final_data\n            return final_data\n        \n        return progress_data\n    \n    def _get_ollama_models_path(self) -> Path:\n        \"\"\"Get path to ollama_models.json file.\"\"\"\n        return Path(__file__).parent.parent.parent.parent / \"src\" / \"llm\" / \"ollama_models.json\"\n    \n    def _load_models_from_file(self, models_path: Path) -> List[Dict[str, str]]:\n        \"\"\"Load models from JSON file.\"\"\"\n        with open(models_path, 'r') as f:\n            return json.load(f)\n    \n    def _get_fallback_models(self) -> List[Dict[str, str]]:\n        \"\"\"Get fallback models when file is not available.\"\"\"\n        return [\n            {\"display_name\": \"[meta] llama3.1 (8B)\", \"model_name\": \"llama3.1:latest\", \"provider\": \"Ollama\"},\n            {\"display_name\": \"[google] gemma3 (4B)\", \"model_name\": \"gemma3:4b\", \"provider\": \"Ollama\"},\n            {\"display_name\": \"[alibaba] qwen3 (4B)\", \"model_name\": \"qwen3:4b\", \"provider\": \"Ollama\"},\n        ]\n    \n    def _format_models_for_api(self, downloaded_models: List[str]) -> List[Dict[str, str]]:\n        \"\"\"Format downloaded models for API response.\"\"\"\n        # Import OLLAMA_MODELS here to avoid circular imports\n        from src.llm.models import OLLAMA_MODELS\n        \n        api_models = []\n        for ollama_model in OLLAMA_MODELS:\n            if ollama_model.model_name in downloaded_models:\n                api_models.append({\n                    \"display_name\": ollama_model.display_name,\n                    \"model_name\": ollama_model.model_name,\n                    \"provider\": \"Ollama\"\n                })\n        \n        return api_models\n\n# Global service instance\nollama_service = OllamaService() "
  },
  {
    "path": "app/backend/services/portfolio.py",
    "content": "\nfrom typing import Optional, List\nfrom app.backend.models.schemas import PortfolioPosition\n\n\ndef create_portfolio(initial_cash: float, margin_requirement: float, tickers: list[str], portfolio_positions: Optional[List[PortfolioPosition]] = None) -> dict:\n    # Initialize base portfolio structure\n    portfolio = {\n        \"cash\": initial_cash,  # Initial cash amount\n        \"margin_requirement\": margin_requirement,  # Initial margin requirement\n        \"margin_used\": 0.0,  # total margin usage across all short positions\n        \"positions\": {\n            ticker: {\n                \"long\": 0,  # Number of shares held long\n                \"short\": 0,  # Number of shares held short\n                \"long_cost_basis\": 0.0,  # Average cost basis for long positions\n                \"short_cost_basis\": 0.0,  # Average price at which shares were sold short\n                \"short_margin_used\": 0.0,  # Dollars of margin used for this ticker's short\n            }\n            for ticker in tickers\n        },\n        \"realized_gains\": {\n            ticker: {\n                \"long\": 0.0,  # Realized gains from long positions\n                \"short\": 0.0,  # Realized gains from short positions\n            }\n            for ticker in tickers\n        },\n    }\n    \n    # If portfolio positions are provided, populate them\n    if portfolio_positions:\n        for position in portfolio_positions:\n            ticker = position.ticker\n            quantity = position.quantity\n            trade_price = position.trade_price\n            \n            # Ensure ticker exists in portfolio (it should from tickers list)\n            if ticker in portfolio[\"positions\"]:\n                if quantity > 0:\n                    # Positive quantity means long position\n                    portfolio[\"positions\"][ticker][\"long\"] = quantity\n                    portfolio[\"positions\"][ticker][\"long_cost_basis\"] = trade_price\n                elif quantity < 0:\n                    # Negative quantity means short position\n                    portfolio[\"positions\"][ticker][\"short\"] = abs(quantity)\n                    portfolio[\"positions\"][ticker][\"short_cost_basis\"] = trade_price\n                    # Calculate margin used for short position\n                    portfolio[\"positions\"][ticker][\"short_margin_used\"] = abs(quantity) * trade_price * margin_requirement\n                    portfolio[\"margin_used\"] += portfolio[\"positions\"][ticker][\"short_margin_used\"]\n    \n    return portfolio"
  },
  {
    "path": "app/frontend/.eslintrc.cjs",
    "content": "module.exports = {\n  root: true,\n  env: { browser: true, es2020: true },\n  extends: [\n    'eslint:recommended',\n    'plugin:@typescript-eslint/recommended',\n    'plugin:react-hooks/recommended',\n  ],\n  ignorePatterns: ['dist', '.eslintrc.cjs'],\n  parser: '@typescript-eslint/parser',\n  plugins: ['react-refresh'],\n  rules: {\n    'react-refresh/only-export-components': [\n      'warn',\n      { allowConstantExport: true },\n    ],\n  },\n}\n"
  },
  {
    "path": "app/frontend/.github/dependabot.yml",
    "content": "# docs:\n# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file\n\nversion: 2\nupdates:\n  - package-ecosystem: 'npm'\n    directory: '/'\n    schedule:\n      interval: 'daily'\n    allow:\n      - dependency-name: '@xyflow/react'\n"
  },
  {
    "path": "app/frontend/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "app/frontend/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023 webkid GmbH\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "app/frontend/README.md",
    "content": "# AI Hedge Fund - Frontend [WIP] 🚧\nThis project is currently a work in progress.  To track progress, please get updates [here](https://x.com/virattt).\n\nThis 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.\n\n## Overview\n\nThis 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.\n\n## Installation\n\nThe project contains the minimum dependencies to get up and running, and includes eslint with additional rules to help write clean React code:\n\n```bash\nnpm install # or `pnpm install` or `yarn install`\n```\n\n## Running the Application\n\nStart the application with:\n\n```bash\nnpm run dev\n```\n\nWhile the application is running, changes made to the code will be automatically reflected in the browser!\n\n## Disclaimer\n\nThis project is for **educational and research purposes only**.\n\n- Not intended for real trading or investment\n- No warranties or guarantees provided\n- Creator assumes no liability for financial losses\n- Consult a financial advisor for investment decisions\n\nBy using this software, you agree to use it solely for learning purposes."
  },
  {
    "path": "app/frontend/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"tailwind.config.ts\",\n    \"css\": \"src/index.css\",\n    \"baseColor\": \"neutral\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"iconLibrary\": \"lucide\"\n}"
  },
  {
    "path": "app/frontend/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n  <meta charset=\"UTF-8\" />\n  <link rel=\"icon\" href=\"/favicon.ico\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <title>AI Hedge Fund</title>\n</head>\n\n<body>\n  <div id=\"root\"></div>\n  <script type=\"module\" src=\"/src/main.tsx\"></script>\n</body>\n\n</html>"
  },
  {
    "path": "app/frontend/package.json",
    "content": "{\n  \"name\": \"vite-react-flow-template\",\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"tsc && vite build\",\n    \"lint\": \"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"@radix-ui/react-accordion\": \"^1.2.10\",\n    \"@radix-ui/react-checkbox\": \"^1.3.2\",\n    \"@radix-ui/react-dialog\": \"^1.1.13\",\n    \"@radix-ui/react-icons\": \"^1.3.2\",\n    \"@radix-ui/react-popover\": \"^1.1.13\",\n    \"@radix-ui/react-separator\": \"^1.1.6\",\n    \"@radix-ui/react-slot\": \"^1.2.0\",\n    \"@radix-ui/react-tabs\": \"^1.1.11\",\n    \"@radix-ui/react-tooltip\": \"^1.2.6\",\n    \"@types/react-syntax-highlighter\": \"^15.5.13\",\n    \"@xyflow/react\": \"^12.5.1\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.1.1\",\n    \"lucide-react\": \"^0.507.0\",\n    \"next-themes\": \"^0.4.6\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-resizable-panels\": \"^3.0.1\",\n    \"react-syntax-highlighter\": \"^15.6.1\",\n    \"shadcn-ui\": \"^0.9.5\",\n    \"sonner\": \"^2.0.5\",\n    \"tailwind-merge\": \"^3.2.0\"\n  },\n  \"license\": \"MIT\",\n  \"devDependencies\": {\n    \"@tailwindcss/typography\": \"^0.5.16\",\n    \"@types/node\": \"^22.15.3\",\n    \"@types/react\": \"^18.2.53\",\n    \"@types/react-dom\": \"^18.2.18\",\n    \"@typescript-eslint/eslint-plugin\": \"^6.20.0\",\n    \"@typescript-eslint/parser\": \"^6.20.0\",\n    \"@vitejs/plugin-react\": \"^4.2.1\",\n    \"autoprefixer\": \"^10.4.21\",\n    \"eslint\": \"^8.56.0\",\n    \"eslint-plugin-react-hooks\": \"^4.6.0\",\n    \"eslint-plugin-react-refresh\": \"^0.4.5\",\n    \"postcss\": \"^8.5.3\",\n    \"tailwindcss\": \"^3.4.1\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"typescript\": \"^5.3.3\",\n    \"vite\": \"^5.0.12\"\n  }\n}\n"
  },
  {
    "path": "app/frontend/postcss.config.mjs",
    "content": "/** @type {import('postcss-load-config').Config} */\nconst config = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n\nexport default config;\n"
  },
  {
    "path": "app/frontend/src/App.tsx",
    "content": "import { Layout } from './components/layout';\nimport { Toaster } from './components/ui/sonner';\n\nexport default function App() {\n  return (\n    <>\n      <Layout />\n      <Toaster />\n    </>\n  );\n}\n"
  },
  {
    "path": "app/frontend/src/components/Flow.tsx",
    "content": "import {\n  Background,\n  BackgroundVariant,\n  ColorMode,\n  Connection,\n  Edge,\n  EdgeChange,\n  MarkerType,\n  NodeChange,\n  ReactFlow,\n  addEdge,\n  useEdgesState,\n  useNodesState\n} from '@xyflow/react';\nimport { useTheme } from 'next-themes';\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nimport '@xyflow/react/dist/style.css';\n\nimport { useFlowContext } from '@/contexts/flow-context';\nimport { useEnhancedFlowActions } from '@/hooks/use-enhanced-flow-actions';\nimport { useFlowHistory } from '@/hooks/use-flow-history';\nimport { useFlowKeyboardShortcuts, useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';\nimport { useToastManager } from '@/hooks/use-toast-manager';\nimport { AppNode } from '@/nodes/types';\nimport { edgeTypes } from '../edges';\nimport { nodeTypes } from '../nodes';\nimport { TooltipProvider } from './ui/tooltip';\n\ntype FlowProps = {\n  className?: string;\n};\n\nexport function Flow({ className = '' }: FlowProps) {\n  const { theme, resolvedTheme } = useTheme();\n  \n  // Use the resolved theme for ReactFlow ColorMode\n  const colorMode: ColorMode = resolvedTheme === 'light' ? 'light' : 'dark';\n  \n  const [nodes, setNodes, onNodesChange] = useNodesState<AppNode>([]);\n  const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);\n  const [isInitialized, setIsInitialized] = useState(false);\n  const proOptions = { hideAttribution: true };\n  \n  // Get flow context for flow ID\n  const { currentFlowId } = useFlowContext();\n  \n  // Get enhanced flow actions for complete state persistence\n  const { saveCurrentFlowWithCompleteState } = useEnhancedFlowActions();\n  \n  // Get toast manager\n  const { success, error } = useToastManager();\n\n  // Initialize flow history (each flow maintains its own separate history)\n  const { takeSnapshot, undo, redo, canUndo, canRedo, clearHistory } = useFlowHistory({ flowId: currentFlowId });\n\n  // Create debounced auto-save function\n  const autoSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n  const lastSavedFlowIdRef = useRef<number | null>(null);\n  \n  const autoSave = useCallback(async (flowIdToSave?: number | null) => {\n    // Use the provided flowId or fall back to current flow ID\n    const targetFlowId = flowIdToSave !== undefined ? flowIdToSave : currentFlowId;\n    \n    // Clear any existing timeout\n    if (autoSaveTimeoutRef.current) {\n      clearTimeout(autoSaveTimeoutRef.current);\n    }\n    \n    // Set new timeout for debounced save\n    autoSaveTimeoutRef.current = setTimeout(async () => {\n      // Double-check that we're still saving to the correct flow\n      if (!targetFlowId) {\n        return;\n      }\n      \n      // If the current flow has changed since this auto-save was scheduled, skip it\n      if (targetFlowId !== currentFlowId) {\n        return;\n      }\n      \n      try {\n        await saveCurrentFlowWithCompleteState();\n        lastSavedFlowIdRef.current = targetFlowId;\n      } catch (error) {\n        console.error(`[Auto-save] Failed to save flow ${targetFlowId}:`, error);\n      }\n    }, 1000); // 1 second debounce\n  }, [currentFlowId, saveCurrentFlowWithCompleteState]);\n\n  // Enhanced onNodesChange handler with auto-save for specific change types\n  const handleNodesChange = useCallback((changes: NodeChange<AppNode>[]) => {\n    // Apply the changes first\n    onNodesChange(changes);\n    \n    // Check if any of the changes should trigger auto-save\n    const shouldAutoSave = changes.some(change => {\n      switch (change.type) {\n        case 'add':\n          return true;\n        case 'remove':\n          return true;\n        case 'position':\n          // Only auto-save position changes when dragging is complete\n          if (!change.dragging) {\n            return true;\n          }\n          return false;\n        default:\n          return false;\n      }\n    });\n\n    // Trigger auto-save if needed and flow is initialized\n    // IMPORTANT: Capture the current flow ID at the time of the change\n    if (shouldAutoSave && isInitialized && currentFlowId) {\n      const flowIdAtTimeOfChange = currentFlowId;\n      autoSave(flowIdAtTimeOfChange);\n    }\n  }, [onNodesChange, autoSave, isInitialized, currentFlowId]);\n\n  // Enhanced onEdgesChange handler with auto-save for edge removal\n  const handleEdgesChange = useCallback((changes: EdgeChange[]) => {\n    // Apply the changes first\n    onEdgesChange(changes);\n    \n    // Check if any of the changes should trigger auto-save\n    const shouldAutoSave = changes.some(change => {\n      switch (change.type) {\n        case 'remove':\n          return true;\n        default:\n          return false;\n      }\n    });\n\n    // Trigger auto-save if needed and flow is initialized\n    // IMPORTANT: Capture the current flow ID at the time of the change\n    if (shouldAutoSave && isInitialized && currentFlowId) {\n      const flowIdAtTimeOfChange = currentFlowId;\n      autoSave(flowIdAtTimeOfChange);\n    }\n  }, [onEdgesChange, autoSave, isInitialized, currentFlowId]);\n\n  // Cleanup timeout on unmount\n  useEffect(() => {\n    return () => {\n      if (autoSaveTimeoutRef.current) {\n        clearTimeout(autoSaveTimeoutRef.current);\n      }\n    };\n  }, []);\n\n  // Cancel pending auto-saves when flow changes to prevent cross-flow saves\n  useEffect(() => {\n    if (autoSaveTimeoutRef.current) {\n      clearTimeout(autoSaveTimeoutRef.current);\n      autoSaveTimeoutRef.current = null;\n    }\n  }, [currentFlowId]);\n\n  // Take initial snapshot when flow is initialized\n  useEffect(() => {\n    if (isInitialized && nodes.length === 0 && edges.length === 0) {\n      takeSnapshot();\n    }\n  }, [isInitialized, takeSnapshot, nodes.length, edges.length]);\n\n  // Take snapshot when nodes or edges change (debounced)\n  useEffect(() => {\n    if (!isInitialized) return;\n    \n    const timeoutId = setTimeout(() => {\n      takeSnapshot();\n    }, 500); // Debounce snapshots by 500ms\n\n    return () => clearTimeout(timeoutId);\n  }, [nodes, edges, takeSnapshot, isInitialized]);\n\n  // // Auto-save when nodes or edges change (debounced with longer delay)\n  // useEffect(() => {\n  //   if (!isInitialized) return;\n    \n  //   const timeoutId = setTimeout(async () => {\n  //     try {\n  //       await saveCurrentFlowWithCompleteState();\n  //       // Don't show success toast for auto-save to avoid spam\n  //     } catch (err) {\n  //       // Only show error notifications for auto-save failures\n  //       error('Auto-save failed', 'auto-save-error');\n  //     }\n  //   }, 1000); // Debounce auto-save by 1 second (longer than undo/redo)\n\n  //   return () => clearTimeout(timeoutId);\n  // }, [nodes, edges, saveCurrentFlowWithCompleteState, error, isInitialized]);\n\n  // Connect keyboard shortcuts to save flow with toast\n  useFlowKeyboardShortcuts(async () => {\n    try {\n      const savedFlow = await saveCurrentFlowWithCompleteState();\n      if (savedFlow) {\n        success(`\"${savedFlow.name}\" saved!`, 'flow-save');\n      } else {\n        error('Failed to save flow', 'flow-save-error');\n      }\n    } catch (err) {\n      error('Failed to save flow', 'flow-save-error');\n    }\n  });\n\n  // Add undo/redo keyboard shortcuts\n  useKeyboardShortcuts({\n    shortcuts: [\n      {\n        key: 'z',\n        ctrlKey: true,\n        metaKey: true,\n        callback: undo,\n        preventDefault: true,\n      },\n      {\n        key: 'z',\n        ctrlKey: true,\n        metaKey: true,\n        shiftKey: true,\n        callback: redo,\n        preventDefault: true,\n      },\n    ],\n  });\n  \n  // Initialize the flow when it first renders\n  const onInit = useCallback(() => {\n    if (!isInitialized) {\n      setIsInitialized(true);\n    }\n  }, [isInitialized]);\n\n  // Connect two nodes with marker\n  const onConnect = useCallback(\n    (connection: Connection) => {\n      // Create a new edge with a marker and unique ID\n      const newEdge: Edge = {\n        ...connection,\n        id: `edge-${Date.now()}`, // Add unique ID\n        markerEnd: {\n          type: MarkerType.ArrowClosed,\n        },\n      };\n      setEdges((eds) => addEdge(newEdge, eds));\n      \n      // Auto-save new connections immediately (structural change)\n      if (currentFlowId) {\n        // IMPORTANT: Capture the current flow ID at the time of the change\n        const flowIdAtTimeOfChange = currentFlowId;\n        \n        // Clear any pending debounced saves and save immediately\n        if (autoSaveTimeoutRef.current) {\n          clearTimeout(autoSaveTimeoutRef.current);\n        }\n        \n        // Use setTimeout to ensure the edge is added to state first\n        setTimeout(async () => {\n          // Double-check that we're still saving to the correct flow\n          if (flowIdAtTimeOfChange !== currentFlowId) {\n            return;\n          }\n          \n          try {\n            await saveCurrentFlowWithCompleteState();\n          } catch (error) {\n            console.error(`[Auto-save] Failed to save new connection for flow ${flowIdAtTimeOfChange}:`, error);\n          }\n        }, 100);\n      }\n    },\n    [setEdges, currentFlowId, saveCurrentFlowWithCompleteState]\n  );\n\n  // Theme-aware background colors\n  const backgroundStyle = {\n    backgroundColor: 'hsl(var(--background))'\n  };\n  \n  const gridColor = resolvedTheme === 'light' ? 'hsl(var(--foreground))' : 'hsl(var(--muted-foreground))';\n\n  return (\n    <div className={`w-full h-full ${className}`}>\n      <TooltipProvider>\n        <ReactFlow\n          nodes={nodes}\n          nodeTypes={nodeTypes}\n          onNodesChange={handleNodesChange}\n          edges={edges}\n          edgeTypes={edgeTypes}\n          onEdgesChange={handleEdgesChange}\n          onConnect={onConnect}\n          onInit={onInit}\n          colorMode={colorMode}\n          proOptions={proOptions}\n        >\n          <Background \n            variant={BackgroundVariant.Dots}\n            gap={13}\n            color={gridColor}\n            style={backgroundStyle}\n          />\n          {/* <CustomControls onReset={resetFlow} /> */}\n        </ReactFlow>\n      </TooltipProvider>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/Layout.tsx",
    "content": "import { BottomPanel } from '@/components/panels/bottom/bottom-panel';\nimport { LeftSidebar } from '@/components/panels/left/left-sidebar';\nimport { RightSidebar } from '@/components/panels/right/right-sidebar';\nimport { TabBar } from '@/components/tabs/tab-bar';\nimport { TabContent } from '@/components/tabs/tab-content';\nimport { SidebarProvider } from '@/components/ui/sidebar';\nimport { FlowProvider, useFlowContext } from '@/contexts/flow-context';\nimport { LayoutProvider, useLayoutContext } from '@/contexts/layout-context';\nimport { TabsProvider, useTabsContext } from '@/contexts/tabs-context';\nimport { useLayoutKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';\nimport { cn } from '@/lib/utils';\nimport { SidebarStorageService } from '@/services/sidebar-storage';\nimport { TabService } from '@/services/tab-service';\nimport { ReactFlowProvider } from '@xyflow/react';\nimport { ReactNode, useEffect, useState } from 'react';\nimport { TopBar } from './layout/top-bar';\n\n// Create a LayoutContent component to access the FlowContext, TabsContext, and LayoutContext\nfunction LayoutContent({ children }: { children: ReactNode }) {\n  const { reactFlowInstance } = useFlowContext();\n  const { openTab } = useTabsContext();\n  const { isBottomCollapsed, expandBottomPanel, collapseBottomPanel, toggleBottomPanel } = useLayoutContext();\n  \n  // Initialize sidebar states from storage service\n  const [isLeftCollapsed, setIsLeftCollapsed] = useState(() => \n    SidebarStorageService.loadLeftSidebarState(false)\n  );\n  \n  const [isRightCollapsed, setIsRightCollapsed] = useState(() => \n    SidebarStorageService.loadRightSidebarState(false)\n  );\n\n  // Track actual sidebar widths for dynamic positioning\n  const [leftSidebarWidth, setLeftSidebarWidth] = useState(280);\n  const [rightSidebarWidth, setRightSidebarWidth] = useState(280);\n  const [bottomPanelHeight, setBottomPanelHeight] = useState(300);\n\n  const handleSettingsClick = () => {\n    const tabData = TabService.createSettingsTab();\n    openTab(tabData);\n  };\n\n  // Add keyboard shortcuts for toggling sidebars and fit view\n  useLayoutKeyboardShortcuts(\n    () => setIsRightCollapsed(!isRightCollapsed), // Cmd+I for right sidebar\n    () => setIsLeftCollapsed(!isLeftCollapsed),   // Cmd+B for left sidebar\n    () => reactFlowInstance.fitView({ padding: 0.1, duration: 500 }), // Cmd+O for fit view\n    // Note: undo/redo will be handled directly in the Flow component for now\n    undefined, // undo\n    undefined, // redo\n    toggleBottomPanel, // Cmd+J for bottom panel\n    handleSettingsClick, // Shift+Cmd+J for settings\n  );\n\n  // Save sidebar states whenever they change\n  useEffect(() => {\n    SidebarStorageService.saveLeftSidebarState(isLeftCollapsed);\n  }, [isLeftCollapsed]);\n\n  useEffect(() => {\n    SidebarStorageService.saveRightSidebarState(isRightCollapsed);\n  }, [isRightCollapsed]);\n\n  // Calculate tab bar and bottom panel positioning based on actual sidebar widths\n  const getSidebarBasedStyle = () => {\n    let left = 0;\n    let right = 0;\n    \n    if (!isLeftCollapsed) {\n      left = leftSidebarWidth;\n    }\n    \n    if (!isRightCollapsed) {\n      right = rightSidebarWidth;\n    }\n    \n    return {\n      left: `${left}px`,\n      right: `${right}px`,\n    };\n  };\n\n  // Calculate main content positioning accounting for tab bar height\n  const getMainContentStyle = () => {\n    const tabBarHeight = 40; // Approximate tab bar height\n    let top = tabBarHeight;\n    let bottom = 0;\n    \n    if (!isBottomCollapsed) {\n      bottom = bottomPanelHeight;\n    }\n    \n    return {\n      top: `${top}px`,\n      bottom: `${bottom}px`,\n      left: '0',\n      right: '0',\n      width: 'auto',\n      height: 'auto',\n    };\n  };\n\n  return (\n    <div className=\"flex h-screen w-screen overflow-hidden relative bg-background\">\n      {/* VSCode-style Top Bar */}\n      <TopBar\n        isLeftCollapsed={isLeftCollapsed}\n        isRightCollapsed={isRightCollapsed}\n        isBottomCollapsed={isBottomCollapsed}\n        onToggleLeft={() => setIsLeftCollapsed(!isLeftCollapsed)}\n        onToggleRight={() => setIsRightCollapsed(!isRightCollapsed)}\n        onToggleBottom={toggleBottomPanel}\n        onSettingsClick={handleSettingsClick}\n      />\n\n      {/* Tab Bar - positioned absolutely like bottom panel */}\n      <div \n        className=\"absolute top-0 z-10 transition-all duration-200\"\n        style={getSidebarBasedStyle()}\n      >\n        <TabBar />\n      </div>\n\n      {/* Main content area */}\n      <main \n        className=\"absolute inset-0 overflow-hidden\" \n        style={{\n          left: !isLeftCollapsed ? `${leftSidebarWidth}px` : '0px',\n          right: !isRightCollapsed ? `${rightSidebarWidth}px` : '0px',\n          top: '40px', // Tab bar height\n          bottom: !isBottomCollapsed ? `${bottomPanelHeight}px` : '0px',\n        }}\n      >\n        <TabContent className=\"h-full w-full\" />\n      </main>\n\n      {/* Floating left sidebar */}\n      <div className={cn(\n        \"absolute top-0 left-0 z-30 h-full transition-transform\",\n        isLeftCollapsed && \"transform -translate-x-full opacity-0\"\n      )}>\n        <LeftSidebar\n          isCollapsed={isLeftCollapsed}\n          onCollapse={() => setIsLeftCollapsed(true)}\n          onExpand={() => setIsLeftCollapsed(false)}\n          onWidthChange={setLeftSidebarWidth}\n        />\n      </div>\n\n      {/* Floating right sidebar */}\n      <div className={cn(\n        \"absolute top-0 right-0 z-30 h-full transition-transform\",\n        isRightCollapsed && \"transform translate-x-full opacity-0\"\n      )}>\n        <RightSidebar\n          isCollapsed={isRightCollapsed}\n          onCollapse={() => setIsRightCollapsed(true)}\n          onExpand={() => setIsRightCollapsed(false)}\n          onWidthChange={setRightSidebarWidth}\n        />\n      </div>\n\n      {/* Bottom panel */}\n      <div \n        className={cn(\n          \"absolute bottom-0 z-20 transition-transform\",\n          isBottomCollapsed && \"transform translate-y-full opacity-0\"\n        )}\n        style={getSidebarBasedStyle()}\n      >\n        <BottomPanel\n          isCollapsed={isBottomCollapsed}\n          onCollapse={collapseBottomPanel}\n          onExpand={expandBottomPanel}\n          onToggleCollapse={toggleBottomPanel}\n          onHeightChange={setBottomPanelHeight}\n        />\n      </div>\n    </div>\n  );\n}\n\ninterface LayoutProps {\n  children: ReactNode;\n}\n\nexport function Layout({ children }: LayoutProps) {\n  return (\n    <SidebarProvider defaultOpen={true}>\n      <ReactFlowProvider>\n        <FlowProvider>\n          <TabsProvider>\n            <LayoutProvider>\n              <LayoutContent>{children}</LayoutContent>\n            </LayoutProvider>\n          </TabsProvider>\n        </FlowProvider>\n      </ReactFlowProvider>\n    </SidebarProvider>\n  );\n}"
  },
  {
    "path": "app/frontend/src/components/custom-controls.tsx",
    "content": "import { ResetIcon } from '@radix-ui/react-icons';\nimport { ControlButton, Controls } from '@xyflow/react';\n\ntype CustomControlsProps = {\n  onReset: () => void;\n};\n\nexport function CustomControls({ onReset }: CustomControlsProps) {\n  return (\n    <Controls \n      position=\"bottom-center\" \n      orientation=\"horizontal\" \n      style={{ bottom: 20, borderRadius: 20, gap: 10 }}\n      className=\"bg-ramp-grey-800 text-primary px-4 py-2 rounded-md [&_button]:border-0 [&_button]:outline-0 [&_button]:shadow-none\"\n    >\n            <ControlButton onClick={onReset} title=\"Reset Flow\">\n              <ResetIcon />\n            </ControlButton>\n    </Controls>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/layout/top-bar.tsx",
    "content": "import { Button } from '@/components/ui/button';\nimport { cn } from '@/lib/utils';\nimport { PanelBottom, PanelLeft, PanelRight, Settings } from 'lucide-react';\n\ninterface TopBarProps {\n  isLeftCollapsed: boolean;\n  isRightCollapsed: boolean;\n  isBottomCollapsed: boolean;\n  onToggleLeft: () => void;\n  onToggleRight: () => void;\n  onToggleBottom: () => void;\n  onSettingsClick: () => void;\n}\n\nexport function TopBar({\n  isLeftCollapsed,\n  isRightCollapsed,\n  isBottomCollapsed,\n  onToggleLeft,\n  onToggleRight,\n  onToggleBottom,\n  onSettingsClick,\n}: TopBarProps) {\n  return (\n    <div className=\"absolute top-0 right-0 z-40 flex items-center gap-0 py-1 px-2 bg-panel/80\">\n      {/* Left Sidebar Toggle */}\n      <Button\n        variant=\"ghost\"\n        size=\"sm\"\n        onClick={onToggleLeft}\n        className={cn(\n          \"h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-ramp-grey-700 transition-colors\",\n          !isLeftCollapsed && \"text-foreground\"\n        )}\n        aria-label=\"Toggle left sidebar\"\n        title=\"Toggle Left Side Bar (⌘B)\"\n      >\n        <PanelLeft size={16} />\n      </Button>\n\n      {/* Bottom Panel Toggle */}\n      <Button\n        variant=\"ghost\"\n        size=\"sm\"\n        onClick={onToggleBottom}\n        className={cn(\n          \"h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-ramp-grey-700 transition-colors\",\n          !isBottomCollapsed && \"text-foreground\"\n        )}\n        aria-label=\"Toggle bottom panel\"\n        title=\"Toggle Bottom Panel (⌘J)\"\n      >\n        <PanelBottom size={16} />\n      </Button>\n\n      {/* Right Sidebar Toggle */}\n      <Button\n        variant=\"ghost\"\n        size=\"sm\"\n        onClick={onToggleRight}\n        className={cn(\n          \"h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-ramp-grey-700 transition-colors\",\n          !isRightCollapsed && \"text-foreground\"\n        )}\n        aria-label=\"Toggle right sidebar\"\n        title=\"Toggle Right Side Bar (⌘I)\"\n      >\n        <PanelRight size={16} />\n      </Button>\n\n      {/* Divider */}\n      <div className=\"w-px h-5 bg-ramp-grey-700 mx-1\" />\n\n      {/* Settings */}\n      <Button\n        variant=\"ghost\"\n        size=\"sm\"\n        onClick={onSettingsClick}\n        className=\"h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-ramp-grey-700 transition-colors\"\n        aria-label=\"Open settings\"\n        title=\"Open Settings (⌘,)\"\n      >\n        <Settings size={16} />\n      </Button>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/bottom/bottom-panel.tsx",
    "content": "import { useLayoutContext } from '@/contexts/layout-context';\nimport { useResizable } from '@/hooks/use-resizable';\nimport { cn } from '@/lib/utils';\nimport { FileText, X } from 'lucide-react';\nimport { ReactNode, useEffect } from 'react';\nimport { Button } from '../../ui/button';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs';\nimport { OutputTab } from './tabs';\n\ninterface BottomPanelProps {\n  children?: ReactNode;\n  isCollapsed: boolean;\n  onCollapse: () => void;\n  onExpand: () => void;\n  onToggleCollapse: () => void;\n  onHeightChange?: (height: number) => void;\n}\n\nexport function BottomPanel({\n  isCollapsed,\n  onToggleCollapse,\n  onHeightChange,\n}: BottomPanelProps) {\n  const { currentBottomTab, setBottomPanelTab } = useLayoutContext();\n  \n  // Use our custom hooks for vertical resizing\n  const { height, isDragging, elementRef, startResize } = useResizable({\n    defaultHeight: 300,\n    minHeight: 200,\n    maxHeight: window.innerHeight,\n    side: 'bottom',\n  });\n  \n  // Notify parent component of height changes\n  useEffect(() => {\n    onHeightChange?.(height);\n  }, [height, onHeightChange]);\n\n  if (isCollapsed) {\n    return null;\n  }\n\n  return (\n    <div \n      ref={elementRef}\n      className={cn(\n        \"bg-panel flex flex-col relative border-t\",\n        isDragging ? \"select-none\" : \"\"\n      )}\n      style={{ \n        height: `${height}px`,\n      }}\n    >\n      {/* Resize handle - on the top for bottom panel */}\n      {!isDragging && (\n        <div \n          className=\"absolute top-0 left-0 right-0 h-1 cursor-ns-resize transition-all duration-150 z-10 hover-bg\"\n          onMouseDown={startResize}\n        />\n      )}\n\n      {/* Header with tabs and close button */}\n      <div className=\"flex items-center justify-between border-b px-4 py-2\">\n        <Tabs value={currentBottomTab} onValueChange={setBottomPanelTab} className=\"flex-1\">\n          <div className=\"flex items-center justify-between\">\n            <TabsList className=\"bg-transparent border-none p-0 h-auto\">\n              <TabsTrigger \n                value=\"output\"\n                className=\"flex items-center gap-2 px-3 py-1.5 text-sm data-[state=active]:active-item text-muted-foreground\"\n              >\n                <FileText size={14} />\n                Output\n              </TabsTrigger>\n            </TabsList>\n            \n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              onClick={onToggleCollapse}\n              className=\"h-6 w-6 text-primary hover-bg\"\n              aria-label=\"Close panel\"\n            >\n              <X size={14} />\n            </Button>\n          </div>\n        </Tabs>\n      </div>\n\n      {/* Content area */}\n      <div className=\"flex-1 min-h-0 overflow-hidden\">\n        <Tabs value={currentBottomTab} className=\"h-full\">\n          <TabsContent value=\"output\" className=\"h-full m-0 p-4\">\n            <OutputTab className=\"h-full\" />\n          </TabsContent>\n        </Tabs>\n      </div>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/bottom/tabs/backtest-output.tsx",
    "content": "import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';\nimport { cn } from '@/lib/utils';\nimport { MoreHorizontal } from 'lucide-react';\nimport { getActionColor } from './output-tab-utils';\n\n// Component for displaying backtest progress\nfunction BacktestProgress({ agentData }: { agentData: Record<string, any> }) {\n  const backtestAgent = agentData['backtest'];\n  \n  if (!backtestAgent) return null;\n  \n  // Get the latest backtest result from the backtest results array\n  const backtestResults = backtestAgent.backtestResults || [];\n  const latestBacktestResult = backtestResults.length > 0 ? backtestResults[backtestResults.length - 1] : null;\n  \n  return (\n    <Card className=\"bg-transparent mb-4\">\n      <CardHeader>\n        <CardTitle className=\"text-lg\">Backtest Progress</CardTitle>\n      </CardHeader>\n      <CardContent>\n        <div className=\"space-y-4\">\n          {/* Current Status */}\n          <div className=\"flex items-center gap-2\">\n            <MoreHorizontal className=\"h-4 w-4 text-yellow-500\" />\n            <span className=\"font-medium\">Backtest Runner</span>\n            <span className=\"text-yellow-500 flex-1\">{backtestAgent.message || backtestAgent.status}</span>\n          </div>\n        </div>\n      </CardContent>\n    </Card>\n  );\n}\n\n// Component for displaying backtest trading table (similar to CLI)\nfunction BacktestTradingTable({ agentData }: { agentData: Record<string, any> }) {\n  const backtestAgent = agentData['backtest'];\n\n  // console.log(\"backtestAgent\", backtestAgent);\n  \n  if (!backtestAgent || !backtestAgent.backtestResults) {\n    return null;\n  }\n    \n  // Get the backtest results directly from the agent data\n  const backtestResults = backtestAgent.backtestResults || [];\n  \n  if (backtestResults.length === 0) {\n    return null;\n  }\n  \n  // Build table rows similar to CLI format\n  const tableRows: any[] = [];\n  \n  backtestResults.forEach((backtestResult: any) => {    \n    // Add ticker rows for this period\n    if (backtestResult.ticker_details) {\n      backtestResult.ticker_details.forEach((ticker: any) => {\n        tableRows.push({\n          type: 'ticker',\n          date: backtestResult.date,\n          ticker: ticker.ticker,\n          action: ticker.action,\n          quantity: ticker.quantity,\n          price: ticker.price,\n          shares_owned: ticker.shares_owned,\n          long_shares: ticker.long_shares,\n          short_shares: ticker.short_shares,\n          position_value: ticker.position_value,\n          bullish_count: ticker.bullish_count,\n          bearish_count: ticker.bearish_count,\n          neutral_count: ticker.neutral_count,\n        });\n      });\n    }\n    \n    // Add portfolio summary row for this period\n    tableRows.push({\n      type: 'summary',\n      date: backtestResult.date,\n      portfolio_value: backtestResult.portfolio_value,\n      cash: backtestResult.cash,\n      portfolio_return: backtestResult.portfolio_return,\n      total_position_value: backtestResult.portfolio_value - backtestResult.cash,\n      performance_metrics: backtestResult.performance_metrics,\n    });\n  });\n    \n  // Sort by date descending (newest first) and show only the last 50 rows to avoid performance issues\n  const recentRows = tableRows\n    .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())\n    .slice(0, 50);\n  \n  \n  return (\n    <Card className=\"bg-transparent mb-4\">\n      <CardHeader>\n        <CardTitle className=\"text-lg\">Activity</CardTitle>\n      </CardHeader>\n      <CardContent>\n        <div className=\"max-h-96 overflow-y-auto\">\n          <Table>\n            <TableHeader>\n              <TableRow>\n                <TableHead>Date</TableHead>\n                <TableHead>Ticker</TableHead>\n                <TableHead>Action</TableHead>\n                <TableHead>Quantity</TableHead>\n                <TableHead>Price</TableHead>\n                <TableHead>Shares</TableHead>\n                <TableHead>Position Value</TableHead>\n                <TableHead>Bullish</TableHead>\n                <TableHead>Bearish</TableHead>\n                <TableHead>Neutral</TableHead>\n              </TableRow>\n            </TableHeader>\n            <TableBody>\n              {recentRows.map((row: any, idx: number) => {\n                if (row.type === 'ticker') {\n                  return (\n                    <TableRow key={idx}>\n                      <TableCell className=\"font-medium\">{row.date}</TableCell>\n                      <TableCell className=\"font-medium text-cyan-500\">{row.ticker}</TableCell>\n                      <TableCell>\n                        <span className={cn(\"font-medium\", getActionColor(row.action || ''))}>\n                          {row.action?.toUpperCase() || 'HOLD'}\n                        </span>\n                      </TableCell>\n                      <TableCell className={cn(\"font-medium\", getActionColor(row.action || ''))}>\n                        {row.quantity?.toLocaleString() || 0}\n                      </TableCell>\n                      <TableCell>${row.price?.toFixed(2) || '0.00'}</TableCell>\n                      <TableCell>{row.shares_owned?.toLocaleString() || 0}</TableCell>\n                      <TableCell className=\"text-primary\">\n                        ${row.position_value?.toLocaleString() || '0'}\n                      </TableCell>\n                      <TableCell className=\"text-green-500\">{row.bullish_count || 0}</TableCell>\n                      <TableCell className=\"text-red-500\">{row.bearish_count || 0}</TableCell>\n                      <TableCell className=\"text-blue-500\">{row.neutral_count || 0}</TableCell>\n                    </TableRow>\n                  );\n                }\n              })}\n            </TableBody>\n          </Table>\n        </div>\n      </CardContent>\n    </Card>\n  );\n}\n\n// Component for displaying backtest results\nfunction BacktestResults({ outputData }: { outputData: any }) {\n  if (!outputData) {\n    return null;\n  }\n\n  console.log(\"outputData\", outputData);\n  \n  if (!outputData.performance_metrics) {\n    return (\n      <Card className=\"bg-transparent mb-4\">\n        <CardHeader>\n          <CardTitle className=\"text-lg\">Backtest Results</CardTitle>\n        </CardHeader>\n        <CardContent>\n          <div className=\"text-center py-8 text-muted-foreground\">\n            Backtest completed. Performance metrics will appear here.\n          </div>\n        </CardContent>\n      </Card>\n    );\n  }\n  \n  const { performance_metrics, final_portfolio, total_days } = outputData;\n  \n  return (\n    <Card className=\"bg-transparent mb-4\">\n      <CardHeader>\n        <CardTitle className=\"text-lg\">Backtest Results</CardTitle>\n      </CardHeader>\n      <CardContent>\n        <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6\">\n          {/* Performance Metrics */}\n          <div className=\"space-y-2\">\n            <h4 className=\"font-medium\">Performance Metrics</h4>\n            <div className=\"space-y-1 text-sm\">\n              {performance_metrics.sharpe_ratio !== null && performance_metrics.sharpe_ratio !== undefined && (\n                <div className=\"flex justify-between\">\n                  <span>Sharpe Ratio:</span>\n                  <span className={cn(\"font-medium\", performance_metrics.sharpe_ratio > 1 ? \"text-green-500\" : \"text-red-500\")}>\n                    {performance_metrics.sharpe_ratio.toFixed(2)}\n                  </span>\n                </div>\n              )}\n              {performance_metrics.sortino_ratio !== null && performance_metrics.sortino_ratio !== undefined && (\n                <div className=\"flex justify-between\">\n                  <span>Sortino Ratio:</span>\n                  <span className={cn(\"font-medium\", performance_metrics.sortino_ratio > 1 ? \"text-green-500\" : \"text-red-500\")}>\n                    {performance_metrics.sortino_ratio.toFixed(2)}\n                  </span>\n                </div>\n              )}\n              {performance_metrics.max_drawdown !== null && performance_metrics.max_drawdown !== undefined && (\n                <div className=\"flex justify-between\">\n                  <span>Max Drawdown:</span>\n                  <span className=\"font-medium text-red-500\">\n                    {Math.abs(performance_metrics.max_drawdown).toFixed(2)}%\n                  </span>\n                </div>\n              )}\n            </div>\n          </div>\n          \n          {/* Portfolio Summary */}\n          <div className=\"space-y-2\">\n            <h4 className=\"font-medium\">Portfolio Summary</h4>\n            <div className=\"space-y-1 text-sm\">\n              <div className=\"flex justify-between\">\n                <span>Total Days:</span>\n                <span className=\"font-medium\">{total_days}</span>\n              </div>\n              <div className=\"flex justify-between\">\n                <span>Final Cash:</span>\n                <span className=\"font-medium\">${final_portfolio.cash.toLocaleString()}</span>\n              </div>\n              <div className=\"flex justify-between\">\n                <span>Margin Used:</span>\n                <span className=\"font-medium\">${final_portfolio.margin_used.toLocaleString()}</span>\n              </div>\n            </div>\n          </div>\n          \n          {/* Exposure Metrics */}\n          <div className=\"space-y-2\">\n            <h4 className=\"font-medium\">Exposure Metrics</h4>\n            <div className=\"space-y-1 text-sm\">\n              {performance_metrics.gross_exposure !== null && performance_metrics.gross_exposure !== undefined && (\n                <div className=\"flex justify-between\">\n                  <span>Gross Exposure:</span>\n                  <span className=\"font-medium\">${performance_metrics.gross_exposure.toLocaleString()}</span>\n                </div>\n              )}\n              {performance_metrics.net_exposure !== null && performance_metrics.net_exposure !== undefined && (\n                <div className=\"flex justify-between\">\n                  <span>Net Exposure:</span>\n                  <span className=\"font-medium\">${performance_metrics.net_exposure.toLocaleString()}</span>\n                </div>\n              )}\n              {performance_metrics.long_short_ratio !== null && performance_metrics.long_short_ratio !== undefined && (\n                <div className=\"flex justify-between\">\n                  <span>Long/Short Ratio:</span>\n                  <span className=\"font-medium\">\n                    {performance_metrics.long_short_ratio === Infinity || performance_metrics.long_short_ratio === null ? '∞' : performance_metrics.long_short_ratio.toFixed(2)}\n                  </span>\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n        \n        {/* Final Positions */}\n        {final_portfolio.positions && (\n          <div>\n            <h4 className=\"font-medium mb-2\">Final Positions</h4>\n            <Table>\n              <TableHeader>\n                <TableRow>\n                  <TableHead>Ticker</TableHead>\n                  <TableHead>Long Shares</TableHead>\n                  <TableHead>Short Shares</TableHead>\n                  <TableHead>Long Cost Basis</TableHead>\n                  <TableHead>Short Cost Basis</TableHead>\n                </TableRow>\n              </TableHeader>\n              <TableBody>\n                {Object.entries(final_portfolio.positions).map(([ticker, position]: [string, any]) => (\n                  <TableRow key={ticker}>\n                    <TableCell className=\"font-medium\">{ticker}</TableCell>\n                    <TableCell className={cn(position.long > 0 ? \"text-green-500\" : \"text-muted-foreground\")}>\n                      {position.long}\n                    </TableCell>\n                    <TableCell className={cn(position.short > 0 ? \"text-red-500\" : \"text-muted-foreground\")}>\n                      {position.short}\n                    </TableCell>\n                    <TableCell>${position.long_cost_basis.toFixed(2)}</TableCell>\n                    <TableCell>${position.short_cost_basis.toFixed(2)}</TableCell>\n                  </TableRow>\n                ))}\n              </TableBody>\n            </Table>\n          </div>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n\n// Component for displaying real-time backtest performance\nfunction BacktestPerformanceMetrics({ agentData }: { agentData: Record<string, any> }) {\n  const backtestAgent = agentData['backtest'];\n  \n  if (!backtestAgent || !backtestAgent.backtestResults) return null;\n  \n  // Get the backtest results directly from the agent data\n  const backtestResults = backtestAgent.backtestResults || [];\n  \n  if (backtestResults.length === 0) return null;\n  \n  const firstPeriod = backtestResults[0];\n  const latestPeriod = backtestResults[backtestResults.length - 1];\n  \n  // Calculate performance metrics\n  const initialValue = firstPeriod.portfolio_value;\n  const currentValue = latestPeriod.portfolio_value;\n  const totalReturn = ((currentValue - initialValue) / initialValue) * 100;\n  \n  // Calculate win rate (periods with positive returns)\n  const periodReturns = backtestResults.slice(1).map((period: any, idx: number) => {\n    const prevPeriod = backtestResults[idx];\n    return ((period.portfolio_value - prevPeriod.portfolio_value) / prevPeriod.portfolio_value) * 100;\n  });\n  \n  const winningPeriods = periodReturns.filter((ret: number) => ret > 0).length;\n  const winRate = periodReturns.length > 0 ? (winningPeriods / periodReturns.length) * 100 : 0;\n  \n  // Calculate max drawdown\n  let maxDrawdown = 0;\n  let peak = initialValue;\n  \n  backtestResults.forEach((period: any) => {\n    if (period.portfolio_value > peak) {\n      peak = period.portfolio_value;\n    }\n    const drawdown = ((period.portfolio_value - peak) / peak) * 100;\n    if (drawdown < maxDrawdown) {\n      maxDrawdown = drawdown;\n    }\n  });\n  \n  return (\n    <Card className=\"bg-transparent mb-4\">\n      <CardHeader>\n        <CardTitle className=\"text-lg\">Performance</CardTitle>\n      </CardHeader>\n      <CardContent>\n        <div className=\"grid grid-cols-2 md:grid-cols-4 gap-4\">\n          <div className=\"text-center\">\n            <div className=\"text-xs text-muted-foreground\">Total Return</div>\n            <div className={cn(\"font-sm\", totalReturn >= 0 ? \"text-green-500\" : \"text-red-500\")}>\n              {totalReturn >= 0 ? '+' : ''}{totalReturn.toFixed(2)}%\n            </div>\n          </div>\n          <div className=\"text-center\">\n            <div className=\"text-xs text-muted-foreground\">Win Rate</div>\n            <div className=\"font-sm\">{winRate.toFixed(1)}%</div>\n          </div>\n          <div className=\"text-center\">\n            <div className=\"text-xs text-muted-foreground\">Max Drawdown</div>\n            <div className=\"font-sm text-red-500\">{Math.abs(maxDrawdown).toFixed(2)}%</div>\n          </div>\n          <div className=\"text-center\">\n            <div className=\"text-xs text-muted-foreground\">Periods Traded</div>\n            <div className=\"font-sm\">{backtestResults.length}</div>\n          </div>\n        </div>\n        \n        {/* Additional metrics */}\n        <div className=\"mt-4 grid grid-cols-2 md:grid-cols-4 gap-4\">\n          <div className=\"text-center\">\n            <div className=\"text-xs text-muted-foreground\">Current Value</div>\n            <div className=\"font-sm\">${currentValue?.toLocaleString()}</div>\n          </div>\n          <div className=\"text-center\">\n            <div className=\"text-xs text-muted-foreground\">Initial Value</div>\n            <div className=\"font-sm\">${initialValue?.toLocaleString()}</div>\n          </div>\n          <div className=\"text-center\">\n            <div className=\"text-xs text-muted-foreground\">P&L</div>\n            <div className={cn(\"font-sm\", totalReturn >= 0 ? \"text-green-500\" : \"text-red-500\")}>\n              ${(currentValue - initialValue).toLocaleString()}\n            </div>\n          </div>\n          <div className=\"text-center\">\n            <div className=\"text-xs text-muted-foreground\">Long/Short Ratio</div>\n            <div className=\"font-sm\">\n              {latestPeriod.long_short_ratio === Infinity || latestPeriod.long_short_ratio === null ? '∞' : latestPeriod.long_short_ratio?.toFixed(2)}\n            </div>\n          </div>\n        </div>\n      </CardContent>\n    </Card>\n  );\n}\n\n// Main component for backtest output\nexport function BacktestOutput({ \n  agentData, \n  outputData \n}: { \n  agentData: Record<string, any>; \n  outputData: any; \n}) {\n  return (\n    <>\n      <BacktestProgress agentData={agentData} />\n      {outputData && <BacktestResults outputData={outputData} />}\n      {agentData && agentData['backtest'] && (\n        <BacktestPerformanceMetrics agentData={agentData} />\n      )}\n      <BacktestTradingTable agentData={agentData} />\n\n    </>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/bottom/tabs/debug-console-tab.tsx",
    "content": "interface DebugConsoleTabProps {\n  className?: string;\n}\n\nexport function DebugConsoleTab({ className }: DebugConsoleTabProps) {\n  return (\n    <div className={className}>\n      <div className=\"h-full bg-background/50 rounded-md p-3 text-sm overflow-auto\">\n        <div className=\"text-muted-foreground\">\n          Debug console is ready...\n        </div>\n      </div>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/bottom/tabs/index.ts",
    "content": "export { DebugConsoleTab } from '@/components/panels/bottom/tabs/debug-console-tab';\nexport { OutputTab } from '@/components/panels/bottom/tabs/output-tab';\nexport { ProblemsTab } from '@/components/panels/bottom/tabs/problems-tab';\nexport { TerminalTab } from '@/components/panels/bottom/tabs/terminal-tab';\n\n"
  },
  {
    "path": "app/frontend/src/components/panels/bottom/tabs/output-tab-utils.ts",
    "content": "import { CheckCircle, Clock, MoreHorizontal, XCircle } from 'lucide-react';\n\n// Helper function to detect if content is JSON\nexport function isJsonString(str: string): boolean {\n  try {\n    const parsed = JSON.parse(str);\n    return typeof parsed === 'object' && parsed !== null;\n  } catch {\n    return false;\n  }\n}\n\n// Helper function to get display name for agent\nexport function getDisplayName(agentName: string): string {\n  // Remove _agent suffix first\n  let name = agentName.replace(\"_agent\", \"\");\n  \n  // Remove ID suffix (everything after the last underscore if it looks like an ID)\n  const lastUnderscoreIndex = name.lastIndexOf(\"_\");\n  if (lastUnderscoreIndex !== -1) {\n    const potentialId = name.substring(lastUnderscoreIndex + 1);\n    // If the part after the last underscore looks like an ID (alphanumeric, 5+ chars), remove it\n    if (/^[a-zA-Z0-9]{5,}$/.test(potentialId)) {\n      name = name.substring(0, lastUnderscoreIndex);\n    }\n  }\n  \n  // Replace remaining underscores with spaces and title case\n  return name.replace(/_/g, \" \").replace(/\\b\\w/g, l => l.toUpperCase());\n}\n\n// Helper function to get status icon and color\nexport function getStatusIcon(status: string) {\n  switch (status.toLowerCase()) {\n    case 'complete':\n      return { icon: CheckCircle, color: 'text-green-500' };\n    case 'error':\n      return { icon: XCircle, color: 'text-red-500' };\n    case 'in_progress':\n      return { icon: MoreHorizontal, color: 'text-yellow-500' };\n    default:\n      return { icon: Clock, color: 'text-muted-foreground' };\n  }\n}\n\n// Helper function to get signal color\nexport function getSignalColor(signal: string): string {\n  switch (signal.toUpperCase()) {\n    case 'BULLISH':\n      return 'text-green-500';\n    case 'BEARISH':\n      return 'text-red-500';\n    case 'NEUTRAL':\n      return 'text-primary';\n    default:\n      return 'text-muted-foreground';\n  }\n}\n\n// Helper function to get action color\nexport function getActionColor(action: string): string {\n  switch (action.toUpperCase()) {\n    case 'BUY':\n    case 'COVER':\n      return 'text-green-500';\n    case 'SELL':\n    case 'SHORT':\n      return 'text-red-500';\n    case 'HOLD':\n      return 'text-primary';\n    default:\n      return 'text-muted-foreground';\n  }\n}\n\n// Helper function to sort agents in display order\nexport function sortAgents(agents: [string, any][]): [string, any][] {\n  return agents.sort(([agentA, dataA], [agentB, dataB]) => {\n    // First, sort by agent type priority (Risk Management and Portfolio Management at bottom)\n    const getPriority = (agentName: string) => {\n      if (agentName.includes(\"risk_management\")) return 3;\n      if (agentName.includes(\"portfolio_management\")) return 4;\n      return 1;\n    };\n    \n    const priorityA = getPriority(agentA);\n    const priorityB = getPriority(agentB);\n    \n    // If different priorities, sort by priority\n    if (priorityA !== priorityB) {\n      return priorityA - priorityB;\n    }\n    \n    // If same priority, sort by timestamp (ascending - oldest first)\n    const timestampA = dataA.timestamp ? new Date(dataA.timestamp).getTime() : 0;\n    const timestampB = dataB.timestamp ? new Date(dataB.timestamp).getTime() : 0;\n    \n    if (timestampA !== timestampB) {\n      return timestampA - timestampB;\n    }\n    \n    // If no timestamp difference, sort alphabetically\n    return agentA.localeCompare(agentB);\n  });\n} "
  },
  {
    "path": "app/frontend/src/components/panels/bottom/tabs/output-tab.tsx",
    "content": "import { useFlowContext } from '@/contexts/flow-context';\nimport { useNodeContext } from '@/contexts/node-context';\nimport { cn } from '@/lib/utils';\nimport { useEffect, useState } from 'react';\nimport { BacktestOutput } from './backtest-output';\nimport { sortAgents } from './output-tab-utils';\nimport { RegularOutput } from './regular-output';\n\ninterface OutputTabProps {\n  className?: string;\n}\n\nexport function OutputTab({ className }: OutputTabProps) {\n  const { currentFlowId } = useFlowContext();\n  const { getAgentNodeDataForFlow, getOutputNodeDataForFlow } = useNodeContext();\n  const [updateTrigger, setUpdateTrigger] = useState(0);\n  \n  // Get current flow data\n  const agentData = getAgentNodeDataForFlow(currentFlowId?.toString() || null);\n  const outputData = getOutputNodeDataForFlow(currentFlowId?.toString() || null);\n  \n  // Force re-render periodically to show real-time updates\n  useEffect(() => {\n    const interval = setInterval(() => {\n      setUpdateTrigger(prev => prev + 1);\n    }, 1000);\n    \n    return () => clearInterval(interval);\n  }, []);\n  \n  // Detect if this is a backtest run\n  const isBacktestRun = agentData && agentData['backtest'];\n  \n  // Sort agents for display (exclude backtest agent from regular agent list)\n  const sortedAgents = sortAgents(Object.entries(agentData).filter(([agentId]) => agentId !== 'backtest'));\n  \n  return (\n    <div className={cn(\"h-full overflow-y-auto font-mono text-sm\", className)}>\n      {/* Render backtest output if this is a backtest run */}\n      {isBacktestRun && (\n        <BacktestOutput agentData={agentData} outputData={outputData} />\n      )}\n      \n      {/* Render regular output if not a backtest run */}\n      {!isBacktestRun && (\n        <RegularOutput sortedAgents={sortedAgents} outputData={outputData} />\n      )}\n      \n      {/* Empty State */}\n      {!outputData && sortedAgents.length === 0 && !isBacktestRun && (\n        <div className=\"text-center py-8 text-muted-foreground\">\n          No output to display. Run an analysis to see progress and results.\n        </div>\n      )}\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/bottom/tabs/problems-tab.tsx",
    "content": "interface ProblemsTabProps {\n  className?: string;\n}\n\nexport function ProblemsTab({ className }: ProblemsTabProps) {\n  return (\n    <div className={className}>\n      <div className=\"h-full bg-background/50 rounded-md p-3 text-sm overflow-auto\">\n        <div className=\"text-muted-foreground\">\n          No problems detected\n        </div>\n      </div>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/bottom/tabs/reasoning-content.tsx",
    "content": "import { Copy } from 'lucide-react';\nimport { useState } from 'react';\nimport { isJsonString } from './output-tab-utils';\n\n// Component to render reasoning content with JSON formatting and copy button\nexport function ReasoningContent({ content }: { content: any }) {\n  const [copySuccess, setCopySuccess] = useState(false);\n  \n  if (!content) return null;\n  \n  const contentString = typeof content === 'string' ? content : JSON.stringify(content, null, 2);\n  const isJson = isJsonString(contentString);\n  \n  const copyToClipboard = () => {\n    navigator.clipboard.writeText(contentString)\n      .then(() => {\n        setCopySuccess(true);\n        setTimeout(() => setCopySuccess(false), 2000);\n      })\n      .catch(err => {\n        console.error('Failed to copy text: ', err);\n      });\n  };\n  \n  return (\n    <div className=\"group relative\">\n      <button \n        onClick={copyToClipboard}\n        className=\"absolute top-1 right-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center gap-1 text-xs p-1 rounded hover:bg-accent bg-background text-muted-foreground border border-border\"\n        title=\"Copy to clipboard\"\n      >\n        <Copy className=\"h-3 w-3\" />\n        <span className=\"text-xs\">{copySuccess ? 'Copied!' : 'Copy'}</span>\n      </button>\n      \n      {isJson ? (\n        <div className=\"text-xs\">\n          <pre className=\"whitespace-pre-wrap bg-muted p-2 rounded text-xs leading-relaxed max-h-[150px] overflow-auto\">\n            {contentString}\n          </pre>\n        </div>\n      ) : (\n        <div className=\"text-sm\">\n          {contentString.split('\\n').map((paragraph, idx) => (\n            <p key={idx} className=\"mb-2 last:mb-0\">{paragraph}</p>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/bottom/tabs/regular-output.tsx",
    "content": "import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport { cn } from '@/lib/utils';\nimport { useEffect, useState } from 'react';\nimport { getActionColor, getDisplayName, getSignalColor, getStatusIcon } from './output-tab-utils';\nimport { ReasoningContent } from './reasoning-content';\n\n// Progress Section Component\nfunction ProgressSection({ sortedAgents }: { sortedAgents: [string, any][] }) {\n  if (sortedAgents.length === 0) return null;\n\n  return (\n    <Card className=\"bg-transparent mb-4\">\n      <CardHeader>\n        <CardTitle className=\"text-lg\">Progress</CardTitle>\n      </CardHeader>\n      <CardContent>\n        <div className=\"space-y-1\">\n          {sortedAgents.map(([agentId, data]) => {\n            const { icon: StatusIcon, color } = getStatusIcon(data.status);\n            const displayName = getDisplayName(agentId);\n            \n            return (\n              <div key={agentId} className=\"flex items-center gap-2\">\n                <StatusIcon className={cn(\"h-4 w-4 flex-shrink-0\", color)} />\n                <span className=\"font-medium\">{displayName}</span>\n                {data.ticker && (\n                  <span>[{data.ticker}]</span>\n                )}\n                <span className={cn(\"flex-1\", color)}>\n                  {data.message || data.status}\n                </span>\n                {data.timestamp && (\n                  <span className=\"text-muted-foreground text-xs\">\n                    {new Date(data.timestamp).toLocaleTimeString()}\n                  </span>\n                )}\n              </div>\n            );\n          })}\n        </div>\n      </CardContent>\n    </Card>\n  );\n}\n\n// Summary Section Component\nfunction SummarySection({ outputData }: { outputData: any }) {\n  if (!outputData) return null;\n\n  return (\n    <Card className=\"bg-transparent mb-4\">\n      <CardHeader>\n        <CardTitle className=\"text-lg\">Summary</CardTitle>\n      </CardHeader>\n      <CardContent>\n        <Table>\n          <TableHeader>\n            <TableRow>\n              <TableHead>Ticker</TableHead>\n              <TableHead>Action</TableHead>\n              <TableHead>Quantity</TableHead>\n              <TableHead>Confidence</TableHead>\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {Object.entries(outputData.decisions).map(([ticker, decision]: [string, any]) => (\n              <TableRow key={ticker}>\n                <TableCell className=\"font-medium\">{ticker}</TableCell>\n                <TableCell>\n                  <span className={cn(\"font-medium\", getActionColor(decision.action || ''))}>\n                    {decision.action?.toUpperCase() || 'UNKNOWN'}\n                  </span>\n                </TableCell>\n                <TableCell>{decision.quantity || 0}</TableCell>\n                <TableCell>{decision.confidence?.toFixed(1) || 0}%</TableCell>\n              </TableRow>\n            ))}\n          </TableBody>\n        </Table>\n      </CardContent>\n    </Card>\n  );\n}\n\n// Analysis Results Section Component\nfunction AnalysisResultsSection({ outputData }: { outputData: any }) {\n  // Always call hooks at the top of the function\n  const [selectedTicker, setSelectedTicker] = useState<string>('');\n  \n  // Calculate tickers (safe to do even if outputData is null)\n  const tickers = outputData?.decisions ? Object.keys(outputData.decisions) : [];\n  \n  // Set default selected ticker\n  useEffect(() => {\n    if (tickers.length > 0 && !selectedTicker) {\n      setSelectedTicker(tickers[0]);\n    }\n  }, [tickers, selectedTicker]);\n\n  // Early returns after all hooks are called\n  if (!outputData) return null;\n  if (tickers.length === 0) return null;\n\n  return (\n    <Card className=\"bg-transparent\">\n      <CardHeader>\n        <CardTitle className=\"text-lg\">Analysis</CardTitle>\n      </CardHeader>\n      <CardContent>\n        <Tabs value={selectedTicker} onValueChange={setSelectedTicker} className=\"w-full\">\n          <TabsList className=\"flex space-x-1 bg-muted p-1 rounded-lg mb-4\">\n            {tickers.map((ticker) => (\n              <TabsTrigger \n                key={ticker} \n                value={ticker} \n                className=\"flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium rounded-md transition-colors data-[state=active]:active-bg data-[state=active]:text-blue-500 data-[state=active]:shadow-sm text-primary hover:text-primary hover-bg\"\n              >\n                {ticker}\n              </TabsTrigger>\n            ))}\n          </TabsList>\n          \n          {tickers.map((ticker) => {\n            const decision = outputData.decisions![ticker];\n            \n            return (\n              <TabsContent key={ticker} value={ticker} className=\"space-y-4\">\n                {/* Agent Analysis */}\n                <Table>\n                  <TableHeader>\n                    <TableRow>\n                      <TableHead>Agent</TableHead>\n                      <TableHead>Signal</TableHead>\n                      <TableHead>Confidence</TableHead>\n                      <TableHead>Reasoning</TableHead>\n                    </TableRow>\n                  </TableHeader>\n                                     <TableBody>\n                     {Object.entries(outputData.analyst_signals || {})\n                       .filter(([agent, signals]: [string, any]) => \n                         ticker in signals && !agent.includes(\"risk_management\")\n                       )\n                       .sort(([agentA], [agentB]) => agentA.localeCompare(agentB))\n                       .map(([agent, signals]: [string, any]) => {\n                         const signal = signals[ticker];\n                         const signalType = signal.signal?.toUpperCase() || 'UNKNOWN';\n                         const signalColor = getSignalColor(signalType);\n                        \n                        return (\n                          <TableRow key={agent}>\n                            <TableCell className=\"font-medium\">\n                              {getDisplayName(agent)}\n                            </TableCell>\n                            <TableCell>\n                              <span className={cn(\"font-medium\", signalColor)}>\n                                {signalType}\n                              </span>\n                            </TableCell>\n                            <TableCell>{signal.confidence || 0}%</TableCell>\n                            <TableCell className=\"max-w-md\">\n                              <ReasoningContent content={signal.reasoning} />\n                            </TableCell>\n                          </TableRow>\n                        );\n                      })}\n                  </TableBody>\n                </Table>\n                \n                {/* Trading Decision */}\n                <Table>\n                  <TableHeader>\n                    <TableRow>\n                      <TableHead>Property</TableHead>\n                      <TableHead>Value</TableHead>\n                    </TableRow>\n                  </TableHeader>\n                  <TableBody>\n                    <TableRow>\n                      <TableCell className=\"font-medium\">Action</TableCell>\n                      <TableCell>\n                        <span className={cn(\"font-medium\", getActionColor(decision.action || ''))}>\n                          {decision.action?.toUpperCase() || 'UNKNOWN'}\n                        </span>\n                      </TableCell>\n                    </TableRow>\n                    <TableRow>\n                      <TableCell className=\"font-medium\">Quantity</TableCell>\n                      <TableCell>{decision.quantity || 0}</TableCell>\n                    </TableRow>\n                    <TableRow>\n                      <TableCell className=\"font-medium\">Confidence</TableCell>\n                      <TableCell>{decision.confidence?.toFixed(1) || 0}%</TableCell>\n                    </TableRow>\n                    {decision.reasoning && (\n                      <TableRow>\n                        <TableCell className=\"font-medium\">Reasoning</TableCell>\n                        <TableCell className=\"max-w-md\">\n                          <ReasoningContent content={decision.reasoning} />\n                        </TableCell>\n                      </TableRow>\n                    )}\n                  </TableBody>\n                </Table>\n              </TabsContent>\n            );\n          })}\n        </Tabs>\n      </CardContent>\n    </Card>\n  );\n}\n\n// Main component for regular output\nexport function RegularOutput({ \n  sortedAgents, \n  outputData \n}: { \n  sortedAgents: [string, any][]; \n  outputData: any; \n}) {\n  return (\n    <>\n      <ProgressSection sortedAgents={sortedAgents} />\n      <SummarySection outputData={outputData} />\n      <AnalysisResultsSection outputData={outputData} />\n    </>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/bottom/tabs/terminal-tab.tsx",
    "content": "interface TerminalTabProps {\n  className?: string;\n}\n\nexport function TerminalTab({ className }: TerminalTabProps) {\n  return (\n    <div className={className}>\n      <div className=\"h-full rounded-md p-3 font-mono text-sm text-green-500 overflow-auto\">\n        <div className=\"whitespace-pre-wrap\">\n          <span className=\"text-blue-500\">$ </span>\n          <span className=\"text-primary\">Welcome to AI Hedge Fund Terminal</span>\n          {'\\n'}\n          <span className=\"text-muted-foreground\">Type commands here...</span>\n          {'\\n'}\n          <span className=\"text-blue-500\">$ </span>\n          <span className=\"animate-pulse\">_</span>\n        </div>\n      </div>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/left/flow-actions.tsx",
    "content": "import { Button } from '@/components/ui/button';\nimport { useFlowContext } from '@/contexts/flow-context';\nimport { cn } from '@/lib/utils';\nimport { Plus, Save } from 'lucide-react';\n\ninterface FlowActionsProps {\n  onSave: () => Promise<void>;\n  onCreate: () => void;\n}\n\nexport function FlowActions({ onSave, onCreate }: FlowActionsProps) {\n  const { currentFlowName, isUnsaved } = useFlowContext();\n\n  return (\n    <div className=\"p-2 flex justify-between flex-shrink-0 items-center border-b mt-4\">\n      <span className=\"text-primary text-sm font-medium ml-4\">\n        Flows\n        {isUnsaved && <span className=\"text-yellow-500 ml-1\">*</span>}\n      </span>\n      <div className=\"flex items-center gap-1\">\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          onClick={onSave}\n          className={cn(\n            \"h-6 w-6 text-primary hover-bg\",\n            isUnsaved && \"text-yellow-500\"\n          )}\n          title={`Save \"${currentFlowName}\"`}\n        >\n          <Save size={14} />\n        </Button>\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          onClick={onCreate}\n          className=\"h-6 w-6 text-primary hover-bg\"\n          title=\"Create new flow\"\n        >\n          <Plus size={14} />\n        </Button>\n      </div>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/left/flow-context-menu.tsx",
    "content": "import { Button } from '@/components/ui/button';\nimport { cn } from '@/lib/utils';\nimport { Copy, Edit, Trash2 } from 'lucide-react';\nimport { useEffect, useRef } from 'react';\n\ninterface FlowContextMenuProps {\n  isOpen: boolean;\n  position: { x: number; y: number };\n  onClose: () => void;\n  onEdit: () => void;\n  onDuplicate: () => void;\n  onDelete: () => void;\n}\n\nexport function FlowContextMenu({ \n  isOpen, \n  position, \n  onClose, \n  onEdit, \n  onDuplicate, \n  onDelete \n}: FlowContextMenuProps) {\n  const menuRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (menuRef.current && !menuRef.current.contains(event.target as Node)) {\n        onClose();\n      }\n    };\n\n    const handleEscape = (event: KeyboardEvent) => {\n      if (event.key === 'Escape') {\n        onClose();\n      }\n    };\n\n    if (isOpen) {\n      document.addEventListener('mousedown', handleClickOutside);\n      document.addEventListener('keydown', handleEscape);\n    }\n\n    return () => {\n      document.removeEventListener('mousedown', handleClickOutside);\n      document.removeEventListener('keydown', handleEscape);\n    };\n  }, [isOpen, onClose]);\n\n  if (!isOpen) return null;\n\n  const handleAction = (action: () => void) => {\n    action();\n    onClose();\n  };\n\n  return (\n    <div\n      ref={menuRef}\n      className={cn(\n        \"fixed z-50 min-w-[160px] bg-ramp-grey-800 border border rounded-md shadow-lg\",\n        \"animate-in fade-in-0 zoom-in-95\"\n      )}\n      style={{\n        left: position.x,\n        top: position.y,\n      }}\n    >\n      <div className=\"p-1\">\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          className=\"w-full justify-start text-primary hover-bg\"\n          onClick={() => handleAction(onEdit)}\n        >\n          <Edit size={14} className=\"mr-2\" />\n          Edit\n        </Button>\n        \n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          className=\"w-full justify-start text-primary hover:bg-ramp-grey-700\"\n          onClick={() => handleAction(onDuplicate)}\n        >\n          <Copy size={14} className=\"mr-2\" />\n          Duplicate\n        </Button>\n        \n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          className=\"w-full justify-start text-red-500 hover:bg-ramp-grey-700 hover:text-red-300\"\n          onClick={() => handleAction(onDelete)}\n        >\n          <Trash2 size={14} className=\"mr-2\" />\n          Delete\n        </Button>\n      </div>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/left/flow-create-dialog.tsx",
    "content": "import { Button } from '@/components/ui/button';\nimport {\n    Dialog,\n    DialogContent,\n    DialogDescription,\n    DialogFooter,\n    DialogHeader,\n    DialogTitle,\n} from '@/components/ui/dialog';\nimport { Input } from '@/components/ui/input';\nimport { useToastManager } from '@/hooks/use-toast-manager';\nimport { flowService } from '@/services/flow-service';\nimport { Flow } from '@/types/flow';\nimport { useEffect, useState } from 'react';\n\ninterface FlowCreateDialogProps {\n  isOpen: boolean;\n  onClose: () => void;\n  onFlowCreated: (flow: Flow) => void;\n}\n\nexport function FlowCreateDialog({ isOpen, onClose, onFlowCreated }: FlowCreateDialogProps) {\n  const [name, setName] = useState('');\n  const [description, setDescription] = useState('');\n  const [isLoading, setIsLoading] = useState(false);\n  const { success, error } = useToastManager();\n\n  // Reset form when dialog opens\n  useEffect(() => {\n    if (isOpen) {\n      setName('');\n      setDescription('');\n    }\n  }, [isOpen]);\n\n  const handleCreate = async () => {\n    if (!name.trim()) {\n      error('Flow name is required');\n      return;\n    }\n\n    setIsLoading(true);\n    try {\n      const newFlow = await flowService.createFlow({\n        name: name.trim(),\n        description: description.trim() || undefined,\n        nodes: [],\n        edges: [],\n        viewport: { x: 0, y: 0, zoom: 1 },\n      });\n      \n      success(`\"${newFlow.name}\" created!`);\n      onFlowCreated(newFlow);\n      onClose();\n    } catch (err) {\n      console.error('Failed to create flow:', err);\n      error('Failed to create flow');\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const handleCancel = () => {\n    setName('');\n    setDescription('');\n    onClose();\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    // Handle Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux)\n    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {\n      e.preventDefault();\n      if (name.trim()) {\n        handleCreate();\n      }\n    }\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={onClose}>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader>\n          <DialogTitle>Create New Flow</DialogTitle>\n          <DialogDescription>\n            Create a new flow with a custom name and description.\n          </DialogDescription>\n        </DialogHeader>\n        \n        <div className=\"grid gap-4 py-4\">\n          <div className=\"grid gap-2\">\n            <label htmlFor=\"create-name\" className=\"text-sm font-medium\">\n              Name\n            </label>\n            <Input\n              id=\"create-name\"\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              onKeyDown={handleKeyDown}\n              placeholder=\"Enter flow name\"\n              className=\"col-span-3\"\n              autoFocus\n            />\n          </div>\n          \n          <div className=\"grid gap-2\">\n            <label htmlFor=\"create-description\" className=\"text-sm font-medium\">\n              Description\n            </label>\n            <Input\n              id=\"create-description\"\n              value={description}\n              onChange={(e) => setDescription(e.target.value)}\n              onKeyDown={handleKeyDown}\n              placeholder=\"Enter flow description (optional)\"\n              className=\"col-span-3\"\n            />\n          </div>\n        </div>\n        \n        <DialogFooter>\n          <Button variant=\"outline\" onClick={handleCancel}>\n            Cancel\n          </Button>\n          <Button \n            onClick={handleCreate} \n            disabled={isLoading || !name.trim()}\n          >\n            {isLoading ? 'Creating...' : 'Create Flow'}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/left/flow-edit-dialog.tsx",
    "content": "import { Button } from '@/components/ui/button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog';\nimport { Input } from '@/components/ui/input';\nimport { useTabsContext } from '@/contexts/tabs-context';\nimport { useToastManager } from '@/hooks/use-toast-manager';\nimport { flowService } from '@/services/flow-service';\nimport { Flow } from '@/types/flow';\nimport { useEffect, useState } from 'react';\n\ninterface FlowEditDialogProps {\n  flow: Flow | null;\n  isOpen: boolean;\n  onClose: () => void;\n  onFlowUpdated: () => void;\n}\n\nexport function FlowEditDialog({ flow, isOpen, onClose, onFlowUpdated }: FlowEditDialogProps) {\n  const [name, setName] = useState(flow?.name || '');\n  const [description, setDescription] = useState(flow?.description || '');\n  const [isLoading, setIsLoading] = useState(false);\n  const { success, error } = useToastManager();\n  const { updateFlowTabTitle } = useTabsContext();\n\n  // Update form when flow changes\n  useEffect(() => {\n    if (flow) {\n      setName(flow.name);\n      setDescription(flow.description || '');\n    }\n  }, [flow]);\n\n  const handleSave = async () => {\n    if (!flow || !name.trim()) {\n      error('Flow name is required');\n      return;\n    }\n\n    setIsLoading(true);\n    try {\n      await flowService.updateFlow(flow.id, {\n        name: name.trim(),\n        description: description.trim() || undefined,\n      });\n      \n      // Update the tab title if it's currently open\n      updateFlowTabTitle(flow.id, name.trim());\n      \n      success(`\"${name}\" updated!`);\n      onFlowUpdated();\n      onClose();\n    } catch (err) {\n      console.error('Failed to update flow:', err);\n      error('Failed to update flow');\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const handleCancel = () => {\n    if (flow) {\n      setName(flow.name);\n      setDescription(flow.description || '');\n    }\n    onClose();\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    // Handle Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux)\n    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {\n      e.preventDefault();\n      if (name.trim()) {\n        handleSave();\n      }\n    }\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={onClose}>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader>\n          <DialogTitle>Edit Flow</DialogTitle>\n          <DialogDescription>\n            Update the name and description for your flow.\n          </DialogDescription>\n        </DialogHeader>\n        \n        <div className=\"grid gap-4 py-4\">\n          <div className=\"grid gap-2\">\n            <label htmlFor=\"name\" className=\"text-sm font-medium\">\n              Name\n            </label>\n            <Input\n              id=\"name\"\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              onKeyDown={handleKeyDown}\n              placeholder=\"Enter flow name\"\n              className=\"col-span-3\"\n            />\n          </div>\n          \n          <div className=\"grid gap-2\">\n            <label htmlFor=\"description\" className=\"text-sm font-medium\">\n              Description\n            </label>\n            <Input\n              id=\"description\"\n              value={description}\n              onChange={(e) => setDescription(e.target.value)}\n              onKeyDown={handleKeyDown}\n              placeholder=\"Enter flow description (optional)\"\n              className=\"col-span-3\"\n            />\n          </div>\n        </div>\n        \n        <DialogFooter>\n          <Button variant=\"outline\" onClick={handleCancel}>\n            Cancel\n          </Button>\n          <Button \n            onClick={handleSave} \n            disabled={isLoading || !name.trim()}\n          >\n            {isLoading ? 'Saving...' : 'Save Changes'}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/left/flow-item-group.tsx",
    "content": "import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';\nimport { Separator } from '@/components/ui/separator';\nimport { Flow } from '@/types/flow';\nimport FlowItem from './flow-item';\n\ninterface FlowItemGroupProps {\n  title: string;\n  flows: Flow[];\n  onLoadFlow: (flow: Flow) => Promise<void>;\n  onDeleteFlow: (flow: Flow) => Promise<void>;\n  onRefresh: () => Promise<void>;\n  currentFlowId?: number | null;\n}\n\nexport function FlowItemGroup({ title, flows, onLoadFlow, onDeleteFlow, onRefresh, currentFlowId }: FlowItemGroupProps) {\n  const groupId = title.toLowerCase().replace(/\\s+/g, '-');\n\n  return (\n    <AccordionItem value={groupId} className=\"border\">\n      <AccordionTrigger className=\"px-4 py-2 text-primary hover-bg hover:no-underline\">\n        <div className=\"flex items-center justify-between w-full\">\n          <span className=\"text-xs font-medium\">{title}</span>\n          <span className=\"text-xs text-muted-foreground\">({flows.length})</span>\n        </div>\n      </AccordionTrigger>\n      <AccordionContent className=\"px-0 pb-0\">\n        <div className=\"space-y-1\">\n          {flows.map((flow, index) => (\n            <div key={flow.id}>\n              <FlowItem\n                flow={flow}\n                onLoadFlow={onLoadFlow}\n                onDeleteFlow={onDeleteFlow}\n                onRefresh={onRefresh}\n                isActive={currentFlowId === flow.id}\n              />\n              {index < flows.length - 1 && (\n                <Separator className=\"mx-4\" />\n              )}\n            </div>\n          ))}\n        </div>\n      </AccordionContent>\n    </AccordionItem>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/left/flow-item.tsx",
    "content": "import { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { useFlowConnectionState } from '@/hooks/use-flow-connection';\nimport { cn } from '@/lib/utils';\nimport { flowService } from '@/services/flow-service';\nimport { Flow } from '@/types/flow';\nimport {\n  Calendar,\n  FileText,\n  Layout,\n  MoreHorizontal,\n  Zap\n} from 'lucide-react';\nimport { useState } from 'react';\nimport { FlowContextMenu } from './flow-context-menu';\nimport { FlowEditDialog } from './flow-edit-dialog';\n\ninterface FlowItemProps {\n  flow: Flow;\n  onLoadFlow: (flow: Flow) => Promise<void>;\n  onDeleteFlow: (flow: Flow) => Promise<void>;\n  onRefresh: () => Promise<void>;\n  isActive?: boolean;\n}\n\nexport default function FlowItem({ flow, onLoadFlow, onDeleteFlow, onRefresh, isActive = false }: FlowItemProps) {\n  const [contextMenu, setContextMenu] = useState<{ isOpen: boolean; position: { x: number; y: number } }>({\n    isOpen: false,\n    position: { x: 0, y: 0 }\n  });\n  const [editDialog, setEditDialog] = useState(false);\n\n  // Check if this flow has an active connection\n  const connectionState = useFlowConnectionState(flow.id.toString());\n  const hasActiveConnection = connectionState && \n    (connectionState.state === 'connecting' || connectionState.state === 'connected');\n\n  const handleLoadFlow = async () => {\n    await onLoadFlow(flow);\n  };\n\n  const handleContextMenu = (e: React.MouseEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    \n    setContextMenu({\n      isOpen: true,\n      position: { x: e.clientX, y: e.clientY }\n    });\n  };\n\n  const handleMenuClick = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    \n    // Get the button's position for the menu\n    const rect = e.currentTarget.getBoundingClientRect();\n    setContextMenu({\n      isOpen: true,\n      position: { x: rect.right - 160, y: rect.bottom } // Offset menu to the left of the button\n    });\n  };\n\n  const closeContextMenu = () => {\n    setContextMenu(prev => ({ ...prev, isOpen: false }));\n  };\n\n  const handleEdit = () => {\n    setEditDialog(true);\n  };\n\n  const handleDuplicateFlow = async () => {\n    try {\n      await flowService.duplicateFlow(flow.id);\n      onRefresh();\n    } catch (error) {\n      console.error('Failed to duplicate flow:', error);\n    }\n  };\n\n  const handleDeleteFlow = async () => {\n    if (window.confirm(`Are you sure you want to delete \"${flow.name}\"?`)) {\n      try {\n        await onDeleteFlow(flow);\n      } catch (error) {\n        console.error('Failed to delete flow:', error);\n      }\n    }\n  };\n\n  const formatDateTime = (dateString: string) => {\n    return new Date(dateString).toLocaleString('en-US', {\n      month: 'short',\n      day: 'numeric',\n      year: 'numeric',\n      hour: 'numeric',\n      minute: '2-digit',\n      second: '2-digit',\n      hour12: true\n    });\n  };\n\n  // Filter out \"default\" tag\n  const filteredTags = flow.tags?.filter(tag => tag !== 'default') || [];\n\n  return (\n    <>\n      <div \n        className={cn(\n          \"group flex items-center justify-between px-4 py-3 transition-colors cursor-pointer\",\n          isActive \n            ? \"border-l-2 border-blue-500\" \n            : \"hover-bg\"\n        )}\n        onClick={handleLoadFlow}\n        onContextMenu={handleContextMenu}\n      >\n        <div className=\"flex-1 min-w-0\">\n          <div className=\"flex items-center justify-between gap-2 mb-1\">\n            <div className=\"flex items-center gap-1 min-w-0\">\n              {flow.is_template ? (\n                <Layout size={14} className=\"text-blue-500 flex-shrink-0\" />\n              ) : (\n                <FileText size={14} className={cn(\n                  \"flex-shrink-0\",\n                  isActive ? \"text-blue-500\" : \"text-muted-foreground\"\n                )} />\n              )}\n              <span\n                className={cn(\n                  \"text-subtitle font-medium text-left truncate\",\n                  isActive \n                    ? \"text-blue-500\" \n                    : \"text-primary\"\n                )}\n                title={flow.name}\n              >\n                {flow.name}\n              </span>\n            </div>\n            \n            {/* Active connection indicator - right aligned */}\n            {hasActiveConnection && (\n              <div className=\"flex items-center gap-1 flex-shrink-0\">\n                <Zap className=\"h-3 w-3 text-yellow-500 animate-pulse\" />\n                <span className=\"text-xs text-yellow-500 font-medium\">Running</span>\n              </div>\n            )}\n          </div>\n          \n          <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n            <Calendar size={10} />\n            <span>{formatDateTime(flow.created_at)}</span>\n          </div>\n          \n          {filteredTags.length > 0 && (\n            <div className=\"flex flex-wrap gap-1 mt-1\">\n              {filteredTags.slice(0, 2).map(tag => (\n                <Badge key={tag} variant=\"secondary\" className=\"text-xs px-1 py-0\">\n                  {tag}\n                </Badge>\n              ))}\n              {filteredTags.length > 2 && (\n                <Badge variant=\"secondary\" className=\"text-xs px-1 py-0\">\n                  +{filteredTags.length - 2}\n                </Badge>\n              )}\n            </div>\n          )}\n        </div>\n        \n        <div className=\"flex items-center\">\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            onClick={handleMenuClick}\n            className=\"h-6 w-6 text-muted-foreground hover-item opacity-0 group-hover:opacity-100 transition-opacity rounded\"\n            title=\"More options\"\n          >\n            <MoreHorizontal size={14} />\n          </Button>\n        </div>\n      </div>\n\n      <FlowContextMenu\n        isOpen={contextMenu.isOpen}\n        position={contextMenu.position}\n        onClose={closeContextMenu}\n        onEdit={handleEdit}\n        onDuplicate={handleDuplicateFlow}\n        onDelete={handleDeleteFlow}\n      />\n\n      <FlowEditDialog\n        flow={flow}\n        isOpen={editDialog}\n        onClose={() => setEditDialog(false)}\n        onFlowUpdated={onRefresh}\n      />\n    </>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/left/flow-list.tsx",
    "content": "import { FlowItemGroup } from '@/components/panels/left/flow-item-group';\nimport { SearchBox } from '@/components/panels/search-box';\nimport { Accordion } from '@/components/ui/accordion';\nimport { useTabsContext } from '@/contexts/tabs-context';\nimport { Flow } from '@/types/flow';\nimport { FolderOpen } from 'lucide-react';\n\ninterface FlowListProps {\n  flows: Flow[];\n  searchQuery: string;\n  isLoading: boolean;\n  openGroups: string[];\n  filteredFlows: Flow[];\n  recentFlows: Flow[];\n  templateFlows: Flow[];\n  onSearchChange: (query: string) => void;\n  onAccordionChange: (value: string[]) => void;\n  onLoadFlow: (flow: Flow) => Promise<void>;\n  onDeleteFlow: (flow: Flow) => Promise<void>;\n  onRefresh: () => Promise<void>;\n}\n\nexport function FlowList({\n  flows,\n  searchQuery,\n  isLoading,\n  openGroups,\n  filteredFlows,\n  recentFlows,\n  templateFlows,\n  onSearchChange,\n  onAccordionChange,\n  onLoadFlow,\n  onDeleteFlow,\n  onRefresh,\n}: FlowListProps) {\n  const { tabs, activeTabId } = useTabsContext();\n\n  // Only consider a flow active if the current active tab is a flow tab with that flow's ID\n  const getActiveFlowId = (): number | null => {\n    const activeTab = tabs.find(tab => tab.id === activeTabId);\n    \n    // If no active tab or active tab is not a flow tab, no flow should be active\n    if (!activeTab || activeTab.type !== 'flow') {\n      return null;\n    }\n    \n    // Return the flow ID from the active flow tab\n    return activeTab.flow?.id || null;\n  };\n\n  const activeFlowId = getActiveFlowId();\n\n  return (\n    <div className=\"flex-grow overflow-auto text-primary scrollbar-thin scrollbar-thumb-ramp-grey-700\">\n      <SearchBox \n        value={searchQuery} \n        onChange={onSearchChange}\n        placeholder=\"Search flows...\"\n      />\n      \n      {isLoading ? (\n        <div className=\"flex items-center justify-center py-8\">\n          <div className=\"text-muted-foreground text-sm\">Loading flows...</div>\n        </div>\n      ) : (\n        <Accordion \n          type=\"multiple\" \n          className=\"w-full\" \n          value={openGroups} \n          onValueChange={onAccordionChange}\n        >\n          {recentFlows.length > 0 && (\n            <FlowItemGroup\n              key=\"recent-flows\"\n              title=\"Recent Flows\"\n              flows={recentFlows}\n              onLoadFlow={onLoadFlow}\n              onDeleteFlow={onDeleteFlow}\n              onRefresh={onRefresh}\n              currentFlowId={activeFlowId}\n            />\n          )}\n          \n          {templateFlows.length > 0 && (\n            <FlowItemGroup\n              key=\"templates\"\n              title=\"Templates\"\n              flows={templateFlows}\n              onLoadFlow={onLoadFlow}\n              onDeleteFlow={onDeleteFlow}\n              onRefresh={onRefresh}\n              currentFlowId={activeFlowId}\n            />\n          )}\n        </Accordion>\n      )}\n\n      {!isLoading && filteredFlows.length === 0 && (\n        <div className=\"text-center py-8 text-muted-foreground text-sm\">\n          {flows.length === 0 ? (\n            <div className=\"space-y-2\">\n              <FolderOpen size={32} className=\"mx-auto text-muted-foreground\" />\n              <div>No flows saved yet</div>\n              <div className=\"text-xs\">Create your first flow to get started</div>\n            </div>\n          ) : (\n            'No flows match your search'\n          )}\n        </div>\n      )}\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/left/left-sidebar.tsx",
    "content": "import { useFlowManagementTabs } from '@/hooks/use-flow-management-tabs';\nimport { useResizable } from '@/hooks/use-resizable';\nimport { cn } from '@/lib/utils';\nimport { ReactNode, useEffect } from 'react';\nimport { FlowActions } from './flow-actions';\nimport { FlowCreateDialog } from './flow-create-dialog';\nimport { FlowList } from './flow-list';\n\ninterface LeftSidebarProps {\n  children?: ReactNode;\n  isCollapsed: boolean;\n  onCollapse: () => void;\n  onExpand: () => void;\n  onWidthChange?: (width: number) => void;\n}\n\nexport function LeftSidebar({\n  isCollapsed,\n  onWidthChange,\n}: LeftSidebarProps) {\n  // Use our custom hooks\n  const { width, isDragging, elementRef, startResize } = useResizable({\n    defaultWidth: 280,\n    minWidth: 200,\n    maxWidth: window.innerWidth * .90,\n    side: 'left',\n  });\n\n  // Notify parent component of width changes\n  useEffect(() => {\n    onWidthChange?.(width);\n  }, [width, onWidthChange]);\n  \n  // Use flow management hook with tabs\n  const {\n    flows,\n    searchQuery,\n    isLoading,\n    openGroups,\n    createDialogOpen,\n    filteredFlows,\n    recentFlows,\n    templateFlows,\n    setSearchQuery,\n    setCreateDialogOpen,\n    handleAccordionChange,\n    handleCreateNewFlow,\n    handleFlowCreated,\n    handleSaveCurrentFlow,\n    handleOpenFlowInTab,\n    handleDeleteFlow,\n    handleRefresh,\n  } = useFlowManagementTabs();\n\n  return (\n    <div \n      ref={elementRef}\n      className={cn(\n        \"h-full bg-panel flex flex-col relative pt-5 border\",\n        isCollapsed ? \"shadow-lg\" : \"\",\n      )}\n      style={{ \n        width: `${width}px`\n      }}\n    >\n      <FlowActions\n        onSave={handleSaveCurrentFlow}\n        onCreate={handleCreateNewFlow}\n      />\n      \n      <FlowList\n        flows={flows}\n        searchQuery={searchQuery}\n        isLoading={isLoading}\n        openGroups={openGroups}\n        filteredFlows={filteredFlows}\n        recentFlows={recentFlows}\n        templateFlows={templateFlows}\n        onSearchChange={setSearchQuery}\n        onAccordionChange={handleAccordionChange}\n        onLoadFlow={handleOpenFlowInTab}\n        onDeleteFlow={handleDeleteFlow}\n        onRefresh={handleRefresh}\n      />\n      \n      {/* Resize handle - on the right side for left sidebar */}\n      {!isDragging && (\n        <div \n          className=\"absolute top-0 right-0 h-full w-1 cursor-ew-resize transition-all duration-150 z-10\"\n          onMouseDown={startResize}\n        />\n      )}\n\n      <FlowCreateDialog\n        isOpen={createDialogOpen}\n        onClose={() => setCreateDialogOpen(false)}\n        onFlowCreated={handleFlowCreated}\n      />\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/right/component-actions.tsx",
    "content": "\ninterface ComponentActionsProps {\n}\n\nexport function ComponentActions({ }: ComponentActionsProps) {\n  return (\n    <div className=\"p-2 flex justify-between flex-shrink-0 items-center border-b mt-4\">\n      <span className=\"text-primary text-sm font-medium ml-4\">Components</span>\n      {/* <div className=\"flex items-center gap-1\">\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          onClick={onToggleCollapse}\n          className=\"h-6 w-6 text-primary hover:bg-ramp-grey-700\"\n          aria-label=\"Toggle sidebar\"\n          title={`Toggle Components Panel (${formatKeyboardShortcut('B')})`}\n        >\n          <PanelRight size={16} />\n        </Button>\n      </div> */}\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/right/component-item-group.tsx",
    "content": "import ComponentItem from '@/components/panels/right/component-item';\nimport { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';\nimport { useFlowContext } from '@/contexts/flow-context';\nimport { ComponentGroup } from '@/data/sidebar-components';\n\ninterface ComponentItemGroupProps {\n  group: ComponentGroup;\n  activeItem: string | null;\n}\n\nexport function ComponentItemGroup({ \n  group, \n  activeItem\n}: ComponentItemGroupProps) {\n  const { name, icon: Icon, iconColor, items } = group;\n  const { addComponentToFlow } = useFlowContext();\n\n  const handleItemClick = async (componentName: string) => {\n    try {\n      await addComponentToFlow(componentName);\n    } catch (error) {\n      console.error('Failed to add component to flow:', error);\n    }\n  };\n  \n  return (\n    <AccordionItem key={name} value={name} className=\"border-none\">\n      <AccordionTrigger className=\"px-4 py-2 text-sm hover-bg hover:no-underline\">\n        <div className=\"flex items-center gap-2\">\n          <Icon size={16} className={iconColor} />\n          <span className=\"capitalize\">{name}</span>\n        </div>\n      </AccordionTrigger>\n      <AccordionContent className=\"px-4\">\n        <div className=\"space-y-1\">\n          {items.map((item) => (\n            <ComponentItem \n              key={item.name}\n              icon={item.icon} \n              label={item.name} \n              isActive={activeItem === item.name}\n              onClick={() => handleItemClick(item.name)}\n            />\n          ))}\n        </div>\n      </AccordionContent>\n    </AccordionItem>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/right/component-item.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { LucideIcon, Plus } from \"lucide-react\";\nimport { useState } from \"react\";\n\ninterface ComponentItemProps {\n  icon: LucideIcon;\n  label: string;\n  onClick?: () => void;\n  className?: string;\n  isActive?: boolean;\n}\n\nexport default function ComponentItem({ \n  icon: Icon, \n  label, \n  onClick, \n  className, \n  isActive = false \n}: ComponentItemProps) {\n  const [isHovered, setIsHovered] = useState(false);\n  \n  const handlePlusClick = (e: React.MouseEvent) => {\n    e.stopPropagation(); // Prevent triggering the parent onClick\n    if (onClick) onClick();\n  };\n  \n  return (\n    <div \n      className={cn(\n        \"group flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer text-subtitle transition-colors duration-150\",\n        isActive ? \"bg-ramp-grey-700 text-primary\" : \"text-primary\",\n        isHovered ? \"hover-bg\" : \"\",\n        className\n      )}\n      onClick={onClick}\n      onMouseEnter={() => setIsHovered(true)}\n      onMouseLeave={() => setIsHovered(false)}\n      role=\"button\"\n      tabIndex={0}\n      onKeyDown={(e) => {\n        if (e.key === 'Enter' && onClick) {\n          onClick();\n        }\n      }}\n    >\n      <div className=\"flex-shrink-0\">\n        <Icon size={16} className={isActive ? \"text-primary\" : \"text-muted-foreground\"} />\n      </div>\n      <span className=\"truncate\">{label}</span>\n      \n      {/* Add button using shadcn Button */}\n      <div className=\"ml-auto opacity-0 group-hover:opacity-100\">\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"h-5 w-5 hover-bg hover:text-primary text-muted-foreground flex items-center justify-center\"\n          onClick={handlePlusClick}\n          aria-label=\"Add\"\n        >\n          <Plus size={14} />\n        </Button>\n      </div>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/right/component-list.tsx",
    "content": "import { Accordion } from '@/components/ui/accordion';\nimport { ComponentGroup } from '@/data/sidebar-components';\nimport { SearchBox } from '../search-box';\nimport { ComponentItemGroup } from './component-item-group';\n\ninterface ComponentListProps {\n  componentGroups: ComponentGroup[];\n  searchQuery: string;\n  isLoading: boolean;\n  openGroups: string[];\n  filteredGroups: ComponentGroup[];\n  activeItem: string | null;\n  onSearchChange: (query: string) => void;\n  onAccordionChange: (value: string[]) => void;\n}\n\nexport function ComponentList({\n  componentGroups,\n  searchQuery,\n  isLoading,\n  openGroups,\n  filteredGroups,\n  activeItem,\n  onSearchChange,\n  onAccordionChange,\n}: ComponentListProps) {\n  return (\n    <div className=\"flex-grow overflow-auto text-primary scrollbar-thin scrollbar-thumb-ramp-grey-700\">\n      <SearchBox \n        value={searchQuery} \n        onChange={onSearchChange}\n        placeholder=\"Search components...\"\n      />\n      \n      {isLoading ? (\n        <div className=\"flex items-center justify-center py-8\">\n          <div className=\"text-muted-foreground text-sm\">Loading components...</div>\n        </div>\n      ) : (\n        <Accordion \n          type=\"multiple\" \n          className=\"w-full\" \n          value={openGroups} \n          onValueChange={onAccordionChange}\n        >\n          {filteredGroups.map(group => (\n            <ComponentItemGroup\n              key={group.name} \n              group={group}\n              activeItem={activeItem}\n            />\n          ))}\n        </Accordion>\n      )}\n\n      {!isLoading && filteredGroups.length === 0 && (\n        <div className=\"text-center py-8 text-muted-foreground text-sm\">\n          {componentGroups.length === 0 ? (\n            <div className=\"space-y-2\">\n              <div>No components available</div>\n              <div className=\"text-xs\">Components will appear here when loaded</div>\n            </div>\n          ) : (\n            'No components match your search'\n          )}\n        </div>\n      )}\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/right/right-sidebar.tsx",
    "content": "import { ComponentGroup, getComponentGroups } from '@/data/sidebar-components';\nimport { useComponentGroups } from '@/hooks/use-component-groups';\nimport { useResizable } from '@/hooks/use-resizable';\nimport { cn } from '@/lib/utils';\nimport { ReactNode, useEffect, useState } from 'react';\nimport { ComponentActions } from './component-actions';\nimport { ComponentList } from './component-list';\n\ninterface RightSidebarProps {\n  children?: ReactNode;\n  isCollapsed: boolean;\n  onCollapse: () => void;\n  onExpand: () => void;\n  onWidthChange?: (width: number) => void;\n}\n\nexport function RightSidebar({\n  isCollapsed,\n  onWidthChange,\n}: RightSidebarProps) {\n  // Use our custom hooks\n  const { width, isDragging, elementRef, startResize } = useResizable({\n    defaultWidth: 280,\n    minWidth: 200,\n    maxWidth: window.innerWidth * .90,\n    side: 'right',\n  });\n  \n  // Notify parent component of width changes\n  useEffect(() => {\n    onWidthChange?.(width);\n  }, [width, onWidthChange]);\n  \n  // State for loading component groups\n  const [componentGroups, setComponentGroups] = useState<ComponentGroup[]>([]);\n  const [isLoading, setIsLoading] = useState(true);\n  \n  // Load component groups on mount\n  useEffect(() => {\n    const loadComponentGroups = async () => {\n      try {\n        setIsLoading(true);\n        const groups = await getComponentGroups();\n        setComponentGroups(groups);\n      } catch (error) {\n        console.error('Failed to load component groups:', error);\n      } finally {\n        setIsLoading(false);\n      }\n    };\n    \n    loadComponentGroups();\n  }, []);\n  \n  const { \n    searchQuery, \n    setSearchQuery, \n    activeItem, \n    openGroups, \n    filteredGroups,\n    handleAccordionChange \n  } = useComponentGroups(componentGroups);\n\n  return (\n    <div \n      ref={elementRef}\n      className={cn(\n        \"h-full bg-panel flex flex-col relative pt-5 border-l\",\n        isCollapsed ? \"shadow-lg\" : \"\",\n      )}\n      style={{ \n        width: `${width}px`\n      }}\n    >\n      <ComponentActions />\n      \n      <ComponentList\n        componentGroups={componentGroups}\n        searchQuery={searchQuery}\n        isLoading={isLoading}\n        openGroups={openGroups}\n        filteredGroups={filteredGroups}\n        activeItem={activeItem}\n        onSearchChange={setSearchQuery}\n        onAccordionChange={handleAccordionChange}\n      />\n      \n      {/* Resize handle - on the left side for right sidebar */}\n      {!isDragging && (\n        <div \n          className=\"absolute top-0 left-0 h-full w-1 cursor-ew-resize transition-all duration-150 z-10\"\n          onMouseDown={startResize}\n        />\n      )}\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/panels/search-box.tsx",
    "content": "import { Button } from '@/components/ui/button';\nimport { Search } from 'lucide-react';\n\ninterface SearchBoxProps {\n  value: string;\n  onChange: (value: string) => void;\n  placeholder?: string;\n}\n\nexport function SearchBox({ \n  value, \n  onChange, \n  placeholder = \"Search components...\" \n}: SearchBoxProps) {\n  return (\n    <div className=\"px-2 py-2 sticky top-0 bg-panel z-10\">\n      <div className=\"flex items-center rounded-md bg-sidebar-accent px-3 py-1\">\n        <Search className=\"h-4 w-4 text-muted-foreground mr-2 flex-shrink-0\" />\n        <input \n          type=\"text\" \n          placeholder={placeholder} \n          className=\"bg-transparent text-sm focus:outline-none text-primary w-full placeholder-muted-foreground\"\n          value={value}\n          onChange={(e) => onChange(e.target.value)}\n        />\n        {value && (\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            onClick={() => onChange('')}\n            className=\"h-4 w-4 text-muted-foreground hover:text-primary\"\n            aria-label=\"Clear search\"\n          >\n            <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n              <line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\" />\n              <line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\" />\n            </svg>\n          </Button>\n        )}\n      </div>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/settings/api-keys.tsx",
    "content": "import { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Input } from '@/components/ui/input';\nimport { apiKeysService } from '@/services/api-keys-api';\nimport { Eye, EyeOff, Key, Trash2 } from 'lucide-react';\nimport { useEffect, useState } from 'react';\n\ninterface ApiKey {\n  key: string;\n  label: string;\n  description: string;\n  url: string;\n  placeholder: string;\n}\n\nconst FINANCIAL_API_KEYS: ApiKey[] = [\n  {\n    key: 'FINANCIAL_DATASETS_API_KEY',\n    label: 'Financial Datasets API',\n    description: 'For getting financial data to power the hedge fund',\n    url: 'https://financialdatasets.ai/',\n    placeholder: 'your-financial-datasets-api-key'\n  }\n];\n\nconst LLM_API_KEYS: ApiKey[] = [\n  {\n    key: 'ANTHROPIC_API_KEY',\n    label: 'Anthropic API',\n    description: 'For Claude models (claude-4-sonnet, claude-4.1-opus, etc.)',\n    url: 'https://anthropic.com/',\n    placeholder: 'your-anthropic-api-key'\n  },\n  {\n    key: 'DEEPSEEK_API_KEY',\n    label: 'DeepSeek API',\n    description: 'For DeepSeek models (deepseek-chat, deepseek-reasoner, etc.)',\n    url: 'https://deepseek.com/',\n    placeholder: 'your-deepseek-api-key'\n  },\n  {\n    key: 'GROQ_API_KEY',\n    label: 'Groq API',\n    description: 'For Groq-hosted models (deepseek, llama3, etc.)',\n    url: 'https://groq.com/',\n    placeholder: 'your-groq-api-key'\n  },\n  {\n    key: 'GOOGLE_API_KEY',\n    label: 'Google API',\n    description: 'For Gemini models (gemini-2.5-flash, gemini-2.5-pro)',\n    url: 'https://ai.dev/',\n    placeholder: 'your-google-api-key'\n  },\n  {\n    key: 'OPENAI_API_KEY',\n    label: 'OpenAI API',\n    description: 'For OpenAI models (gpt-4o, gpt-4o-mini, etc.)',\n    url: 'https://platform.openai.com/',\n    placeholder: 'your-openai-api-key'\n  },\n  {\n    key: 'OPENROUTER_API_KEY',\n    label: 'OpenRouter API',\n    description: 'For OpenRouter models (gpt-4o, gpt-4o-mini, etc.)',\n    url: 'https://openrouter.ai/',\n    placeholder: 'your-openrouter-api-key'\n  },\n  {\n    key: 'GIGACHAT_API_KEY',\n    label: 'GigaChat API',\n    description: 'For GigaChat models (GigaChat-2-Max, etc.)',\n    url: 'https://github.com/ai-forever/gigachat',\n    placeholder: 'your-gigachat-api-key'\n  }\n];\n\nexport function ApiKeysSettings() {\n  const [apiKeys, setApiKeys] = useState<Record<string, string>>({});\n  const [visibleKeys, setVisibleKeys] = useState<Record<string, boolean>>({});\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n\n  // Load API keys from backend on component mount\n  useEffect(() => {\n    loadApiKeys();\n  }, []);\n\n  const loadApiKeys = async () => {\n    try {\n      setLoading(true);\n      setError(null);\n      const apiKeysSummary = await apiKeysService.getAllApiKeys();\n      \n      // Load actual key values for existing keys\n      const keysData: Record<string, string> = {};\n      for (const summary of apiKeysSummary) {\n        try {\n          const fullKey = await apiKeysService.getApiKey(summary.provider);\n          keysData[summary.provider] = fullKey.key_value;\n        } catch (err) {\n          console.warn(`Failed to load key for ${summary.provider}:`, err);\n        }\n      }\n      \n      setApiKeys(keysData);\n    } catch (err) {\n      console.error('Failed to load API keys:', err);\n      setError('Failed to load API keys. Please try again.');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleKeyChange = async (key: string, value: string) => {\n    // Update local state immediately for responsive UI\n    setApiKeys(prev => ({\n      ...prev,\n      [key]: value\n    }));\n\n    // Auto-save with debouncing\n    try {\n      if (value.trim()) {\n        await apiKeysService.createOrUpdateApiKey({\n          provider: key,\n          key_value: value.trim(),\n          is_active: true\n        });\n      } else {\n        // If value is empty, delete the key\n        try {\n          await apiKeysService.deleteApiKey(key);\n        } catch (err) {\n          // Key might not exist, which is fine\n          console.log(`Key ${key} not found for deletion, which is expected`);\n        }\n      }\n    } catch (err) {\n      console.error(`Failed to save API key ${key}:`, err);\n      setError(`Failed to save ${key}. Please try again.`);\n    }\n  };\n\n  const toggleKeyVisibility = (key: string) => {\n    setVisibleKeys(prev => ({\n      ...prev,\n      [key]: !prev[key]\n    }));\n  };\n\n  const clearKey = async (key: string) => {\n    try {\n      await apiKeysService.deleteApiKey(key);\n      setApiKeys(prev => {\n        const newKeys = { ...prev };\n        delete newKeys[key];\n        return newKeys;\n      });\n    } catch (err) {\n      console.error(`Failed to delete API key ${key}:`, err);\n      setError(`Failed to delete ${key}. Please try again.`);\n    }\n  };\n\n  const renderApiKeySection = (title: string, description: string, keys: ApiKey[], icon: React.ReactNode) => (\n    <Card className=\"bg-panel border-gray-700 dark:border-gray-700\">\n      <CardHeader>\n        <CardTitle className=\"text-lg font-medium text-primary flex items-center gap-2\">\n          {icon}\n          {title}\n        </CardTitle>\n        <p className=\"text-sm text-muted-foreground\">{description}</p>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        {keys.map((apiKey) => (\n          <div key={apiKey.key} className=\"space-y-2\">\n                         <button\n               className=\"text-sm font-medium text-primary hover:text-blue-500 cursor-pointer transition-colors text-left\"\n               onClick={() => window.open(apiKey.url, '_blank')}\n             >\n               {apiKey.label}\n             </button>\n            <div className=\"relative\">\n              <Input\n                type={visibleKeys[apiKey.key] ? 'text' : 'password'}\n                placeholder={apiKey.placeholder}\n                value={apiKeys[apiKey.key] || ''}\n                onChange={(e) => handleKeyChange(apiKey.key, e.target.value)}\n                className=\"pr-20\"\n              />\n              <div className=\"absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1\">\n                {apiKeys[apiKey.key] && (\n                  <Button\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    className=\"h-7 w-7 hover:bg-red-500/10 hover:text-red-500\"\n                    onClick={() => clearKey(apiKey.key)}\n                  >\n                    <Trash2 className=\"h-3 w-3\" />\n                  </Button>\n                )}\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  className=\"h-7 w-7\"\n                  onClick={() => toggleKeyVisibility(apiKey.key)}\n                >\n                  {visibleKeys[apiKey.key] ? (\n                    <EyeOff className=\"h-3 w-3\" />\n                  ) : (\n                    <Eye className=\"h-3 w-3\" />\n                  )}\n                </Button>\n              </div>\n            </div>\n          </div>\n        ))}\n      </CardContent>\n    </Card>\n  );\n\n  if (loading) {\n    return (\n      <div className=\"space-y-6\">\n        <div>\n          <h2 className=\"text-xl font-semibold text-primary mb-2\">API Keys</h2>\n          <p className=\"text-sm text-muted-foreground\">\n            Loading API keys...\n          </p>\n        </div>\n        <Card className=\"bg-panel border-gray-700 dark:border-gray-700\">\n          <CardContent className=\"p-6\">\n            <div className=\"text-sm text-muted-foreground\">\n              Please wait while we load your API keys...\n            </div>\n          </CardContent>\n        </Card>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-6\">\n      <div>\n        <h2 className=\"text-xl font-semibold text-primary mb-2\">API Keys</h2>\n        <p className=\"text-sm text-muted-foreground\">\n          Configure API endpoints and authentication credentials for financial data and language models.\n          Changes are automatically saved.\n        </p>\n      </div>\n\n      {/* Error Message */}\n      {error && (\n        <Card className=\"bg-red-500/5 border-red-500/20\">\n          <CardContent className=\"p-4\">\n            <div className=\"flex items-start gap-3\">\n              <Key className=\"h-5 w-5 text-red-500 mt-0.5 flex-shrink-0\" />\n              <div className=\"space-y-1\">\n                <h4 className=\"text-sm font-medium text-red-500\">Error</h4>\n                <p className=\"text-xs text-muted-foreground\">{error}</p>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={() => {\n                    setError(null);\n                    loadApiKeys();\n                  }}\n                  className=\"text-xs mt-2 p-0 h-auto text-red-500 hover:text-red-400\"\n                >\n                  Try again\n                </Button>\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n      )}\n\n      {/* Financial Data API Keys */}\n      {renderApiKeySection(\n        'Financial Data',\n        'API keys for accessing financial market data and datasets.',\n        FINANCIAL_API_KEYS,\n        <Key className=\"h-4 w-4\" />\n      )}\n\n      {/* LLM API Keys */}\n      {renderApiKeySection(\n        'Language Models',\n        'API keys for accessing various large language model providers.',\n        LLM_API_KEYS,\n        <Key className=\"h-4 w-4\" />\n      )}\n\n      {/* Security Note */}\n      <Card className=\"bg-amber-500/5 border-amber-500/20\">\n        <CardContent className=\"p-4\">\n          <div className=\"flex items-start gap-3\">\n            <Key className=\"h-5 w-5 text-amber-500 mt-0.5 flex-shrink-0\" />\n            <div className=\"space-y-1\">\n              <h4 className=\"text-sm font-medium text-amber-500\">Security Note</h4>\n              <p className=\"text-xs text-muted-foreground\">\n                API keys are stored securely on your local system and changes are automatically saved. \n                Keep your API keys secure and don't share them with others.\n              </p>\n            </div>\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/settings/appearance.tsx",
    "content": "import { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { cn } from '@/lib/utils';\nimport { Monitor, Moon, Sun } from 'lucide-react';\nimport { useTheme } from 'next-themes';\n\nexport function ThemeSettings() {\n  const { theme, setTheme } = useTheme();\n\n  const themes = [\n    {\n      id: 'light',\n      name: 'Light',\n      description: 'A clean, bright interface',\n      icon: Sun,\n    },\n    {\n      id: 'dark',\n      name: 'Dark',\n      description: 'A comfortable dark interface',\n      icon: Moon,\n    },\n    {\n      id: 'system',\n      name: 'System',\n      description: 'Use your system preference',\n      icon: Monitor,\n    },\n  ];\n\n  return (\n    <div className=\"space-y-6\">\n      <div>\n        <h2 className=\"text-xl font-semibold text-primary mb-2\">Theme</h2>\n        <p className=\"text-sm text-muted-foreground\">\n          Customize the look and feel of your application.\n        </p>\n      </div>\n\n      <Card className=\"bg-panel border-gray-700 dark:border-gray-700\">\n        <CardHeader>\n          <CardTitle className=\"text-lg font-medium text-primary\">\n            Theme\n          </CardTitle>\n        </CardHeader>\n        <CardContent className=\"space-y-4\">\n          <p className=\"text-sm text-muted-foreground\">\n            Select your preferred theme or use system setting to automatically switch between light and dark modes.\n          </p>\n          \n          <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3\">\n            {themes.map((themeOption) => {\n              const Icon = themeOption.icon;\n              const isSelected = theme === themeOption.id;\n              \n              return (\n                <Button\n                  key={themeOption.id}\n                  variant=\"outline\"\n                  className={cn(\n                    \"flex flex-col items-center gap-3 h-auto p-4 bg-panel border-gray-600 hover:border-primary hover-bg\",\n                    isSelected && \"border-blue-500 bg-blue-500/10 text-blue-500\"\n                  )}\n                  onClick={() => setTheme(themeOption.id)}\n                >\n                  <Icon className=\"h-6 w-6\" />\n                  <div className=\"text-center\">\n                    <div className=\"font-medium text-sm\">{themeOption.name}</div>\n                    <div className=\"text-xs text-muted-foreground mt-1\">\n                      {themeOption.description}\n                    </div>\n                  </div>\n                </Button>\n              );\n            })}\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/settings/index.ts",
    "content": "export { ApiKeysSettings } from './api-keys';\nexport { ThemeSettings } from './appearance';\nexport { Models } from './models';\nexport { CloudModels } from './models/cloud';\nexport { OllamaSettings } from './models/ollama';\n\n"
  },
  {
    "path": "app/frontend/src/components/settings/models/cloud.tsx",
    "content": "import { Badge } from '@/components/ui/badge';\nimport { cn } from '@/lib/utils';\nimport { Cloud, RefreshCw } from 'lucide-react';\nimport { useEffect, useState } from 'react';\n\ninterface CloudModelsProps {\n  className?: string;\n}\n\ninterface CloudModel {\n  display_name: string;\n  model_name: string;\n  provider: string;\n}\n\ninterface ModelProvider {\n  name: string;\n  models: Array<{\n    display_name: string;\n    model_name: string;\n  }>;\n}\n\nexport function CloudModels({ className }: CloudModelsProps) {\n  const [providers, setProviders] = useState<ModelProvider[]>([]);\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  const fetchProviders = async () => {\n    setLoading(true);\n    setError(null);\n    try {\n      const response = await fetch('http://localhost:8000/language-models/providers');\n      if (response.ok) {\n        const data = await response.json();\n        setProviders(data.providers);\n      } else {\n        const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));\n        setError(`Failed to fetch providers: ${errorData.detail}`);\n      }\n    } catch (error) {\n      console.error('Failed to fetch cloud model providers:', error);\n      setError('Failed to connect to backend service');\n    }\n    setLoading(false);\n  };\n\n  useEffect(() => {\n    fetchProviders();\n  }, []);\n\n  // Flatten all models from all providers into a single array\n  const allModels: CloudModel[] = providers.flatMap(provider =>\n    provider.models.map(model => ({\n      ...model,\n      provider: provider.name\n    }))\n  ).sort((a, b) => a.provider.localeCompare(b.provider));\n\n  return (\n    <div className={cn(\"space-y-6\", className)}>\n\n      {error && (\n        <div className=\"bg-red-900/20 border border-red-600/30 rounded-lg p-4\">\n          <div className=\"flex items-start gap-3\">\n            <Cloud className=\"h-5 w-5 text-red-500 mt-0.5\" />\n            <div>\n              <h4 className=\"font-medium text-red-300\">Error</h4>\n              <p className=\"text-sm text-red-500 mt-1\">{error}</p>\n            </div>\n          </div>\n        </div>\n      )}\n\n      <div className=\"space-y-2\">\n        <div className=\"flex items-center justify-between mb-3\">\n          <h3 className=\"font-medium text-primary\n          \">Available Models</h3>\n          <span className=\"text-xs text-muted-foreground\">\n            {allModels.length} models from {providers.length} providers\n          </span>\n        </div>\n\n        {loading ? (\n          <div className=\"text-center py-8\">\n            <RefreshCw className=\"h-8 w-8 mx-auto mb-2 animate-spin text-muted-foreground\" />\n            <p className=\"text-sm text-muted-foreground\">Loading cloud models...</p>\n          </div>\n        ) : allModels.length > 0 ? (\n          <div className=\"space-y-1\">\n            {allModels.map((model) => (\n              <div \n                key={`${model.provider}-${model.model_name}`}\n                className=\"group flex items-center justify-between bg-muted hover-bg rounded-md px-3 py-2.5 transition-colors\"\n              >\n                <div className=\"flex-1 min-w-0\">\n                  <div className=\"flex items-center gap-2\">\n                    <span className=\"font-medium text-sm truncate text-primary\">{model.display_name}</span>\n                    {model.model_name !== model.display_name && (\n                      <span className=\"font-mono text-xs text-muted-foreground\">\n                        {model.model_name}\n                      </span>\n                    )}\n                  </div>\n                </div>\n                \n                <div className=\"flex items-center gap-2\">\n                  <Badge className=\"text-xs text-primary bg-primary/10 border-primary/30 hover:bg-primary/20 hover:border-primary/50\">\n                    {model.provider}\n                  </Badge>\n                </div>\n              </div>\n            ))}\n          </div>\n        ) : (\n          !loading && (\n            <div className=\"text-center py-8 text-muted-foreground\">\n              <Cloud className=\"h-8 w-8 mx-auto mb-2 opacity-50\" />\n              <p className=\"text-sm\">No models available</p>\n            </div>\n          )\n        )}\n      </div>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/settings/models/ollama.tsx",
    "content": "import { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { cn } from '@/lib/utils';\nimport { AlertTriangle, Brain, CheckCircle, Download, Play, RefreshCw, Server, Square, Trash2, X } from 'lucide-react';\nimport { useEffect, useState } from 'react';\n\ninterface OllamaStatus {\n  installed: boolean;\n  running: boolean;\n  available_models: string[];\n  server_url: string;\n  error?: string;\n}\n\ninterface RecommendedModel {\n  display_name: string;\n  model_name: string;\n  provider: string;\n}\n\ninterface ModelWithStatus extends RecommendedModel {\n  isDownloaded: boolean;\n}\n\ninterface DownloadProgress {\n  status: string;\n  percentage?: number;\n  message?: string;\n  phase?: string;\n  bytes_downloaded?: number;\n  total_bytes?: number;\n  error?: string;\n}\n\nexport function OllamaSettings() {\n  const [ollamaStatus, setOllamaStatus] = useState<OllamaStatus | null>(null);\n  const [recommendedModels, setRecommendedModels] = useState<RecommendedModel[]>([]);\n  const [loading, setLoading] = useState(false);\n  const [actionLoading, setActionLoading] = useState<string | null>(null);\n  const [error, setError] = useState<string | null>(null);\n  const [downloadProgress, setDownloadProgress] = useState<Record<string, DownloadProgress>>({});\n  const [activeDownloads, setActiveDownloads] = useState<Set<string>>(new Set());\n  const [pollIntervals, setPollIntervals] = useState<Set<NodeJS.Timeout>>(new Set());\n  const [deleteConfirmation, setDeleteConfirmation] = useState<{\n    isOpen: boolean;\n    modelName: string;\n    displayName: string;\n  }>({\n    isOpen: false,\n    modelName: '',\n    displayName: ''\n  });\n  const [cancellingDownloads, setCancellingDownloads] = useState<Set<string>>(new Set());\n  const [cancelConfirmation, setCancelConfirmation] = useState<{\n    isOpen: boolean;\n    modelName: string;\n    displayName: string;\n  }>({\n    isOpen: false,\n    modelName: '',\n    displayName: ''\n  });\n\n  const fetchOllamaStatus = async () => {\n    try {\n      const response = await fetch('http://localhost:8000/ollama/status');\n      if (response.ok) {\n        const status = await response.json();\n        setOllamaStatus(status);\n        setError(null);\n      } else {\n        const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));\n        setError(`Failed to get status: ${errorData.detail}`);\n      }\n    } catch (error) {\n      console.error('Failed to fetch Ollama status:', error);\n      setError('Failed to connect to backend service');\n    }\n  };\n\n  const fetchRecommendedModels = async () => {\n    try {\n      const response = await fetch('http://localhost:8000/ollama/models/recommended');\n      if (response.ok) {\n        const models = await response.json();\n        setRecommendedModels(models);\n      } else {\n        console.error('Failed to fetch recommended models');\n      }\n    } catch (error) {\n      console.error('Failed to fetch recommended models:', error);\n    }\n  };\n\n  const startOllamaServer = async () => {\n    setActionLoading('start-server');\n    setError(null);\n    try {\n      const response = await fetch('http://localhost:8000/ollama/start', {\n        method: 'POST',\n      });\n      if (response.ok) {\n        await fetchOllamaStatus();\n      } else {\n        const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));\n        setError(`Failed to start server: ${errorData.detail}`);\n      }\n    } catch (error) {\n      console.error('Failed to start Ollama server:', error);\n      setError('Failed to start Ollama server');\n    }\n    setActionLoading(null);\n  };\n\n  const stopOllamaServer = async () => {\n    setActionLoading('stop-server');\n    setError(null);\n    try {\n      const response = await fetch('http://localhost:8000/ollama/stop', {\n        method: 'POST',\n      });\n      if (response.ok) {\n        await fetchOllamaStatus();\n      } else {\n        const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));\n        setError(`Failed to stop server: ${errorData.detail}`);\n      }\n    } catch (error) {\n      console.error('Failed to stop Ollama server:', error);\n      setError('Failed to stop Ollama server');\n    }\n    setActionLoading(null);\n  };\n\n  const downloadModelWithProgress = async (modelName: string) => {\n    setError(null);\n    setActiveDownloads(prev => new Set(prev).add(modelName));\n    setDownloadProgress(prev => ({\n      ...prev,\n      [modelName]: { status: 'starting', percentage: 0, message: 'Initializing download...' }\n    }));\n\n    try {\n      // Make a POST request to the progress endpoint which returns a streaming response\n      const response = await fetch('http://localhost:8000/ollama/models/download/progress', {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json',\n        },\n        body: JSON.stringify({ model_name: modelName }),\n      });\n\n      if (!response.ok) {\n        const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));\n        setError(`Failed to start download for ${modelName}: ${errorData.detail}`);\n        setActiveDownloads(prev => {\n          const newSet = new Set(prev);\n          newSet.delete(modelName);\n          return newSet;\n        });\n        return;\n      }\n\n      // Read the streaming response\n      const reader = response.body?.getReader();\n      const decoder = new TextDecoder();\n\n      if (reader) {\n        try {\n          while (true) {\n            const { done, value } = await reader.read();\n            if (done) break;\n\n            const chunk = decoder.decode(value, { stream: true });\n            const lines = chunk.split('\\n');\n\n            for (const line of lines) {\n              if (line.startsWith('data: ')) {\n                try {\n                  const jsonData = line.slice(6).trim();\n                  if (jsonData) {\n                    const data = JSON.parse(jsonData);\n                    \n                    setDownloadProgress(prev => ({\n                      ...prev,\n                      [modelName]: data\n                    }));\n\n                    // Check if download is complete or failed\n                    if (data.status === 'completed') {\n                      setActiveDownloads(prev => {\n                        const newSet = new Set(prev);\n                        newSet.delete(modelName);\n                        return newSet;\n                      });\n                      // Immediately clean up progress display for completed downloads\n                      setDownloadProgress(prev => {\n                        const newProgress = { ...prev };\n                        delete newProgress[modelName];\n                        return newProgress;\n                      });\n                      // Refresh status to show the new model with retry logic\n                      const refreshWithRetry = async (attempts = 0) => {\n                        try {\n                          const response = await fetch('http://localhost:8000/ollama/status');\n                          if (response.ok) {\n                            const status = await response.json();\n                            setOllamaStatus(status);\n                            setError(null);\n                            \n                            // Check if the model is now in the available models list\n                            if (attempts < 5 && status && !status.available_models.includes(modelName)) {\n                              // Wait a bit longer and try again\n                              setTimeout(() => refreshWithRetry(attempts + 1), 2000);\n                            }\n                          } else if (attempts < 5) {\n                            // If fetch failed, retry\n                            setTimeout(() => refreshWithRetry(attempts + 1), 2000);\n                          }\n                        } catch (error) {\n                          console.error('Failed to refresh status:', error);\n                          if (attempts < 5) {\n                            setTimeout(() => refreshWithRetry(attempts + 1), 2000);\n                          }\n                        }\n                      };\n                      setTimeout(() => refreshWithRetry(), 2000);\n                      return; // Exit the function\n                    } else if (data.status === 'error' || data.status === 'cancelled') {\n                      setActiveDownloads(prev => {\n                        const newSet = new Set(prev);\n                        newSet.delete(modelName);\n                        return newSet;\n                      });\n                      if (data.status === 'error') {\n                        setError(`Download failed for ${modelName}: ${data.message || data.error}`);\n                      }\n                      // Clean up progress display after 3 seconds for errors/cancellations\n                      setTimeout(() => {\n                        setDownloadProgress(prev => {\n                          const newProgress = { ...prev };\n                          delete newProgress[modelName];\n                          return newProgress;\n                        });\n                      }, 3000);\n                      return; // Exit the function\n                    }\n                  }\n                } catch (e) {\n                  console.error('Error parsing progress data:', e, 'Line:', line);\n                }\n              }\n            }\n          }\n        } finally {\n          reader.releaseLock();\n        }\n      }\n    } catch (error) {\n      console.error('Failed to download model with progress:', error);\n      setError(`Failed to download ${modelName}: ${error instanceof Error ? error.message : 'Unknown error'}`);\n      setActiveDownloads(prev => {\n        const newSet = new Set(prev);\n        newSet.delete(modelName);\n        return newSet;\n      });\n    }\n  };\n\n  const performCancelDownload = async (modelName: string) => {\n    setError(null);\n    setCancellingDownloads(prev => new Set(prev).add(modelName));\n    \n    try {\n      // Call the backend to cancel the download\n      const response = await fetch(`http://localhost:8000/ollama/models/download/${encodeURIComponent(modelName)}`, {\n        method: 'DELETE',\n      });\n      \n      if (response.ok) {\n        console.log(`Successfully cancelled download for ${modelName}`);\n      } else {\n        const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));\n        console.warn(`Failed to cancel download for ${modelName}: ${errorData.detail}`);\n        // Don't show error to user since the UI cleanup will still happen\n      }\n    } catch (error) {\n      console.error('Failed to cancel download:', error);\n      // Don't show error to user since the UI cleanup will still happen\n    }\n    \n    // Always clean up the UI state regardless of backend response\n    setActiveDownloads(prev => {\n      const newSet = new Set(prev);\n      newSet.delete(modelName);\n      return newSet;\n    });\n    setDownloadProgress(prev => {\n      const newProgress = { ...prev };\n      delete newProgress[modelName];\n      return newProgress;\n    });\n    setCancellingDownloads(prev => {\n      const newSet = new Set(prev);\n      newSet.delete(modelName);\n      return newSet;\n    });\n    \n    console.log(`Cancelled download tracking for ${modelName}`);\n  };\n\n  const cancelDownload = (modelName: string) => {\n    const displayName = recommendedModels.find(m => m.model_name === modelName)?.display_name || modelName;\n    setCancelConfirmation({\n      isOpen: true,\n      modelName,\n      displayName\n    });\n  };\n\n  const deleteModel = async (modelName: string) => {\n    setActionLoading(`delete-${modelName}`);\n    setError(null);\n    try {\n      const response = await fetch(`http://localhost:8000/ollama/models/${encodeURIComponent(modelName)}`, {\n        method: 'DELETE',\n      });\n      if (response.ok) {\n        await fetchOllamaStatus();\n      } else {\n        const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));\n        setError(`Failed to delete ${modelName}: ${errorData.detail}`);\n      }\n    } catch (error) {\n      console.error('Failed to delete model:', error);\n      setError(`Failed to delete ${modelName}`);\n    }\n    setActionLoading(null);\n  };\n\n  const confirmDeleteModel = async () => {\n    const { modelName } = deleteConfirmation;\n    setDeleteConfirmation({ isOpen: false, modelName: '', displayName: '' });\n    await deleteModel(modelName);\n  };\n\n  const cancelDeleteModel = () => {\n    setDeleteConfirmation({ isOpen: false, modelName: '', displayName: '' });\n  };\n\n  const confirmCancelDownload = async () => {\n    const { modelName } = cancelConfirmation;\n    setCancelConfirmation({ isOpen: false, modelName: '', displayName: '' });\n    await performCancelDownload(modelName);\n  };\n\n  const cancelCancelDownload = () => {\n    setCancelConfirmation({ isOpen: false, modelName: '', displayName: '' });\n  };\n\n  const refreshStatus = async () => {\n    setLoading(true);\n    setError(null);\n    await Promise.all([fetchOllamaStatus(), fetchRecommendedModels()]);\n    setLoading(false);\n  };\n\n  const checkForActiveDownloads = async () => {\n    // Check if Ollama is running\n    if (!ollamaStatus?.running) return;\n\n    try {\n      // Get all active downloads in one call instead of checking each model individually\n      const response = await fetch('http://localhost:8000/ollama/models/downloads/active');\n      if (response.ok) {\n        const activeDownloads = await response.json();\n        \n        // Update state with any active downloads found (only downloading/starting status)\n        Object.entries(activeDownloads).forEach(([modelName, progress]) => {\n          const progressData = progress as DownloadProgress;\n          \n          // Only add truly active downloads to avoid showing completed ones on refresh\n          if (progressData.status === 'downloading' || progressData.status === 'starting') {\n            setActiveDownloads(prev => new Set(prev).add(modelName));\n            setDownloadProgress(prev => ({\n              ...prev,\n              [modelName]: progressData\n            }));\n            \n            // Start monitoring this download\n            reconnectToDownload(modelName);\n          }\n        });\n      }\n    } catch (error) {\n      // Ignore errors - probably no active downloads or server not available\n      console.debug('No active downloads found or error checking:', error);\n    }\n  };\n\n  const reconnectToDownload = async (modelName: string) => {\n    // Don't reconnect if we're already tracking this download\n    if (activeDownloads.has(modelName)) {\n      console.debug(`Already tracking download for ${modelName}`);\n      return;\n    }\n\n    console.log(`Monitoring existing download for ${modelName}`);\n    \n    // Poll for progress updates instead of starting a new stream\n    const pollProgress = async () => {\n      try {\n        // Check all active downloads instead of individual model\n        const response = await fetch('http://localhost:8000/ollama/models/downloads/active');\n        if (response.ok) {\n          const activeDownloads = await response.json();\n          const progress = activeDownloads[modelName];\n          \n          if (progress) {\n            setDownloadProgress(prev => ({\n              ...prev,\n              [modelName]: progress\n            }));\n\n            // Check if download is complete or failed\n            if (progress.status === 'completed') {\n              setActiveDownloads(prev => {\n                const newSet = new Set(prev);\n                newSet.delete(modelName);\n                return newSet;\n              });\n              // Immediately clean up progress display for completed downloads\n              setDownloadProgress(prev => {\n                const newProgress = { ...prev };\n                delete newProgress[modelName];\n                return newProgress;\n              });\n              // Refresh status to show the new model with retry logic\n              const refreshWithRetry = async (attempts = 0) => {\n                try {\n                  const response = await fetch('http://localhost:8000/ollama/status');\n                  if (response.ok) {\n                    const status = await response.json();\n                    setOllamaStatus(status);\n                    setError(null);\n                    \n                    // Check if the model is now in the available models list\n                    if (attempts < 5 && status && !status.available_models.includes(modelName)) {\n                      // Wait a bit longer and try again\n                      setTimeout(() => refreshWithRetry(attempts + 1), 2000);\n                    }\n                  } else if (attempts < 5) {\n                    // If fetch failed, retry\n                    setTimeout(() => refreshWithRetry(attempts + 1), 2000);\n                  }\n                } catch (error) {\n                  console.error('Failed to refresh status:', error);\n                  if (attempts < 5) {\n                    setTimeout(() => refreshWithRetry(attempts + 1), 2000);\n                  }\n                }\n              };\n              setTimeout(() => refreshWithRetry(), 2000);\n              return false; // Stop polling\n            } else if (progress.status === 'error' || progress.status === 'cancelled') {\n              setActiveDownloads(prev => {\n                const newSet = new Set(prev);\n                newSet.delete(modelName);\n                return newSet;\n              });\n              if (progress.status === 'error') {\n                setError(`Download failed for ${modelName}: ${progress.message || progress.error}`);\n              }\n              // Clean up progress display after 3 seconds for errors/cancellations\n              setTimeout(() => {\n                setDownloadProgress(prev => {\n                  const newProgress = { ...prev };\n                  delete newProgress[modelName];\n                  return newProgress;\n                });\n              }, 3000);\n              return false; // Stop polling\n            }\n            \n            return true; // Continue polling\n          } else {\n            // Model not in active downloads, remove from tracking\n            setActiveDownloads(prev => {\n              const newSet = new Set(prev);\n              newSet.delete(modelName);\n              return newSet;\n            });\n            return false; // Stop polling\n          }\n        } else {\n          // Error getting active downloads, stop polling\n          console.error(`Error getting active downloads: ${response.status}`);\n          return false; // Stop polling\n        }\n      } catch (error) {\n        console.error(`Error polling progress for ${modelName}:`, error);\n        return false; // Stop polling on error\n      }\n    };\n\n    // Start polling every 2 seconds\n    const pollInterval = setInterval(async () => {\n      const shouldContinue = await pollProgress();\n      if (!shouldContinue) {\n        clearInterval(pollInterval);\n        setPollIntervals(prev => {\n          const newSet = new Set(prev);\n          newSet.delete(pollInterval);\n          return newSet;\n        });\n      }\n    }, 2000);\n\n    // Track the interval for cleanup\n    setPollIntervals(prev => new Set(prev).add(pollInterval));\n\n    // Do an initial poll\n    const shouldContinue = await pollProgress();\n    if (!shouldContinue) {\n      clearInterval(pollInterval);\n      setPollIntervals(prev => {\n        const newSet = new Set(prev);\n        newSet.delete(pollInterval);\n        return newSet;\n      });\n    }\n  };\n\n  useEffect(() => {\n    refreshStatus();\n  }, []);\n\n  // Check for active downloads after we have status and models data\n  useEffect(() => {\n    if (ollamaStatus?.running && recommendedModels.length > 0) {\n      checkForActiveDownloads();\n    }\n  }, [ollamaStatus?.running, recommendedModels.length]); // Only depend on running status and whether we have models\n\n  // Cleanup polling intervals on unmount\n  useEffect(() => {\n    return () => {\n      pollIntervals.forEach(interval => clearInterval(interval));\n    };\n  }, [pollIntervals]);\n\n  const getStatusIcon = () => {\n    if (!ollamaStatus) return <RefreshCw className=\"h-4 w-4 animate-spin text-muted-foreground\" />;\n    if (!ollamaStatus.installed) return <AlertTriangle className=\"h-4 w-4 text-muted-foreground\" />;\n    if (!ollamaStatus.running) return <Server className=\"h-4 w-4 text-muted-foreground\" />;\n    return <CheckCircle className=\"h-4 w-4 text-muted-foreground\" />;\n  };\n\n  const getStatusText = () => {\n    if (!ollamaStatus) return \"Checking...\";\n    if (!ollamaStatus.installed) return \"Not Installed\";\n    if (!ollamaStatus.running) return \"Not Running\";\n    return \"Running\";\n  };\n\n  const getStatusColor = (): \"secondary\" | \"destructive\" | \"outline\" | \"warning\" | \"success\" | null | undefined => {\n    return \"secondary\";\n  };\n\n  const formatBytes = (bytes: number): string => {\n    if (bytes === 0) return '0 B';\n    const k = 1024;\n    const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];\n    const i = Math.floor(Math.log(bytes) / Math.log(k));\n    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n  };\n\n  // Create a unified list of all models (downloaded first, then available for download)\n  const allModels: ModelWithStatus[] = [];\n  \n  // Add downloaded models first (sorted alphabetically)\n  if (ollamaStatus?.available_models) {\n    const sortedDownloaded = [...ollamaStatus.available_models].sort();\n    sortedDownloaded.forEach(modelName => {\n      // Try to find the model in recommended list for display name\n      const recommendedModel = recommendedModels.find(m => m.model_name === modelName);\n      allModels.push({\n        model_name: modelName,\n        display_name: recommendedModel?.display_name || modelName,\n        provider: 'Ollama',\n        isDownloaded: true\n      });\n    });\n  }\n  \n  // Add non-downloaded recommended models (sorted alphabetically by display name)\n  // Exclude models that are already downloaded OR currently being downloaded\n  const nonDownloadedModels = recommendedModels\n    .filter(model => \n      !ollamaStatus?.available_models?.includes(model.model_name) && \n      !activeDownloads.has(model.model_name)\n    )\n    .sort((a, b) => a.display_name.localeCompare(b.display_name))\n    .map(model => ({\n      ...model,\n      isDownloaded: false\n    }));\n  \n  allModels.push(...nonDownloadedModels);\n\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"flex items-center justify-between\">\n        <div>\n          <h3 className=\"text-lg font-semibold text-primary mb-2\">Ollama</h3>\n          <p className=\"text-sm text-muted-foreground dark:text-muted-foreground\">\n            Manage local AI models with Ollama for enhanced privacy and performance.\n          </p>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <Badge variant={getStatusColor()} className=\"flex items-center gap-1\">\n            {getStatusIcon()}\n            {getStatusText()}\n          </Badge>\n          <Button\n            size=\"sm\"\n            onClick={refreshStatus}\n            disabled={loading}\n            className=\"text-primary hover:bg-primary/20 hover:text-primary bg-primary/10 border-primary/30 hover:border-primary/50\"\n          >\n            <RefreshCw className={cn(\"h-4 w-4\", loading && \"animate-spin\")} />\n          </Button>\n        </div>\n      </div>\n\n      {error && (\n        <div className=\"bg-red-900/20 border border-red-600/30 rounded-lg p-4\">\n          <div className=\"flex items-start gap-3\">\n            <AlertTriangle className=\"h-5 w-5 text-red-400 mt-0.5\" />\n            <div>\n              <h4 className=\"font-medium text-red-300\">Error</h4>\n              <p className=\"text-sm text-red-400 mt-1\">{error}</p>\n            </div>\n          </div>\n        </div>\n      )}\n\n      {!ollamaStatus?.installed && (\n        <div className=\"bg-muted rounded-lg p-4\">\n          <div className=\"flex items-start gap-3\">\n            <AlertTriangle className=\"h-5 w-5 text-muted-foreground mt-0.5\" />\n            <div>\n              <h4 className=\"font-medium text-muted-foreground\">Ollama Not Installed</h4>\n              <p className=\"text-sm text-muted-foreground mt-1\">\n                Install Ollama to use local AI models. Visit{' '}\n                <a \n                  href=\"https://ollama.com\" \n                  target=\"_blank\" \n                  rel=\"noopener noreferrer\"\n                  className=\"underline hover:no-underline text-muted-foreground\"\n                >\n                  ollama.com\n                </a>{' '}\n                to download and install.\n              </p>\n            </div>\n          </div>\n        </div>\n      )}\n\n      {ollamaStatus?.installed && !ollamaStatus.running && (\n        <div className=\"flex items-center justify-between bg-muted rounded-lg p-4\">\n          <div>\n            <h4 className=\"font-medium text-primary\">Ollama Server</h4>\n            <p className=\"text-sm text-primary\">\n              Ollama is installed but not currently running.\n            </p>\n          </div>\n          <Button\n            onClick={startOllamaServer}\n            disabled={actionLoading === 'start-server'}\n            className=\"flex items-center gap-2 text-primary hover:bg-primary/20 hover:text-primary bg-primary/10 border-primary/30 hover:border-primary/50\"\n          >\n            <Play className=\"h-4 w-4\" />\n            {actionLoading === 'start-server' ? 'Starting...' : 'Start Server'}\n          </Button>\n        </div>\n      )}\n\n      {ollamaStatus?.running && (\n        <div className=\"flex items-center justify-between bg-muted rounded-lg p-4\">\n          <div className=\"flex items-center gap-2\">\n            <CheckCircle className=\"h-5 w-5 text-primary\" />\n            <div>\n              <span className=\"font-medium text-primary\">\n                Ollama Server Running\n              </span>\n              <p className=\"text-sm text-muted-foreground\">\n                Server available at {ollamaStatus.server_url}\n              </p>\n            </div>\n          </div>\n          <Button\n            onClick={stopOllamaServer}\n            disabled={actionLoading === 'stop-server'}\n            className=\"flex items-center gap-2 text-red-400 hover:bg-red-500/20 hover:text-red-300 bg-red-500/10 border-red-500/30 hover:border-red-500/50\"\n          >\n            <Square className=\"h-4 w-4\" />\n            {actionLoading === 'stop-server' ? 'Stopping...' : 'Disconnect'}\n          </Button>\n        </div>\n      )}\n\n      {ollamaStatus?.running && (\n        <div className=\"space-y-2\">\n          <div className=\"flex items-center justify-between mb-3\">\n            <h3 className=\"font-medium text-primary\">Available Models</h3>\n            <span className=\"text-xs text-muted-foreground\">\n              {ollamaStatus.available_models.length} downloaded\n            </span>\n          </div>\n          \n          {/* Show active downloads */}\n          {Object.entries(downloadProgress).map(([modelName, progress]) => (\n            <div key={`download-${modelName}`} className=\"bg-muted rounded-md px-3 py-3\">\n              <div className=\"flex items-center justify-between mb-2\">\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"font-medium text-sm text-primary\">\n                    {recommendedModels.find(m => m.model_name === modelName)?.display_name || modelName}\n                  </span>\n                  <Badge className={cn(\n                    \"text-xs border\",\n                    progress.status === 'downloading' && \"bg-blue-600/30 text-primary border-blue-600/40\",\n                    progress.status === 'completed' && \"bg-green-600/30 text-green-500 border-green-600/40\",\n                    progress.status === 'error' && \"bg-red-600/30 text-red-500 border-red-600/40\",\n                    progress.status === 'cancelled' && \"bg-muted text-muted-foreground\"\n                  )}>\n                    {progress.status === 'downloading' && 'Downloading'}\n                    {progress.status === 'completed' && 'Completed'}\n                    {progress.status === 'error' && 'Failed'}\n                    {progress.status === 'cancelled' && 'Cancelled'}\n                    {!['downloading', 'completed', 'error', 'cancelled'].includes(progress.status) && progress.status}\n                  </Badge>\n                </div>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={() => cancelDownload(modelName)}\n                  disabled={cancellingDownloads.has(modelName) || ['completed', 'error', 'cancelled'].includes(progress.status)}\n                  className=\"text-muted-foreground hover:text-primary h-6 w-6 p-0\"\n                >\n                  {cancellingDownloads.has(modelName) ? (\n                    <RefreshCw className=\"h-3 w-3 animate-spin\" />\n                  ) : (\n                    <X className=\"h-3 w-3\" />\n                  )}\n                </Button>\n              </div>\n              \n              {/* Progress bar */}\n              <div className=\"space-y-2\">\n                <div className=\"flex items-center justify-between text-xs text-muted-foreground\">\n                  <span>{progress.phase || progress.status}</span>\n                  <span>{progress.percentage ? `${progress.percentage.toFixed(1)}%` : '...'}</span>\n                </div>\n                <div className=\"w-full bg-muted rounded-full h-2\">\n                  <div \n                    className=\"bg-blue-500 h-2 rounded-full transition-all duration-300\"\n                    style={{ width: `${progress.percentage || 0}%` }}\n                  />\n                </div>\n                {progress.bytes_downloaded && progress.total_bytes && (\n                  <div className=\"text-xs text-muted-foreground\">\n                    {formatBytes(progress.bytes_downloaded)} / {formatBytes(progress.total_bytes)}\n                  </div>\n                )}\n                {progress.message && (\n                  <div className=\"text-xs text-muted-foreground truncate\">{progress.message}</div>\n                )}\n              </div>\n            </div>\n          ))}\n          \n          {allModels.length > 0 ? (\n            <div className=\"space-y-1\">\n              {allModels.map((model) => (\n                <div \n                  key={model.model_name} \n                  className=\"group flex items-center justify-between bg-muted hover-bg rounded-md px-3 py-2.5 transition-colors\"\n                >\n                  <div className=\"flex-1 min-w-0\">\n                    <div className=\"flex items-center gap-2\">\n                      <span className=\"font-medium text-sm truncate text-primary\">{model.display_name}</span>\n                      {model.model_name !== model.display_name && (\n                        <span className=\"font-mono text-xs text-muted-foreground\">\n                          {model.model_name}\n                        </span>\n                      )}\n                    </div>\n                  </div>\n                  \n                  <div className=\"flex items-center gap-2\">\n                    {model.isDownloaded && (\n                      <>\n                        <Button\n                          variant=\"ghost\"\n                          size=\"sm\"\n                          onClick={() => {\n                            setDeleteConfirmation({\n                              isOpen: true,\n                              modelName: model.model_name,\n                              displayName: model.display_name\n                            });\n                          }}\n                          disabled={actionLoading === `delete-${model.model_name}`}\n                          className=\"opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary h-6 w-6 p-0\"\n                        >\n                          <Trash2 className=\"h-3 w-3\" />\n                        </Button>\n                      </>\n                    )}\n                    {!model.isDownloaded && !activeDownloads.has(model.model_name) && (\n                      <>\n                        <Button\n                          size=\"sm\"\n                          onClick={() => downloadModelWithProgress(model.model_name)}\n                          className=\"flex items-center gap-2 h-7 text-primary hover:bg-primary/20 hover:text-primary bg-primary/10 border-primary/30 hover:border-primary/50\"\n                        >\n                          <Download className=\"h-3 w-3\" />\n                          Download\n                        </Button>\n                      </>\n                    )}\n                  </div>\n                </div>\n              ))}\n            </div>\n          ) : (\n            <div className=\"text-center py-8 text-muted-foreground\">\n              <Brain className=\"h-8 w-8 mx-auto mb-2 opacity-50\" />\n              <p className=\"text-sm\">No models available</p>\n            </div>\n          )}\n        </div>\n      )}\n      \n      {/* Delete Confirmation Dialog */}\n      <Dialog open={deleteConfirmation.isOpen} onOpenChange={(open) => {\n        if (!open) cancelDeleteModel();\n      }}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle className=\"flex items-center gap-2\">\n              <AlertTriangle className=\"h-5 w-5 text-red-400\" />\n              Delete Model\n            </DialogTitle>\n            <DialogDescription>\n              Are you sure you want to delete <strong>{deleteConfirmation.displayName}</strong>?\n              <br />\n              <span className=\"text-sm text-muted-foreground mt-1 block\">\n                Model: {deleteConfirmation.modelName}\n              </span>\n              <br />\n              This action cannot be undone. You will need to download the model again to use it.\n            </DialogDescription>\n          </DialogHeader>\n          <DialogFooter className=\"flex gap-2\">\n            <Button\n              variant=\"outline\"\n              onClick={cancelDeleteModel}\n              disabled={actionLoading === `delete-${deleteConfirmation.modelName}`}\n            >\n              Cancel\n            </Button>\n            <Button\n              variant=\"destructive\"\n              onClick={confirmDeleteModel}\n              disabled={actionLoading === `delete-${deleteConfirmation.modelName}`}\n              className=\"flex items-center gap-2\"\n            >\n              <Trash2 className=\"h-4 w-4\" />\n              {actionLoading === `delete-${deleteConfirmation.modelName}` ? 'Deleting...' : 'Delete Model'}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n      \n      {/* Cancel Download Confirmation Dialog */}\n      <Dialog open={cancelConfirmation.isOpen} onOpenChange={(open) => {\n        if (!open) cancelCancelDownload();\n      }}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle className=\"flex items-center gap-2\">\n              <AlertTriangle className=\"h-5 w-5 text-yellow-500\" />\n              Cancel Download\n            </DialogTitle>\n            <DialogDescription>\n              Are you sure you want to cancel the download of <strong>{cancelConfirmation.displayName}</strong>?\n              <br />\n              <span className=\"text-sm text-muted-foreground mt-1 block\">\n                Model: {cancelConfirmation.modelName}\n              </span>\n              <br />\n              Any progress will be lost and you'll need to start the download again.\n            </DialogDescription>\n          </DialogHeader>\n          <DialogFooter className=\"flex gap-2\">\n            <Button\n              variant=\"outline\"\n              onClick={cancelCancelDownload}\n              disabled={cancellingDownloads.has(cancelConfirmation.modelName)}\n            >\n              Continue Download\n            </Button>\n            <Button\n              variant=\"destructive\"\n              onClick={confirmCancelDownload}\n              disabled={cancellingDownloads.has(cancelConfirmation.modelName)}\n              className=\"flex items-center gap-2\"\n            >\n              <X className=\"h-4 w-4\" />\n              {cancellingDownloads.has(cancelConfirmation.modelName) ? 'Cancelling...' : 'Cancel Download'}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n}"
  },
  {
    "path": "app/frontend/src/components/settings/models.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { Cloud, Server } from 'lucide-react';\nimport { useState } from 'react';\nimport { CloudModels } from './models/cloud';\nimport { OllamaSettings } from './models/ollama';\n\ninterface ModelsProps {\n  className?: string;\n}\n\ninterface ModelSection {\n  id: string;\n  label: string;\n  icon: React.ComponentType<{ className?: string }>;\n  description: string;\n  component: React.ComponentType;\n}\n\nexport function Models({ className }: ModelsProps) {\n  const [selectedSection, setSelectedSection] = useState('cloud');\n\n  const modelSections: ModelSection[] = [\n    {\n      id: 'cloud',\n      label: 'Cloud',\n      icon: Cloud,\n      description: 'API-based models from cloud providers',\n      component: CloudModels,\n    },\n    {\n      id: 'local',\n      label: 'Ollama',\n      icon: Server,\n      description: 'Ollama models running locally on your machine',\n      component: OllamaSettings,\n    },\n  ];\n\n  const renderContent = () => {\n    const section = modelSections.find(s => s.id === selectedSection);\n    if (!section) return null;\n    \n    const Component = section.component;\n    return <Component />;\n  };\n\n  return (\n    <div className={cn(\"space-y-6\", className)}>\n      <div>\n        <h2 className=\"text-xl font-semibold text-primary mb-2\">Models</h2>\n        <p className=\"text-sm text-muted-foreground\">\n          Manage your AI models from local and cloud providers.\n        </p>\n      </div>\n\n      {/* Model Type Navigation */}\n      <div className=\"flex space-x-1 bg-muted p-1 rounded-lg\">\n        {modelSections.map((section) => {\n          const Icon = section.icon;\n          const isSelected = selectedSection === section.id;\n          const isDisabled = false; // Enable all tabs now that cloud models is functional\n          \n          return (\n            <button\n              key={section.id}\n              onClick={() => !isDisabled && setSelectedSection(section.id)}\n              disabled={isDisabled}\n              className={cn(\n                \"flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium rounded-md transition-colors\",\n                isSelected \n                  ? \"active-bg text-blue-500 shadow-sm\" \n                  : isDisabled\n                  ? \"text-muted-foreground cursor-not-allowed\"\n                  : \"text-primary hover:text-primary hover-bg\"\n              )}\n            >\n              <Icon className=\"h-4 w-4\" />\n              {section.label}\n              {isDisabled && (\n                <span className=\"text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded\">\n                  Soon\n                </span>\n              )}\n            </button>\n          );\n        })}\n      </div>\n\n      {/* Content Area */}\n      <div className=\"mt-6\">\n        {renderContent()}\n      </div>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/settings/settings.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { CubeIcon } from '@radix-ui/react-icons';\nimport { Key, Palette } from 'lucide-react';\nimport { useState } from 'react';\nimport { ApiKeysSettings, Models } from './';\nimport { ThemeSettings } from './appearance';\n\ninterface SettingsProps {\n  className?: string;\n}\n\ninterface SettingsNavItem {\n  id: string;\n  label: string;\n  icon: React.ComponentType<{ className?: string }>;\n  description?: string;\n}\n\nexport function Settings({ className }: SettingsProps) {\n  const [selectedSection, setSelectedSection] = useState('api');\n\n  const navigationItems: SettingsNavItem[] = [\n    {\n      id: 'api',\n      label: 'API Keys',\n      icon: Key,\n      description: 'API endpoints and authentication',\n    },\n    {\n      id: 'models',\n      label: 'Models',\n      icon: CubeIcon,\n      description: 'Local and cloud AI models',\n    },\n    {\n      id: 'theme',\n      label: 'Theme',\n      icon: Palette,\n      description: 'Theme and display preferences',\n    },\n  ];\n\n  const renderContent = () => {\n    switch (selectedSection) {\n      case 'models':\n        return <Models />;\n      case 'theme':\n        return <ThemeSettings />;\n      case 'api':\n        return <ApiKeysSettings />;\n      default:\n        return <Models />;\n    }\n  };\n\n  return (\n    <div className={cn(\"flex justify-center h-full overflow-hidden bg-panel\", className)}>\n      <div className=\"flex w-full max-w-7xl mx-auto\">\n        {/* Left Navigation Pane */}\n        <div className=\"w-60 bg-panel flex-shrink-0\">\n          <div className=\"p-4 border-b\">\n            <h1 className=\"text-lg font-semibold text-primary\">Settings</h1>\n          </div>\n          <nav className=\"p-2\">\n            {navigationItems.map((item) => {\n              const Icon = item.icon;\n              const isSelected = selectedSection === item.id;\n              return (\n                <button\n                  key={item.id}\n                  onClick={() => setSelectedSection(item.id)}\n                  className={cn(\n                    \"w-full flex items-center gap-3 px-3 py-2 text-left rounded-md text-sm transition-colors\",\n                    isSelected \n                      ? \"active-bg text-blue-500\" \n                      : \"text-primary hover-item\"\n                  )}\n                >\n                  <Icon className=\"h-4 w-4 flex-shrink-0\" />\n                  <span className=\"truncate\">{item.label}</span>\n                </button>\n              );\n            })}\n          </nav>\n        </div>\n\n        {/* Right Content Pane */}\n        <div className=\"flex-1 overflow-auto bg-panel\">\n          <div className=\"p-8 max-w-4xl\">\n            {renderContent()}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/tabs/flow-tab-content.tsx",
    "content": "import { Flow } from '@/components/Flow';\nimport { useFlowContext } from '@/contexts/flow-context';\nimport { useTabsContext } from '@/contexts/tabs-context';\nimport { setNodeInternalState, setCurrentFlowId as setNodeStateFlowId } from '@/hooks/use-node-state';\nimport { cn } from '@/lib/utils';\nimport { flowService } from '@/services/flow-service';\nimport { Flow as FlowType } from '@/types/flow';\nimport { useEffect } from 'react';\n\n// Import the flow connection manager to check if flow is actively running\n\ninterface FlowTabContentProps {\n  flow: FlowType;\n  className?: string;\n}\n\nexport function FlowTabContent({ flow, className }: FlowTabContentProps) {\n  const { loadFlow } = useFlowContext();\n  const { activeTabId } = useTabsContext();\n\n  // Enhanced load function that restores both use-node-state and node context data\n  const loadFlowWithCompleteState = async (flowToLoad: FlowType) => {\n    try {\n      const flowId = flowToLoad.id.toString();\n      \n      // First, set the flow ID for node state isolation\n      setNodeStateFlowId(flowId);\n      \n      // DO NOT clear configuration state when switching tabs - useNodeState handles flow isolation automatically\n      // DO NOT reset runtime data when switching tabs - preserve all runtime state\n      // Runtime data should only be reset when explicitly starting a new run via the Play button\n      console.log(`[FlowTabContent] Loading flow ${flowId}, preserving all state (configuration + runtime)`);\n\n      // Load the flow using the basic context function (handles React Flow state)\n      await loadFlow(flowToLoad);\n\n      // Then restore internal states for each node (use-node-state data)\n      if (flowToLoad.nodes) {\n        flowToLoad.nodes.forEach((node: any) => {\n          if (node.data?.internal_state) {\n            setNodeInternalState(node.id, node.data.internal_state);\n          }\n        });\n      }\n      \n      // NOTE: We intentionally do NOT restore nodeContextData here\n      // Runtime execution data (messages, analysis, agent status) should start fresh\n      // Only configuration data (tickers, model selections) is restored above\n    } catch (error) {\n      console.error('Failed to load flow with complete state:', error);\n      throw error;\n    }\n  };\n\n  // Fetch the latest flow state when this tab becomes active\n  useEffect(() => {\n    const isThisTabActive = activeTabId === `flow-${flow.id}`;\n    \n    if (isThisTabActive) {\n      const fetchAndLoadFlow = async () => {\n        try {\n          // Fetch the latest flow data from the backend\n          const latestFlow = await flowService.getFlow(flow.id);\n          // Load the fresh flow data with complete state restoration\n          await loadFlowWithCompleteState(latestFlow);\n        } catch (error) {\n          console.error('Failed to fetch latest flow state:', error);\n          // Fallback to loading the cached flow data with complete state restoration\n          await loadFlowWithCompleteState(flow);\n        }\n      };\n\n      fetchAndLoadFlow();\n    }\n  }, [activeTabId, flow.id, flow, loadFlow]);\n\n  return (\n    <div className={cn(\"h-full w-full\", className)}>\n      <Flow />\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/tabs/tab-bar.tsx",
    "content": "import { Button } from '@/components/ui/button';\nimport { useTabsContext } from '@/contexts/tabs-context';\nimport { cn } from '@/lib/utils';\nimport { FileText, Layout, Settings, X } from 'lucide-react';\nimport { ReactNode, useState } from 'react';\n\ninterface TabBarProps {\n  className?: string;\n}\n\n// Get icon for tab type\nconst getTabIcon = (type: string): ReactNode => {\n  switch (type) {\n    case 'flow':\n      return <FileText size={13} />;\n    case 'settings':\n      return <Settings size={13} />;\n    default:\n      return <Layout size={13} />;\n  }\n};\n\nexport function TabBar({ className }: TabBarProps) {\n  const { tabs, activeTabId, setActiveTab, closeTab, reorderTabs } = useTabsContext();\n  const [draggedIndex, setDraggedIndex] = useState<number | null>(null);\n  const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);\n\n  if (tabs.length === 0) {\n    return null;\n  }\n\n  const handleDragStart = (e: React.DragEvent, index: number) => {\n    setDraggedIndex(index);\n    e.dataTransfer.effectAllowed = 'move';\n    e.dataTransfer.setData('text/html', ''); // Required for some browsers\n  };\n\n  const handleDragOver = (e: React.DragEvent, index: number) => {\n    e.preventDefault();\n    e.dataTransfer.dropEffect = 'move';\n    \n    if (draggedIndex !== null && draggedIndex !== index) {\n      setDragOverIndex(index);\n    }\n  };\n\n  const handleDragLeave = () => {\n    setDragOverIndex(null);\n  };\n\n  const handleDrop = (e: React.DragEvent, dropIndex: number) => {\n    e.preventDefault();\n    \n    if (draggedIndex !== null && draggedIndex !== dropIndex) {\n      reorderTabs(draggedIndex, dropIndex);\n    }\n    \n    setDraggedIndex(null);\n    setDragOverIndex(null);\n  };\n\n  const handleDragEnd = () => {\n    setDraggedIndex(null);\n    setDragOverIndex(null);\n  };\n\n  return (\n    <div className={cn(\n      \"flex items-center bg-panel border-b overflow-x-auto\",\n      className\n    )}>\n      <div className=\"flex items-center min-w-0\">\n        {tabs.map((tab, index) => (\n          <div\n            key={tab.id}\n            draggable\n            onDragStart={(e) => handleDragStart(e, index)}\n            onDragOver={(e) => handleDragOver(e, index)}\n            onDragLeave={handleDragLeave}\n            onDrop={(e) => handleDrop(e, index)}\n            onDragEnd={handleDragEnd}\n            className={cn(\n              \"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\",\n              // Active tab styling - VSCode style\n              activeTabId === tab.id \n                ? \"bg-panel before:absolute before:bottom-0 before:left-0 before:right-0 before:h-0.5 before:content-['']\" \n                : \"bg-panel hover:bg-[var(--tab-hover-background)]\",\n              // Drag states\n              draggedIndex === index && \"opacity-60 scale-[0.98]\",\n              dragOverIndex === index && \"ring-1 ring-[var(--tab-accent)]/30\",\n              \"hover:cursor-grab active:cursor-grabbing\"\n            )}\n            style={{\n              borderRight: `1px solid var(--tab-border)`,\n              color: activeTabId === tab.id ? 'var(--tab-active-text)' : 'var(--tab-inactive-text)',\n              backgroundColor: dragOverIndex === index ? 'var(--tab-hover-background)' : undefined,\n            }}\n            onMouseEnter={(e) => {\n              if (activeTabId !== tab.id) {\n                e.currentTarget.style.color = 'var(--tab-hover-text)';\n              }\n            }}\n            onMouseLeave={(e) => {\n              if (activeTabId !== tab.id) {\n                e.currentTarget.style.color = 'var(--tab-inactive-text)';\n              }\n            }}\n            onClick={() => setActiveTab(tab.id)}\n          >\n            {/* Active tab accent bar */}\n            {activeTabId === tab.id && (\n              <div \n                className=\"absolute bottom-0 left-0 right-0 h-0.5\"\n                style={{ backgroundColor: 'var(--tab-accent)' }}\n              />\n            )}\n\n            {/* Tab Icon */}\n            <div className={cn(\n              \"flex-shrink-0 transition-colors duration-150\",\n              activeTabId === tab.id ? \"text-primary\" : \"\"\n            )}\n            style={{\n              color: activeTabId === tab.id ? 'var(--tab-icon-active)' : 'var(--tab-icon-inactive)'\n            }}>\n              {getTabIcon(tab.type)}\n            </div>\n\n            {/* Tab Title */}\n            <span className={cn(\n              \"text-[13px] font-normal truncate min-w-0 transition-colors duration-150\"\n            )}>\n              {tab.title}\n            </span>\n\n            {/* Close Button */}\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              className={cn(\n                \"h-5 w-5 p-0 flex-shrink-0 ml-1 rounded-sm transition-all duration-150\",\n                \"opacity-0 group-hover:opacity-100 focus:opacity-100 focus:outline-none\",\n                activeTabId === tab.id && \"opacity-70 hover:opacity-100\"\n              )}\n              onMouseEnter={(e) => {\n                e.currentTarget.style.backgroundColor = 'var(--tab-close-hover)';\n                e.currentTarget.style.color = 'var(--tab-hover-text)';\n              }}\n              onMouseLeave={(e) => {\n                e.currentTarget.style.backgroundColor = 'transparent';\n                e.currentTarget.style.color = 'inherit';\n              }}\n              onClick={(e) => {\n                e.stopPropagation();\n                closeTab(tab.id);\n              }}\n              onMouseDown={(e) => e.stopPropagation()} // Prevent drag when clicking close button\n              title=\"Close tab\"\n            >\n              <X size={11} className=\"transition-transform duration-150 hover:scale-110\" />\n            </Button>\n\n            {/* Modified indicator dot for unsaved changes - VSCode style */}\n            {/* You can add this when you implement unsaved changes tracking */}\n            {/* <div className=\"absolute top-1/2 left-1 w-1.5 h-1.5 rounded-full transform -translate-y-1/2\" style={{ backgroundColor: 'var(--tab-active-text)' }} /> */}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/tabs/tab-content.tsx",
    "content": "import { useTabsContext } from '@/contexts/tabs-context';\nimport { cn } from '@/lib/utils';\nimport { TabService } from '@/services/tab-service';\nimport { FileText, FolderOpen } from 'lucide-react';\nimport { useEffect } from 'react';\n\ninterface TabContentProps {\n  className?: string;\n}\n\nexport function TabContent({ className }: TabContentProps) {\n  const { tabs, activeTabId, openTab } = useTabsContext();\n\n  const activeTab = tabs.find(tab => tab.id === activeTabId);\n\n  // Restore content for tabs that don't have it (from localStorage restoration)\n  useEffect(() => {\n    if (activeTab && !activeTab.content) {\n      try {\n        const restoredTab = TabService.restoreTab({\n          type: activeTab.type,\n          title: activeTab.title,\n          flow: activeTab.flow,\n          metadata: activeTab.metadata,\n        });\n        \n        // Update the tab with restored content\n        openTab({\n          id: activeTab.id,\n          type: restoredTab.type,\n          title: restoredTab.title,\n          content: restoredTab.content,\n          flow: restoredTab.flow,\n          metadata: restoredTab.metadata,\n        });\n      } catch (error) {\n        console.error('Failed to restore tab content:', error);\n      }\n    }\n  }, [activeTab, openTab]);\n\n  if (!activeTab) {\n    return (\n      <div className={cn(\n        \"h-full w-full flex items-center justify-center bg-background text-muted-foreground\",\n        className\n      )}>\n        <div className=\"text-center space-y-4\">\n          <FolderOpen size={48} className=\"mx-auto text-muted-foreground/50\" />\n          <div>\n            <div className=\"text-xl font-medium mb-2\">Welcome to the AI Hedge Fund</div>\n            <div className=\"text-sm max-w-md\">\n              Create a flow from the left sidebar (⌘B) to open it in a tab, or open settings (⌘,) to configure your preferences.\n            </div>\n          </div>\n          <div className=\"flex items-center justify-center gap-2 text-xs text-muted-foreground/70\">\n            <FileText size={14} />\n            <span>Flows now open in tabs</span>\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  // Show loading state if content is being restored\n  if (!activeTab.content) {\n    return (\n      <div className={cn(\n        \"h-full w-full flex items-center justify-center bg-background text-muted-foreground\",\n        className\n      )}>\n        <div className=\"text-center\">\n          <div className=\"text-lg font-medium mb-2\">Loading {activeTab.title}...</div>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className={cn(\"h-full w-full bg-background overflow-hidden\", className)}>\n      {activeTab.content}\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/components/ui/accordion.tsx",
    "content": "import * as React from \"react\"\nimport * as AccordionPrimitive from \"@radix-ui/react-accordion\"\nimport { ChevronDown } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Accordion = AccordionPrimitive.Root\n\nconst AccordionItem = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <AccordionPrimitive.Item\n    ref={ref}\n    className={cn(\"border-b\", className)}\n    {...props}\n  />\n))\nAccordionItem.displayName = \"AccordionItem\"\n\nconst AccordionTrigger = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Header className=\"flex\">\n    <AccordionPrimitive.Trigger\n      ref={ref}\n      className={cn(\n        \"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronDown className=\"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200\" />\n    </AccordionPrimitive.Trigger>\n  </AccordionPrimitive.Header>\n))\nAccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName\n\nconst AccordionContent = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Content\n    ref={ref}\n    className=\"overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down\"\n    {...props}\n  >\n    <div className={cn(\"pb-4 pt-0\", className)}>{children}</div>\n  </AccordionPrimitive.Content>\n))\nAccordionContent.displayName = AccordionPrimitive.Content.displayName\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent }\n"
  },
  {
    "path": "app/frontend/src/components/ui/badge.tsx",
    "content": "import { cva, type VariantProps } from \"class-variance-authority\"\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst badgeVariants = cva(\n  \"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\",\n  {\n    variants: {\n      variant: {\n        secondary:\n          \"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n        destructive:\n          \"border-transparent bg-red-500 text-destructive-foreground shadow hover:bg-destructive/80\",\n        warning:\n          \"border-transparent bg-yellow-500 text-primary shadow hover:bg-yellow-500/80\",\n        success:\n          \"border-transparent bg-blue-500 text-primary shadow hover:bg-blue-500/80\",\n        outline: \"text-foreground\",\n      },\n    }\n  }\n)\n\nexport interface BadgeProps\n  extends React.HTMLAttributes<HTMLDivElement>,\n    VariantProps<typeof badgeVariants> {}\n\nfunction Badge({ className, variant, ...props }: BadgeProps) {\n  return (\n    <div className={cn(badgeVariants({ variant }), className)} {...props} />\n  )\n}\n\nexport { Badge, badgeVariants }\n"
  },
  {
    "path": "app/frontend/src/components/ui/button.tsx",
    "content": "import { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst buttonVariants = cva(\n  \"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\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"bg-primary text-primary-foreground shadow hover:bg-primary/90\",\n        destructive:\n          \"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90\",\n        outline:\n          \"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground\",\n        secondary:\n          \"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80\",\n        ghost: \"hover:bg-accent hover:text-accent-foreground\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2\",\n        sm: \"h-8 rounded-md px-3 text-xs\",\n        lg: \"h-10 rounded-md px-8\",\n        icon: \"h-9 w-9\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nexport interface ButtonProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  ({ className, variant, size, asChild = false, ...props }, ref) => {\n    const Comp = asChild ? Slot : \"button\"\n    return (\n      <Comp\n        className={cn(buttonVariants({ variant, size, className }))}\n        ref={ref}\n        {...props}\n      />\n    )\n  }\n)\nButton.displayName = \"Button\"\n\nexport { Button, buttonVariants }\n"
  },
  {
    "path": "app/frontend/src/components/ui/card.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Card = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\n      \"rounded-xl border bg-card text-card-foreground shadow\",\n      className\n    )}\n    {...props}\n  />\n))\nCard.displayName = \"Card\"\n\nconst CardHeader = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"flex flex-col space-y-1.5 p-6\", className)}\n    {...props}\n  />\n))\nCardHeader.displayName = \"CardHeader\"\n\nconst CardTitle = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"font-semibold leading-none tracking-tight\", className)}\n    {...props}\n  />\n))\nCardTitle.displayName = \"CardTitle\"\n\nconst CardDescription = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nCardDescription.displayName = \"CardDescription\"\n\nconst CardContent = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn(\"p-6 pt-0\", className)} {...props} />\n))\nCardContent.displayName = \"CardContent\"\n\nconst CardFooter = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"flex items-center p-6 pt-0\", className)}\n    {...props}\n  />\n))\nCardFooter.displayName = \"CardFooter\"\n\nexport { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }\n\n"
  },
  {
    "path": "app/frontend/src/components/ui/checkbox.tsx",
    "content": "import * as React from \"react\"\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\"\nimport { Check } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Checkbox = React.forwardRef<\n  React.ElementRef<typeof CheckboxPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <CheckboxPrimitive.Root\n    ref={ref}\n    className={cn(\n      \"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground\",\n      className\n    )}\n    {...props}\n  >\n    <CheckboxPrimitive.Indicator\n      className={cn(\"flex items-center justify-center text-current\")}\n    >\n      <Check className=\"h-4 w-4\" />\n    </CheckboxPrimitive.Indicator>\n  </CheckboxPrimitive.Root>\n))\nCheckbox.displayName = CheckboxPrimitive.Root.displayName\n\nexport { Checkbox }\n"
  },
  {
    "path": "app/frontend/src/components/ui/command.tsx",
    "content": "import { type DialogProps } from \"@radix-ui/react-dialog\"\nimport { Command as CommandPrimitive } from \"cmdk\"\nimport { Search } from \"lucide-react\"\nimport * as React from \"react\"\n\nimport { Dialog, DialogContent } from \"@/components/ui/dialog\"\nimport { cn } from \"@/lib/utils\"\n\nconst Command = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive\n    ref={ref}\n    className={cn(\n      \"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground\",\n      className\n    )}\n    {...props}\n  />\n))\nCommand.displayName = CommandPrimitive.displayName\n\nconst CommandDialog = ({ children, ...props }: DialogProps) => {\n  return (\n    <Dialog {...props}>\n      <DialogContent className=\"overflow-hidden p-0\">\n        <Command className=\"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n          {children}\n        </Command>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\nconst CommandInput = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Input>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>\n>(({ className, ...props }, ref) => (\n  <div className=\"flex items-center border-b px-3\" cmdk-input-wrapper=\"\">\n    <Search className=\"mr-2 h-4 w-4 shrink-0 opacity-50\" />\n    <CommandPrimitive.Input\n      ref={ref}\n      className={cn(\n        \"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    />\n  </div>\n))\n\nCommandInput.displayName = CommandPrimitive.Input.displayName\n\nconst CommandList = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.List\n    ref={ref}\n    className={cn(\"max-h-[300px] overflow-y-auto overflow-x-hidden\", className)}\n    {...props}\n  />\n))\n\nCommandList.displayName = CommandPrimitive.List.displayName\n\nconst CommandEmpty = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Empty>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>\n>((props, ref) => (\n  <CommandPrimitive.Empty\n    ref={ref}\n    className=\"py-6 text-center text-sm\"\n    {...props}\n  />\n))\n\nCommandEmpty.displayName = CommandPrimitive.Empty.displayName\n\nconst CommandGroup = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Group>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Group\n    ref={ref}\n    className={cn(\n      \"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground\",\n      className\n    )}\n    {...props}\n  />\n))\n\nCommandGroup.displayName = CommandPrimitive.Group.displayName\n\nconst CommandSeparator = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 h-px bg-border\", className)}\n    {...props}\n  />\n))\nCommandSeparator.displayName = CommandPrimitive.Separator.displayName\n\nconst CommandItem = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n      className\n    )}\n    {...props}\n  />\n))\n\nCommandItem.displayName = CommandPrimitive.Item.displayName\n\nconst CommandShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn(\n        \"ml-auto text-xs tracking-widest text-muted-foreground\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\nCommandShortcut.displayName = \"CommandShortcut\"\n\nexport {\n  Command,\n  CommandDialog, CommandEmpty,\n  CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut\n}\n"
  },
  {
    "path": "app/frontend/src/components/ui/dialog.tsx",
    "content": "import * as DialogPrimitive from \"@radix-ui/react-dialog\"\nimport { X } from \"lucide-react\"\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Dialog = DialogPrimitive.Root\n\nconst DialogTrigger = DialogPrimitive.Trigger\n\nconst DialogPortal = DialogPrimitive.Portal\n\nconst DialogClose = DialogPrimitive.Close\n\nconst DialogOverlay = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Overlay\n    ref={ref}\n    className={cn(\n      \"fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n      className\n    )}\n    {...props}\n  />\n))\nDialogOverlay.displayName = DialogPrimitive.Overlay.displayName\n\nconst DialogContent = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <DialogPortal>\n    <DialogOverlay />\n    <DialogPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <DialogPrimitive.Close className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\">\n        <X className=\"h-4 w-4\" />\n        <span className=\"sr-only\">Close</span>\n      </DialogPrimitive.Close>\n    </DialogPrimitive.Content>\n  </DialogPortal>\n))\nDialogContent.displayName = DialogPrimitive.Content.displayName\n\nconst DialogHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col space-y-1.5 text-center sm:text-left\",\n      className\n    )}\n    {...props}\n  />\n)\nDialogHeader.displayName = \"DialogHeader\"\n\nconst DialogFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n      className\n    )}\n    {...props}\n  />\n)\nDialogFooter.displayName = \"DialogFooter\"\n\nconst DialogTitle = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Title\n    ref={ref}\n    className={cn(\n      \"text-lg font-semibold leading-none tracking-tight\",\n      className\n    )}\n    {...props}\n  />\n))\nDialogTitle.displayName = DialogPrimitive.Title.displayName\n\nconst DialogDescription = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Description\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nDialogDescription.displayName = DialogPrimitive.Description.displayName\n\nexport {\n  Dialog, DialogClose,\n  DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger\n}\n"
  },
  {
    "path": "app/frontend/src/components/ui/input.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Input = React.forwardRef<HTMLInputElement, React.ComponentProps<\"input\">>(\n  ({ className, type, ...props }, ref) => {\n    return (\n      <input\n        type={type}\n        className={cn(\n          \"flex h-9 w-full rounded-md border border-border bg-transparent px-3 py-1 text-subtitle shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\",\n          className\n        )}\n        ref={ref}\n        {...props}\n      />\n    )\n  }\n)\nInput.displayName = \"Input\"\n\nexport { Input }\n"
  },
  {
    "path": "app/frontend/src/components/ui/llm-selector.tsx",
    "content": "import { ChevronsUpDown } from \"lucide-react\"\nimport * as React from \"react\"\n\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport { type LanguageModel } from \"@/data/models\"\nimport { cn } from \"@/lib/utils\"\n\ninterface ModelSelectorProps {\n  models: LanguageModel[];\n  value: string;\n  onChange: (item: LanguageModel | null) => void;\n  placeholder?: string;\n}\n\nexport function ModelSelector({ \n  models, \n  value, \n  onChange, \n  placeholder = \"Select a model...\" \n}: ModelSelectorProps) {\n  const [open, setOpen] = React.useState(false)\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"outline\"\n          role=\"combobox\"\n          aria-expanded={open}\n          className=\"w-full justify-between bg-node border border-border\"\n        >\n          <span className=\"text-subtitle\">\n            {value\n              ? models.find((model) => model.model_name === value)?.display_name\n              : placeholder}\n          </span>\n          <ChevronsUpDown className=\"ml-2 h-4 w-4 shrink-0\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-full min-w-[350px] p-0 bg-node border border-border shadow-lg\">\n        <Command className=\"bg-node\">\n          <CommandInput placeholder=\"Search model...\" className=\"h-9 bg-node\" />\n          <CommandList className=\"bg-node\">\n            <CommandEmpty>No model found.</CommandEmpty>\n            <CommandGroup>\n              {models.map((model) => (\n                <CommandItem\n                  key={model.model_name}\n                  value={model.model_name}\n                  className={cn(\n                    \"cursor-pointer bg-node hover:bg-accent\",\n                    value === model.model_name && \"bg-blue-600/10 border-l-2 border-blue-500/50\"\n                  )}\n                  onSelect={(currentValue) => {\n                    if (currentValue === value) {\n                      onChange(null);\n                    } else {\n                      const selectedModel = models.find(m => m.model_name === currentValue);\n                      if (selectedModel) {\n                        onChange(selectedModel);\n                      }\n                    }\n                    setOpen(false);\n                  }}\n                >\n                  <div className=\"flex items-center justify-between w-full\">\n                    <div className=\"flex flex-col items-start min-w-0 flex-1\">\n                      <span className=\"text-title\">{model.display_name}</span>\n                      <span className=\"text-xs text-muted-foreground font-mono\">{model.model_name}</span>\n                    </div>\n                    <Badge className=\"text-xs text-primary bg-primary/10 border-primary/30 hover:bg-primary/20 hover:border-primary/50\">\n                      {model.provider}\n                    </Badge>\n                  </div>\n                </CommandItem>\n              ))}\n            </CommandGroup>\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  )\n} "
  },
  {
    "path": "app/frontend/src/components/ui/popover.tsx",
    "content": "\"use client\"\n\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Popover = PopoverPrimitive.Root\n\nconst PopoverTrigger = PopoverPrimitive.Trigger\n\nconst PopoverContent = React.forwardRef<\n  React.ElementRef<typeof PopoverPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>\n>(({ className, align = \"center\", sideOffset = 4, ...props }, ref) => (\n  <PopoverPrimitive.Portal>\n    <PopoverPrimitive.Content\n      ref={ref}\n      align={align}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className\n      )}\n      {...props}\n    />\n  </PopoverPrimitive.Portal>\n))\nPopoverContent.displayName = PopoverPrimitive.Content.displayName\n\nexport { Popover, PopoverContent, PopoverTrigger }\n"
  },
  {
    "path": "app/frontend/src/components/ui/resizable.tsx",
    "content": "import { GripVertical } from \"lucide-react\"\nimport * as ResizablePrimitive from \"react-resizable-panels\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ResizablePanelGroup = ({\n  className,\n  ...props\n}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (\n  <ResizablePrimitive.PanelGroup\n    className={cn(\n      \"flex h-full w-full data-[panel-group-direction=vertical]:flex-col\",\n      className\n    )}\n    {...props}\n  />\n)\n\nconst ResizablePanel = ResizablePrimitive.Panel\n\nconst ResizableHandle = ({\n  withHandle,\n  className,\n  ...props\n}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {\n  withHandle?: boolean\n}) => (\n  <ResizablePrimitive.PanelResizeHandle\n    className={cn(\n      \"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90\",\n      className\n    )}\n    {...props}\n  >\n    {withHandle && (\n      <div className=\"z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border\">\n        <GripVertical className=\"h-2.5 w-2.5\" />\n      </div>\n    )}\n  </ResizablePrimitive.PanelResizeHandle>\n)\n\nexport { ResizablePanelGroup, ResizablePanel, ResizableHandle }\n"
  },
  {
    "path": "app/frontend/src/components/ui/separator.tsx",
    "content": "import * as React from \"react\"\nimport * as SeparatorPrimitive from \"@radix-ui/react-separator\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Separator = React.forwardRef<\n  React.ElementRef<typeof SeparatorPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>\n>(\n  (\n    { className, orientation = \"horizontal\", decorative = true, ...props },\n    ref\n  ) => (\n    <SeparatorPrimitive.Root\n      ref={ref}\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        \"shrink-0 bg-border\",\n        orientation === \"horizontal\" ? \"h-[1px] w-full\" : \"h-full w-[1px]\",\n        className\n      )}\n      {...props}\n    />\n  )\n)\nSeparator.displayName = SeparatorPrimitive.Root.displayName\n\nexport { Separator }\n"
  },
  {
    "path": "app/frontend/src/components/ui/sheet.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SheetPrimitive from \"@radix-ui/react-dialog\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Sheet = SheetPrimitive.Root\n\nconst SheetTrigger = SheetPrimitive.Trigger\n\nconst SheetClose = SheetPrimitive.Close\n\nconst SheetPortal = SheetPrimitive.Portal\n\nconst SheetOverlay = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <SheetPrimitive.Overlay\n    className={cn(\n      \"fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n      className\n    )}\n    {...props}\n    ref={ref}\n  />\n))\nSheetOverlay.displayName = SheetPrimitive.Overlay.displayName\n\nconst sheetVariants = cva(\n  \"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\",\n  {\n    variants: {\n      side: {\n        top: \"inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top\",\n        bottom:\n          \"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom\",\n        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\",\n        right:\n          \"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\",\n      },\n    },\n    defaultVariants: {\n      side: \"right\",\n    },\n  }\n)\n\ninterface SheetContentProps\n  extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,\n    VariantProps<typeof sheetVariants> {}\n\nconst SheetContent = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Content>,\n  SheetContentProps\n>(({ side = \"right\", className, children, ...props }, ref) => (\n  <SheetPortal>\n    <SheetOverlay />\n    <SheetPrimitive.Content\n      ref={ref}\n      className={cn(sheetVariants({ side }), className)}\n      {...props}\n    >\n      <SheetPrimitive.Close className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary\">\n        <X className=\"h-4 w-4\" />\n        <span className=\"sr-only\">Close</span>\n      </SheetPrimitive.Close>\n      {children}\n    </SheetPrimitive.Content>\n  </SheetPortal>\n))\nSheetContent.displayName = SheetPrimitive.Content.displayName\n\nconst SheetHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col space-y-2 text-center sm:text-left\",\n      className\n    )}\n    {...props}\n  />\n)\nSheetHeader.displayName = \"SheetHeader\"\n\nconst SheetFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n      className\n    )}\n    {...props}\n  />\n)\nSheetFooter.displayName = \"SheetFooter\"\n\nconst SheetTitle = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <SheetPrimitive.Title\n    ref={ref}\n    className={cn(\"text-lg font-semibold text-foreground\", className)}\n    {...props}\n  />\n))\nSheetTitle.displayName = SheetPrimitive.Title.displayName\n\nconst SheetDescription = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <SheetPrimitive.Description\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nSheetDescription.displayName = SheetPrimitive.Description.displayName\n\nexport {\n  Sheet,\n  SheetPortal,\n  SheetOverlay,\n  SheetTrigger,\n  SheetClose,\n  SheetContent,\n  SheetHeader,\n  SheetFooter,\n  SheetTitle,\n  SheetDescription,\n}\n"
  },
  {
    "path": "app/frontend/src/components/ui/sidebar.tsx",
    "content": "import { Slot } from \"@radix-ui/react-slot\"\nimport { VariantProps, cva } from \"class-variance-authority\"\nimport { PanelLeft } from \"lucide-react\"\nimport * as React from \"react\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { Separator } from \"@/components/ui/separator\"\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { useIsMobile } from \"@/hooks/use-mobile\"\nimport { cn } from \"@/lib/utils\"\n\nconst SIDEBAR_COOKIE_NAME = \"sidebar_state\"\nconst SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7\nconst SIDEBAR_WIDTH = \"16rem\"\nconst SIDEBAR_WIDTH_MOBILE = \"18rem\"\nconst SIDEBAR_WIDTH_ICON = \"3rem\"\nconst SIDEBAR_KEYBOARD_SHORTCUT = \"b\"\n\ntype SidebarContextProps = {\n  state: \"expanded\" | \"collapsed\"\n  open: boolean\n  setOpen: (open: boolean) => void\n  openMobile: boolean\n  setOpenMobile: (open: boolean) => void\n  isMobile: boolean\n  toggleSidebar: () => void\n}\n\nconst SidebarContext = React.createContext<SidebarContextProps | null>(null)\n\nfunction useSidebar() {\n  const context = React.useContext(SidebarContext)\n  if (!context) {\n    throw new Error(\"useSidebar must be used within a SidebarProvider.\")\n  }\n\n  return context\n}\n\nconst SidebarProvider = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\"> & {\n    defaultOpen?: boolean\n    open?: boolean\n    onOpenChange?: (open: boolean) => void\n  }\n>(\n  (\n    {\n      defaultOpen = true,\n      open: openProp,\n      onOpenChange: setOpenProp,\n      className,\n      style,\n      children,\n      ...props\n    },\n    ref\n  ) => {\n    const isMobile = useIsMobile()\n    const [openMobile, setOpenMobile] = React.useState(false)\n\n    // This is the internal state of the sidebar.\n    // We use openProp and setOpenProp for control from outside the component.\n    const [_open, _setOpen] = React.useState(defaultOpen)\n    const open = openProp ?? _open\n    const setOpen = React.useCallback(\n      (value: boolean | ((value: boolean) => boolean)) => {\n        const openState = typeof value === \"function\" ? value(open) : value\n        if (setOpenProp) {\n          setOpenProp(openState)\n        } else {\n          _setOpen(openState)\n        }\n\n        // This sets the cookie to keep the sidebar state.\n        document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`\n      },\n      [setOpenProp, open]\n    )\n\n    // Helper to toggle the sidebar.\n    const toggleSidebar = React.useCallback(() => {\n      return isMobile\n        ? setOpenMobile((open) => !open)\n        : setOpen((open) => !open)\n    }, [isMobile, setOpen, setOpenMobile])\n\n    // Adds a keyboard shortcut to toggle the sidebar.\n    React.useEffect(() => {\n      const handleKeyDown = (event: KeyboardEvent) => {\n        if (\n          event.key === SIDEBAR_KEYBOARD_SHORTCUT &&\n          (event.metaKey || event.ctrlKey)\n        ) {\n          event.preventDefault()\n          toggleSidebar()\n        }\n      }\n\n      window.addEventListener(\"keydown\", handleKeyDown)\n      return () => window.removeEventListener(\"keydown\", handleKeyDown)\n    }, [toggleSidebar])\n\n    // We add a state so that we can do data-state=\"expanded\" or \"collapsed\".\n    // This makes it easier to style the sidebar with Tailwind classes.\n    const state = open ? \"expanded\" : \"collapsed\"\n\n    const contextValue = React.useMemo<SidebarContextProps>(\n      () => ({\n        state,\n        open,\n        setOpen,\n        isMobile,\n        openMobile,\n        setOpenMobile,\n        toggleSidebar,\n      }),\n      [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]\n    )\n\n    return (\n      <SidebarContext.Provider value={contextValue}>\n        <TooltipProvider delayDuration={0}>\n          <div\n            style={\n              {\n                \"--sidebar-width\": SIDEBAR_WIDTH,\n                \"--sidebar-width-icon\": SIDEBAR_WIDTH_ICON,\n                ...style,\n              } as React.CSSProperties\n            }\n            className={cn(\n              \"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar\",\n              className\n            )}\n            ref={ref}\n            {...props}\n          >\n            {children}\n          </div>\n        </TooltipProvider>\n      </SidebarContext.Provider>\n    )\n  }\n)\nSidebarProvider.displayName = \"SidebarProvider\"\n\nconst Sidebar = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\"> & {\n    side?: \"left\" | \"right\"\n    variant?: \"sidebar\" | \"floating\" | \"inset\"\n    collapsible?: \"offcanvas\" | \"icon\" | \"none\"\n  }\n>(\n  (\n    {\n      side = \"left\",\n      variant = \"sidebar\",\n      collapsible = \"offcanvas\",\n      className,\n      children,\n      ...props\n    },\n    ref\n  ) => {\n    const { isMobile, state, openMobile, setOpenMobile } = useSidebar()\n\n    if (collapsible === \"none\") {\n      return (\n        <div\n          className={cn(\n            \"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground\",\n            className\n          )}\n          ref={ref}\n          {...props}\n        >\n          {children}\n        </div>\n      )\n    }\n\n    if (isMobile) {\n      return (\n        <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>\n          <SheetContent\n            data-sidebar=\"sidebar\"\n            data-mobile=\"true\"\n            className=\"w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden\"\n            style={\n              {\n                \"--sidebar-width\": SIDEBAR_WIDTH_MOBILE,\n              } as React.CSSProperties\n            }\n            side={side}\n          >\n            <SheetHeader className=\"sr-only\">\n              <SheetTitle>Sidebar</SheetTitle>\n              <SheetDescription>Displays the mobile sidebar.</SheetDescription>\n            </SheetHeader>\n            <div className=\"flex h-full w-full flex-col\">{children}</div>\n          </SheetContent>\n        </Sheet>\n      )\n    }\n\n    return (\n      <div\n        ref={ref}\n        className=\"group peer hidden text-sidebar-foreground md:block\"\n        data-state={state}\n        data-collapsible={state === \"collapsed\" ? collapsible : \"\"}\n        data-variant={variant}\n        data-side={side}\n      >\n        {/* This is what handles the sidebar gap on desktop */}\n        <div\n          className={cn(\n            \"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear\",\n            \"group-data-[collapsible=offcanvas]:w-0\",\n            \"group-data-[side=right]:rotate-180\",\n            variant === \"floating\" || variant === \"inset\"\n              ? \"group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]\"\n              : \"group-data-[collapsible=icon]:w-[--sidebar-width-icon]\"\n          )}\n        />\n        <div\n          className={cn(\n            \"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex\",\n            side === \"left\"\n              ? \"left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]\"\n              : \"right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]\",\n            // Adjust the padding for floating and inset variants.\n            variant === \"floating\" || variant === \"inset\"\n              ? \"p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]\"\n              : \"group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l\",\n            className\n          )}\n          {...props}\n        >\n          <div\n            data-sidebar=\"sidebar\"\n            className=\"flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow\"\n          >\n            {children}\n          </div>\n        </div>\n      </div>\n    )\n  }\n)\nSidebar.displayName = \"Sidebar\"\n\nconst SidebarTrigger = React.forwardRef<\n  React.ElementRef<typeof Button>,\n  React.ComponentProps<typeof Button>\n>(({ className, onClick, ...props }, ref) => {\n  const { toggleSidebar } = useSidebar()\n\n  return (\n    <Button\n      ref={ref}\n      data-sidebar=\"trigger\"\n      variant=\"ghost\"\n      size=\"icon\"\n      className={cn(\"h-7 w-7\", className)}\n      onClick={(event) => {\n        onClick?.(event)\n        toggleSidebar()\n      }}\n      {...props}\n    >\n      <PanelLeft />\n      <span className=\"sr-only\">Toggle Sidebar</span>\n    </Button>\n  )\n})\nSidebarTrigger.displayName = \"SidebarTrigger\"\n\nconst SidebarRail = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<\"button\">\n>(({ className, ...props }, ref) => {\n  const { toggleSidebar } = useSidebar()\n\n  return (\n    <button\n      ref={ref}\n      data-sidebar=\"rail\"\n      aria-label=\"Toggle Sidebar\"\n      tabIndex={-1}\n      onClick={toggleSidebar}\n      title=\"Toggle Sidebar\"\n      className={cn(\n        \"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex\",\n        \"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize\",\n        \"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize\",\n        \"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar\",\n        \"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2\",\n        \"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarRail.displayName = \"SidebarRail\"\n\nconst SidebarInset = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"main\">\n>(({ className, ...props }, ref) => {\n  return (\n    <main\n      ref={ref}\n      className={cn(\n        \"relative flex w-full flex-1 flex-col bg-background\",\n        \"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarInset.displayName = \"SidebarInset\"\n\nconst SidebarInput = React.forwardRef<\n  React.ElementRef<typeof Input>,\n  React.ComponentProps<typeof Input>\n>(({ className, ...props }, ref) => {\n  return (\n    <Input\n      ref={ref}\n      data-sidebar=\"input\"\n      className={cn(\n        \"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarInput.displayName = \"SidebarInput\"\n\nconst SidebarHeader = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"header\"\n      className={cn(\"flex flex-col gap-2 p-2\", className)}\n      {...props}\n    />\n  )\n})\nSidebarHeader.displayName = \"SidebarHeader\"\n\nconst SidebarFooter = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"footer\"\n      className={cn(\"flex flex-col gap-2 p-2\", className)}\n      {...props}\n    />\n  )\n})\nSidebarFooter.displayName = \"SidebarFooter\"\n\nconst SidebarSeparator = React.forwardRef<\n  React.ElementRef<typeof Separator>,\n  React.ComponentProps<typeof Separator>\n>(({ className, ...props }, ref) => {\n  return (\n    <Separator\n      ref={ref}\n      data-sidebar=\"separator\"\n      className={cn(\"mx-2 w-auto bg-sidebar-border\", className)}\n      {...props}\n    />\n  )\n})\nSidebarSeparator.displayName = \"SidebarSeparator\"\n\nconst SidebarContent = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"content\"\n      className={cn(\n        \"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarContent.displayName = \"SidebarContent\"\n\nconst SidebarGroup = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"group\"\n      className={cn(\"relative flex w-full min-w-0 flex-col p-2\", className)}\n      {...props}\n    />\n  )\n})\nSidebarGroup.displayName = \"SidebarGroup\"\n\nconst SidebarGroupLabel = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\"> & { asChild?: boolean }\n>(({ className, asChild = false, ...props }, ref) => {\n  const Comp = asChild ? Slot : \"div\"\n\n  return (\n    <Comp\n      ref={ref}\n      data-sidebar=\"group-label\"\n      className={cn(\n        \"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        \"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarGroupLabel.displayName = \"SidebarGroupLabel\"\n\nconst SidebarGroupAction = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<\"button\"> & { asChild?: boolean }\n>(({ className, asChild = false, ...props }, ref) => {\n  const Comp = asChild ? Slot : \"button\"\n\n  return (\n    <Comp\n      ref={ref}\n      data-sidebar=\"group-action\"\n      className={cn(\n        \"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        // Increases the hit area of the button on mobile.\n        \"after:absolute after:-inset-2 after:md:hidden\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarGroupAction.displayName = \"SidebarGroupAction\"\n\nconst SidebarGroupContent = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    data-sidebar=\"group-content\"\n    className={cn(\"w-full text-sm\", className)}\n    {...props}\n  />\n))\nSidebarGroupContent.displayName = \"SidebarGroupContent\"\n\nconst SidebarMenu = React.forwardRef<\n  HTMLUListElement,\n  React.ComponentProps<\"ul\">\n>(({ className, ...props }, ref) => (\n  <ul\n    ref={ref}\n    data-sidebar=\"menu\"\n    className={cn(\"flex w-full min-w-0 flex-col gap-1\", className)}\n    {...props}\n  />\n))\nSidebarMenu.displayName = \"SidebarMenu\"\n\nconst SidebarMenuItem = React.forwardRef<\n  HTMLLIElement,\n  React.ComponentProps<\"li\">\n>(({ className, ...props }, ref) => (\n  <li\n    ref={ref}\n    data-sidebar=\"menu-item\"\n    className={cn(\"group/menu-item relative\", className)}\n    {...props}\n  />\n))\nSidebarMenuItem.displayName = \"SidebarMenuItem\"\n\nconst sidebarMenuButtonVariants = cva(\n  \"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0\",\n  {\n    variants: {\n      variant: {\n        default: \"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground\",\n        outline:\n          \"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]\",\n      },\n      size: {\n        default: \"h-8 text-sm\",\n        sm: \"h-7 text-xs\",\n        lg: \"h-12 text-sm group-data-[collapsible=icon]:!p-0\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nconst SidebarMenuButton = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<\"button\"> & {\n    asChild?: boolean\n    isActive?: boolean\n    tooltip?: string | React.ComponentProps<typeof TooltipContent>\n  } & VariantProps<typeof sidebarMenuButtonVariants>\n>(\n  (\n    {\n      asChild = false,\n      isActive = false,\n      variant = \"default\",\n      size = \"default\",\n      tooltip,\n      className,\n      ...props\n    },\n    ref\n  ) => {\n    const Comp = asChild ? Slot : \"button\"\n    const { isMobile, state } = useSidebar()\n\n    const button = (\n      <Comp\n        ref={ref}\n        data-sidebar=\"menu-button\"\n        data-size={size}\n        data-active={isActive}\n        className={cn(sidebarMenuButtonVariants({ variant, size }), className)}\n        {...props}\n      />\n    )\n\n    if (!tooltip) {\n      return button\n    }\n\n    if (typeof tooltip === \"string\") {\n      tooltip = {\n        children: tooltip,\n      }\n    }\n\n    return (\n      <Tooltip>\n        <TooltipTrigger asChild>{button}</TooltipTrigger>\n        <TooltipContent\n          side=\"right\"\n          align=\"center\"\n          hidden={state !== \"collapsed\" || isMobile}\n          {...tooltip}\n        />\n      </Tooltip>\n    )\n  }\n)\nSidebarMenuButton.displayName = \"SidebarMenuButton\"\n\nconst SidebarMenuAction = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<\"button\"> & {\n    asChild?: boolean\n    showOnHover?: boolean\n  }\n>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {\n  const Comp = asChild ? Slot : \"button\"\n\n  return (\n    <Comp\n      ref={ref}\n      data-sidebar=\"menu-action\"\n      className={cn(\n        \"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0\",\n        // Increases the hit area of the button on mobile.\n        \"after:absolute after:-inset-2 after:md:hidden\",\n        \"peer-data-[size=sm]/menu-button:top-1\",\n        \"peer-data-[size=default]/menu-button:top-1.5\",\n        \"peer-data-[size=lg]/menu-button:top-2.5\",\n        \"group-data-[collapsible=icon]:hidden\",\n        showOnHover &&\n          \"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarMenuAction.displayName = \"SidebarMenuAction\"\n\nconst SidebarMenuBadge = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    data-sidebar=\"menu-badge\"\n    className={cn(\n      \"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground\",\n      \"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground\",\n      \"peer-data-[size=sm]/menu-button:top-1\",\n      \"peer-data-[size=default]/menu-button:top-1.5\",\n      \"peer-data-[size=lg]/menu-button:top-2.5\",\n      \"group-data-[collapsible=icon]:hidden\",\n      className\n    )}\n    {...props}\n  />\n))\nSidebarMenuBadge.displayName = \"SidebarMenuBadge\"\n\nconst SidebarMenuSkeleton = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\"> & {\n    showIcon?: boolean\n  }\n>(({ className, showIcon = false, ...props }, ref) => {\n  // Random width between 50 to 90%.\n  const width = React.useMemo(() => {\n    return `${Math.floor(Math.random() * 40) + 50}%`\n  }, [])\n\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"menu-skeleton\"\n      className={cn(\"flex h-8 items-center gap-2 rounded-md px-2\", className)}\n      {...props}\n    >\n      {showIcon && (\n        <Skeleton\n          className=\"size-4 rounded-md\"\n          data-sidebar=\"menu-skeleton-icon\"\n        />\n      )}\n      <Skeleton\n        className=\"h-4 max-w-[--skeleton-width] flex-1\"\n        data-sidebar=\"menu-skeleton-text\"\n        style={\n          {\n            \"--skeleton-width\": width,\n          } as React.CSSProperties\n        }\n      />\n    </div>\n  )\n})\nSidebarMenuSkeleton.displayName = \"SidebarMenuSkeleton\"\n\nconst SidebarMenuSub = React.forwardRef<\n  HTMLUListElement,\n  React.ComponentProps<\"ul\">\n>(({ className, ...props }, ref) => (\n  <ul\n    ref={ref}\n    data-sidebar=\"menu-sub\"\n    className={cn(\n      \"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5\",\n      \"group-data-[collapsible=icon]:hidden\",\n      className\n    )}\n    {...props}\n  />\n))\nSidebarMenuSub.displayName = \"SidebarMenuSub\"\n\nconst SidebarMenuSubItem = React.forwardRef<\n  HTMLLIElement,\n  React.ComponentProps<\"li\">\n>(({ ...props }, ref) => <li ref={ref} {...props} />)\nSidebarMenuSubItem.displayName = \"SidebarMenuSubItem\"\n\nconst SidebarMenuSubButton = React.forwardRef<\n  HTMLAnchorElement,\n  React.ComponentProps<\"a\"> & {\n    asChild?: boolean\n    size?: \"sm\" | \"md\"\n    isActive?: boolean\n  }\n>(({ asChild = false, size = \"md\", isActive, className, ...props }, ref) => {\n  const Comp = asChild ? Slot : \"a\"\n\n  return (\n    <Comp\n      ref={ref}\n      data-sidebar=\"menu-sub-button\"\n      data-size={size}\n      data-active={isActive}\n      className={cn(\n        \"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground\",\n        \"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground\",\n        size === \"sm\" && \"text-xs\",\n        size === \"md\" && \"text-sm\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarMenuSubButton.displayName = \"SidebarMenuSubButton\"\n\nexport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarGroupAction,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarHeader,\n  SidebarInput,\n  SidebarInset,\n  SidebarMenu,\n  SidebarMenuAction,\n  SidebarMenuBadge,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarMenuSkeleton,\n  SidebarMenuSub,\n  SidebarMenuSubButton,\n  SidebarMenuSubItem,\n  SidebarProvider,\n  SidebarRail,\n  SidebarSeparator,\n  SidebarTrigger,\n  useSidebar\n}\n\n"
  },
  {
    "path": "app/frontend/src/components/ui/skeleton.tsx",
    "content": "import { cn } from \"@/lib/utils\"\n\nfunction Skeleton({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) {\n  return (\n    <div\n      className={cn(\"animate-pulse rounded-md bg-primary/10\", className)}\n      {...props}\n    />\n  )\n}\n\nexport { Skeleton }\n"
  },
  {
    "path": "app/frontend/src/components/ui/sonner.tsx",
    "content": "import { useTheme } from \"next-themes\"\nimport { Toaster as Sonner } from \"sonner\"\n\ntype ToasterProps = React.ComponentProps<typeof Sonner>\n\nconst Toaster = ({ ...props }: ToasterProps) => {\n  const { theme = \"system\" } = useTheme()\n\n  return (\n    <Sonner\n      theme={theme as ToasterProps[\"theme\"]}\n      className=\"toaster group\"\n      toastOptions={{\n        classNames: {\n          toast:\n            \"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg\",\n          description: \"group-[.toast]:text-muted-foreground\",\n          actionButton:\n            \"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground\",\n          cancelButton:\n            \"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground\",\n          success: \"group-[.toast]:text-blue-500 [&>[data-icon]]:text-blue-500\",\n        },\n      }}\n      {...props}\n    />\n  )\n}\n\nexport { Toaster }\n"
  },
  {
    "path": "app/frontend/src/components/ui/table.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Table = React.forwardRef<\n  HTMLTableElement,\n  React.HTMLAttributes<HTMLTableElement>\n>(({ className, ...props }, ref) => (\n  <div className=\"relative w-full overflow-auto\">\n    <table\n      ref={ref}\n      className={cn(\"w-full caption-bottom text-sm\", className)}\n      {...props}\n    />\n  </div>\n))\nTable.displayName = \"Table\"\n\nconst TableHeader = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <thead ref={ref} className={cn(\"[&_tr]:border-b\", className)} {...props} />\n))\nTableHeader.displayName = \"TableHeader\"\n\nconst TableBody = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tbody\n    ref={ref}\n    className={cn(\"[&_tr:last-child]:border-0\", className)}\n    {...props}\n  />\n))\nTableBody.displayName = \"TableBody\"\n\nconst TableFooter = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tfoot\n    ref={ref}\n    className={cn(\n      \"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0\",\n      className\n    )}\n    {...props}\n  />\n))\nTableFooter.displayName = \"TableFooter\"\n\nconst TableRow = React.forwardRef<\n  HTMLTableRowElement,\n  React.HTMLAttributes<HTMLTableRowElement>\n>(({ className, ...props }, ref) => (\n  <tr\n    ref={ref}\n    className={cn(\n      \"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted\",\n      className\n    )}\n    {...props}\n  />\n))\nTableRow.displayName = \"TableRow\"\n\nconst TableHead = React.forwardRef<\n  HTMLTableCellElement,\n  React.ThHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <th\n    ref={ref}\n    className={cn(\n      \"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\",\n      className\n    )}\n    {...props}\n  />\n))\nTableHead.displayName = \"TableHead\"\n\nconst TableCell = React.forwardRef<\n  HTMLTableCellElement,\n  React.TdHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <td\n    ref={ref}\n    className={cn(\n      \"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\",\n      className\n    )}\n    {...props}\n  />\n))\nTableCell.displayName = \"TableCell\"\n\nconst TableCaption = React.forwardRef<\n  HTMLTableCaptionElement,\n  React.HTMLAttributes<HTMLTableCaptionElement>\n>(({ className, ...props }, ref) => (\n  <caption\n    ref={ref}\n    className={cn(\"mt-4 text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nTableCaption.displayName = \"TableCaption\"\n\nexport {\n  Table,\n  TableHeader,\n  TableBody,\n  TableFooter,\n  TableHead,\n  TableRow,\n  TableCell,\n  TableCaption,\n}\n"
  },
  {
    "path": "app/frontend/src/components/ui/tabs.tsx",
    "content": "import * as TabsPrimitive from \"@radix-ui/react-tabs\"\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Tabs = TabsPrimitive.Root\n\nconst TabsList = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.List\n    ref={ref}\n    className={cn(\n      \"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground\",\n      className\n    )}\n    {...props}\n  />\n))\nTabsList.displayName = TabsPrimitive.List.displayName\n\nconst TabsTrigger = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      \"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow\",\n      className\n    )}\n    {...props}\n  />\n))\nTabsTrigger.displayName = TabsPrimitive.Trigger.displayName\n\nconst TabsContent = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Content\n    ref={ref}\n    className={cn(\n      \"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n      className\n    )}\n    {...props}\n  />\n))\nTabsContent.displayName = TabsPrimitive.Content.displayName\n\nexport { Tabs, TabsContent, TabsList, TabsTrigger }\n"
  },
  {
    "path": "app/frontend/src/components/ui/tooltip.tsx",
    "content": "import * as TooltipPrimitive from \"@radix-ui/react-tooltip\"\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst TooltipProvider = TooltipPrimitive.Provider\n\nconst Tooltip = TooltipPrimitive.Root\n\nconst TooltipTrigger = TooltipPrimitive.Trigger\n\nconst TooltipContent = React.forwardRef<\n  React.ElementRef<typeof TooltipPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n  <TooltipPrimitive.Portal>\n    <TooltipPrimitive.Content\n      ref={ref}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]\",\n        className\n      )}\n      {...props}\n    />\n  </TooltipPrimitive.Portal>\n))\nTooltipContent.displayName = TooltipPrimitive.Content.displayName\n\nexport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }\n"
  },
  {
    "path": "app/frontend/src/contexts/flow-context.tsx",
    "content": "import { getMultiNodeDefinition, isMultiNodeComponent } from '@/data/multi-node-mappings';\nimport { getNodeTypeDefinition } from '@/data/node-mappings';\nimport { flowConnectionManager } from '@/hooks/use-flow-connection';\nimport { clearAllNodeStates, getAllNodeStates, setNodeInternalState, setCurrentFlowId as setNodeStateFlowId } from '@/hooks/use-node-state';\nimport { flowService } from '@/services/flow-service';\nimport { Flow } from '@/types/flow';\nimport { MarkerType, ReactFlowInstance, useReactFlow, XYPosition } from '@xyflow/react';\nimport { createContext, ReactNode, useCallback, useContext, useState } from 'react';\n\ninterface FlowContextType {\n  addComponentToFlow: (componentName: string) => Promise<void>;\n  saveCurrentFlow: (name?: string, description?: string) => Promise<Flow | null>;\n  loadFlow: (flow: Flow) => Promise<void>;\n  createNewFlow: () => Promise<void>;\n  currentFlowId: number | null;\n  currentFlowName: string;\n  isUnsaved: boolean;\n  reactFlowInstance: ReactFlowInstance;\n}\n\nconst FlowContext = createContext<FlowContextType | null>(null);\n\nexport function useFlowContext() {\n  const context = useContext(FlowContext);\n  if (!context) {\n    throw new Error('useFlowContext must be used within a FlowProvider');\n  }\n  return context;\n}\n\ninterface FlowProviderProps {\n  children: ReactNode;\n}\n\nexport function FlowProvider({ children }: FlowProviderProps) {\n  const reactFlowInstance = useReactFlow();\n  const [currentFlowId, setCurrentFlowId] = useState<number | null>(null);\n  const [currentFlowName, setCurrentFlowName] = useState('Untitled Flow');\n  const [isUnsaved, setIsUnsaved] = useState(false);\n\n  // Calculate viewport center position with optional randomness\n  const getViewportPosition = useCallback((addRandomness = false): XYPosition => {\n    let position: XYPosition = { x: 0, y: 0 }; // Default position\n    \n    try {\n      const { zoom, x, y } = reactFlowInstance.getViewport();\n      \n      // Get the React Flow container dimensions instead of window dimensions\n      const flowContainer = document.querySelector('.react-flow__viewport')?.parentElement;\n      const containerWidth = flowContainer?.clientWidth || window.innerWidth;\n      const containerHeight = flowContainer?.clientHeight || window.innerHeight;\n      \n      position = {\n        x: (containerWidth / 2 - x) / zoom,\n        y: (containerHeight / 2 - y) / zoom,\n      };\n    } catch (err) {\n      console.warn('Could not get viewport', err);\n    }\n    \n    if (addRandomness) {\n      position.x += Math.random() * 300;\n      position.y = 0;\n    }\n    \n    return position;\n  }, [reactFlowInstance]);\n\n  // Mark flow as unsaved when changes are made\n  const markAsUnsaved = useCallback(() => {\n    setIsUnsaved(true);\n  }, []);\n\n  // Save current flow\n  const saveCurrentFlow = useCallback(async (name?: string, description?: string): Promise<Flow | null> => {\n    try {\n      const nodes = reactFlowInstance.getNodes();\n      const edges = reactFlowInstance.getEdges();\n      const viewport = reactFlowInstance.getViewport();\n      \n      // Collect all node internal states (from use-node-state)\n      const nodeStates = getAllNodeStates();\n      const nodeInternalStates = Object.fromEntries(nodeStates);\n\n      // Create structured data - nodeContextData will be added by enhanced save functions\n      const data = {\n        nodeStates: nodeInternalStates,  // use-node-state data\n        // nodeContextData will be added separately by enhanced save functions\n      };\n\n      if (currentFlowId) {\n        // Update existing flow\n        const updatedFlow = await flowService.updateFlow(currentFlowId, {\n          name: name || currentFlowName,\n          description,\n          nodes,\n          edges,\n          viewport,\n          data,\n        });\n        setCurrentFlowName(updatedFlow.name);\n        setIsUnsaved(false);\n        // Remember this flow as the last selected\n        localStorage.setItem('lastSelectedFlowId', updatedFlow.id.toString());\n        // Ensure the flow ID is set for node state isolation\n        setNodeStateFlowId(updatedFlow.id.toString());\n        return updatedFlow;\n      } else {\n        // Create new flow\n        const newFlow = await flowService.createFlow({\n          name: name || currentFlowName,\n          description,\n          nodes,\n          edges,\n          viewport,\n          data,\n        });\n        setCurrentFlowId(newFlow.id);\n        setCurrentFlowName(newFlow.name);\n        setIsUnsaved(false);\n        // Remember this flow as the last selected\n        localStorage.setItem('lastSelectedFlowId', newFlow.id.toString());\n        // Set the flow ID for node state isolation\n        setNodeStateFlowId(newFlow.id.toString());\n        return newFlow;\n      }\n    } catch (error) {\n      console.error('Failed to save flow:', error);\n      return null;\n    }\n  }, [reactFlowInstance, currentFlowId, currentFlowName]);\n\n  // Load a flow\n  const loadFlow = useCallback(async (flow: Flow) => {\n    try {\n      // CRITICAL: Set the current flow ID FIRST, before rendering nodes\n      // This ensures useNodeState hooks initialize with the correct flow ID\n      setNodeStateFlowId(flow.id.toString());\n      setCurrentFlowId(flow.id);\n      setCurrentFlowName(flow.name);\n      \n      // DO NOT clear configuration state when loading flows - useNodeState handles flow isolation automatically\n      // Only restore additional internal states if they exist in the flow data\n      if (flow.data) {\n        // Handle backward compatibility - data might be direct nodeStates or structured data\n        const dataToRestore = flow.data.nodeStates || flow.data;\n        \n        if (dataToRestore) {\n          Object.entries(dataToRestore).forEach(([nodeId, nodeState]) => {\n            setNodeInternalState(nodeId, nodeState as Record<string, any>);\n          });\n        }\n        \n        // nodeContextData restoration will be handled by enhanced load functions\n      }\n      \n      // Now render the nodes - useNodeState hooks will initialize with correct flow ID\n      reactFlowInstance.setNodes(flow.nodes || []);\n      reactFlowInstance.setEdges(flow.edges || []);\n      \n      if (flow.viewport) {\n        reactFlowInstance.setViewport(flow.viewport);\n      } else {\n        // Fit view if no viewport data\n        setTimeout(() => {\n          reactFlowInstance.fitView();\n        }, 100);\n      }\n\n      setIsUnsaved(false);\n      \n      // Remember this flow as the last selected\n      localStorage.setItem('lastSelectedFlowId', flow.id.toString());\n\n      // IMPORTANT: Allow components to mount first, then recover connection state\n      // This ensures useFlowConnection hooks are initialized before recovery\n      setTimeout(() => {\n        // Check if this flow has any stale processing states and recover them\n        const connection = flowConnectionManager.getConnection(flow.id.toString());\n        if (connection.state === 'idle') {\n          // No active connection, so any IN_PROGRESS states are stale and should be reset\n          console.log(`Flow ${flow.id} loaded - checking for stale connection states`);\n        }\n      }, 100);\n    } catch (error) {\n      console.error('Failed to load flow:', error);\n    }\n  }, [reactFlowInstance]);\n\n  // Create a new flow\n  const createNewFlow = useCallback(async () => {\n    try {\n      // CRITICAL: Reset flow ID FIRST, before clearing nodes\n      setNodeStateFlowId(null);\n      setCurrentFlowId(null);\n      setCurrentFlowName('Untitled Flow');\n      \n      // Clear all node states for the current flow\n      clearAllNodeStates();\n      \n      // Clear the React Flow canvas\n      reactFlowInstance.setNodes([]);\n      reactFlowInstance.setEdges([]);\n      reactFlowInstance.setViewport({ x: 0, y: 0, zoom: 1 });\n\n      setIsUnsaved(false);\n\n      // Clear any active connections when creating a new flow\n      // Note: We don't have a current flow ID to clear, so this is mainly cleanup\n      console.log('Created new flow - any previous connections should be cleaned up');\n    } catch (error) {\n      console.error('Failed to create new flow:', error);\n    }\n  }, [reactFlowInstance]);\n\n  // Add a single node to the flow\n  const addSingleNodeToFlow = useCallback(async (componentName: string) => {\n    try {\n      const nodeTypeDefinition = await getNodeTypeDefinition(componentName);\n      if (!nodeTypeDefinition) {\n        console.warn(`No node type definition found for component: ${componentName}`);\n        return;\n      }\n\n      const position = getViewportPosition(false);\n      const newNode = nodeTypeDefinition.createNode(position);\n      reactFlowInstance.setNodes((nodes) => [...nodes, newNode]);\n      markAsUnsaved();\n    } catch (error) {\n      console.error(`Failed to add component ${componentName} to flow:`, error);\n    }\n  }, [reactFlowInstance, getViewportPosition, markAsUnsaved]);\n\n  // Add a multi node (group of nodes with edges) to the flow\n  const addMultipleNodesToFlow = useCallback(async (name: string) => {\n    try {\n      const multiNodeDefinition = getMultiNodeDefinition(name);\n      if (!multiNodeDefinition) {\n        console.warn(`No multi node definition found for: ${name}`);\n        return;\n      }\n\n      const basePosition = getViewportPosition();\n\n      // Calculate bounding box of all nodes to center the group\n      const nodePositions = multiNodeDefinition.nodes.map(node => ({\n        x: node.offsetX,\n        y: node.offsetY\n      }));\n      \n      const minX = Math.min(...nodePositions.map(pos => pos.x));\n      const maxX = Math.max(...nodePositions.map(pos => pos.x));\n      const minY = Math.min(...nodePositions.map(pos => pos.y));\n      const maxY = Math.max(...nodePositions.map(pos => pos.y));\n      \n      // Center the group by adjusting base position\n      const groupCenterX = (minX + maxX) / 2;\n      const groupCenterY = (minY + maxY) / 2;\n      \n      const adjustedBasePosition = {\n        x: basePosition.x - groupCenterX,\n        y: basePosition.y - groupCenterY,\n      };\n\n      // Create nodes (async)\n      const newNodes = await Promise.all(\n        multiNodeDefinition.nodes.map(async (nodeConfig) => {\n          try {\n            const nodeTypeDefinition = await getNodeTypeDefinition(nodeConfig.componentName);\n            if (!nodeTypeDefinition) {\n              console.warn(`No node type definition found for: ${nodeConfig.componentName}`);\n              return null;\n            }\n\n            const position = {\n              x: adjustedBasePosition.x + nodeConfig.offsetX,\n              y: adjustedBasePosition.y + nodeConfig.offsetY,\n            };\n\n            return nodeTypeDefinition.createNode(position);\n          } catch (error) {\n            console.error(`Failed to create node for ${nodeConfig.componentName}:`, error);\n            return null;\n          }\n        })\n      );\n      \n      const validNodes = newNodes.filter((node): node is NonNullable<typeof node> => node !== null);\n\n      // Create a mapping from component names to actual node IDs\n      const componentNameToNodeId = new Map<string, string>();\n      multiNodeDefinition.nodes.forEach((nodeConfig, index) => {\n        const correspondingNode = validNodes[index];\n        if (correspondingNode) {\n          componentNameToNodeId.set(nodeConfig.componentName, correspondingNode.id);\n        }\n      });\n\n      // Create edges using the actual node IDs\n      const newEdges = multiNodeDefinition.edges.map((edgeConfig) => {\n        const sourceNodeId = componentNameToNodeId.get(edgeConfig.source);\n        const targetNodeId = componentNameToNodeId.get(edgeConfig.target);\n        \n        if (!sourceNodeId || !targetNodeId) {\n          console.warn(`Could not resolve node IDs for edge: ${edgeConfig.source} -> ${edgeConfig.target}`);\n          return null;\n        }\n        \n        return {\n          id: `${sourceNodeId}-${targetNodeId}`,\n          source: sourceNodeId,\n          target: targetNodeId,\n          markerEnd: {\n            type: MarkerType.ArrowClosed,\n          },\n        };\n      }).filter((edge): edge is NonNullable<typeof edge> => edge !== null);\n\n      // Add nodes and edges to flow\n      reactFlowInstance.setNodes((nodes) => [...nodes, ...validNodes]);\n      reactFlowInstance.setEdges((edges) => [...edges, ...newEdges]);\n      markAsUnsaved();\n      \n      // Fit view to show all nodes after a short delay to ensure nodes are rendered\n      setTimeout(() => {\n        reactFlowInstance.fitView({ padding: 0.1, duration: 500 });\n      }, 100);\n    } catch (error) {\n      console.error(`Failed to add multi-node component ${name} to flow:`, error);\n    }\n  }, [reactFlowInstance, getViewportPosition, markAsUnsaved]);\n\n  // Main entry point - route to single node or multi node\n  const addComponentToFlow = useCallback(async (componentName: string) => {\n    if (isMultiNodeComponent(componentName)) {\n      await addMultipleNodesToFlow(componentName);\n    } else {\n      await addSingleNodeToFlow(componentName);\n    }\n  }, [addMultipleNodesToFlow, addSingleNodeToFlow]);\n\n  const value = {\n    addComponentToFlow,\n    saveCurrentFlow,\n    loadFlow,\n    createNewFlow,\n    currentFlowId,\n    currentFlowName,\n    isUnsaved,\n    reactFlowInstance,\n  };\n\n  return (\n    <FlowContext.Provider value={value}>\n      {children}\n    </FlowContext.Provider>\n  );\n} "
  },
  {
    "path": "app/frontend/src/contexts/layout-context.tsx",
    "content": "import { SidebarStorageService } from '@/services/sidebar-storage';\nimport { createContext, ReactNode, useContext, useEffect, useState } from 'react';\n\ninterface LayoutContextType {\n  isBottomCollapsed: boolean;\n  expandBottomPanel: () => void;\n  collapseBottomPanel: () => void;\n  toggleBottomPanel: () => void;\n  setBottomPanelTab: (tab: string) => void;\n  currentBottomTab: string;\n}\n\nconst LayoutContext = createContext<LayoutContextType | null>(null);\n\nexport function useLayoutContext() {\n  const context = useContext(LayoutContext);\n  if (!context) {\n    throw new Error('useLayoutContext must be used within a LayoutProvider');\n  }\n  return context;\n}\n\ninterface LayoutProviderProps {\n  children: ReactNode;\n}\n\nexport function LayoutProvider({ children }: LayoutProviderProps) {\n  const [isBottomCollapsed, setIsBottomCollapsed] = useState(() => \n    SidebarStorageService.loadBottomPanelState(true)\n  );\n  const [currentBottomTab, setCurrentBottomTab] = useState('output');\n\n  // Save bottom panel state when it changes\n  useEffect(() => {\n    SidebarStorageService.saveBottomPanelState(isBottomCollapsed);\n  }, [isBottomCollapsed]);\n\n  const expandBottomPanel = () => {\n    setIsBottomCollapsed(false);\n  };\n\n  const collapseBottomPanel = () => {\n    setIsBottomCollapsed(true);\n  };\n\n  const toggleBottomPanel = () => {\n    setIsBottomCollapsed(!isBottomCollapsed);\n  };\n\n  const setBottomPanelTab = (tab: string) => {\n    setCurrentBottomTab(tab);\n  };\n\n  const value = {\n    isBottomCollapsed,\n    expandBottomPanel,\n    collapseBottomPanel,\n    toggleBottomPanel,\n    setBottomPanelTab,\n    currentBottomTab,\n  };\n\n  return (\n    <LayoutContext.Provider value={value}>\n      {children}\n    </LayoutContext.Provider>\n  );\n} "
  },
  {
    "path": "app/frontend/src/contexts/node-context.tsx",
    "content": "import { LanguageModel } from '@/data/models';\nimport { createContext, ReactNode, useCallback, useContext, useState } from 'react';\n\nexport type NodeStatus = 'IDLE' | 'IN_PROGRESS' | 'COMPLETE' | 'ERROR';\n\n// Message history item\nexport interface MessageItem {\n  timestamp: string;\n  message: string;\n  ticker: string | null;\n  analysis: Record<string, string>;\n}\n\n// Agent node state structure\nexport interface AgentNodeData {\n  status: NodeStatus;\n  ticker: string | null;\n  message: string;\n  lastUpdated: number;\n  messages: MessageItem[];\n  timestamp?: string;\n  analysis: string | null;\n  backtestResults?: any[];\n}\n\n// Data structure for the output node data (from complete event)\nexport interface OutputNodeData {\n  decisions: Record<string, any>;\n  analyst_signals: Record<string, any>;\n  // Backtest-specific fields\n  performance_metrics?: {\n    sharpe_ratio?: number;\n    sortino_ratio?: number;\n    max_drawdown?: number;\n    max_drawdown_date?: string;\n    long_short_ratio?: number;\n    gross_exposure?: number;\n    net_exposure?: number;\n  };\n  final_portfolio?: {\n    cash: number;\n    margin_used: number;\n    positions: Record<string, any>;\n  };\n  total_days?: number;\n}\n\n// Default agent node state\nconst DEFAULT_AGENT_NODE_STATE: AgentNodeData = {\n  status: 'IDLE',\n  ticker: null,\n  message: '',\n  messages: [],\n  lastUpdated: Date.now(),\n  analysis: null,\n};\n\n// Helper function to create flow-aware composite keys\nfunction createCompositeKey(flowId: string | null, nodeId: string): string {\n  return flowId ? `${flowId}:${nodeId}` : nodeId;\n}\n\ninterface NodeContextType {\n  agentNodeData: Record<string, AgentNodeData>;\n  outputNodeData: OutputNodeData | null;\n  agentModels: Record<string, LanguageModel | null>;\n  updateAgentNode: (flowId: string | null, nodeId: string, data: Partial<AgentNodeData> | NodeStatus) => void;\n  updateAgentNodes: (flowId: string | null, nodeIds: string[], status: NodeStatus) => void;\n  setOutputNodeData: (flowId: string | null, data: OutputNodeData) => void;\n  setAgentModel: (flowId: string | null, nodeId: string, model: LanguageModel | null) => void;\n  getAgentModel: (flowId: string | null, nodeId: string) => LanguageModel | null;\n  getAllAgentModels: (flowId: string | null) => Record<string, LanguageModel | null>;\n  resetAllNodes: (flowId: string | null) => void;\n  resetNodeStatuses: (flowId: string | null) => void;\n  exportNodeContextData: (flowId: string | null) => {\n    agentNodeData: Record<string, AgentNodeData>;\n    outputNodeData: OutputNodeData | null;\n  };\n  importNodeContextData: (flowId: string | null, data: {\n    agentNodeData?: Record<string, AgentNodeData>;\n    outputNodeData?: OutputNodeData | null;\n  }) => void;\n  // New flow-aware functions\n  getAgentNodeDataForFlow: (flowId: string | null) => Record<string, AgentNodeData>;\n  getOutputNodeDataForFlow: (flowId: string | null) => OutputNodeData | null;\n}\n\nconst NodeContext = createContext<NodeContextType | undefined>(undefined);\n\nexport function NodeProvider({ children }: { children: ReactNode }) {\n  // Use composite keys for flow-aware agent node data storage\n  const [agentNodeData, setAgentNodeData] = useState<Record<string, AgentNodeData>>({});\n  // Flow-aware output node data storage\n  const [outputNodeData, setOutputNodeData] = useState<Record<string, OutputNodeData>>({});\n  // Agent models also need to be flow-aware to maintain model selections per flow\n  const [agentModels, setAgentModels] = useState<Record<string, LanguageModel | null>>({});\n\n  const updateAgentNode = useCallback((flowId: string | null, nodeId: string, data: Partial<AgentNodeData> | NodeStatus) => {\n    const compositeKey = createCompositeKey(flowId, nodeId);\n    \n    // Handle string status shorthand (just passing a status string)\n    if (typeof data === 'string') {\n      setAgentNodeData(prev => {\n        const existingNode = prev[compositeKey] || { ...DEFAULT_AGENT_NODE_STATE };\n        return {\n          ...prev,\n          [compositeKey]: {\n            ...existingNode,\n            status: data,\n            lastUpdated: Date.now()\n          }\n        };\n      });\n      return;\n    }\n\n    // Handle data object - full update\n    setAgentNodeData(prev => {\n      const existingNode = prev[compositeKey] || { ...DEFAULT_AGENT_NODE_STATE };\n      \n      const newMessages = [...existingNode.messages];\n      \n      // Add message to history if it's new - use more robust checking\n      if (data.message && data.timestamp) {\n        // Check if this exact message already exists (prevent duplicates)\n        const messageExists = newMessages.some(msg => \n          msg.timestamp === data.timestamp && \n          msg.message === data.message &&\n          msg.ticker === data.ticker\n        );\n        \n        if (!messageExists) {\n          const ticker = data.ticker || null;\n\n          const messageItem: MessageItem = {\n            timestamp: data.timestamp,\n            message: data.message,\n            ticker: ticker,\n            analysis: {} as Record<string, string>,\n          }\n\n          // Add analysis for ticker to messageItem if ticker is not null\n          if (ticker && data.analysis) {\n            messageItem.analysis[ticker] = data.analysis;\n          }\n\n          newMessages.push(messageItem);\n        }\n      }\n      \n      const updatedNode = {\n        ...existingNode,\n        ...data,\n        messages: newMessages,\n        lastUpdated: Date.now()\n      };\n      \n      return {\n        ...prev,\n        [compositeKey]: updatedNode\n      };\n    });\n  }, []);\n\n  const updateAgentNodes = useCallback((flowId: string | null, nodeIds: string[], status: NodeStatus) => {\n    if (nodeIds.length === 0) return;\n    \n    setAgentNodeData(prev => {\n      const newStates = { ...prev };\n      \n      nodeIds.forEach(id => {\n        const compositeKey = createCompositeKey(flowId, id);\n        newStates[compositeKey] = {\n          ...(newStates[compositeKey] || { ...DEFAULT_AGENT_NODE_STATE }),\n          status,\n          lastUpdated: Date.now()\n        };\n      });\n      \n      return newStates;\n    });\n  }, []);\n\n  const setAgentModel = useCallback((flowId: string | null, nodeId: string, model: LanguageModel | null) => {\n    const compositeKey = createCompositeKey(flowId, nodeId);\n    \n    setAgentModels(prev => {\n      if (model === null) {\n        // Remove the agent model if setting to null\n        const { [compositeKey]: removed, ...rest } = prev;\n        return rest;\n      } else {\n        // Set the agent model\n        return {\n          ...prev,\n          [compositeKey]: model\n        };\n      }\n    });\n  }, []);\n\n  const getAgentModel = useCallback((flowId: string | null, nodeId: string): LanguageModel | null => {\n    const compositeKey = createCompositeKey(flowId, nodeId);\n    return agentModels[compositeKey] || null;\n  }, [agentModels]);\n\n  const getAllAgentModels = useCallback((flowId: string | null): Record<string, LanguageModel | null> => {\n    // Return only models for the specified flow\n    if (!flowId) {\n      // If no flow ID, return models without flow prefix (backward compatibility)\n      return Object.fromEntries(\n        Object.entries(agentModels).filter(([key]) => !key.includes(':'))\n      );\n    }\n    \n    const flowPrefix = `${flowId}:`;\n    const currentFlowModels: Record<string, LanguageModel | null> = {};\n    \n    Object.entries(agentModels).forEach(([compositeKey, model]) => {\n      if (compositeKey.startsWith(flowPrefix)) {\n        const nodeId = compositeKey.substring(flowPrefix.length);\n        currentFlowModels[nodeId] = model;\n      }\n    });\n    \n    return currentFlowModels;\n  }, [agentModels]);\n\n  const setOutputNodeDataForFlow = useCallback((flowId: string | null, data: OutputNodeData) => {\n    if (!flowId) {\n      // If no flow ID, use 'default' as key for backward compatibility\n      setOutputNodeData(prev => ({ ...prev, 'default': data }));\n    } else {\n      setOutputNodeData(prev => ({ ...prev, [flowId]: data }));\n    }\n  }, []);\n\n  const resetAllNodes = useCallback((flowId: string | null) => {\n    // Clear all agent data for specified flow only\n    if (!flowId) {\n      // If no flow ID, clear all data (backward compatibility)\n      setAgentNodeData({});\n      setOutputNodeData({});\n    } else {\n      // Clear only data for specified flow\n      const flowPrefix = `${flowId}:`;\n      setAgentNodeData(prev => {\n        const newData: Record<string, AgentNodeData> = {};\n        Object.entries(prev).forEach(([key, value]) => {\n          if (!key.startsWith(flowPrefix)) {\n            newData[key] = value;\n          }\n        });\n        return newData;\n      });\n      \n      // Clear output data for specified flow\n      setOutputNodeData(prev => {\n        const { [flowId]: removed, ...rest } = prev;\n        return rest;\n      });\n    }\n    \n    // Note: We don't reset agentModels here as users would want to keep their model selections\n  }, []);\n\n  const resetNodeStatuses = useCallback((flowId: string | null) => {\n    // Reset only node statuses to IDLE, preserving all data (messages, backtestResults, etc.)\n    if (!flowId) {\n      // If no flow ID, reset all node statuses (backward compatibility)\n      setAgentNodeData(prev => {\n        const newData: Record<string, AgentNodeData> = {};\n        Object.entries(prev).forEach(([key, value]) => {\n          newData[key] = {\n            ...value,\n            status: 'IDLE',\n            lastUpdated: Date.now(),\n          };\n        });\n        return newData;\n      });\n    } else {\n      // Reset only statuses for specified flow\n      const flowPrefix = `${flowId}:`;\n      setAgentNodeData(prev => {\n        const newData: Record<string, AgentNodeData> = {};\n        Object.entries(prev).forEach(([key, value]) => {\n          if (key.startsWith(flowPrefix)) {\n            // Reset status for this flow's nodes\n            newData[key] = {\n              ...value,\n              status: 'IDLE',\n              lastUpdated: Date.now(),\n            };\n          } else {\n            // Keep other flows' data unchanged\n            newData[key] = value;\n          }\n        });\n        return newData;\n      });\n    }\n    \n    // Note: We don't touch output data or agent models - only reset processing statuses\n  }, []);\n\n  // Export node context data for persistence\n  const exportNodeContextData = useCallback((flowId: string | null) => {\n    // Export agent data for specified flow\n    const currentFlowAgentData: Record<string, AgentNodeData> = {};\n    const flowPrefix = flowId ? `${flowId}:` : '';\n    \n    Object.entries(agentNodeData).forEach(([compositeKey, data]) => {\n      if (flowId) {\n        if (compositeKey.startsWith(flowPrefix)) {\n          const nodeId = compositeKey.substring(flowPrefix.length);\n          currentFlowAgentData[nodeId] = data;\n        }\n      } else {\n        // If no flow ID, export data without flow prefix (backward compatibility)\n        if (!compositeKey.includes(':')) {\n          currentFlowAgentData[compositeKey] = data;\n        }\n      }\n    });\n\n    // Export output data for specified flow\n    const currentFlowOutputData = flowId \n      ? outputNodeData[flowId] || null\n      : outputNodeData['default'] || null;\n\n    return {\n      agentNodeData: currentFlowAgentData,\n      outputNodeData: currentFlowOutputData,\n    };\n  }, [agentNodeData, outputNodeData]);\n\n  // Import node context data from persistence\n  const importNodeContextData = useCallback((flowId: string | null, data: {\n    agentNodeData?: Record<string, AgentNodeData>;\n    outputNodeData?: OutputNodeData | null;\n  }) => {\n    // Import agent data\n    if (data.agentNodeData) {\n      Object.entries(data.agentNodeData).forEach(([nodeId, nodeData]) => {\n        const compositeKey = createCompositeKey(flowId, nodeId);\n        setAgentNodeData(prev => ({\n          ...prev,\n          [compositeKey]: nodeData,\n        }));\n      });\n    }\n\n    // Import output data\n    if (data.outputNodeData) {\n      if (flowId) {\n        setOutputNodeData(prev => ({\n          ...prev,\n          [flowId]: data.outputNodeData!,\n        }));\n      } else {\n        setOutputNodeData(prev => ({\n          ...prev,\n          'default': data.outputNodeData!,\n        }));\n      }\n    }\n  }, []);\n\n  // Helper functions to get data for a specific flow\n  const getAgentNodeDataForFlow = useCallback((flowId: string | null): Record<string, AgentNodeData> => {\n    if (!flowId) {\n      // If no flow ID, return data without flow prefix (backward compatibility)\n      return Object.fromEntries(\n        Object.entries(agentNodeData).filter(([key]) => !key.includes(':'))\n      );\n    }\n    \n    const flowPrefix = `${flowId}:`;\n    const currentFlowData: Record<string, AgentNodeData> = {};\n    \n    Object.entries(agentNodeData).forEach(([compositeKey, data]) => {\n      if (compositeKey.startsWith(flowPrefix)) {\n        const nodeId = compositeKey.substring(flowPrefix.length);\n        currentFlowData[nodeId] = data;\n      }\n    });\n    \n    return currentFlowData;\n  }, [agentNodeData]);\n\n  const getOutputNodeDataForFlow = useCallback((flowId: string | null): OutputNodeData | null => {\n    if (!flowId) {\n      // If no flow ID, return 'default' data for backward compatibility\n      return outputNodeData['default'] || null;\n    }\n    \n    return outputNodeData[flowId] || null;\n  }, [outputNodeData]);\n\n  // Context value object\n  const contextValue = {\n    // Legacy getters for backward compatibility - these will return empty data\n    // Components should use the explicit flow-based functions instead\n    agentNodeData: {},\n    outputNodeData: null,\n    agentModels,\n    updateAgentNode,\n    updateAgentNodes,\n    setOutputNodeData: setOutputNodeDataForFlow,\n    setAgentModel,\n    getAgentModel,\n    getAllAgentModels,\n    resetAllNodes,\n    resetNodeStatuses,\n    exportNodeContextData,\n    importNodeContextData,\n    // New flow-aware functions\n    getAgentNodeDataForFlow,\n    getOutputNodeDataForFlow,\n  };\n\n  return (\n    <NodeContext.Provider value={contextValue}>\n      {children}\n    </NodeContext.Provider>\n  );\n}\n\nexport function useNodeContext() {\n  const context = useContext(NodeContext);\n  \n  if (context === undefined) {\n    throw new Error('useNodeContext must be used within a NodeProvider');\n  }\n  \n  return context;\n} "
  },
  {
    "path": "app/frontend/src/contexts/tabs-context.tsx",
    "content": "import { Flow } from '@/types/flow';\nimport { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';\n\n// Define tab types\nexport type TabType = 'flow' | 'settings';\n\nexport interface Tab {\n  id: string;\n  type: TabType;\n  title: string;\n  content: ReactNode;\n  // For flow tabs\n  flow?: Flow;\n  // For other tabs (settings, etc.)\n  metadata?: Record<string, any>;\n}\n\n// Serializable version of Tab for localStorage (without content)\ninterface SerializableTab {\n  id: string;\n  type: TabType;\n  title: string;\n  flow?: Flow;\n  metadata?: Record<string, any>;\n}\n\ninterface TabsContextType {\n  tabs: Tab[];\n  activeTabId: string | null;\n  openTab: (tab: Omit<Tab, 'id'> & { id?: string }) => void;\n  closeTab: (tabId: string) => void;\n  setActiveTab: (tabId: string) => void;\n  closeAllTabs: () => void;\n  isTabOpen: (identifier: string, type: TabType) => boolean;\n  getTabByIdentifier: (identifier: string, type: TabType) => Tab | undefined;\n  reorderTabs: (fromIndex: number, toIndex: number) => void;\n  updateTabTitle: (tabId: string, newTitle: string) => void;\n  updateFlowTabTitle: (flowId: number, newTitle: string) => void;\n}\n\nconst TabsContext = createContext<TabsContextType | null>(null);\n\nexport function useTabsContext() {\n  const context = useContext(TabsContext);\n  if (!context) {\n    throw new Error('useTabsContext must be used within a TabsProvider');\n  }\n  return context;\n}\n\ninterface TabsProviderProps {\n  children: ReactNode;\n}\n\n// localStorage keys\nconst TABS_STORAGE_KEY = 'ai-hedge-fund-tabs';\nconst ACTIVE_TAB_STORAGE_KEY = 'ai-hedge-fund-active-tab';\n\nexport function TabsProvider({ children }: TabsProviderProps) {\n  const [tabs, setTabs] = useState<Tab[]>([]);\n  const [activeTabId, setActiveTabId] = useState<string | null>(null);\n  const [isInitialized, setIsInitialized] = useState(false);\n\n  // Generate unique tab ID\n  const generateTabId = useCallback((type: TabType, identifier?: string): string => {\n    if (type === 'flow' && identifier) {\n      return `flow-${identifier}`;\n    }\n    if (type === 'settings') {\n      return 'settings';\n    }\n    return `${type}-${Date.now()}`;\n  }, []);\n\n  // Save tabs to localStorage\n  const saveTabsToStorage = useCallback((tabsToSave: Tab[], activeId: string | null) => {\n    try {\n      // Convert tabs to serializable format (without content)\n      const serializableTabs: SerializableTab[] = tabsToSave.map(tab => ({\n        id: tab.id,\n        type: tab.type,\n        title: tab.title,\n        flow: tab.flow,\n        metadata: tab.metadata,\n      }));\n      \n      localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(serializableTabs));\n      localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, activeId || '');\n    } catch (error) {\n      console.error('Failed to save tabs to localStorage:', error);\n    }\n  }, []);\n\n  // Load tabs from localStorage\n  const loadTabsFromStorage = useCallback((): { tabs: SerializableTab[], activeTabId: string | null } => {\n    try {\n      const savedTabs = localStorage.getItem(TABS_STORAGE_KEY);\n      const savedActiveTabId = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY);\n      \n      if (savedTabs) {\n        const parsedTabs: SerializableTab[] = JSON.parse(savedTabs);\n        return {\n          tabs: parsedTabs,\n          activeTabId: savedActiveTabId || null,\n        };\n      }\n    } catch (error) {\n      console.error('Failed to load tabs from localStorage:', error);\n    }\n    \n    return { tabs: [], activeTabId: null };\n  }, []);\n\n  // Initialize tabs from localStorage on mount\n  useEffect(() => {\n    if (!isInitialized) {\n      const { tabs: savedTabs, activeTabId: savedActiveTabId } = loadTabsFromStorage();\n      \n      if (savedTabs.length > 0) {\n        // We'll restore the content later when the tab service is available\n        // For now, just set up the tab structure\n        const restoredTabs: Tab[] = savedTabs.map(savedTab => ({\n          ...savedTab,\n          content: null, // Will be filled in by TabService when tabs are accessed\n        }));\n        \n        setTabs(restoredTabs);\n        setActiveTabId(savedActiveTabId);\n      }\n      \n      setIsInitialized(true);\n    }\n  }, [isInitialized, loadTabsFromStorage]);\n\n  // Save tabs to localStorage whenever they change\n  useEffect(() => {\n    if (isInitialized) {\n      saveTabsToStorage(tabs, activeTabId);\n    }\n  }, [tabs, activeTabId, isInitialized, saveTabsToStorage]);\n\n  // Check if a tab is already open\n  const isTabOpen = useCallback((identifier: string, type: TabType): boolean => {\n    const tabId = generateTabId(type, identifier);\n    return tabs.some(tab => tab.id === tabId);\n  }, [tabs, generateTabId]);\n\n  // Get tab by identifier\n  const getTabByIdentifier = useCallback((identifier: string, type: TabType): Tab | undefined => {\n    const tabId = generateTabId(type, identifier);\n    return tabs.find(tab => tab.id === tabId);\n  }, [tabs, generateTabId]);\n\n  // Open a new tab or focus existing one\n  const openTab = useCallback((tabData: Omit<Tab, 'id'> & { id?: string }) => {\n    const tabId = tabData.id || generateTabId(tabData.type, \n      tabData.type === 'flow' && tabData.flow ? tabData.flow.id.toString() : undefined\n    );\n\n    setTabs(prevTabs => {\n      // Check if tab already exists\n      const existingTabIndex = prevTabs.findIndex(tab => tab.id === tabId);\n      \n      if (existingTabIndex !== -1) {\n        // Tab exists, just update it and focus\n        const updatedTabs = [...prevTabs];\n        updatedTabs[existingTabIndex] = { ...tabData, id: tabId };\n        setActiveTabId(tabId);\n        return updatedTabs;\n      } else {\n        // Create new tab\n        const newTab: Tab = { ...tabData, id: tabId };\n        setActiveTabId(tabId);\n        return [...prevTabs, newTab];\n      }\n    });\n  }, [generateTabId]);\n\n  // Close a tab\n  const closeTab = useCallback((tabId: string) => {\n    setTabs(prevTabs => {\n      const newTabs = prevTabs.filter(tab => tab.id !== tabId);\n      \n      // If closing active tab, set new active tab\n      if (activeTabId === tabId) {\n        if (newTabs.length > 0) {\n          // Find the index of the closed tab\n          const closedIndex = prevTabs.findIndex(tab => tab.id === tabId);\n          // Try to activate the tab to the right, or the last tab if closing the last one\n          const newActiveIndex = closedIndex < newTabs.length ? closedIndex : newTabs.length - 1;\n          setActiveTabId(newTabs[newActiveIndex]?.id || null);\n        } else {\n          setActiveTabId(null);\n        }\n      }\n      \n      return newTabs;\n    });\n  }, [activeTabId]);\n\n  // Set active tab\n  const setActiveTab = useCallback((tabId: string) => {\n    if (tabs.some(tab => tab.id === tabId)) {\n      setActiveTabId(tabId);\n    }\n  }, [tabs]);\n\n  // Close all tabs\n  const closeAllTabs = useCallback(() => {\n    setTabs([]);\n    setActiveTabId(null);\n  }, []);\n\n  // Reorder tabs\n  const reorderTabs = useCallback((fromIndex: number, toIndex: number) => {\n    setTabs(prevTabs => {\n      const newTabs = [...prevTabs];\n      const [movedTab] = newTabs.splice(fromIndex, 1);\n      newTabs.splice(toIndex, 0, movedTab);\n      return newTabs;\n    });\n  }, []);\n\n  // Update tab title\n  const updateTabTitle = useCallback((tabId: string, newTitle: string) => {\n    setTabs(prevTabs => {\n      const updatedTabs = prevTabs.map(tab =>\n        tab.id === tabId ? { ...tab, title: newTitle } : tab\n      );\n      return updatedTabs;\n    });\n  }, []);\n\n  // Update flow tab title\n  const updateFlowTabTitle = useCallback((flowId: number, newTitle: string) => {\n    setTabs(prevTabs => {\n      const updatedTabs = prevTabs.map(tab => {\n        if (tab.type === 'flow' && tab.flow?.id === flowId) {\n          return { \n            ...tab, \n            title: newTitle,\n            // Also update the flow object's name to keep it in sync\n            flow: tab.flow ? { ...tab.flow, name: newTitle } : tab.flow\n          };\n        }\n        return tab;\n      });\n      return updatedTabs;\n    });\n  }, []);\n\n  const value = {\n    tabs,\n    activeTabId,\n    openTab,\n    closeTab,\n    setActiveTab,\n    closeAllTabs,\n    isTabOpen,\n    getTabByIdentifier,\n    reorderTabs,\n    updateTabTitle,\n    updateFlowTabTitle,\n  };\n\n  return (\n    <TabsContext.Provider value={value}>\n      {children}\n    </TabsContext.Provider>\n  );\n} "
  },
  {
    "path": "app/frontend/src/data/agents.ts",
    "content": "import { api } from '@/services/api';\n\nexport interface Agent {\n  key: string;\n  display_name: string;\n  description: string;\n  investing_style: string;\n  order: number;\n}\n\n// In-memory cache for agents to avoid repeated API calls\nlet agents: Agent[] | null = null;\n\n/**\n * Get the list of agents from the backend API\n * Uses caching to avoid repeated API calls\n */\nexport const getAgents = async (): Promise<Agent[]> => {\n  if (agents) {\n    return agents;\n  }\n  \n  try {\n    agents = await api.getAgents();\n    return agents;\n  } catch (error) {\n    console.error('Failed to fetch agents:', error);\n    throw error; // Let the calling component handle the error\n  }\n};\n"
  },
  {
    "path": "app/frontend/src/data/models.ts",
    "content": "import { api } from '@/services/api';\n\nexport interface LanguageModel {\n  display_name: string;\n  model_name: string;\n  provider: \"Anthropic\" | \"DeepSeek\" | \"Google\" | \"Groq\" | \"OpenAI\";\n}\n\n// Cache for models to avoid repeated API calls\nlet languageModels: LanguageModel[] | null = null;\n\n/**\n * Get the list of models from the backend API\n * Uses caching to avoid repeated API calls\n */\nexport const getModels = async (): Promise<LanguageModel[]> => {\n  if (languageModels) {\n    return languageModels;\n  }\n  \n  try {\n    languageModels = await api.getLanguageModels();\n    return languageModels;\n  } catch (error) {\n    console.error('Failed to fetch models:', error);\n    throw error; // Let the calling component handle the error\n  }\n};\n\n/**\n * Get the default model (GPT-4.1) from the models list\n */\nexport const getDefaultModel = async (): Promise<LanguageModel | null> => {\n  try {\n    const models = await getModels();\n    return models.find(model => model.model_name === \"gpt-4.1\") || models[0] || null;\n  } catch (error) {\n    console.error('Failed to get default model:', error);\n    return null;\n  }\n};\n"
  },
  {
    "path": "app/frontend/src/data/multi-node-mappings.ts",
    "content": "export interface MultiNodeDefinition {\n  name: string;\n  nodes: {\n    componentName: string;\n    offsetX: number;\n    offsetY: number;\n  }[];\n  edges: {\n    source: string;\n    target: string;\n  }[];\n}\n\nconst multiNodeDefinition: Record<string, MultiNodeDefinition> = {\n  \"Value Investors\": {\n    name: \"Value Investors\",\n    nodes: [\n      { componentName: \"Stock Input\", offsetX: 0, offsetY: 0 },\n      { componentName: \"Ben Graham\", offsetX: 400, offsetY: -400 },\n      { componentName: \"Charlie Munger\", offsetX: 400, offsetY: 0 },\n      { componentName: \"Warren Buffett\", offsetX: 400, offsetY: 400 },\n      { componentName: \"Portfolio Manager\", offsetX: 800, offsetY: 0 },\n    ],\n    edges: [\n      { source: \"Stock Input\", target: \"Ben Graham\" },\n      { source: \"Stock Input\", target: \"Charlie Munger\" },\n      { source: \"Stock Input\", target: \"Warren Buffett\" },\n      { source: \"Ben Graham\", target: \"Portfolio Manager\" },\n      { source: \"Charlie Munger\", target: \"Portfolio Manager\" },\n      { source: \"Warren Buffett\", target: \"Portfolio Manager\" },\n    ],\n  },\n  \"Data Wizards\": {\n    name: \"Data Wizards\",\n    nodes: [\n      { componentName: \"Stock Input\", offsetX: 0, offsetY: 0 },\n      { componentName: \"Technical Analyst\", offsetX: 400, offsetY: -550 },\n      { componentName: \"Fundamentals Analyst\", offsetX: 400, offsetY: -200 },\n      { componentName: \"Sentiment Analyst\", offsetX: 400, offsetY: 150 },\n      { componentName: \"Valuation Analyst\", offsetX: 400, offsetY: 500 },\n      { componentName: \"Portfolio Manager\", offsetX: 800, offsetY: 0 },\n    ],\n    edges: [\n      { source: \"Stock Input\", target: \"Technical Analyst\" },\n      { source: \"Stock Input\", target: \"Fundamentals Analyst\" },\n      { source: \"Stock Input\", target: \"Sentiment Analyst\" },\n      { source: \"Stock Input\", target: \"Valuation Analyst\" },\n      { source: \"Technical Analyst\", target: \"Portfolio Manager\" },\n      { source: \"Fundamentals Analyst\", target: \"Portfolio Manager\" },\n      { source: \"Sentiment Analyst\", target: \"Portfolio Manager\" },\n      { source: \"Valuation Analyst\", target: \"Portfolio Manager\" },\n\n    ],\n  },\n  \"Market Mavericks\": {\n    name: \"Market Mavericks\",\n    nodes: [\n      { componentName: \"Stock Input\", offsetX: 0, offsetY: 0 },\n      { componentName: \"Michael Burry\", offsetX: 400, offsetY: -400 },\n      { componentName: \"Bill Ackman\", offsetX: 400, offsetY: 0 },\n      { componentName: \"Stanley Druckenmiller\", offsetX: 400, offsetY: 400 },\n      { componentName: \"Portfolio Manager\", offsetX: 800, offsetY: 0 },\n    ],\n    edges: [\n      { source: \"Stock Input\", target: \"Michael Burry\" },\n      { source: \"Stock Input\", target: \"Bill Ackman\" },\n      { source: \"Stock Input\", target: \"Stanley Druckenmiller\" },\n      { source: \"Michael Burry\", target: \"Portfolio Manager\" },\n      { source: \"Bill Ackman\", target: \"Portfolio Manager\" },\n      { source: \"Stanley Druckenmiller\", target: \"Portfolio Manager\" },\n    ],\n  },\n};\n\nexport function getMultiNodeDefinition(name: string): MultiNodeDefinition | null {\n  return multiNodeDefinition[name] || null;\n}\n\nexport function isMultiNodeComponent(componentName: string): boolean {\n  return componentName in multiNodeDefinition;\n} "
  },
  {
    "path": "app/frontend/src/data/node-mappings.ts",
    "content": "import { AppNode } from \"@/nodes/types\";\nimport { Agent, getAgents } from \"./agents\";\n\n// Map of sidebar item names to node creation functions\nexport interface NodeTypeDefinition {\n  createNode: (position: { x: number, y: number }) => AppNode;\n}\n\n// Cache for node type definitions to avoid repeated API calls\nlet nodeTypeDefinitionsCache: Record<string, NodeTypeDefinition> | null = null;\n\n// Utility function to generate unique short ID suffix\nconst generateUniqueIdSuffix = (): string => {\n  // Generate a short random ID (6 characters)\n  const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';\n  let result = '';\n  for (let i = 0; i < 6; i++) {\n    result += chars.charAt(Math.floor(Math.random() * chars.length));\n  }\n  return result;\n};\n\n/**\n * Extract the base agent key from a unique node ID\n * @param uniqueId The unique node ID with suffix (e.g., \"warren_buffett_abc123\")\n * @returns The base agent key (e.g., \"warren_buffett\")\n */\nexport const extractBaseAgentKey = (uniqueId: string): string => {\n  // For agent nodes, remove the last underscore and 6-character suffix\n  // For other nodes like portfolio_manager, also remove the suffix\n  const parts = uniqueId.split('_');\n  if (parts.length >= 2) {\n    const lastPart = parts[parts.length - 1];\n    // If the last part is a 6-character alphanumeric string, it's likely our suffix\n    if (lastPart.length === 6 && /^[a-z0-9]+$/.test(lastPart)) {\n      return parts.slice(0, -1).join('_');\n    }\n  }\n  return uniqueId; // Return original if no suffix pattern found\n};\n\n// Define base node creation functions (non-agent nodes)\nconst baseNodeTypeDefinitions: Record<string, NodeTypeDefinition> = {\n  \"Portfolio Input\": {\n    createNode: (position: { x: number, y: number }): AppNode => ({\n      id: `portfolio-start-node_${generateUniqueIdSuffix()}`,\n      type: \"portfolio-start-node\",\n      position,\n      data: {\n        name: \"Portfolio Input\",\n        description: \"Enter your portfolio including tickers, shares, and prices. Connect this node to Analysts to generate insights.\",\n        status: \"Idle\",\n      },\n    }),\n  },\n  \"Portfolio Manager\": {\n    createNode: (position: { x: number, y: number }): AppNode => ({\n      id: `portfolio_manager_${generateUniqueIdSuffix()}`,\n      type: \"portfolio-manager-node\",\n      position,\n      data: {\n        name: \"Portfolio Manager\",\n        description: \"Generates investment decisions based on input from Analysts.\",\n        status: \"Idle\",\n      },\n    }),\n  },\n  \"Stock Input\": {\n    createNode: (position: { x: number, y: number }): AppNode => ({\n      id: `stock-analyzer-node_${generateUniqueIdSuffix()}`,\n      type: \"stock-analyzer-node\",\n      position,\n      data: {\n        name: \"Stock Input\",\n        description: \"Enter individual stocks and connect this node to Analysts to generate insights.\",\n        status: \"Idle\",\n      },\n    }),\n  },\n};\n\n/**\n * Get all node type definitions, including agents fetched from the backend\n */\nconst getNodeTypeDefinitions = async (): Promise<Record<string, NodeTypeDefinition>> => {\n  if (nodeTypeDefinitionsCache) {\n    return nodeTypeDefinitionsCache;\n  }\n\n  const agents = await getAgents();\n  \n  // Create agent node definitions\n  const agentNodeDefinitions = agents.reduce((acc: Record<string, NodeTypeDefinition>, agent: Agent) => {\n    acc[agent.display_name] = {\n      createNode: (position: { x: number, y: number }): AppNode => ({\n        id: `${agent.key}_${generateUniqueIdSuffix()}`,\n        type: \"agent-node\",\n        position,\n        data: {\n          name: agent.display_name,\n          description: agent.investing_style || \"\",\n          status: \"Idle\",\n        },\n      }),\n    };\n    return acc;\n  }, {});\n\n  // Combine base and agent definitions\n  nodeTypeDefinitionsCache = {\n    ...baseNodeTypeDefinitions,\n    ...agentNodeDefinitions,\n  };\n\n  return nodeTypeDefinitionsCache;\n};\n\nexport async function getNodeTypeDefinition(componentName: string): Promise<NodeTypeDefinition | null> {\n  const nodeTypeDefinitions = await getNodeTypeDefinitions();\n  return nodeTypeDefinitions[componentName] || null;\n}\n\n// Get the node ID that would be generated for a component\nexport async function getNodeIdForComponent(componentName: string): Promise<string | null> {\n  const nodeTypeDefinition = await getNodeTypeDefinition(componentName);\n  if (!nodeTypeDefinition) {\n    return null;\n  }\n  \n  // Extract ID by creating a temporary node (position doesn't matter for ID extraction)\n  const tempNode = nodeTypeDefinition.createNode({ x: 0, y: 0 });\n  return tempNode.id;\n}\n\n/**\n * Clear the node type definitions cache - useful for testing or when you want to force a refresh\n */\nexport const clearNodeTypeDefinitionsCache = () => {\n  nodeTypeDefinitionsCache = null;\n}; "
  },
  {
    "path": "app/frontend/src/data/sidebar-components.ts",
    "content": "import {\n  BadgeDollarSign,\n  Bot,\n  Brain,\n  Calculator,\n  ChartLine,\n  ChartPie,\n  LucideIcon,\n  Network,\n  Play,\n  Zap\n} from 'lucide-react';\nimport { Agent, getAgents } from './agents';\n\n// Define component items by group\nexport interface ComponentItem {\n  name: string;\n  icon: LucideIcon;\n}\n\nexport interface ComponentGroup {\n  name: string;\n  icon: LucideIcon;\n  iconColor: string;\n  items: ComponentItem[];\n}\n\n/**\n * Get all component groups, including agents fetched from the backend\n */\nexport const getComponentGroups = async (): Promise<ComponentGroup[]> => {\n  const agents = await getAgents();\n  \n  return [\n    {\n      name: \"Start Nodes\",\n      icon: Play,\n      iconColor: \"text-blue-500\",\n      items: [\n        { name: \"Portfolio Input\", icon: ChartPie },\n        { name: \"Stock Input\", icon: ChartLine },\n      ]\n    },\n    {\n      name: \"Analysts\",\n      icon: Bot,\n      iconColor: \"text-red-500\",\n      items: agents.map((agent: Agent) => ({\n        name: agent.display_name,\n        icon: Bot\n      }))\n    },\n    {\n      name: \"Swarms\",\n      icon: Network,\n      iconColor: \"text-yellow-500\",\n      items: [\n        { name: \"Data Wizards\", icon: Calculator },\n        { name: \"Market Mavericks\", icon: Zap },\n        { name: \"Value Investors\", icon: BadgeDollarSign },\n      ]\n    },\n    {\n      name: \"End Nodes\",\n      icon: Brain,\n      iconColor: \"text-green-500\",\n      items: [\n        { name: \"Portfolio Manager\", icon: Brain },\n        // { name: \"JSON Output\", icon: FileJson },\n        // { name: \"Investment Report\", icon: FileText },\n      ]\n    },\n  ];\n};"
  },
  {
    "path": "app/frontend/src/edges/index.ts",
    "content": "import type { EdgeTypes } from '@xyflow/react';\n\n\nexport const edgeTypes = {\n  // Add your custom edge types here!\n} satisfies EdgeTypes;\n"
  },
  {
    "path": "app/frontend/src/hooks/use-component-groups.ts",
    "content": "import { ComponentGroup } from '@/data/sidebar-components';\nimport { useEffect, useMemo, useState } from 'react';\n\nexport function useComponentGroups(componentGroups: ComponentGroup[]) {\n  const [searchQuery, setSearchQuery] = useState('');\n  const [activeItem, setActiveItem] = useState<string | null>('Chat Input');\n  const [openGroups, setOpenGroups] = useState<string[]>([]); // Start with all groups collapsed\n  const [isSearching, setIsSearching] = useState(false);\n\n  // Filter groups and items based on search query\n  const filteredGroups = useMemo(() => {\n    if (!searchQuery) return componentGroups;\n\n    return componentGroups.map(group => {\n      // Filter items within the group\n      const filteredItems = group.items.filter(item => \n        item.name.toLowerCase().includes(searchQuery.toLowerCase())\n      );\n\n      // Return group with filtered items\n      return {\n        ...group,\n        items: filteredItems\n      };\n    }).filter(group => group.items.length > 0); // Only include groups with matching items\n  }, [componentGroups, searchQuery]);\n\n  // Handle search query changes\n  useEffect(() => {\n    if (searchQuery) {\n      setIsSearching(true);\n      // Open all groups that have matching items\n      setOpenGroups(filteredGroups.map(group => group.name));\n    } else if (isSearching) {\n      // Only reset groups when exiting search mode\n      setIsSearching(false);\n    }\n  }, [searchQuery, filteredGroups]);\n\n  // Handle accordion value changes\n  const handleAccordionChange = (value: string[]) => {\n    // Only update if we're not actively searching\n    if (!searchQuery) {\n      setOpenGroups(value);\n    } else {\n      // During search, we need to preserve expanded groups that have matches\n      const matchingGroups = filteredGroups.map(group => group.name);\n      // Keep all matching groups open while allowing manual toggling of others\n      const newValue = value.filter(group => matchingGroups.includes(group));\n      if (newValue.length < matchingGroups.length) {\n        // If user is closing a search result group, allow that\n        setOpenGroups(newValue);\n      } else {\n        // User is opening a new group during search\n        setOpenGroups(value);\n      }\n    }\n  };\n\n  return {\n    searchQuery,\n    setSearchQuery,\n    activeItem,\n    setActiveItem,\n    openGroups,\n    setOpenGroups,\n    isSearching,\n    filteredGroups,\n    handleAccordionChange\n  };\n} "
  },
  {
    "path": "app/frontend/src/hooks/use-enhanced-flow-actions.ts",
    "content": "import { useFlowContext } from '@/contexts/flow-context';\nimport { useNodeContext } from '@/contexts/node-context';\nimport {\n    getNodeInternalState,\n    setNodeInternalState,\n    setCurrentFlowId as setNodeStateFlowId\n} from '@/hooks/use-node-state';\nimport { flowService } from '@/services/flow-service';\nimport { Flow } from '@/types/flow';\nimport { useCallback } from 'react';\n\n/**\n * Enhanced flow actions that include complete state persistence\n * (both use-node-state data and node context data)\n */\nexport function useEnhancedFlowActions() {\n  const { saveCurrentFlow, loadFlow, reactFlowInstance, currentFlowId } = useFlowContext();\n  const { exportNodeContextData } = useNodeContext();\n\n  // Enhanced save that includes node context data\n  const saveCurrentFlowWithCompleteState = useCallback(async (name?: string, description?: string): Promise<Flow | null> => {\n    try {\n      // Get current nodes from React Flow\n      const currentNodes = reactFlowInstance.getNodes();\n      \n      // Get node context data (runtime data: agent status, messages, output data)\n      const flowId = currentFlowId?.toString() || null;\n      const nodeContextData = exportNodeContextData(flowId);\n      \n      // Enhance nodes with internal states\n      const nodesWithStates = currentNodes.map((node: any) => {\n        const internalState = getNodeInternalState(node.id);\n        return {\n          ...node,\n          data: {\n            ...node.data,\n            // Only add internal_state if there is actually state to save\n            ...(internalState && Object.keys(internalState).length > 0 ? { internal_state: internalState } : {})\n          }\n        };\n      });\n\n      // Temporarily replace nodes in React Flow with enhanced nodes\n      reactFlowInstance.setNodes(nodesWithStates);\n      \n      try {\n        // Use the basic save function\n        const savedFlow = await saveCurrentFlow(name, description);\n        \n        if (savedFlow) {\n          // After basic save, update with node context data\n          const updatedFlow = await flowService.updateFlow(savedFlow.id, {\n            ...savedFlow,\n            data: {\n              ...savedFlow.data,\n              nodeContextData, // Add runtime data from node context\n            }\n          });\n          \n          return updatedFlow;\n        }\n        \n        return savedFlow;\n      } finally {\n        // Restore original nodes (without internal_state in React Flow)\n        reactFlowInstance.setNodes(currentNodes);\n      }\n    } catch (err) {\n      console.error('Failed to save flow with complete state:', err);\n      return null;\n    }\n  }, [reactFlowInstance, saveCurrentFlow, exportNodeContextData, currentFlowId]);\n\n  // Enhanced load that restores node context data\n  const loadFlowWithCompleteState = useCallback(async (flow: Flow) => {\n    try {\n      // First, set the flow ID for node state isolation\n      setNodeStateFlowId(flow.id.toString());\n      \n      // DO NOT clear configuration state when loading flows - useNodeState handles flow isolation automatically\n      // DO NOT reset runtime data when loading flows - preserve all runtime state\n      // Runtime data should only be reset when explicitly starting a new run via the Play button\n      console.log(`[EnhancedFlowActions] Loading flow ${flow.id} (${flow.name}), preserving all state (configuration + runtime)`);\n\n      // Load the flow using the basic function (handles React Flow state)\n      await loadFlow(flow);\n\n      // Then restore internal states for each node (use-node-state data)\n      if (flow.nodes) {\n        flow.nodes.forEach((node: any) => {\n          if (node.data?.internal_state) {\n            setNodeInternalState(node.id, node.data.internal_state);\n          }\n        });\n      }\n      \n      // NOTE: We intentionally do NOT restore nodeContextData here\n      // Runtime execution data (messages, analysis, agent status) should start fresh\n      // Only configuration data (tickers, model selections) is restored above\n\n      console.log('Flow loaded with complete state restoration:', flow.name);\n    } catch (error) {\n      console.error('Failed to load flow with complete state:', error);\n      throw error;\n    }\n  }, [loadFlow]);\n\n  return {\n    saveCurrentFlowWithCompleteState,\n    loadFlowWithCompleteState,\n  };\n} "
  },
  {
    "path": "app/frontend/src/hooks/use-flow-connection.ts",
    "content": "import { useNodeContext } from '@/contexts/node-context';\nimport { api } from '@/services/api';\nimport { backtestApi } from '@/services/backtest-api';\nimport { BacktestRequest, HedgeFundRequest } from '@/services/types';\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\n// Connection state for a specific flow\nexport type FlowConnectionState = 'idle' | 'connecting' | 'connected' | 'error' | 'completed';\n\ninterface FlowConnectionInfo {\n  state: FlowConnectionState;\n  abortController: (() => void) | null;\n  startTime: number;\n  lastActivity: number;\n  error?: string;\n}\n\n// Global connection manager - tracks all active flow connections\nclass FlowConnectionManager {\n  private connections = new Map<string, FlowConnectionInfo>();\n  private listeners = new Set<() => void>();\n\n  // Get connection info for a flow\n  getConnection(flowId: string): FlowConnectionInfo {\n    return this.connections.get(flowId) || {\n      state: 'idle',\n      abortController: null,\n      startTime: 0,\n      lastActivity: 0,\n    };\n  }\n\n  // Set connection info for a flow\n  setConnection(flowId: string, info: Partial<FlowConnectionInfo>): void {\n    const existing = this.getConnection(flowId);\n    const updated = {\n      ...existing,\n      ...info,\n      lastActivity: Date.now(),\n    };\n    \n    this.connections.set(flowId, updated);\n    this.notifyListeners();\n  }\n\n  // Remove connection for a flow\n  removeConnection(flowId: string): void {\n    const connection = this.connections.get(flowId);\n    if (connection?.abortController) {\n      connection.abortController();\n    }\n    this.connections.delete(flowId);\n    this.notifyListeners();\n  }\n\n  // Add listener for connection changes\n  addListener(listener: () => void): void {\n    this.listeners.add(listener);\n  }\n\n  // Remove listener\n  removeListener(listener: () => void): void {\n    this.listeners.delete(listener);\n  }\n\n  // Notify all listeners of changes\n  private notifyListeners(): void {\n    this.listeners.forEach(listener => listener());\n  }\n}\n\n// Global instance\nexport const flowConnectionManager = new FlowConnectionManager();\n\n/**\n * Hook for managing flow connections and execution\n * @param flowId The ID of the flow to manage\n * @returns Connection state and control functions\n */\nexport function useFlowConnection(flowId: string | null) {\n  const nodeContext = useNodeContext();\n  const [, forceUpdate] = useState({});\n  const listenerRef = useRef<() => void>();\n\n  // Force re-render when connections change\n  useEffect(() => {\n    const listener = () => forceUpdate({});\n    listenerRef.current = listener;\n    flowConnectionManager.addListener(listener);\n    \n    return () => {\n      if (listenerRef.current) {\n        flowConnectionManager.removeListener(listenerRef.current);\n      }\n    };\n  }, []);\n\n  // Get current connection state\n  const connection = flowId ? flowConnectionManager.getConnection(flowId) : null;\n  const isConnecting = connection?.state === 'connecting';\n  const isConnected = connection?.state === 'connected';\n  const isError = connection?.state === 'error';\n  const isCompleted = connection?.state === 'completed';\n  \n  // Check if any agents are currently processing\n  const isProcessing = flowId ? (() => {\n    const agentData = nodeContext.getAgentNodeDataForFlow(flowId);\n    return Object.values(agentData).some(agent => agent.status === 'IN_PROGRESS');\n  })() : false;\n  \n  // Can run if we have a flow ID and we're not already running\n  const canRun = Boolean(flowId && !isConnecting && !isConnected && !isProcessing);\n\n  // Start a flow connection\n  const runFlow = useCallback((params: HedgeFundRequest) => {\n    if (!flowId || !canRun) return;\n\n    // Reset node states for this flow\n    nodeContext.resetAllNodes(flowId);\n\n    // Set connecting state\n    flowConnectionManager.setConnection(flowId, {\n      state: 'connecting',\n      startTime: Date.now(),\n    });\n\n    try {\n      // Start the API call\n      const abortController = api.runHedgeFund(params, nodeContext, flowId);\n\n      // Update connection with abort controller\n      flowConnectionManager.setConnection(flowId, {\n        state: 'connected',\n        abortController,\n      });\n\n      // TODO: We should enhance the API to notify us when the connection completes\n      // For now, we'll rely on the complete event from the SSE stream\n      \n    } catch (error) {\n      console.error('Failed to start hedge fund run:', error);\n      flowConnectionManager.setConnection(flowId, {\n        state: 'error',\n        error: error instanceof Error ? error.message : 'Unknown error',\n        abortController: null,\n      });\n    }\n  }, [flowId, canRun, nodeContext]);\n\n  // Start a backtest connection\n  const runBacktest = useCallback((params: BacktestRequest) => {\n    if (!flowId || !canRun) return;\n\n    // Reset node states for this flow\n    nodeContext.resetAllNodes(flowId);\n\n    // Set connecting state\n    flowConnectionManager.setConnection(flowId, {\n      state: 'connecting',\n      startTime: Date.now(),\n    });\n\n    try {\n      // Start the backtest API call\n      const abortController = backtestApi.runBacktest(params, nodeContext, flowId);\n\n      // Update connection with abort controller\n      flowConnectionManager.setConnection(flowId, {\n        state: 'connected',\n        abortController,\n      });\n\n      // TODO: We should enhance the API to notify us when the connection completes\n      // For now, we'll rely on the complete event from the SSE stream\n      \n    } catch (error) {\n      console.error('Failed to start backtest:', error);\n      flowConnectionManager.setConnection(flowId, {\n        state: 'error',\n        error: error instanceof Error ? error.message : 'Unknown error',\n        abortController: null,\n      });\n    }\n  }, [flowId, canRun, nodeContext]);\n\n  // Stop a flow connection\n  const stopFlow = useCallback(() => {\n    if (!flowId) return;\n\n    console.log(`[stopFlow] Stopping flow ${flowId}`);\n    const connection = flowConnectionManager.getConnection(flowId);\n    console.log(`[stopFlow] Current connection state:`, connection);\n    \n    if (connection.abortController) {\n      console.log(`[stopFlow] Calling abort controller for flow ${flowId}`);\n      connection.abortController();\n    } else {\n      console.log(`[stopFlow] No abort controller found for flow ${flowId}`);\n    }\n\n    // Reset only node statuses when stopping, preserving all data (backtest results, messages, etc.)\n    nodeContext.resetNodeStatuses(flowId);\n\n    // Update connection state\n    flowConnectionManager.setConnection(flowId, {\n      state: 'idle',\n      abortController: null,\n    });\n    \n    console.log(`[stopFlow] Flow ${flowId} stopped and reset to idle`);\n  }, [flowId, nodeContext]);\n\n  // Recover from stale states (called when loading a flow)\n  const recoverFlowState = useCallback(() => {\n    if (!flowId) return;\n\n    const connection = flowConnectionManager.getConnection(flowId);\n    \n    // If we think we're connected but have no processing nodes, we're probably stale\n    if ((connection.state === 'connected' || connection.state === 'connecting') && !isProcessing) {\n      // Check if the connection is old (more than 5 minutes)\n      const isStale = Date.now() - connection.lastActivity > 5 * 60 * 1000;\n      \n      if (isStale) {\n        console.log(`Recovering stale connection for flow ${flowId}`);\n        flowConnectionManager.setConnection(flowId, {\n          state: 'idle',\n          abortController: null,\n        });\n      }\n    }\n  }, [flowId, isProcessing]);\n\n  return {\n    // State\n    isConnecting,\n    isConnected,\n    isError,\n    isCompleted,\n    isProcessing,\n    canRun,\n    error: connection?.error,\n    \n    // Actions\n    runFlow,\n    runBacktest,\n    stopFlow,\n    recoverFlowState,\n  };\n}\n\n// Utility hook to get connection state for any flow (for monitoring)\nexport function useFlowConnectionState(flowId: string | null) {\n  const [, forceUpdate] = useState({});\n\n  useEffect(() => {\n    if (!flowId) return;\n\n    const unsubscribe = flowConnectionManager.addListener(() => {\n      forceUpdate({});\n    });\n\n    return unsubscribe;\n  }, [flowId]);\n\n  return flowId ? flowConnectionManager.getConnection(flowId) : null;\n}\n"
  },
  {
    "path": "app/frontend/src/hooks/use-flow-history.ts",
    "content": "import { Edge, Node, useReactFlow } from '@xyflow/react';\nimport { useCallback, useRef, useState } from 'react';\n\ninterface FlowSnapshot {\n  nodes: Node[];\n  edges: Edge[];\n  timestamp: number;\n}\n\ninterface UseFlowHistoryOptions {\n  maxHistorySize?: number;\n  flowId?: number | null;\n}\n\nexport function useFlowHistory({ maxHistorySize = 50, flowId }: UseFlowHistoryOptions = {}) {\n  const { getNodes, getEdges, setNodes, setEdges } = useReactFlow();\n  const [historyIndexes, setHistoryIndexes] = useState<Record<string, number>>({});\n  const histories = useRef<Record<string, FlowSnapshot[]>>({});\n  const isUndoRedoAction = useRef(false);\n\n  // Get flow-specific history key\n  const getFlowKey = useCallback((id: number | null) => {\n    return id ? `flow-${id}` : 'new-flow';\n  }, []);\n\n  // Get current flow's history and index\n  const getCurrentFlowHistory = useCallback(() => {\n    const flowKey = getFlowKey(flowId ?? null);\n    if (!histories.current[flowKey]) {\n      histories.current[flowKey] = [];\n    }\n    return histories.current[flowKey];\n  }, [flowId, getFlowKey]);\n\n  const getCurrentHistoryIndex = useCallback(() => {\n    const flowKey = getFlowKey(flowId ?? null);\n    return historyIndexes[flowKey] ?? -1;\n  }, [flowId, getFlowKey, historyIndexes]);\n\n  const setCurrentHistoryIndex = useCallback((index: number) => {\n    const flowKey = getFlowKey(flowId ?? null);\n    setHistoryIndexes(prev => ({ ...prev, [flowKey]: index }));\n  }, [flowId, getFlowKey]);\n\n  // Create a snapshot of current state (excluding UI-only properties)\n  const createSnapshot = useCallback((): FlowSnapshot => {\n    // Strip UI-only properties from nodes (like selection state)\n    const cleanNodes = getNodes().map(node => {\n      const { selected, ...cleanNode } = node;\n      return cleanNode;\n    });\n\n    // Create clean copies\n    return {\n      nodes: JSON.parse(JSON.stringify(cleanNodes)),\n      edges: JSON.parse(JSON.stringify(getEdges())),\n      timestamp: Date.now(),\n    };\n  }, [getNodes, getEdges]);\n\n  // Check if two snapshots are meaningfully different (ignoring UI-only changes)\n  const snapshotsAreDifferent = useCallback((snapshot1: FlowSnapshot, snapshot2: FlowSnapshot): boolean => {\n    // Compare serialized versions to check for meaningful differences\n    const nodes1Str = JSON.stringify(snapshot1.nodes);\n    const nodes2Str = JSON.stringify(snapshot2.nodes);\n    const edges1Str = JSON.stringify(snapshot1.edges);\n    const edges2Str = JSON.stringify(snapshot2.edges);\n    \n    return nodes1Str !== nodes2Str || edges1Str !== edges2Str;\n  }, []);\n\n  // Take a snapshot and add it to history\n  const takeSnapshot = useCallback(() => {\n    // Don't take snapshots during undo/redo operations\n    if (isUndoRedoAction.current) {\n      return;\n    }\n\n    const snapshot = createSnapshot();\n    const currentHistory = getCurrentFlowHistory();\n    const currentIndex = getCurrentHistoryIndex();\n    \n    // Don't add duplicate snapshots (when only UI-only properties changed)\n    if (currentHistory.length > 0) {\n      const lastSnapshot = currentHistory[currentIndex];\n      if (lastSnapshot && !snapshotsAreDifferent(snapshot, lastSnapshot)) {\n        return; // Skip duplicate snapshot\n      }\n    }\n\n    const newHistory = [...currentHistory];\n    \n    // If we're not at the end of history, remove future snapshots\n    if (currentIndex < newHistory.length - 1) {\n      newHistory.splice(currentIndex + 1);\n    }\n    \n    // Add new snapshot\n    newHistory.push(snapshot);\n    \n    // Update the flow's history\n    const flowKey = getFlowKey(flowId ?? null);\n    \n    // Limit history size\n    if (newHistory.length > maxHistorySize) {\n      newHistory.shift();\n      setCurrentHistoryIndex(maxHistorySize - 1);\n    } else {\n      setCurrentHistoryIndex(currentIndex + 1);\n    }\n    \n    histories.current[flowKey] = newHistory;\n  }, [createSnapshot, getCurrentFlowHistory, getCurrentHistoryIndex, maxHistorySize, snapshotsAreDifferent, getFlowKey, flowId, setCurrentHistoryIndex]);\n\n  // Restore a snapshot\n  const restoreSnapshot = useCallback((snapshot: FlowSnapshot) => {\n    isUndoRedoAction.current = true;\n    setNodes(snapshot.nodes);\n    setEdges(snapshot.edges);\n    // Reset flag after React has processed the state updates\n    setTimeout(() => {\n      isUndoRedoAction.current = false;\n    }, 0);\n  }, [setNodes, setEdges]);\n\n  // Undo last action\n  const undo = useCallback(() => {\n    const currentIndex = getCurrentHistoryIndex();\n    const currentHistory = getCurrentFlowHistory();\n    \n    if (currentIndex > 0) {\n      const prevSnapshot = currentHistory[currentIndex - 1];\n      restoreSnapshot(prevSnapshot);\n      setCurrentHistoryIndex(currentIndex - 1);\n    }\n  }, [getCurrentHistoryIndex, getCurrentFlowHistory, restoreSnapshot, setCurrentHistoryIndex]);\n\n  // Redo next action\n  const redo = useCallback(() => {\n    const currentIndex = getCurrentHistoryIndex();\n    const currentHistory = getCurrentFlowHistory();\n    \n    if (currentIndex < currentHistory.length - 1) {\n      const nextSnapshot = currentHistory[currentIndex + 1];\n      restoreSnapshot(nextSnapshot);\n      setCurrentHistoryIndex(currentIndex + 1);\n    }\n  }, [getCurrentHistoryIndex, getCurrentFlowHistory, restoreSnapshot, setCurrentHistoryIndex]);\n\n  // Check if undo is available\n  const canUndo = getCurrentHistoryIndex() > 0;\n  \n  // Check if redo is available\n  const canRedo = getCurrentHistoryIndex() < getCurrentFlowHistory().length - 1;\n\n  // Clear history for current flow\n  const clearHistory = useCallback(() => {\n    const flowKey = getFlowKey(flowId ?? null);\n    histories.current[flowKey] = [];\n    setCurrentHistoryIndex(-1);\n  }, [getFlowKey, flowId, setCurrentHistoryIndex]);\n\n  return {\n    takeSnapshot,\n    undo,\n    redo,\n    canUndo,\n    canRedo,\n    clearHistory,\n  };\n} "
  },
  {
    "path": "app/frontend/src/hooks/use-flow-management-tabs.ts",
    "content": "import { useFlowContext } from '@/contexts/flow-context';\nimport { useNodeContext } from '@/contexts/node-context';\nimport { useTabsContext } from '@/contexts/tabs-context';\nimport {\n  clearFlowNodeStates,\n  getNodeInternalState,\n  setNodeInternalState\n} from '@/hooks/use-node-state';\nimport { useToastManager } from '@/hooks/use-toast-manager';\nimport { flowService } from '@/services/flow-service';\nimport { TabService } from '@/services/tab-service';\nimport { Flow } from '@/types/flow';\nimport { useCallback, useEffect, useState } from 'react';\n\nexport interface UseFlowManagementTabsReturn {\n  // State\n  flows: Flow[];\n  searchQuery: string;\n  isLoading: boolean;\n  openGroups: string[];\n  createDialogOpen: boolean;\n  \n  // Computed values\n  filteredFlows: Flow[];\n  recentFlows: Flow[];\n  templateFlows: Flow[];\n  \n  // Actions\n  setSearchQuery: (query: string) => void;\n  setOpenGroups: (groups: string[]) => void;\n  setCreateDialogOpen: (open: boolean) => void;\n  handleAccordionChange: (value: string[]) => void;\n  handleCreateNewFlow: () => void;\n  handleFlowCreated: (newFlow: Flow) => Promise<void>;\n  handleSaveCurrentFlow: () => Promise<void>;\n  handleOpenFlowInTab: (flow: Flow) => Promise<void>;\n  handleDeleteFlow: (flow: Flow) => Promise<void>;\n  handleRefresh: () => Promise<void>;\n  \n  // Internal functions (for testing/advanced use)\n  loadFlows: () => Promise<void>;\n  createDefaultFlow: () => Promise<void>;\n}\n\nexport function useFlowManagementTabs(): UseFlowManagementTabsReturn {\n  // Get flow context, node context, tabs context, and toast manager\n  const { saveCurrentFlow, reactFlowInstance, currentFlowId } = useFlowContext();\n  const { exportNodeContextData } = useNodeContext();\n  const { openTab, isTabOpen, closeTab } = useTabsContext();\n  const { success, error } = useToastManager();\n  \n  // State for flows\n  const [flows, setFlows] = useState<Flow[]>([]);\n  const [searchQuery, setSearchQuery] = useState('');\n  const [isLoading, setIsLoading] = useState(false);\n  const [openGroups, setOpenGroups] = useState<string[]>(['recent-flows']);\n  const [createDialogOpen, setCreateDialogOpen] = useState(false);\n\n  // Enhanced save function that includes internal node states AND node context data\n  const saveCurrentFlowWithStates = useCallback(async (): Promise<Flow | null> => {\n    try {\n      // Get current nodes from React Flow\n      const currentNodes = reactFlowInstance.getNodes();\n      \n      // Get node context data (runtime data: agent status, messages, output data)\n      const flowId = currentFlowId?.toString() || null;\n      const nodeContextData = exportNodeContextData(flowId);\n      \n      // Enhance nodes with internal states\n      const nodesWithStates = currentNodes.map((node: any) => {\n        const internalState = getNodeInternalState(node.id);\n        return {\n          ...node,\n          data: {\n            ...node.data,\n            // Only add internal_state if there is actually state to save\n            ...(internalState && Object.keys(internalState).length > 0 ? { internal_state: internalState } : {})\n          }\n        };\n      });\n\n      // Temporarily replace nodes in React Flow with enhanced nodes\n      reactFlowInstance.setNodes(nodesWithStates);\n      \n      try {\n        // Use the context's save function which handles currentFlowId properly\n        const savedFlow = await saveCurrentFlow();\n        \n        if (savedFlow) {\n          // After basic save, update with node context data\n          const updatedFlow = await flowService.updateFlow(savedFlow.id, {\n            ...savedFlow,\n            data: {\n              ...savedFlow.data,\n              nodeContextData, // Add runtime data from node context\n            }\n          });\n          \n          return updatedFlow;\n        }\n        \n        return savedFlow;\n      } finally {\n        // Restore original nodes (without internal_state in React Flow)\n        reactFlowInstance.setNodes(currentNodes);\n      }\n    } catch (err) {\n      console.error('Failed to save flow with states:', err);\n      return null;\n    }\n  }, [reactFlowInstance, saveCurrentFlow, exportNodeContextData, currentFlowId]);\n\n  // Create default flow for new users\n  const createDefaultFlow = useCallback(async () => {\n    try {\n      // Get current React Flow state, fallback to empty arrays if nothing exists\n      const nodes = reactFlowInstance?.getNodes() || [];\n      const edges = reactFlowInstance?.getEdges() || [];\n      const viewport = reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 };\n      \n      const defaultFlow = await flowService.createDefaultFlow(nodes, edges, viewport);\n      setFlows([defaultFlow]);\n      \n      // Open the default flow in a tab\n      const tabData = TabService.createFlowTab(defaultFlow);\n      openTab(tabData);\n    } catch (error) {\n      console.error('Failed to create default flow:', error);\n    }\n  }, [reactFlowInstance, openTab]);\n\n  // Load flows from API\n  const loadFlows = useCallback(async () => {\n    setIsLoading(true);\n    try {\n      const flowsData = await flowService.getFlows();\n      setFlows(flowsData);\n      \n      // Don't automatically create or open tabs on startup\n      // Let users explicitly open tabs by clicking on flows\n      // Tabs will be restored from localStorage if they exist\n      \n    } catch (error) {\n      console.error('Error loading flows:', error);\n    } finally {\n      setIsLoading(false);\n    }\n  }, []);\n\n  // Load flows on mount\n  useEffect(() => {\n    loadFlows();\n  }, [loadFlows]);\n\n  // Filter flows based on search query\n  const filteredFlows = flows.filter(flow =>\n    flow.name.toLowerCase().includes(searchQuery.toLowerCase()) ||\n    flow.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||\n    flow.tags?.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))\n  );\n\n  // Sort flows by updated_at descending, then group them\n  const sortedFlows = [...filteredFlows].sort((a, b) => {\n    const dateA = new Date(a.updated_at || a.created_at);\n    const dateB = new Date(b.updated_at || b.created_at);\n    return dateB.getTime() - dateA.getTime();\n  });\n\n  // Group flows\n  const recentFlows = sortedFlows.filter(f => !f.is_template).slice(0, 10);\n  const templateFlows = sortedFlows.filter(f => f.is_template);\n\n  // Event handlers\n  const handleAccordionChange = useCallback((value: string[]) => {\n    setOpenGroups(value);\n  }, []);\n\n  const handleCreateNewFlow = useCallback(() => {\n    setCreateDialogOpen(true);\n  }, []);\n\n  const handleFlowCreated = useCallback(async (newFlow: Flow) => {\n    // Open the new flow in a tab\n    const tabData = TabService.createFlowTab(newFlow);\n    openTab(tabData);\n    \n    // Remember it\n    localStorage.setItem('lastSelectedFlowId', newFlow.id.toString());\n    \n    // Refresh the flows list to show the new flow\n    await loadFlows();\n  }, [openTab, loadFlows]);\n\n  const handleSaveCurrentFlow = useCallback(async () => {\n    try {\n      const savedFlow = await saveCurrentFlowWithStates();\n      if (savedFlow) {\n        // Remember the saved flow\n        localStorage.setItem('lastSelectedFlowId', savedFlow.id.toString());\n        // Refresh the flows list\n        await loadFlows();\n        success(`\"${savedFlow.name}\" saved!`, 'flow-save');\n      } else {\n        error('Failed to save flow', 'flow-save-error');\n      }\n    } catch (err) {\n      console.error('Failed to save flow:', err);\n      error('Failed to save flow', 'flow-save-error');\n    }\n  }, [saveCurrentFlowWithStates, loadFlows, success, error]);\n\n  const handleOpenFlowInTab = useCallback(async (flow: Flow) => {\n    try {      \n      // Always fetch the full flow data including nodes, edges, and viewport\n      // This ensures we have the latest data from the backend\n      const fullFlow = await flowService.getFlow(flow.id);\n      \n      // Create tab data with configuration restoration only\n      const createTabWithConfigRestore = (flowData: Flow) => {\n        const tabData = TabService.createFlowTab(flowData);\n        \n        // Enhance the tab content to restore only configuration data when the tab is activated\n        return {\n          ...tabData,\n          onActivate: () => {\n            // NOTE: We intentionally do NOT restore nodeContextData here\n            // Runtime execution data (messages, analysis, agent status) should start fresh\n            \n            // Restore internal states for each node (use-node-state data - configuration only)\n            if (flowData.nodes) {\n              flowData.nodes.forEach((node: any) => {\n                if (node.data?.internal_state) {\n                  setNodeInternalState(node.id, node.data.internal_state);\n                }\n              });\n            }\n          }\n        };\n      };\n      \n      // Check if tab is already open\n      if (isTabOpen(flow.id.toString(), 'flow')) {\n        // Tab exists - update it with fresh data and focus it\n        const tabId = `flow-${flow.id}`;\n        const enhancedTabData = createTabWithConfigRestore(fullFlow);\n        \n        // Update the existing tab with fresh data\n        openTab({\n          id: tabId,\n          type: enhancedTabData.type,\n          title: enhancedTabData.title,\n          content: enhancedTabData.content,\n          flow: enhancedTabData.flow,\n          metadata: enhancedTabData.metadata,\n        });\n        \n        // Trigger the enhanced restoration\n        if (enhancedTabData.onActivate) {\n          enhancedTabData.onActivate();\n        }\n      } else {\n        // Create new tab with fresh data\n        const enhancedTabData = createTabWithConfigRestore(fullFlow);\n        openTab(enhancedTabData);\n        \n        // Trigger the enhanced restoration for new tab\n        if (enhancedTabData.onActivate) {\n          enhancedTabData.onActivate();\n        }\n      }\n      \n      // Remember the selected flow\n      localStorage.setItem('lastSelectedFlowId', fullFlow.id.toString());\n    } catch (err) {\n      console.error('Failed to open flow in tab:', err);\n      error('Failed to load flow data');\n    }\n  }, [isTabOpen, openTab, error]);\n\n  const handleRefresh = useCallback(async () => {\n    await loadFlows();\n  }, [loadFlows]);\n\n  const handleDeleteFlow = useCallback(async (flow: Flow) => {\n    try {\n      await flowService.deleteFlow(flow.id);\n      \n      // Close the tab if it's open\n      const tabId = `flow-${flow.id}`;\n      closeTab(tabId);\n      \n      // Clear node states for the deleted flow\n      clearFlowNodeStates(flow.id.toString());\n      \n      // Remove from localStorage if it was the last selected\n      const lastSelectedFlowId = localStorage.getItem('lastSelectedFlowId');\n      if (lastSelectedFlowId === flow.id.toString()) {\n        localStorage.removeItem('lastSelectedFlowId');\n      }\n      \n      // Refresh the flows list\n      await loadFlows();\n    } catch (error) {\n      console.error('Failed to delete flow:', error);\n    }\n  }, [loadFlows, closeTab]);\n\n  return {\n    // State\n    flows,\n    searchQuery,\n    isLoading,\n    openGroups,\n    createDialogOpen,\n    \n    // Computed values\n    filteredFlows,\n    recentFlows,\n    templateFlows,\n    \n    // Actions\n    setSearchQuery,\n    setOpenGroups,\n    setCreateDialogOpen,\n    handleAccordionChange,\n    handleCreateNewFlow,\n    handleFlowCreated,\n    handleSaveCurrentFlow,\n    handleOpenFlowInTab,\n    handleDeleteFlow,\n    handleRefresh,\n    \n    // Internal functions\n    loadFlows,\n    createDefaultFlow,\n  };\n} "
  },
  {
    "path": "app/frontend/src/hooks/use-flow-management.ts",
    "content": "import { useFlowContext } from '@/contexts/flow-context';\nimport { useNodeContext } from '@/contexts/node-context';\nimport {\n  clearFlowNodeStates,\n  getNodeInternalState,\n  setNodeInternalState,\n  setCurrentFlowId as setNodeStateFlowId\n} from '@/hooks/use-node-state';\nimport { useToastManager } from '@/hooks/use-toast-manager';\nimport { flowService } from '@/services/flow-service';\nimport { Flow } from '@/types/flow';\nimport { useCallback, useEffect, useState } from 'react';\n\nexport interface UseFlowManagementReturn {\n  // State\n  flows: Flow[];\n  searchQuery: string;\n  isLoading: boolean;\n  openGroups: string[];\n  createDialogOpen: boolean;\n  \n  // Computed values\n  filteredFlows: Flow[];\n  recentFlows: Flow[];\n  templateFlows: Flow[];\n  \n  // Actions\n  setSearchQuery: (query: string) => void;\n  setOpenGroups: (groups: string[]) => void;\n  setCreateDialogOpen: (open: boolean) => void;\n  handleAccordionChange: (value: string[]) => void;\n  handleCreateNewFlow: () => void;\n  handleFlowCreated: (newFlow: Flow) => Promise<void>;\n  handleSaveCurrentFlow: () => Promise<void>;\n  handleLoadFlow: (flow: Flow) => Promise<void>;\n  handleDeleteFlow: (flow: Flow) => Promise<void>;\n  handleRefresh: () => Promise<void>;\n  \n  // Internal functions (for testing/advanced use)\n  loadFlows: () => Promise<void>;\n  createDefaultFlow: () => Promise<void>;\n}\n\nexport function useFlowManagement(): UseFlowManagementReturn {\n  // Get flow context, node context, and toast manager\n  const { saveCurrentFlow, loadFlow, reactFlowInstance, currentFlowId } = useFlowContext();\n  const { exportNodeContextData } = useNodeContext();\n  const { success, error } = useToastManager();\n  \n  // State for flows\n  const [flows, setFlows] = useState<Flow[]>([]);\n  const [searchQuery, setSearchQuery] = useState('');\n  const [isLoading, setIsLoading] = useState(false);\n  const [openGroups, setOpenGroups] = useState<string[]>(['recent-flows']);\n  const [createDialogOpen, setCreateDialogOpen] = useState(false);\n\n  // Enhanced save function that includes internal node states AND node context data\n  const saveCurrentFlowWithStates = useCallback(async (): Promise<Flow | null> => {\n    try {\n      // Get current nodes from React Flow\n      const currentNodes = reactFlowInstance.getNodes();\n      \n      // Get node context data (runtime data: agent status, messages, output data)\n      const flowId = currentFlowId?.toString() || null;\n      const nodeContextData = exportNodeContextData(flowId);\n      \n      // Enhance nodes with internal states\n      const nodesWithStates = currentNodes.map((node: any) => {\n        const internalState = getNodeInternalState(node.id);\n        return {\n          ...node,\n          data: {\n            ...node.data,\n            // Only add internal_state if there is actually state to save\n            ...(internalState && Object.keys(internalState).length > 0 ? { internal_state: internalState } : {})\n          }\n        };\n      });\n\n      // Temporarily replace nodes in React Flow with enhanced nodes\n      reactFlowInstance.setNodes(nodesWithStates);\n      \n      try {\n        // Use the context's save function which handles currentFlowId properly\n        const savedFlow = await saveCurrentFlow();\n        \n        if (savedFlow) {\n          // After basic save, update with node context data\n          const updatedFlow = await flowService.updateFlow(savedFlow.id, {\n            ...savedFlow,\n            data: {\n              ...savedFlow.data,\n              nodeContextData, // Add runtime data from node context\n            }\n          });\n          \n          return updatedFlow;\n        }\n        \n        return savedFlow;\n      } finally {\n        // Restore original nodes (without internal_state in React Flow)\n        reactFlowInstance.setNodes(currentNodes);\n      }\n    } catch (err) {\n      console.error('Failed to save flow with states:', err);\n      return null;\n    }\n  }, [reactFlowInstance, saveCurrentFlow, exportNodeContextData, currentFlowId]);\n\n  // Enhanced load function that restores internal node states AND node context data\n  const loadFlowWithStates = useCallback(async (flow: Flow) => {\n    try {\n      // First, set the flow ID for node state isolation\n      setNodeStateFlowId(flow.id.toString());\n      \n      // DO NOT clear configuration state when loading flows - useNodeState handles flow isolation automatically\n      // DO NOT reset runtime data when loading flows - preserve all runtime state\n      // Runtime data should only be reset when explicitly starting a new run via the Play button\n      console.log(`[FlowManagement] Loading flow ${flow.id} (${flow.name}), preserving all state (configuration + runtime)`);\n\n      // Load the flow using the context (this handles currentFlowId, currentFlowName, etc.)\n      await loadFlow(flow);\n\n      // Then restore internal states for each node (use-node-state data)\n      if (flow.nodes) {\n        flow.nodes.forEach((node: any) => {\n          if (node.data?.internal_state) {\n            setNodeInternalState(node.id, node.data.internal_state);\n          }\n        });\n      }\n      \n      // NOTE: We intentionally do NOT restore nodeContextData here\n      // Runtime execution data (messages, analysis, agent status) should start fresh\n      // Only configuration data (tickers, model selections) is restored above\n\n      console.log('Flow loaded with complete state restoration:', flow.name);\n    } catch (error) {\n      console.error('Failed to load flow with states:', error);\n      throw error; // Re-throw to handle in calling function\n    }\n  }, [loadFlow]);\n\n  // Create default flow for new users\n  const createDefaultFlow = useCallback(async () => {\n    try {\n      console.log('Creating default flow for new user...');\n      // Get current React Flow state, fallback to empty arrays if nothing exists\n      const nodes = reactFlowInstance?.getNodes() || [];\n      const edges = reactFlowInstance?.getEdges() || [];\n      const viewport = reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 };\n      \n      const defaultFlow = await flowService.createDefaultFlow(nodes, edges, viewport);\n      console.log('Default flow created:', defaultFlow);\n      setFlows([defaultFlow]);\n      \n      // Set the flow ID for node state isolation before loading\n      setNodeStateFlowId(defaultFlow.id.toString());\n      await loadFlowWithStates(defaultFlow);\n      console.log('Default flow loaded successfully');\n    } catch (error) {\n      console.error('Failed to create default flow:', error);\n    }\n  }, [reactFlowInstance, loadFlowWithStates]);\n\n  // Load flows from API\n  const loadFlows = useCallback(async () => {\n    setIsLoading(true);\n    try {\n      console.log('Loading flows from API...');\n      const flowsData = await flowService.getFlows();\n      console.log('Loaded flows:', flowsData);\n      setFlows(flowsData);\n      \n      if (flowsData.length === 0) {\n        // Create default flow if user has no flows\n        console.log('No flows found, creating default flow...');\n        await createDefaultFlow();\n      } else {\n        // Try to restore the last selected flow from localStorage\n        const lastSelectedFlowId = localStorage.getItem('lastSelectedFlowId');\n        let flowToLoad = null;\n\n        if (lastSelectedFlowId) {\n          // Try to find the last selected flow\n          flowToLoad = flowsData.find(flow => flow.id === parseInt(lastSelectedFlowId));\n          if (flowToLoad) {\n            console.log('Restoring last selected flow:', flowToLoad.name);\n          }\n        }\n\n        // If no last selected flow or it doesn't exist anymore, use the most recent\n        if (!flowToLoad) {\n          flowToLoad = flowsData.reduce((latest, current) => {\n            const latestDate = new Date(latest.updated_at || latest.created_at);\n            const currentDate = new Date(current.updated_at || current.created_at);\n            return currentDate > latestDate ? current : latest;\n          });\n          console.log('Loading most recent flow:', flowToLoad.name);\n        }\n\n        // Fetch the full flow data before loading\n        const fullFlow = await flowService.getFlow(flowToLoad.id);\n        await loadFlowWithStates(fullFlow);\n      }\n    } catch (error) {\n      console.error('Error loading flows:', error);\n    } finally {\n      setIsLoading(false);\n    }\n  }, [createDefaultFlow, loadFlowWithStates]);\n\n  // Load flows on mount\n  useEffect(() => {\n    loadFlows();\n  }, [loadFlows]);\n\n  // Filter flows based on search query\n  const filteredFlows = flows.filter(flow =>\n    flow.name.toLowerCase().includes(searchQuery.toLowerCase()) ||\n    flow.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||\n    flow.tags?.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))\n  );\n\n  // Sort flows by updated_at descending, then group them\n  const sortedFlows = [...filteredFlows].sort((a, b) => {\n    const dateA = new Date(a.updated_at || a.created_at);\n    const dateB = new Date(b.updated_at || b.created_at);\n    return dateB.getTime() - dateA.getTime();\n  });\n\n  // Group flows\n  const recentFlows = sortedFlows.filter(f => !f.is_template).slice(0, 10);\n  const templateFlows = sortedFlows.filter(f => f.is_template);\n\n  // Event handlers\n  const handleAccordionChange = useCallback((value: string[]) => {\n    setOpenGroups(value);\n  }, []);\n\n  const handleCreateNewFlow = useCallback(() => {\n    setCreateDialogOpen(true);\n  }, []);\n\n  const handleFlowCreated = useCallback(async (newFlow: Flow) => {\n    // Load the new flow and remember it\n    await loadFlowWithStates(newFlow);\n    localStorage.setItem('lastSelectedFlowId', newFlow.id.toString());\n    \n    // Refresh the flows list to show the new flow\n    await loadFlows();\n  }, [loadFlowWithStates, loadFlows]);\n\n  const handleSaveCurrentFlow = useCallback(async () => {\n    try {\n      const savedFlow = await saveCurrentFlowWithStates();\n      if (savedFlow) {\n        // Remember the saved flow\n        localStorage.setItem('lastSelectedFlowId', savedFlow.id.toString());\n        // Refresh the flows list\n        await loadFlows();\n        success(`\"${savedFlow.name}\" saved!`, 'flow-save');\n      } else {\n        error('Failed to save flow', 'flow-save-error');\n      }\n    } catch (err) {\n      console.error('Failed to save flow:', err);\n      error('Failed to save flow', 'flow-save-error');\n    }\n  }, [saveCurrentFlowWithStates, loadFlows, success, error]);\n\n  const handleLoadFlow = useCallback(async (flow: Flow) => {\n    try {\n      // Fetch the full flow data including nodes, edges, and viewport\n      const fullFlow = await flowService.getFlow(flow.id);\n      await loadFlowWithStates(fullFlow);\n      // Remember the selected flow\n      localStorage.setItem('lastSelectedFlowId', flow.id.toString());\n      console.log('Flow loaded:', fullFlow.name);\n    } catch (error) {\n      console.error('Failed to load flow:', error);\n    }\n  }, [loadFlowWithStates]);\n\n  const handleRefresh = useCallback(async () => {\n    await loadFlows();\n  }, [loadFlows]);\n\n  const handleDeleteFlow = useCallback(async (flow: Flow) => {\n    try {\n      await flowService.deleteFlow(flow.id);\n      // Clear node states for the deleted flow\n      clearFlowNodeStates(flow.id.toString());\n      // Remove from localStorage if it was the last selected\n      const lastSelectedFlowId = localStorage.getItem('lastSelectedFlowId');\n      if (lastSelectedFlowId === flow.id.toString()) {\n        localStorage.removeItem('lastSelectedFlowId');\n      }\n      // Refresh the flows list\n      await loadFlows();\n    } catch (error) {\n      console.error('Failed to delete flow:', error);\n    }\n  }, [loadFlows]);\n\n  return {\n    // State\n    flows,\n    searchQuery,\n    isLoading,\n    openGroups,\n    createDialogOpen,\n    \n    // Computed values\n    filteredFlows,\n    recentFlows,\n    templateFlows,\n    \n    // Actions\n    setSearchQuery,\n    setOpenGroups,\n    setCreateDialogOpen,\n    handleAccordionChange,\n    handleCreateNewFlow,\n    handleFlowCreated,\n    handleSaveCurrentFlow,\n    handleLoadFlow,\n    handleDeleteFlow,\n    handleRefresh,\n    \n    // Internal functions\n    loadFlows,\n    createDefaultFlow,\n  };\n} "
  },
  {
    "path": "app/frontend/src/hooks/use-keyboard-shortcuts.ts",
    "content": "import { useEffect } from 'react';\n\ninterface KeyboardShortcut {\n  key: string;\n  ctrlKey?: boolean;\n  metaKey?: boolean;\n  shiftKey?: boolean;\n  altKey?: boolean;\n  callback: () => void;\n  preventDefault?: boolean;\n}\n\ninterface UseKeyboardShortcutsProps {\n  shortcuts: KeyboardShortcut[];\n}\n\nexport function useKeyboardShortcuts({ shortcuts }: UseKeyboardShortcutsProps) {\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      shortcuts.forEach(({ key, ctrlKey, metaKey, shiftKey, altKey, callback, preventDefault = true }) => {\n        const isCtrlMatch = ctrlKey ? event.ctrlKey : !event.ctrlKey;\n        const isMetaMatch = metaKey ? event.metaKey : !event.metaKey;\n        const isShiftMatch = shiftKey ? event.shiftKey : !event.shiftKey;\n        const isAltMatch = altKey ? event.altKey : !event.altKey;\n        const isKeyMatch = event.key.toLowerCase() === key.toLowerCase();\n\n        // For save shortcut, we want either Ctrl+S OR Cmd+S\n        const isSaveShortcut = key.toLowerCase() === 's' && (ctrlKey || metaKey);\n        const matchesSaveShortcut = isSaveShortcut && (event.ctrlKey || event.metaKey) && isKeyMatch;\n\n        // For shortcuts that should work with either Ctrl or Cmd\n        const isModifierShortcut = (ctrlKey || metaKey) && (event.ctrlKey || event.metaKey);\n        const matchesModifierShortcut = isModifierShortcut && isKeyMatch && isShiftMatch && isAltMatch;\n\n        if (matchesSaveShortcut || matchesModifierShortcut || (isKeyMatch && isCtrlMatch && isMetaMatch && isShiftMatch && isAltMatch)) {\n          if (preventDefault) {\n            event.preventDefault();\n          }\n          callback();\n        }\n      });\n    };\n\n    document.addEventListener('keydown', handleKeyDown);\n\n    return () => {\n      document.removeEventListener('keydown', handleKeyDown);\n    };\n  }, [shortcuts]);\n}\n\n// Convenience hook specifically for common shortcuts\nexport function useFlowKeyboardShortcuts(saveFlow: (showToast?: boolean) => void) {\n  const shortcuts: KeyboardShortcut[] = [\n    {\n      key: 's',\n      ctrlKey: true, // This will match either Ctrl+S or Cmd+S due to our logic above\n      metaKey: true,\n      callback: () => saveFlow(true),\n      preventDefault: true,\n    },\n  ];\n\n  useKeyboardShortcuts({ shortcuts });\n}\n\n// Convenience hook for layout keyboard shortcuts\nexport function useLayoutKeyboardShortcuts(\n  toggleRightSidebar: () => void, \n  toggleLeftSidebar?: () => void,\n  fitView?: () => void,\n  undo?: () => void,\n  redo?: () => void,\n  toggleBottomPanel?: () => void,\n  openSettings?: () => void\n) {\n  const shortcuts: KeyboardShortcut[] = [\n    {\n      key: 'i',\n      ctrlKey: true, // This will match either Ctrl+I or Cmd+I due to our logic above  \n      metaKey: true,\n      callback: toggleRightSidebar,\n      preventDefault: true,\n    },\n  ];\n\n  // Add left sidebar toggle if provided\n  if (toggleLeftSidebar) {\n    shortcuts.push({\n      key: 'b',\n      ctrlKey: true, // This will match either Ctrl+B or Cmd+B\n      metaKey: true,\n      callback: toggleLeftSidebar,\n      preventDefault: true,\n    });\n  }\n\n  // Add fit view shortcut if provided\n  if (fitView) {\n    shortcuts.push({\n      key: '0',\n      ctrlKey: true, // This will match either Ctrl+O or Cmd+O\n      metaKey: true,\n      callback: fitView,\n      preventDefault: true,\n    });\n  }\n\n  // Add undo shortcut if provided\n  if (undo) {\n    shortcuts.push({\n      key: 'z',\n      ctrlKey: true, // This will match either Ctrl+Z or Cmd+Z\n      metaKey: true,\n      callback: undo,\n      preventDefault: true,\n    });\n  }\n\n  // Add redo shortcut if provided\n  if (redo) {\n    shortcuts.push({\n      key: 'z',\n      ctrlKey: true, // This will match either Ctrl+Shift+Z or Cmd+Shift+Z\n      metaKey: true,\n      shiftKey: true,\n      callback: redo,\n      preventDefault: true,\n    });\n  }\n\n  // Add bottom panel toggle shortcut if provided\n  if (toggleBottomPanel) {\n    shortcuts.push({\n      key: 'j',\n      ctrlKey: true, // This will match either Ctrl+J or Cmd+J (like VSCode)\n      metaKey: true,\n      callback: toggleBottomPanel,\n      preventDefault: true,\n    });\n  }\n\n  // Add settings shortcut if provided\n  if (openSettings) {\n    shortcuts.push({\n      key: 'j',\n      ctrlKey: true, // This will match either Ctrl+Shift+J or Cmd+Shift+J\n      metaKey: true,\n      shiftKey: true,\n      callback: openSettings,\n      preventDefault: true,\n    });\n    \n    // Add settings shortcut if provided\n    shortcuts.push({\n      key: ',',\n      ctrlKey: true, // This will match either Ctrl+, or Cmd+,\n      metaKey: true,\n      callback: openSettings,\n      preventDefault: true,\n    });\n  }\n\n  useKeyboardShortcuts({ shortcuts });\n} "
  },
  {
    "path": "app/frontend/src/hooks/use-mobile.tsx",
    "content": "import * as React from \"react\"\n\nconst MOBILE_BREAKPOINT = 768\n\nexport function useIsMobile() {\n  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)\n\n  React.useEffect(() => {\n    const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)\n    const onChange = () => {\n      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    }\n    mql.addEventListener(\"change\", onChange)\n    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    return () => mql.removeEventListener(\"change\", onChange)\n  }, [])\n\n  return !!isMobile\n}\n"
  },
  {
    "path": "app/frontend/src/hooks/use-node-state.ts",
    "content": "import { useCallback, useEffect, useState } from 'react';\n\n// =============================================================================\n// FLOW STATE MANAGER - Handles global state and flow isolation\n// =============================================================================\n\nclass FlowStateManager {\n  private nodeStatesMap = new Map<string, Record<string, any>>();\n  private stateChangeListeners = new Set<() => void>();\n  private flowIdChangeListeners = new Set<() => void>();\n  private currentFlowId: string | null = null;\n\n  // Flow ID Management\n  setCurrentFlowId(flowId: string | null): void {\n    const oldFlowId = this.currentFlowId;\n    this.currentFlowId = flowId;\n    \n    if (oldFlowId !== flowId) {\n      this.notifyFlowIdChange();\n    }\n  }\n\n  getCurrentFlowId(): string | null {\n    return this.currentFlowId;\n  }\n\n  // Key Generation\n  private createCompositeKey(nodeId: string): string {\n    return this.currentFlowId ? `${this.currentFlowId}:${nodeId}` : nodeId;\n  }\n\n  // State Access\n  getNodeState(nodeId: string, stateKey: string): any {\n    const compositeKey = this.createCompositeKey(nodeId);\n    const nodeState = this.nodeStatesMap.get(compositeKey);\n    return nodeState?.[stateKey];\n  }\n\n  setNodeState(nodeId: string, stateKey: string, value: any): void {\n    const compositeKey = this.createCompositeKey(nodeId);\n    \n    if (!this.nodeStatesMap.has(compositeKey)) {\n      this.nodeStatesMap.set(compositeKey, {});\n    }\n    \n    this.nodeStatesMap.get(compositeKey)![stateKey] = value;\n    this.notifyStateChange();\n  }\n\n  // Node Management\n  getNodeInternalState(nodeId: string): Record<string, any> | undefined {\n    const compositeKey = this.createCompositeKey(nodeId);\n    return this.nodeStatesMap.get(compositeKey);\n  }\n\n  setNodeInternalState(nodeId: string, state: Record<string, any>): void {\n    const compositeKey = this.createCompositeKey(nodeId);\n    this.nodeStatesMap.set(compositeKey, { ...state });\n    this.notifyStateChange();\n  }\n\n  clearNodeInternalState(nodeId: string): void {\n    const compositeKey = this.createCompositeKey(nodeId);\n    this.nodeStatesMap.delete(compositeKey);\n    this.notifyStateChange();\n  }\n\n  // Flow Management\n  getAllNodeStates(): Map<string, Record<string, any>> {\n    if (!this.currentFlowId) {\n      // Backward compatibility - return all states\n      return new Map(this.nodeStatesMap);\n    }\n    \n    // Filter states for current flow and strip flow prefix\n    const currentFlowStates = new Map<string, Record<string, any>>();\n    const flowPrefix = `${this.currentFlowId}:`;\n    \n    for (const [compositeKey, state] of this.nodeStatesMap.entries()) {\n      if (compositeKey.startsWith(flowPrefix)) {\n        const nodeId = compositeKey.substring(flowPrefix.length);\n        currentFlowStates.set(nodeId, state);\n      }\n    }\n    \n    return currentFlowStates;\n  }\n\n  clearAllNodeStates(): void {\n    if (!this.currentFlowId) {\n      // Backward compatibility - clear all states\n      this.nodeStatesMap.clear();\n    } else {\n      // Clear only current flow's states\n      const flowPrefix = `${this.currentFlowId}:`;\n      const keysToDelete = Array.from(this.nodeStatesMap.keys())\n        .filter(key => key.startsWith(flowPrefix));\n      \n      keysToDelete.forEach(key => this.nodeStatesMap.delete(key));\n    }\n    \n    this.notifyStateChange();\n  }\n\n  clearFlowNodeStates(flowId: string): void {\n    const flowPrefix = `${flowId}:`;\n    const keysToDelete = Array.from(this.nodeStatesMap.keys())\n      .filter(key => key.startsWith(flowPrefix));\n    \n    keysToDelete.forEach(key => this.nodeStatesMap.delete(key));\n    this.notifyStateChange();\n  }\n\n  // Listener Management\n  addStateChangeListener(listener: () => void): () => void {\n    this.stateChangeListeners.add(listener);\n    return () => this.stateChangeListeners.delete(listener);\n  }\n\n  addFlowIdChangeListener(listener: () => void): () => void {\n    this.flowIdChangeListeners.add(listener);\n    return () => this.flowIdChangeListeners.delete(listener);\n  }\n\n  private notifyStateChange(): void {\n    this.stateChangeListeners.forEach(listener => listener());\n  }\n\n  private notifyFlowIdChange(): void {\n    this.flowIdChangeListeners.forEach(listener => listener());\n  }\n}\n\n// Global instance\nconst flowStateManager = new FlowStateManager();\n\n// =============================================================================\n// PUBLIC API - Clean interface for external use\n// =============================================================================\n\nexport interface UseNodeStateReturn<T> {\n  0: T;\n  1: (value: T | ((prev: T) => T)) => void;\n}\n\n// Flow Management\nexport function setCurrentFlowId(flowId: string | null): void {\n  flowStateManager.setCurrentFlowId(flowId);\n}\n\n// Node State Management\nexport function getNodeInternalState(nodeId: string): Record<string, any> | undefined {\n  return flowStateManager.getNodeInternalState(nodeId);\n}\n\nexport function setNodeInternalState(nodeId: string, state: Record<string, any>): void {\n  flowStateManager.setNodeInternalState(nodeId, state);\n}\n\nexport function clearNodeInternalState(nodeId: string): void {\n  flowStateManager.clearNodeInternalState(nodeId);\n}\n\n// Flow State Management\nexport function getAllNodeStates(): Map<string, Record<string, any>> {\n  return flowStateManager.getAllNodeStates();\n}\n\nexport function clearAllNodeStates(): void {\n  flowStateManager.clearAllNodeStates();\n}\n\nexport function clearFlowNodeStates(flowId: string): void {\n  flowStateManager.clearFlowNodeStates(flowId);\n}\n\nexport function addStateChangeListener(listener: () => void): () => void {\n  return flowStateManager.addStateChangeListener(listener);\n}\n\n// =============================================================================\n// REACT HOOKS - Focused on React integration\n// =============================================================================\n\n/**\n * Drop-in replacement for useState that automatically persists state across\n * flow saves/loads and provides flow isolation.\n * \n * @param nodeId - The ID of the node (from NodeProps)\n * @param stateKey - Unique key for this state value within the node  \n * @param defaultValue - Default value for the state\n * @returns [value, setValue] tuple like useState\n */\nexport function useNodeState<T>(\n  nodeId: string,\n  stateKey: string,\n  defaultValue: T\n): [T, (value: T | ((prev: T) => T)) => void] {\n  \n  // Initialize with stored value or default\n  const getStoredValue = useCallback((): T => {\n    const storedValue = flowStateManager.getNodeState(nodeId, stateKey);\n    return storedValue !== undefined ? storedValue : defaultValue;\n  }, [nodeId, stateKey, defaultValue]);\n\n  const [value, setValue] = useState<T>(getStoredValue);\n  const [, forceUpdate] = useState({});\n\n  // Handle flow changes - reset to stored value for new flow\n  useEffect(() => {\n    const unsubscribe = flowStateManager.addFlowIdChangeListener(() => {\n      // Use setTimeout to defer the state update to avoid updating during render\n      setTimeout(() => {\n        const newValue = getStoredValue();\n        setValue(newValue);\n        forceUpdate({}); // Force re-render\n      }, 0);\n    });\n    \n    return unsubscribe;\n  }, [getStoredValue]);\n\n  // Handle external state changes - update if this specific state changed\n  useEffect(() => {\n    const unsubscribe = flowStateManager.addStateChangeListener(() => {\n      const storedValue = flowStateManager.getNodeState(nodeId, stateKey);\n      if (storedValue !== undefined) {\n        // Use setTimeout to defer the state update to avoid updating during render\n        setTimeout(() => {\n          setValue(prevValue => {\n            if (prevValue !== storedValue) {\n              console.debug(`[useNodeState] Updated ${nodeId}.${stateKey}:`, storedValue);\n              return storedValue;\n            }\n            return prevValue;\n          });\n        }, 0);\n      }\n    });\n    \n    return unsubscribe;\n  }, [nodeId, stateKey]);\n\n  // Persist value when it changes\n  const setValueAndPersist = useCallback((newValue: T | ((prev: T) => T)) => {\n    setValue(prevValue => {\n      const finalValue = typeof newValue === 'function' \n        ? (newValue as (prev: T) => T)(prevValue) \n        : newValue;\n      \n      flowStateManager.setNodeState(nodeId, stateKey, finalValue);\n      return finalValue;\n    });\n  }, [nodeId, stateKey]);\n\n  // Initialize stored state on mount or flow change\n  useEffect(() => {\n    const storedValue = flowStateManager.getNodeState(nodeId, stateKey);\n    if (storedValue === undefined) {\n      // Use setTimeout to defer the state update to avoid updating during render\n      setTimeout(() => {\n        flowStateManager.setNodeState(nodeId, stateKey, value);\n      }, 0);\n    }\n  }, [nodeId, stateKey, value]);\n\n  return [value, setValueAndPersist];\n}"
  },
  {
    "path": "app/frontend/src/hooks/use-output-node-connection.ts",
    "content": "import { getConnectedEdges, useReactFlow } from '@xyflow/react';\nimport { useMemo } from 'react';\n\nimport { useFlowContext } from '@/contexts/flow-context';\nimport { useNodeContext } from '@/contexts/node-context';\n\n/**\n * Custom hook to determine output node connection state and processing status\n * @param nodeId - The ID of the output node\n * @returns Object containing connection state and processing status\n */\nexport function useOutputNodeConnection(nodeId: string) {\n  const { currentFlowId } = useFlowContext();\n  const { getAgentNodeDataForFlow, getOutputNodeDataForFlow } = useNodeContext();\n  const { getNodes, getEdges } = useReactFlow();\n\n  // Get data for the current flow\n  const flowId = currentFlowId?.toString() || null;\n  const agentNodeData = getAgentNodeDataForFlow(flowId);\n  const outputNodeData = getOutputNodeDataForFlow(flowId);\n\n  return useMemo(() => {\n    // Get all nodes and edges\n    const nodes = getNodes();\n    const edges = getEdges();\n    \n    // Find edges connected to this output node\n    const connectedEdges = getConnectedEdges([{ id: nodeId }] as any, edges);\n    const connectedAgentIds = connectedEdges\n      .filter(edge => edge.target === nodeId)\n      .map(edge => edge.source)\n      .filter(sourceId => {\n        const sourceNode = nodes.find(n => n.id === sourceId);\n        return sourceNode?.type === 'agent-node';\n      });\n\n    // Check if any connected agents are running\n    const isAnyAgentRunning = connectedAgentIds.some(agentId => \n      agentNodeData[agentId]?.status === 'IN_PROGRESS'\n    );\n\n    // Check if processing (any agent is running)\n    const isProcessing = isAnyAgentRunning;\n\n    // Check if output is available\n    const isOutputAvailable = outputNodeData !== null && outputNodeData !== undefined;\n\n    // Check if connected to any agents  \n    const isConnected = connectedAgentIds.length > 0;\n\n    return {\n      isProcessing,\n      isAnyAgentRunning,\n      isOutputAvailable,\n      isConnected,\n      connectedAgentIds: new Set(connectedAgentIds),\n    };\n  }, [nodeId, agentNodeData, outputNodeData, getNodes, getEdges]);\n} "
  },
  {
    "path": "app/frontend/src/hooks/use-resizable.ts",
    "content": "import { useEffect, useRef, useState } from 'react';\n\ninterface UseResizableOptions {\n  minWidth?: number;\n  maxWidth?: number;\n  defaultWidth?: number;\n  minHeight?: number;\n  maxHeight?: number;\n  defaultHeight?: number;\n  side?: 'left' | 'right' | 'bottom';\n}\n\nexport function useResizable({\n  minWidth = 200,\n  maxWidth = 500,\n  defaultWidth = 250,\n  minHeight = 200,\n  maxHeight = 600,\n  defaultHeight = 300,\n  side = 'left'\n}: UseResizableOptions = {}) {\n  const [width, setWidth] = useState(defaultWidth);\n  const [height, setHeight] = useState(defaultHeight);\n  const [isDragging, setIsDragging] = useState(false);\n  const elementRef = useRef<HTMLDivElement>(null);\n  // Add a ref for tracking dragging state - updates synchronously unlike state\n  const isDraggingRef = useRef(false);\n\n  // Handle manual resizing with mouse\n  const startResize = (e: React.MouseEvent) => {\n    e.preventDefault();\n    // Set both the ref (for immediate use in mousemove) and state (for rendering)\n    isDraggingRef.current = true;\n    setIsDragging(true);\n    document.addEventListener('mousemove', handleMouseMove);\n    document.addEventListener('mouseup', stopResize);\n  };\n\n  const handleMouseMove = (e: MouseEvent) => {\n    // Use the ref value instead of state for checking\n    if (!isDraggingRef.current) return;\n    \n    // Get element's position\n    const elementRect = elementRef.current?.getBoundingClientRect();\n    if (!elementRect) return;\n    \n    if (side === 'bottom') {\n      // For bottom panel: dragging up decreases height\n      const newHeight = elementRect.bottom - e.clientY;\n      const clampedHeight = Math.max(minHeight, Math.min(maxHeight, newHeight));\n      setHeight(clampedHeight);\n    } else {\n      // For horizontal resizing (left/right sidebars)\n      let newWidth;\n      if (side === 'left') {\n        // For left sidebar: dragging right increases width\n        newWidth = e.clientX - elementRect.left;\n      } else {\n        // For right sidebar: dragging left decreases width\n        newWidth = elementRect.right - e.clientX;\n      }\n      \n      // Calculate new width (limit between minWidth and maxWidth)\n      newWidth = Math.max(minWidth, Math.min(maxWidth, newWidth));\n      \n      setWidth(newWidth);\n    }\n  };\n\n  const stopResize = () => {\n    // Update both ref and state\n    isDraggingRef.current = false;\n    setIsDragging(false);\n    document.removeEventListener('mousemove', handleMouseMove);\n    document.removeEventListener('mouseup', stopResize);\n  };\n\n  // Clean up event listeners when component unmounts\n  useEffect(() => {\n    return () => {\n      document.removeEventListener('mousemove', handleMouseMove);\n      document.removeEventListener('mouseup', stopResize);\n    };\n  }, []);\n\n  return {\n    width,\n    height,\n    isDragging,\n    elementRef,\n    startResize\n  };\n} "
  },
  {
    "path": "app/frontend/src/hooks/use-toast-manager.ts",
    "content": "import { useCallback, useState } from 'react';\nimport { toast } from 'sonner';\n\ntype ToastType = 'success' | 'error' | 'info' | 'warning';\ntype ToastPosition = 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right';\n\ninterface ToastOptions {\n  duration?: number;\n  position?: ToastPosition;\n  preventDuplicates?: boolean;\n}\n\ninterface ToastState {\n  [key: string]: boolean;\n}\n\n/**\n * A generalized toast manager hook that handles duplicate prevention and provides\n * convenience methods for different toast types.\n * \n * @example\n * ```typescript\n * const { success, error, info, warning } = useToastManager();\n * \n * // Basic usage\n * success('Data saved!');\n * error('Something went wrong');\n * \n * // With custom ID to prevent duplicates\n * success('Flow saved!', 'flow-save');\n * \n * // With custom options\n * info('Processing...', 'process', { \n *   duration: 5000, \n *   position: 'top-center' \n * });\n * \n * // Disable duplicate prevention\n * warning('Warning!', undefined, { preventDuplicates: false });\n * ```\n */\nexport function useToastManager() {\n  const [visibleToasts, setVisibleToasts] = useState<ToastState>({});\n\n  const showToast = useCallback((\n    type: ToastType,\n    message: string,\n    toastId?: string,\n    options: ToastOptions = {}\n  ) => {\n    const {\n      duration = 2000,\n      position = 'top-center',\n      preventDuplicates = true\n    } = options;\n\n    // Use message as ID if no toastId provided\n    const id = toastId || message;\n\n    // Check if we should prevent duplicates\n    if (preventDuplicates && visibleToasts[id]) {\n      return;\n    }\n\n    // Mark toast as visible\n    if (preventDuplicates) {\n      setVisibleToasts(prev => ({ ...prev, [id]: true }));\n    }\n\n    const onDismiss = () => {\n      if (preventDuplicates) {\n        setVisibleToasts(prev => ({ ...prev, [id]: false }));\n      }\n    };\n\n    const onAutoClose = () => {\n      if (preventDuplicates) {\n        setVisibleToasts(prev => ({ ...prev, [id]: false }));\n      }\n    };\n\n    // Show the appropriate toast type\n    switch (type) {\n      case 'success':\n        toast.success(message, {\n          duration,\n          position,\n          onDismiss,\n          onAutoClose,\n        });\n        break;\n      case 'error':\n        toast.error(message, {\n          duration,\n          position,\n          onDismiss,\n          onAutoClose,\n        });\n        break;\n      case 'info':\n        toast.info(message, {\n          duration,\n          position,\n          onDismiss,\n          onAutoClose,\n        });\n        break;\n      case 'warning':\n        toast.warning(message, {\n          duration,\n          position,\n          onDismiss,\n          onAutoClose,\n        });\n        break;\n    }\n  }, [visibleToasts]);\n\n  // Convenience methods for specific toast types\n  const success = useCallback((message: string, toastId?: string, options?: ToastOptions) => {\n    showToast('success', message, toastId, options);\n  }, [showToast]);\n\n  const error = useCallback((message: string, toastId?: string, options?: ToastOptions) => {\n    showToast('error', message, toastId, options);\n  }, [showToast]);\n\n  const info = useCallback((message: string, toastId?: string, options?: ToastOptions) => {\n    showToast('info', message, toastId, options);\n  }, [showToast]);\n\n  const warning = useCallback((message: string, toastId?: string, options?: ToastOptions) => {\n    showToast('warning', message, toastId, options);\n  }, [showToast]);\n\n  return {\n    showToast,\n    success,\n    error,\n    info,\n    warning,\n    visibleToasts,\n  };\n} "
  },
  {
    "path": "app/frontend/src/index.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n  :root {\n    --background: 0 0% 94%;\n    --foreground: 0 0% 3.9%;\n    --card: 0 0% 100%;\n    --card-foreground: 0 0% 3.9%;\n    --popover: 0 0% 100%;\n    --popover-foreground: 0 0% 3.9%;\n    --primary: 0 0% 9%;\n    --primary-foreground: 0 0% 98%;\n    --secondary: 0 0% 96.1%;\n    --secondary-foreground: 0 0% 9%;\n    --muted: 0 0% 96.1%;\n    --muted-foreground: 0 0% 45.1%;\n    --accent: 0 0% 96.1%;\n    --accent-foreground: 0 0% 9%;\n    --destructive: 0 84.2% 60.2%;\n    --destructive-foreground: 0 0% 98%;\n    --border: 0 0% 85%;\n    --node-border: 0 0% 88%;\n    --node-border-hover: 0 0% 75%;\n    --node-border-selected: 0 0% 10%;\n    --status-border: 0 0% 85%;\n    --input: 0 0% 89.8%;\n    --ring: 0 0% 3.9%;\n    --radius: 0.5rem;\n    --sidebar-background: 0 0% 98%;\n    --panel-bg: 0 0% 100%;\n    --node-bg: 0 0% 100%;\n    \n    /* Hover colors for light theme */\n    --hover-background: rgb(156 163 175 / 0.3); /* gray-400/30 */\n    --hover-foreground: 0 0% 15%;\n    \n    /* Active/selected colors for light theme */\n    --active-background: rgb(229 231 235 / 0.8); /* gray-200/80 */\n    --active-foreground: 0 0% 10%;\n    \n    /* Ramp Grey Colors */\n    --ramp-grey-100: #f5f5f5;\n    --ramp-grey-200: #e6e6e6;\n    --ramp-grey-300: #d9d9d9;\n    --ramp-grey-400: #b3b3b3;\n    --ramp-grey-500: #757575;\n    --ramp-grey-600: #444444;\n    --ramp-grey-700: #383838;\n    --ramp-grey-800: #2c2c2c;\n    --ramp-grey-900: #1e1e1e;\n    --ramp-grey-1000: #111111;\n\n    /* Tab Colors - Light Theme */\n    --tab-active-text: #1a1a1a;\n    --tab-inactive-text: #666666;\n    --tab-hover-text: #1a1a1a;\n    --tab-background: #ffffff;\n    --tab-hover-background: #f0f0f0;\n    --tab-border: #e0e0e0;\n    --tab-accent: #007acc;\n    --tab-icon-active: #007acc;\n    --tab-icon-inactive: #666666;\n    --tab-close-hover: #e0e0e0;\n\n    font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;\n    line-height: 1.5;\n    font-weight: 400;\n    --chart-1: 12 76% 61%;\n    --chart-2: 173 58% 39%;\n    --chart-3: 197 37% 24%;\n    --chart-4: 43 74% 66%;\n    --chart-5: 27 87% 67%;\n    --sidebar-foreground: 240 5.3% 26.1%;\n    --sidebar-primary: 240 5.9% 10%;\n    --sidebar-primary-foreground: 0 0% 98%;\n    --sidebar-accent: 240 4.8% 95.9%;\n    --sidebar-accent-foreground: 240 5.9% 10%;\n    --sidebar-border: 220 13% 91%;\n    --sidebar-ring: 217.2 91.2% 59.8%;\n  }\n\n  .dark {\n    --background: 0 0% 3.9%;\n    --foreground: 0 0% 98%;\n    --card: 0 0% 3.9%;\n    --card-foreground: 0 0% 98%;\n    --popover: 0 0% 3.9%;\n    --popover-foreground: 0 0% 98%;\n    --primary: 0 0% 98%;\n    --primary-foreground: 0 0% 9%;\n    --secondary: 0 0% 14.9%;\n    --secondary-foreground: 0 0% 98%;\n    --muted: 0 0% 14.9%;\n    --muted-foreground: 0 0% 63.9%;\n    --accent: 0 0% 14.9%;\n    --accent-foreground: 0 0% 98%;\n    --destructive: 0 62.8% 30.6%;\n    --destructive-foreground: 0 0% 98%;\n    --border: 0 0% 25%;\n    --node-border: 0 0% 25%;\n    --node-border-hover: 0 0% 40%;\n    --node-border-selected: 0 0% 90%;\n    --status-border: 0 0% 25%;\n    --input: 0 0% 14.9%;\n    --ring: 0 0% 83.1%;\n    --panel-bg: 240 3% 11%;\n    --node-bg: 240 3% 11%;\n    --sidebar-background: 240 5.9% 16%;\n    \n    /* Hover colors for dark theme */\n    --hover-background: rgb(55 65 81 / 0.5); /* gray-700/50 */\n    --hover-foreground: 0 0% 95%;\n    \n    /* Active/selected colors for dark theme */\n    --active-background: rgb(55 65 81 / 0.8); /* gray-700/80 */\n    --active-foreground: 0 0% 98%;\n\n    /* Tab Colors - Dark Theme */\n    --tab-active-text: #cccccc;\n    --tab-inactive-text: #969696;\n    --tab-hover-text: #cccccc;\n    --tab-background: #1e1e1e;\n    --tab-hover-background: #1e1e1e;\n    --tab-border: #333333;\n    --tab-accent: #007acc;\n    --tab-icon-active: #007acc;\n    --tab-icon-inactive: #858585;\n    --tab-close-hover: #464647;\n\n    --chart-1: 220 70% 50%;\n    --chart-2: 160 60% 45%;\n    --chart-3: 30 80% 55%;\n    --chart-4: 280 65% 60%;\n    --chart-5: 340 75% 55%;\n    --sidebar-foreground: 240 4.8% 95.9%;\n    --sidebar-primary: 224.3 76.3% 48%;\n    --sidebar-primary-foreground: 0 0% 100%;\n    --sidebar-accent: 240 3.7% 15.9%;\n    --sidebar-accent-foreground: 240 4.8% 95.9%;\n    --sidebar-border: 240 3.7% 15.9%;\n    --sidebar-ring: 217.2 91.2% 59.8%;\n  }\n}\n\n@layer utilities {\n  .bg-node {\n    background-color: hsl(var(--node-bg));\n  }\n  \n  .border-status {\n    border-color: hsl(var(--status-border));\n  }\n  \n  .border-node {\n    border-color: hsl(var(--node-border));\n  }\n  \n  .border-node-hover {\n    border-color: hsl(var(--node-border-hover));\n  }\n  \n  .border-node-selected {\n    border-color: hsl(var(--node-border-selected));\n  }\n  \n  .hover-bg {\n    @apply hover:bg-[var(--hover-background)];\n  }\n  \n  .hover-text {\n    @apply hover:text-[hsl(var(--hover-foreground))];\n  }\n  \n  .hover-item {\n    @apply hover:bg-[var(--hover-background)] hover:text-[hsl(var(--hover-foreground))];\n  }\n  \n  .active-bg {\n    @apply bg-[var(--active-background)];\n  }\n  \n  .active-text {\n    @apply text-[hsl(var(--active-foreground))];\n  }\n  \n  .active-item {\n    @apply bg-[var(--active-background)] text-[hsl(var(--active-foreground))];\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border;\n  }\n\n  body {\n    @apply bg-background text-foreground;\n  }\n\n  html,\n  body,\n  #root {\n    height: 100%;\n    margin: 0;\n  }\n\n  @font-face {\n    font-family: \"geist\";\n    font-style: normal;\n    font-weight: 100 900;\n    src: url(/fonts/geist.woff2) format(\"woff2\");\n  }\n\n  @font-face {\n    font-family: \"geist-mono\";\n    font-style: normal;\n    font-weight: 100 900;\n    src: url(/fonts/geist-mono.woff2) format(\"woff2\");\n  }\n}\n\n.skeleton {\n  * {\n    pointer-events: none !important;\n  }\n\n  *[class^=\"text-\"] {\n    color: transparent;\n    @apply rounded-md bg-foreground/20 select-none animate-pulse;\n  }\n\n  .skeleton-bg {\n    @apply bg-foreground/10;\n  }\n\n  .skeleton-div {\n    @apply bg-foreground/20 animate-pulse;\n  }\n}\n\n.ProseMirror {\n  outline: none;\n}\n\n.cm-editor,\n.cm-gutters {\n  @apply bg-background dark:bg-zinc-800 outline-none selection:bg-zinc-900 !important;\n}\n\n.ͼo.cm-focused>.cm-scroller>.cm-selectionLayer .cm-selectionBackground,\n.ͼo.cm-selectionBackground,\n.ͼo.cm-content::selection {\n  @apply bg-zinc-200 dark:bg-zinc-900 !important;\n}\n\n.cm-activeLine,\n.cm-activeLineGutter {\n  @apply bg-transparent !important;\n}\n\n.cm-activeLine {\n  @apply rounded-r-sm !important;\n}\n\n.cm-lineNumbers {\n  @apply min-w-7;\n}\n\n.cm-foldGutter {\n  @apply min-w-3;\n}\n\n.cm-lineNumbers .cm-activeLineGutter {\n  @apply rounded-l-sm !important;\n}\n\n.suggestion-highlight {\n  @apply bg-blue-200 hover:bg-blue-300 dark:hover:bg-blue-400/50 dark:text-blue-50 dark:bg-blue-500/40;\n}\n\n/* Animated border for in-progress agent nodes */\n.node-in-progress {\n  position: relative;\n  border: none !important;\n}\n\n.animated-border-container {\n  position: absolute;\n  top: -1px;\n  left: -1px;\n  right: -1px;\n  bottom: -1px;\n  border-radius: 0.5rem;\n  overflow: hidden;\n  z-index: 0;\n  pointer-events: none;\n}\n\n.animated-border-container::after {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  border-radius: 0.5rem;\n  background: linear-gradient(90deg, \n    #2383F4, #5e61e7, #8F00FF, #7831d4, #2383F4\n  );\n  background-size: 200% 100%;\n  animation: gradientFlow 3s linear infinite;\n  -webkit-mask: \n    linear-gradient(#fff 0 0) content-box, \n    linear-gradient(#fff 0 0);\n  -webkit-mask-composite: xor;\n  mask-composite: exclude;\n  padding: 3px;\n}\n\n@keyframes gradientFlow {\n  0% {\n    background-position: 0% 0%;\n  }\n  100% {\n    background-position: 200% 0%;\n  }\n}\n\n/* Gradient animation for in-progress elements */\n.gradient-animation {\n  background: linear-gradient(90deg, \n    #2383F4, #5e61e7, #8F00FF, #7831d4, #2383F4\n  );\n  background-size: 200% 100%;\n  animation: gradientFlow 3s linear infinite;\n}\n\n/* Gradient text animation */\n.gradient-text {\n  background: linear-gradient(90deg, \n    #2383F4, #5e61e7, #8F00FF, #7831d4, #2383F4\n  );\n  background-size: 200% 100%;\n  animation: gradientFlow 3s linear infinite;\n  background-clip: text;\n  -webkit-background-clip: text;\n  color: transparent;\n}\n\n/* Remove white lines between ReactFlow Controls buttons */\n.react-flow__controls .react-flow__controls-button {\n  border-right: none !important;\n}\n\n.react-flow__controls .react-flow__controls-button + .react-flow__controls-button {\n  border-left: none !important;\n}"
  },
  {
    "path": "app/frontend/src/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n\n// Platform detection utility\nexport function isMac(): boolean {\n  return typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;\n}\n\n// Keyboard shortcut formatting utility\nexport function formatKeyboardShortcut(key: string): string {\n  const modifierKey = isMac() ? '⌘' : 'Ctrl';\n  return `${modifierKey}${key.toUpperCase()}`;\n}\n\n// Provider color utility for consistent styling across components\nexport function getProviderColor(provider: string): string {\n  return 'bg-gray-600/20 text-primary border-gray-600/40';\n  // switch (provider.toLowerCase()) {\n  //   case 'anthropic':\n  //     return 'bg-orange-600/20 text-orange-300 border-orange-600/40';\n  //   case 'google':\n  //     return 'bg-green-600/20 text-green-300 border-green-600/40';\n  //   case 'groq':\n  //     return 'bg-red-600/20 text-red-300 border-red-600/40';\n  //   case 'deepseek':\n  //     return 'bg-blue-600/20 text-blue-300 border-blue-600/40';\n  //   case 'openai':\n  //     return 'bg-gray-900/60 text-gray-200 border-gray-700/60';\n  //   case 'ollama':\n  //     return 'bg-white/90 text-gray-800 border-gray-300';\n  //   default:\n  //     return 'bg-gray-600/20 text-gray-300 border-gray-600/40';\n  // }\n}\n"
  },
  {
    "path": "app/frontend/src/main.tsx",
    "content": "import React from 'react';\nimport ReactDOM from 'react-dom/client';\n\nimport App from './App';\nimport { NodeProvider } from './contexts/node-context';\nimport { ThemeProvider } from './providers/theme-provider';\n\nimport './index.css';\n\nReactDOM.createRoot(document.getElementById('root')!).render(\n  <React.StrictMode>\n    <ThemeProvider>\n      <NodeProvider>\n        <App />\n      </NodeProvider>\n    </ThemeProvider>\n  </React.StrictMode>\n);\n"
  },
  {
    "path": "app/frontend/src/nodes/components/agent-node.tsx",
    "content": "import { type NodeProps } from '@xyflow/react';\nimport { Bot } from 'lucide-react';\nimport { useEffect, useState } from 'react';\n\nimport { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';\nimport { CardContent } from '@/components/ui/card';\nimport { ModelSelector } from '@/components/ui/llm-selector';\nimport { useFlowContext } from '@/contexts/flow-context';\nimport { useNodeContext } from '@/contexts/node-context';\nimport { getModels, LanguageModel } from '@/data/models';\nimport { useNodeState } from '@/hooks/use-node-state';\nimport { cn } from '@/lib/utils';\nimport { type AgentNode } from '../types';\nimport { getStatusColor } from '../utils';\nimport { AgentOutputDialog } from './agent-output-dialog';\nimport { NodeShell } from './node-shell';\n\nexport function AgentNode({\n  data,\n  selected,\n  id,\n  isConnectable,\n}: NodeProps<AgentNode>) {\n  const { currentFlowId } = useFlowContext();\n  const { getAgentNodeDataForFlow, setAgentModel, getAgentModel } = useNodeContext();\n  \n  // Get agent node data for the current flow\n  const agentNodeData = getAgentNodeDataForFlow(currentFlowId?.toString() || null);\n  const nodeData = agentNodeData[id] || { \n    status: 'IDLE', \n    ticker: null, \n    message: '', \n    messages: [],\n    lastUpdated: 0\n  };\n  const status = nodeData.status;\n  const isInProgress = status === 'IN_PROGRESS';\n  const [isDialogOpen, setIsDialogOpen] = useState(false);\n  \n  // Use persistent state hooks\n  const [availableModels, setAvailableModels] = useNodeState<LanguageModel[]>(id, 'availableModels', []);\n  const [selectedModel, setSelectedModel] = useNodeState<LanguageModel | null>(id, 'selectedModel', null);\n\n  // Load models on mount\n  useEffect(() => {\n    const loadModels = async () => {\n      try {\n        const models = await getModels();\n        setAvailableModels(models);\n      } catch (error) {\n        console.error('Failed to load models:', error);\n        // Keep empty array as fallback\n      }\n    };\n    \n    loadModels();\n  }, [setAvailableModels]);\n\n  // Update the node context when the model changes\n  useEffect(() => {\n    const flowId = currentFlowId?.toString() || null;\n    const currentContextModel = getAgentModel(flowId, id);\n    if (selectedModel !== currentContextModel) {\n      setAgentModel(flowId, id, selectedModel);\n    }\n  }, [selectedModel, id, currentFlowId, setAgentModel, getAgentModel]);\n\n  const handleModelChange = (model: LanguageModel | null) => {\n    setSelectedModel(model);\n  };\n\n  const handleUseGlobalModel = () => {\n    setSelectedModel(null);\n  };\n\n  return (\n    <NodeShell\n      id={id}\n      selected={selected}\n      isConnectable={isConnectable}\n      icon={<Bot className=\"h-5 w-5\" />}\n      iconColor={getStatusColor(status)}\n      name={data.name || \"Agent\"}\n      description={data.description}\n      status={status}\n    >\n      <CardContent className=\"p-0\">\n        <div className=\"border-t border-border p-3\">\n          <div className=\"flex flex-col gap-2\">\n            <div className=\"text-subtitle text-primary flex items-center gap-1\">\n              Status\n            </div>\n\n            <div className={cn(\n              \"text-foreground text-xs rounded p-2 border border-status\",\n              isInProgress ? \"gradient-animation\" : getStatusColor(status)\n            )}>\n              <span className=\"capitalize\">{status.toLowerCase().replace(/_/g, ' ')}</span>\n            </div>\n            \n            {nodeData.message && (\n              <div className=\"text-foreground text-subtitle\">\n                {nodeData.message !== \"Done\" && nodeData.message}\n                {nodeData.ticker && <span className=\"ml-1\">({nodeData.ticker})</span>}\n              </div>\n            )}\n            <Accordion type=\"single\" collapsible>\n              <AccordionItem value=\"advanced\" className=\"border-none\">\n                <AccordionTrigger className=\"!text-subtitle text-primary\">\n                  Advanced\n                </AccordionTrigger>\n                <AccordionContent className=\"pt-2\">\n                  <div className=\"flex flex-col gap-2\">\n                    <div className=\"text-subtitle text-primary flex items-center gap-1\">\n                      Model\n                    </div>\n                    <ModelSelector\n                      models={availableModels}\n                      value={selectedModel?.model_name || \"\"}\n                      onChange={handleModelChange}\n                      placeholder=\"Auto\"\n                    />\n                    {selectedModel && (\n                      <button\n                        onClick={handleUseGlobalModel}\n                        className=\"text-subtitle text-primary hover:text-foreground transition-colors text-left\"\n                      >\n                        Reset to Auto\n                      </button>\n                    )}\n                  </div>\n                </AccordionContent>\n              </AccordionItem>\n            </Accordion>\n          </div>\n        </div>\n        <AgentOutputDialog\n          isOpen={isDialogOpen}\n          onOpenChange={setIsDialogOpen}\n          name={data.name || \"Agent\"}\n          nodeId={id}\n          flowId={currentFlowId?.toString() || null}\n        />\n      </CardContent>\n    </NodeShell>\n  );\n}\n"
  },
  {
    "path": "app/frontend/src/nodes/components/agent-output-dialog.tsx",
    "content": "import {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from '@/components/ui/dialog';\nimport { useNodeContext } from '@/contexts/node-context';\nimport { formatTimeFromTimestamp } from '@/utils/date-utils';\nimport { formatContent } from '@/utils/text-utils';\nimport { AlignJustify, Copy, Loader2 } from 'lucide-react';\nimport { useEffect, useRef, useState } from 'react';\nimport { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';\nimport { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';\n\ninterface AgentOutputDialogProps {\n  isOpen: boolean;\n  onOpenChange: (open: boolean) => void;\n  name: string;\n  nodeId: string;\n  flowId: string | null;\n}\n\nexport function AgentOutputDialog({ \n  isOpen, \n  onOpenChange, \n  name, \n  nodeId,\n  flowId\n}: AgentOutputDialogProps) {\n  const { getAgentNodeDataForFlow } = useNodeContext();\n  \n  // Use the passed flowId instead of getting it from flow context\n  const agentNodeData = getAgentNodeDataForFlow(flowId);\n  const nodeData = agentNodeData[nodeId] || { \n    status: 'IDLE', \n    ticker: null, \n    message: '', \n    messages: [],\n    lastUpdated: 0\n  };\n\n  const messages = nodeData.messages || [];\n  const nodeStatus = nodeData.status;\n  \n  const [copySuccess, setCopySuccess] = useState(false);\n  const [selectedTicker, setSelectedTicker] = useState<string | null>(null);\n  const initialFocusRef = useRef<HTMLDivElement>(null);\n\n  // Collect all analysis from all messages into a single analysis dictionary\n  const allAnalysis = messages\n    .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) // Sort by timestamp\n    .reduce<Record<string, string>>((acc, msg) => {\n      // Add analysis from this message to our accumulated analysis\n      if (msg.analysis && Object.keys(msg.analysis).length > 0) {\n        // Filter out null values before adding to our accumulated decisions\n        const validDecisions = Object.entries(msg.analysis)\n          .filter(([_, value]) => value !== null && value !== undefined)\n          .reduce((obj, [key, value]) => {\n            obj[key] = value;\n            return obj;\n          }, {} as Record<string, string>);\n        \n        if (Object.keys(validDecisions).length > 0) {\n          // Combine with accumulated decisions, newer messages overwrite older ones for the same ticker\n          return { ...acc, ...validDecisions };\n        }\n      }\n      return acc;\n    }, {});\n\n  // Get all unique tickers that have decisions\n  const tickersWithDecisions = Object.keys(allAnalysis);\n\n  // Reset selected ticker when node changes\n  useEffect(() => {\n    setSelectedTicker(null);\n  }, [nodeId]);\n\n  // If no ticker is selected but we have decisions, select the first one\n  useEffect(() => {\n    if (tickersWithDecisions.length > 0 && (!selectedTicker || !tickersWithDecisions.includes(selectedTicker))) {\n      setSelectedTicker(tickersWithDecisions[0]);\n    }\n  }, [tickersWithDecisions, selectedTicker]);\n\n  // Get the selected decision text\n  const selectedDecision = selectedTicker && allAnalysis[selectedTicker] ? allAnalysis[selectedTicker] : null;\n\n  const copyToClipboard = () => {\n    if (selectedDecision) {\n      navigator.clipboard.writeText(selectedDecision)\n        .then(() => {\n          setCopySuccess(true);\n          setTimeout(() => setCopySuccess(false), 2000);\n        })\n        .catch(err => {\n          console.error('Failed to copy text: ', err);\n        });\n    }\n  };\n\n  return (\n    <Dialog \n      open={isOpen} \n      onOpenChange={onOpenChange}\n      defaultOpen={false}\n      modal={true}\n    >\n      <DialogTrigger asChild>\n        <div className=\"border-t border-border p-3 flex justify-end items-center cursor-pointer hover:bg-accent/50\" onClick={() => onOpenChange(true)}>\n          <div className=\"flex items-center gap-1\">\n            <div className=\"text-subtitle text-muted-foreground\">Output</div>\n            <AlignJustify className=\"h-3.5 w-3.5 text-muted-foreground\" />\n          </div>\n        </div>\n      </DialogTrigger>\n      <DialogContent \n        className=\"sm:max-w-[900px]\" \n        autoFocus={false} \n        onOpenAutoFocus={(e) => e.preventDefault()}\n      >\n        <DialogHeader>\n          <DialogTitle>{name}</DialogTitle>\n        </DialogHeader>\n        \n        <div className=\"grid grid-cols-2 gap-6 pt-4\" ref={initialFocusRef} tabIndex={-1}>\n          {/* Activity Log Section */}\n          <div>\n            <h3 className=\"font-medium mb-3 text-primary\">Log</h3>\n            <div className=\"h-[400px] overflow-y-auto border border-border rounded-lg p-3\">\n              {messages.length > 0 ? (\n                <div className=\"p-3 space-y-3\">\n                  {messages\n                    .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) // Sort newest first for log\n                    .map((msg, idx) => (\n                    <div key={idx} className=\"border-l-2 border-primary pl-3 text-sm\">\n                      <div className=\"text-foreground\">\n                        {msg.ticker && <span>[{msg.ticker}] </span>}\n                        {msg.message}\n                      </div>\n                      <div className=\"text-muted-foreground\">\n                        {formatTimeFromTimestamp(msg.timestamp)}\n                      </div>\n                    </div>\n                  ))}\n                </div>\n              ) : (\n                <div className=\"flex items-center justify-center h-full text-muted-foreground\">\n                  No activity available\n                </div>\n              )}\n            </div>\n          </div>\n          \n          {/* Analysis Section */}\n          <div>\n            <div className=\"flex justify-between items-center mb-3\">\n              <h3 className=\"font-medium text-primary\">Analysis</h3>\n              <div className=\"flex items-center gap-2\">\n                {/* Ticker selector */}\n                {tickersWithDecisions.length > 0 && (\n                  <div className=\"flex items-center gap-1\">\n                    <span className=\"text-xs text-muted-foreground font-medium\">Ticker:</span>\n                    <select \n                      className=\"text-xs p-1 rounded bg-background border border-border cursor-pointer\"\n                      value={selectedTicker || ''}\n                      onChange={(e) => setSelectedTicker(e.target.value)}\n                      autoFocus={false}\n                    >\n                      {tickersWithDecisions.map((ticker) => (\n                        <option key={ticker} value={ticker}>\n                          {ticker}\n                        </option>\n                      ))}\n                    </select>\n                  </div>\n                )}\n              </div>\n            </div>\n            <div className=\"h-[400px] overflow-y-auto border border-border rounded-lg p-3\">\n              {tickersWithDecisions.length > 0 ? (\n                <div className=\"p-3 rounded-lg text-sm leading-relaxed\">\n                  {selectedTicker && (\n                    <div className=\"mb-3 flex justify-between items-center\">\n                      <div className=\" text-muted-foreground font-medium\">Summary for {selectedTicker}</div>\n                      {selectedDecision && (\n                        <button \n                          onClick={copyToClipboard}\n                          className=\"flex items-center gap-1.5 text-xs p-1.5 rounded hover:bg-accent transition-colors text-muted-foreground\"\n                          title=\"Copy to clipboard\"\n                        >\n                          <Copy className=\"h-3.5 w-3.5 \" />\n                          <span className=\"font-medium\">{copySuccess ? 'Copied!' : 'Copy'}</span>\n                        </button>\n                      )}\n                    </div>\n                  )}\n                  {selectedDecision ? (\n                    (() => {\n                      const { isJson, formattedContent } = formatContent(selectedDecision);\n                      \n                      if (isJson) {\n                        // Use react-syntax-highlighter for better JSON rendering\n                        return (\n                          <div className=\"overflow-auto rounded-md text-xs\">\n                            <SyntaxHighlighter\n                              language=\"json\"\n                              style={vscDarkPlus}\n                              customStyle={{\n                                margin: 0,\n                                padding: '0.75rem',\n                                fontSize: '0.875rem',\n                                lineHeight: 1.5,\n                                whiteSpace: 'pre-wrap',\n                                wordWrap: 'break-word',\n                                overflowWrap: 'break-word',\n                              }}\n                              showLineNumbers={false}\n                              wrapLines={true}\n                              wrapLongLines={true}\n                            >\n                              {formattedContent as string}\n                            </SyntaxHighlighter>\n                          </div>\n                        );\n                      } else {\n                        // Display as regular text paragraphs\n                        return (\n                          (formattedContent as string[]).map((paragraph, idx) => (\n                            <p key={idx} className=\"mb-3 last:mb-0\">{paragraph}</p>\n                          ))\n                        );\n                      }\n                    })()\n                  ) : nodeStatus === 'IN_PROGRESS' ? (\n                    <div className=\"flex items-center justify-center h-full text-muted-foreground\">\n                      <Loader2 className=\"h-5 w-5 animate-spin mr-2\" />\n                      Analysis in progress...\n                    </div>\n                  ) : (\n                    <div className=\"flex items-center justify-center h-full text-muted-foreground\">\n                      No analysis available for {selectedTicker}\n                    </div>\n                  )}\n                </div>\n              ) : nodeStatus === 'IN_PROGRESS' ? (\n                <div className=\"flex items-center justify-center h-full text-muted-foreground\">\n                  <Loader2 className=\"h-5 w-5 animate-spin mr-2\" />\n                  Analysis in progress...\n                </div>\n              ) : nodeStatus === 'COMPLETE' ? (\n                <div className=\"flex items-center justify-center h-full text-muted-foreground\">\n                  Analysis completed with no results\n                </div>\n              ) : nodeStatus === 'ERROR' ? (\n                <div className=\"flex items-center justify-center h-full text-muted-foreground\">\n                  Analysis failed\n                </div>\n              ) : (\n                <div className=\"flex items-center justify-center h-full text-muted-foreground\">\n                  No analysis available\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n} "
  },
  {
    "path": "app/frontend/src/nodes/components/investment-report-dialog.tsx",
    "content": "import {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from '@/components/ui/accordion';\nimport { Badge } from '@/components/ui/badge';\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from '@/components/ui/card';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog';\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from '@/components/ui/table';\nimport { extractBaseAgentKey } from '@/data/node-mappings';\nimport { createAgentDisplayNames } from '@/utils/text-utils';\nimport { ArrowDown, ArrowUp, Minus } from 'lucide-react';\nimport { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';\nimport { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';\n\ninterface InvestmentReportDialogProps {\n  isOpen: boolean;\n  onOpenChange: (open: boolean) => void;\n  outputNodeData: any;\n  connectedAgentIds: Set<string>;\n}\n\ntype ActionType = 'long' | 'short' | 'hold';\n\nexport function InvestmentReportDialog({\n  isOpen,\n  onOpenChange,\n  outputNodeData,\n  connectedAgentIds,\n}: InvestmentReportDialogProps) {\n  // Check if this is a backtest result and return early if it is\n  // Backtest results should be displayed in the backtest output tab, not in the investment report dialog\n  if (outputNodeData?.decisions?.backtest?.type === 'backtest_complete') {\n    return null;\n  }\n\n  // Return early if no output data\n  if (!outputNodeData || !outputNodeData.decisions) {\n    return null;\n  }\n\n  const getActionIcon = (action: ActionType) => {\n    switch (action) {\n      case 'long':\n        return <ArrowUp className=\"h-4 w-4 text-green-500\" />;\n      case 'short':\n        return <ArrowDown className=\"h-4 w-4 text-red-500\" />;\n      case 'hold':\n        return <Minus className=\"h-4 w-4 text-yellow-500\" />;\n      default:\n        return null;\n    }\n  };\n\n  const getSignalBadge = (signal: string) => {\n    const variant = signal === 'bullish' ? 'success' :\n                   signal === 'bearish' ? 'destructive' : 'outline';\n\n    return (\n      <Badge variant={variant as any}>\n        {signal}\n      </Badge>\n    );\n  };\n\n  const getConfidenceBadge = (confidence: number) => {\n    let variant = 'outline';\n    if (confidence >= 50) variant = 'success';\n    else if (confidence >= 0) variant = 'warning';\n    else variant = 'outline';\n    const rounded = Number(confidence.toFixed(1));\n    return (\n      <Badge variant={variant as any}>\n        {rounded}%\n      </Badge>\n    );\n  };\n\n  // Extract unique tickers from the data\n  const tickers = Object.keys(outputNodeData.decisions || {});\n\n  // Use the unique node IDs directly since they're now stored as keys in analyst_signals\n  const connectedUniqueAgentIds = Array.from(connectedAgentIds);\n  const agents = Object.keys(outputNodeData.analyst_signals || {})\n    .filter(agent =>\n      extractBaseAgentKey(agent) !== 'risk_management_agent' && connectedUniqueAgentIds.includes(agent)\n    );\n\n  const agentDisplayNames = createAgentDisplayNames(agents);\n\n  return (\n    <Dialog open={isOpen} onOpenChange={onOpenChange}>\n      <DialogContent className=\"max-w-6xl max-h-[90vh] overflow-y-auto\">\n        <DialogHeader>\n          <DialogTitle className=\"text-xl font-bold\">Investment Report</DialogTitle>\n        </DialogHeader>\n\n        <div className=\"space-y-8 my-4\">\n          {/* Summary Section */}\n          <section>\n            <h2 className=\"text-lg font-semibold mb-4\">Summary</h2>\n            <Card>\n              <CardHeader className=\"pb-2\">\n                <CardDescription>\n                  Recommended trading actions based on analyst signals\n                </CardDescription>\n              </CardHeader>\n              <CardContent>\n                <Table>\n                  <TableHeader>\n                    <TableRow>\n                      <TableHead>Ticker</TableHead>\n                      <TableHead>Price</TableHead>\n                      <TableHead>Action</TableHead>\n                      <TableHead>Quantity</TableHead>\n                      <TableHead>Confidence</TableHead>\n                    </TableRow>\n                  </TableHeader>\n                  <TableBody>\n                    {tickers.map(ticker => {\n                      const decision = outputNodeData.decisions[ticker];\n                      const currentPrice = outputNodeData.current_prices?.[ticker] || 'N/A';\n                      return (\n                        <TableRow key={ticker}>\n                          <TableCell className=\"font-medium\">{ticker}</TableCell>\n                          <TableCell>${typeof currentPrice === 'number' ? currentPrice.toFixed(2) : currentPrice}</TableCell>\n                          <TableCell>\n                            <div className=\"flex items-center gap-2\">\n                              {getActionIcon(decision.action as ActionType)}\n                              <span className=\"capitalize\">{decision.action}</span>\n                            </div>\n                          </TableCell>\n                          <TableCell>{decision.quantity}</TableCell>\n                          <TableCell>{getConfidenceBadge(decision.confidence)}</TableCell>\n                        </TableRow>\n                      );\n                    })}\n                  </TableBody>\n                </Table>\n              </CardContent>\n            </Card>\n          </section>\n          {/* Analyst Signals Section */}\n          <section>\n            <h2 className=\"text-lg font-semibold mb-4\">Analyst Signals</h2>\n            <Accordion type=\"multiple\" className=\"w-full\">\n              {tickers.map(ticker => (\n                <AccordionItem key={ticker} value={ticker}>\n                  <AccordionTrigger className=\"text-base font-medium px-4 py-3 bg-muted/30 rounded-md hover:bg-muted/50\">\n                    <div className=\"flex items-center gap-2\">\n                      {ticker}\n                      <div className=\"flex items-center gap-1\">\n                        {getActionIcon(outputNodeData.decisions[ticker].action as ActionType)}\n                        <span className=\"text-sm font-normal text-muted-foreground\">\n                          {outputNodeData.decisions[ticker].action} {outputNodeData.decisions[ticker].quantity} shares\n                        </span>\n                      </div>\n                    </div>\n                  </AccordionTrigger>\n                  <AccordionContent className=\"pt-4 px-1\">\n                    <div className=\"space-y-4\">\n                      {/* Agent Signals */}\n                      <div className=\"grid grid-cols-1 gap-4\">\n                        {agents.map(agent => {\n                          const signal = outputNodeData.analyst_signals[agent]?.[ticker];\n                          if (!signal) return null;\n\n                          return (\n                            <Card key={agent} className=\"overflow-hidden\">\n                              <CardHeader className=\"bg-muted/50 pb-3\">\n                                <div className=\"flex items-center justify-between\">\n                                  <CardTitle className=\"text-base\">\n                                    {agentDisplayNames.get(agent) || agent}\n                                  </CardTitle>\n                                  <div className=\"flex items-center gap-2\">\n                                    {getSignalBadge(signal.signal)}\n                                    {getConfidenceBadge(signal.confidence)}\n                                  </div>\n                                </div>\n                              </CardHeader>\n                              <CardContent className=\"pt-3\">\n                                {typeof signal.reasoning === 'string' ? (\n                                  <p className=\"text-sm whitespace-pre-line\">\n                                    {signal.reasoning}\n                                  </p>\n                                ) : (\n                                  <div className=\"max-h-48 overflow-y-auto bg-muted/30\">\n                                    <SyntaxHighlighter\n                                      language=\"json\"\n                                      style={vscDarkPlus}\n                                      className=\"text-sm rounded-md\"\n                                      customStyle={{\n                                        fontSize: '0.875rem',\n                                        margin: 0,\n                                        padding: '12px',\n                                        backgroundColor: 'hsl(var(--muted))',\n                                      }}\n                                    >\n                                      {JSON.stringify(signal.reasoning, null, 2)}\n                                    </SyntaxHighlighter>\n                                  </div>\n                                )}\n                              </CardContent>\n                            </Card>\n                          );\n                        })}\n                      </div>\n                    </div>\n                  </AccordionContent>\n                </AccordionItem>\n              ))}\n            </Accordion>\n          </section>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}"
  },
  {
    "path": "app/frontend/src/nodes/components/investment-report-node.tsx",
    "content": "import { type NodeProps } from '@xyflow/react';\nimport { FileText } from 'lucide-react';\nimport { useState } from 'react';\n\nimport { CardContent } from '@/components/ui/card';\nimport { useFlowContext } from '@/contexts/flow-context';\nimport { useNodeContext } from '@/contexts/node-context';\nimport { useOutputNodeConnection } from '@/hooks/use-output-node-connection';\nimport { type InvestmentReportNode } from '../types';\nimport { InvestmentReportDialog } from './investment-report-dialog';\nimport { NodeShell } from './node-shell';\nimport { OutputNodeStatus } from './output-node-status';\n\nexport function InvestmentReportNode({\n  data,\n  selected,\n  id,\n  isConnectable,\n}: NodeProps<InvestmentReportNode>) {  \n  const { currentFlowId } = useFlowContext();\n  const { getOutputNodeDataForFlow } = useNodeContext();\n  \n  // Get output node data for the current flow\n  const flowId = currentFlowId?.toString() || null;\n  const outputNodeData = getOutputNodeDataForFlow(flowId);\n  \n  const [showOutput, setShowOutput] = useState(false);\n  \n  // Use the custom hook for connection logic\n  const { isProcessing, isAnyAgentRunning, isOutputAvailable, isConnected, connectedAgentIds } = useOutputNodeConnection(id);\n  const status = isProcessing || isAnyAgentRunning ? 'IN_PROGRESS' : 'IDLE';\n\n  const handleViewOutput = () => {\n    setShowOutput(true);\n  }\n\n  return (\n    <>\n      <NodeShell\n        id={id}\n        selected={selected}\n        isConnectable={isConnectable}\n        icon={<FileText className=\"h-5 w-5\" />}\n        name={data.name || \"Investment Report\"}\n        description={data.description}\n        hasRightHandle={false}\n        status={status}\n      >\n        <CardContent className=\"p-0\">\n          <div className=\"border-t border-border p-3\">\n            <div className=\"flex flex-col gap-2\">\n              <div className=\"text-subtitle text-muted-foreground flex items-center gap-1\">\n                Results\n              </div>\n              \n              <OutputNodeStatus\n                isProcessing={isProcessing}\n                isAnyAgentRunning={isAnyAgentRunning}\n                isOutputAvailable={isOutputAvailable}\n                isConnected={isConnected}\n                onViewOutput={handleViewOutput}\n              />\n            </div>\n          </div>\n        </CardContent>\n      </NodeShell>\n      <InvestmentReportDialog \n        isOpen={showOutput} \n        onOpenChange={setShowOutput} \n        outputNodeData={outputNodeData} \n        connectedAgentIds={connectedAgentIds}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "app/frontend/src/nodes/components/json-output-dialog.tsx",
    "content": "import { Copy, Download } from 'lucide-react';\nimport { useState } from 'react';\nimport { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';\nimport { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';\n\nimport { Button } from '@/components/ui/button';\nimport {\n    Dialog,\n    DialogContent,\n    DialogHeader,\n    DialogTitle,\n} from '@/components/ui/dialog';\n\ninterface JsonOutputDialogProps {\n  isOpen: boolean;\n  onOpenChange: (open: boolean) => void;\n  outputNodeData: any;\n  connectedAgentIds: Set<string>;\n}\n\nexport function JsonOutputDialog({ \n  isOpen, \n  onOpenChange, \n  outputNodeData,\n  connectedAgentIds\n}: JsonOutputDialogProps) {\n  const [copySuccess, setCopySuccess] = useState(false);\n  const [downloadSuccess, setDownloadSuccess] = useState(false);\n\n  if (!outputNodeData) return null;\n\n  // Convert React Flow node IDs to backend agent keys for filtering\n  const connectedBackendAgentKeys = Array.from(connectedAgentIds).map(nodeId => `${nodeId}_agent`);\n  \n  // Filter the outputNodeData to only include connected agents\n  const filteredOutputData = {\n    ...outputNodeData,\n    analyst_signals: Object.fromEntries(\n      Object.entries(outputNodeData.analyst_signals || {})\n        .filter(([agentId]) => \n          agentId === 'risk_management_agent' || connectedBackendAgentKeys.includes(agentId)\n        )\n    )\n  };\n\n  const jsonString = JSON.stringify(filteredOutputData, null, 2);\n\n  const copyToClipboard = () => {\n    navigator.clipboard.writeText(jsonString)\n      .then(() => {\n        setCopySuccess(true);\n        setTimeout(() => setCopySuccess(false), 2000);\n      })\n      .catch(err => {\n        console.error('Failed to copy text: ', err);\n      });\n  };\n\n  const downloadJson = () => {\n    try {\n      const blob = new Blob([jsonString], { type: 'application/json' });\n      const url = URL.createObjectURL(blob);\n      const link = document.createElement('a');\n      link.href = url;\n      link.download = `output-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;\n      document.body.appendChild(link);\n      link.click();\n      document.body.removeChild(link);\n      URL.revokeObjectURL(url);\n      \n      setDownloadSuccess(true);\n      setTimeout(() => setDownloadSuccess(false), 2000);\n    } catch (err) {\n      console.error('Failed to download JSON: ', err);\n    }\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={onOpenChange}>\n      <DialogContent className=\"max-w-5xl max-h-[90vh] overflow-hidden flex flex-col\">\n        <DialogHeader>\n          <DialogTitle className=\"text-xl font-bold flex items-center justify-between\">\n            JSON Output\n            <div className=\"flex items-center gap-2\">\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={copyToClipboard}\n                className=\"flex items-center gap-1.5\"\n              >\n                <Copy className=\"h-4 w-4\" />\n                <span className=\"font-medium\">{copySuccess ? 'Copied!' : 'Copy'}</span>\n              </Button>\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={downloadJson}\n                className=\"flex items-center gap-1.5\"\n              >\n                <Download className=\"h-4 w-4\" />\n                <span className=\"font-medium\">{downloadSuccess ? 'Downloaded!' : 'Download'}</span>\n              </Button>\n            </div>\n          </DialogTitle>\n        </DialogHeader>\n        \n        <div className=\"flex-1 min-h-0 my-4 overflow-auto rounded-md border border-border bg-muted/30\">\n          <SyntaxHighlighter\n            language=\"json\"\n            style={vscDarkPlus}\n            customStyle={{\n              margin: 0,\n              padding: '0.75rem',\n              fontSize: '0.875rem',\n              lineHeight: 1.5,\n              whiteSpace: 'pre-wrap',\n              wordWrap: 'break-word',\n              overflowWrap: 'break-word',\n            }}\n            wrapLines={true}\n            wrapLongLines={true}\n          >\n            {jsonString}\n          </SyntaxHighlighter>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n} "
  },
  {
    "path": "app/frontend/src/nodes/components/json-output-node.tsx",
    "content": "import { type NodeProps } from '@xyflow/react';\nimport { FileJson } from 'lucide-react';\nimport { useEffect, useState } from 'react';\n\nimport { CardContent } from '@/components/ui/card';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport { useFlowContext } from '@/contexts/flow-context';\nimport { useNodeContext } from '@/contexts/node-context';\nimport { useOutputNodeConnection } from '@/hooks/use-output-node-connection';\nimport { api } from '@/services/api';\nimport { type JsonOutputNode } from '../types';\nimport { JsonOutputDialog } from './json-output-dialog';\nimport { NodeShell } from './node-shell';\nimport { OutputNodeStatus } from './output-node-status';\n\nexport function JsonOutputNode({\n  data,\n  selected,\n  id,\n  isConnectable,\n}: NodeProps<JsonOutputNode>) {  \n  const { currentFlowId } = useFlowContext();\n  const { getOutputNodeDataForFlow } = useNodeContext();\n  \n  // Get output node data for the current flow\n  const flowId = currentFlowId?.toString() || null;\n  const outputNodeData = getOutputNodeDataForFlow(flowId);\n  \n  const [showOutput, setShowOutput] = useState(false);\n  const [saveToFile, setSaveToFile] = useState(false);\n  \n  // Use the custom hook for connection logic\n  const { isProcessing, isAnyAgentRunning, isOutputAvailable, isConnected, connectedAgentIds } = useOutputNodeConnection(id);\n  const status = isProcessing || isAnyAgentRunning ? 'IN_PROGRESS' : 'IDLE';\n\n  // Save to file when output is available and saveToFile is enabled\n  useEffect(() => {\n    if (saveToFile && isOutputAvailable && outputNodeData) {\n      saveJsonFile(outputNodeData);\n    }\n  }, [saveToFile, isOutputAvailable, outputNodeData]);\n\n  const saveJsonFile = async (data: any) => {\n    try {\n      // Generate filename with current date and time in user's timezone\n      const now = new Date();\n      const year = now.getFullYear();\n      const month = String(now.getMonth() + 1).padStart(2, '0');\n      const day = String(now.getDate()).padStart(2, '0');\n      const hours = String(now.getHours()).padStart(2, '0');\n      const minutes = String(now.getMinutes()).padStart(2, '0');\n      const seconds = String(now.getSeconds()).padStart(2, '0');\n      const dateTime = `${year}-${month}-${day}_${hours}h${minutes}m${seconds}s`;\n      const filename = `run_${dateTime}.json`;\n\n      // Save file via API\n      await api.saveJsonFile(filename, data);\n      \n      console.log(`JSON output saved to outputs/${filename}`);\n    } catch (error) {\n      console.error('Failed to save JSON output:', error);\n    }\n  };\n\n  const handleViewOutput = () => {\n    setShowOutput(true);\n  }\n\n  return (\n    <>\n      <NodeShell\n        id={id}\n        selected={selected}\n        isConnectable={isConnectable}\n        icon={<FileJson className=\"h-5 w-5\" />}\n        name={data.name || \"JSON Output\"}\n        description={data.description}\n        hasRightHandle={false}\n        status={status}\n      >\n        <CardContent className=\"p-0\">\n          <div className=\"border-t border-border p-3\">\n            <div className=\"flex flex-col gap-2\">\n              <div className=\"text-subtitle text-muted-foreground flex items-center gap-1\">\n                Results\n              </div>\n              \n              <OutputNodeStatus\n                isProcessing={isProcessing}\n                isAnyAgentRunning={isAnyAgentRunning}\n                isOutputAvailable={isOutputAvailable}\n                isConnected={isConnected}\n                onViewOutput={handleViewOutput}\n              />\n              \n              <div className=\"flex items-center space-x-2 mt-2\">\n                <Checkbox\n                  id=\"save-to-file\"\n                  checked={saveToFile}\n                  onCheckedChange={(checked: boolean) => setSaveToFile(checked)}\n                />\n                <label\n                  htmlFor=\"save-to-file\"\n                  className=\"text-subtitle text-muted-foreground cursor-pointer\"\n                >\n                  Save to File\n                </label>\n              </div>\n            </div>\n          </div>\n        </CardContent>\n      </NodeShell>\n\n      <JsonOutputDialog \n        isOpen={showOutput} \n        onOpenChange={setShowOutput} \n        outputNodeData={outputNodeData} \n        connectedAgentIds={connectedAgentIds}\n      />\n    </>\n  );\n} "
  },
  {
    "path": "app/frontend/src/nodes/components/node-shell.tsx",
    "content": "import { Card, CardHeader } from '@/components/ui/card';\nimport { cn } from '@/lib/utils';\nimport { Handle, Position } from '@xyflow/react';\nimport { ReactNode } from 'react';\n\nexport interface NodeShellProps {\n  id: string;\n  selected?: boolean;\n  isConnectable?: boolean;\n  icon: ReactNode;\n  iconColor?: string;\n  name: string;\n  description?: string;\n  children: ReactNode;\n  hasLeftHandle?: boolean;\n  hasRightHandle?: boolean;\n  status?: string;\n  width?: string;\n}\n\nexport function NodeShell({\n  id,\n  selected,\n  isConnectable,\n  icon,\n  iconColor,\n  name,\n  description,\n  children,\n  hasLeftHandle = true,\n  hasRightHandle = true,\n  status = 'IDLE',\n  width = 'w-64',\n}: NodeShellProps) {\n  const isInProgress = status === 'IN_PROGRESS';\n  return (\n    <div\n      className={cn(\n        \"react-flow__node-default relative select-none cursor-pointer p-0 rounded-lg border border-node transition-all duration-200\",\n        width,\n        !selected && \"hover:border-node-hover hover:shadow-lg\",\n        selected && \"border-node-selected shadow-xl\",\n        isInProgress && \"node-in-progress\"\n      )}\n      data-id={id}\n      data-nodeid={id}\n    >\n      {isInProgress && (\n        <div className=\"animated-border-container\"></div>\n      )}\n      {hasLeftHandle && (\n        <Handle\n          type=\"target\"\n          position={Position.Left}\n          className=\"w-3 h-3 rounded-full bg-gray-500 border-2 border-card absolute left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 transition-all duration-200 hover:bg-gray-500 hover:w-4 hover:h-4 hover:shadow-[0_0_5px_2px_rgba(59,130,246,0.3)]\"\n          isConnectable={isConnectable}\n        />\n      )}\n      <div className=\"overflow-hidden rounded-lg\">\n        <Card className=\"bg-node rounded-none overflow-hidden border-none\">\n          <CardHeader className=\"p-3 bg-node flex flex-row items-center space-x-2 rounded-t-sm\">\n            <div className={cn(\n              \"flex items-center justify-center h-8 w-8 rounded-lg text-primary\",\n              isInProgress ? \"gradient-animation\" : iconColor\n            )}>\n              {icon}\n            </div>\n            <div className=\"text-title font-semibold text-primary\">\n              {name || \"Custom Component\"}\n            </div>\n          </CardHeader>\n          {description && (\n            <div className=\"px-3 py-2 text-subtitle text-primary text-left\">\n              {description}\n            </div>\n          )}\n          {children}\n        </Card>\n      </div>\n      {hasRightHandle && (\n        <Handle\n          type=\"source\"\n          position={Position.Right}\n          className=\"w-3 h-3 rounded-full bg-gray-500 border-2 border-card absolute right-0 top-1/2 translate-x-1/2 -translate-y-1/2 z-10 transition-all duration-200 hover:bg-gray-500 hover:w-4 hover:h-4 hover:shadow-[0_0_5px_2px_rgba(59,130,246,0.3)]\"\n          isConnectable={isConnectable}\n        />\n      )}\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/nodes/components/output-node-status.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { getStatusColor } from '../utils';\n\ninterface OutputNodeStatusProps {\n  isProcessing: boolean;\n  isAnyAgentRunning: boolean;\n  isOutputAvailable: boolean;\n  isConnected: boolean;\n  onViewOutput?: () => void;\n  processingText?: string;\n  completingText?: string;\n  availableText?: string;\n  idleText?: string;\n}\n\nexport function OutputNodeStatus({\n  isProcessing,\n  isAnyAgentRunning,\n  isOutputAvailable,\n  isConnected,\n  onViewOutput,\n  processingText = \"In Progress\",\n  completingText = \"Completing\",\n  availableText = \"View Output\",\n  idleText = \"Idle\"\n}: OutputNodeStatusProps) {\n  // Determine the current state and appropriate styling\n  const isLocallyProcessing = isProcessing; // Connected agents are running\n  const isGloballyProcessing = !isProcessing && isAnyAgentRunning; // Other agents running\n  const hasGradientAnimation = isLocallyProcessing || isGloballyProcessing;\n  const isClickable = isOutputAvailable && !isLocallyProcessing && !isGloballyProcessing;\n  \n  // Determine display text based on current state\n  let displayText: string;\n  if (isLocallyProcessing) {\n    displayText = processingText; // \"In Progress\"\n  } else if (isGloballyProcessing) {\n    displayText = completingText; // \"Completing\"\n  } else if (isOutputAvailable) {\n    displayText = availableText; // \"View Output\"\n  } else {\n    displayText = idleText; // \"Idle\"\n  }\n\n  const status = hasGradientAnimation ? 'IN_PROGRESS' : 'IDLE';\n\n  return (\n    <div \n      className={cn(\n        \"text-foreground text-xs rounded p-2 border border-status transition-colors\",\n        hasGradientAnimation ? \"gradient-animation\" : getStatusColor(status),\n        isClickable && \"bg-primary text-primary-foreground cursor-pointer hover:bg-primary/80\",\n        !isOutputAvailable && !hasGradientAnimation && \"opacity-50\"\n      )}\n      onClick={isClickable ? onViewOutput : undefined}\n    >\n      {hasGradientAnimation ? (\n        <div className=\"flex items-center gap-2 justify-center\">\n          <span className=\"capitalize\">{displayText}</span>\n        </div>\n      ) : (\n        <span className=\"capitalize\">{displayText}</span>\n      )}\n    </div>\n  );\n} "
  },
  {
    "path": "app/frontend/src/nodes/components/portfolio-manager-node.tsx",
    "content": "import { type NodeProps } from '@xyflow/react';\nimport { Brain } from 'lucide-react';\nimport { useEffect, useState } from 'react';\n\nimport { Button } from '@/components/ui/button';\nimport { CardContent } from '@/components/ui/card';\nimport { ModelSelector } from '@/components/ui/llm-selector';\nimport { useFlowContext } from '@/contexts/flow-context';\nimport { useNodeContext } from '@/contexts/node-context';\nimport { getDefaultModel, getModels, LanguageModel } from '@/data/models';\nimport { useNodeState } from '@/hooks/use-node-state';\nimport { useOutputNodeConnection } from '@/hooks/use-output-node-connection';\nimport { cn } from '@/lib/utils';\nimport { type PortfolioManagerNode } from '../types';\nimport { getStatusColor } from '../utils';\nimport { InvestmentReportDialog } from './investment-report-dialog';\nimport { NodeShell } from './node-shell';\n\nexport function PortfolioManagerNode({\n  data,\n  selected,\n  id,\n  isConnectable,\n}: NodeProps<PortfolioManagerNode>) {\n  const { currentFlowId } = useFlowContext();\n  const { getAgentNodeDataForFlow, setAgentModel, getAgentModel, getOutputNodeDataForFlow } = useNodeContext();\n\n  // Get agent node data for the current flow\n  const agentNodeData = getAgentNodeDataForFlow(currentFlowId?.toString() || null);\n  const nodeData = agentNodeData[id] || {\n    status: 'IDLE',\n    ticker: null,\n    message: '',\n    messages: [],\n    lastUpdated: 0,\n  };\n  const status = nodeData.status;\n  const isInProgress = status === 'IN_PROGRESS';\n  const [isDialogOpen, setIsDialogOpen] = useState(false);\n\n  // Use persistent state hooks\n  const [availableModels, setAvailableModels] = useNodeState<LanguageModel[]>(\n    id,\n    'availableModels',\n    []\n  );\n  const [selectedModel, setSelectedModel] = useNodeState<LanguageModel | null>(\n    id,\n    'selectedModel',\n    null\n  );\n\n  // Load models on mount\n  useEffect(() => {\n    const loadModels = async () => {\n      try {\n        const [models, defaultModel] = await Promise.all([\n          getModels(),\n          getDefaultModel()\n        ]);\n        setAvailableModels(models);\n        \n        // Set default model if no model is currently selected\n        if (!selectedModel && defaultModel) {\n          setSelectedModel(defaultModel);\n        }\n      } catch (error) {\n        console.error('Failed to load models:', error);\n        // Keep empty array as fallback\n      }\n    };\n\n    loadModels();\n  }, [setAvailableModels, selectedModel, setSelectedModel]);\n\n  // Update the node context when the model changes\n  useEffect(() => {\n    const flowId = currentFlowId?.toString() || null;\n    const currentContextModel = getAgentModel(flowId, id);\n    if (selectedModel !== currentContextModel) {\n      setAgentModel(flowId, id, selectedModel);\n    }\n  }, [selectedModel, id, currentFlowId, setAgentModel, getAgentModel]);\n\n  const handleModelChange = (model: LanguageModel | null) => {\n    setSelectedModel(model);\n  };\n  \n  const outputNodeData = getOutputNodeDataForFlow(currentFlowId?.toString() || null);\n\n  // Get connected agent IDs\n  const { connectedAgentIds } = useOutputNodeConnection(id);\n\n  return (\n    <>\n      <NodeShell\n        id={id}\n        selected={selected}\n        isConnectable={isConnectable}\n        icon={<Brain className=\"h-5 w-5\" />}\n        iconColor={getStatusColor(status)}\n        name={data.name || 'Portfolio Manager'}\n        description={data.description}\n        hasRightHandle={false}\n        status={status}\n      >\n        <CardContent className=\"p-0\">\n          <div className=\"border-t border-border p-3\">\n            <div className=\"flex flex-col gap-4\">\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"text-subtitle text-primary flex items-center gap-1\">\n                  Status\n                </div>\n\n                <div\n                  className={cn(\n                    'text-foreground text-xs rounded p-2 border border-status',\n                    isInProgress ? 'gradient-animation' : getStatusColor(status)\n                  )}\n                >\n                  <span className=\"capitalize\">\n                    {status.toLowerCase().replace(/_/g, ' ')}\n                  </span>\n                </div>\n              </div>\n              <div className='flex flex-col gap-2'>\n                {outputNodeData && (\n                  <Button\n                    size=\"sm\"\n                    onClick={() => setIsDialogOpen(true)}\n                  >\n                    View Investment Report\n                  </Button>\n                )}\n              </div>\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"text-subtitle text-primary flex items-center gap-1\">\n                  Model\n                </div>\n                <ModelSelector\n                  models={availableModels}\n                  value={selectedModel?.model_name || ''}\n                  onChange={handleModelChange}\n                  placeholder=\"Auto\"\n                />\n              </div>\n            </div>\n          </div>\n          <InvestmentReportDialog\n            isOpen={isDialogOpen}\n            onOpenChange={setIsDialogOpen}\n            outputNodeData={outputNodeData}\n            connectedAgentIds={connectedAgentIds}\n          />\n        </CardContent>\n      </NodeShell>\n    </>\n  );\n}\n"
  },
  {
    "path": "app/frontend/src/nodes/components/portfolio-start-node.tsx",
    "content": "import { useReactFlow, type NodeProps } from '@xyflow/react';\nimport { ChevronDown, PieChart, Play, Plus, Square, X } from 'lucide-react';\nimport { useEffect, useState } from 'react';\n\nimport { Button } from '@/components/ui/button';\nimport { CardContent } from '@/components/ui/card';\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandList,\n} from '@/components/ui/command';\nimport { Input } from '@/components/ui/input';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';\nimport { useFlowContext } from '@/contexts/flow-context';\nimport { useLayoutContext } from '@/contexts/layout-context';\nimport { useNodeContext } from '@/contexts/node-context';\nimport { useFlowConnection } from '@/hooks/use-flow-connection';\nimport { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';\nimport { useNodeState } from '@/hooks/use-node-state';\nimport { cn, formatKeyboardShortcut } from '@/lib/utils';\nimport { type PortfolioStartNode } from '../types';\nimport { NodeShell } from './node-shell';\n\ninterface PortfolioPosition {\n  ticker: string;\n  quantity: string;\n  tradePrice: string;\n}\n\nconst runModes = [\n  { value: 'single', label: 'Single Run' },\n  { value: 'backtest', label: 'Backtest' },\n];\n\nexport function PortfolioStartNode({\n  data,\n  selected,\n  id,\n  isConnectable,\n}: NodeProps<PortfolioStartNode>) {\n  // Calculate default dates\n  const today = new Date();\n  const threeMonthsAgo = new Date(today);\n  threeMonthsAgo.setMonth(today.getMonth() - 3);\n  \n  // Use persistent state hooks\n  const [positions, setPositions] = useNodeState<PortfolioPosition[]>(id, 'positions', [\n    { ticker: '', quantity: '', tradePrice: '' },\n  ]);\n  const [initialCash, setInitialCash] = useNodeState(id, 'initialCash', '100000');\n  const [runMode, setRunMode] = useNodeState(id, 'runMode', 'single');\n  const [startDate, setStartDate] = useNodeState(id, 'startDate', threeMonthsAgo.toISOString().split('T')[0]);\n  const [endDate, setEndDate] = useNodeState(id, 'endDate', today.toISOString().split('T')[0]);\n  const [open, setOpen] = useState(false);\n  \n  const { currentFlowId } = useFlowContext();\n  const nodeContext = useNodeContext();\n  const { getAllAgentModels } = nodeContext;\n  const { getNodes, getEdges } = useReactFlow();\n  const { expandBottomPanel, setBottomPanelTab } = useLayoutContext();\n  \n  // Use the new flow connection hook\n  const flowId = currentFlowId?.toString() || null;\n  const {\n    isConnecting,\n    isConnected,\n    isProcessing,\n    canRun,\n    runFlow,\n    runBacktest,\n    stopFlow,\n    recoverFlowState\n  } = useFlowConnection(flowId);\n  \n  // Check if the portfolio analyzer can be run\n  const canRunPortfolioAnalyzer = canRun && positions.length > 0 && positions.every(pos => pos.ticker.trim() !== '');\n  \n  // Add keyboard shortcut for Cmd+Enter / Ctrl+Enter to run portfolio analyzer\n  useKeyboardShortcuts({\n    shortcuts: [\n      {\n        key: 'Enter',\n        ctrlKey: true,\n        metaKey: true,\n        callback: () => {\n          if (canRunPortfolioAnalyzer) {\n            handlePlay();\n          }\n        },\n        preventDefault: true,\n      },\n    ],\n  });\n  \n  // Recover flow state when component mounts or flow changes\n  useEffect(() => {\n    if (flowId) {\n      recoverFlowState();\n    }\n  }, [flowId, recoverFlowState]);\n  \n  const handlePositionChange = (index: number, field: keyof PortfolioPosition, value: string) => {\n    const newPositions = [...positions];\n    newPositions[index][field] = value;\n    setPositions(newPositions);\n  };\n\n  const addPosition = () => {\n    setPositions([...positions, { ticker: '', quantity: '', tradePrice: '' }]);\n  };\n\n  const removePosition = (index: number) => {\n    const newPositions = positions.filter((_, i) => i !== index);\n    setPositions(newPositions);\n  };\n\n  const handleInitialCashChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    setInitialCash(e.target.value);\n  };\n\n  const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    setStartDate(e.target.value);\n  };\n\n  const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    setEndDate(e.target.value);\n  };\n\n  const handleStop = () => {\n    stopFlow();\n  };\n\n  const handlePlay = () => {\n    // Expand bottom panel and set to output tab if backtest\n    if (runMode === 'backtest') {\n      expandBottomPanel();\n      setBottomPanelTab('output');\n    }\n    \n    // Get the current flow's nodes and edges\n    const allNodes = getNodes();\n    const allEdges = getEdges();\n    \n    // Find all nodes that are reachable from the portfolio-analyzer-node\n    const reachableNodes = new Set<string>();\n    const visited = new Set<string>();\n    \n    // DFS to find all reachable nodes\n    const dfs = (nodeId: string) => {\n      if (visited.has(nodeId)) return;\n      visited.add(nodeId);\n      \n      // If this is not the portfolio-analyzer-node itself, add it to reachable nodes\n      if (nodeId !== id) {\n        reachableNodes.add(nodeId);\n      }\n      \n      // Find all outgoing edges from this node\n      const outgoingEdges = allEdges.filter(edge => edge.source === nodeId);\n      for (const edge of outgoingEdges) {\n        dfs(edge.target);\n      }\n    };\n    \n    // Start DFS from the portfolio-analyzer-node\n    dfs(id);\n    \n    // Filter nodes to only include reachable ones\n    const agentNodes = allNodes.filter(node => reachableNodes.has(node.id));\n    \n    // Filter edges to only include connections between reachable nodes (plus the portfolio-analyzer-node)\n    const reachableNodeIds = new Set([id, ...reachableNodes]);\n    const validEdges = allEdges.filter(edge => \n      reachableNodeIds.has(edge.source) && reachableNodeIds.has(edge.target)\n    );\n\n    // Collect agent models from all agent nodes\n    const agentModels = [];\n    const allAgentModels = getAllAgentModels(flowId);\n    for (const node of agentNodes) {\n      const model = allAgentModels[node.id];\n      if (model) {\n        agentModels.push({\n          agent_id: node.id,\n          model_name: model.model_name,\n          model_provider: model.provider as any\n        });\n      }\n    }\n    \n    // Convert positions to the expected format for backend use\n    const portfolioPositions = positions\n      .filter(pos => pos.ticker.trim() !== '' && pos.quantity.trim() !== '' && pos.tradePrice.trim() !== '')\n      .map(pos => ({\n        ticker: pos.ticker.trim(),\n        quantity: parseFloat(pos.quantity) || 0,\n        trade_price: parseFloat(pos.tradePrice) || 0\n      }));\n    \n    // For now, extract tickers for current API compatibility\n    const tickerList = positions.map(pos => pos.ticker.trim()).filter(ticker => ticker !== '');\n    \n    // Check if we're in backtest mode\n    if (runMode === 'backtest') {\n      // Use the flow connection hook to run the backtest with selected dates\n      runBacktest({\n        tickers: tickerList,\n        // Send the actual graph structure instead of just selected analysts\n        graph_nodes: agentNodes.map(node => ({\n          id: node.id,\n          type: node.type,\n          data: node.data,\n          position: node.position\n        })),\n        graph_edges: validEdges,\n        agent_models: agentModels,\n        start_date: startDate,\n        end_date: endDate,\n        initial_capital: parseFloat(initialCash) || 100000,\n        margin_requirement: 0.0, // Default margin requirement\n        model_name: undefined,\n        model_provider: undefined,\n        // Pass portfolio positions to backend\n        portfolio_positions: portfolioPositions,\n      });\n    } else {\n      // Use the regular hedge fund API for single run\n      runFlow({\n        tickers: tickerList,\n        // Send the actual graph structure instead of just selected agents\n        graph_nodes: agentNodes.map(node => ({\n          id: node.id,\n          type: node.type,\n          data: node.data,\n          position: node.position\n        })),\n        graph_edges: validEdges,\n        agent_models: agentModels,\n        // No global model - each agent uses its own model or system default\n        model_name: undefined,\n        model_provider: undefined,\n        start_date: threeMonthsAgo.toISOString().split('T')[0],\n        end_date: today.toISOString().split('T')[0],\n        initial_cash: parseFloat(initialCash) || 100000,\n        // Pass portfolio positions to backend\n        portfolio_positions: portfolioPositions,\n      });\n    }\n  };\n\n  // Determine if we're processing (connecting, connected, or any agents running)\n  const showAsProcessing = isConnecting || isConnected || isProcessing;\n\n  return (\n    <TooltipProvider>\n      <NodeShell\n        id={id}\n        selected={selected}\n        isConnectable={isConnectable}\n        icon={<PieChart className=\"h-5 w-5\" />}\n        name={data.name || \"Portfolio Analyzer\"}\n        description={data.description}\n        hasLeftHandle={false}\n        width=\"w-80\"\n      >\n        <CardContent className=\"p-0\">\n          <div className=\"border-t border-border p-3\">\n            <div className=\"flex flex-col gap-4\">\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"text-subtitle text-primary flex items-center gap-1\">\n                  Available Cash\n                </div>\n                <div className=\"relative flex-1\">\n                  <div className=\"absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground pointer-events-none\">\n                    $\n                  </div>\n                  <Input\n                    type=\"number\"\n                    placeholder=\"100000\"\n                    value={initialCash}\n                    onChange={handleInitialCashChange}\n                    className=\"pl-8 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"\n                    step=\"0.01\"\n                    min=\"0\"\n                  />\n                </div>\n              </div>\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"text-subtitle text-primary flex items-center gap-1\">\n                  <Tooltip delayDuration={200}>\n                    <TooltipTrigger asChild>\n                      <span>Positions</span>\n                    </TooltipTrigger>\n                    <TooltipContent side=\"right\">\n                      Add your portfolio positions with ticker, quantity, and trade price\n                    </TooltipContent>\n                  </Tooltip>\n                </div>\n                <div className=\"flex flex-col gap-2\">\n                  {positions.map((position, index) => {\n                    return (\n                    <div key={index} className=\"flex gap-2 items-center\">\n                      <Input\n                        placeholder=\"Ticker\"\n                        value={position.ticker}\n                        onChange={(e) => handlePositionChange(index, 'ticker', e.target.value)}\n                        className=\"flex-1\"\n                      />\n                      <Input\n                        type=\"number\"\n                        placeholder=\"Quantity\"\n                        value={position.quantity}\n                        onChange={(e) => handlePositionChange(index, 'quantity', e.target.value)}\n                        className=\"w-20\"\n                        step=\"any\"\n                      />\n                      <div className=\"relative flex-1\">\n                        <div className=\"absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground pointer-events-none\">\n                          $\n                        </div>\n                        <Input\n                          type=\"number\"\n                          placeholder=\"Price\"\n                          value={position.tradePrice}\n                          onChange={(e) => handlePositionChange(index, 'tradePrice', e.target.value)}\n                          className=\"pl-8\"\n                          step=\"0.01\"\n                          min=\"0\"\n                        />\n                      </div>\n                      {positions.length > 1 && (\n                        <Button\n                          size=\"icon\"\n                          variant=\"ghost\"\n                          onClick={() => removePosition(index)}\n                          className=\"flex-shrink-0 h-8 w-4 text-muted-foreground hover:text-destructive\"\n                        >\n                          <X className=\"h-4 w-4\" />\n                        </Button>\n                      )}\n                    </div>\n                    );\n                  })}\n                  <Button\n                    onClick={addPosition}\n                    className=\"w-full mt-2 transition-all duration-200 hover:bg-primary hover:text-primary-foreground active:scale-95\"\n                    size=\"sm\"\n                    variant=\"secondary\"\n                  >\n                    <Plus className=\"h-4 w-4 mr-2\" />\n                    Add Position\n                  </Button>\n                </div>\n              </div>\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"text-subtitle text-primary flex items-center gap-1\">\n                  Run\n                </div>\n                <div className=\"flex gap-2\">\n                  <Popover open={open} onOpenChange={setOpen}>\n                    <PopoverTrigger asChild>\n                      <Button\n                        variant=\"outline\"\n                        role=\"combobox\"\n                        aria-expanded={open}\n                        className=\"flex-1 justify-between h-10 px-3 py-2 bg-node border border-border hover:bg-accent\"\n                      >\n                        <span className=\"text-subtitle\">\n                          {runModes.find((mode) => mode.value === runMode)?.label || 'Single Analysis'}\n                        </span>\n                        <ChevronDown className=\"ml-2 h-4 w-4 shrink-0 opacity-50\" />\n                      </Button>\n                    </PopoverTrigger>\n                    <PopoverContent className=\"w-[var(--radix-popover-trigger-width)] p-0 bg-node border border-border shadow-lg\">\n                      <Command className=\"bg-node\">\n                        <CommandList className=\"bg-node\">\n                          <CommandEmpty>No run mode found.</CommandEmpty>\n                          <CommandGroup>\n                            {runModes.map((mode) => (\n                              <CommandItem\n                                key={mode.value}\n                                value={mode.value}\n                                className={cn(\n                                  \"cursor-pointer bg-node hover:bg-accent\",\n                                  runMode === mode.value\n                                )}\n                                onSelect={(currentValue) => {\n                                  setRunMode(currentValue);\n                                  setOpen(false);\n                                }}\n                              >\n                                {mode.label}\n                              </CommandItem>\n                            ))}\n                          </CommandGroup>\n                        </CommandList>\n                      </Command>\n                    </PopoverContent>\n                  </Popover>\n                  <Button \n                    size=\"icon\" \n                    variant=\"secondary\"\n                    className=\"flex-shrink-0 transition-all duration-200 hover:bg-primary hover:text-primary-foreground active:scale-95\"\n                    title={showAsProcessing ? \"Stop\" : `Run (${formatKeyboardShortcut('↵')})`}\n                    onClick={showAsProcessing ? handleStop : handlePlay}\n                    disabled={!canRunPortfolioAnalyzer && !showAsProcessing}\n                  >\n                    {showAsProcessing ? (\n                      <Square className=\"h-3.5 w-3.5\" />\n                    ) : (\n                      <Play className=\"h-3.5 w-3.5\" />\n                    )}\n                  </Button>\n                </div>\n              </div>\n              {runMode === 'backtest' && (\n                <div className=\"flex flex-col gap-4\">\n                  <div className=\"flex flex-col gap-2\">\n                    <div className=\"text-subtitle text-primary flex items-center gap-1\">\n                      Start Date\n                    </div>\n                    <Input\n                      type=\"date\"\n                      value={startDate}\n                      onChange={handleStartDateChange}\n                    />\n                  </div>\n                  <div className=\"flex flex-col gap-2\">\n                    <div className=\"text-subtitle text-primary flex items-center gap-1\">\n                      End Date\n                    </div>\n                    <Input\n                      type=\"date\"\n                      value={endDate}\n                      onChange={handleEndDateChange}\n                    />\n                  </div>\n                </div>\n              )}\n            </div>\n          </div>\n        </CardContent>\n      </NodeShell>\n    </TooltipProvider>\n  );\n}\n"
  },
  {
    "path": "app/frontend/src/nodes/components/stock-analyzer-node.tsx",
    "content": "import { useReactFlow, type NodeProps } from '@xyflow/react';\nimport { ChartLine, ChevronDown, Play, Square } from 'lucide-react';\nimport { useEffect, useState } from 'react';\n\nimport { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';\nimport { Button } from '@/components/ui/button';\nimport { CardContent } from '@/components/ui/card';\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandList,\n} from '@/components/ui/command';\nimport { Input } from '@/components/ui/input';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';\nimport { useFlowContext } from '@/contexts/flow-context';\nimport { useLayoutContext } from '@/contexts/layout-context';\nimport { useNodeContext } from '@/contexts/node-context';\nimport { useFlowConnection } from '@/hooks/use-flow-connection';\nimport { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';\nimport { useNodeState } from '@/hooks/use-node-state';\nimport { cn, formatKeyboardShortcut } from '@/lib/utils';\nimport { type StockAnalyzerNode } from '../types';\nimport { NodeShell } from './node-shell';\n\nconst runModes = [\n  { value: 'single', label: 'Single Run' },\n  { value: 'backtest', label: 'Backtest' },\n];\n\nexport function StockAnalyzerNode({\n  data,\n  selected,\n  id,\n  isConnectable,\n}: NodeProps<StockAnalyzerNode>) {\n  // Calculate default dates\n  const today = new Date();\n  const threeMonthsAgo = new Date(today);\n  threeMonthsAgo.setMonth(today.getMonth() - 3);\n  \n  // Use persistent state hooks\n  const [tickers, setTickers] = useNodeState(id, 'tickers', 'AAPL,NVDA,TSLA');\n  const [runMode, setRunMode] = useNodeState(id, 'runMode', 'single');\n  const [initialCash, setInitialCash] = useNodeState(id, 'initialCash', '100000');\n  const [startDate, setStartDate] = useNodeState(id, 'startDate', threeMonthsAgo.toISOString().split('T')[0]);\n  const [endDate, setEndDate] = useNodeState(id, 'endDate', today.toISOString().split('T')[0]);\n  const [open, setOpen] = useState(false);\n  \n  const { currentFlowId } = useFlowContext();\n  const nodeContext = useNodeContext();\n  const { getAllAgentModels } = nodeContext;\n  const { getNodes, getEdges } = useReactFlow();\n  const { expandBottomPanel, setBottomPanelTab } = useLayoutContext();\n  \n  // Use the new flow connection hook\n  const flowId = currentFlowId?.toString() || null;\n  const {\n    isConnecting,\n    isConnected,\n    isProcessing,\n    canRun,\n    runFlow,\n    runBacktest,\n    stopFlow,\n    recoverFlowState\n  } = useFlowConnection(flowId);\n  \n  // Check if the hedge fund can be run\n  const canRunHedgeFund = canRun && tickers.trim() !== '';\n  \n  // Add keyboard shortcut for Cmd+Enter / Ctrl+Enter to run hedge fund\n  useKeyboardShortcuts({\n    shortcuts: [\n      {\n        key: 'Enter',\n        ctrlKey: true,\n        metaKey: true,\n        callback: () => {\n          if (canRunHedgeFund) {\n            handlePlay();\n          }\n        },\n        preventDefault: true,\n      },\n    ],\n  });\n  \n  // Recover flow state when component mounts or flow changes\n  useEffect(() => {\n    if (flowId) {\n      recoverFlowState();\n    }\n  }, [flowId, recoverFlowState]);\n  \n  const handleTickersChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    setTickers(e.target.value);\n  };\n\n  const handleInitialCashChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    // Remove non-numeric characters except decimal point\n    const numericValue = e.target.value.replace(/[^0-9.]/g, '');\n    setInitialCash(numericValue);\n  };\n\n  const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    setStartDate(e.target.value);\n  };\n\n  const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    setEndDate(e.target.value);\n  };\n\n  // Format the display value with commas\n  const formatCurrency = (value: string) => {\n    if (!value) return '';\n    const num = parseFloat(value);\n    if (isNaN(num)) return value;\n    return num.toLocaleString('en-US');\n  };\n\n  const handleStop = () => {\n    stopFlow();\n  };\n\n  const handlePlay = () => {\n    // Expand bottom panel and set to output tab if backtest\n    if (runMode === 'backtest') {\n      expandBottomPanel();\n      setBottomPanelTab('output');\n    }\n    \n    // Get the current flow's nodes and edges\n    const allNodes = getNodes();\n    const allEdges = getEdges();\n    \n    // Find all nodes that are reachable from the stock-analyzer-node\n    const reachableNodes = new Set<string>();\n    const visited = new Set<string>();\n    \n    // DFS to find all reachable nodes\n    const dfs = (nodeId: string) => {\n      if (visited.has(nodeId)) return;\n      visited.add(nodeId);\n      \n      // If this is not the stock-analyzer-node itself, add it to reachable nodes\n      if (nodeId !== id) {\n        reachableNodes.add(nodeId);\n      }\n      \n      // Find all outgoing edges from this node\n      const outgoingEdges = allEdges.filter(edge => edge.source === nodeId);\n      for (const edge of outgoingEdges) {\n        dfs(edge.target);\n      }\n    };\n    \n    // Start DFS from the stock-analyzer-node\n    dfs(id);\n    \n    // Filter nodes to only include reachable ones\n    const agentNodes = allNodes.filter(node => reachableNodes.has(node.id));\n    \n    // Filter edges to only include connections between reachable nodes (plus the stock-analyzer-node)\n    const reachableNodeIds = new Set([id, ...reachableNodes]);\n    const validEdges = allEdges.filter(edge => \n      reachableNodeIds.has(edge.source) && reachableNodeIds.has(edge.target)\n    );\n\n    // Collect agent models from all agent nodes\n    const agentModels = [];\n    const allAgentModels = getAllAgentModels(flowId);\n    for (const node of agentNodes) {\n      const model = allAgentModels[node.id];\n      if (model) {\n        agentModels.push({\n          agent_id: node.id,\n          model_name: model.model_name,\n          model_provider: model.provider as any\n        });\n      }\n    }\n    \n    // Convert tickers to array    \n    const tickerList = tickers.split(',').map(t => t.trim());\n    \n    // Check if we're in backtest mode\n    if (runMode === 'backtest') {\n      // Use the flow connection hook to run the backtest with selected dates\n      runBacktest({\n        tickers: tickerList,\n        // Send the actual graph structure instead of just selected agents\n        graph_nodes: agentNodes.map(node => ({\n          id: node.id,\n          type: node.type,\n          data: node.data,\n          position: node.position\n        })),\n        graph_edges: validEdges,\n        agent_models: agentModels,\n        start_date: startDate,\n        end_date: endDate,\n        initial_capital: parseFloat(initialCash) || 100000,\n        margin_requirement: 0.0, // Default margin requirement\n        model_name: undefined,\n        model_provider: undefined,\n      });\n    } else {\n      // Use the regular hedge fund API for single run\n      runFlow({\n        tickers: tickerList,\n        // Send the actual graph structure instead of just selected agents\n        graph_nodes: agentNodes.map(node => ({\n          id: node.id,\n          type: node.type,\n          data: node.data,\n          position: node.position\n        })),\n        graph_edges: validEdges,\n        agent_models: agentModels,\n        // No global model - each agent uses its own model or system default\n        model_name: undefined,\n        model_provider: undefined,\n        start_date: startDate,\n        end_date: endDate,\n      });\n    }\n  };\n\n  // Determine if we're processing (connecting, connected, or any agents running)\n  const showAsProcessing = isConnecting || isConnected || isProcessing;\n\n  return (\n    <TooltipProvider>\n      <NodeShell\n        id={id}\n        selected={selected}\n        isConnectable={isConnectable}\n        icon={<ChartLine className=\"h-5 w-5\" />}\n        name={data.name || \"Stock Analyzer\"}\n        description={data.description}\n        hasLeftHandle={false}\n      >\n        <CardContent className=\"p-0\">\n          <div className=\"border-t border-border p-3\">\n            <div className=\"flex flex-col gap-4\">\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"text-subtitle text-primary flex items-center gap-1\">\n                  <Tooltip delayDuration={200}>\n                    <TooltipTrigger asChild>\n                      <span>Tickers</span>\n                    </TooltipTrigger>\n                    <TooltipContent side=\"right\">\n                      You can add multiple tickers using commas (AAPL,NVDA,TSLA)\n                    </TooltipContent>\n                  </Tooltip>\n                </div>\n                <Input\n                  placeholder=\"Enter tickers\"\n                  value={tickers}\n                  onChange={handleTickersChange}\n                />\n              </div>\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"text-subtitle text-primary flex items-center gap-1\">\n                  Run\n                </div>\n                <div className=\"flex gap-2\">\n                  <Popover open={open} onOpenChange={setOpen}>\n                    <PopoverTrigger asChild>\n                      <Button\n                        variant=\"outline\"\n                        role=\"combobox\"\n                        aria-expanded={open}\n                        className=\"flex-1 justify-between h-10 px-3 py-2 bg-node border border-border hover:bg-accent\"\n                      >\n                        <span className=\"text-subtitle\">\n                          {runModes.find((mode) => mode.value === runMode)?.label || 'Single Run'}\n                        </span>\n                        <ChevronDown className=\"ml-2 h-4 w-4 shrink-0 opacity-50\" />\n                      </Button>\n                    </PopoverTrigger>\n                    <PopoverContent className=\"w-[var(--radix-popover-trigger-width)] p-0 bg-node border border-border shadow-lg\">\n                      <Command className=\"bg-node\">\n                        <CommandList className=\"bg-node\">\n                          <CommandEmpty>No run mode found.</CommandEmpty>\n                          <CommandGroup>\n                            {runModes.map((mode) => (\n                              <CommandItem\n                                key={mode.value}\n                                value={mode.value}\n                                className={cn(\n                                  \"cursor-pointer bg-node hover:bg-accent\",\n                                  runMode === mode.value\n                                )}\n                                onSelect={(currentValue) => {\n                                  setRunMode(currentValue);\n                                  setOpen(false);\n                                }}\n                              >\n                                {mode.label}\n                              </CommandItem>\n                            ))}\n                          </CommandGroup>\n                        </CommandList>\n                      </Command>\n                    </PopoverContent>\n                  </Popover>\n                  <Button \n                    size=\"icon\" \n                    variant=\"secondary\"\n                    className=\"flex-shrink-0 transition-all duration-200 hover:bg-primary hover:text-primary-foreground active:scale-95\"\n                    title={showAsProcessing ? \"Stop\" : `Run (${formatKeyboardShortcut('↵')})`}\n                    onClick={showAsProcessing ? handleStop : handlePlay}\n                    disabled={!canRunHedgeFund && !showAsProcessing}\n                  >\n                    {showAsProcessing ? (\n                      <Square className=\"h-3.5 w-3.5\" />\n                    ) : (\n                      <Play className=\"h-3.5 w-3.5\" />\n                    )}\n                  </Button>\n                </div>\n              </div>\n              {runMode === 'backtest' && (\n                <Accordion type=\"single\" collapsible>\n                  <AccordionItem value=\"advanced\" className=\"border-none\">\n                    <AccordionTrigger className=\"!text-subtitle text-primary\">\n                      Advanced\n                    </AccordionTrigger>\n                    <AccordionContent className=\"pt-2\">\n                      <div className=\"flex flex-col gap-4\">\n                        <div className=\"flex flex-col gap-2\">\n                          <div className=\"text-subtitle text-primary flex items-center gap-1\">\n                            Available Cash\n                          </div>\n                          <div className=\"relative flex-1\">\n                            <div className=\"absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground pointer-events-none\">\n                              $\n                            </div>\n                            <Input\n                              type=\"text\"\n                              placeholder=\"100,000\"\n                              value={formatCurrency(initialCash)}\n                              onChange={handleInitialCashChange}\n                              className=\"pl-8 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"\n                            />\n                          </div>\n                        </div>\n                        <div className=\"flex flex-col gap-2\">\n                          <div className=\"text-subtitle text-primary flex items-center gap-1\">\n                            Start Date\n                          </div>\n                          <Input\n                            type=\"date\"\n                            value={startDate}\n                            onChange={handleStartDateChange}\n                          />\n                        </div>\n                        <div className=\"flex flex-col gap-2\">\n                          <div className=\"text-subtitle text-primary flex items-center gap-1\">\n                            End Date\n                          </div>\n                          <Input\n                            type=\"date\"\n                            value={endDate}\n                            onChange={handleEndDateChange}\n                          />\n                        </div>\n                      </div>\n                    </AccordionContent>\n                  </AccordionItem>\n                </Accordion>\n              )}\n              {runMode === 'single' && (\n                <Accordion type=\"single\" collapsible>\n                  <AccordionItem value=\"advanced\" className=\"border-none\">\n                    <AccordionTrigger className=\"!text-subtitle text-primary\">\n                      Advanced\n                    </AccordionTrigger>\n                    <AccordionContent className=\"pt-2\">\n                      <div className=\"flex flex-col gap-4\">\n                        <div className=\"flex flex-col gap-2\">\n                          <div className=\"text-subtitle text-primary flex items-center gap-1\">\n                            End Date\n                          </div>\n                          <Input\n                            type=\"date\"\n                            value={endDate}\n                            onChange={handleEndDateChange}\n                          />\n                        </div>\n                        <div className=\"flex flex-col gap-2\">\n                          <div className=\"text-subtitle text-primary flex items-center gap-1\">\n                            Start Date\n                          </div>\n                          <Input\n                            type=\"date\"\n                            value={startDate}\n                            onChange={handleStartDateChange}\n                          />\n                        </div>\n                      </div>\n                    </AccordionContent>\n                  </AccordionItem>\n                </Accordion>\n              )}\n            </div>\n          </div>\n        </CardContent>\n      </NodeShell>\n    </TooltipProvider>\n  );\n}\n"
  },
  {
    "path": "app/frontend/src/nodes/index.ts",
    "content": "import { Edge, type NodeTypes } from '@xyflow/react';\n\nimport { AgentNode } from './components/agent-node';\nimport { InvestmentReportNode } from './components/investment-report-node';\nimport { JsonOutputNode } from './components/json-output-node';\nimport { PortfolioManagerNode } from './components/portfolio-manager-node';\nimport { PortfolioStartNode } from './components/portfolio-start-node';\nimport { StockAnalyzerNode } from './components/stock-analyzer-node';\nimport { type AppNode } from './types';\n\n// Types\nexport * from './types';\n\nexport const initialNodes: AppNode[] = [\n  {\n    id: 'stock-analyzer-node',\n    type: 'stock-analyzer-node',\n    position: { x: 0, y: 0 },\n    data: {\n      name: 'Stock Analyzer',\n      description: 'Stock Analyzer',\n      status: 'Idle',\n    },\n  },\n  {\n    id: 'valuation_analyst',\n    type: 'agent-node',\n    position: { x: 300, y: 25 },\n    data: {\n      name: 'Valuation Analyst',\n      description: 'Company Valuation Specialist',\n      status: 'Idle',\n    },\n  },\n  {\n    id: 'portfolio-manager-node',\n    type: 'portfolio-manager-node',\n    position: { x: 600, y: 75 },\n    data: {\n      name: 'Portfolio Manager',\n      description: 'Portfolio Manager',\n      status: 'Idle',\n    },\n  },\n];\n\nexport const initialEdges: Edge[] = [\n  { id: 'e1-2', source: 'portfolio-manager-node', target: 'valuation_analyst' },\n  { id: 'e2-3', source: 'valuation_analyst', target: 'investment-report-node' },\n];\n\nexport const nodeTypes = {\n  'agent-node': AgentNode,\n  'investment-report-node': InvestmentReportNode,\n  'json-output-node': JsonOutputNode,\n  'portfolio-start-node': PortfolioStartNode,\n  'portfolio-manager-node': PortfolioManagerNode,\n  'stock-analyzer-node': StockAnalyzerNode,\n} satisfies NodeTypes;\n"
  },
  {
    "path": "app/frontend/src/nodes/types.ts",
    "content": "import { MessageItem } from '@/contexts/node-context';\nimport type { BuiltInNode, Node } from '@xyflow/react';\n\nexport type NodeMessage = MessageItem;\n\nexport type AgentNode = Node<{ name: string, description: string, status: string }, 'agent-node'>;\nexport type InvestmentReportNode = Node<{ name: string, description: string, status: string }, 'investment-report-node'>;\nexport type JsonOutputNode = Node<{ name: string, description: string, status: string }, 'json-output-node'>;\nexport type PortfolioStartNode = Node<{ name: string, description: string, status: string }, 'portfolio-start-node'>;\nexport type PortfolioManagerNode = Node<{ name: string, description: string, status: string }, 'portfolio-manager-node'>;\nexport type StockAnalyzerNode = Node<{ name: string, description: string, status: string }, 'stock-analyzer-node'>;\nexport type AppNode = BuiltInNode | AgentNode | InvestmentReportNode | JsonOutputNode | PortfolioStartNode | PortfolioManagerNode | StockAnalyzerNode;\n"
  },
  {
    "path": "app/frontend/src/nodes/utils.ts",
    "content": "import { type Edge, type Node, getConnectedEdges } from '@xyflow/react';\n\nexport type NodeStatus = 'IDLE' | 'IN_PROGRESS' | 'COMPLETE' | 'ERROR';\n\n/**\n * Returns the appropriate background color class based on node status\n */\nexport function getStatusColor(status: NodeStatus): string {\n  switch (status) {\n    case 'IN_PROGRESS':\n      return 'bg-amber-500  dark:bg-amber-80';\n    case 'ERROR':\n      return 'bg-red-500 dark:bg-red-800';\n    default:\n      return 'bg-node';\n  }\n}\n\n/**\n * Finds all nodes that are part of a complete path from a start node to an end node\n * @param params - Object containing parameters\n * @param params.startNodeId - The ID of the starting node\n * @param params.endNodeId - The ID of the end/target node\n * @param params.nodes - All nodes in the flow\n * @param params.edges - All edges in the flow\n * @returns A Set of node IDs that are part of a complete path\n */\nexport function getNodesInCompletePaths({\n  startNodeId,\n  endNodeId,\n  nodes,\n  edges\n}: {\n  startNodeId: string;\n  endNodeId: string;\n  nodes: Node[];\n  edges: Edge[];\n}): Set<string> {\n  const connectedEdges = getConnectedEdges(nodes, edges);\n  const selectedAgents = new Set<string>();\n  \n  // Helper function to find all paths from start to end\n  const findCompletePaths = (\n    currentNode: string,\n    visited: Set<string>,\n    currentPath: string[]\n  ) => {\n    // Add current node to visited and current path\n    visited.add(currentNode);\n    currentPath.push(currentNode);\n    \n    // If we've reached the end node, we've found a complete path\n    if (currentNode === endNodeId) {\n      // Add all nodes in this path to selectedAgents\n      currentPath.forEach(node => selectedAgents.add(node));\n      visited.delete(currentNode);\n      currentPath.pop();\n      return;\n    }\n    \n    // Find all edges where current node is the source\n    const outgoingEdges = connectedEdges.filter(edge => edge.source === currentNode);\n    \n    // Explore each outgoing edge\n    for (const edge of outgoingEdges) {\n      if (!visited.has(edge.target)) {\n        findCompletePaths(edge.target, visited, currentPath);\n      }\n    }\n    \n    // Backtrack\n    visited.delete(currentNode);\n    currentPath.pop();\n  };\n  \n  // Start the path finding from the start node\n  findCompletePaths(startNodeId, new Set<string>(), []);\n  \n  return selectedAgents;\n}\n"
  },
  {
    "path": "app/frontend/src/providers/theme-provider.tsx",
    "content": "import { ThemeProvider as NextThemesProvider } from 'next-themes';\nimport { ReactNode } from 'react';\n\ninterface ThemeProviderProps {\n  children: ReactNode;\n}\n\nexport function ThemeProvider({ children }: ThemeProviderProps) {\n  return (\n    <NextThemesProvider\n      attribute=\"class\"\n      defaultTheme=\"system\"\n      enableSystem={true}\n      storageKey=\"theme\"\n    >\n      {children}\n    </NextThemesProvider>\n  );\n} "
  },
  {
    "path": "app/frontend/src/services/api-keys-api.ts",
    "content": "const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';\n\nexport interface ApiKey {\n  id: number;\n  provider: string;\n  key_value: string;\n  is_active: boolean;\n  description?: string;\n  created_at: string;\n  updated_at?: string;\n  last_used?: string;\n}\n\nexport interface ApiKeySummary {\n  id: number;\n  provider: string;\n  is_active: boolean;\n  description?: string;\n  created_at: string;\n  updated_at?: string;\n  last_used?: string;\n  has_key: boolean;\n}\n\nexport interface ApiKeyCreateRequest {\n  provider: string;\n  key_value: string;\n  description?: string;\n  is_active: boolean;\n}\n\nexport interface ApiKeyUpdateRequest {\n  key_value?: string;\n  description?: string;\n  is_active?: boolean;\n}\n\nexport interface ApiKeyBulkUpdateRequest {\n  api_keys: ApiKeyCreateRequest[];\n}\n\nclass ApiKeysService {\n  private baseUrl = `${API_BASE_URL}/api-keys`;\n\n  async getAllApiKeys(includeInactive = false): Promise<ApiKeySummary[]> {\n    const params = new URLSearchParams();\n    if (includeInactive) {\n      params.append('include_inactive', 'true');\n    }\n    \n    const response = await fetch(`${this.baseUrl}?${params}`);\n    if (!response.ok) {\n      throw new Error(`Failed to fetch API keys: ${response.statusText}`);\n    }\n    return response.json();\n  }\n\n  async getApiKey(provider: string): Promise<ApiKey> {\n    const response = await fetch(`${this.baseUrl}/${encodeURIComponent(provider)}`);\n    if (!response.ok) {\n      if (response.status === 404) {\n        throw new Error('API key not found');\n      }\n      throw new Error(`Failed to fetch API key: ${response.statusText}`);\n    }\n    return response.json();\n  }\n\n  async createOrUpdateApiKey(request: ApiKeyCreateRequest): Promise<ApiKey> {\n    const response = await fetch(this.baseUrl, {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n      },\n      body: JSON.stringify(request),\n    });\n    \n    if (!response.ok) {\n      throw new Error(`Failed to create/update API key: ${response.statusText}`);\n    }\n    return response.json();\n  }\n\n  async updateApiKey(provider: string, request: ApiKeyUpdateRequest): Promise<ApiKey> {\n    const response = await fetch(`${this.baseUrl}/${encodeURIComponent(provider)}`, {\n      method: 'PUT',\n      headers: {\n        'Content-Type': 'application/json',\n      },\n      body: JSON.stringify(request),\n    });\n    \n    if (!response.ok) {\n      if (response.status === 404) {\n        throw new Error('API key not found');\n      }\n      throw new Error(`Failed to update API key: ${response.statusText}`);\n    }\n    return response.json();\n  }\n\n  async deleteApiKey(provider: string): Promise<void> {\n    const response = await fetch(`${this.baseUrl}/${encodeURIComponent(provider)}`, {\n      method: 'DELETE',\n    });\n    \n    if (!response.ok) {\n      if (response.status === 404) {\n        throw new Error('API key not found');\n      }\n      throw new Error(`Failed to delete API key: ${response.statusText}`);\n    }\n  }\n\n  async deactivateApiKey(provider: string): Promise<ApiKeySummary> {\n    const response = await fetch(`${this.baseUrl}/${encodeURIComponent(provider)}/deactivate`, {\n      method: 'PATCH',\n    });\n    \n    if (!response.ok) {\n      if (response.status === 404) {\n        throw new Error('API key not found');\n      }\n      throw new Error(`Failed to deactivate API key: ${response.statusText}`);\n    }\n    return response.json();\n  }\n\n  async bulkUpdateApiKeys(request: ApiKeyBulkUpdateRequest): Promise<ApiKey[]> {\n    const response = await fetch(`${this.baseUrl}/bulk`, {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n      },\n      body: JSON.stringify(request),\n    });\n    \n    if (!response.ok) {\n      throw new Error(`Failed to bulk update API keys: ${response.statusText}`);\n    }\n    return response.json();\n  }\n\n  async updateLastUsed(provider: string): Promise<void> {\n    const response = await fetch(`${this.baseUrl}/${encodeURIComponent(provider)}/last-used`, {\n      method: 'PATCH',\n    });\n    \n    if (!response.ok) {\n      if (response.status === 404) {\n        throw new Error('API key not found');\n      }\n      throw new Error(`Failed to update last used timestamp: ${response.statusText}`);\n    }\n  }\n}\n\nexport const apiKeysService = new ApiKeysService(); "
  },
  {
    "path": "app/frontend/src/services/api.ts",
    "content": "import { NodeStatus, OutputNodeData, useNodeContext } from '@/contexts/node-context';\nimport { Agent } from '@/data/agents';\nimport { LanguageModel } from '@/data/models';\nimport { extractBaseAgentKey } from '@/data/node-mappings';\nimport { flowConnectionManager } from '@/hooks/use-flow-connection';\nimport {\n  HedgeFundRequest\n} from '@/services/types';\n\nconst API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';\n\nexport const api = {\n  /**\n   * Gets the list of available agents from the backend\n   * @returns Promise that resolves to the list of agents\n   */\n  getAgents: async (): Promise<Agent[]> => {\n    try {\n      const response = await fetch(`${API_BASE_URL}/hedge-fund/agents`);\n      if (!response.ok) {\n        throw new Error(`HTTP error! status: ${response.status}`);\n      }\n      const data = await response.json();\n      return data.agents;\n    } catch (error) {\n      console.error('Failed to fetch agents:', error);\n      throw error;\n    }\n  },\n\n  /**\n   * Gets the list of available models from the backend\n   * @returns Promise that resolves to the list of models\n   */\n  getLanguageModels: async (): Promise<LanguageModel[]> => {\n    try {\n      const response = await fetch(`${API_BASE_URL}/language-models/`);\n      if (!response.ok) {\n        throw new Error(`HTTP error! status: ${response.status}`);\n      }\n      const data = await response.json();\n      return data.models;\n    } catch (error) {\n      console.error('Failed to fetch models:', error);\n      throw error;\n    }\n  },\n\n  /**\n   * Saves JSON data to a file in the project's /outputs directory\n   * @param filename The name of the file to save\n   * @param data The JSON data to save\n   * @returns Promise that resolves when the file is saved\n   */\n  saveJsonFile: async (filename: string, data: any): Promise<void> => {\n    try {\n      const response = await fetch(`${API_BASE_URL}/storage/save-json`, {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json',\n        },\n        body: JSON.stringify({\n          filename,\n          data\n        }),\n      });\n\n      if (!response.ok) {\n        throw new Error(`HTTP error! status: ${response.status}`);\n      }\n\n      const result = await response.json();\n      console.log(result.message);\n    } catch (error) {\n      console.error('Failed to save JSON file:', error);\n      throw error;\n    }\n  },\n\n  /**\n   * Runs a hedge fund simulation with the given parameters and streams the results\n   * @param params The hedge fund request parameters\n   * @param nodeContext Node context for updating node states\n   * @param flowId The ID of the current flow\n   * @returns A function to abort the SSE connection\n   */\n  runHedgeFund: (\n    params: HedgeFundRequest, \n    nodeContext: ReturnType<typeof useNodeContext>,\n    flowId: string | null = null\n  ): (() => void) => {\n    // Convert tickers string to array if needed\n    if (typeof params.tickers === 'string') {\n      params.tickers = (params.tickers as unknown as string).split(',').map(t => t.trim());\n    }\n\n    // Helper function to get agent IDs from graph structure\n    const getAgentIds = () => params.graph_nodes.map(node => node.id);\n\n    // Pass the unique node IDs directly to the backend\n    const backendParams = params;\n\n    // For SSE connections with FastAPI, we need to use POST\n    // First, create the controller\n    const controller = new AbortController();\n    const { signal } = controller;\n\n    // Make a POST request with the JSON body\n    fetch(`${API_BASE_URL}/hedge-fund/run`, {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n      },\n      body: JSON.stringify(backendParams),\n      signal,\n    })\n    .then(response => {\n      if (!response.ok) {\n        throw new Error(`HTTP error! status: ${response.status}`);\n      }\n            \n      // Process the response as a stream of SSE events\n      const reader = response.body?.getReader();\n      if (!reader) {\n        throw new Error('Failed to get response reader');\n      }\n      \n      const decoder = new TextDecoder();\n      let buffer = '';\n      \n      // Function to process the stream\n      const processStream = async () => {\n        try {\n          while (true) {\n            const { done, value } = await reader.read();\n            \n            if (done) {\n              break;\n            }\n            \n            // Decode the chunk and add to buffer\n            const chunk = decoder.decode(value, { stream: true });\n            buffer += chunk;\n            \n            // Process any complete events in the buffer (separated by double newlines)\n            const events = buffer.split('\\n\\n');\n            buffer = events.pop() || ''; // Keep last partial event in buffer\n            \n            for (const eventText of events) {\n              if (!eventText.trim()) continue;\n                            \n              try {\n                // Parse the event type and data from the SSE format\n                const eventTypeMatch = eventText.match(/^event: (.+)$/m);\n                const dataMatch = eventText.match(/^data: (.+)$/m);\n                \n                if (eventTypeMatch && dataMatch) {\n                  const eventType = eventTypeMatch[1];\n                  const eventData = JSON.parse(dataMatch[1]);\n                  \n                  console.log(`Parsed ${eventType} event:`, eventData);\n                  \n                  // Process based on event type\n                  switch (eventType) {\n                    case 'start':\n                      // Reset all nodes at the start of a new run\n                      nodeContext.resetAllNodes(flowId);\n                      break;\n                    case 'progress':\n                      if (eventData.agent) {\n                        // Map the progress to a node status\n                        let nodeStatus: NodeStatus = 'IN_PROGRESS';\n                        if (eventData.status === 'Done') {\n                          nodeStatus = 'COMPLETE';\n                        }\n                        // Map the backend agent name to the unique node ID\n                        const baseAgentKey = eventData.agent.replace('_agent', '');\n                        \n                        // Find the unique node ID that corresponds to this base agent key\n                        const uniqueNodeId = getAgentIds().find(id => \n                          extractBaseAgentKey(id) === baseAgentKey\n                        ) || baseAgentKey;\n                                                \n                        // Use the enhanced API to update both status and additional data\n                        nodeContext.updateAgentNode(flowId, uniqueNodeId, {\n                          status: nodeStatus,\n                          ticker: eventData.ticker,\n                          message: eventData.status,\n                          analysis: eventData.analysis,\n                          timestamp: eventData.timestamp\n                        });\n                      }\n                      break;\n                    case 'complete':\n                      // Store the complete event data in the node context\n                      if (eventData.data) {\n                        nodeContext.setOutputNodeData(flowId, eventData.data as OutputNodeData);\n                      }\n                      // Mark all agents as complete when the whole process is done\n                      nodeContext.updateAgentNodes(flowId, getAgentIds(), 'COMPLETE');\n                      // Also update the output node\n                      nodeContext.updateAgentNode(flowId, 'output', {\n                        status: 'COMPLETE',\n                        message: 'Analysis complete'\n                      });\n\n                      // Update flow connection state to completed\n                      if (flowId) {\n                        flowConnectionManager.setConnection(flowId, {\n                          state: 'completed',\n                          abortController: null,\n                        });\n\n                        // Optional: Auto-cleanup completed connections after a delay\n                        setTimeout(() => {\n                          const currentConnection = flowConnectionManager.getConnection(flowId);\n                          if (currentConnection.state === 'completed') {\n                            flowConnectionManager.setConnection(flowId, {\n                              state: 'idle',\n                            });\n                          }\n                        }, 30000); // 30 seconds\n                      }\n                      break;\n                    case 'error':\n                      // Mark all agents as error when there's an error  \n                      nodeContext.updateAgentNodes(flowId, getAgentIds(), 'ERROR');\n                      \n                      // Update flow connection state to error\n                      if (flowId) {\n                        flowConnectionManager.setConnection(flowId, {\n                          state: 'error',\n                          error: eventData.message || 'Unknown error occurred',\n                          abortController: null,\n                        });\n                      }\n                      break;\n                    default:\n                      console.warn('Unknown event type:', eventType);\n                  }\n                }\n              } catch (err) {\n                console.error('Error parsing SSE event:', err, 'Raw event:', eventText);\n              }\n            }\n          }\n          \n          // After the stream has finished, check if we are still in a connected state.\n          // This can happen if the backend closes the connection without sending a 'complete' event.\n          if (flowId) {\n            const currentConnection = flowConnectionManager.getConnection(flowId);\n            if (currentConnection.state === 'connected') {\n              flowConnectionManager.setConnection(flowId, {\n                state: 'completed',\n                abortController: null,\n              });\n            }\n          }\n        } catch (error: any) { // Type assertion for error\n          if (error.name !== 'AbortError') {\n            console.error('Error reading SSE stream:', error);\n            // Mark all agents as error when there's a connection error\n            nodeContext.updateAgentNodes(flowId, getAgentIds(), 'ERROR');\n            \n            // Update flow connection state to error\n            if (flowId) {\n              flowConnectionManager.setConnection(flowId, {\n                state: 'error',\n                error: error.message || 'Connection error',\n                abortController: null,\n              });\n            }\n          }\n        }\n      };\n      \n      // Start processing the stream\n      processStream();\n    })\n    .catch((error: any) => { // Type assertion for error\n      if (error.name !== 'AbortError') {\n        console.error('SSE connection error:', error);\n        // Mark all agents as error when there's a connection error\n        nodeContext.updateAgentNodes(flowId, getAgentIds(), 'ERROR');\n        \n        // Update flow connection state to error\n        if (flowId) {\n          flowConnectionManager.setConnection(flowId, {\n            state: 'error',\n            error: error.message || 'Connection failed',\n            abortController: null,\n          });\n        }\n      }\n    });\n\n    // Return abort function\n    return () => {\n      controller.abort();\n      // Update connection state when manually aborted\n      if (flowId) {\n        flowConnectionManager.setConnection(flowId, {\n          state: 'idle',\n          abortController: null,\n        });\n      }\n    };\n  },\n}; "
  },
  {
    "path": "app/frontend/src/services/backtest-api.ts",
    "content": "import { NodeStatus, useNodeContext } from '@/contexts/node-context';\nimport { extractBaseAgentKey } from '@/data/node-mappings';\nimport { flowConnectionManager } from '@/hooks/use-flow-connection';\nimport {\n  BacktestDayResult,\n  BacktestPerformanceMetrics,\n  BacktestRequest\n} from '@/services/types';\n\nconst API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';\n\nexport const backtestApi = {\n  /**\n   * Runs a backtest simulation with the given parameters and streams the results\n   * @param params The backtest request parameters\n   * @param nodeContext Node context for updating node states\n   * @param flowId The ID of the current flow\n   * @returns A function to abort the SSE connection\n   */\n  runBacktest: (\n    params: BacktestRequest,\n    nodeContext: ReturnType<typeof useNodeContext>,\n    flowId: string | null = null\n  ): (() => void) => {\n    // Create the controller for aborting the request\n    const controller = new AbortController();\n    const { signal } = controller;\n\n    // Make a POST request to the backtest endpoint\n    fetch(`${API_BASE_URL}/hedge-fund/backtest`, {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n      },\n      body: JSON.stringify(params),\n      signal,\n    })\n    .then(response => {\n      if (!response.ok) {\n        throw new Error(`HTTP error! status: ${response.status}`);\n      }\n            \n      // Process the response as a stream of SSE events\n      const reader = response.body?.getReader();\n      if (!reader) {\n        throw new Error('Failed to get response reader');\n      }\n      \n      const decoder = new TextDecoder();\n      let buffer = '';\n      \n      // Local array to accumulate backtest results\n      let backtestResults: any[] = [];\n      \n      // Function to process the stream\n      const processStream = async () => {\n        try {\n          while (true) {\n            const { done, value } = await reader.read();\n            \n            if (done) {\n              break;\n            }\n            \n            // Decode the chunk and add to buffer\n            const chunk = decoder.decode(value, { stream: true });\n            buffer += chunk;\n            \n            // Process any complete events in the buffer (separated by double newlines)\n            const events = buffer.split('\\n\\n');\n            buffer = events.pop() || '';\n            \n            for (const eventText of events) {\n              if (!eventText.trim()) continue;\n                            \n              try {\n                // Parse the event type and data from the SSE format\n                const eventTypeMatch = eventText.match(/^event: (.+)$/m);\n                const dataMatch = eventText.match(/^data: (.+)$/m);\n                \n                if (eventTypeMatch && dataMatch) {\n                  const eventType = eventTypeMatch[1];\n                  const eventData = JSON.parse(dataMatch[1]);\n                  \n                  console.log(`Parsed backtest ${eventType} event:`, eventData);\n                  \n                  // Process based on event type\n                  switch (eventType) {\n                    case 'start':\n                      // Reset all nodes at the start of a new backtest\n                      nodeContext.resetAllNodes(flowId);\n                      // Clear local backtest results\n                      backtestResults = [];\n                      // Create a backtest agent entry\n                      nodeContext.updateAgentNode(flowId, 'backtest', {\n                        status: 'IN_PROGRESS',\n                        message: 'Starting backtest...',\n                        backtestResults: [],\n                      });\n                      break;\n                    \n                    case 'progress':\n                      // Handle individual agent updates (from actual agents during backtest)\n                      if (eventData.agent && eventData.agent !== 'backtest') {\n                        // Map the progress to a node status\n                        let nodeStatus: NodeStatus = 'IN_PROGRESS';\n                        if (eventData.status === 'Done') {\n                          nodeStatus = 'COMPLETE';\n                        }\n                        // Map the backend agent name to the unique node ID\n                        const baseAgentKey = eventData.agent.replace('_agent', '');\n                        \n                        // Find the unique node ID that corresponds to this base agent key\n                        // We need to get the agent IDs from the request parameters\n                        const agentIds = params.graph_nodes.map(node => node.id);\n                        const uniqueNodeId = agentIds.find(id => \n                          extractBaseAgentKey(id) === baseAgentKey\n                        ) || baseAgentKey;\n                                                \n                        // Use the enhanced API to update both status and additional data\n                        nodeContext.updateAgentNode(flowId, uniqueNodeId, {\n                          status: nodeStatus,\n                          ticker: eventData.ticker,\n                          message: eventData.status,\n                          analysis: eventData.analysis,\n                          timestamp: eventData.timestamp\n                        });\n                      }\n                      // Handle backtest-specific progress updates\n                      else if (eventData.agent === 'backtest') {\n                        // If this progress update contains backtest result data, add it to local array\n                        if (eventData.analysis) {\n                          try {\n                            const backtestResultData = JSON.parse(eventData.analysis);\n                            // Add to local array and keep only the last 50 results to avoid memory issues\n                            backtestResults = [...backtestResults, backtestResultData].slice(-50);\n                          } catch (error) {\n                            console.error('Error parsing backtest result data:', error);\n                          }\n                        }\n                        \n                        // Update the node with the local backtest results\n                        nodeContext.updateAgentNode(flowId, 'backtest', {\n                          status: 'IN_PROGRESS',\n                          message: eventData.status,\n                          backtestResults: backtestResults,\n                        });\n                      }\n                      break;\n                    \n                    case 'complete':\n                      // Store the complete backtest results\n                      if (eventData.data) {\n                        const backtestResults = {\n                          decisions: { backtest: { type: 'backtest_complete' } },\n                          analyst_signals: {},\n                          performance_metrics: eventData.data.performance_metrics,\n                          final_portfolio: eventData.data.final_portfolio,\n                          total_days: eventData.data.total_days,\n                        };\n                        \n                        nodeContext.setOutputNodeData(flowId, backtestResults);\n                      }\n                      \n                      // Mark the backtest agent as complete\n                      nodeContext.updateAgentNode(flowId, 'backtest', {\n                        status: 'COMPLETE',\n                        message: 'Backtest completed successfully',\n                      });\n                      \n                      // Update the output node\n                      nodeContext.updateAgentNode(flowId, 'output', {\n                        status: 'COMPLETE',\n                        message: 'Backtest analysis complete'\n                      });\n\n                      // Update flow connection state to completed\n                      if (flowId) {\n                        flowConnectionManager.setConnection(flowId, {\n                          state: 'completed',\n                          abortController: null,\n                        });\n\n                        // Auto-cleanup completed connections after a delay\n                        setTimeout(() => {\n                          const currentConnection = flowConnectionManager.getConnection(flowId);\n                          if (currentConnection.state === 'completed') {\n                            flowConnectionManager.setConnection(flowId, {\n                              state: 'idle',\n                            });\n                          }\n                        }, 30000); // 30 seconds\n                      }\n                      break;\n                    \n                    case 'error':\n                      // Mark nodes as error when there's an error\n                      nodeContext.updateAgentNode(flowId, 'portfolio-start', {\n                        status: 'ERROR',\n                        message: eventData.message || 'Backtest failed',\n                      });\n                      \n                      // Update flow connection state to error\n                      if (flowId) {\n                        flowConnectionManager.setConnection(flowId, {\n                          state: 'error',\n                          error: eventData.message || 'Unknown error occurred',\n                          abortController: null,\n                        });\n                      }\n                      break;\n                    \n                    default:\n                      console.warn('Unknown backtest event type:', eventType);\n                  }\n                }\n              } catch (err) {\n                console.error('Error parsing backtest SSE event:', err, 'Raw event:', eventText);\n              }\n            }\n          }\n          \n          // After the stream has finished, check if we are still in a connected state\n          if (flowId) {\n            const currentConnection = flowConnectionManager.getConnection(flowId);\n            if (currentConnection.state === 'connected') {\n              flowConnectionManager.setConnection(flowId, {\n                state: 'completed',\n                abortController: null,\n              });\n            }\n          }\n        } catch (error: any) {\n          if (error.name === 'AbortError') {\n          } else {\n            console.error('Error reading backtest SSE stream:', error);\n            // Mark nodes as error when there's a connection error\n            nodeContext.updateAgentNode(flowId, 'portfolio-start', {\n              status: 'ERROR',\n              message: 'Connection error during backtest',\n            });\n            \n            // Update flow connection state to error\n            if (flowId) {\n              flowConnectionManager.setConnection(flowId, {\n                state: 'error',\n                error: error.message || 'Connection error',\n                abortController: null,\n              });\n            }\n          }\n        }\n      };\n      \n      // Start processing the stream\n      processStream();\n    })\n    .catch((error: any) => {\n      console.error('Backtest SSE connection error:', error);\n      // Mark nodes as error when there's a connection error\n      nodeContext.updateAgentNode(flowId, 'portfolio-start', {\n        status: 'ERROR',\n        message: 'Failed to connect to backtest service',\n      });\n      \n      // Update flow connection state to error\n      if (flowId) {\n        flowConnectionManager.setConnection(flowId, {\n          state: 'error',\n          error: error.message || 'Connection failed',\n          abortController: null,\n        });\n      }\n    });\n\n    // Return abort function\n    return () => {\n      controller.abort();\n      // Update connection state when manually aborted\n      if (flowId) {\n        flowConnectionManager.setConnection(flowId, {\n          state: 'idle',\n          abortController: null,\n        });\n      }\n    };\n  },\n};\n\nexport type { BacktestDayResult, BacktestPerformanceMetrics, BacktestRequest };\n"
  },
  {
    "path": "app/frontend/src/services/flow-service.ts",
    "content": "import { Flow } from '@/types/flow';\n\nconst API_BASE_URL = 'http://localhost:8000';\n\nexport interface CreateFlowRequest {\n  name: string;\n  description?: string;\n  nodes: any;\n  edges: any;\n  viewport?: any;\n  data?: any;\n  is_template?: boolean;\n  tags?: string[];\n}\n\nexport interface UpdateFlowRequest {\n  name?: string;\n  description?: string;\n  nodes?: any;\n  edges?: any;\n  viewport?: any;\n  data?: any;\n  is_template?: boolean;\n  tags?: string[];\n}\n\nexport const flowService = {\n  // Get all flows\n  async getFlows(): Promise<Flow[]> {\n    const response = await fetch(`${API_BASE_URL}/flows/`);\n    if (!response.ok) {\n      throw new Error('Failed to fetch flows');\n    }\n    return response.json();\n  },\n\n  // Get a specific flow\n  async getFlow(id: number): Promise<Flow> {\n    const response = await fetch(`${API_BASE_URL}/flows/${id}`);\n    if (!response.ok) {\n      throw new Error('Failed to fetch flow');\n    }\n    return response.json();\n  },\n\n  // Create a new flow\n  async createFlow(data: CreateFlowRequest): Promise<Flow> {\n    const response = await fetch(`${API_BASE_URL}/flows/`, {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n      },\n      body: JSON.stringify(data),\n    });\n    if (!response.ok) {\n      throw new Error('Failed to create flow');\n    }\n    return response.json();\n  },\n\n  // Update an existing flow\n  async updateFlow(id: number, data: UpdateFlowRequest): Promise<Flow> {\n    const response = await fetch(`${API_BASE_URL}/flows/${id}`, {\n      method: 'PUT',\n      headers: {\n        'Content-Type': 'application/json',\n      },\n      body: JSON.stringify(data),\n    });\n    if (!response.ok) {\n      throw new Error('Failed to update flow');\n    }\n    return response.json();\n  },\n\n  // Delete a flow\n  async deleteFlow(id: number): Promise<void> {\n    const response = await fetch(`${API_BASE_URL}/flows/${id}`, {\n      method: 'DELETE',\n    });\n    if (!response.ok) {\n      throw new Error('Failed to delete flow');\n    }\n  },\n\n  // Duplicate a flow\n  async duplicateFlow(id: number, newName?: string): Promise<Flow> {\n    const url = `${API_BASE_URL}/flows/${id}/duplicate${newName ? `?new_name=${encodeURIComponent(newName)}` : ''}`;\n    const response = await fetch(url, {\n      method: 'POST',\n    });\n    if (!response.ok) {\n      throw new Error('Failed to duplicate flow');\n    }\n    return response.json();\n  },\n\n  // Create a default flow for new users\n  async createDefaultFlow(nodes: any, edges: any, viewport?: any): Promise<Flow> {\n    return this.createFlow({\n      name: 'My First Flow',\n      description: 'Welcome to AI Hedge Fund! Start building your flow here.',\n      nodes,\n      edges,\n      viewport,\n    });\n  },\n}; "
  },
  {
    "path": "app/frontend/src/services/sidebar-storage.ts",
    "content": "export interface SidebarStates {\n  leftCollapsed: boolean;\n  rightCollapsed: boolean;\n  bottomCollapsed: boolean;\n}\n\nexport class SidebarStorageService {\n  private static readonly LEFT_SIDEBAR_KEY = 'ai-hedge-fund-left-sidebar-collapsed';\n  private static readonly RIGHT_SIDEBAR_KEY = 'ai-hedge-fund-right-sidebar-collapsed';\n  private static readonly BOTTOM_PANEL_KEY = 'ai-hedge-fund-bottom-panel-collapsed';\n\n  /**\n   * Save left sidebar collapsed state to localStorage\n   */\n  static saveLeftSidebarState(isCollapsed: boolean): boolean {\n    try {\n      localStorage.setItem(this.LEFT_SIDEBAR_KEY, JSON.stringify(isCollapsed));\n      return true;\n    } catch (error) {\n      console.error('Failed to save left sidebar state to localStorage:', error);\n      return false;\n    }\n  }\n\n  /**\n   * Save right sidebar collapsed state to localStorage\n   */\n  static saveRightSidebarState(isCollapsed: boolean): boolean {\n    try {\n      localStorage.setItem(this.RIGHT_SIDEBAR_KEY, JSON.stringify(isCollapsed));\n      return true;\n    } catch (error) {\n      console.error('Failed to save right sidebar state to localStorage:', error);\n      return false;\n    }\n  }\n\n  /**\n   * Save bottom panel collapsed state to localStorage\n   */\n  static saveBottomPanelState(isCollapsed: boolean): boolean {\n    try {\n      localStorage.setItem(this.BOTTOM_PANEL_KEY, JSON.stringify(isCollapsed));\n      return true;\n    } catch (error) {\n      console.error('Failed to save bottom panel state to localStorage:', error);\n      return false;\n    }\n  }\n\n  /**\n   * Save both sidebar states to localStorage\n   */\n  static saveSidebarStates(states: SidebarStates): boolean {\n    try {\n      const leftSuccess = this.saveLeftSidebarState(states.leftCollapsed);\n      const rightSuccess = this.saveRightSidebarState(states.rightCollapsed);\n      const bottomSuccess = this.saveBottomPanelState(states.bottomCollapsed);\n      return leftSuccess && rightSuccess && bottomSuccess;\n    } catch (error) {\n      console.error('Failed to save sidebar states to localStorage:', error);\n      return false;\n    }\n  }\n\n  /**\n   * Load left sidebar collapsed state from localStorage\n   */\n  static loadLeftSidebarState(defaultValue: boolean = false): boolean {\n    try {\n      const saved = localStorage.getItem(this.LEFT_SIDEBAR_KEY);\n      if (saved === null) {\n        return defaultValue;\n      }\n      \n      const parsed = JSON.parse(saved);\n      return typeof parsed === 'boolean' ? parsed : defaultValue;\n    } catch (error) {\n      console.error('Failed to load left sidebar state from localStorage:', error);\n      return defaultValue;\n    }\n  }\n\n  /**\n   * Load right sidebar collapsed state from localStorage\n   */\n  static loadRightSidebarState(defaultValue: boolean = false): boolean {\n    try {\n      const saved = localStorage.getItem(this.RIGHT_SIDEBAR_KEY);\n      if (saved === null) {\n        return defaultValue;\n      }\n      \n      const parsed = JSON.parse(saved);\n      return typeof parsed === 'boolean' ? parsed : defaultValue;\n    } catch (error) {\n      console.error('Failed to load right sidebar state from localStorage:', error);\n      return defaultValue;\n    }\n  }\n\n  /**\n   * Load bottom panel collapsed state from localStorage\n   */\n  static loadBottomPanelState(defaultValue: boolean = true): boolean {\n    try {\n      const saved = localStorage.getItem(this.BOTTOM_PANEL_KEY);\n      if (saved === null) {\n        return defaultValue;\n      }\n      \n      const parsed = JSON.parse(saved);\n      return typeof parsed === 'boolean' ? parsed : defaultValue;\n    } catch (error) {\n      console.error('Failed to load bottom panel state from localStorage:', error);\n      return defaultValue;\n    }\n  }\n\n  /**\n   * Load both sidebar states from localStorage\n   */\n  static loadSidebarStates(defaultStates: SidebarStates = { leftCollapsed: false, rightCollapsed: false, bottomCollapsed: true }): SidebarStates {\n    return {\n      leftCollapsed: this.loadLeftSidebarState(defaultStates.leftCollapsed),\n      rightCollapsed: this.loadRightSidebarState(defaultStates.rightCollapsed),\n      bottomCollapsed: this.loadBottomPanelState(defaultStates.bottomCollapsed),\n    };\n  }\n\n  /**\n   * Clear left sidebar state from localStorage\n   */\n  static clearLeftSidebarState(): boolean {\n    try {\n      localStorage.removeItem(this.LEFT_SIDEBAR_KEY);\n      return true;\n    } catch (error) {\n      console.error('Failed to clear left sidebar state from localStorage:', error);\n      return false;\n    }\n  }\n\n  /**\n   * Clear right sidebar state from localStorage\n   */\n  static clearRightSidebarState(): boolean {\n    try {\n      localStorage.removeItem(this.RIGHT_SIDEBAR_KEY);\n      return true;\n    } catch (error) {\n      console.error('Failed to clear right sidebar state from localStorage:', error);\n      return false;\n    }\n  }\n\n  /**\n   * Clear bottom panel state from localStorage\n   */\n  static clearBottomPanelState(): boolean {\n    try {\n      localStorage.removeItem(this.BOTTOM_PANEL_KEY);\n      return true;\n    } catch (error) {\n      console.error('Failed to clear bottom panel state from localStorage:', error);\n      return false;\n    }\n  }\n\n  /**\n   * Clear all sidebar and panel states from localStorage\n   */\n  static clearSidebarStates(): boolean {\n    try {\n      const leftSuccess = this.clearLeftSidebarState();\n      const rightSuccess = this.clearRightSidebarState();\n      const bottomSuccess = this.clearBottomPanelState();\n      return leftSuccess && rightSuccess && bottomSuccess;\n    } catch (error) {\n      console.error('Failed to clear sidebar states from localStorage:', error);\n      return false;\n    }\n  }\n\n  /**\n   * Reset all sidebar and panel states to default values\n   */\n  static resetToDefaults(): boolean {\n    try {\n      // Clear all existing states\n      const cleared = this.clearSidebarStates();\n      \n      if (cleared) {\n        console.log('Successfully reset sidebar states to defaults');\n      }\n      \n      return cleared;\n    } catch (error) {\n      console.error('Failed to reset sidebar states to defaults:', error);\n      return false;\n    }\n  }\n\n  /**\n   * Check if left sidebar state exists in localStorage\n   */\n  static hasLeftSidebarState(): boolean {\n    try {\n      return localStorage.getItem(this.LEFT_SIDEBAR_KEY) !== null;\n    } catch (error) {\n      console.error('Failed to check left sidebar state existence in localStorage:', error);\n      return false;\n    }\n  }\n\n  /**\n   * Check if right sidebar state exists in localStorage\n   */\n  static hasRightSidebarState(): boolean {\n    try {\n      return localStorage.getItem(this.RIGHT_SIDEBAR_KEY) !== null;\n    } catch (error) {\n      console.error('Failed to check right sidebar state existence in localStorage:', error);\n      return false;\n    }\n  }\n\n  /**\n   * Check if both sidebar states exist in localStorage\n   */\n  static hasSidebarStates(): { left: boolean; right: boolean } {\n    return {\n      left: this.hasLeftSidebarState(),\n      right: this.hasRightSidebarState(),\n    };\n  }\n} "
  },
  {
    "path": "app/frontend/src/services/tab-service.ts",
    "content": "import { Settings } from '@/components/settings/settings';\nimport { FlowTabContent } from '@/components/tabs/flow-tab-content';\nimport { Flow } from '@/types/flow';\nimport { ReactNode, createElement } from 'react';\n\nexport interface TabData {\n  type: 'flow' | 'settings';\n  title: string;\n  flow?: Flow;\n  metadata?: Record<string, any>;\n}\n\nexport class TabService {\n  static createTabContent(tabData: TabData): ReactNode {\n    switch (tabData.type) {\n      case 'flow':\n        if (!tabData.flow) {\n          throw new Error('Flow tab requires flow data');\n        }\n        return createElement(FlowTabContent, { flow: tabData.flow });\n      \n      case 'settings':\n        return createElement(Settings);\n      \n      default:\n        throw new Error(`Unsupported tab type: ${tabData.type}`);\n    }\n  }\n\n  static createFlowTab(flow: Flow): TabData & { content: ReactNode } {\n    return {\n      type: 'flow',\n      title: flow.name,\n      flow: flow,\n      content: TabService.createTabContent({ type: 'flow', title: flow.name, flow }),\n    };\n  }\n\n  static createSettingsTab(): TabData & { content: ReactNode } {\n    return {\n      type: 'settings',\n      title: 'Settings',\n      content: TabService.createTabContent({ type: 'settings', title: 'Settings' }),\n    };\n  }\n\n  // Restore tab content for persisted tabs (used when loading from localStorage)\n  static restoreTabContent(tabData: TabData): ReactNode {\n    return TabService.createTabContent(tabData);\n  }\n\n  // Helper method to restore a complete tab from saved data\n  static restoreTab(savedTab: TabData): TabData & { content: ReactNode } {\n    switch (savedTab.type) {\n      case 'flow':\n        if (!savedTab.flow) {\n          throw new Error('Flow tab requires flow data for restoration');\n        }\n        return TabService.createFlowTab(savedTab.flow);\n      \n      case 'settings':\n        return TabService.createSettingsTab();\n      \n      default:\n        throw new Error(`Cannot restore unsupported tab type: ${savedTab.type}`);\n    }\n  }\n} "
  },
  {
    "path": "app/frontend/src/services/types.ts",
    "content": "// Shared types for API requests and responses\nexport enum ModelProvider {\n  OPENAI = 'OpenAI',\n  ANTHROPIC = 'Anthropic',\n  GROQ = 'Groq',\n  OLLAMA = 'Ollama',\n}\n\nexport interface AgentModelConfig {\n  agent_id: string;\n  model_name?: string;\n  model_provider?: ModelProvider;\n}\n\nexport interface GraphNode {\n  id: string;\n  type?: string;\n  data?: any;\n  position?: { x: number; y: number };\n}\n\nexport interface GraphEdge {\n  id: string;\n  source: string;\n  target: string;\n  type?: string;\n  data?: any;\n}\n\nexport interface PortfolioPosition {\n  ticker: string;\n  quantity: number;\n  trade_price: number;\n}\n\n// Base interface for shared fields between HedgeFundRequest and BacktestRequest\nexport interface BaseHedgeFundRequest {\n  tickers: string[];\n  graph_nodes: GraphNode[];\n  graph_edges: GraphEdge[];\n  agent_models?: AgentModelConfig[];\n  model_name?: string;\n  model_provider?: ModelProvider;\n  margin_requirement?: number;\n  portfolio_positions?: PortfolioPosition[];\n}\n\nexport interface HedgeFundRequest extends BaseHedgeFundRequest {\n  end_date?: string;\n  start_date?: string;\n  initial_cash?: number;\n}\n\nexport interface BacktestRequest extends BaseHedgeFundRequest {\n  start_date: string;\n  end_date: string;\n  initial_capital?: number;\n}\n\nexport interface BacktestDayResult {\n  date: string;\n  portfolio_value: number;\n  cash: number;\n  decisions: Record<string, any>;\n  executed_trades: Record<string, number>;\n  analyst_signals: Record<string, any>;\n  current_prices: Record<string, number>;\n  long_exposure: number;\n  short_exposure: number;\n  gross_exposure: number;\n  net_exposure: number;\n  long_short_ratio: number | null;\n}\n\nexport interface BacktestPerformanceMetrics {\n  sharpe_ratio?: number;\n  sortino_ratio?: number;\n  max_drawdown?: number;\n  max_drawdown_date?: string;\n  long_short_ratio?: number;\n  gross_exposure?: number;\n  net_exposure?: number;\n} "
  },
  {
    "path": "app/frontend/src/types/flow.ts",
    "content": "export interface Flow {\n  id: number;\n  name: string;\n  description?: string;\n  nodes: any;\n  edges: any;\n  viewport?: any;\n  data?: any;\n  is_template: boolean;\n  tags?: string[];\n  created_at: string;\n  updated_at?: string;\n} "
  },
  {
    "path": "app/frontend/src/utils/date-utils.ts",
    "content": "/**\n * Formats an ISO timestamp to local browser time (HH:MM:SS.ms)\n * @param timestamp ISO timestamp string\n * @returns Formatted time string in local timezone\n */\nexport function formatTimeFromTimestamp(timestamp: string): string {\n  // Parse ISO timestamp string into Date object\n  const date = new Date(timestamp);\n  \n  // Get minutes, seconds separately\n  const minutes = date.getMinutes().toString().padStart(2, '0');\n  const seconds = date.getSeconds().toString().padStart(2, '0');\n  \n  // Get milliseconds and format to 2 digits\n  const ms = Math.floor((date.getMilliseconds() / 10));\n  const twoDigitMs = ms.toString().padStart(2, '0');\n  \n  // Format time with milliseconds\n  const hours12 = date.getHours() % 12 || 12;\n  const ampm = date.getHours() >= 12 ? 'PM' : 'AM';\n  const hours12Str = hours12.toString().padStart(2, '0');\n  \n  return `${hours12Str}:${minutes}:${seconds}.${twoDigitMs} ${ampm}`;\n} "
  },
  {
    "path": "app/frontend/src/utils/text-utils.ts",
    "content": "import { extractBaseAgentKey } from '@/data/node-mappings';\n\n/**\n * Splits text into smaller paragraphs for better readability.\n * @param text The text to format\n * @returns An array of formatted paragraphs\n */\nexport function formatTextIntoParagraphs(text: string): string[] {\n  if (!text) return [];\n  \n  // First split by any existing paragraphs\n  const paragraphs = text.split('\\n').filter(p => p.trim().length > 0);\n  \n  const formattedParagraphs: string[] = [];\n  \n  // Process each paragraph\n  paragraphs.forEach(paragraph => {\n    // Split into sentences using period, question mark, or exclamation mark followed by space\n    // Modified to avoid treating decimal points as sentence endings\n    const sentences = paragraph.match(/[^.!?]+(?:\\.\\d+[^.!?]*|[.!?]+\\s*)/g) || [paragraph];\n    \n    let currentChunk = '';\n    let sentenceCount = 0;\n    \n    // Group every 2-3 sentences\n    sentences.forEach(sentence => {\n      currentChunk += sentence;\n      sentenceCount++;\n      \n      // After 2-3 sentences, create a new paragraph\n      // Only consider it a break point if it's not part of a decimal number\n      if (sentenceCount >= 2 && (sentenceCount % 3 === 0 || (sentence.endsWith('. ') && !sentence.match(/\\d\\.\\s*$/)))) {\n        formattedParagraphs.push(currentChunk.trim());\n        currentChunk = '';\n        sentenceCount = 0;\n      }\n    });\n    \n    // Add any remaining text\n    if (currentChunk.trim()) {\n      formattedParagraphs.push(currentChunk.trim());\n    }\n  });\n  \n  return formattedParagraphs;\n}\n\n/**\n * Checks if a string is valid JSON format\n * @param text The text to check\n * @returns true if the text is valid JSON or valid JS object, false otherwise\n */\nexport function isJsonString(text: string): boolean {\n  if (!text) return false;\n  \n  // Trim the text to remove any leading/trailing whitespace\n  const trimmedText = text.trim();\n  \n  // Quick initial check - if it doesn't look like JSON at all, return early\n  if (\n    !(\n      (trimmedText.startsWith('{') && trimmedText.endsWith('}')) || \n      (trimmedText.startsWith('[') && trimmedText.endsWith(']'))\n    )\n  ) {\n    return false;\n  }\n  \n  // More sophisticated check for Python json.dumps() output or valid JSON\n  // Count the number of keys/properties in the string to validate it looks like JSON\n  const bracketMatches = trimmedText.match(/[{}[\\]]/g) || [];\n  const colonMatches = trimmedText.match(/:/g) || [];\n  \n  // For objects, we should have colons separating keys and values\n  // And balanced brackets (though this is a simple heuristic)\n  if (trimmedText.startsWith('{') && (colonMatches.length === 0 || bracketMatches.length % 2 !== 0)) {\n    return false;\n  }\n  \n  try {\n    // First try standard JSON parsing\n    JSON.parse(trimmedText);\n    return true;\n  } catch (e) {\n    // If standard JSON parsing fails, try to handle JavaScript-like objects with NaN/undefined/Infinity\n    try {\n      // Replace JavaScript-specific values with JSON-compatible ones for validation\n      const normalizedText = trimmedText\n        .replace(/\\bNaN\\b/g, 'null')\n        .replace(/\\bundefined\\b/g, 'null')\n        .replace(/\\bInfinity\\b/g, 'null')\n        .replace(/\\b-Infinity\\b/g, 'null');\n      \n      JSON.parse(normalizedText);\n      return true;\n    } catch (e2) {\n      return false;\n    }\n  }\n}\n\n/**\n * Formats content that could be either JSON or regular text\n * @param content The content to format\n * @returns An object containing the formatted content and whether it's JSON\n */\nexport function formatContent(content: string): { \n  isJson: boolean; \n  formattedContent: string | string[]; \n} {\n  if (!content) {\n    return { isJson: false, formattedContent: [] };\n  }\n  \n  if (isJsonString(content)) {\n    try {\n      // First try standard JSON parsing\n      const parsedJson = JSON.parse(content);\n      const formattedJson = JSON.stringify(parsedJson, null, 2);\n      return { isJson: true, formattedContent: formattedJson };\n    } catch (e) {\n      // If standard JSON parsing fails, try to handle JavaScript-like objects with NaN/undefined/Infinity\n      try {\n        // Track the positions of special values before replacement\n        const specialValues: Array<{value: string, regex: RegExp}> = [\n          {value: 'NaN', regex: /\\bNaN\\b/g},\n          {value: 'undefined', regex: /\\bundefined\\b/g},\n          {value: 'Infinity', regex: /\\bInfinity\\b/g},\n          {value: '-Infinity', regex: /\\b-Infinity\\b/g}\n        ];\n        \n        // Replace JavaScript-specific values with JSON-compatible ones for parsing\n        let normalizedContent = content;\n        specialValues.forEach(({regex}) => {\n          normalizedContent = normalizedContent.replace(regex, 'null');\n        });\n        \n        const parsedJson = JSON.parse(normalizedContent);\n        let formattedJson = JSON.stringify(parsedJson, null, 2);\n        \n        // Now restore the special values by parsing the original content structure\n        // This is a more precise approach than the previous heuristic\n        const originalMatches: Array<{value: string, positions: number[]}> = [];\n        \n        specialValues.forEach(({value, regex}) => {\n          const matches = [...content.matchAll(regex)];\n          if (matches.length > 0) {\n            originalMatches.push({\n              value,\n              positions: matches.map(m => m.index!).sort((a, b) => b - a) // reverse order for replacement\n            });\n          }\n        });\n        \n        // Replace nulls back to original special values in reverse order to maintain positions\n        originalMatches.forEach(({value, positions}) => {\n          positions.forEach(() => {\n            // Find the next occurrence of null in the formatted JSON and replace it\n            const nullIndex = formattedJson.indexOf('null');\n            if (nullIndex !== -1) {\n              formattedJson = formattedJson.substring(0, nullIndex) + value + formattedJson.substring(nullIndex + 4);\n            }\n          });\n        });\n        \n        return { isJson: true, formattedContent: formattedJson };\n      } catch (e2) {\n        // If both attempts fail, fall back to text formatting\n        return { isJson: false, formattedContent: formatTextIntoParagraphs(content) };\n      }\n    }\n  }\n  \n  // Format as regular text\n  return { isJson: false, formattedContent: formatTextIntoParagraphs(content) };\n}\n\n/**\n * Creates a simple syntax-highlighted version of JSON\n * @param jsonString The JSON string to format\n * @returns HTML string with basic syntax highlighting classes\n */\nexport function createHighlightedJson(jsonString: string): string {\n  if (!jsonString) return '';\n  \n  try {\n    // Ensure the JSON is properly formatted\n    const obj = JSON.parse(jsonString);\n    const formattedJson = JSON.stringify(obj, null, 2);\n    \n    // Replace JSON elements with styled spans\n    let highlightedJson = formattedJson\n      .replace(/&/g, '&amp;')\n      .replace(/</g, '&lt;')\n      .replace(/>/g, '&gt;')\n      .replace(/(\"(\\\\u[a-zA-Z0-9]{4}|\\\\[^u]|[^\\\\\"])*\"(\\s*:)?|\\b(true|false|null)\\b|-?\\d+(?:\\.\\d*)?(?:[eE][+\\-]?\\d+)?)/g, \n        (match) => {\n          // Colors matching the screenshot\n          if (/^\"/.test(match)) {\n            if (/:$/.test(match)) {\n              return `<span class=\"text-primary\">${match}</span>`;\n            } else {\n              // String values are orange\n              return `<span style=\"color: #d18f52\">${match}</span>`;\n            }\n          } else if (/true|false/.test(match)) {\n            // Boolean values are purple\n            return `<span style=\"color: #c586c0\">${match}</span>`;\n          } else if (/null/.test(match)) {\n            // Null values are purple\n            return `<span style=\"color: #c586c0\">${match}</span>`;\n          } else {\n            // Numbers are light blue/teal\n            return `<span class=\"text-blue-500\">${match}</span>`;\n          }\n        }\n      );\n    \n    // Style brackets and commas\n    highlightedJson = highlightedJson\n      // Brackets and braces - light gray\n      .replace(/(\\{|\\}|\\[|\\])/g, '<span style=\"color: #d4d4d4\">$1</span>')\n      // Commas\n      .replace(/,/g, '<span style=\"color: #d4d4d4\">,</span>');\n    \n    return highlightedJson;\n  } catch (e) {\n    // If there's an error parsing/formatting, return the original string\n    return jsonString\n      .replace(/&/g, '&amp;')\n      .replace(/</g, '&lt;')\n      .replace(/>/g, '&gt;');\n  }\n}\n\n/**\n * Creates clean display names for agents by removing unique ID suffixes and adding numbering for duplicates\n * @param agentIds Array of unique agent IDs (e.g., [\"warren_buffett_abc123\", \"fundamentals_xyz789\"])\n * @returns Map of agent ID to clean display name (e.g., \"Warren Buffett\", \"Fundamentals #1\")\n */\nexport function createAgentDisplayNames(agentIds: string[]): Map<string, string> {\n  // Extract base agent keys and count occurrences\n  const baseAgentCounts = new Map<string, number>();\n  const baseAgentKeys = agentIds.map(id => extractBaseAgentKey(id));\n  \n  baseAgentKeys.forEach(baseKey => {\n    baseAgentCounts.set(baseKey, (baseAgentCounts.get(baseKey) || 0) + 1);\n  });\n  \n  // Create display names with numbering for duplicates\n  const baseAgentCounters = new Map<string, number>();\n  const displayNames = new Map<string, string>();\n  \n  agentIds.forEach(agentId => {\n    const baseKey = extractBaseAgentKey(agentId);\n    const count = baseAgentCounts.get(baseKey) || 1;\n    \n    // Convert snake_case to readable format\n    const readableName = baseKey.replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase());\n    \n    if (count > 1) {\n      const currentCounter = (baseAgentCounters.get(baseKey) || 0) + 1;\n      baseAgentCounters.set(baseKey, currentCounter);\n      displayNames.set(agentId, `${readableName} #${currentCounter}`);\n    } else {\n      displayNames.set(agentId, readableName);\n    }\n  });\n  \n  return displayNames;\n} "
  },
  {
    "path": "app/frontend/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "app/frontend/tailwind.config.ts",
    "content": "import tailwindcssTypography from '@tailwindcss/typography';\nimport type { Config } from 'tailwindcss';\nimport tailwindcssAnimate from 'tailwindcss-animate';\n\nconst config: Config = {\n  darkMode: ['class', 'class'],\n  content: [\n    './index.html',\n    './src/**/*.{js,ts,jsx,tsx}',\n  ],\n  theme: {\n  \tfontFamily: {\n  \t\tsans: [\n  \t\t\t'Inter',\n  \t\t\t'sans-serif'\n  \t\t],\n  \t\tmono: [\n  \t\t\t'Inter',\n  \t\t\t'sans-serif'\n  \t\t]\n  \t},\n  \textend: {\n  \t\tfontSize: {\n  \t\t\ttitle: [\n  \t\t\t\t'0.875rem',\n  \t\t\t\t{\n  \t\t\t\t\tlineHeight: '1.25rem'\n  \t\t\t\t}\n  \t\t\t],\n  \t\t\tsubtitle: [\n  \t\t\t\t'0.625rem',\n  \t\t\t\t{\n  \t\t\t\t\tlineHeight: '1rem'\n  \t\t\t\t}\n  \t\t\t]\n  \t\t},\n  \t\tborderRadius: {\n  \t\t\tlg: 'var(--radius)',\n  \t\t\tmd: 'calc(var(--radius) - 2px)',\n  \t\t\tsm: 'calc(var(--radius) - 4px)'\n  \t\t},\n  \t\tcolors: {\n  \t\t\tbackground: 'hsl(var(--background))',\n  \t\t\tforeground: 'hsl(var(--foreground))',\n  \t\t\tcard: {\n  \t\t\t\tDEFAULT: 'hsl(var(--card))',\n  \t\t\t\tforeground: 'hsl(var(--card-foreground))'\n  \t\t\t},\n  \t\t\tpopover: {\n  \t\t\t\tDEFAULT: 'hsl(var(--popover))',\n  \t\t\t\tforeground: 'hsl(var(--popover-foreground))'\n  \t\t\t},\n  \t\t\tprimary: {\n  \t\t\t\tDEFAULT: 'hsl(var(--primary))',\n  \t\t\t\tforeground: 'hsl(var(--primary-foreground))'\n  \t\t\t},\n  \t\t\tsecondary: {\n  \t\t\t\tDEFAULT: 'hsl(var(--secondary))',\n  \t\t\t\tforeground: 'hsl(var(--secondary-foreground))'\n  \t\t\t},\n  \t\t\tmuted: {\n  \t\t\t\tDEFAULT: 'hsl(var(--muted))',\n  \t\t\t\tforeground: 'hsl(var(--muted-foreground))'\n  \t\t\t},\n  \t\t\taccent: {\n  \t\t\t\tDEFAULT: 'hsl(var(--accent))',\n  \t\t\t\tforeground: 'hsl(var(--accent-foreground))'\n  \t\t\t},\n  \t\t\tdestructive: {\n  \t\t\t\tDEFAULT: 'hsl(var(--destructive))',\n  \t\t\t\tforeground: 'hsl(var(--destructive-foreground))'\n  \t\t\t},\n  \t\t\tpanel: 'hsl(var(--panel-bg))',\n  \t\t\t'ramp-grey': {\n  \t\t\t\t'100': 'var(--ramp-grey-100)',\n  \t\t\t\t'200': 'var(--ramp-grey-200)',\n  \t\t\t\t'300': 'var(--ramp-grey-300)',\n  \t\t\t\t'400': 'var(--ramp-grey-400)',\n  \t\t\t\t'500': 'var(--ramp-grey-500)',\n  \t\t\t\t'600': 'var(--ramp-grey-600)',\n  \t\t\t\t'700': 'var(--ramp-grey-700)',\n  \t\t\t\t'800': 'var(--ramp-grey-800)',\n  \t\t\t\t'900': 'var(--ramp-grey-900)',\n  \t\t\t\t'1000': 'var(--ramp-grey-1000)'\n  \t\t\t},\n  \t\t\tborder: 'hsl(var(--border))',\n  \t\t\tinput: 'hsl(var(--input))',\n  \t\t\tring: 'hsl(var(--ring))',\n  \t\t\tnode: {\n  \t\t\t\tDEFAULT: 'hsl(var(--node))',\n  \t\t\t\tforeground: 'hsl(var(--node-foreground))',\n  \t\t\t\thandle: 'hsl(var(--node-handle))',\n  \t\t\t\tborder: 'hsl(var(--node-border))'\n  \t\t\t},\n  \t\t\tchart: {\n  \t\t\t\t'1': 'hsl(var(--chart-1))',\n  \t\t\t\t'2': 'hsl(var(--chart-2))',\n  \t\t\t\t'3': 'hsl(var(--chart-3))',\n  \t\t\t\t'4': 'hsl(var(--chart-4))',\n  \t\t\t\t'5': 'hsl(var(--chart-5))'\n  \t\t\t},\n  \t\t\tsidebar: {\n  \t\t\t\tDEFAULT: 'hsl(var(--sidebar-background))',\n  \t\t\t\tforeground: 'hsl(var(--sidebar-foreground))',\n  \t\t\t\tprimary: 'hsl(var(--sidebar-primary))',\n  \t\t\t\t'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',\n  \t\t\t\taccent: 'hsl(var(--sidebar-accent))',\n  \t\t\t\t'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',\n  \t\t\t\tborder: 'hsl(var(--sidebar-border))',\n  \t\t\t\tring: 'hsl(var(--sidebar-ring))'\n  \t\t\t}\n  \t\t},\n  \t\tkeyframes: {\n  \t\t\t'accordion-down': {\n  \t\t\t\tfrom: {\n  \t\t\t\t\theight: '0'\n  \t\t\t\t},\n  \t\t\t\tto: {\n  \t\t\t\t\theight: 'var(--radix-accordion-content-height)'\n  \t\t\t\t}\n  \t\t\t},\n  \t\t\t'accordion-up': {\n  \t\t\t\tfrom: {\n  \t\t\t\t\theight: 'var(--radix-accordion-content-height)'\n  \t\t\t\t},\n  \t\t\t\tto: {\n  \t\t\t\t\theight: '0'\n  \t\t\t\t}\n  \t\t\t}\n  \t\t},\n  \t\tanimation: {\n  \t\t\t'accordion-down': 'accordion-down 0.2s ease-out',\n  \t\t\t'accordion-up': 'accordion-up 0.2s ease-out'\n  \t\t}\n  \t}\n  },\n  plugins: [\n    tailwindcssAnimate,\n    tailwindcssTypography\n  ],\n};\n\nexport default config;\n"
  },
  {
    "path": "app/frontend/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\n      \"ES2020\",\n      \"DOM\",\n      \"DOM.Iterable\"\n    ],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    /* Path Alias */\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\n        \"./src/*\"\n      ]\n    }\n  },\n  \"include\": [\n    \"src\"\n  ],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.node.json\"\n    }\n  ]\n}"
  },
  {
    "path": "app/frontend/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "app/frontend/vite.config.ts",
    "content": "import react from '@vitejs/plugin-react'\nimport path from 'path'\nimport { defineConfig } from 'vite'\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  plugins: [react()],\n  resolve: {\n    alias: {\n      \"@\": path.resolve(__dirname, \"./src\"),\n    },\n  },\n})\n"
  },
  {
    "path": "app/run.bat",
    "content": "@echo off\nREM AI Hedge Fund Web Application Setup and Runner (Windows)\nREM This script makes it easy for non-technical users to run the full web application\n\nREM Colors for output\nset \"INFO=[INFO]\"\nset \"SUCCESS=[SUCCESS]\"\nset \"WARNING=[WARNING]\"\nset \"ERROR=[ERROR]\"\n\nREM Check Node.js\nwhere node >nul 2>&1\nif %errorlevel% neq 0 (\n    echo %ERROR% Node.js is not installed. Please install from https://nodejs.org/\n    pause\n    exit /b 1\n)\n\nREM Check npm\nwhere npm >nul 2>&1\nif %errorlevel% neq 0 (\n    echo %ERROR% npm is not installed. Please install Node.js from https://nodejs.org/\n    pause\n    exit /b 1\n)\n\nREM Check Python (or python3)\nwhere python >nul 2>&1\nif %errorlevel% neq 0 (\n    where python3 >nul 2>&1\n    if %errorlevel% neq 0 (\n        echo %ERROR% Python is not installed. Please install from https://python.org/\n        pause\n        exit /b 1\n    )\n)\n\nREM Check Poetry\nwhere poetry >nul 2>&1\nif %errorlevel% neq 0 (\n    REM Try alternative Poetry locations\n    if exist \"%APPDATA%\\Python\\Scripts\\poetry.exe\" (\n        echo %INFO% Poetry found in user directory. Adding to PATH temporarily...\n        set \"PATH=%APPDATA%\\Python\\Scripts;%PATH%\"\n        goto :poetry_found\n    )\n    if exist \"%APPDATA%\\Python\\Python311\\Scripts\\poetry.exe\" (\n        echo %INFO% Poetry found in Python311 user directory. Adding to PATH temporarily...\n        set \"PATH=%APPDATA%\\Python\\Python311\\Scripts;%PATH%\"\n        goto :poetry_found\n    )\n    echo %WARNING% Poetry is not installed.\n    echo %INFO% Poetry is required to manage Python dependencies for this project.\n    echo.\n    set /p install_poetry=\"Would you like to install Poetry automatically? (y/N): \"\n    if /i \"%install_poetry%\"==\"y\" (\n        echo %INFO% Installing Poetry using official installer...\n        \n        REM Download the installer script first, then run it\n        curl -sSL https://install.python-poetry.org -o \"%TEMP%\\install-poetry.py\"\n        if %errorlevel% neq 0 (\n            echo %WARNING% Failed to download Poetry installer. Trying alternative method...\n            python -m pip install --user poetry\n            if %errorlevel% neq 0 (\n                echo %ERROR% Failed to install Poetry automatically.\n                echo %INFO% This is likely due to Windows permission restrictions or network issues.\n                echo.\n                echo %INFO% SOLUTION: Please try one of these options:\n                echo %INFO% 1. Run this script as Administrator\n                echo %INFO% 2. Manual install: curl -sSL https://install.python-poetry.org -o install-poetry.py\n                echo %INFO% 3. Then run: python install-poetry.py\n                echo.\n                pause\n                exit /b 1\n            )\n        ) else (\n            REM Run the downloaded installer\n            python \"%TEMP%\\install-poetry.py\"\n            if %errorlevel% neq 0 (\n                echo %ERROR% Failed to install Poetry with official installer.\n                echo %INFO% Trying alternative method with pip...\n                python -m pip install --user poetry\n                if %errorlevel% neq 0 (\n                    echo %ERROR% Failed to install Poetry automatically.\n                    echo %INFO% This is likely due to Windows permission restrictions or network issues.\n                    echo.\n                    echo %INFO% SOLUTION: Please try one of these options:\n                    echo %INFO% 1. Run this script as Administrator\n                    echo %INFO% 2. Manual install: curl -sSL https://install.python-poetry.org -o install-poetry.py\n                    echo %INFO% 3. Then run: python install-poetry.py\n                    echo.\n                    pause\n                    exit /b 1\n                )\n            )\n            REM Clean up the temporary installer file\n            del \"%TEMP%\\install-poetry.py\" >nul 2>&1\n        )\n        echo %SUCCESS% Poetry installed successfully!\n        echo %INFO% Refreshing environment...\n        call refreshenv >nul 2>&1 || echo %WARNING% Could not refresh environment.\n        \n        REM Check if Poetry is now available\n        where poetry >nul 2>&1\n        if %errorlevel% neq 0 (\n            echo %WARNING% Poetry was installed but is not yet available in PATH.\n            echo %INFO% You may need to restart your terminal or add Poetry to your PATH.\n            echo %INFO% Poetry is typically installed to: %APPDATA%\\Python\\Scripts\n            echo %INFO% You can also try running the script again after restarting your terminal.\n            pause\n            exit /b 1\n        )\n        echo %SUCCESS% Poetry is now available and ready to use!\n    ) else (\n        echo %ERROR% Poetry is required to run this application.\n        echo %ERROR% Please install Poetry manually using one of these methods:\n        echo %INFO% 1. Download installer: curl -sSL https://install.python-poetry.org -o install-poetry.py\n        echo %INFO% 2. Run installer: python install-poetry.py\n        echo %INFO% 3. Restart your terminal and run this script again\n        pause\n        exit /b 1\n    )\n)\n\n:poetry_found\necho %SUCCESS% Poetry is available!\n\nREM Ensure correct working directory\nif not exist \"frontend\" (\n    echo %ERROR% This script must be run from the app\\ directory\n    echo %ERROR% Please navigate to the app\\ directory and run: run.bat\n    pause\n    exit /b 1\n)\n\nif not exist \"backend\" (\n    echo %ERROR% This script must be run from the app\\ directory\n    echo %ERROR% Please navigate to the app\\ directory and run: run.bat\n    pause\n    exit /b 1\n)\n\necho.\necho %INFO% AI Hedge Fund Web Application Setup\necho %INFO% This script will install dependencies and start both frontend and backend services\necho.\n\nREM Check for .env\nif not exist \"..\\.env\" (\n    if exist \"..\\.env.example\" (\n        echo %WARNING% No .env file found. Creating from .env.example...\n        copy \"..\\.env.example\" \"..\\.env\"\n        echo %WARNING% Please edit ..\\.env to add your API keys:\n        echo %WARNING%   - OPENAI_API_KEY=your-openai-api-key\n        echo %WARNING%   - GROQ_API_KEY=your-groq-api-key\n        echo %WARNING%   - FINANCIAL_DATASETS_API_KEY=your-financial-datasets-api-key\n        echo.\n    ) else (\n        echo %ERROR% No .env or .env.example file found in the root directory.\n        echo %ERROR% Please create a .env file with your API keys.\n        pause\n        exit /b 1\n    )\n) else (\n    echo %SUCCESS% Environment file (.env)\n)\n\nREM Setup database\necho %INFO% Setting up database...\necho %INFO% Database: SQLite (hedge_fund.db)\necho %INFO% Location: Project root directory\necho %INFO% Tables will be created automatically on first backend startup\n\nif exist \"..\\hedge_fund.db\" (\n    echo %SUCCESS% Database file already exists\n) else (\n    echo %INFO% Database will be created when backend starts for the first time\n)\n\nREM Install backend dependencies\necho %INFO% Installing backend dependencies...\ncd backend\n\npoetry run python -c \"import uvicorn; import fastapi\" >nul 2>&1\nif %errorlevel% equ 0 (\n    echo %SUCCESS% Backend dependencies already installed\n) else (\n    echo %INFO% Installing Python dependencies with Poetry...\n    poetry install\n    if %errorlevel% neq 0 (\n        echo %ERROR% Failed to install backend dependencies\n        pause\n        exit /b 1\n    ) else (\n        echo %SUCCESS% Backend dependencies installed successfully\n    )\n)\n\ncd ..\n\nREM Install frontend dependencies\necho %INFO% Installing frontend dependencies...\ncd frontend\n\nif exist \"node_modules\" (\n    echo %SUCCESS% Frontend dependencies already installed\n) else (\n    echo %INFO% Installing Node.js dependencies...\n    npm install\n    if %errorlevel% neq 0 (\n        echo %ERROR% Failed to install frontend dependencies\n        pause\n        exit /b 1\n    )\n    echo %SUCCESS% Frontend dependencies installed\n)\n\ncd ..\n\nREM Start services\necho %INFO% Starting the AI Hedge Fund web application...\necho %INFO% Press Ctrl+C to stop all services\necho.\n\nREM Start backend\necho %INFO% Launching backend server...\nREM Run from project root to ensure proper Python imports\ncd ..\nstart /b poetry run uvicorn app.backend.main:app --reload --host 127.0.0.1 --port 8000\ncd app\n\ntimeout /t 3 /nobreak >nul\n\nREM Check database initialization\necho %INFO% Checking database initialization...\ntimeout /t 2 /nobreak >nul\n\nif exist \"..\\hedge_fund.db\" (\n    echo %SUCCESS% Database initialized successfully\n) else (\n    echo %WARNING% Database file not found, but will be created on first API call\n)\n\nREM Start frontend\necho %INFO% Launching frontend development server...\ncd frontend\nstart /b npm run dev\ncd ..\n\ntimeout /t 5 /nobreak >nul\n\necho %INFO% Opening browser...\ntimeout /t 2 /nobreak >nul\nstart http://localhost:5173\n\necho.\necho %SUCCESS% AI Hedge Fund web application is now running\necho %INFO% Frontend: http://localhost:5173\necho %INFO% Backend:  http://localhost:8000\necho %INFO% Docs:     http://localhost:8000/docs\necho %INFO% Database: SQLite (hedge_fund.db in project root)\necho.\necho %INFO% Press any key to stop both services...\npause >nul\n\nREM Stop services\ntaskkill /f /im \"uvicorn.exe\" >nul 2>&1\ntaskkill /f /im \"node.exe\" >nul 2>&1\n\necho %SUCCESS% Services stopped. Goodbye\npause\n"
  },
  {
    "path": "app/run.sh",
    "content": "#!/bin/bash\n\n# AI Hedge Fund Web Application Setup and Runner\n# This script makes it easy for non-technical users to run the full web application\n\nset -e  # Exit on any error\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\n# Function to print colored output\nprint_status() {\n    echo -e \"${BLUE}[INFO]${NC} $1\"\n}\n\nprint_success() {\n    echo -e \"${GREEN}[SUCCESS]${NC} $1\"\n}\n\nprint_warning() {\n    echo -e \"${YELLOW}[WARNING]${NC} $1\"\n}\n\nprint_error() {\n    echo -e \"${RED}[ERROR]${NC} $1\"\n}\n\n# Function to check if a command exists\ncommand_exists() {\n    command -v \"$1\" >/dev/null 2>&1\n}\n\n# Function to open browser\nopen_browser() {\n    local url=\"$1\"\n    if [[ \"$OSTYPE\" == \"darwin\"* ]]; then\n        # macOS\n        open \"$url\"\n    elif [[ \"$OSTYPE\" == \"linux-gnu\"* ]]; then\n        # Linux\n        if command_exists xdg-open; then\n            xdg-open \"$url\"\n        elif command_exists firefox; then\n            firefox \"$url\" &\n        elif command_exists google-chrome; then\n            google-chrome \"$url\" &\n        elif command_exists chromium; then\n            chromium \"$url\" &\n        fi\n    elif [[ \"$OSTYPE\" == \"cygwin\" ]] || [[ \"$OSTYPE\" == \"msys\" ]]; then\n        # Windows\n        start \"$url\"\n    fi\n}\n\n# Function to check if we're in the right directory\ncheck_directory() {\n    if [[ ! -d \"frontend\" ]] || [[ ! -d \"backend\" ]]; then\n        print_error \"This script must be run from the app/ directory\"\n        print_error \"Please navigate to the app/ directory and run: ./run.sh\"\n        exit 1\n    fi\n}\n\n# Function to check prerequisites\ncheck_prerequisites() {\n    print_status \"Checking prerequisites...\"\n    \n    local missing_deps=()\n    \n    # Check for Node.js\n    if ! command_exists node; then\n        missing_deps+=(\"Node.js (https://nodejs.org/)\")\n    fi\n    \n    # Check for npm\n    if ! command_exists npm; then\n        missing_deps+=(\"npm (comes with Node.js)\")\n    fi\n    \n    # Check for Python\n    if ! command_exists python3; then\n        missing_deps+=(\"Python 3 (https://python.org/)\")\n    fi\n    \n    # Check for Poetry - offer to install if missing\n    if ! command_exists poetry; then\n        print_warning \"Poetry is not installed.\"\n        print_status \"Poetry is required to manage Python dependencies for this project.\"\n        echo \"\"\n        read -p \"Would you like to install Poetry automatically? (y/N): \" -n 1 -r\n        echo \"\"\n        if [[ $REPLY =~ ^[Yy]$ ]]; then\n            print_status \"Installing Poetry...\"\n            if python3 -m pip install poetry; then\n                print_success \"Poetry installed successfully!\"\n                print_status \"Refreshing environment...\"\n                # Try to refresh the PATH for this session\n                export PATH=\"$HOME/.local/bin:$PATH\"\n                if ! command_exists poetry; then\n                    print_warning \"Poetry may not be in PATH. You might need to restart your terminal.\"\n                    print_warning \"Alternatively, try: source ~/.bashrc or source ~/.zshrc\"\n                fi\n            else\n                print_error \"Failed to install Poetry automatically.\"\n                print_error \"Please install Poetry manually from https://python-poetry.org/\"\n                exit 1\n            fi\n        else\n            missing_deps+=(\"Poetry (https://python-poetry.org/)\")\n        fi\n    fi\n    \n    if [[ ${#missing_deps[@]} -gt 0 ]]; then\n        print_error \"Missing required dependencies:\"\n        for dep in \"${missing_deps[@]}\"; do\n            echo \"  - $dep\"\n        done\n        echo \"\"\n        print_error \"Please install the missing dependencies and run this script again.\"\n        exit 1\n    fi\n    \n    print_success \"All prerequisites are installed!\"\n}\n\n# Function to setup environment variables\nsetup_environment() {\n    print_status \"Setting up environment variables...\"\n    \n    # Check if .env exists in the root directory\n    if [[ ! -f \"../.env\" ]]; then\n        if [[ -f \"../.env.example\" ]]; then\n            print_warning \"No .env file found. Creating from .env.example...\"\n            cp \"../.env.example\" \"../.env\"\n            print_warning \"Please edit the .env file in the root directory to add your API keys:\"\n            print_warning \"  - OPENAI_API_KEY=your-openai-api-key\"\n            print_warning \"  - GROQ_API_KEY=your-groq-api-key\"\n            print_warning \"  - FINANCIAL_DATASETS_API_KEY=your-financial-datasets-api-key\"\n            echo \"\"\n        else\n            print_error \"No .env or .env.example file found in the root directory.\"\n            print_error \"Please create a .env file with your API keys.\"\n            exit 1\n        fi\n    else\n        print_success \"Environment file (.env) found!\"\n    fi\n}\n\n# Function to setup database\nsetup_database() {\n    print_status \"Setting up database...\"\n    \n    # Database will be automatically created by the backend when it starts\n    print_status \"Database: SQLite (hedge_fund.db)\"\n    print_status \"Location: Project root directory\"\n    print_status \"Tables will be created automatically on first backend startup\"\n    \n    # Check if database already exists\n    if [[ -f \"../hedge_fund.db\" ]]; then\n        print_success \"Database file already exists!\"\n    else\n        print_status \"Database will be created when backend starts for the first time\"\n    fi\n}\n\n# Function to install backend dependencies\ninstall_backend() {\n    print_status \"Installing backend dependencies...\"\n    \n    cd backend\n    \n    # Check if dependencies are actually installed and working\n    if poetry run python -c \"import uvicorn; import fastapi\" >/dev/null 2>&1; then\n        print_success \"Backend dependencies already installed!\"\n    else\n        print_status \"Installing Python dependencies with Poetry...\"\n        poetry install\n        if poetry run python -c \"import uvicorn; import fastapi\" >/dev/null 2>&1; then\n            print_success \"Backend dependencies installed!\"\n        else\n            print_error \"Failed to install backend dependencies properly\"\n            print_error \"Try running: cd backend && poetry install --sync\"\n            exit 1\n        fi\n    fi\n    \n    cd ..\n}\n\n# Function to install frontend dependencies\ninstall_frontend() {\n    print_status \"Installing frontend dependencies...\"\n    \n    cd frontend\n    \n    # Check if node_modules exists and has content\n    if [[ -d \"node_modules\" ]] && [[ -n \"$(ls -A node_modules 2>/dev/null)\" ]]; then\n        print_success \"Frontend dependencies already installed!\"\n    else\n        print_status \"Installing Node.js dependencies...\"\n        npm install\n        print_success \"Frontend dependencies installed!\"\n    fi\n    \n    cd ..\n}\n\n# Function to start both services\nstart_services() {\n    print_status \"Starting the AI Hedge Fund web application...\"\n    print_status \"This will start both the backend API and frontend web interface\"\n    print_status \"Press Ctrl+C to stop both services\"\n    echo \"\"\n    \n    # Create a temporary directory for log files\n    LOG_DIR=$(mktemp -d)\n    BACKEND_LOG=\"$LOG_DIR/backend.log\"\n    FRONTEND_LOG=\"$LOG_DIR/frontend.log\"\n    \n    # Function to cleanup on exit\n    cleanup() {\n        print_status \"Shutting down services...\"\n        \n        # Kill background processes\n        if [[ -n \"$BACKEND_PID\" ]] && kill -0 \"$BACKEND_PID\" 2>/dev/null; then\n            kill \"$BACKEND_PID\" 2>/dev/null || true\n        fi\n        \n        if [[ -n \"$FRONTEND_PID\" ]] && kill -0 \"$FRONTEND_PID\" 2>/dev/null; then\n            kill \"$FRONTEND_PID\" 2>/dev/null || true\n        fi\n        \n        # Clean up log directory\n        rm -rf \"$LOG_DIR\" 2>/dev/null || true\n        \n        print_success \"Services stopped. Goodbye!\"\n        exit 0\n    }\n    \n    # Set up signal handlers\n    trap cleanup SIGINT SIGTERM\n    \n    # Start backend\n    print_status \"Starting backend server...\"\n    # Run from the app directory (parent of backend) to ensure proper Python imports\n    cd ..\n    poetry run uvicorn app.backend.main:app --reload --host 127.0.0.1 --port 8000 > \"$LOG_DIR/backend.log\" 2>&1 &\n    BACKEND_PID=$!\n    cd app\n    \n    # Wait a moment for backend to start\n    sleep 3\n    \n    # Check if backend started successfully\n    if ! kill -0 \"$BACKEND_PID\" 2>/dev/null; then\n        print_error \"Backend failed to start. Check the logs:\"\n        cat \"$BACKEND_LOG\"\n        exit 1\n    fi\n    \n    print_success \"Backend server started (PID: $BACKEND_PID)\"\n    \n    # Check database initialization\n    print_status \"Checking database initialization...\"\n    sleep 2  # Give backend time to initialize database\n    \n    if [[ -f \"../hedge_fund.db\" ]]; then\n        print_success \"Database initialized successfully!\"\n    else\n        print_warning \"Database file not found, but will be created on first API call\"\n    fi\n    \n    # Start frontend\n    print_status \"Starting frontend development server...\"\n    cd frontend\n    npm run dev > \"$FRONTEND_LOG\" 2>&1 &\n    FRONTEND_PID=$!\n    cd ..\n    \n    # Wait a moment for frontend to start\n    sleep 5\n    \n    # Check if frontend started successfully\n    if ! kill -0 \"$FRONTEND_PID\" 2>/dev/null; then\n        print_error \"Frontend failed to start. Check the logs:\"\n        cat \"$FRONTEND_LOG\"\n        cleanup\n        exit 1\n    fi\n    \n    print_success \"Frontend development server started (PID: $FRONTEND_PID)\"\n    \n    # Open browser after frontend is running\n    print_status \"Opening web browser...\"\n    sleep 2  # Give frontend a moment to fully start\n    open_browser \"http://localhost:5173\"\n    \n    echo \"\"\n    print_success \"🚀 AI Hedge Fund web application is now running!\"\n    print_success \"🌐 Browser should open automatically to http://localhost:5173\"\n    echo \"\"\n    print_status \"Frontend (Web Interface): http://localhost:5173\"\n    print_status \"Backend (API): http://localhost:8000\"\n    print_status \"API Documentation: http://localhost:8000/docs\"\n    print_status \"Database: SQLite (hedge_fund.db in project root)\"\n    echo \"\"\n    print_status \"Press Ctrl+C to stop both services\"\n    echo \"\"\n    \n    # Wait for user interrupt\n    while true; do\n        # Check if processes are still running\n        if ! kill -0 \"$BACKEND_PID\" 2>/dev/null; then\n            print_error \"Backend process died unexpectedly\"\n            break\n        fi\n        \n        if ! kill -0 \"$FRONTEND_PID\" 2>/dev/null; then\n            print_error \"Frontend process died unexpectedly\"\n            break\n        fi\n        \n        sleep 1\n    done\n    \n    cleanup\n}\n\n# Main execution\nmain() {\n    echo \"\"\n    print_status \"🚀 AI Hedge Fund Web Application Setup\"\n    print_status \"This script will install dependencies and start both frontend and backend services\"\n    echo \"\"\n    \n    check_directory\n    check_prerequisites\n    setup_environment\n    setup_database\n    install_backend\n    install_frontend\n    start_services\n}\n\n# Show help if requested\nif [[ \"$1\" == \"--help\" ]] || [[ \"$1\" == \"-h\" ]]; then\n    echo \"AI Hedge Fund Web Application Setup and Runner\"\n    echo \"\"\n    echo \"Usage: ./run.sh\"\n    echo \"\"\n    echo \"This script will:\"\n    echo \"  1. Check for required dependencies (Node.js, npm, Python, Poetry)\"\n    echo \"  2. Install backend dependencies using Poetry\"\n    echo \"  3. Install frontend dependencies using npm\"\n    echo \"  4. Start both the backend API server and frontend development server\"\n    echo \"  5. Automatically initialize SQLite database on first run\"\n    echo \"\"\n    echo \"Requirements:\"\n    echo \"  - Node.js and npm (https://nodejs.org/)\"\n    echo \"  - Python 3 (https://python.org/)\"\n    echo \"  - Poetry (https://python-poetry.org/)\"\n    echo \"\"\n    echo \"After running, you can access:\"\n    echo \"  - Frontend: http://localhost:5173\"\n    echo \"  - Backend API: http://localhost:8000\"\n    echo \"  - API Docs: http://localhost:8000/docs\"\n    echo \"  - Database: SQLite file (hedge_fund.db) in project root\"\n    echo \"\"\n    exit 0\nfi\n\n# Run main function\nmain "
  },
  {
    "path": "docker/.dockerignore",
    "content": "# Git\n.git\n.gitignore\n\n# Poetry\n.venv\n__pycache__/\n*.py[cod]\n*$py.class\n.pytest_cache/\n\n# Environment\n.env\n\n# IDEs and editors\n.idea/\n.vscode/\n*.swp\n*.swo\n\n# Logs and data\nlogs/\ndata/\n*.log\n\n# OS specific\n.DS_Store\nThumbs.db "
  },
  {
    "path": "docker/Dockerfile",
    "content": "FROM python:3.11-slim\n\nWORKDIR /app\n\n# Set PYTHONPATH to include the app directory\nENV PYTHONPATH=/app\n\n# Install Poetry\nRUN pip install poetry==1.7.1\n\n# Copy only dependency files first for better caching\nCOPY pyproject.toml poetry.lock* /app/\n\n# Configure Poetry to not use a virtual environment\nRUN poetry config virtualenvs.create false \\\n    && poetry install --no-interaction --no-ansi\n\n# Copy rest of the source code\nCOPY . /app/\n\n# Default command (will be overridden by Docker Compose)\nCMD [\"python\", \"src/main.py\"] \n"
  },
  {
    "path": "docker/README.md",
    "content": "# AI Hedge Fund\n\nThis 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.\n\nThis system employs several agents working together:\n\n1. Aswath Damodaran Agent - The Dean of Valuation, focuses on story, numbers, and disciplined valuation\n2. Ben Graham Agent - The godfather of value investing, only buys hidden gems with a margin of safety\n3. Bill Ackman Agent - An activist investor, takes bold positions and pushes for change\n4. Cathie Wood Agent - The queen of growth investing, believes in the power of innovation and disruption\n5. Charlie Munger Agent - Warren Buffett's partner, only buys wonderful businesses at fair prices\n6. Michael Burry Agent - The Big Short contrarian who hunts for deep value\n7. Mohnish Pabrai Agent - The Dhandho investor, who looks for doubles at low risk\n8. Peter Lynch Agent - Practical investor who seeks \"ten-baggers\" in everyday businesses\n9. Phil Fisher Agent - Meticulous growth investor who uses deep \"scuttlebutt\" research \n10. Rakesh Jhunjhunwala Agent - The Big Bull of India\n11. Stanley Druckenmiller Agent - Macro legend who hunts for asymmetric opportunities with growth potential\n12. Warren Buffett Agent - The oracle of Omaha, seeks wonderful companies at a fair price\n13. Valuation Agent - Calculates the intrinsic value of a stock and generates trading signals\n14. Sentiment Agent - Analyzes market sentiment and generates trading signals\n15. Fundamentals Agent - Analyzes fundamental data and generates trading signals\n16. Technicals Agent - Analyzes technical indicators and generates trading signals\n17. Risk Manager - Calculates risk metrics and sets position limits\n18. Portfolio Manager - Makes final trading decisions and generates orders\n\n<img width=\"1042\" alt=\"Screenshot 2025-03-22 at 6 19 07 PM\" src=\"https://github.com/user-attachments/assets/cbae3dcf-b571-490d-b0ad-3f0f035ac0d4\" />\n\nNote: the system does not actually make any trades.\n\n[![Twitter Follow](https://img.shields.io/twitter/follow/virattt?style=social)](https://twitter.com/virattt)\n\n## Disclaimer\n\nThis project is for **educational and research purposes only**.\n\n- Not intended for real trading or investment\n- No investment advice or guarantees provided\n- Creator assumes no liability for financial losses\n- Consult a financial advisor for investment decisions\n- Past performance does not indicate future results\n\nBy using this software, you agree to use it solely for learning purposes.\n\n## Table of Contents\n- [How to Install](#how-to-install)\n- [How to Run](#how-to-run)\n  - [⌨️ Command Line Interface](#️-command-line-interface)\n  - [🖥️ Web Application (NEW!)](#️-web-application)\n- [Contributing](#contributing)\n- [Feature Requests](#feature-requests)\n- [License](#license)\n\n## How to Install\n\nBefore 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.\n\n### 1. Clone the Repository\n\n```bash\ngit clone https://github.com/virattt/ai-hedge-fund.git\ncd ai-hedge-fund\n```\n\n### 2. Set Up Your API Keys\n\nCreate a `.env` file for your API keys:\n```bash\n# Create .env file for your API keys (in the root directory)\ncp .env.example .env\n```\n\nOpen and edit the `.env` file to add your API keys:\n```bash\n# For running LLMs hosted by openai (gpt-4o, gpt-4o-mini, etc.)\nOPENAI_API_KEY=your-openai-api-key\n\n# For getting financial data to power the hedge fund\nFINANCIAL_DATASETS_API_KEY=your-financial-datasets-api-key\n```\n\n**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. \n\n**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.\n\n## How to Run\n\n### ⌨️ Command Line Interface\n\nFor users who prefer working with command line tools, 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.\n\n<img width=\"992\" alt=\"Screenshot 2025-01-06 at 5 50 17 PM\" src=\"https://github.com/user-attachments/assets/e8ca04bf-9989-4a7d-a8b4-34e04666663b\" />\n\n#### Using Docker\n\n1. Make sure you have Docker installed on your system. If not, you can download it from [Docker's official website](https://www.docker.com/get-started).\n\n2. Navigate to the docker directory:\n```bash\ncd docker\n```\n\n3. Build the Docker image:\n```bash\n# On Linux/Mac:\n./run.sh build\n\n# On Windows:\nrun.bat build\n```\n\n#### Running the AI Hedge Fund (with Docker)\n```bash\n# Navigate to the docker directory first\ncd docker\n\n# On Linux/Mac:\n./run.sh --ticker AAPL,MSFT,NVDA main\n\n# On Windows:\nrun.bat --ticker AAPL,MSFT,NVDA main\n```\n\nYou can also specify a `--ollama` flag to run the AI hedge fund using local LLMs.\n\n```bash\n# With Docker (from docker/ directory):\n# On Linux/Mac:\n./run.sh --ticker AAPL,MSFT,NVDA --ollama main\n\n# On Windows:\nrun.bat --ticker AAPL,MSFT,NVDA --ollama main\n```\n\nIf you already have an Ollama server running (locally or on your network), point the containers at it instead of starting the bundled instance. You can either pass an explicit base URL or export `OLLAMA_BASE_URL` before running the scripts.\n\n```bash\n# Linux / macOS\n./run.sh --ticker AAPL,MSFT,NVDA --ollama --ollama-base-url http://localhost:11434 main\n\n# Windows\nrun.bat --ticker AAPL,MSFT,NVDA --ollama --ollama-base-url http://localhost:11434 main\n```\n\nWhen `OLLAMA_BASE_URL` is provided, the Docker compose services reuse that endpoint and the Ollama container is not started. To launch the embedded Ollama service manually with Docker Compose, add the `embedded-ollama` profile (e.g. `docker compose --profile embedded-ollama up`).\n\nYou can optionally specify the start and end dates to make decisions for a specific time period.\n\n```bash\n# With Docker (from docker/ directory):\n# On Linux/Mac:\n./run.sh --ticker AAPL,MSFT,NVDA --start-date 2024-01-01 --end-date 2024-03-01 main\n\n# On Windows:\nrun.bat --ticker AAPL,MSFT,NVDA --start-date 2024-01-01 --end-date 2024-03-01 main\n```\n\n#### Running the Backtester (with Docker)\n```bash\n# Navigate to the docker directory first\ncd docker\n\n# On Linux/Mac:\n./run.sh --ticker AAPL,MSFT,NVDA backtest\n\n# On Windows:\nrun.bat --ticker AAPL,MSFT,NVDA backtest\n```\n\n**Example Output:**\n<img width=\"941\" alt=\"Screenshot 2025-01-06 at 5 47 52 PM\" src=\"https://github.com/user-attachments/assets/00e794ea-8628-44e6-9a84-8f8a31ad3b47\" />\n\n\nYou can optionally specify the start and end dates to backtest over a specific time period.\n\n```bash\n# With Docker (from docker/ directory):\n# On Linux/Mac:\n./run.sh --ticker AAPL,MSFT,NVDA --start-date 2024-01-01 --end-date 2024-03-01 backtest\n\n# On Windows:\nrun.bat --ticker AAPL,MSFT,NVDA --start-date 2024-01-01 --end-date 2024-03-01 backtest\n```\n\nYou can also specify a `--ollama` flag to run the backtester using local LLMs.\n```bash\n# With Docker (from docker/ directory):\n# On Linux/Mac:\n./run.sh --ticker AAPL,MSFT,NVDA --ollama backtest\n\n# On Windows:\nrun.bat --ticker AAPL,MSFT,NVDA --ollama backtest\n```\n\n## Contributing\n\n1. Fork the repository\n2. Create a feature branch\n3. Commit your changes\n4. Push to the branch\n5. Create a Pull Request\n\n**Important**: Please keep your pull requests small and focused.  This will make it easier to review and merge.\n\n## Feature Requests\n\nIf 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`.\n\n## License\n\nThis project is licensed under the MIT License - see the LICENSE file for details.\n"
  },
  {
    "path": "docker/docker-compose.yml",
    "content": "services:\n  ollama:\n    profiles:\n      - embedded-ollama\n    image: ollama/ollama:latest\n    container_name: ollama\n    environment:\n      - OLLAMA_HOST=0.0.0.0\n      # Apple Silicon GPU acceleration\n      - METAL_DEVICE=on\n      - METAL_DEVICE_INDEX=0\n    volumes:\n      - ollama_data:/root/.ollama\n    ports:\n      - \"11434:11434\"\n    restart: unless-stopped\n\n  hedge-fund:\n    build:\n      context: ..\n      dockerfile: docker/Dockerfile\n    image: ai-hedge-fund\n    volumes:\n      - ../.env:/app/.env\n    command: python src/main.py --ticker AAPL,MSFT,NVDA\n    environment:\n      PYTHONUNBUFFERED: \"1\"\n      OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}\n      PYTHONPATH: /app\n    tty: true\n    stdin_open: true\n\n  hedge-fund-reasoning:\n    build:\n      context: ..\n      dockerfile: docker/Dockerfile\n    image: ai-hedge-fund\n    volumes:\n      - ../.env:/app/.env\n    command: python src/main.py --ticker AAPL,MSFT,NVDA --show-reasoning\n    environment:\n      PYTHONUNBUFFERED: \"1\"\n      OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}\n      PYTHONPATH: /app\n    tty: true\n    stdin_open: true\n\n  hedge-fund-ollama:\n    build:\n      context: ..\n      dockerfile: docker/Dockerfile\n    image: ai-hedge-fund\n    volumes:\n      - ../.env:/app/.env\n    command: python src/main.py --ticker AAPL,MSFT,NVDA --ollama\n    environment:\n      PYTHONUNBUFFERED: \"1\"\n      OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}\n      PYTHONPATH: /app\n    tty: true\n    stdin_open: true\n\n  backtester:\n    build:\n      context: ..\n      dockerfile: docker/Dockerfile\n    image: ai-hedge-fund\n    volumes:\n      - ../.env:/app/.env\n    command: python src/backtester.py --ticker AAPL,MSFT,NVDA\n    environment:\n      PYTHONUNBUFFERED: \"1\"\n      OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}\n      PYTHONPATH: /app\n    tty: true\n    stdin_open: true\n\n  backtester-ollama:\n    build:\n      context: ..\n      dockerfile: docker/Dockerfile\n    image: ai-hedge-fund\n    volumes:\n      - ../.env:/app/.env\n    command: python src/backtester.py --ticker AAPL,MSFT,NVDA --ollama\n    environment:\n      PYTHONUNBUFFERED: \"1\"\n      OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}\n      PYTHONPATH: /app\n    tty: true\n    stdin_open: true\n\nvolumes:\n  ollama_data: \n"
  },
  {
    "path": "docker/run.bat",
    "content": "@echo off\nsetlocal enabledelayedexpansion\n\n:: Default values\nset TICKER=AAPL,MSFT,NVDA\nset USE_OLLAMA=\nset \"OLLAMA_BASE_URL_VALUE=%OLLAMA_BASE_URL%\"\nset USE_EXTERNAL_OLLAMA=\nset START_DATE=\nset END_DATE=\nset INITIAL_AMOUNT=100000.0\nset MARGIN_REQUIREMENT=0.0\nset SHOW_REASONING=\nset COMMAND=\nset MODEL_NAME=\n\n:: Help function\n:show_help\necho AI Hedge Fund Docker Runner\necho.\necho Usage: run.bat [OPTIONS] COMMAND\necho.\necho Options:\necho   --ticker SYMBOLS    Comma-separated list of ticker symbols (e.g., AAPL,MSFT,NVDA)\necho   --start-date DATE   Start date in YYYY-MM-DD format\necho   --end-date DATE     End date in YYYY-MM-DD format\necho   --initial-cash AMT  Initial cash position (default: 100000.0)\necho   --margin-requirement RATIO  Margin requirement ratio (default: 0.0)\necho   --ollama            Use Ollama for local LLM inference\necho   --ollama-base-url URL  Use an existing Ollama endpoint (implies --ollama)\necho   --show-reasoning    Show reasoning from each agent\necho.\necho Commands:\necho   main                Run the main hedge fund application\necho   backtest            Run the backtester\necho   build               Build the Docker image\necho   compose             Run using Docker Compose with integrated Ollama\necho   ollama              Start only the Ollama container for model management\necho   pull MODEL          Pull a specific model into the Ollama container\necho   help                Show this help message\necho.\necho Examples:\necho   run.bat --ticker AAPL,MSFT,NVDA main\necho   run.bat --ticker AAPL,MSFT,NVDA --ollama main\necho   run.bat --ticker AAPL,MSFT,NVDA --start-date 2024-01-01 --end-date 2024-03-01 backtest\necho   run.bat compose     # Run with Docker Compose (includes Ollama)\necho   run.bat ollama      # Start only the Ollama container\necho   run.bat pull llama3 # Pull the llama3 model to Ollama\necho.\ngoto :eof\n\n:: Parse arguments\n:parse_args\nif \"%~1\"==\"\" goto :check_command\nif \"%~1\"==\"--ticker\" (\n    set TICKER=%~2\n    shift\n    shift\n    goto :parse_args\n)\nif \"%~1\"==\"--start-date\" (\n    set START_DATE=--start-date %~2\n    shift\n    shift\n    goto :parse_args\n)\nif \"%~1\"==\"--end-date\" (\n    set END_DATE=--end-date %~2\n    shift\n    shift\n    goto :parse_args\n)\nif \"%~1\"==\"--initial-cash\" (\n    set INITIAL_AMOUNT=%~2\n    shift\n    shift\n    goto :parse_args\n)\nif \"%~1\"==\"--margin-requirement\" (\n    set MARGIN_REQUIREMENT=%~2\n    shift\n    shift\n    goto :parse_args\n)\nif \"%~1\"==\"--ollama\" (\n    set USE_OLLAMA=--ollama\n    shift\n    goto :parse_args\n)\nif \"%~1\"==\"--ollama-base-url\" (\n    set \"OLLAMA_BASE_URL_VALUE=%~2\"\n    set USE_EXTERNAL_OLLAMA=1\n    if \"!USE_OLLAMA!\"==\"\" (\n        set USE_OLLAMA=--ollama\n    )\n    shift\n    shift\n    goto :parse_args\n)\nif \"%~1\"==\"--show-reasoning\" (\n    set SHOW_REASONING=--show-reasoning\n    shift\n    goto :parse_args\n)\nif \"%~1\"==\"main\" (\n    set COMMAND=main\n    shift\n    goto :parse_args\n)\nif \"%~1\"==\"backtest\" (\n    set COMMAND=backtest\n    shift\n    goto :parse_args\n)\nif \"%~1\"==\"build\" (\n    set COMMAND=build\n    shift\n    goto :parse_args\n)\nif \"%~1\"==\"compose\" (\n    set COMMAND=compose\n    shift\n    goto :parse_args\n)\nif \"%~1\"==\"ollama\" (\n    set COMMAND=ollama\n    shift\n    goto :parse_args\n)\nif \"%~1\"==\"pull\" (\n    set COMMAND=pull\n    set MODEL_NAME=%~2\n    shift\n    shift\n    goto :parse_args\n)\nif \"%~1\"==\"help\" (\n    call :show_help\n    exit /b 0\n)\nif \"%~1\"==\"--help\" (\n    call :show_help\n    exit /b 0\n)\necho Unknown option: %~1\ncall :show_help\nexit /b 1\n\n:check_command\nif defined USE_OLLAMA (\n    if not defined USE_EXTERNAL_OLLAMA if not \"!OLLAMA_BASE_URL_VALUE!\"==\"\" (\n        set \"__OLLAMA_CHECK=!OLLAMA_BASE_URL_VALUE!\"\n        if /I not \"!__OLLAMA_CHECK!\"==\"http://ollama:11434\" if /I not \"!__OLLAMA_CHECK!\"==\"http://ollama:11434/\" (\n            set USE_EXTERNAL_OLLAMA=1\n        )\n        set \"__OLLAMA_CHECK=\"\n    )\n)\n\nif defined USE_EXTERNAL_OLLAMA (\n    if \"!OLLAMA_BASE_URL_VALUE!\"==\"\" (\n        echo Error: --ollama-base-url requires a value.\n        exit /b 1\n    )\n)\n\nif \"!COMMAND!\"==\"\" (\n    echo Error: No command specified.\n    call :show_help\n    exit /b 1\n)\n\n:: Show help if 'help' command is provided\nif \"!COMMAND!\"==\"help\" (\n    call :show_help\n    exit /b 0\n)\n\n:: Check for Docker Compose existence\ndocker compose version >nul 2>&1\nif !ERRORLEVEL! EQU 0 (\n    set COMPOSE_CMD=docker compose\n) else (\n    docker-compose --version >nul 2>&1\n    if !ERRORLEVEL! EQU 0 (\n        set COMPOSE_CMD=docker-compose\n    ) else (\n        echo Error: Docker Compose is not installed.\n        exit /b 1\n    )\n)\n\n:: Build the Docker image if 'build' command is provided\nif \"!COMMAND!\"==\"build\" (\n    docker build -t ai-hedge-fund -f Dockerfile ..\n    exit /b 0\n)\n\n:: Start Ollama container if 'ollama' command is provided\nif \"!COMMAND!\"==\"ollama\" (\n    echo Starting Ollama container...\n    !COMPOSE_CMD! --profile embedded-ollama up -d ollama\n    \n    :: Check if Ollama is running\n    echo Waiting for Ollama to start...\n    for /l %%i in (1, 1, 30) do (\n        !COMPOSE_CMD! exec ollama curl -s http://localhost:11434/api/version >nul 2>&1\n        if !ERRORLEVEL! EQU 0 (\n            echo Ollama is now running.\n            :: Show available models\n            echo Available models:\n            !COMPOSE_CMD! exec ollama ollama list\n            \n            echo.\n            echo Manage your models using:\n            echo   run.bat pull ^<model-name^>   # Download a model\n            echo   run.bat ollama              # Start Ollama and show models\n            exit /b 0\n        )\n        timeout /t 1 /nobreak >nul\n        echo.\n    )\n    \n    echo Failed to start Ollama within the expected time. You may need to check the container logs.\n    exit /b 1\n)\n\n:: Pull a model if 'pull' command is provided\nif \"!COMMAND!\"==\"pull\" (\n    if \"!MODEL_NAME!\"==\"\" (\n        echo Error: No model name specified.\n        echo Usage: run.bat pull ^<model-name^>\n        echo Example: run.bat pull llama3\n        exit /b 1\n    )\n    \n    :: Start Ollama if it's not already running\n    !COMPOSE_CMD! --profile embedded-ollama up -d ollama\n    \n    :: Wait for Ollama to start\n    echo Ensuring Ollama is running...\n    for /l %%i in (1, 1, 30) do (\n        !COMPOSE_CMD! exec ollama curl -s http://localhost:11434/api/version >nul 2>&1\n        if !ERRORLEVEL! EQU 0 (\n            echo Ollama is running.\n            goto :pull_model\n        )\n        timeout /t 1 /nobreak >nul\n        echo.\n    )\n    \n    :pull_model\n    :: Pull the model\n    echo Pulling model: !MODEL_NAME!\n    echo This may take some time depending on the model size and your internet connection.\n    echo You can press Ctrl+C to cancel at any time (the model will continue downloading in the background).\n    \n    !COMPOSE_CMD! exec ollama ollama pull \"!MODEL_NAME!\"\n    \n    :: Check if the model was successfully pulled\n    !COMPOSE_CMD! exec ollama ollama list | findstr /i \"!MODEL_NAME!\" >nul\n    if !ERRORLEVEL! EQU 0 (\n        echo Model !MODEL_NAME! was successfully downloaded.\n    ) else (\n        echo Warning: Model !MODEL_NAME! may not have been properly downloaded.\n        echo Check the Ollama container status with: run.bat ollama\n    )\n    \n    exit /b 0\n)\n\n:: Run with Docker Compose if 'compose' command is provided\nif \"!COMMAND!\"==\"compose\" (\n    echo Running with Docker Compose (includes Ollama)...\n    !COMPOSE_CMD! --profile embedded-ollama up --build\n    exit /b 0\n)\n\n:: Check if .env file exists, if not create from .env.example\nif not exist .env (\n    if exist .env.example (\n        echo No .env file found. Creating from .env.example...\n        copy .env.example .env\n        echo Please edit .env file to add your API keys.\n    ) else (\n        echo Error: No .env or .env.example file found.\n        exit /b 1\n    )\n)\n\n:: Set script path and parameters based on command\nif \"!COMMAND!\"==\"main\" (\n    set SCRIPT_PATH=src/main.py\n    if \"!COMMAND!\"==\"main\" (\n        set INITIAL_PARAM=--initial-cash !INITIAL_AMOUNT!\n    )\n) else if \"!COMMAND!\"==\"backtest\" (\n    set SCRIPT_PATH=src/backtester.py\n    if \"!COMMAND!\"==\"backtest\" (\n        set INITIAL_PARAM=--initial-capital !INITIAL_AMOUNT!\n    )\n)\n\n:: If using Ollama, prepare embedded service or external connection\nif not \"!USE_OLLAMA!\"==\"\" (\n    if not \"!OLLAMA_BASE_URL_VALUE!\"==\"\" (\n        set \"OLLAMA_BASE_URL=!OLLAMA_BASE_URL_VALUE!\"\n    )\n\n    set COMMAND_OVERRIDE=\n\n    if not \"!START_DATE!\"==\"\" (\n        set COMMAND_OVERRIDE=!COMMAND_OVERRIDE! !START_DATE!\n    )\n\n    if not \"!END_DATE!\"==\"\" (\n        set COMMAND_OVERRIDE=!COMMAND_OVERRIDE! !END_DATE!\n    )\n\n    if not \"!INITIAL_PARAM!\"==\"\" (\n        set COMMAND_OVERRIDE=!COMMAND_OVERRIDE! !INITIAL_PARAM!\n    )\n\n    if not \"!MARGIN_REQUIREMENT!\"==\"\" (\n        set COMMAND_OVERRIDE=!COMMAND_OVERRIDE! --margin-requirement !MARGIN_REQUIREMENT!\n    )\n\n    if defined USE_EXTERNAL_OLLAMA (\n        set \"TRIMMED_BASE=!OLLAMA_BASE_URL_VALUE!\"\n        if \"!TRIMMED_BASE!\"==\"\" set \"TRIMMED_BASE=!OLLAMA_BASE_URL!\"\n        if \"!TRIMMED_BASE!\"==\"\" (\n            echo Error: No external Ollama base URL provided.\n            exit /b 1\n        )\n        if \"!TRIMMED_BASE:~-1!\"==\"/\" set \"TRIMMED_BASE=!TRIMMED_BASE:~0,-1!\"\n        set \"HEALTHCHECK_URL=!TRIMMED_BASE!/api/version\"\n        echo Using external Ollama endpoint at !TRIMMED_BASE!\n        echo Checking connectivity to Ollama...\n        set REACHABLE=\n        for /l %%i in (1, 1, 30) do (\n            powershell -NoLogo -Command \"try {Invoke-WebRequest -Uri '!HEALTHCHECK_URL!' -UseBasicParsing -TimeoutSec 2 ^| Out-Null; exit 0} catch { exit 1 }\" >nul 2>&1\n            if !ERRORLEVEL! EQU 0 (\n                set REACHABLE=1\n                goto :external_check_done\n            )\n            timeout /t 1 /nobreak >nul\n            echo.\n        )\n:external_check_done\n        if defined REACHABLE (\n            echo External Ollama endpoint is reachable.\n        ) else (\n            echo Warning: Unable to reach Ollama at !HEALTHCHECK_URL! within 30 seconds.\n            echo Continuing anyway; ensure the endpoint is reachable from within the containers.\n        )\n    ) else (\n        echo Setting up embedded Ollama container for local LLM inference...\n        !COMPOSE_CMD! --profile embedded-ollama up -d ollama\n\n        echo Waiting for Ollama to start...\n        for /l %%i in (1, 1, 30) do (\n            !COMPOSE_CMD! exec ollama curl -s http://localhost:11434/api/version >nul 2>&1\n            if !ERRORLEVEL! EQU 0 (\n                echo Ollama is running.\n                echo Available models:\n                !COMPOSE_CMD! exec ollama ollama list\n                goto :continue_ollama\n            )\n            timeout /t 1 /nobreak >nul\n            echo.\n        )\n        echo Warning: Unable to confirm Ollama startup within 30 seconds.\n:continue_ollama\n    )\n\n    docker images -q ai-hedge-fund 2>nul | findstr /r /c:\"^..*$\" >nul\n    if !ERRORLEVEL! NEQ 0 (\n        echo Building AI Hedge Fund image...\n        docker build -t ai-hedge-fund -f Dockerfile ..\n    )\n\n    echo Running AI Hedge Fund with Ollama using Docker Compose...\n\n    if \"!COMMAND!\"==\"main\" (\n        if not \"!SHOW_REASONING!\"==\"\" (\n            !COMPOSE_CMD! run --rm hedge-fund-reasoning python src/main.py --ticker !TICKER! !COMMAND_OVERRIDE! !SHOW_REASONING! --ollama\n        ) else (\n            !COMPOSE_CMD! run --rm hedge-fund-ollama python src/main.py --ticker !TICKER! !COMMAND_OVERRIDE! --ollama\n        )\n    ) else if \"!COMMAND!\"==\"backtest\" (\n        !COMPOSE_CMD! run --rm backtester-ollama python src/backtester.py --ticker !TICKER! !COMMAND_OVERRIDE! !SHOW_REASONING! --ollama\n    )\n\n    exit /b 0\n)\n\n:: Standard Docker run (without Ollama)\n:: Build the command\nset CMD=docker run -it --rm -v %cd%\\.env:/app/.env\n\n:: Add the command\nset CMD=!CMD! ai-hedge-fund python !SCRIPT_PATH! --ticker !TICKER! !START_DATE! !END_DATE! !INITIAL_PARAM! --margin-requirement !MARGIN_REQUIREMENT! !SHOW_REASONING!\n\n:: Run the command\necho Running: !CMD!\n!CMD!\n\n:: Exit\nexit /b 0\n\n:: Start script execution\ncall :parse_args %* \n\n"
  },
  {
    "path": "docker/run.sh",
    "content": "#!/bin/bash\n\n# Help text to display when --help is provided\nshow_help() {\n  echo \"AI Hedge Fund Docker Runner\"\n  echo \"\"\n  echo \"Usage: ./run.sh [OPTIONS] COMMAND\"\n  echo \"\"\n  echo \"Options:\"\n  echo \"  --ticker SYMBOLS    Comma-separated list of ticker symbols (e.g., AAPL,MSFT,NVDA)\"\n  echo \"  --start-date DATE   Start date in YYYY-MM-DD format\"\n  echo \"  --end-date DATE     End date in YYYY-MM-DD format\"\n  echo \"  --initial-cash AMT  Initial cash position (default: 100000.0)\"\n  echo \"  --margin-requirement RATIO  Margin requirement ratio (default: 0.0)\"\n  echo \"  --ollama            Use Ollama for local LLM inference\"\n  echo \"  --ollama-base-url URL  Use an existing Ollama endpoint (implies --ollama)\"\n  echo \"  --show-reasoning    Show reasoning from each agent\"\n  echo \"\"\n  echo \"Commands:\"\n  echo \"  main                Run the main hedge fund application\"\n  echo \"  backtest            Run the backtester\"\n  echo \"  build               Build the Docker image\"\n  echo \"  compose             Run using Docker Compose with integrated Ollama\"\n  echo \"  ollama              Start only the Ollama container for model management\"\n  echo \"  pull MODEL          Pull a specific model into the Ollama container\"\n  echo \"  help                Show this help message\"\n  echo \"\"\n  echo \"Examples:\"\n  echo \"  ./run.sh --ticker AAPL,MSFT,NVDA main\"\n  echo \"  ./run.sh --ticker AAPL,MSFT,NVDA --ollama main\"\n  echo \"  ./run.sh --ticker AAPL,MSFT,NVDA --start-date 2024-01-01 --end-date 2024-03-01 backtest\"\n  echo \"  ./run.sh compose    # Run with Docker Compose (includes Ollama)\"\n  echo \"  ./run.sh ollama     # Start only the Ollama container\"\n  echo \"  ./run.sh pull llama3 # Pull the llama3 model to Ollama\"\n  echo \"\"\n}\n\n# Default values\nTICKER=\"AAPL,MSFT,NVDA\"\nUSE_OLLAMA=\"\"\nOLLAMA_BASE_URL_VALUE=\"${OLLAMA_BASE_URL:-}\"\nUSE_EXTERNAL_OLLAMA=\"\"\nSTART_DATE=\"\"\nEND_DATE=\"\"\nINITIAL_AMOUNT=\"100000.0\"\nMARGIN_REQUIREMENT=\"0.0\"\nSHOW_REASONING=\"\"\nCOMMAND=\"\"\nMODEL_NAME=\"\"\n\n# Parse arguments\nwhile [[ $# -gt 0 ]]; do\n  case $1 in\n    --ticker)\n      TICKER=\"$2\"\n      shift 2\n      ;;\n    --start-date)\n      START_DATE=\"--start-date $2\"\n      shift 2\n      ;;\n    --end-date)\n      END_DATE=\"--end-date $2\"\n      shift 2\n      ;;\n    --initial-cash)\n      INITIAL_AMOUNT=\"$2\"\n      shift 2\n      ;;\n    --margin-requirement)\n      MARGIN_REQUIREMENT=\"$2\"\n      shift 2\n      ;;\n    --ollama)\n      USE_OLLAMA=\"--ollama\"\n      shift\n      ;;\n    --ollama-base-url)\n      OLLAMA_BASE_URL_VALUE=\"$2\"\n      USE_EXTERNAL_OLLAMA=\"1\"\n      if [ -z \"$USE_OLLAMA\" ]; then\n        USE_OLLAMA=\"--ollama\"\n      fi\n      shift 2\n      ;;\n    --show-reasoning)\n      SHOW_REASONING=\"--show-reasoning\"\n      shift\n      ;;\n    main|backtest|build|help|compose|ollama)\n      COMMAND=\"$1\"\n      shift\n      ;;\n    pull)\n      COMMAND=\"pull\"\n      MODEL_NAME=\"$2\"\n      shift 2\n      ;;\n    --help)\n      show_help\n      exit 0\n      ;;\n    *)\n      echo \"Unknown option: $1\"\n      show_help\n      exit 1\n      ;;\n  esac\ndone\n\n# Determine if we should use an external Ollama instance\nif [ -n \"$USE_OLLAMA\" ] && [ -z \"$USE_EXTERNAL_OLLAMA\" ] && [ -n \"$OLLAMA_BASE_URL_VALUE\" ]; then\n  if [ \"$OLLAMA_BASE_URL_VALUE\" != \"http://ollama:11434\" ] && [ \"$OLLAMA_BASE_URL_VALUE\" != \"http://ollama:11434/\" ]; then\n    USE_EXTERNAL_OLLAMA=\"1\"\n  fi\nfi\n\nif [ \"$USE_EXTERNAL_OLLAMA\" = \"1\" ] && [ -z \"$OLLAMA_BASE_URL_VALUE\" ]; then\n  echo \"Error: --ollama-base-url requires a value.\"\n  exit 1\nfi\n\n# Check if command is provided\nif [ -z \"$COMMAND\" ]; then\n  echo \"Error: No command specified.\"\n  show_help\n  exit 1\nfi\n\n# Show help if 'help' command is provided\nif [ \"$COMMAND\" = \"help\" ]; then\n  show_help\n  exit 0\nfi\n\n# Check for Docker Compose existence\nif ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then\n  echo \"Error: Docker Compose is not installed.\"\n  exit 1\nfi\n\n# Determine which Docker Compose command to use\nif command -v docker-compose &> /dev/null; then\n  COMPOSE_CMD=\"docker-compose\"\nelse\n  COMPOSE_CMD=\"docker compose\"\nfi\n\n# Detect system architecture for GPU configuration\nARCH=$(uname -m)\nOS=$(uname -s)\nGPU_CONFIG=\"\"\n\n# Set appropriate GPU configuration based on architecture\nif [ \"$OS\" = \"Darwin\" ] && { [ \"$ARCH\" = \"arm64\" ] || [ \"$ARCH\" = \"aarch64\" ]; }; then\n  echo \"Detected Apple Silicon (M-series) - Metal GPU acceleration should be enabled\"\n  # Metal GPU is handled via environment variables in docker-compose.yml\nelif command -v nvidia-smi &> /dev/null; then\n  echo \"NVIDIA GPU detected - Adding NVIDIA GPU configuration\"\n  GPU_CONFIG=\"-f docker-compose.yml -f docker-compose.nvidia.yml\"\nfi\n\n# Build the Docker image if 'build' command is provided\nif [ \"$COMMAND\" = \"build\" ]; then\n  docker build -t ai-hedge-fund -f Dockerfile ..\n  exit 0\nfi\n\n# Start Ollama container if 'ollama' command is provided\nif [ \"$COMMAND\" = \"ollama\" ]; then\n  echo \"Starting Ollama container...\"\n  $COMPOSE_CMD $GPU_CONFIG --profile embedded-ollama up -d ollama\n  \n  # Check if Ollama is running\n  echo \"Waiting for Ollama to start...\"\n  for i in {1..30}; do\n    if docker run --rm --network=host curlimages/curl:latest curl -s http://localhost:11434/api/version &> /dev/null; then\n      echo \"Ollama is now running.\"\n      # Show available models\n      echo \"Available models:\"\n      docker exec -t ollama ollama list\n      \n      echo -e \"\\nManage your models using:\"\n      echo \"  ./run.sh pull <model-name>   # Download a model\"\n      echo \"  ./run.sh ollama              # Start Ollama and show models\"\n      exit 0\n    fi\n    echo -n \".\"\n    sleep 1\n  done\n  \n  echo \"Failed to start Ollama within the expected time. You may need to check the container logs.\"\n  exit 1\nfi\n\n# Pull a model if 'pull' command is provided\nif [ \"$COMMAND\" = \"pull\" ]; then\n  if [ -z \"$MODEL_NAME\" ]; then\n    echo \"Error: No model name specified.\"\n    echo \"Usage: ./run.sh pull <model-name>\"\n    echo \"Example: ./run.sh pull llama3\"\n    exit 1\n  fi\n  \n  # Start Ollama if it's not already running\n  $COMPOSE_CMD $GPU_CONFIG --profile embedded-ollama up -d ollama\n  \n  # Wait for Ollama to start\n  echo \"Ensuring Ollama is running...\"\n  for i in {1..30}; do\n    if docker run --rm --network=host curlimages/curl:latest curl -s http://localhost:11434/api/version &> /dev/null; then\n      echo \"Ollama is running.\"\n      break\n    fi\n    echo -n \".\"\n    sleep 1\n  done\n  \n  # Pull the model\n  echo \"Pulling model: $MODEL_NAME\"\n  echo \"This may take some time depending on the model size and your internet connection.\"\n  echo \"You can press Ctrl+C to cancel at any time (the model will continue downloading in the background).\"\n  \n  docker exec -t ollama ollama pull \"$MODEL_NAME\"\n  \n  # Check if the model was successfully pulled\n  if docker exec -t ollama ollama list | grep -q \"$MODEL_NAME\"; then\n    echo \"Model $MODEL_NAME was successfully downloaded.\"\n  else\n    echo \"Warning: Model $MODEL_NAME may not have been properly downloaded.\"\n    echo \"Check the Ollama container status with: ./run.sh ollama\"\n  fi\n  \n  exit 0\nfi\n\n# Run with Docker Compose\nif [ \"$COMMAND\" = \"compose\" ]; then\n  echo \"Running with Docker Compose (includes Ollama)...\"\n  $COMPOSE_CMD $GPU_CONFIG --profile embedded-ollama up --build\n  exit 0\nfi\n\n# Check if .env file exists, if not create from .env.example\nif [ ! -f .env ]; then\n  if [ -f .env.example ]; then\n    echo \"No .env file found. Creating from .env.example...\"\n    cp .env.example .env\n    echo \"Please edit .env file to add your API keys.\"\n  else\n    echo \"Error: No .env or .env.example file found.\"\n    exit 1\n  fi\nfi\n\n# Set script path and parameters based on command\nif [ \"$COMMAND\" = \"main\" ]; then\n  SCRIPT_PATH=\"src/main.py\"\n  if [ \"$COMMAND\" = \"main\" ]; then\n    INITIAL_PARAM=\"--initial-cash $INITIAL_AMOUNT\"\n  fi\nelif [ \"$COMMAND\" = \"backtest\" ]; then\n  SCRIPT_PATH=\"src/backtester.py\"\n  if [ \"$COMMAND\" = \"backtest\" ]; then\n    INITIAL_PARAM=\"--initial-capital $INITIAL_AMOUNT\"\n  fi\nfi\n\n# If using Ollama, prepare embedded service or external connection\nif [ -n \"$USE_OLLAMA\" ]; then\n  if [ -n \"$OLLAMA_BASE_URL_VALUE\" ]; then\n    export OLLAMA_BASE_URL=\"$OLLAMA_BASE_URL_VALUE\"\n  fi\n\n  COMMAND_OVERRIDE=\"\"\n\n  if [ -n \"$START_DATE\" ]; then\n    COMMAND_OVERRIDE=\"$COMMAND_OVERRIDE $START_DATE\"\n  fi\n\n  if [ -n \"$END_DATE\" ]; then\n    COMMAND_OVERRIDE=\"$COMMAND_OVERRIDE $END_DATE\"\n  fi\n\n  if [ -n \"$INITIAL_PARAM\" ]; then\n    COMMAND_OVERRIDE=\"$COMMAND_OVERRIDE $INITIAL_PARAM\"\n  fi\n\n  if [ -n \"$MARGIN_REQUIREMENT\" ]; then\n    COMMAND_OVERRIDE=\"$COMMAND_OVERRIDE --margin-requirement $MARGIN_REQUIREMENT\"\n  fi\n\n  if [ \"$USE_EXTERNAL_OLLAMA\" = \"1\" ]; then\n    TRIMMED_BASE=\"${OLLAMA_BASE_URL_VALUE%/}\"\n    if [ -z \"$TRIMMED_BASE\" ]; then\n      TRIMMED_BASE=\"${OLLAMA_BASE_URL%/}\"\n    fi\n    if [ -z \"$TRIMMED_BASE\" ]; then\n      echo \"Error: No external Ollama base URL provided.\"\n      exit 1\n    fi\n    HEALTHCHECK_URL=\"$TRIMMED_BASE/api/version\"\n    echo \"Using external Ollama endpoint at $TRIMMED_BASE\"\n    echo \"Checking connectivity to Ollama...\"\n    REACHABLE=false\n    for i in {1..30}; do\n      if docker run --rm --network=host curlimages/curl:latest curl -s \"$HEALTHCHECK_URL\" &> /dev/null; then\n        REACHABLE=true\n        break\n      fi\n      echo -n \".\"\n      sleep 1\n    done\n    echo \"\"\n    if [ \"$REACHABLE\" = false ]; then\n      echo \"Warning: Unable to reach Ollama at $HEALTHCHECK_URL within 30 seconds.\"\n      echo \"Continuing anyway; ensure the endpoint is reachable from within the containers.\"\n    else\n      echo \"External Ollama endpoint is reachable.\"\n    fi\n  else\n    echo \"Setting up embedded Ollama container for local LLM inference...\"\n    $COMPOSE_CMD $GPU_CONFIG --profile embedded-ollama up -d ollama\n\n    echo \"Waiting for Ollama to start...\"\n    EMBEDDED_REACHABLE=false\n    for i in {1..30}; do\n      if docker run --rm --network=host curlimages/curl:latest curl -s http://localhost:11434/api/version &> /dev/null; then\n        EMBEDDED_REACHABLE=true\n        echo \"Ollama is running.\"\n        echo \"Available models:\"\n        docker exec -t ollama ollama list\n        break\n      fi\n      echo -n \".\"\n      sleep 1\n    done\n    echo \"\"\n    if [ \"$EMBEDDED_REACHABLE\" = false ]; then\n      echo \"Warning: Unable to confirm embedded Ollama startup within 30 seconds.\"\n    fi\n  fi\n\n  if [[ \"$(docker images -q ai-hedge-fund 2> /dev/null)\" == \"\" ]]; then\n    echo \"Building AI Hedge Fund image...\"\n    docker build -t ai-hedge-fund -f Dockerfile ..\n  fi\n\n  echo \"Running AI Hedge Fund with Ollama using Docker Compose...\"\n\n  if [ \"$COMMAND\" = \"main\" ]; then\n    if [ -n \"$SHOW_REASONING\" ]; then\n      $COMPOSE_CMD $GPU_CONFIG run --rm hedge-fund-reasoning python src/main.py --ticker $TICKER $COMMAND_OVERRIDE $SHOW_REASONING --ollama\n    else\n      $COMPOSE_CMD $GPU_CONFIG run --rm hedge-fund-ollama python src/main.py --ticker $TICKER $COMMAND_OVERRIDE --ollama\n    fi\n  elif [ \"$COMMAND\" = \"backtest\" ]; then\n    $COMPOSE_CMD $GPU_CONFIG run --rm backtester-ollama python src/backtester.py --ticker $TICKER $COMMAND_OVERRIDE $SHOW_REASONING --ollama\n  fi\n\n  exit 0\nfi\n# Standard Docker run (without Ollama)\n# Build the command\nCMD=\"docker run -it --rm -v $(pwd)/.env:/app/.env\"\n\n# Add the command\nCMD=\"$CMD ai-hedge-fund python $SCRIPT_PATH --ticker $TICKER $START_DATE $END_DATE $INITIAL_PARAM --margin-requirement $MARGIN_REQUIREMENT $SHOW_REASONING\"\n\n# Run the command\necho \"Running: $CMD\"\n$CMD "
  },
  {
    "path": "pyproject.toml",
    "content": "[tool.poetry]\nname = \"ai-hedge-fund\"\nversion = \"1.0.0\"\ndescription = \"An AI-powered hedge fund that uses multiple agents to make trading decisions\"\nauthors = [\"Your Name <your.email@example.com>\"]\nreadme = \"README.md\"\npackages = [\n    { include = \"src\", from = \".\" },\n    { include = \"app\", from = \".\" }\n]\n\n[tool.poetry.dependencies]\npython = \"^3.11\"\nlangchain = \"^0.3.7\"\nlangchain-anthropic = \"0.3.5\"\nlangchain-groq = \"0.2.3\"\nlangchain-openai = \"^0.3.5\"\nlangchain-deepseek = \"^0.1.2\"\nlangchain-ollama = \"0.3.6\"\nlanggraph = \"0.2.56\"\npandas = \"^2.1.0\"\nnumpy = \"^1.24.0\"\npython-dotenv = \"1.0.0\"\nmatplotlib = \"^3.9.2\"\ntabulate = \"^0.9.0\"\ncolorama = \"^0.4.6\"\nquestionary = \"^2.1.0\"\nrich = \"^13.9.4\"\nlangchain-google-genai = \"^2.0.11\"\n# Backend dependencies\nfastapi = {extras = [\"standard\"], version = \"^0.104.0\"}\nfastapi-cli = \"^0.0.7\"\npydantic = \"^2.4.2\"\nhttpx = \"^0.27.0\"\nsqlalchemy = \"^2.0.22\"\nalembic = \"^1.12.0\"\nlangchain-gigachat = \"^0.3.12\"\nlangchain-xai = \"^0.2.5\"\n\n[tool.poetry.group.dev.dependencies]\npytest = \"^7.4.0\"\nblack = \"^23.7.0\"\nisort = \"^5.12.0\"\nflake8 = \"^6.1.0\"\n\n[build-system]\nrequires = [\"poetry-core\"]\nbuild-backend = \"poetry.core.masonry.api\"\n\n[tool.black]\nline-length = 420\ntarget-version = ['py311']\ninclude = '\\.pyi?$'\n\n[tool.isort]\nprofile = \"black\"\nforce_alphabetical_sort_within_sections = true\n\n[tool.poetry.scripts]\nbacktester = \"src.backtesting.cli:main\""
  },
  {
    "path": "src/__init__.py",
    "content": ""
  },
  {
    "path": "src/agents/__init__.py",
    "content": ""
  },
  {
    "path": "src/agents/aswath_damodaran.py",
    "content": "from __future__ import annotations\n\nimport json\nfrom typing_extensions import Literal\nfrom pydantic import BaseModel\n\nfrom src.graph.state import AgentState, show_agent_reasoning\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_core.messages import HumanMessage\n\nfrom src.tools.api import (\n    get_financial_metrics,\n    get_market_cap,\n    search_line_items,\n)\nfrom src.utils.api_key import get_api_key_from_state\nfrom src.utils.llm import call_llm\nfrom src.utils.progress import progress\n\n\nclass AswathDamodaranSignal(BaseModel):\n    signal: Literal[\"bullish\", \"bearish\", \"neutral\"]\n    confidence: float          # 0‒100\n    reasoning: str\n\n\ndef aswath_damodaran_agent(state: AgentState, agent_id: str = \"aswath_damodaran_agent\"):\n    \"\"\"\n    Analyze US equities through Aswath Damodaran's intrinsic-value lens:\n      • Cost of Equity via CAPM (risk-free + β·ERP)\n      • 5-yr revenue / FCFF growth trends & reinvestment efficiency\n      • FCFF-to-Firm DCF → equity value → per-share intrinsic value\n      • Cross-check with relative valuation (PE vs. Fwd PE sector median proxy)\n    Produces a trading signal and explanation in Damodaran's analytical voice.\n    \"\"\"\n    data      = state[\"data\"]\n    end_date  = data[\"end_date\"]\n    tickers   = data[\"tickers\"]\n    api_key  = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n\n    analysis_data: dict[str, dict] = {}\n    damodaran_signals: dict[str, dict] = {}\n\n    for ticker in tickers:\n        # ─── Fetch core data ────────────────────────────────────────────────────\n        progress.update_status(agent_id, ticker, \"Fetching financial metrics\")\n        metrics = get_financial_metrics(ticker, end_date, period=\"ttm\", limit=5, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Fetching financial line items\")\n        line_items = search_line_items(\n            ticker,\n            [\n                \"free_cash_flow\",\n                \"ebit\",\n                \"interest_expense\",\n                \"capital_expenditure\",\n                \"depreciation_and_amortization\",\n                \"outstanding_shares\",\n                \"net_income\",\n                \"total_debt\",\n            ],\n            end_date,\n            api_key=api_key,\n        )\n\n        progress.update_status(agent_id, ticker, \"Getting market cap\")\n        market_cap = get_market_cap(ticker, end_date, api_key=api_key)\n\n        # ─── Analyses ───────────────────────────────────────────────────────────\n        progress.update_status(agent_id, ticker, \"Analyzing growth and reinvestment\")\n        growth_analysis = analyze_growth_and_reinvestment(metrics, line_items)\n\n        progress.update_status(agent_id, ticker, \"Analyzing risk profile\")\n        risk_analysis = analyze_risk_profile(metrics, line_items)\n\n        progress.update_status(agent_id, ticker, \"Calculating intrinsic value (DCF)\")\n        intrinsic_val_analysis = calculate_intrinsic_value_dcf(metrics, line_items, risk_analysis)\n\n        progress.update_status(agent_id, ticker, \"Assessing relative valuation\")\n        relative_val_analysis = analyze_relative_valuation(metrics)\n\n        # ─── Score & margin of safety ──────────────────────────────────────────\n        total_score = (\n            growth_analysis[\"score\"]\n            + risk_analysis[\"score\"]\n            + relative_val_analysis[\"score\"]\n        )\n        max_score = growth_analysis[\"max_score\"] + risk_analysis[\"max_score\"] + relative_val_analysis[\"max_score\"]\n\n        intrinsic_value = intrinsic_val_analysis[\"intrinsic_value\"]\n        margin_of_safety = (\n            (intrinsic_value - market_cap) / market_cap if intrinsic_value and market_cap else None\n        )\n\n        # Decision rules (Damodaran tends to act with ~20-25 % MOS)\n        if margin_of_safety is not None and margin_of_safety >= 0.25:\n            signal = \"bullish\"\n        elif margin_of_safety is not None and margin_of_safety <= -0.25:\n            signal = \"bearish\"\n        else:\n            signal = \"neutral\"\n\n        analysis_data[ticker] = {\n            \"signal\": signal,\n            \"score\": total_score,\n            \"max_score\": max_score,\n            \"margin_of_safety\": margin_of_safety,\n            \"growth_analysis\": growth_analysis,\n            \"risk_analysis\": risk_analysis,\n            \"relative_val_analysis\": relative_val_analysis,\n            \"intrinsic_val_analysis\": intrinsic_val_analysis,\n            \"market_cap\": market_cap,\n        }\n\n        # ─── LLM: craft Damodaran-style narrative ──────────────────────────────\n        progress.update_status(agent_id, ticker, \"Generating Damodaran analysis\")\n        damodaran_output = generate_damodaran_output(\n            ticker=ticker,\n            analysis_data=analysis_data,\n            state=state,\n            agent_id=agent_id,\n        )\n\n        damodaran_signals[ticker] = damodaran_output.model_dump()\n\n        progress.update_status(agent_id, ticker, \"Done\", analysis=damodaran_output.reasoning)\n\n    # ─── Push message back to graph state ──────────────────────────────────────\n    message = HumanMessage(content=json.dumps(damodaran_signals), name=agent_id)\n\n    if state[\"metadata\"][\"show_reasoning\"]:\n        show_agent_reasoning(damodaran_signals, \"Aswath Damodaran Agent\")\n\n    state[\"data\"][\"analyst_signals\"][agent_id] = damodaran_signals\n    progress.update_status(agent_id, None, \"Done\")\n\n    return {\"messages\": [message], \"data\": state[\"data\"]}\n\n\n# ────────────────────────────────────────────────────────────────────────────────\n# Helper analyses\n# ────────────────────────────────────────────────────────────────────────────────\ndef analyze_growth_and_reinvestment(metrics: list, line_items: list) -> dict[str, any]:\n    \"\"\"\n    Growth score (0-4):\n      +2  5-yr CAGR of revenue > 8 %\n      +1  5-yr CAGR of revenue > 3 %\n      +1  Positive FCFF growth over 5 yr\n    Reinvestment efficiency (ROIC > WACC) adds +1\n    \"\"\"\n    max_score = 4\n    if len(metrics) < 2:\n        return {\"score\": 0, \"max_score\": max_score, \"details\": \"Insufficient history\"}\n\n    # Revenue CAGR (oldest to latest)\n    revs = [m.revenue for m in reversed(metrics) if hasattr(m, \"revenue\") and m.revenue]\n    if len(revs) >= 2 and revs[0] > 0:\n        cagr = (revs[-1] / revs[0]) ** (1 / (len(revs) - 1)) - 1\n    else:\n        cagr = None\n\n    score, details = 0, []\n\n    if cagr is not None:\n        if cagr > 0.08:\n            score += 2\n            details.append(f\"Revenue CAGR {cagr:.1%} (> 8 %)\")\n        elif cagr > 0.03:\n            score += 1\n            details.append(f\"Revenue CAGR {cagr:.1%} (> 3 %)\")\n        else:\n            details.append(f\"Sluggish revenue CAGR {cagr:.1%}\")\n    else:\n        details.append(\"Revenue data incomplete\")\n\n    # FCFF growth (proxy: free_cash_flow trend)\n    fcfs = [li.free_cash_flow for li in reversed(line_items) if li.free_cash_flow]\n    if len(fcfs) >= 2 and fcfs[-1] > fcfs[0]:\n        score += 1\n        details.append(\"Positive FCFF growth\")\n    else:\n        details.append(\"Flat or declining FCFF\")\n\n    # Reinvestment efficiency (ROIC vs. 10 % hurdle)\n    latest = metrics[0]\n    if latest.return_on_invested_capital and latest.return_on_invested_capital > 0.10:\n        score += 1\n        details.append(f\"ROIC {latest.return_on_invested_capital:.1%} (> 10 %)\")\n\n    return {\"score\": score, \"max_score\": max_score, \"details\": \"; \".join(details), \"metrics\": latest.model_dump()}\n\n\ndef analyze_risk_profile(metrics: list, line_items: list) -> dict[str, any]:\n    \"\"\"\n    Risk score (0-3):\n      +1  Beta < 1.3\n      +1  Debt/Equity < 1\n      +1  Interest Coverage > 3×\n    \"\"\"\n    max_score = 3\n    if not metrics:\n        return {\"score\": 0, \"max_score\": max_score, \"details\": \"No metrics\"}\n\n    latest = metrics[0]\n    score, details = 0, []\n\n    # Beta\n    beta = getattr(latest, \"beta\", None)\n    if beta is not None:\n        if beta < 1.3:\n            score += 1\n            details.append(f\"Beta {beta:.2f}\")\n        else:\n            details.append(f\"High beta {beta:.2f}\")\n    else:\n        details.append(\"Beta NA\")\n\n    # Debt / Equity\n    dte = getattr(latest, \"debt_to_equity\", None)\n    if dte is not None:\n        if dte < 1:\n            score += 1\n            details.append(f\"D/E {dte:.1f}\")\n        else:\n            details.append(f\"High D/E {dte:.1f}\")\n    else:\n        details.append(\"D/E NA\")\n\n    # Interest coverage\n    ebit = getattr(latest, \"ebit\", None)\n    interest = getattr(latest, \"interest_expense\", None)\n    if ebit and interest and interest != 0:\n        coverage = ebit / abs(interest)\n        if coverage > 3:\n            score += 1\n            details.append(f\"Interest coverage × {coverage:.1f}\")\n        else:\n            details.append(f\"Weak coverage × {coverage:.1f}\")\n    else:\n        details.append(\"Interest coverage NA\")\n\n    # Compute cost of equity for later use\n    cost_of_equity = estimate_cost_of_equity(beta)\n\n    return {\n        \"score\": score,\n        \"max_score\": max_score,\n        \"details\": \"; \".join(details),\n        \"beta\": beta,\n        \"cost_of_equity\": cost_of_equity,\n    }\n\n\ndef analyze_relative_valuation(metrics: list) -> dict[str, any]:\n    \"\"\"\n    Simple PE check vs. historical median (proxy since sector comps unavailable):\n      +1 if TTM P/E < 70 % of 5-yr median\n      +0 if between 70 %-130 %\n      ‑1 if >130 %\n    \"\"\"\n    max_score = 1\n    if not metrics or len(metrics) < 5:\n        return {\"score\": 0, \"max_score\": max_score, \"details\": \"Insufficient P/E history\"}\n\n    pes = [m.price_to_earnings_ratio for m in metrics if m.price_to_earnings_ratio]\n    if len(pes) < 5:\n        return {\"score\": 0, \"max_score\": max_score, \"details\": \"P/E data sparse\"}\n\n    ttm_pe = pes[0]\n    median_pe = sorted(pes)[len(pes) // 2]\n\n    if ttm_pe < 0.7 * median_pe:\n        score, desc = 1, f\"P/E {ttm_pe:.1f} vs. median {median_pe:.1f} (cheap)\"\n    elif ttm_pe > 1.3 * median_pe:\n        score, desc = -1, f\"P/E {ttm_pe:.1f} vs. median {median_pe:.1f} (expensive)\"\n    else:\n        score, desc = 0, f\"P/E inline with history\"\n\n    return {\"score\": score, \"max_score\": max_score, \"details\": desc}\n\n\n# ────────────────────────────────────────────────────────────────────────────────\n# Intrinsic value via FCFF DCF (Damodaran style)\n# ────────────────────────────────────────────────────────────────────────────────\ndef calculate_intrinsic_value_dcf(metrics: list, line_items: list, risk_analysis: dict) -> dict[str, any]:\n    \"\"\"\n    FCFF DCF with:\n      • Base FCFF = latest free cash flow\n      • Growth = 5-yr revenue CAGR (capped 12 %)\n      • Fade linearly to terminal growth 2.5 % by year 10\n      • Discount @ cost of equity (no debt split given data limitations)\n    \"\"\"\n    if not metrics or len(metrics) < 2 or not line_items:\n        return {\"intrinsic_value\": None, \"details\": [\"Insufficient data\"]}\n\n    latest_m = metrics[0]\n    fcff0 = getattr(latest_m, \"free_cash_flow\", None)\n    shares = getattr(line_items[0], \"outstanding_shares\", None)\n    if not fcff0 or not shares:\n        return {\"intrinsic_value\": None, \"details\": [\"Missing FCFF or share count\"]}\n\n    # Growth assumptions\n    revs = [m.revenue for m in reversed(metrics) if m.revenue]\n    if len(revs) >= 2 and revs[0] > 0:\n        base_growth = min((revs[-1] / revs[0]) ** (1 / (len(revs) - 1)) - 1, 0.12)\n    else:\n        base_growth = 0.04  # fallback\n\n    terminal_growth = 0.025\n    years = 10\n\n    # Discount rate\n    discount = risk_analysis.get(\"cost_of_equity\") or 0.09\n\n    # Project FCFF and discount\n    pv_sum = 0.0\n    g = base_growth\n    g_step = (terminal_growth - base_growth) / (years - 1)\n    for yr in range(1, years + 1):\n        fcff_t = fcff0 * (1 + g)\n        pv = fcff_t / (1 + discount) ** yr\n        pv_sum += pv\n        g += g_step\n\n    # Terminal value (perpetuity with terminal growth)\n    tv = (\n        fcff0\n        * (1 + terminal_growth)\n        / (discount - terminal_growth)\n        / (1 + discount) ** years\n    )\n\n    equity_value = pv_sum + tv\n    intrinsic_per_share = equity_value / shares\n\n    return {\n        \"intrinsic_value\": equity_value,\n        \"intrinsic_per_share\": intrinsic_per_share,\n        \"assumptions\": {\n            \"base_fcff\": fcff0,\n            \"base_growth\": base_growth,\n            \"terminal_growth\": terminal_growth,\n            \"discount_rate\": discount,\n            \"projection_years\": years,\n        },\n        \"details\": [\"FCFF DCF completed\"],\n    }\n\n\ndef estimate_cost_of_equity(beta: float | None) -> float:\n    \"\"\"CAPM: r_e = r_f + β × ERP (use Damodaran's long-term averages).\"\"\"\n    risk_free = 0.04          # 10-yr US Treasury proxy\n    erp = 0.05                # long-run US equity risk premium\n    beta = beta if beta is not None else 1.0\n    return risk_free + beta * erp\n\n\n# ────────────────────────────────────────────────────────────────────────────────\n# LLM generation\n# ────────────────────────────────────────────────────────────────────────────────\ndef generate_damodaran_output(\n    ticker: str,\n    analysis_data: dict[str, any],\n    state: AgentState,\n    agent_id: str,\n) -> AswathDamodaranSignal:\n    \"\"\"\n    Ask the LLM to channel Prof. Damodaran's analytical style:\n      • Story → Numbers → Value narrative\n      • Emphasize risk, growth, and cash-flow assumptions\n      • Cite cost of capital, implied MOS, and valuation cross-checks\n    \"\"\"\n    template = ChatPromptTemplate.from_messages(\n        [\n            (\n                \"system\",\n                \"\"\"You are Aswath Damodaran, Professor of Finance at NYU Stern.\n                Use your valuation framework to issue trading signals on US equities.\n\n                Speak with your usual clear, data-driven tone:\n                  ◦ Start with the company \"story\" (qualitatively)\n                  ◦ Connect that story to key numerical drivers: revenue growth, margins, reinvestment, risk\n                  ◦ Conclude with value: your FCFF DCF estimate, margin of safety, and relative valuation sanity checks\n                  ◦ Highlight major uncertainties and how they affect value\n                Return ONLY the JSON specified below.\"\"\",\n            ),\n            (\n                \"human\",\n                \"\"\"Ticker: {ticker}\n\n                Analysis data:\n                {analysis_data}\n\n                Respond EXACTLY in this JSON schema:\n                {{\n                  \"signal\": \"bullish\" | \"bearish\" | \"neutral\",\n                  \"confidence\": float (0-100),\n                  \"reasoning\": \"string\"\n                }}\"\"\",\n            ),\n        ]\n    )\n\n    prompt = template.invoke({\"analysis_data\": json.dumps(analysis_data, indent=2), \"ticker\": ticker})\n\n    def default_signal():\n        return AswathDamodaranSignal(\n            signal=\"neutral\",\n            confidence=0.0,\n            reasoning=\"Parsing error; defaulting to neutral\",\n        )\n\n    return call_llm(\n        prompt=prompt,\n        pydantic_model=AswathDamodaranSignal,\n        agent_name=agent_id,\n        state=state,\n        default_factory=default_signal,\n    )\n"
  },
  {
    "path": "src/agents/ben_graham.py",
    "content": "from src.graph.state import AgentState, show_agent_reasoning\nfrom src.tools.api import get_financial_metrics, get_market_cap, search_line_items\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_core.messages import HumanMessage\nfrom pydantic import BaseModel\nimport json\nfrom typing_extensions import Literal\nfrom src.utils.progress import progress\nfrom src.utils.llm import call_llm\nimport math\nfrom src.utils.api_key import get_api_key_from_state\n\n\nclass BenGrahamSignal(BaseModel):\n    signal: Literal[\"bullish\", \"bearish\", \"neutral\"]\n    confidence: float\n    reasoning: str\n\n\ndef ben_graham_agent(state: AgentState, agent_id: str = \"ben_graham_agent\"):\n    \"\"\"\n    Analyzes stocks using Benjamin Graham's classic value-investing principles:\n    1. Earnings stability over multiple years.\n    2. Solid financial strength (low debt, adequate liquidity).\n    3. Discount to intrinsic value (e.g. Graham Number or net-net).\n    4. Adequate margin of safety.\n    \"\"\"\n    data = state[\"data\"]\n    end_date = data[\"end_date\"]\n    tickers = data[\"tickers\"]\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    \n    analysis_data = {}\n    graham_analysis = {}\n\n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Fetching financial metrics\")\n        metrics = get_financial_metrics(ticker, end_date, period=\"annual\", limit=10, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Gathering financial line items\")\n        financial_line_items = search_line_items(ticker, [\"earnings_per_share\", \"revenue\", \"net_income\", \"book_value_per_share\", \"total_assets\", \"total_liabilities\", \"current_assets\", \"current_liabilities\", \"dividends_and_other_cash_distributions\", \"outstanding_shares\"], end_date, period=\"annual\", limit=10, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Getting market cap\")\n        market_cap = get_market_cap(ticker, end_date, api_key=api_key)\n\n        # Perform sub-analyses\n        progress.update_status(agent_id, ticker, \"Analyzing earnings stability\")\n        earnings_analysis = analyze_earnings_stability(metrics, financial_line_items)\n\n        progress.update_status(agent_id, ticker, \"Analyzing financial strength\")\n        strength_analysis = analyze_financial_strength(financial_line_items)\n\n        progress.update_status(agent_id, ticker, \"Analyzing Graham valuation\")\n        valuation_analysis = analyze_valuation_graham(financial_line_items, market_cap)\n\n        # Aggregate scoring\n        total_score = earnings_analysis[\"score\"] + strength_analysis[\"score\"] + valuation_analysis[\"score\"]\n        max_possible_score = 15  # total possible from the three analysis functions\n\n        # Map total_score to signal\n        if total_score >= 0.7 * max_possible_score:\n            signal = \"bullish\"\n        elif total_score <= 0.3 * max_possible_score:\n            signal = \"bearish\"\n        else:\n            signal = \"neutral\"\n\n        analysis_data[ticker] = {\"signal\": signal, \"score\": total_score, \"max_score\": max_possible_score, \"earnings_analysis\": earnings_analysis, \"strength_analysis\": strength_analysis, \"valuation_analysis\": valuation_analysis}\n\n        progress.update_status(agent_id, ticker, \"Generating Ben Graham analysis\")\n        graham_output = generate_graham_output(\n            ticker=ticker,\n            analysis_data=analysis_data,\n            state=state,\n            agent_id=agent_id,\n        )\n\n        graham_analysis[ticker] = {\"signal\": graham_output.signal, \"confidence\": graham_output.confidence, \"reasoning\": graham_output.reasoning}\n\n        progress.update_status(agent_id, ticker, \"Done\", analysis=graham_output.reasoning)\n\n    # Wrap results in a single message for the chain\n    message = HumanMessage(content=json.dumps(graham_analysis), name=agent_id)\n\n    # Optionally display reasoning\n    if state[\"metadata\"][\"show_reasoning\"]:\n        show_agent_reasoning(graham_analysis, \"Ben Graham Agent\")\n\n    # Store signals in the overall state\n    state[\"data\"][\"analyst_signals\"][agent_id] = graham_analysis\n\n    progress.update_status(agent_id, None, \"Done\")\n\n    return {\"messages\": [message], \"data\": state[\"data\"]}\n\n\ndef analyze_earnings_stability(metrics: list, financial_line_items: list) -> dict:\n    \"\"\"\n    Graham wants at least several years of consistently positive earnings (ideally 5+).\n    We'll check:\n    1. Number of years with positive EPS.\n    2. Growth in EPS from first to last period.\n    \"\"\"\n    score = 0\n    details = []\n\n    if not metrics or not financial_line_items:\n        return {\"score\": score, \"details\": \"Insufficient data for earnings stability analysis\"}\n\n    eps_vals = []\n    for item in financial_line_items:\n        if item.earnings_per_share is not None:\n            eps_vals.append(item.earnings_per_share)\n\n    if len(eps_vals) < 2:\n        details.append(\"Not enough multi-year EPS data.\")\n        return {\"score\": score, \"details\": \"; \".join(details)}\n\n    # 1. Consistently positive EPS\n    positive_eps_years = sum(1 for e in eps_vals if e > 0)\n    total_eps_years = len(eps_vals)\n    if positive_eps_years == total_eps_years:\n        score += 3\n        details.append(\"EPS was positive in all available periods.\")\n    elif positive_eps_years >= (total_eps_years * 0.8):\n        score += 2\n        details.append(\"EPS was positive in most periods.\")\n    else:\n        details.append(\"EPS was negative in multiple periods.\")\n\n    # 2. EPS growth from earliest to latest\n    if eps_vals[0] > eps_vals[-1]:\n        score += 1\n        details.append(\"EPS grew from earliest to latest period.\")\n    else:\n        details.append(\"EPS did not grow from earliest to latest period.\")\n\n    return {\"score\": score, \"details\": \"; \".join(details)}\n\n\ndef analyze_financial_strength(financial_line_items: list) -> dict:\n    \"\"\"\n    Graham checks liquidity (current ratio >= 2), manageable debt,\n    and dividend record (preferably some history of dividends).\n    \"\"\"\n    score = 0\n    details = []\n\n    if not financial_line_items:\n        return {\"score\": score, \"details\": \"No data for financial strength analysis\"}\n\n    latest_item = financial_line_items[0]\n    total_assets = latest_item.total_assets or 0\n    total_liabilities = latest_item.total_liabilities or 0\n    current_assets = latest_item.current_assets or 0\n    current_liabilities = latest_item.current_liabilities or 0\n\n    # 1. Current ratio\n    if current_liabilities > 0:\n        current_ratio = current_assets / current_liabilities\n        if current_ratio >= 2.0:\n            score += 2\n            details.append(f\"Current ratio = {current_ratio:.2f} (>=2.0: solid).\")\n        elif current_ratio >= 1.5:\n            score += 1\n            details.append(f\"Current ratio = {current_ratio:.2f} (moderately strong).\")\n        else:\n            details.append(f\"Current ratio = {current_ratio:.2f} (<1.5: weaker liquidity).\")\n    else:\n        details.append(\"Cannot compute current ratio (missing or zero current_liabilities).\")\n\n    # 2. Debt vs. Assets\n    if total_assets > 0:\n        debt_ratio = total_liabilities / total_assets\n        if debt_ratio < 0.5:\n            score += 2\n            details.append(f\"Debt ratio = {debt_ratio:.2f}, under 0.50 (conservative).\")\n        elif debt_ratio < 0.8:\n            score += 1\n            details.append(f\"Debt ratio = {debt_ratio:.2f}, somewhat high but could be acceptable.\")\n        else:\n            details.append(f\"Debt ratio = {debt_ratio:.2f}, quite high by Graham standards.\")\n    else:\n        details.append(\"Cannot compute debt ratio (missing total_assets).\")\n\n    # 3. Dividend track record\n    div_periods = [item.dividends_and_other_cash_distributions for item in financial_line_items if item.dividends_and_other_cash_distributions is not None]\n    if div_periods:\n        # In many data feeds, dividend outflow is shown as a negative number\n        # (money going out to shareholders). We'll consider any negative as 'paid a dividend'.\n        div_paid_years = sum(1 for d in div_periods if d < 0)\n        if div_paid_years > 0:\n            # e.g. if at least half the periods had dividends\n            if div_paid_years >= (len(div_periods) // 2 + 1):\n                score += 1\n                details.append(\"Company paid dividends in the majority of the reported years.\")\n            else:\n                details.append(\"Company has some dividend payments, but not most years.\")\n        else:\n            details.append(\"Company did not pay dividends in these periods.\")\n    else:\n        details.append(\"No dividend data available to assess payout consistency.\")\n\n    return {\"score\": score, \"details\": \"; \".join(details)}\n\n\ndef analyze_valuation_graham(financial_line_items: list, market_cap: float) -> dict:\n    \"\"\"\n    Core Graham approach to valuation:\n    1. Net-Net Check: (Current Assets - Total Liabilities) vs. Market Cap\n    2. Graham Number: sqrt(22.5 * EPS * Book Value per Share)\n    3. Compare per-share price to Graham Number => margin of safety\n    \"\"\"\n    if not financial_line_items or not market_cap or market_cap <= 0:\n        return {\"score\": 0, \"details\": \"Insufficient data to perform valuation\"}\n\n    latest = financial_line_items[0]\n    current_assets = latest.current_assets or 0\n    total_liabilities = latest.total_liabilities or 0\n    book_value_ps = latest.book_value_per_share or 0\n    eps = latest.earnings_per_share or 0\n    shares_outstanding = latest.outstanding_shares or 0\n\n    details = []\n    score = 0\n\n    # 1. Net-Net Check\n    #   NCAV = Current Assets - Total Liabilities\n    #   If NCAV > Market Cap => historically a strong buy signal\n    net_current_asset_value = current_assets - total_liabilities\n    if net_current_asset_value > 0 and shares_outstanding > 0:\n        net_current_asset_value_per_share = net_current_asset_value / shares_outstanding\n        price_per_share = market_cap / shares_outstanding if shares_outstanding else 0\n\n        details.append(f\"Net Current Asset Value = {net_current_asset_value:,.2f}\")\n        details.append(f\"NCAV Per Share = {net_current_asset_value_per_share:,.2f}\")\n        details.append(f\"Price Per Share = {price_per_share:,.2f}\")\n\n        if net_current_asset_value > market_cap:\n            score += 4  # Very strong Graham signal\n            details.append(\"Net-Net: NCAV > Market Cap (classic Graham deep value).\")\n        else:\n            # For partial net-net discount\n            if net_current_asset_value_per_share >= (price_per_share * 0.67):\n                score += 2\n                details.append(\"NCAV Per Share >= 2/3 of Price Per Share (moderate net-net discount).\")\n    else:\n        details.append(\"NCAV not exceeding market cap or insufficient data for net-net approach.\")\n\n    # 2. Graham Number\n    #   GrahamNumber = sqrt(22.5 * EPS * BVPS).\n    #   Compare the result to the current price_per_share\n    #   If GrahamNumber >> price, indicates undervaluation\n    graham_number = None\n    if eps > 0 and book_value_ps > 0:\n        graham_number = math.sqrt(22.5 * eps * book_value_ps)\n        details.append(f\"Graham Number = {graham_number:.2f}\")\n    else:\n        details.append(\"Unable to compute Graham Number (EPS or Book Value missing/<=0).\")\n\n    # 3. Margin of Safety relative to Graham Number\n    if graham_number and shares_outstanding > 0:\n        current_price = market_cap / shares_outstanding\n        if current_price > 0:\n            margin_of_safety = (graham_number - current_price) / current_price\n            details.append(f\"Margin of Safety (Graham Number) = {margin_of_safety:.2%}\")\n            if margin_of_safety > 0.5:\n                score += 3\n                details.append(\"Price is well below Graham Number (>=50% margin).\")\n            elif margin_of_safety > 0.2:\n                score += 1\n                details.append(\"Some margin of safety relative to Graham Number.\")\n            else:\n                details.append(\"Price close to or above Graham Number, low margin of safety.\")\n        else:\n            details.append(\"Current price is zero or invalid; can't compute margin of safety.\")\n    # else: already appended details for missing graham_number\n\n    return {\"score\": score, \"details\": \"; \".join(details)}\n\n\ndef generate_graham_output(\n    ticker: str,\n    analysis_data: dict[str, any],\n    state: AgentState,\n    agent_id: str,\n) -> BenGrahamSignal:\n    \"\"\"\n    Generates an investment decision in the style of Benjamin Graham:\n    - Value emphasis, margin of safety, net-nets, conservative balance sheet, stable earnings.\n    - Return the result in a JSON structure: { signal, confidence, reasoning }.\n    \"\"\"\n\n    template = ChatPromptTemplate.from_messages(\n        [\n            (\n                \"system\",\n                \"\"\"You are a Benjamin Graham AI agent, making investment decisions using his principles:\n            1. Insist on a margin of safety by buying below intrinsic value (e.g., using Graham Number, net-net).\n            2. Emphasize the company's financial strength (low leverage, ample current assets).\n            3. Prefer stable earnings over multiple years.\n            4. Consider dividend record for extra safety.\n            5. Avoid speculative or high-growth assumptions; focus on proven metrics.\n            \n            When providing your reasoning, be thorough and specific by:\n            1. Explaining the key valuation metrics that influenced your decision the most (Graham Number, NCAV, P/E, etc.)\n            2. Highlighting the specific financial strength indicators (current ratio, debt levels, etc.)\n            3. Referencing the stability or instability of earnings over time\n            4. Providing quantitative evidence with precise numbers\n            5. Comparing current metrics to Graham's specific thresholds (e.g., \"Current ratio of 2.5 exceeds Graham's minimum of 2.0\")\n            6. Using Benjamin Graham's conservative, analytical voice and style in your explanation\n            \n            For example, if bullish: \"The stock trades at a 35% discount to net current asset value, providing an ample margin of safety. The current ratio of 2.5 and debt-to-equity of 0.3 indicate strong financial position...\"\n            For example, if bearish: \"Despite consistent earnings, the current price of $50 exceeds our calculated Graham Number of $35, offering no margin of safety. Additionally, the current ratio of only 1.2 falls below Graham's preferred 2.0 threshold...\"\n                        \n            Return a rational recommendation: bullish, bearish, or neutral, with a confidence level (0-100) and thorough reasoning.\n            \"\"\",\n            ),\n            (\n                \"human\",\n                \"\"\"Based on the following analysis, create a Graham-style investment signal:\n\n            Analysis Data for {ticker}:\n            {analysis_data}\n\n            Return JSON exactly in this format:\n            {{\n              \"signal\": \"bullish\" or \"bearish\" or \"neutral\",\n              \"confidence\": float (0-100),\n              \"reasoning\": \"string\"\n            }}\n            \"\"\",\n            ),\n        ]\n    )\n\n    prompt = template.invoke({\"analysis_data\": json.dumps(analysis_data, indent=2), \"ticker\": ticker})\n\n    def create_default_ben_graham_signal():\n        return BenGrahamSignal(signal=\"neutral\", confidence=0.0, reasoning=\"Error in generating analysis; defaulting to neutral.\")\n\n    return call_llm(\n        prompt=prompt,\n        pydantic_model=BenGrahamSignal,\n        agent_name=agent_id,\n        state=state,\n        default_factory=create_default_ben_graham_signal,\n    )\n"
  },
  {
    "path": "src/agents/bill_ackman.py",
    "content": "from src.graph.state import AgentState, show_agent_reasoning\nfrom src.tools.api import get_financial_metrics, get_market_cap, search_line_items\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_core.messages import HumanMessage\nfrom pydantic import BaseModel\nimport json\nfrom typing_extensions import Literal\nfrom src.utils.progress import progress\nfrom src.utils.llm import call_llm\nfrom src.utils.api_key import get_api_key_from_state\n\n\nclass BillAckmanSignal(BaseModel):\n    signal: Literal[\"bullish\", \"bearish\", \"neutral\"]\n    confidence: float\n    reasoning: str\n\n\ndef bill_ackman_agent(state: AgentState, agent_id: str = \"bill_ackman_agent\"):\n    \"\"\"\n    Analyzes stocks using Bill Ackman's investing principles and LLM reasoning.\n    Fetches multiple periods of data for a more robust long-term view.\n    Incorporates brand/competitive advantage, activism potential, and other key factors.\n    \"\"\"\n    data = state[\"data\"]\n    end_date = data[\"end_date\"]\n    tickers = data[\"tickers\"]\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    analysis_data = {}\n    ackman_analysis = {}\n    \n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Fetching financial metrics\")\n        metrics = get_financial_metrics(ticker, end_date, period=\"annual\", limit=5, api_key=api_key)\n        \n        progress.update_status(agent_id, ticker, \"Gathering financial line items\")\n        # Request multiple periods of data (annual or TTM) for a more robust long-term view.\n        financial_line_items = search_line_items(\n            ticker,\n            [\n                \"revenue\",\n                \"operating_margin\",\n                \"debt_to_equity\",\n                \"free_cash_flow\",\n                \"total_assets\",\n                \"total_liabilities\",\n                \"dividends_and_other_cash_distributions\",\n                \"outstanding_shares\",\n                # Optional: intangible_assets if available\n                # \"intangible_assets\"\n            ],\n            end_date,\n            period=\"annual\",\n            limit=5,\n            api_key=api_key,\n        )\n        \n        progress.update_status(agent_id, ticker, \"Getting market cap\")\n        market_cap = get_market_cap(ticker, end_date, api_key=api_key)\n        \n        progress.update_status(agent_id, ticker, \"Analyzing business quality\")\n        quality_analysis = analyze_business_quality(metrics, financial_line_items)\n        \n        progress.update_status(agent_id, ticker, \"Analyzing balance sheet and capital structure\")\n        balance_sheet_analysis = analyze_financial_discipline(metrics, financial_line_items)\n        \n        progress.update_status(agent_id, ticker, \"Analyzing activism potential\")\n        activism_analysis = analyze_activism_potential(financial_line_items)\n        \n        progress.update_status(agent_id, ticker, \"Calculating intrinsic value & margin of safety\")\n        valuation_analysis = analyze_valuation(financial_line_items, market_cap)\n        \n        # Combine partial scores or signals\n        total_score = (\n            quality_analysis[\"score\"]\n            + balance_sheet_analysis[\"score\"]\n            + activism_analysis[\"score\"]\n            + valuation_analysis[\"score\"]\n        )\n        max_possible_score = 20  # Adjust weighting as desired (5 from each sub-analysis, for instance)\n        \n        # Generate a simple buy/hold/sell (bullish/neutral/bearish) signal\n        if total_score >= 0.7 * max_possible_score:\n            signal = \"bullish\"\n        elif total_score <= 0.3 * max_possible_score:\n            signal = \"bearish\"\n        else:\n            signal = \"neutral\"\n        \n        analysis_data[ticker] = {\n            \"signal\": signal,\n            \"score\": total_score,\n            \"max_score\": max_possible_score,\n            \"quality_analysis\": quality_analysis,\n            \"balance_sheet_analysis\": balance_sheet_analysis,\n            \"activism_analysis\": activism_analysis,\n            \"valuation_analysis\": valuation_analysis\n        }\n        \n        progress.update_status(agent_id, ticker, \"Generating Bill Ackman analysis\")\n        ackman_output = generate_ackman_output(\n            ticker=ticker, \n            analysis_data=analysis_data,\n            state=state,\n            agent_id=agent_id,\n        )\n        \n        ackman_analysis[ticker] = {\n            \"signal\": ackman_output.signal,\n            \"confidence\": ackman_output.confidence,\n            \"reasoning\": ackman_output.reasoning\n        }\n        \n        progress.update_status(agent_id, ticker, \"Done\", analysis=ackman_output.reasoning)\n    \n    # Wrap results in a single message for the chain\n    message = HumanMessage(\n        content=json.dumps(ackman_analysis),\n        name=agent_id\n    )\n    \n    # Show reasoning if requested\n    if state[\"metadata\"][\"show_reasoning\"]:\n        show_agent_reasoning(ackman_analysis, \"Bill Ackman Agent\")\n    \n    # Add signals to the overall state\n    state[\"data\"][\"analyst_signals\"][agent_id] = ackman_analysis\n\n    progress.update_status(agent_id, None, \"Done\")\n\n    return {\n        \"messages\": [message],\n        \"data\": state[\"data\"]\n    }\n\n\ndef analyze_business_quality(metrics: list, financial_line_items: list) -> dict:\n    \"\"\"\n    Analyze whether the company has a high-quality business with stable or growing cash flows,\n    durable competitive advantages (moats), and potential for long-term growth.\n    Also tries to infer brand strength if intangible_assets data is present (optional).\n    \"\"\"\n    score = 0\n    details = []\n    \n    if not metrics or not financial_line_items:\n        return {\n            \"score\": 0,\n            \"details\": \"Insufficient data to analyze business quality\"\n        }\n    \n    # 1. Multi-period revenue growth analysis\n    revenues = [item.revenue for item in financial_line_items if item.revenue is not None]\n    if len(revenues) >= 2:\n        initial, final = revenues[-1], revenues[0]\n        if initial and final and final > initial:\n            growth_rate = (final - initial) / abs(initial)\n            if growth_rate > 0.5:  # e.g., 50% cumulative growth\n                score += 2\n                details.append(f\"Revenue grew by {(growth_rate*100):.1f}% over the full period (strong growth).\")\n            else:\n                score += 1\n                details.append(f\"Revenue growth is positive but under 50% cumulatively ({(growth_rate*100):.1f}%).\")\n        else:\n            details.append(\"Revenue did not grow significantly or data insufficient.\")\n    else:\n        details.append(\"Not enough revenue data for multi-period trend.\")\n    \n    # 2. Operating margin and free cash flow consistency\n    fcf_vals = [item.free_cash_flow for item in financial_line_items if item.free_cash_flow is not None]\n    op_margin_vals = [item.operating_margin for item in financial_line_items if item.operating_margin is not None]\n    \n    if op_margin_vals:\n        above_15 = sum(1 for m in op_margin_vals if m > 0.15)\n        if above_15 >= (len(op_margin_vals) // 2 + 1):\n            score += 2\n            details.append(\"Operating margins have often exceeded 15% (indicates good profitability).\")\n        else:\n            details.append(\"Operating margin not consistently above 15%.\")\n    else:\n        details.append(\"No operating margin data across periods.\")\n    \n    if fcf_vals:\n        positive_fcf_count = sum(1 for f in fcf_vals if f > 0)\n        if positive_fcf_count >= (len(fcf_vals) // 2 + 1):\n            score += 1\n            details.append(\"Majority of periods show positive free cash flow.\")\n        else:\n            details.append(\"Free cash flow not consistently positive.\")\n    else:\n        details.append(\"No free cash flow data across periods.\")\n    \n    # 3. Return on Equity (ROE) check from the latest metrics\n    latest_metrics = metrics[0]\n    if latest_metrics.return_on_equity and latest_metrics.return_on_equity > 0.15:\n        score += 2\n        details.append(f\"High ROE of {latest_metrics.return_on_equity:.1%}, indicating a competitive advantage.\")\n    elif latest_metrics.return_on_equity:\n        details.append(f\"ROE of {latest_metrics.return_on_equity:.1%} is moderate.\")\n    else:\n        details.append(\"ROE data not available.\")\n    \n    # 4. (Optional) Brand Intangible (if intangible_assets are fetched)\n    # intangible_vals = [item.intangible_assets for item in financial_line_items if item.intangible_assets]\n    # if intangible_vals and sum(intangible_vals) > 0:\n    #     details.append(\"Significant intangible assets may indicate brand value or proprietary tech.\")\n    #     score += 1\n    \n    return {\n        \"score\": score,\n        \"details\": \"; \".join(details)\n    }\n\n\ndef analyze_financial_discipline(metrics: list, financial_line_items: list) -> dict:\n    \"\"\"\n    Evaluate the company's balance sheet over multiple periods:\n    - Debt ratio trends\n    - Capital returns to shareholders over time (dividends, buybacks)\n    \"\"\"\n    score = 0\n    details = []\n    \n    if not metrics or not financial_line_items:\n        return {\n            \"score\": 0,\n            \"details\": \"Insufficient data to analyze financial discipline\"\n        }\n    \n    # 1. Multi-period debt ratio or debt_to_equity\n    debt_to_equity_vals = [item.debt_to_equity for item in financial_line_items if item.debt_to_equity is not None]\n    if debt_to_equity_vals:\n        below_one_count = sum(1 for d in debt_to_equity_vals if d < 1.0)\n        if below_one_count >= (len(debt_to_equity_vals) // 2 + 1):\n            score += 2\n            details.append(\"Debt-to-equity < 1.0 for the majority of periods (reasonable leverage).\")\n        else:\n            details.append(\"Debt-to-equity >= 1.0 in many periods (could be high leverage).\")\n    else:\n        # Fallback to total_liabilities / total_assets\n        liab_to_assets = []\n        for item in financial_line_items:\n            if item.total_liabilities and item.total_assets and item.total_assets > 0:\n                liab_to_assets.append(item.total_liabilities / item.total_assets)\n        \n        if liab_to_assets:\n            below_50pct_count = sum(1 for ratio in liab_to_assets if ratio < 0.5)\n            if below_50pct_count >= (len(liab_to_assets) // 2 + 1):\n                score += 2\n                details.append(\"Liabilities-to-assets < 50% for majority of periods.\")\n            else:\n                details.append(\"Liabilities-to-assets >= 50% in many periods.\")\n        else:\n            details.append(\"No consistent leverage ratio data available.\")\n    \n    # 2. Capital allocation approach (dividends + share counts)\n    dividends_list = [\n        item.dividends_and_other_cash_distributions\n        for item in financial_line_items\n        if item.dividends_and_other_cash_distributions is not None\n    ]\n    if dividends_list:\n        paying_dividends_count = sum(1 for d in dividends_list if d < 0)\n        if paying_dividends_count >= (len(dividends_list) // 2 + 1):\n            score += 1\n            details.append(\"Company has a history of returning capital to shareholders (dividends).\")\n        else:\n            details.append(\"Dividends not consistently paid or no data on distributions.\")\n    else:\n        details.append(\"No dividend data found across periods.\")\n    \n    # Check for decreasing share count (simple approach)\n    shares = [item.outstanding_shares for item in financial_line_items if item.outstanding_shares is not None]\n    if len(shares) >= 2:\n        # For buybacks, the newest count should be less than the oldest count\n        if shares[0] < shares[-1]:\n            score += 1\n            details.append(\"Outstanding shares have decreased over time (possible buybacks).\")\n        else:\n            details.append(\"Outstanding shares have not decreased over the available periods.\")\n    else:\n        details.append(\"No multi-period share count data to assess buybacks.\")\n    \n    return {\n        \"score\": score,\n        \"details\": \"; \".join(details)\n    }\n\n\ndef analyze_activism_potential(financial_line_items: list) -> dict:\n    \"\"\"\n    Bill Ackman often engages in activism if a company has a decent brand or moat\n    but is underperforming operationally.\n    \n    We'll do a simplified approach:\n    - Look for positive revenue trends but subpar margins\n    - That may indicate 'activism upside' if operational improvements could unlock value.\n    \"\"\"\n    if not financial_line_items:\n        return {\n            \"score\": 0,\n            \"details\": \"Insufficient data for activism potential\"\n        }\n    \n    # Check revenue growth vs. operating margin\n    revenues = [item.revenue for item in financial_line_items if item.revenue is not None]\n    op_margins = [item.operating_margin for item in financial_line_items if item.operating_margin is not None]\n    \n    if len(revenues) < 2 or not op_margins:\n        return {\n            \"score\": 0,\n            \"details\": \"Not enough data to assess activism potential (need multi-year revenue + margins).\"\n        }\n    \n    initial, final = revenues[-1], revenues[0]\n    revenue_growth = (final - initial) / abs(initial) if initial else 0\n    avg_margin = sum(op_margins) / len(op_margins)\n    \n    score = 0\n    details = []\n    \n    # Suppose if there's decent revenue growth but margins are below 10%, Ackman might see activism potential.\n    if revenue_growth > 0.15 and avg_margin < 0.10:\n        score += 2\n        details.append(\n            f\"Revenue growth is healthy (~{revenue_growth*100:.1f}%), but margins are low (avg {avg_margin*100:.1f}%). \"\n            \"Activism could unlock margin improvements.\"\n        )\n    else:\n        details.append(\"No clear sign of activism opportunity (either margins are already decent or growth is weak).\")\n    \n    return {\"score\": score, \"details\": \"; \".join(details)}\n\n\ndef analyze_valuation(financial_line_items: list, market_cap: float) -> dict:\n    \"\"\"\n    Ackman invests in companies trading at a discount to intrinsic value.\n    Uses a simplified DCF with FCF as a proxy, plus margin of safety analysis.\n    \"\"\"\n    if not financial_line_items or market_cap is None:\n        return {\n            \"score\": 0,\n            \"details\": \"Insufficient data to perform valuation\"\n        }\n    \n    # Since financial_line_items are in descending order (newest first),\n    # the most recent period is the first element\n    latest = financial_line_items[0]\n    fcf = latest.free_cash_flow if latest.free_cash_flow else 0\n    \n    if fcf <= 0:\n        return {\n            \"score\": 0,\n            \"details\": f\"No positive FCF for valuation; FCF = {fcf}\",\n            \"intrinsic_value\": None\n        }\n    \n    # Basic DCF assumptions\n    growth_rate = 0.06\n    discount_rate = 0.10\n    terminal_multiple = 15\n    projection_years = 5\n    \n    present_value = 0\n    for year in range(1, projection_years + 1):\n        future_fcf = fcf * (1 + growth_rate) ** year\n        pv = future_fcf / ((1 + discount_rate) ** year)\n        present_value += pv\n    \n    # Terminal Value\n    terminal_value = (\n        fcf * (1 + growth_rate) ** projection_years * terminal_multiple\n    ) / ((1 + discount_rate) ** projection_years)\n    \n    intrinsic_value = present_value + terminal_value\n    margin_of_safety = (intrinsic_value - market_cap) / market_cap\n    \n    score = 0\n    # Simple scoring\n    if margin_of_safety > 0.3:\n        score += 3\n    elif margin_of_safety > 0.1:\n        score += 1\n    \n    details = [\n        f\"Calculated intrinsic value: ~{intrinsic_value:,.2f}\",\n        f\"Market cap: ~{market_cap:,.2f}\",\n        f\"Margin of safety: {margin_of_safety:.2%}\"\n    ]\n    \n    return {\n        \"score\": score,\n        \"details\": \"; \".join(details),\n        \"intrinsic_value\": intrinsic_value,\n        \"margin_of_safety\": margin_of_safety\n    }\n\n\ndef generate_ackman_output(\n    ticker: str,\n    analysis_data: dict[str, any],\n    state: AgentState,\n    agent_id: str,\n) -> BillAckmanSignal:\n    \"\"\"\n    Generates investment decisions in the style of Bill Ackman.\n    Includes more explicit references to brand strength, activism potential, \n    catalysts, and management changes in the system prompt.\n    \"\"\"\n    template = ChatPromptTemplate.from_messages([\n        (\n            \"system\",\n            \"\"\"You are a Bill Ackman AI agent, making investment decisions using his principles:\n\n            1. Seek high-quality businesses with durable competitive advantages (moats), often in well-known consumer or service brands.\n            2. Prioritize consistent free cash flow and growth potential over the long term.\n            3. Advocate for strong financial discipline (reasonable leverage, efficient capital allocation).\n            4. Valuation matters: target intrinsic value with a margin of safety.\n            5. Consider activism where management or operational improvements can unlock substantial upside.\n            6. Concentrate on a few high-conviction investments.\n\n            In your reasoning:\n            - Emphasize brand strength, moat, or unique market positioning.\n            - Review free cash flow generation and margin trends as key signals.\n            - Analyze leverage, share buybacks, and dividends as capital discipline metrics.\n            - Provide a valuation assessment with numerical backup (DCF, multiples, etc.).\n            - Identify any catalysts for activism or value creation (e.g., cost cuts, better capital allocation).\n            - Use a confident, analytic, and sometimes confrontational tone when discussing weaknesses or opportunities.\n\n            Return your final recommendation (signal: bullish, neutral, or bearish) with a 0-100 confidence and a thorough reasoning section.\n            \"\"\"\n        ),\n        (\n            \"human\",\n            \"\"\"Based on the following analysis, create an Ackman-style investment signal.\n\n            Analysis Data for {ticker}:\n            {analysis_data}\n\n            Return your output in strictly valid JSON:\n            {{\n              \"signal\": \"bullish\" | \"bearish\" | \"neutral\",\n              \"confidence\": float (0-100),\n              \"reasoning\": \"string\"\n            }}\n            \"\"\"\n        )\n    ])\n\n    prompt = template.invoke({\n        \"analysis_data\": json.dumps(analysis_data, indent=2),\n        \"ticker\": ticker\n    })\n\n    def create_default_bill_ackman_signal():\n        return BillAckmanSignal(\n            signal=\"neutral\",\n            confidence=0.0,\n            reasoning=\"Error in analysis, defaulting to neutral\"\n        )\n\n    return call_llm(\n        prompt=prompt, \n        pydantic_model=BillAckmanSignal, \n        agent_name=agent_id, \n        state=state,\n        default_factory=create_default_bill_ackman_signal,\n    )\n"
  },
  {
    "path": "src/agents/cathie_wood.py",
    "content": "from src.graph.state import AgentState, show_agent_reasoning\nfrom src.tools.api import get_financial_metrics, get_market_cap, search_line_items\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_core.messages import HumanMessage\nfrom pydantic import BaseModel\nimport json\nfrom typing_extensions import Literal\nfrom src.utils.progress import progress\nfrom src.utils.llm import call_llm\nfrom src.utils.api_key import get_api_key_from_state\n\n\nclass CathieWoodSignal(BaseModel):\n    signal: Literal[\"bullish\", \"bearish\", \"neutral\"]\n    confidence: float\n    reasoning: str\n\n\ndef cathie_wood_agent(state: AgentState, agent_id: str = \"cathie_wood_agent\"):\n    \"\"\"\n    Analyzes stocks using Cathie Wood's investing principles and LLM reasoning.\n    1. Prioritizes companies with breakthrough technologies or business models\n    2. Focuses on industries with rapid adoption curves and massive TAM (Total Addressable Market).\n    3. Invests mostly in AI, robotics, genomic sequencing, fintech, and blockchain.\n    4. Willing to endure short-term volatility for long-term gains.\n    \"\"\"\n    data = state[\"data\"]\n    end_date = data[\"end_date\"]\n    tickers = data[\"tickers\"]\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    analysis_data = {}\n    cw_analysis = {}\n\n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Fetching financial metrics\")\n        metrics = get_financial_metrics(ticker, end_date, period=\"annual\", limit=5, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Gathering financial line items\")\n        # Request multiple periods of data (annual or TTM) for a more robust view.\n        financial_line_items = search_line_items(\n            ticker,\n            [\n                \"revenue\",\n                \"gross_margin\",\n                \"operating_margin\",\n                \"debt_to_equity\",\n                \"free_cash_flow\",\n                \"total_assets\",\n                \"total_liabilities\",\n                \"dividends_and_other_cash_distributions\",\n                \"outstanding_shares\",\n                \"research_and_development\",\n                \"capital_expenditure\",\n                \"operating_expense\",\n            ],\n            end_date,\n            period=\"annual\",\n            limit=5,\n            api_key=api_key,\n        )\n\n        progress.update_status(agent_id, ticker, \"Getting market cap\")\n        market_cap = get_market_cap(ticker, end_date, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Analyzing disruptive potential\")\n        disruptive_analysis = analyze_disruptive_potential(metrics, financial_line_items)\n\n        progress.update_status(agent_id, ticker, \"Analyzing innovation-driven growth\")\n        innovation_analysis = analyze_innovation_growth(metrics, financial_line_items)\n\n        progress.update_status(agent_id, ticker, \"Calculating valuation & high-growth scenario\")\n        valuation_analysis = analyze_cathie_wood_valuation(financial_line_items, market_cap)\n\n        # Combine partial scores or signals\n        total_score = disruptive_analysis[\"score\"] + innovation_analysis[\"score\"] + valuation_analysis[\"score\"]\n        max_possible_score = 15  # Adjust weighting as desired\n\n        if total_score >= 0.7 * max_possible_score:\n            signal = \"bullish\"\n        elif total_score <= 0.3 * max_possible_score:\n            signal = \"bearish\"\n        else:\n            signal = \"neutral\"\n\n        analysis_data[ticker] = {\"signal\": signal, \"score\": total_score, \"max_score\": max_possible_score, \"disruptive_analysis\": disruptive_analysis, \"innovation_analysis\": innovation_analysis, \"valuation_analysis\": valuation_analysis}\n\n        progress.update_status(agent_id, ticker, \"Generating Cathie Wood analysis\")\n        cw_output = generate_cathie_wood_output(\n            ticker=ticker,\n            analysis_data=analysis_data,\n            state=state,\n            agent_id=agent_id,\n        )\n\n        cw_analysis[ticker] = {\"signal\": cw_output.signal, \"confidence\": cw_output.confidence, \"reasoning\": cw_output.reasoning}\n\n        progress.update_status(agent_id, ticker, \"Done\", analysis=cw_output.reasoning)\n\n    message = HumanMessage(content=json.dumps(cw_analysis), name=agent_id)\n\n    if state[\"metadata\"].get(\"show_reasoning\"):\n        show_agent_reasoning(cw_analysis, agent_id)\n\n    state[\"data\"][\"analyst_signals\"][agent_id] = cw_analysis\n\n    progress.update_status(agent_id, None, \"Done\")\n\n    return {\"messages\": [message], \"data\": state[\"data\"]}\n\n\ndef analyze_disruptive_potential(metrics: list, financial_line_items: list) -> dict:\n    \"\"\"\n    Analyze whether the company has disruptive products, technology, or business model.\n    Evaluates multiple dimensions of disruptive potential:\n    1. Revenue Growth Acceleration - indicates market adoption\n    2. R&D Intensity - shows innovation investment\n    3. Gross Margin Trends - suggests pricing power and scalability\n    4. Operating Leverage - demonstrates business model efficiency\n    5. Market Share Dynamics - indicates competitive position\n    \"\"\"\n    score = 0\n    details = []\n\n    if not metrics or not financial_line_items:\n        return {\"score\": 0, \"details\": \"Insufficient data to analyze disruptive potential\"}\n\n    # 1. Revenue Growth Analysis - Check for accelerating growth\n    revenues = [item.revenue for item in financial_line_items if item.revenue]\n    if len(revenues) >= 3:  # Need at least 3 periods to check acceleration\n        growth_rates = []\n        for i in range(len(revenues) - 1):\n            if revenues[i] and revenues[i + 1]:\n                growth_rate = (revenues[i] - revenues[i + 1]) / abs(revenues[i + 1]) if revenues[i + 1] != 0 else 0\n                growth_rates.append(growth_rate)\n\n        # Check if growth is accelerating (first growth rate higher than last, since they're in reverse order)\n        if len(growth_rates) >= 2 and growth_rates[0] > growth_rates[-1]:\n            score += 2\n            details.append(f\"Revenue growth is accelerating: {(growth_rates[0]*100):.1f}% vs {(growth_rates[-1]*100):.1f}%\")\n\n        # Check absolute growth rate (most recent growth rate is at index 0)\n        latest_growth = growth_rates[0] if growth_rates else 0\n        if latest_growth > 1.0:\n            score += 3\n            details.append(f\"Exceptional revenue growth: {(latest_growth*100):.1f}%\")\n        elif latest_growth > 0.5:\n            score += 2\n            details.append(f\"Strong revenue growth: {(latest_growth*100):.1f}%\")\n        elif latest_growth > 0.2:\n            score += 1\n            details.append(f\"Moderate revenue growth: {(latest_growth*100):.1f}%\")\n    else:\n        details.append(\"Insufficient revenue data for growth analysis\")\n\n    # 2. Gross Margin Analysis - Check for expanding margins\n    gross_margins = [item.gross_margin for item in financial_line_items if hasattr(item, \"gross_margin\") and item.gross_margin is not None]\n    if len(gross_margins) >= 2:\n        margin_trend = gross_margins[0] - gross_margins[-1]\n        if margin_trend > 0.05:  # 5% improvement\n            score += 2\n            details.append(f\"Expanding gross margins: +{(margin_trend*100):.1f}%\")\n        elif margin_trend > 0:\n            score += 1\n            details.append(f\"Slightly improving gross margins: +{(margin_trend*100):.1f}%\")\n\n        # Check absolute margin level (most recent margin is at index 0)\n        if gross_margins[0] > 0.50:  # High margin business\n            score += 2\n            details.append(f\"High gross margin: {(gross_margins[0]*100):.1f}%\")\n    else:\n        details.append(\"Insufficient gross margin data\")\n\n    # 3. Operating Leverage Analysis\n    revenues = [item.revenue for item in financial_line_items if item.revenue]\n    operating_expenses = [item.operating_expense for item in financial_line_items if hasattr(item, \"operating_expense\") and item.operating_expense]\n\n    if len(revenues) >= 2 and len(operating_expenses) >= 2:\n        rev_growth = (revenues[0] - revenues[-1]) / abs(revenues[-1])\n        opex_growth = (operating_expenses[0] - operating_expenses[-1]) / abs(operating_expenses[-1])\n\n        if rev_growth > opex_growth:\n            score += 2\n            details.append(\"Positive operating leverage: Revenue growing faster than expenses\")\n    else:\n        details.append(\"Insufficient data for operating leverage analysis\")\n\n    # 4. R&D Investment Analysis\n    rd_expenses = [item.research_and_development for item in financial_line_items if hasattr(item, \"research_and_development\") and item.research_and_development is not None]\n    if rd_expenses and revenues:\n        rd_intensity = rd_expenses[0] / revenues[0]\n        if rd_intensity > 0.15:  # High R&D intensity\n            score += 3\n            details.append(f\"High R&D investment: {(rd_intensity*100):.1f}% of revenue\")\n        elif rd_intensity > 0.08:\n            score += 2\n            details.append(f\"Moderate R&D investment: {(rd_intensity*100):.1f}% of revenue\")\n        elif rd_intensity > 0.05:\n            score += 1\n            details.append(f\"Some R&D investment: {(rd_intensity*100):.1f}% of revenue\")\n    else:\n        details.append(\"No R&D data available\")\n\n    # Normalize score to be out of 5\n    max_possible_score = 12  # Sum of all possible points\n    normalized_score = (score / max_possible_score) * 5\n\n    return {\"score\": normalized_score, \"details\": \"; \".join(details), \"raw_score\": score, \"max_score\": max_possible_score}\n\n\ndef analyze_innovation_growth(metrics: list, financial_line_items: list) -> dict:\n    \"\"\"\n    Evaluate the company's commitment to innovation and potential for exponential growth.\n    Analyzes multiple dimensions:\n    1. R&D Investment Trends - measures commitment to innovation\n    2. Free Cash Flow Generation - indicates ability to fund innovation\n    3. Operating Efficiency - shows scalability of innovation\n    4. Capital Allocation - reveals innovation-focused management\n    5. Growth Reinvestment - demonstrates commitment to future growth\n    \"\"\"\n    score = 0\n    details = []\n\n    if not metrics or not financial_line_items:\n        return {\"score\": 0, \"details\": \"Insufficient data to analyze innovation-driven growth\"}\n\n    # 1. R&D Investment Trends\n    rd_expenses = [item.research_and_development for item in financial_line_items if hasattr(item, \"research_and_development\") and item.research_and_development]\n    revenues = [item.revenue for item in financial_line_items if item.revenue]\n\n    if rd_expenses and revenues and len(rd_expenses) >= 2:\n        rd_growth = (rd_expenses[0] - rd_expenses[-1]) / abs(rd_expenses[-1]) if rd_expenses[-1] != 0 else 0\n        if rd_growth > 0.5:  # 50% growth in R&D\n            score += 3\n            details.append(f\"Strong R&D investment growth: +{(rd_growth*100):.1f}%\")\n        elif rd_growth > 0.2:\n            score += 2\n            details.append(f\"Moderate R&D investment growth: +{(rd_growth*100):.1f}%\")\n\n        # Check R&D intensity trend (corrected for reverse chronological order)\n        rd_intensity_start = rd_expenses[-1] / revenues[-1]\n        rd_intensity_end = rd_expenses[0] / revenues[0]\n        if rd_intensity_end > rd_intensity_start:\n            score += 2\n            details.append(f\"Increasing R&D intensity: {(rd_intensity_end*100):.1f}% vs {(rd_intensity_start*100):.1f}%\")\n    else:\n        details.append(\"Insufficient R&D data for trend analysis\")\n\n    # 2. Free Cash Flow Analysis\n    fcf_vals = [item.free_cash_flow for item in financial_line_items if item.free_cash_flow]\n    if fcf_vals and len(fcf_vals) >= 2:\n        fcf_growth = (fcf_vals[0] - fcf_vals[-1]) / abs(fcf_vals[-1])\n        positive_fcf_count = sum(1 for f in fcf_vals if f > 0)\n\n        if fcf_growth > 0.3 and positive_fcf_count == len(fcf_vals):\n            score += 3\n            details.append(\"Strong and consistent FCF growth, excellent innovation funding capacity\")\n        elif positive_fcf_count >= len(fcf_vals) * 0.75:\n            score += 2\n            details.append(\"Consistent positive FCF, good innovation funding capacity\")\n        elif positive_fcf_count > len(fcf_vals) * 0.5:\n            score += 1\n            details.append(\"Moderately consistent FCF, adequate innovation funding capacity\")\n    else:\n        details.append(\"Insufficient FCF data for analysis\")\n\n    # 3. Operating Efficiency Analysis\n    op_margin_vals = [item.operating_margin for item in financial_line_items if item.operating_margin]\n    if op_margin_vals and len(op_margin_vals) >= 2:\n        margin_trend = op_margin_vals[0] - op_margin_vals[-1]\n\n        if op_margin_vals[0] > 0.15 and margin_trend > 0:\n            score += 3\n            details.append(f\"Strong and improving operating margin: {(op_margin_vals[0]*100):.1f}%\")\n        elif op_margin_vals[0] > 0.10:\n            score += 2\n            details.append(f\"Healthy operating margin: {(op_margin_vals[0]*100):.1f}%\")\n        elif margin_trend > 0:\n            score += 1\n            details.append(\"Improving operating efficiency\")\n    else:\n        details.append(\"Insufficient operating margin data\")\n\n    # 4. Capital Allocation Analysis\n    capex = [item.capital_expenditure for item in financial_line_items if hasattr(item, \"capital_expenditure\") and item.capital_expenditure]\n    if capex and revenues and len(capex) >= 2:\n        capex_intensity = abs(capex[0]) / revenues[0]\n        capex_growth = (abs(capex[0]) - abs(capex[-1])) / abs(capex[-1]) if capex[-1] != 0 else 0\n\n        if capex_intensity > 0.10 and capex_growth > 0.2:\n            score += 2\n            details.append(\"Strong investment in growth infrastructure\")\n        elif capex_intensity > 0.05:\n            score += 1\n            details.append(\"Moderate investment in growth infrastructure\")\n    else:\n        details.append(\"Insufficient CAPEX data\")\n\n    # 5. Growth Reinvestment Analysis\n    dividends = [item.dividends_and_other_cash_distributions for item in financial_line_items if hasattr(item, \"dividends_and_other_cash_distributions\") and item.dividends_and_other_cash_distributions]\n    if dividends and fcf_vals:\n        latest_payout_ratio = dividends[0] / fcf_vals[0] if fcf_vals[0] != 0 else 1\n        if latest_payout_ratio < 0.2:  # Low dividend payout ratio suggests reinvestment focus\n            score += 2\n            details.append(\"Strong focus on reinvestment over dividends\")\n        elif latest_payout_ratio < 0.4:\n            score += 1\n            details.append(\"Moderate focus on reinvestment over dividends\")\n    else:\n        details.append(\"Insufficient dividend data\")\n\n    # Normalize score to be out of 5\n    max_possible_score = 15  # Sum of all possible points\n    normalized_score = (score / max_possible_score) * 5\n\n    return {\"score\": normalized_score, \"details\": \"; \".join(details), \"raw_score\": score, \"max_score\": max_possible_score}\n\n\ndef analyze_cathie_wood_valuation(financial_line_items: list, market_cap: float) -> dict:\n    \"\"\"\n    Cathie Wood often focuses on long-term exponential growth potential. We can do\n    a simplified approach looking for a large total addressable market (TAM) and the\n    company's ability to capture a sizable portion.\n    \"\"\"\n    if not financial_line_items or market_cap is None:\n        return {\"score\": 0, \"details\": \"Insufficient data for valuation\"}\n\n    latest = financial_line_items[0]\n    fcf = latest.free_cash_flow if latest.free_cash_flow else 0\n\n    if fcf <= 0:\n        return {\"score\": 0, \"details\": f\"No positive FCF for valuation; FCF = {fcf}\", \"intrinsic_value\": None}\n\n    # Instead of a standard DCF, let's assume a higher growth rate for an innovative company.\n    # Example values:\n    growth_rate = 0.20  # 20% annual growth\n    discount_rate = 0.15\n    terminal_multiple = 25\n    projection_years = 5\n\n    present_value = 0\n    for year in range(1, projection_years + 1):\n        future_fcf = fcf * (1 + growth_rate) ** year\n        pv = future_fcf / ((1 + discount_rate) ** year)\n        present_value += pv\n\n    # Terminal Value\n    terminal_value = (fcf * (1 + growth_rate) ** projection_years * terminal_multiple) / ((1 + discount_rate) ** projection_years)\n    intrinsic_value = present_value + terminal_value\n\n    margin_of_safety = (intrinsic_value - market_cap) / market_cap\n\n    score = 0\n    if margin_of_safety > 0.5:\n        score += 3\n    elif margin_of_safety > 0.2:\n        score += 1\n\n    details = [f\"Calculated intrinsic value: ~{intrinsic_value:,.2f}\", f\"Market cap: ~{market_cap:,.2f}\", f\"Margin of safety: {margin_of_safety:.2%}\"]\n\n    return {\"score\": score, \"details\": \"; \".join(details), \"intrinsic_value\": intrinsic_value, \"margin_of_safety\": margin_of_safety}\n\n\ndef generate_cathie_wood_output(\n    ticker: str,\n    analysis_data: dict[str, any],\n    state: AgentState,\n    agent_id: str = \"cathie_wood_agent\",\n) -> CathieWoodSignal:\n    \"\"\"\n    Generates investment decisions in the style of Cathie Wood.\n    \"\"\"\n    template = ChatPromptTemplate.from_messages(\n        [\n            (\n                \"system\",\n                \"\"\"You are a Cathie Wood AI agent, making investment decisions using her principles:\n\n            1. Seek companies leveraging disruptive innovation.\n            2. Emphasize exponential growth potential, large TAM.\n            3. Focus on technology, healthcare, or other future-facing sectors.\n            4. Consider multi-year time horizons for potential breakthroughs.\n            5. Accept higher volatility in pursuit of high returns.\n            6. Evaluate management's vision and ability to invest in R&D.\n\n            Rules:\n            - Identify disruptive or breakthrough technology.\n            - Evaluate strong potential for multi-year revenue growth.\n            - Check if the company can scale effectively in a large market.\n            - Use a growth-biased valuation approach.\n            - Provide a data-driven recommendation (bullish, bearish, or neutral).\n            \n            When providing your reasoning, be thorough and specific by:\n            1. Identifying the specific disruptive technologies/innovations the company is leveraging\n            2. Highlighting growth metrics that indicate exponential potential (revenue acceleration, expanding TAM)\n            3. Discussing the long-term vision and transformative potential over 5+ year horizons\n            4. Explaining how the company might disrupt traditional industries or create new markets\n            5. Addressing R&D investment and innovation pipeline that could drive future growth\n            6. Using Cathie Wood's optimistic, future-focused, and conviction-driven voice\n            \n            For example, if bullish: \"The company's AI-driven platform is transforming the $500B healthcare analytics market, with evidence of platform adoption accelerating from 40% to 65% YoY. Their R&D investments of 22% of revenue are creating a technological moat that positions them to capture a significant share of this expanding market. The current valuation doesn't reflect the exponential growth trajectory we expect as...\"\n            For example, if bearish: \"While operating in the genomics space, the company lacks truly disruptive technology and is merely incrementally improving existing techniques. R&D spending at only 8% of revenue signals insufficient investment in breakthrough innovation. With revenue growth slowing from 45% to 20% YoY, there's limited evidence of the exponential adoption curve we look for in transformative companies...\"\n            \"\"\",\n            ),\n            (\n                \"human\",\n                \"\"\"Based on the following analysis, create a Cathie Wood-style investment signal.\n\n            Analysis Data for {ticker}:\n            {analysis_data}\n\n            Return the trading signal in this JSON format:\n            {{\n              \"signal\": \"bullish/bearish/neutral\",\n              \"confidence\": float (0-100),\n              \"reasoning\": \"string\"\n            }}\n            \"\"\",\n            ),\n        ]\n    )\n\n    prompt = template.invoke({\"analysis_data\": json.dumps(analysis_data, indent=2), \"ticker\": ticker})\n\n    def create_default_cathie_wood_signal():\n        return CathieWoodSignal(signal=\"neutral\", confidence=0.0, reasoning=\"Error in analysis, defaulting to neutral\")\n\n    return call_llm(\n        prompt=prompt,\n        pydantic_model=CathieWoodSignal,\n        agent_name=agent_id,\n        state=state,\n        default_factory=create_default_cathie_wood_signal,\n    )\n\n\n# source: https://ark-invest.com\n"
  },
  {
    "path": "src/agents/charlie_munger.py",
    "content": "from src.graph.state import AgentState, show_agent_reasoning\nfrom src.tools.api import get_financial_metrics, get_market_cap, search_line_items, get_insider_trades, get_company_news\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_core.messages import HumanMessage\nfrom pydantic import BaseModel\nimport json\nfrom typing_extensions import Literal\nfrom src.utils.progress import progress\nfrom src.utils.llm import call_llm\nfrom src.utils.api_key import get_api_key_from_state\n\nclass CharlieMungerSignal(BaseModel):\n    signal: Literal[\"bullish\", \"bearish\", \"neutral\"]\n    confidence: int\n    reasoning: str\n\n\ndef charlie_munger_agent(state: AgentState, agent_id: str = \"charlie_munger_agent\"):\n    \"\"\"\n    Analyzes stocks using Charlie Munger's investing principles and mental models.\n    Focuses on moat strength, management quality, predictability, and valuation.\n    \"\"\"\n    data = state[\"data\"]\n    end_date = data[\"end_date\"]\n    tickers = data[\"tickers\"]\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    analysis_data = {}\n    munger_analysis = {}\n    \n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Fetching financial metrics\")\n        metrics = get_financial_metrics(ticker, end_date, period=\"annual\", limit=10, api_key=api_key)  # Munger looks at longer periods\n        \n        progress.update_status(agent_id, ticker, \"Gathering financial line items\")\n        financial_line_items = search_line_items(\n            ticker,\n            [\n                \"revenue\",\n                \"net_income\",\n                \"operating_income\",\n                \"return_on_invested_capital\",\n                \"gross_margin\",\n                \"operating_margin\",\n                \"free_cash_flow\",\n                \"capital_expenditure\",\n                \"cash_and_equivalents\",\n                \"total_debt\",\n                \"shareholders_equity\",\n                \"outstanding_shares\",\n                \"research_and_development\",\n                \"goodwill_and_intangible_assets\",\n            ],\n            end_date,\n            period=\"annual\",\n            limit=10,  # Munger examines long-term trends\n            api_key=api_key,\n        )\n        \n        progress.update_status(agent_id, ticker, \"Getting market cap\")\n        market_cap = get_market_cap(ticker, end_date, api_key=api_key)\n        \n        progress.update_status(agent_id, ticker, \"Fetching insider trades\")\n        # Munger values management with skin in the game\n        insider_trades = get_insider_trades(\n            ticker,\n            end_date,\n            limit=100,\n            api_key=api_key,\n        )\n        \n        progress.update_status(agent_id, ticker, \"Fetching company news\")\n        # Munger avoids businesses with frequent negative press\n        company_news = get_company_news(\n            ticker,\n            end_date,\n            limit=10,\n            api_key=api_key,\n        )\n        \n        progress.update_status(agent_id, ticker, \"Analyzing moat strength\")\n        moat_analysis = analyze_moat_strength(metrics, financial_line_items)\n        \n        progress.update_status(agent_id, ticker, \"Analyzing management quality\")\n        management_analysis = analyze_management_quality(financial_line_items, insider_trades)\n        \n        progress.update_status(agent_id, ticker, \"Analyzing business predictability\")\n        predictability_analysis = analyze_predictability(financial_line_items)\n        \n        progress.update_status(agent_id, ticker, \"Calculating Munger-style valuation\")\n        valuation_analysis = calculate_munger_valuation(financial_line_items, market_cap)\n        \n        # Combine partial scores with Munger's weighting preferences\n        # Munger weights quality and predictability higher than current valuation\n        total_score = (\n            moat_analysis[\"score\"] * 0.35 +\n            management_analysis[\"score\"] * 0.25 +\n            predictability_analysis[\"score\"] * 0.25 +\n            valuation_analysis[\"score\"] * 0.15\n        )\n        \n        max_possible_score = 10  # Scale to 0-10\n                \n        # Generate a simple buy/hold/sell signal\n        if total_score >= 7.5:  # Munger has very high standards\n            signal = \"bullish\"\n        elif total_score <= 5.5:\n            signal = \"bearish\"\n        else:\n            signal = \"neutral\"\n        \n        analysis_data[ticker] = {\n            \"signal\": signal,\n            \"score\": total_score,\n            \"max_score\": max_possible_score,\n            \"moat_analysis\": moat_analysis,\n            \"management_analysis\": management_analysis,\n            \"predictability_analysis\": predictability_analysis,\n            \"valuation_analysis\": valuation_analysis,\n            # Include some qualitative assessment from news\n            \"news_sentiment\": analyze_news_sentiment(company_news) if company_news else \"No news data available\"\n        }\n        \n        progress.update_status(agent_id, ticker, \"Generating Charlie Munger analysis\")\n        munger_output = generate_munger_output(\n            ticker=ticker, \n            analysis_data=analysis_data[ticker],\n            state=state,\n            agent_id=agent_id,\n            confidence_hint=compute_confidence(analysis_data[ticker], signal)\n        )\n        \n        munger_analysis[ticker] = {\n            \"signal\": munger_output.signal,\n            \"confidence\": munger_output.confidence,\n            \"reasoning\": munger_output.reasoning\n        }\n        \n        progress.update_status(agent_id, ticker, \"Done\", analysis=munger_output.reasoning)\n    \n    # Wrap results in a single message for the chain\n    message = HumanMessage(\n        content=json.dumps(munger_analysis),\n        name=agent_id\n    )\n    \n    # Show reasoning if requested\n    if state[\"metadata\"][\"show_reasoning\"]:\n        show_agent_reasoning(munger_analysis, \"Charlie Munger Agent\")\n\n    progress.update_status(agent_id, None, \"Done\")\n    \n    # Add signals to the overall state\n    state[\"data\"][\"analyst_signals\"][agent_id] = munger_analysis\n\n    return {\n        \"messages\": [message],\n        \"data\": state[\"data\"]\n    }\n\n\ndef analyze_moat_strength(metrics: list, financial_line_items: list) -> dict:\n    \"\"\"\n    Analyze the business's competitive advantage using Munger's approach:\n    - Consistent high returns on capital (ROIC)\n    - Pricing power (stable/improving gross margins)\n    - Low capital requirements\n    - Network effects and intangible assets (R&D investments, goodwill)\n    \"\"\"\n    score = 0\n    details = []\n    \n    if not metrics or not financial_line_items:\n        return {\n            \"score\": 0,\n            \"details\": \"Insufficient data to analyze moat strength\"\n        }\n    \n    # 1. Return on Invested Capital (ROIC) analysis - Munger's favorite metric\n    roic_values = [item.return_on_invested_capital for item in financial_line_items \n                   if hasattr(item, 'return_on_invested_capital') and item.return_on_invested_capital is not None]\n    \n    if roic_values:\n        # Check if ROIC consistently above 15% (Munger's threshold)\n        high_roic_count = sum(1 for r in roic_values if r > 0.15)\n        if high_roic_count >= len(roic_values) * 0.8:  # 80% of periods show high ROIC\n            score += 3\n            details.append(f\"Excellent ROIC: >15% in {high_roic_count}/{len(roic_values)} periods\")\n        elif high_roic_count >= len(roic_values) * 0.5:  # 50% of periods\n            score += 2\n            details.append(f\"Good ROIC: >15% in {high_roic_count}/{len(roic_values)} periods\")\n        elif high_roic_count > 0:\n            score += 1\n            details.append(f\"Mixed ROIC: >15% in only {high_roic_count}/{len(roic_values)} periods\")\n        else:\n            details.append(\"Poor ROIC: Never exceeds 15% threshold\")\n    else:\n        details.append(\"No ROIC data available\")\n    \n    # 2. Pricing power - check gross margin stability and trends\n    gross_margins = [item.gross_margin for item in financial_line_items \n                    if hasattr(item, 'gross_margin') and item.gross_margin is not None]\n    \n    if gross_margins and len(gross_margins) >= 3:\n        # Munger likes stable or improving gross margins\n        margin_trend = sum(1 for i in range(1, len(gross_margins)) if gross_margins[i] >= gross_margins[i-1])\n        if margin_trend >= len(gross_margins) * 0.7:  # Improving in 70% of periods\n            score += 2\n            details.append(\"Strong pricing power: Gross margins consistently improving\")\n        elif sum(gross_margins) / len(gross_margins) > 0.3:  # Average margin > 30%\n            score += 1\n            details.append(f\"Good pricing power: Average gross margin {sum(gross_margins)/len(gross_margins):.1%}\")\n        else:\n            details.append(\"Limited pricing power: Low or declining gross margins\")\n    else:\n        details.append(\"Insufficient gross margin data\")\n    \n    # 3. Capital intensity - Munger prefers low capex businesses\n    if len(financial_line_items) >= 3:\n        capex_to_revenue = []\n        for item in financial_line_items:\n            if (hasattr(item, 'capital_expenditure') and item.capital_expenditure is not None and \n                hasattr(item, 'revenue') and item.revenue is not None and item.revenue > 0):\n                # Note: capital_expenditure is typically negative in financial statements\n                capex_ratio = abs(item.capital_expenditure) / item.revenue\n                capex_to_revenue.append(capex_ratio)\n        \n        if capex_to_revenue:\n            avg_capex_ratio = sum(capex_to_revenue) / len(capex_to_revenue)\n            if avg_capex_ratio < 0.05:  # Less than 5% of revenue\n                score += 2\n                details.append(f\"Low capital requirements: Avg capex {avg_capex_ratio:.1%} of revenue\")\n            elif avg_capex_ratio < 0.10:  # Less than 10% of revenue\n                score += 1\n                details.append(f\"Moderate capital requirements: Avg capex {avg_capex_ratio:.1%} of revenue\")\n            else:\n                details.append(f\"High capital requirements: Avg capex {avg_capex_ratio:.1%} of revenue\")\n        else:\n            details.append(\"No capital expenditure data available\")\n    else:\n        details.append(\"Insufficient data for capital intensity analysis\")\n    \n    # 4. Intangible assets - Munger values R&D and intellectual property\n    r_and_d = [item.research_and_development for item in financial_line_items\n              if hasattr(item, 'research_and_development') and item.research_and_development is not None]\n    \n    goodwill_and_intangible_assets = [item.goodwill_and_intangible_assets for item in financial_line_items\n               if hasattr(item, 'goodwill_and_intangible_assets') and item.goodwill_and_intangible_assets is not None]\n\n    if r_and_d and len(r_and_d) > 0:\n        if sum(r_and_d) > 0:  # If company is investing in R&D\n            score += 1\n            details.append(\"Invests in R&D, building intellectual property\")\n    \n    if (goodwill_and_intangible_assets and len(goodwill_and_intangible_assets) > 0):\n        score += 1\n        details.append(\"Significant goodwill/intangible assets, suggesting brand value or IP\")\n    \n    # Scale score to 0-10 range\n    final_score = min(10, score * 10 / 9)  # Max possible raw score is 9\n    \n    return {\n        \"score\": final_score,\n        \"details\": \"; \".join(details)\n        \n    }\n\n\ndef analyze_management_quality(financial_line_items: list, insider_trades: list) -> dict:\n    \"\"\"\n    Evaluate management quality using Munger's criteria:\n    - Capital allocation wisdom\n    - Insider ownership and transactions\n    - Cash management efficiency\n    - Candor and transparency\n    - Long-term focus\n    \"\"\"\n    score = 0\n    details = []\n    \n    if not financial_line_items:\n        return {\n            \"score\": 0,\n            \"details\": \"Insufficient data to analyze management quality\"\n        }\n    \n    # 1. Capital allocation - Check FCF to net income ratio\n    # Munger values companies that convert earnings to cash\n    fcf_values = [item.free_cash_flow for item in financial_line_items \n                 if hasattr(item, 'free_cash_flow') and item.free_cash_flow is not None]\n    \n    net_income_values = [item.net_income for item in financial_line_items \n                        if hasattr(item, 'net_income') and item.net_income is not None]\n    \n    if fcf_values and net_income_values and len(fcf_values) == len(net_income_values):\n        # Calculate FCF to Net Income ratio for each period\n        fcf_to_ni_ratios = []\n        for i in range(len(fcf_values)):\n            if net_income_values[i] and net_income_values[i] > 0:\n                fcf_to_ni_ratios.append(fcf_values[i] / net_income_values[i])\n        \n        if fcf_to_ni_ratios:\n            avg_ratio = sum(fcf_to_ni_ratios) / len(fcf_to_ni_ratios)\n            if avg_ratio > 1.1:  # FCF > net income suggests good accounting\n                score += 3\n                details.append(f\"Excellent cash conversion: FCF/NI ratio of {avg_ratio:.2f}\")\n            elif avg_ratio > 0.9:  # FCF roughly equals net income\n                score += 2\n                details.append(f\"Good cash conversion: FCF/NI ratio of {avg_ratio:.2f}\")\n            elif avg_ratio > 0.7:  # FCF somewhat lower than net income\n                score += 1\n                details.append(f\"Moderate cash conversion: FCF/NI ratio of {avg_ratio:.2f}\")\n            else:\n                details.append(f\"Poor cash conversion: FCF/NI ratio of only {avg_ratio:.2f}\")\n        else:\n            details.append(\"Could not calculate FCF to Net Income ratios\")\n    else:\n        details.append(\"Missing FCF or Net Income data\")\n    \n    # 2. Debt management - Munger is cautious about debt\n    debt_values = [item.total_debt for item in financial_line_items \n                  if hasattr(item, 'total_debt') and item.total_debt is not None]\n    \n    equity_values = [item.shareholders_equity for item in financial_line_items \n                    if hasattr(item, 'shareholders_equity') and item.shareholders_equity is not None]\n    \n    if debt_values and equity_values and len(debt_values) == len(equity_values):\n        # Calculate D/E ratio for most recent period\n        recent_de_ratio = debt_values[0] / equity_values[0] if equity_values[0] > 0 else float('inf')\n        \n        if recent_de_ratio < 0.3:  # Very low debt\n            score += 3\n            details.append(f\"Conservative debt management: D/E ratio of {recent_de_ratio:.2f}\")\n        elif recent_de_ratio < 0.7:  # Moderate debt\n            score += 2\n            details.append(f\"Prudent debt management: D/E ratio of {recent_de_ratio:.2f}\")\n        elif recent_de_ratio < 1.5:  # Higher but still reasonable debt\n            score += 1\n            details.append(f\"Moderate debt level: D/E ratio of {recent_de_ratio:.2f}\")\n        else:\n            details.append(f\"High debt level: D/E ratio of {recent_de_ratio:.2f}\")\n    else:\n        details.append(\"Missing debt or equity data\")\n    \n    # 3. Cash management efficiency - Munger values appropriate cash levels\n    cash_values = [item.cash_and_equivalents for item in financial_line_items\n                  if hasattr(item, 'cash_and_equivalents') and item.cash_and_equivalents is not None]\n    revenue_values = [item.revenue for item in financial_line_items\n                     if hasattr(item, 'revenue') and item.revenue is not None]\n    \n    if cash_values and revenue_values and len(cash_values) > 0 and len(revenue_values) > 0:\n        # Calculate cash to revenue ratio (Munger likes 10-20% for most businesses)\n        cash_to_revenue = cash_values[0] / revenue_values[0] if revenue_values[0] > 0 else 0\n        \n        if 0.1 <= cash_to_revenue <= 0.25:\n            # Goldilocks zone - not too much, not too little\n            score += 2\n            details.append(f\"Prudent cash management: Cash/Revenue ratio of {cash_to_revenue:.2f}\")\n        elif 0.05 <= cash_to_revenue < 0.1 or 0.25 < cash_to_revenue <= 0.4:\n            # Reasonable but not ideal\n            score += 1\n            details.append(f\"Acceptable cash position: Cash/Revenue ratio of {cash_to_revenue:.2f}\")\n        elif cash_to_revenue > 0.4:\n            # Too much cash - potentially inefficient capital allocation\n            details.append(f\"Excess cash reserves: Cash/Revenue ratio of {cash_to_revenue:.2f}\")\n        else:\n            # Too little cash - potentially risky\n            details.append(f\"Low cash reserves: Cash/Revenue ratio of {cash_to_revenue:.2f}\")\n    else:\n        details.append(\"Insufficient cash or revenue data\")\n    \n    # 4. Insider activity - Munger values skin in the game\n    if insider_trades and len(insider_trades) > 0:\n        # Count buys vs. sells\n        buys = sum(1 for trade in insider_trades if hasattr(trade, 'transaction_type') and \n                   trade.transaction_type and trade.transaction_type.lower() in ['buy', 'purchase'])\n        sells = sum(1 for trade in insider_trades if hasattr(trade, 'transaction_type') and \n                    trade.transaction_type and trade.transaction_type.lower() in ['sell', 'sale'])\n        \n        # Calculate the buy ratio\n        total_trades = buys + sells\n        if total_trades > 0:\n            buy_ratio = buys / total_trades\n            if buy_ratio > 0.7:  # Strong insider buying\n                score += 2\n                details.append(f\"Strong insider buying: {buys}/{total_trades} transactions are purchases\")\n            elif buy_ratio > 0.4:  # Balanced insider activity\n                score += 1\n                details.append(f\"Balanced insider trading: {buys}/{total_trades} transactions are purchases\")\n            elif buy_ratio < 0.1 and sells > 5:  # Heavy selling\n                score -= 1  # Penalty for excessive selling\n                details.append(f\"Concerning insider selling: {sells}/{total_trades} transactions are sales\")\n            else:\n                details.append(f\"Mixed insider activity: {buys}/{total_trades} transactions are purchases\")\n        else:\n            details.append(\"No recorded insider transactions\")\n    else:\n        details.append(\"No insider trading data available\")\n    \n    # 5. Consistency in share count - Munger prefers stable/decreasing shares\n    share_counts = [item.outstanding_shares for item in financial_line_items\n                   if hasattr(item, 'outstanding_shares') and item.outstanding_shares is not None]\n    \n    if share_counts and len(share_counts) >= 3:\n        if share_counts[0] < share_counts[-1] * 0.95:  # 5%+ reduction in shares\n            score += 2\n            details.append(\"Shareholder-friendly: Reducing share count over time\")\n        elif share_counts[0] < share_counts[-1] * 1.05:  # Stable share count\n            score += 1\n            details.append(\"Stable share count: Limited dilution\")\n        elif share_counts[0] > share_counts[-1] * 1.2:  # >20% dilution\n            score -= 1  # Penalty for excessive dilution\n            details.append(\"Concerning dilution: Share count increased significantly\")\n        else:\n            details.append(\"Moderate share count increase over time\")\n    else:\n        details.append(\"Insufficient share count data\")\n    \n\n    # FCF / NI ratios -> already computed for scoring\n    insider_buy_ratio = None\n    recent_de_ratio = None\n    cash_to_revenue = None\n    share_count_trend = \"unknown\"\n\n    # Debt ratio (D/E) -> we compute `recent_de_ratio`\n    if debt_values and equity_values and len(debt_values) == len(equity_values):\n        recent_de_ratio = debt_values[0] / equity_values[0] if equity_values[0] > 0 else float(\"inf\")\n\n    # Cash/Revenue -> we compute `cash_to_revenue`\n    if cash_values and revenue_values and revenue_values[0] and revenue_values[0] > 0:\n        cash_to_revenue = cash_values[0] / revenue_values[0]\n\n    # Insider ratio -> we compute `insider_buy_ratio`\n    if insider_trades and len(insider_trades) > 0:\n        buys = sum(1 for t in insider_trades\n                   if getattr(t, \"transaction_type\", None)\n                   and t.transaction_type.lower() in [\"buy\", \"purchase\"])\n        sells = sum(1 for t in insider_trades\n                    if getattr(t, \"transaction_type\", None)\n                    and t.transaction_type.lower() in [\"sell\", \"sale\"])\n        total = buys + sells\n        insider_buy_ratio = (buys / total) if total > 0 else None\n\n    # Share count trend (decreasing / stable / increasing)\n    share_counts = [item.outstanding_shares for item in financial_line_items\n                    if hasattr(item, \"outstanding_shares\") and item.outstanding_shares is not None]\n    if share_counts and len(share_counts) >= 3:\n        if share_counts[0] < share_counts[-1] * 0.95:\n            share_count_trend = \"decreasing\"\n        elif share_counts[0] > share_counts[-1] * 1.05:\n            share_count_trend = \"increasing\"\n        else:\n            share_count_trend = \"stable\"\n\n    # Scale score to 0-10 range\n    # Maximum possible raw score would be 12 (3+3+2+2+2)\n    final_score = max(0, min(10, score * 10 / 12))\n    \n    return {\n        \"score\": final_score,\n        \"details\": \"; \".join(details),\n        \"insider_buy_ratio\": insider_buy_ratio,\n        \"recent_de_ratio\": recent_de_ratio,\n        \"cash_to_revenue\": cash_to_revenue,\n        \"share_count_trend\": share_count_trend,\n    }\n\n\ndef analyze_predictability(financial_line_items: list) -> dict:\n    \"\"\"\n    Assess the predictability of the business - Munger strongly prefers businesses\n    whose future operations and cashflows are relatively easy to predict.\n    \"\"\"\n    score = 0\n    details = []\n    \n    if not financial_line_items or len(financial_line_items) < 5:\n        return {\n            \"score\": 0,\n            \"details\": \"Insufficient data to analyze business predictability (need 5+ years)\"\n        }\n    \n    # 1. Revenue stability and growth\n    revenues = [item.revenue for item in financial_line_items \n               if hasattr(item, 'revenue') and item.revenue is not None]\n    \n    if revenues and len(revenues) >= 5:\n        # Calculate year-over-year growth rates, handling zero division\n        growth_rates = []\n        for i in range(len(revenues)-1):\n            if revenues[i+1] != 0:  # Avoid division by zero\n                growth_rate = (revenues[i] / revenues[i+1] - 1)\n                growth_rates.append(growth_rate)\n        \n        if not growth_rates:\n            details.append(\"Cannot calculate revenue growth: zero revenue values found\")\n        else:\n            avg_growth = sum(growth_rates) / len(growth_rates)\n            growth_volatility = sum(abs(r - avg_growth) for r in growth_rates) / len(growth_rates)\n            \n            if avg_growth > 0.05 and growth_volatility < 0.1:\n                # Steady, consistent growth (Munger loves this)\n                score += 3\n                details.append(f\"Highly predictable revenue: {avg_growth:.1%} avg growth with low volatility\")\n            elif avg_growth > 0 and growth_volatility < 0.2:\n                # Positive but somewhat volatile growth\n                score += 2\n                details.append(f\"Moderately predictable revenue: {avg_growth:.1%} avg growth with some volatility\")\n            elif avg_growth > 0:\n                # Growing but unpredictable\n                score += 1\n                details.append(f\"Growing but less predictable revenue: {avg_growth:.1%} avg growth with high volatility\")\n            else:\n                details.append(f\"Declining or highly unpredictable revenue: {avg_growth:.1%} avg growth\")\n    else:\n        details.append(\"Insufficient revenue history for predictability analysis\")\n    \n    # 2. Operating income stability\n    op_income = [item.operating_income for item in financial_line_items \n                if hasattr(item, 'operating_income') and item.operating_income is not None]\n    \n    if op_income and len(op_income) >= 5:\n        # Count positive operating income periods\n        positive_periods = sum(1 for income in op_income if income > 0)\n        \n        if positive_periods == len(op_income):\n            # Consistently profitable operations\n            score += 3\n            details.append(\"Highly predictable operations: Operating income positive in all periods\")\n        elif positive_periods >= len(op_income) * 0.8:\n            # Mostly profitable operations\n            score += 2\n            details.append(f\"Predictable operations: Operating income positive in {positive_periods}/{len(op_income)} periods\")\n        elif positive_periods >= len(op_income) * 0.6:\n            # Somewhat profitable operations\n            score += 1\n            details.append(f\"Somewhat predictable operations: Operating income positive in {positive_periods}/{len(op_income)} periods\")\n        else:\n            details.append(f\"Unpredictable operations: Operating income positive in only {positive_periods}/{len(op_income)} periods\")\n    else:\n        details.append(\"Insufficient operating income history\")\n    \n    # 3. Margin consistency - Munger values stable margins\n    op_margins = [item.operating_margin for item in financial_line_items \n                 if hasattr(item, 'operating_margin') and item.operating_margin is not None]\n    \n    if op_margins and len(op_margins) >= 5:\n        # Calculate margin volatility\n        avg_margin = sum(op_margins) / len(op_margins)\n        margin_volatility = sum(abs(m - avg_margin) for m in op_margins) / len(op_margins)\n        \n        if margin_volatility < 0.03:  # Very stable margins\n            score += 2\n            details.append(f\"Highly predictable margins: {avg_margin:.1%} avg with minimal volatility\")\n        elif margin_volatility < 0.07:  # Moderately stable margins\n            score += 1\n            details.append(f\"Moderately predictable margins: {avg_margin:.1%} avg with some volatility\")\n        else:\n            details.append(f\"Unpredictable margins: {avg_margin:.1%} avg with high volatility ({margin_volatility:.1%})\")\n    else:\n        details.append(\"Insufficient margin history\")\n    \n    # 4. Cash generation reliability\n    fcf_values = [item.free_cash_flow for item in financial_line_items \n                 if hasattr(item, 'free_cash_flow') and item.free_cash_flow is not None]\n    \n    if fcf_values and len(fcf_values) >= 5:\n        # Count positive FCF periods\n        positive_fcf_periods = sum(1 for fcf in fcf_values if fcf > 0)\n        \n        if positive_fcf_periods == len(fcf_values):\n            # Consistently positive FCF\n            score += 2\n            details.append(\"Highly predictable cash generation: Positive FCF in all periods\")\n        elif positive_fcf_periods >= len(fcf_values) * 0.8:\n            # Mostly positive FCF\n            score += 1\n            details.append(f\"Predictable cash generation: Positive FCF in {positive_fcf_periods}/{len(fcf_values)} periods\")\n        else:\n            details.append(f\"Unpredictable cash generation: Positive FCF in only {positive_fcf_periods}/{len(fcf_values)} periods\")\n    else:\n        details.append(\"Insufficient free cash flow history\")\n    \n    # Scale score to 0-10 range\n    # Maximum possible raw score would be 10 (3+3+2+2)\n    final_score = min(10, score * 10 / 10)\n    \n    return {\n        \"score\": final_score,\n        \"details\": \"; \".join(details)\n    }\n\n\ndef calculate_munger_valuation(financial_line_items: list, market_cap: float) -> dict:\n    \"\"\"\n    Calculate intrinsic value using Munger's approach:\n    - Focus on owner earnings (approximated by FCF)\n    - Simple multiple on normalized earnings\n    - Prefer paying a fair price for a wonderful business\n    \"\"\"\n    score = 0\n    details = []\n    \n    if not financial_line_items or market_cap is None:\n        return {\n            \"score\": 0,\n            \"details\": \"Insufficient data to perform valuation\"\n        }\n    \n    # Get FCF values (Munger's preferred \"owner earnings\" metric)\n    fcf_values = [item.free_cash_flow for item in financial_line_items \n                 if hasattr(item, 'free_cash_flow') and item.free_cash_flow is not None]\n    \n    if not fcf_values or len(fcf_values) < 3:\n        return {\n            \"score\": 0,\n            \"details\": \"Insufficient free cash flow data for valuation\"\n        }\n    \n    # 1. Normalize earnings by taking average of last 3-5 years\n    # (Munger prefers to normalize earnings to avoid over/under-valuation based on cyclical factors)\n    normalized_fcf = sum(fcf_values[:min(5, len(fcf_values))]) / min(5, len(fcf_values))\n    \n    if normalized_fcf <= 0:\n        return {\n            \"score\": 0,\n            \"details\": f\"Negative or zero normalized FCF ({normalized_fcf}), cannot value\",\n            \"intrinsic_value\": None\n        }\n    \n    # 2. Calculate FCF yield (inverse of P/FCF multiple)\n    if market_cap <= 0:\n        return {\n            \"score\": 0,\n            \"details\": f\"Invalid market cap ({market_cap}), cannot value\"\n        }\n    \n    fcf_yield = normalized_fcf / market_cap\n    \n    # 3. Apply Munger's FCF multiple based on business quality\n    # Munger would pay higher multiples for wonderful businesses\n    # Let's use a sliding scale where higher FCF yields are more attractive\n    if fcf_yield > 0.08:  # >8% FCF yield (P/FCF < 12.5x)\n        score += 4\n        details.append(f\"Excellent value: {fcf_yield:.1%} FCF yield\")\n    elif fcf_yield > 0.05:  # >5% FCF yield (P/FCF < 20x)\n        score += 3\n        details.append(f\"Good value: {fcf_yield:.1%} FCF yield\")\n    elif fcf_yield > 0.03:  # >3% FCF yield (P/FCF < 33x)\n        score += 1\n        details.append(f\"Fair value: {fcf_yield:.1%} FCF yield\")\n    else:\n        details.append(f\"Expensive: Only {fcf_yield:.1%} FCF yield\")\n    \n    # 4. Calculate simple intrinsic value range\n    # Munger tends to use straightforward valuations, avoiding complex DCF models\n    conservative_value = normalized_fcf * 10  # 10x FCF = 10% yield\n    reasonable_value = normalized_fcf * 15    # 15x FCF ≈ 6.7% yield\n    optimistic_value = normalized_fcf * 20    # 20x FCF = 5% yield\n    \n    # 5. Calculate margins of safety\n    margin_of_safety_vs_fair_value = (reasonable_value - market_cap) / market_cap\n    \n    if margin_of_safety_vs_fair_value > 0.3:  # >30% upside\n        score += 3\n        details.append(f\"Large margin of safety: {margin_of_safety_vs_fair_value:.1%} upside to reasonable value\")\n    elif margin_of_safety_vs_fair_value > 0.1:  # >10% upside\n        score += 2\n        details.append(f\"Moderate margin of safety: {margin_of_safety_vs_fair_value:.1%} upside to reasonable value\")\n    elif margin_of_safety_vs_fair_value > -0.1:  # Within 10% of reasonable value\n        score += 1\n        details.append(f\"Fair price: Within 10% of reasonable value ({margin_of_safety_vs_fair_value:.1%})\")\n    else:\n        details.append(f\"Expensive: {-margin_of_safety_vs_fair_value:.1%} premium to reasonable value\")\n    \n    # 6. Check earnings trajectory for additional context\n    # Munger likes growing owner earnings\n    if len(fcf_values) >= 3:\n        recent_avg = sum(fcf_values[:3]) / 3\n        older_avg = sum(fcf_values[-3:]) / 3 if len(fcf_values) >= 6 else fcf_values[-1]\n        \n        if recent_avg > older_avg * 1.2:  # >20% growth in FCF\n            score += 3\n            details.append(\"Growing FCF trend adds to intrinsic value\")\n        elif recent_avg > older_avg:\n            score += 2\n            details.append(\"Stable to growing FCF supports valuation\")\n        else:\n            details.append(\"Declining FCF trend is concerning\")\n\n    # Scale score to 0-10 range\n    # Maximum possible raw score would be 10 (4+3+3)\n    final_score = min(10, score * 10 / 10) \n    \n    return {\n        \"score\": final_score,\n        \"details\": \"; \".join(details),\n        \"intrinsic_value_range\": {\n            \"conservative\": conservative_value,\n            \"reasonable\": reasonable_value,\n            \"optimistic\": optimistic_value\n        },\n        \"fcf_yield\": fcf_yield,\n        \"normalized_fcf\": normalized_fcf,\n        \"margin_of_safety_vs_fair_value\": margin_of_safety_vs_fair_value,\n\n    }\n\n\ndef analyze_news_sentiment(news_items: list) -> str:\n    \"\"\"\n    Simple qualitative analysis of recent news.\n    Munger pays attention to significant news but doesn't overreact to short-term stories.\n    \"\"\"\n    if not news_items or len(news_items) == 0:\n        return \"No news data available\"\n    \n    # Just return a simple count for now - in a real implementation, this would use NLP\n    return f\"Qualitative review of {len(news_items)} recent news items would be needed\"\n\ndef _r(x, n=3):\n    try:\n        return round(float(x), n)\n    except Exception:\n        return None\n\ndef make_munger_facts_bundle(analysis: dict[str, any]) -> dict[str, any]:\n    moat = analysis.get(\"moat_analysis\") or {}\n    mgmt = analysis.get(\"management_analysis\") or {}\n    pred = analysis.get(\"predictability_analysis\") or {}\n    val  = analysis.get(\"valuation_analysis\") or {}\n    ivr  = val.get(\"intrinsic_value_range\") or {}\n\n    moat_score = _r(moat.get(\"score\"), 2) or 0\n    mgmt_score = _r(mgmt.get(\"score\"), 2) or 0\n    pred_score = _r(pred.get(\"score\"), 2) or 0\n    val_score  = _r(val.get(\"score\"), 2) or 0\n\n    # Simple mental-model flags (booleans/ints = cheap tokens, strong guidance)\n    flags = {\n        \"moat_strong\": moat_score >= 7,\n        \"predictable\": pred_score >= 7,\n        \"owner_aligned\": (mgmt_score >= 7) or ((mgmt.get(\"insider_buy_ratio\") or 0) >= 0.6),\n        \"low_leverage\": (mgmt.get(\"recent_de_ratio\") is not None and mgmt.get(\"recent_de_ratio\") < 0.7),\n        \"sensible_cash\": (mgmt.get(\"cash_to_revenue\") is not None and 0.1 <= mgmt.get(\"cash_to_revenue\") <= 0.25),\n        \"low_capex\": None,  # inferred in moat score already; keep placeholder if you later expose a ratio\n        \"mos_positive\": (val.get(\"mos_to_reasonable\") or 0) > 0.0,\n        \"fcf_yield_ok\": (val.get(\"fcf_yield\") or 0) >= 0.05,\n        \"share_count_friendly\": (mgmt.get(\"share_count_trend\") == \"decreasing\"),\n    }\n\n    return {\n        \"pre_signal\": analysis.get(\"signal\"),\n        \"score\": _r(analysis.get(\"score\"), 2),\n        \"max_score\": _r(analysis.get(\"max_score\"), 2),\n        \"moat_score\": moat_score,\n        \"mgmt_score\": mgmt_score,\n        \"predictability_score\": pred_score,\n        \"valuation_score\": val_score,\n        \"fcf_yield\": _r(val.get(\"fcf_yield\"), 4),\n        \"normalized_fcf\": _r(val.get(\"normalized_fcf\"), 0),\n        \"reasonable_value\": _r(ivr.get(\"reasonable\"), 0),\n        \"margin_of_safety_vs_fair_value\": _r(val.get(\"margin_of_safety_vs_fair_value\"), 3),\n        \"insider_buy_ratio\": _r(mgmt.get(\"insider_buy_ratio\"), 2),\n        \"recent_de_ratio\": _r(mgmt.get(\"recent_de_ratio\"), 2),\n        \"cash_to_revenue\": _r(mgmt.get(\"cash_to_revenue\"), 2),\n        \"share_count_trend\": mgmt.get(\"share_count_trend\"),\n        \"flags\": flags,\n        # keep one-liners, very short\n        \"notes\": {\n            \"moat\": (moat.get(\"details\") or \"\")[:120],\n            \"mgmt\": (mgmt.get(\"details\") or \"\")[:120],\n            \"predictability\": (pred.get(\"details\") or \"\")[:120],\n            \"valuation\": (val.get(\"details\") or \"\")[:120],\n        },\n    }\n\ndef compute_confidence(analysis: dict, signal: str) -> int:\n    # Pull component scores (0..10 each in your pipeline)\n    moat = float((analysis.get(\"moat_analysis\") or {}).get(\"score\") or 0)\n    mgmt = float((analysis.get(\"management_analysis\") or {}).get(\"score\") or 0)\n    pred = float((analysis.get(\"predictability_analysis\") or {}).get(\"score\") or 0)\n    val  = float((analysis.get(\"valuation_analysis\") or {}).get(\"score\") or 0)\n\n    # Quality dominates (Munger): 0.35*moat + 0.25*mgmt + 0.25*pred (max 8.5)\n    quality = 0.35 * moat + 0.25 * mgmt + 0.25 * pred  # 0..8.5\n    quality_pct = 100 * (quality / 8.5) if quality > 0 else 0  # 0..100\n\n    # Valuation bump from MOS vs “reasonable”\n    mos = (analysis.get(\"valuation_analysis\") or {}).get(\"margin_of_safety_vs_fair_value\")\n    mos = float(mos) if mos is not None else 0.0\n    # Convert MOS into a bounded +/-10pp adjustment\n    val_adj = max(-10.0, min(10.0, mos * 100.0 / 3.0))  # ~+/-10pp if MOS is around +/-30%\n\n    # Base confidence: weighted toward quality, then small valuation adjustment\n    base = 0.85 * quality_pct + 0.15 * (val * 10)  # val score 0..10 -> 0..100\n    base = base + val_adj\n\n    # Ensure bucket semantics by clamping into Munger buckets depending on signal\n    if signal == \"bullish\":\n        # If overvalued (mos<0), cap to mixed bucket\n        upper = 100 if mos > 0 else 69\n        lower = 50 if quality_pct >= 55 else 30\n    elif signal == \"bearish\":\n        # If clearly overvalued (mos< -0.05), allow very low bucket\n        lower = 10 if mos < -0.05 else 30\n        upper = 49\n    else:  # neutral\n        lower, upper = 50, 69\n\n    conf = int(round(max(lower, min(upper, base))))\n    # Keep inside global 10..100\n    return max(10, min(100, conf))\n\n\ndef generate_munger_output(\n    ticker: str,\n    analysis_data: dict[str, any],\n    state: AgentState,\n    agent_id: str,\n    confidence_hint: int,\n) -> CharlieMungerSignal:\n    facts_bundle = make_munger_facts_bundle(analysis_data)\n    template = ChatPromptTemplate.from_messages([\n        (\"system\",\n         \"You are Charlie Munger. Decide bullish, bearish, or neutral using only the facts. \"\n         \"Return JSON only. Keep reasoning under 120 characters. \"\n         \"Use the provided confidence exactly; do not change it.\"),\n        (\"human\",\n         \"Ticker: {ticker}\\n\"\n         \"Facts:\\n{facts}\\n\"\n         \"Confidence: {confidence}\\n\"\n         \"Return exactly:\\n\"\n         \"{{\\n\"  # escaped {\n         '  \"signal\": \"bullish\" | \"bearish\" | \"neutral\",\\n'\n         f'  \"confidence\": {confidence_hint},\\n'\n         '  \"reasoning\": \"short justification\"\\n'\n         \"}}\")  # escaped }\n    ])\n\n    prompt = template.invoke({\n        \"ticker\": ticker,\n        \"facts\": json.dumps(facts_bundle, separators=(\",\", \":\"), ensure_ascii=False),\n        \"confidence\": confidence_hint,\n    })\n\n    def _default():\n        return CharlieMungerSignal(signal=\"neutral\", confidence=confidence_hint, reasoning=\"Insufficient data\")\n\n    return call_llm(\n        prompt=prompt,\n        pydantic_model=CharlieMungerSignal,\n        agent_name=agent_id,\n        state=state,\n        default_factory=_default,\n    )\n"
  },
  {
    "path": "src/agents/fundamentals.py",
    "content": "from langchain_core.messages import HumanMessage\nfrom src.graph.state import AgentState, show_agent_reasoning\nfrom src.utils.api_key import get_api_key_from_state\nfrom src.utils.progress import progress\nimport json\n\nfrom src.tools.api import get_financial_metrics\n\n\n##### Fundamental Agent #####\ndef fundamentals_analyst_agent(state: AgentState, agent_id: str = \"fundamentals_analyst_agent\"):\n    \"\"\"Analyzes fundamental data and generates trading signals for multiple tickers.\"\"\"\n    data = state[\"data\"]\n    end_date = data[\"end_date\"]\n    tickers = data[\"tickers\"]\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    # Initialize fundamental analysis for each ticker\n    fundamental_analysis = {}\n\n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Fetching financial metrics\")\n\n        # Get the financial metrics\n        financial_metrics = get_financial_metrics(\n            ticker=ticker,\n            end_date=end_date,\n            period=\"ttm\",\n            limit=10,\n            api_key=api_key,\n        )\n\n        if not financial_metrics:\n            progress.update_status(agent_id, ticker, \"Failed: No financial metrics found\")\n            continue\n\n        # Pull the most recent financial metrics\n        metrics = financial_metrics[0]\n\n        # Initialize signals list for different fundamental aspects\n        signals = []\n        reasoning = {}\n\n        progress.update_status(agent_id, ticker, \"Analyzing profitability\")\n        # 1. Profitability Analysis\n        return_on_equity = metrics.return_on_equity\n        net_margin = metrics.net_margin\n        operating_margin = metrics.operating_margin\n\n        thresholds = [\n            (return_on_equity, 0.15),  # Strong ROE above 15%\n            (net_margin, 0.20),  # Healthy profit margins\n            (operating_margin, 0.15),  # Strong operating efficiency\n        ]\n        profitability_score = sum(metric is not None and metric > threshold for metric, threshold in thresholds)\n\n        signals.append(\"bullish\" if profitability_score >= 2 else \"bearish\" if profitability_score == 0 else \"neutral\")\n        reasoning[\"profitability_signal\"] = {\n            \"signal\": signals[0],\n            \"details\": (f\"ROE: {return_on_equity:.2%}\" if return_on_equity else \"ROE: N/A\") + \", \" + (f\"Net Margin: {net_margin:.2%}\" if net_margin else \"Net Margin: N/A\") + \", \" + (f\"Op Margin: {operating_margin:.2%}\" if operating_margin else \"Op Margin: N/A\"),\n        }\n\n        progress.update_status(agent_id, ticker, \"Analyzing growth\")\n        # 2. Growth Analysis\n        revenue_growth = metrics.revenue_growth\n        earnings_growth = metrics.earnings_growth\n        book_value_growth = metrics.book_value_growth\n\n        thresholds = [\n            (revenue_growth, 0.10),  # 10% revenue growth\n            (earnings_growth, 0.10),  # 10% earnings growth\n            (book_value_growth, 0.10),  # 10% book value growth\n        ]\n        growth_score = sum(metric is not None and metric > threshold for metric, threshold in thresholds)\n\n        signals.append(\"bullish\" if growth_score >= 2 else \"bearish\" if growth_score == 0 else \"neutral\")\n        reasoning[\"growth_signal\"] = {\n            \"signal\": signals[1],\n            \"details\": (f\"Revenue Growth: {revenue_growth:.2%}\" if revenue_growth else \"Revenue Growth: N/A\") + \", \" + (f\"Earnings Growth: {earnings_growth:.2%}\" if earnings_growth else \"Earnings Growth: N/A\"),\n        }\n\n        progress.update_status(agent_id, ticker, \"Analyzing financial health\")\n        # 3. Financial Health\n        current_ratio = metrics.current_ratio\n        debt_to_equity = metrics.debt_to_equity\n        free_cash_flow_per_share = metrics.free_cash_flow_per_share\n        earnings_per_share = metrics.earnings_per_share\n\n        health_score = 0\n        if current_ratio and current_ratio > 1.5:  # Strong liquidity\n            health_score += 1\n        if debt_to_equity and debt_to_equity < 0.5:  # Conservative debt levels\n            health_score += 1\n        if free_cash_flow_per_share and earnings_per_share and free_cash_flow_per_share > earnings_per_share * 0.8:  # Strong FCF conversion\n            health_score += 1\n\n        signals.append(\"bullish\" if health_score >= 2 else \"bearish\" if health_score == 0 else \"neutral\")\n        reasoning[\"financial_health_signal\"] = {\n            \"signal\": signals[2],\n            \"details\": (f\"Current Ratio: {current_ratio:.2f}\" if current_ratio else \"Current Ratio: N/A\") + \", \" + (f\"D/E: {debt_to_equity:.2f}\" if debt_to_equity else \"D/E: N/A\"),\n        }\n\n        progress.update_status(agent_id, ticker, \"Analyzing valuation ratios\")\n        # 4. Price to X ratios\n        pe_ratio = metrics.price_to_earnings_ratio\n        pb_ratio = metrics.price_to_book_ratio\n        ps_ratio = metrics.price_to_sales_ratio\n\n        thresholds = [\n            (pe_ratio, 25),  # Reasonable P/E ratio\n            (pb_ratio, 3),  # Reasonable P/B ratio\n            (ps_ratio, 5),  # Reasonable P/S ratio\n        ]\n        price_ratio_score = sum(metric is not None and metric > threshold for metric, threshold in thresholds)\n\n        signals.append(\"bearish\" if price_ratio_score >= 2 else \"bullish\" if price_ratio_score == 0 else \"neutral\")\n        reasoning[\"price_ratios_signal\"] = {\n            \"signal\": signals[3],\n            \"details\": (f\"P/E: {pe_ratio:.2f}\" if pe_ratio else \"P/E: N/A\") + \", \" + (f\"P/B: {pb_ratio:.2f}\" if pb_ratio else \"P/B: N/A\") + \", \" + (f\"P/S: {ps_ratio:.2f}\" if ps_ratio else \"P/S: N/A\"),\n        }\n\n        progress.update_status(agent_id, ticker, \"Calculating final signal\")\n        # Determine overall signal\n        bullish_signals = signals.count(\"bullish\")\n        bearish_signals = signals.count(\"bearish\")\n\n        if bullish_signals > bearish_signals:\n            overall_signal = \"bullish\"\n        elif bearish_signals > bullish_signals:\n            overall_signal = \"bearish\"\n        else:\n            overall_signal = \"neutral\"\n\n        # Calculate confidence level\n        total_signals = len(signals)\n        confidence = round(max(bullish_signals, bearish_signals) / total_signals, 2) * 100\n\n        fundamental_analysis[ticker] = {\n            \"signal\": overall_signal,\n            \"confidence\": confidence,\n            \"reasoning\": reasoning,\n        }\n\n        progress.update_status(agent_id, ticker, \"Done\", analysis=json.dumps(reasoning, indent=4))\n\n    # Create the fundamental analysis message\n    message = HumanMessage(\n        content=json.dumps(fundamental_analysis),\n        name=agent_id,\n    )\n\n    # Print the reasoning if the flag is set\n    if state[\"metadata\"][\"show_reasoning\"]:\n        show_agent_reasoning(fundamental_analysis, \"Fundamental Analysis Agent\")\n\n    # Add the signal to the analyst_signals list\n    state[\"data\"][\"analyst_signals\"][agent_id] = fundamental_analysis\n\n    progress.update_status(agent_id, None, \"Done\")\n    \n    return {\n        \"messages\": [message],\n        \"data\": data,\n    }\n"
  },
  {
    "path": "src/agents/growth_agent.py",
    "content": "from __future__ import annotations\n\n\"\"\"Growth Agent\n\nImplements a growth-focused valuation methodology.\n\"\"\"\n\nimport json\nimport statistics\nfrom langchain_core.messages import HumanMessage\nfrom src.graph.state import AgentState, show_agent_reasoning\nfrom src.utils.progress import progress\nfrom src.utils.api_key import get_api_key_from_state\nfrom src.tools.api import (\n    get_financial_metrics,\n    get_insider_trades,\n)\n\ndef growth_analyst_agent(state: AgentState, agent_id: str = \"growth_analyst_agent\"):\n    \"\"\"Run growth analysis across tickers and write signals back to `state`.\"\"\"\n\n    data = state[\"data\"]\n    end_date = data[\"end_date\"]\n    tickers = data[\"tickers\"]\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    growth_analysis: dict[str, dict] = {}\n\n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Fetching financial data\")\n\n        # --- Historical financial metrics ---\n        financial_metrics = get_financial_metrics(\n            ticker=ticker,\n            end_date=end_date,\n            period=\"ttm\",\n            limit=12, # 3 years of ttm data\n            api_key=api_key,\n        )\n        if not financial_metrics or len(financial_metrics) < 4:\n            progress.update_status(agent_id, ticker, \"Failed: Not enough financial metrics\")\n            continue\n        \n        most_recent_metrics = financial_metrics[0]\n\n        # --- Insider Trades ---\n        insider_trades = get_insider_trades(\n            ticker=ticker,\n            end_date=end_date,\n            limit=1000,\n            api_key=api_key\n        )\n\n        # ------------------------------------------------------------------\n        # Tool Implementation\n        # ------------------------------------------------------------------\n        \n        # 1. Historical Growth Analysis\n        growth_trends = analyze_growth_trends(financial_metrics)\n        \n        # 2. Growth-Oriented Valuation\n        valuation_metrics = analyze_valuation(most_recent_metrics)\n        \n        # 3. Margin Expansion Monitor\n        margin_trends = analyze_margin_trends(financial_metrics)\n        \n        # 4. Insider Conviction Tracker\n        insider_conviction = analyze_insider_conviction(insider_trades)\n        \n        # 5. Financial Health Check\n        financial_health = check_financial_health(most_recent_metrics)\n\n        # ------------------------------------------------------------------\n        # Aggregate & signal\n        # ------------------------------------------------------------------\n        scores = {\n            \"growth\": growth_trends['score'],\n            \"valuation\": valuation_metrics['score'],\n            \"margins\": margin_trends['score'],\n            \"insider\": insider_conviction['score'],\n            \"health\": financial_health['score']\n        }\n        \n        weights = {\n            \"growth\": 0.40,\n            \"valuation\": 0.25,\n            \"margins\": 0.15,\n            \"insider\": 0.10,\n            \"health\": 0.10\n        }\n\n        weighted_score = sum(scores[key] * weights[key] for key in scores)\n        \n        if weighted_score > 0.6:\n            signal = \"bullish\"\n        elif weighted_score < 0.4:\n            signal = \"bearish\"\n        else:\n            signal = \"neutral\"\n            \n        confidence = round(abs(weighted_score - 0.5) * 2 * 100)\n\n        reasoning = {\n            \"historical_growth\": growth_trends,\n            \"growth_valuation\": valuation_metrics,\n            \"margin_expansion\": margin_trends,\n            \"insider_conviction\": insider_conviction,\n            \"financial_health\": financial_health,\n            \"final_analysis\": {\n                \"signal\": signal,\n                \"confidence\": confidence,\n                \"weighted_score\": round(weighted_score, 2)\n            }\n        }\n\n        growth_analysis[ticker] = {\n            \"signal\": signal,\n            \"confidence\": confidence,\n            \"reasoning\": reasoning,\n        }\n        progress.update_status(agent_id, ticker, \"Done\", analysis=json.dumps(reasoning, indent=4))\n\n    # ---- Emit message (for LLM tool chain) ----\n    msg = HumanMessage(content=json.dumps(growth_analysis), name=agent_id)\n    if state[\"metadata\"].get(\"show_reasoning\"):\n        show_agent_reasoning(growth_analysis, \"Growth Analysis Agent\")\n\n    # Add the signal to the analyst_signals list\n    state[\"data\"][\"analyst_signals\"][agent_id] = growth_analysis\n\n    progress.update_status(agent_id, None, \"Done\")\n    \n    return {\"messages\": [msg], \"data\": data}\n\n#############################\n# Helper Functions\n#############################\n\ndef _calculate_trend(data: list[float | None]) -> float:\n    \"\"\"Calculates the slope of the trend line for the given data.\"\"\"\n    clean_data = [d for d in data if d is not None]\n    if len(clean_data) < 2:\n        return 0.0\n    \n    y = clean_data\n    x = list(range(len(y)))\n    \n    try:\n        # Simple linear regression\n        sum_x = sum(x)\n        sum_y = sum(y)\n        sum_xy = sum(i * j for i, j in zip(x, y))\n        sum_x2 = sum(i**2 for i in x)\n        n = len(y)\n        \n        slope = (n * sum_xy - sum_x * sum_y) / (n * sum_x2 - sum_x**2)\n        return slope\n    except ZeroDivisionError:\n        return 0.0\n\ndef analyze_growth_trends(metrics: list) -> dict:\n    \"\"\"Analyzes historical growth trends.\"\"\"\n    \n    rev_growth = [m.revenue_growth for m in metrics]\n    eps_growth = [m.earnings_per_share_growth for m in metrics]\n    fcf_growth = [m.free_cash_flow_growth for m in metrics]\n\n    rev_trend = _calculate_trend(rev_growth)\n    eps_trend = _calculate_trend(eps_growth)\n    fcf_trend = _calculate_trend(fcf_growth)\n\n    # Score based on recent growth and trend\n    score = 0\n    \n    # Revenue\n    if rev_growth[0] is not None:\n        if rev_growth[0] > 0.20:\n            score += 0.4\n        elif rev_growth[0] > 0.10:\n            score += 0.2\n        if rev_trend > 0:\n            score += 0.1 # Accelerating\n            \n    # EPS\n    if eps_growth[0] is not None:\n        if eps_growth[0] > 0.20:\n            score += 0.25\n        elif eps_growth[0] > 0.10:\n            score += 0.1\n        if eps_trend > 0:\n            score += 0.05\n    \n    # FCF\n    if fcf_growth[0] is not None:\n        if fcf_growth[0] > 0.15:\n            score += 0.1\n            \n    score = min(score, 1.0)\n\n    return {\n        \"score\": score,\n        \"revenue_growth\": rev_growth[0],\n        \"revenue_trend\": rev_trend,\n        \"eps_growth\": eps_growth[0],\n        \"eps_trend\": eps_trend,\n        \"fcf_growth\": fcf_growth[0],\n        \"fcf_trend\": fcf_trend\n    }\n\ndef analyze_valuation(metrics) -> dict:\n    \"\"\"Analyzes valuation from a growth perspective.\"\"\"\n    \n    peg_ratio = metrics.peg_ratio\n    ps_ratio = metrics.price_to_sales_ratio\n    \n    score = 0\n    \n    # PEG Ratio\n    if peg_ratio is not None:\n        if peg_ratio < 1.0:\n            score += 0.5\n        elif peg_ratio < 2.0:\n            score += 0.25\n            \n    # Price to Sales Ratio\n    if ps_ratio is not None:\n        if ps_ratio < 2.0:\n            score += 0.5\n        elif ps_ratio < 5.0:\n            score += 0.25\n            \n    score = min(score, 1.0)\n    \n    return {\n        \"score\": score,\n        \"peg_ratio\": peg_ratio,\n        \"price_to_sales_ratio\": ps_ratio\n    }\n\ndef analyze_margin_trends(metrics: list) -> dict:\n    \"\"\"Analyzes historical margin trends.\"\"\"\n    \n    gross_margins = [m.gross_margin for m in metrics]\n    operating_margins = [m.operating_margin for m in metrics]\n    net_margins = [m.net_margin for m in metrics]\n\n    gm_trend = _calculate_trend(gross_margins)\n    om_trend = _calculate_trend(operating_margins)\n    nm_trend = _calculate_trend(net_margins)\n    \n    score = 0\n    \n    # Gross Margin\n    if gross_margins[0] is not None:\n        if gross_margins[0] > 0.5: # Healthy margin\n            score += 0.2\n        if gm_trend > 0: # Expanding\n            score += 0.2\n\n    # Operating Margin\n    if operating_margins[0] is not None:\n        if operating_margins[0] > 0.15: # Healthy margin\n            score += 0.2\n        if om_trend > 0: # Expanding\n            score += 0.2\n            \n    # Net Margin Trend\n    if nm_trend > 0:\n        score += 0.2\n        \n    score = min(score, 1.0)\n    \n    return {\n        \"score\": score,\n        \"gross_margin\": gross_margins[0],\n        \"gross_margin_trend\": gm_trend,\n        \"operating_margin\": operating_margins[0],\n        \"operating_margin_trend\": om_trend,\n        \"net_margin\": net_margins[0],\n        \"net_margin_trend\": nm_trend\n    }\n\ndef analyze_insider_conviction(trades: list) -> dict:\n    \"\"\"Analyzes insider trading activity.\"\"\"\n    \n    buys = sum(t.transaction_value for t in trades if t.transaction_value and t.transaction_shares > 0)\n    sells = sum(abs(t.transaction_value) for t in trades if t.transaction_value and t.transaction_shares < 0)\n    \n    if (buys + sells) == 0:\n        net_flow_ratio = 0\n    else:\n        net_flow_ratio = (buys - sells) / (buys + sells)\n    \n    score = 0\n    if net_flow_ratio > 0.5:\n        score = 1.0\n    elif net_flow_ratio > 0.1:\n        score = 0.7\n    elif net_flow_ratio > -0.1:\n        score = 0.5 # Neutral\n    else:\n        score = 0.2\n        \n    return {\n        \"score\": score,\n        \"net_flow_ratio\": net_flow_ratio,\n        \"buys\": buys,\n        \"sells\": sells\n    }\n\ndef check_financial_health(metrics) -> dict:\n    \"\"\"Checks the company's financial health.\"\"\"\n    \n    debt_to_equity = metrics.debt_to_equity\n    current_ratio = metrics.current_ratio\n    \n    score = 1.0\n    \n    # Debt to Equity\n    if debt_to_equity is not None:\n        if debt_to_equity > 1.5:\n            score -= 0.5\n        elif debt_to_equity > 0.8:\n            score -= 0.2\n            \n    # Current Ratio\n    if current_ratio is not None:\n        if current_ratio < 1.0:\n            score -= 0.5\n        elif current_ratio < 1.5:\n            score -= 0.2\n            \n    score = max(score, 0.0)\n    \n    return {\n        \"score\": score,\n        \"debt_to_equity\": debt_to_equity,\n        \"current_ratio\": current_ratio\n    }\n"
  },
  {
    "path": "src/agents/michael_burry.py",
    "content": "from __future__ import annotations\n\nfrom datetime import datetime, timedelta\nimport json\nfrom typing_extensions import Literal\n\nfrom src.graph.state import AgentState, show_agent_reasoning\nfrom langchain_core.messages import HumanMessage\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom pydantic import BaseModel\n\nfrom src.tools.api import (\n    get_company_news,\n    get_financial_metrics,\n    get_insider_trades,\n    get_market_cap,\n    search_line_items,\n)\nfrom src.utils.llm import call_llm\nfrom src.utils.progress import progress\nfrom src.utils.api_key import get_api_key_from_state\n\n\nclass MichaelBurrySignal(BaseModel):\n    \"\"\"Schema returned by the LLM.\"\"\"\n\n    signal: Literal[\"bullish\", \"bearish\", \"neutral\"]\n    confidence: float  # 0–100\n    reasoning: str\n\n\ndef michael_burry_agent(state: AgentState, agent_id: str = \"michael_burry_agent\"):\n    \"\"\"Analyse stocks using Michael Burry's deep‑value, contrarian framework.\"\"\"\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    data = state[\"data\"]\n    end_date: str = data[\"end_date\"]  # YYYY‑MM‑DD\n    tickers: list[str] = data[\"tickers\"]\n\n    # We look one year back for insider trades / news flow\n    start_date = (datetime.fromisoformat(end_date) - timedelta(days=365)).date().isoformat()\n\n    analysis_data: dict[str, dict] = {}\n    burry_analysis: dict[str, dict] = {}\n\n    for ticker in tickers:\n        # ------------------------------------------------------------------\n        # Fetch raw data\n        # ------------------------------------------------------------------\n        progress.update_status(agent_id, ticker, \"Fetching financial metrics\")\n        metrics = get_financial_metrics(ticker, end_date, period=\"ttm\", limit=5, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Fetching line items\")\n        line_items = search_line_items(\n            ticker,\n            [\n                \"free_cash_flow\",\n                \"net_income\",\n                \"total_debt\",\n                \"cash_and_equivalents\",\n                \"total_assets\",\n                \"total_liabilities\",\n                \"outstanding_shares\",\n                \"issuance_or_purchase_of_equity_shares\",\n            ],\n            end_date,\n            api_key=api_key,\n        )\n\n        progress.update_status(agent_id, ticker, \"Fetching insider trades\")\n        insider_trades = get_insider_trades(ticker, end_date=end_date, start_date=start_date)\n\n        progress.update_status(agent_id, ticker, \"Fetching company news\")\n        news = get_company_news(ticker, end_date=end_date, start_date=start_date, limit=250)\n\n        progress.update_status(agent_id, ticker, \"Fetching market cap\")\n        market_cap = get_market_cap(ticker, end_date, api_key=api_key)\n\n        # ------------------------------------------------------------------\n        # Run sub‑analyses\n        # ------------------------------------------------------------------\n        progress.update_status(agent_id, ticker, \"Analyzing value\")\n        value_analysis = _analyze_value(metrics, line_items, market_cap)\n\n        progress.update_status(agent_id, ticker, \"Analyzing balance sheet\")\n        balance_sheet_analysis = _analyze_balance_sheet(metrics, line_items)\n\n        progress.update_status(agent_id, ticker, \"Analyzing insider activity\")\n        insider_analysis = _analyze_insider_activity(insider_trades)\n\n        progress.update_status(agent_id, ticker, \"Analyzing contrarian sentiment\")\n        contrarian_analysis = _analyze_contrarian_sentiment(news)\n\n        # ------------------------------------------------------------------\n        # Aggregate score & derive preliminary signal\n        # ------------------------------------------------------------------\n        total_score = (\n            value_analysis[\"score\"]\n            + balance_sheet_analysis[\"score\"]\n            + insider_analysis[\"score\"]\n            + contrarian_analysis[\"score\"]\n        )\n        max_score = (\n            value_analysis[\"max_score\"]\n            + balance_sheet_analysis[\"max_score\"]\n            + insider_analysis[\"max_score\"]\n            + contrarian_analysis[\"max_score\"]\n        )\n\n        if total_score >= 0.7 * max_score:\n            signal = \"bullish\"\n        elif total_score <= 0.3 * max_score:\n            signal = \"bearish\"\n        else:\n            signal = \"neutral\"\n\n        # ------------------------------------------------------------------\n        # Collect data for LLM reasoning & output\n        # ------------------------------------------------------------------\n        analysis_data[ticker] = {\n            \"signal\": signal,\n            \"score\": total_score,\n            \"max_score\": max_score,\n            \"value_analysis\": value_analysis,\n            \"balance_sheet_analysis\": balance_sheet_analysis,\n            \"insider_analysis\": insider_analysis,\n            \"contrarian_analysis\": contrarian_analysis,\n            \"market_cap\": market_cap,\n        }\n\n        progress.update_status(agent_id, ticker, \"Generating LLM output\")\n        burry_output = _generate_burry_output(\n            ticker=ticker,\n            analysis_data=analysis_data,\n            state=state,\n            agent_id=agent_id,\n        )\n\n        burry_analysis[ticker] = {\n            \"signal\": burry_output.signal,\n            \"confidence\": burry_output.confidence,\n            \"reasoning\": burry_output.reasoning,\n        }\n\n        progress.update_status(agent_id, ticker, \"Done\", analysis=burry_output.reasoning)\n\n    # ----------------------------------------------------------------------\n    # Return to the graph\n    # ----------------------------------------------------------------------\n    message = HumanMessage(content=json.dumps(burry_analysis), name=agent_id)\n\n    if state[\"metadata\"].get(\"show_reasoning\"):\n        show_agent_reasoning(burry_analysis, \"Michael Burry Agent\")\n\n    state[\"data\"][\"analyst_signals\"][agent_id] = burry_analysis\n\n    progress.update_status(agent_id, None, \"Done\")\n\n    return {\"messages\": [message], \"data\": state[\"data\"]}\n\n\n###############################################################################\n# Sub‑analysis helpers\n###############################################################################\n\n\ndef _latest_line_item(line_items: list):\n    \"\"\"Return the most recent line‑item object or *None*.\"\"\"\n    return line_items[0] if line_items else None\n\n\n# ----- Value ----------------------------------------------------------------\n\ndef _analyze_value(metrics, line_items, market_cap):\n    \"\"\"Free cash‑flow yield, EV/EBIT, other classic deep‑value metrics.\"\"\"\n\n    max_score = 6  # 4 pts for FCF‑yield, 2 pts for EV/EBIT\n    score = 0\n    details: list[str] = []\n\n    # Free‑cash‑flow yield\n    latest_item = _latest_line_item(line_items)\n    fcf = getattr(latest_item, \"free_cash_flow\", None) if latest_item else None\n    if fcf is not None and market_cap:\n        fcf_yield = fcf / market_cap\n        if fcf_yield >= 0.15:\n            score += 4\n            details.append(f\"Extraordinary FCF yield {fcf_yield:.1%}\")\n        elif fcf_yield >= 0.12:\n            score += 3\n            details.append(f\"Very high FCF yield {fcf_yield:.1%}\")\n        elif fcf_yield >= 0.08:\n            score += 2\n            details.append(f\"Respectable FCF yield {fcf_yield:.1%}\")\n        else:\n            details.append(f\"Low FCF yield {fcf_yield:.1%}\")\n    else:\n        details.append(\"FCF data unavailable\")\n\n    # EV/EBIT (from financial metrics)\n    if metrics:\n        ev_ebit = getattr(metrics[0], \"ev_to_ebit\", None)\n        if ev_ebit is not None:\n            if ev_ebit < 6:\n                score += 2\n                details.append(f\"EV/EBIT {ev_ebit:.1f} (<6)\")\n            elif ev_ebit < 10:\n                score += 1\n                details.append(f\"EV/EBIT {ev_ebit:.1f} (<10)\")\n            else:\n                details.append(f\"High EV/EBIT {ev_ebit:.1f}\")\n        else:\n            details.append(\"EV/EBIT data unavailable\")\n    else:\n        details.append(\"Financial metrics unavailable\")\n\n    return {\"score\": score, \"max_score\": max_score, \"details\": \"; \".join(details)}\n\n\n# ----- Balance sheet --------------------------------------------------------\n\ndef _analyze_balance_sheet(metrics, line_items):\n    \"\"\"Leverage and liquidity checks.\"\"\"\n\n    max_score = 3\n    score = 0\n    details: list[str] = []\n\n    latest_metrics = metrics[0] if metrics else None\n    latest_item = _latest_line_item(line_items)\n\n    debt_to_equity = getattr(latest_metrics, \"debt_to_equity\", None) if latest_metrics else None\n    if debt_to_equity is not None:\n        if debt_to_equity < 0.5:\n            score += 2\n            details.append(f\"Low D/E {debt_to_equity:.2f}\")\n        elif debt_to_equity < 1:\n            score += 1\n            details.append(f\"Moderate D/E {debt_to_equity:.2f}\")\n        else:\n            details.append(f\"High leverage D/E {debt_to_equity:.2f}\")\n    else:\n        details.append(\"Debt‑to‑equity data unavailable\")\n\n    # Quick liquidity sanity check (cash vs total debt)\n    if latest_item is not None:\n        cash = getattr(latest_item, \"cash_and_equivalents\", None)\n        total_debt = getattr(latest_item, \"total_debt\", None)\n        if cash is not None and total_debt is not None:\n            if cash > total_debt:\n                score += 1\n                details.append(\"Net cash position\")\n            else:\n                details.append(\"Net debt position\")\n        else:\n            details.append(\"Cash/debt data unavailable\")\n\n    return {\"score\": score, \"max_score\": max_score, \"details\": \"; \".join(details)}\n\n\n# ----- Insider activity -----------------------------------------------------\n\ndef _analyze_insider_activity(insider_trades):\n    \"\"\"Net insider buying over the last 12 months acts as a hard catalyst.\"\"\"\n\n    max_score = 2\n    score = 0\n    details: list[str] = []\n\n    if not insider_trades:\n        details.append(\"No insider trade data\")\n        return {\"score\": score, \"max_score\": max_score, \"details\": \"; \".join(details)}\n\n    shares_bought = sum(t.transaction_shares or 0 for t in insider_trades if (t.transaction_shares or 0) > 0)\n    shares_sold = abs(sum(t.transaction_shares or 0 for t in insider_trades if (t.transaction_shares or 0) < 0))\n    net = shares_bought - shares_sold\n    if net > 0:\n        score += 2 if net / max(shares_sold, 1) > 1 else 1\n        details.append(f\"Net insider buying of {net:,} shares\")\n    else:\n        details.append(\"Net insider selling\")\n\n    return {\"score\": score, \"max_score\": max_score, \"details\": \"; \".join(details)}\n\n\n# ----- Contrarian sentiment -------------------------------------------------\n\ndef _analyze_contrarian_sentiment(news):\n    \"\"\"Very rough gauge: a wall of recent negative headlines can be a *positive* for a contrarian.\"\"\"\n\n    max_score = 1\n    score = 0\n    details: list[str] = []\n\n    if not news:\n        details.append(\"No recent news\")\n        return {\"score\": score, \"max_score\": max_score, \"details\": \"; \".join(details)}\n\n    # Count negative sentiment articles\n    sentiment_negative_count = sum(\n        1 for n in news if n.sentiment and n.sentiment.lower() in [\"negative\", \"bearish\"]\n    )\n    \n    if sentiment_negative_count >= 5:\n        score += 1  # The more hated, the better (assuming fundamentals hold up)\n        details.append(f\"{sentiment_negative_count} negative headlines (contrarian opportunity)\")\n    else:\n        details.append(\"Limited negative press\")\n\n    return {\"score\": score, \"max_score\": max_score, \"details\": \"; \".join(details)}\n\n\n###############################################################################\n# LLM generation\n###############################################################################\n\ndef _generate_burry_output(\n    ticker: str,\n    analysis_data: dict,\n    state: AgentState,\n    agent_id: str,\n) -> MichaelBurrySignal:\n    \"\"\"Call the LLM to craft the final trading signal in Burry's voice.\"\"\"\n\n    template = ChatPromptTemplate.from_messages(\n        [\n            (\n                \"system\",\n                \"\"\"You are an AI agent emulating Dr. Michael J. Burry. Your mandate:\n                - Hunt for deep value in US equities using hard numbers (free cash flow, EV/EBIT, balance sheet)\n                - Be contrarian: hatred in the press can be your friend if fundamentals are solid\n                - Focus on downside first – avoid leveraged balance sheets\n                - Look for hard catalysts such as insider buying, buybacks, or asset sales\n                - Communicate in Burry's terse, data‑driven style\n\n                When providing your reasoning, be thorough and specific by:\n                1. Start with the key metric(s) that drove your decision\n                2. Cite concrete numbers (e.g. \"FCF yield 14.7%\", \"EV/EBIT 5.3\")\n                3. Highlight risk factors and why they are acceptable (or not)\n                4. Mention relevant insider activity or contrarian opportunities\n                5. Use Burry's direct, number-focused communication style with minimal words\n                \n                For example, if bullish: \"FCF yield 12.8%. EV/EBIT 6.2. Debt-to-equity 0.4. Net insider buying 25k shares. Market missing value due to overreaction to recent litigation. Strong buy.\"\n                For example, if bearish: \"FCF yield only 2.1%. Debt-to-equity concerning at 2.3. Management diluting shareholders. Pass.\"\n                \"\"\",\n            ),\n            (\n                \"human\",\n                \"\"\"Based on the following data, create the investment signal as Michael Burry would:\n\n                Analysis Data for {ticker}:\n                {analysis_data}\n\n                Return the trading signal in the following JSON format exactly:\n                {{\n                  \"signal\": \"bullish\" | \"bearish\" | \"neutral\",\n                  \"confidence\": float between 0 and 100,\n                  \"reasoning\": \"string\"\n                }}\n                \"\"\",\n            ),\n        ]\n    )\n\n    prompt = template.invoke({\"analysis_data\": json.dumps(analysis_data, indent=2), \"ticker\": ticker})\n\n    # Default fallback signal in case parsing fails\n    def create_default_michael_burry_signal():\n        return MichaelBurrySignal(signal=\"neutral\", confidence=0.0, reasoning=\"Parsing error – defaulting to neutral\")\n\n    return call_llm(\n        prompt=prompt,\n        pydantic_model=MichaelBurrySignal,\n        agent_name=agent_id,\n        state=state,\n        default_factory=create_default_michael_burry_signal,\n    )\n"
  },
  {
    "path": "src/agents/mohnish_pabrai.py",
    "content": "from src.graph.state import AgentState, show_agent_reasoning\nfrom src.tools.api import get_financial_metrics, get_market_cap, search_line_items\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_core.messages import HumanMessage\nfrom pydantic import BaseModel\nimport json\nfrom typing_extensions import Literal\nfrom src.utils.progress import progress\nfrom src.utils.llm import call_llm\nfrom src.utils.api_key import get_api_key_from_state\n\n\nclass MohnishPabraiSignal(BaseModel):\n    signal: Literal[\"bullish\", \"bearish\", \"neutral\"]\n    confidence: float\n    reasoning: str\n\n\ndef mohnish_pabrai_agent(state: AgentState, agent_id: str = \"mohnish_pabrai_agent\"):\n    \"\"\"Evaluate stocks using Mohnish Pabrai's checklist and 'heads I win, tails I don't lose much' approach.\"\"\"\n    data = state[\"data\"]\n    end_date = data[\"end_date\"]\n    tickers = data[\"tickers\"]\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n\n    analysis_data: dict[str, any] = {}\n    pabrai_analysis: dict[str, any] = {}\n\n    # Pabrai focuses on: downside protection, simple business, moat via unit economics, FCF yield vs alternatives,\n    # and potential for doubling in 2-3 years at low risk.\n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Fetching financial metrics\")\n        metrics = get_financial_metrics(ticker, end_date, period=\"annual\", limit=8, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Gathering financial line items\")\n        line_items = search_line_items(\n            ticker,\n            [\n                # Profitability and cash generation\n                \"revenue\",\n                \"gross_profit\",\n                \"gross_margin\",\n                \"operating_income\",\n                \"operating_margin\",\n                \"net_income\",\n                \"free_cash_flow\",\n                # Balance sheet - debt and liquidity\n                \"total_debt\",\n                \"cash_and_equivalents\",\n                \"current_assets\",\n                \"current_liabilities\",\n                \"shareholders_equity\",\n                # Capital intensity\n                \"capital_expenditure\",\n                \"depreciation_and_amortization\",\n                # Shares outstanding for per-share context\n                \"outstanding_shares\",\n            ],\n            end_date,\n            period=\"annual\",\n            limit=8,\n            api_key=api_key,\n        )\n\n        progress.update_status(agent_id, ticker, \"Getting market cap\")\n        market_cap = get_market_cap(ticker, end_date, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Analyzing downside protection\")\n        downside = analyze_downside_protection(line_items)\n\n        progress.update_status(agent_id, ticker, \"Analyzing cash yield and valuation\")\n        valuation = analyze_pabrai_valuation(line_items, market_cap)\n\n        progress.update_status(agent_id, ticker, \"Assessing potential to double\")\n        double_potential = analyze_double_potential(line_items, market_cap)\n\n        # Combine to an overall score in spirit of Pabrai: heavily weight downside and cash yield\n        total_score = (\n            downside[\"score\"] * 0.45\n            + valuation[\"score\"] * 0.35\n            + double_potential[\"score\"] * 0.20\n        )\n        max_score = 10\n\n        if total_score >= 7.5:\n            signal = \"bullish\"\n        elif total_score <= 4.0:\n            signal = \"bearish\"\n        else:\n            signal = \"neutral\"\n\n        analysis_data[ticker] = {\n            \"signal\": signal,\n            \"score\": total_score,\n            \"max_score\": max_score,\n            \"downside_protection\": downside,\n            \"valuation\": valuation,\n            \"double_potential\": double_potential,\n            \"market_cap\": market_cap,\n        }\n\n        progress.update_status(agent_id, ticker, \"Generating Pabrai analysis\")\n        pabrai_output = generate_pabrai_output(\n            ticker=ticker,\n            analysis_data=analysis_data,\n            state=state,\n            agent_id=agent_id,\n        )\n\n        pabrai_analysis[ticker] = {\n            \"signal\": pabrai_output.signal,\n            \"confidence\": pabrai_output.confidence,\n            \"reasoning\": pabrai_output.reasoning,\n        }\n\n        progress.update_status(agent_id, ticker, \"Done\", analysis=pabrai_output.reasoning)\n\n    message = HumanMessage(content=json.dumps(pabrai_analysis), name=agent_id)\n\n    if state[\"metadata\"][\"show_reasoning\"]:\n        show_agent_reasoning(pabrai_analysis, \"Mohnish Pabrai Agent\")\n\n    progress.update_status(agent_id, None, \"Done\")\n\n    state[\"data\"][\"analyst_signals\"][agent_id] = pabrai_analysis\n\n    return {\"messages\": [message], \"data\": state[\"data\"]}\n\n\ndef analyze_downside_protection(financial_line_items: list) -> dict[str, any]:\n    \"\"\"Assess balance-sheet strength and downside resiliency (capital preservation first).\"\"\"\n    if not financial_line_items:\n        return {\"score\": 0, \"details\": \"Insufficient data\"}\n\n    latest = financial_line_items[0]\n    details: list[str] = []\n    score = 0\n\n    cash = getattr(latest, \"cash_and_equivalents\", None)\n    debt = getattr(latest, \"total_debt\", None)\n    current_assets = getattr(latest, \"current_assets\", None)\n    current_liabilities = getattr(latest, \"current_liabilities\", None)\n    equity = getattr(latest, \"shareholders_equity\", None)\n\n    # Net cash position is a strong downside protector\n    net_cash = None\n    if cash is not None and debt is not None:\n        net_cash = cash - debt\n        if net_cash > 0:\n            score += 3\n            details.append(f\"Net cash position: ${net_cash:,.0f}\")\n        else:\n            details.append(f\"Net debt position: ${net_cash:,.0f}\")\n\n    # Current ratio\n    if current_assets is not None and current_liabilities is not None and current_liabilities > 0:\n        current_ratio = current_assets / current_liabilities\n        if current_ratio >= 2.0:\n            score += 2\n            details.append(f\"Strong liquidity (current ratio {current_ratio:.2f})\")\n        elif current_ratio >= 1.2:\n            score += 1\n            details.append(f\"Adequate liquidity (current ratio {current_ratio:.2f})\")\n        else:\n            details.append(f\"Weak liquidity (current ratio {current_ratio:.2f})\")\n\n    # Low leverage\n    if equity is not None and equity > 0 and debt is not None:\n        de_ratio = debt / equity\n        if de_ratio < 0.3:\n            score += 2\n            details.append(f\"Very low leverage (D/E {de_ratio:.2f})\")\n        elif de_ratio < 0.7:\n            score += 1\n            details.append(f\"Moderate leverage (D/E {de_ratio:.2f})\")\n        else:\n            details.append(f\"High leverage (D/E {de_ratio:.2f})\")\n\n    # Free cash flow positive and stable\n    fcf_values = [getattr(li, \"free_cash_flow\", None) for li in financial_line_items if getattr(li, \"free_cash_flow\", None) is not None]\n    if fcf_values and len(fcf_values) >= 3:\n        recent_avg = sum(fcf_values[:3]) / 3\n        older = sum(fcf_values[-3:]) / 3 if len(fcf_values) >= 6 else fcf_values[-1]\n        if recent_avg > 0 and recent_avg >= older:\n            score += 2\n            details.append(\"Positive and improving/stable FCF\")\n        elif recent_avg > 0:\n            score += 1\n            details.append(\"Positive but declining FCF\")\n        else:\n            details.append(\"Negative FCF\")\n\n    return {\"score\": min(10, score), \"details\": \"; \".join(details)}\n\n\ndef analyze_pabrai_valuation(financial_line_items: list, market_cap: float | None) -> dict[str, any]:\n    \"\"\"Value via simple FCF yield and asset-light preference (keep it simple, low mistakes).\"\"\"\n    if not financial_line_items or market_cap is None or market_cap <= 0:\n        return {\"score\": 0, \"details\": \"Insufficient data\", \"fcf_yield\": None, \"normalized_fcf\": None}\n\n    details: list[str] = []\n    fcf_values = [getattr(li, \"free_cash_flow\", None) for li in financial_line_items if getattr(li, \"free_cash_flow\", None) is not None]\n    capex_vals = [abs(getattr(li, \"capital_expenditure\", 0) or 0) for li in financial_line_items]\n\n    if not fcf_values or len(fcf_values) < 3:\n        return {\"score\": 0, \"details\": \"Insufficient FCF history\", \"fcf_yield\": None, \"normalized_fcf\": None}\n\n    normalized_fcf = sum(fcf_values[:min(5, len(fcf_values))]) / min(5, len(fcf_values))\n    if normalized_fcf <= 0:\n        return {\"score\": 0, \"details\": \"Non-positive normalized FCF\", \"fcf_yield\": None, \"normalized_fcf\": normalized_fcf}\n\n    fcf_yield = normalized_fcf / market_cap\n\n    score = 0\n    if fcf_yield > 0.10:\n        score += 4\n        details.append(f\"Exceptional value: {fcf_yield:.1%} FCF yield\")\n    elif fcf_yield > 0.07:\n        score += 3\n        details.append(f\"Attractive value: {fcf_yield:.1%} FCF yield\")\n    elif fcf_yield > 0.05:\n        score += 2\n        details.append(f\"Reasonable value: {fcf_yield:.1%} FCF yield\")\n    elif fcf_yield > 0.03:\n        score += 1\n        details.append(f\"Borderline value: {fcf_yield:.1%} FCF yield\")\n    else:\n        details.append(f\"Expensive: {fcf_yield:.1%} FCF yield\")\n\n    # Asset-light tilt: lower capex intensity preferred\n    if capex_vals and len(financial_line_items) >= 3:\n        revenue_vals = [getattr(li, \"revenue\", None) for li in financial_line_items]\n        capex_to_revenue = []\n        for i, li in enumerate(financial_line_items):\n            revenue = getattr(li, \"revenue\", None)\n            capex = abs(getattr(li, \"capital_expenditure\", 0) or 0)\n            if revenue and revenue > 0:\n                capex_to_revenue.append(capex / revenue)\n        if capex_to_revenue:\n            avg_ratio = sum(capex_to_revenue) / len(capex_to_revenue)\n            if avg_ratio < 0.05:\n                score += 2\n                details.append(f\"Asset-light: Avg capex {avg_ratio:.1%} of revenue\")\n            elif avg_ratio < 0.10:\n                score += 1\n                details.append(f\"Moderate capex: Avg capex {avg_ratio:.1%} of revenue\")\n            else:\n                details.append(f\"Capex heavy: Avg capex {avg_ratio:.1%} of revenue\")\n\n    return {\"score\": min(10, score), \"details\": \"; \".join(details), \"fcf_yield\": fcf_yield, \"normalized_fcf\": normalized_fcf}\n\n\ndef analyze_double_potential(financial_line_items: list, market_cap: float | None) -> dict[str, any]:\n    \"\"\"Estimate low-risk path to double capital in ~2-3 years: runway from FCF growth + rerating.\"\"\"\n    if not financial_line_items or market_cap is None or market_cap <= 0:\n        return {\"score\": 0, \"details\": \"Insufficient data\"}\n\n    details: list[str] = []\n\n    # Use revenue and FCF trends as rough growth proxy (keep it simple)\n    revenues = [getattr(li, \"revenue\", None) for li in financial_line_items if getattr(li, \"revenue\", None) is not None]\n    fcfs = [getattr(li, \"free_cash_flow\", None) for li in financial_line_items if getattr(li, \"free_cash_flow\", None) is not None]\n\n    score = 0\n    if revenues and len(revenues) >= 3:\n        recent_rev = sum(revenues[:3]) / 3\n        older_rev = sum(revenues[-3:]) / 3 if len(revenues) >= 6 else revenues[-1]\n        if older_rev > 0:\n            rev_growth = (recent_rev / older_rev) - 1\n            if rev_growth > 0.15:\n                score += 2\n                details.append(f\"Strong revenue trajectory ({rev_growth:.1%})\")\n            elif rev_growth > 0.05:\n                score += 1\n                details.append(f\"Modest revenue growth ({rev_growth:.1%})\")\n\n    if fcfs and len(fcfs) >= 3:\n        recent_fcf = sum(fcfs[:3]) / 3\n        older_fcf = sum(fcfs[-3:]) / 3 if len(fcfs) >= 6 else fcfs[-1]\n        if older_fcf != 0:\n            fcf_growth = (recent_fcf / older_fcf) - 1\n            if fcf_growth > 0.20:\n                score += 3\n                details.append(f\"Strong FCF growth ({fcf_growth:.1%})\")\n            elif fcf_growth > 0.08:\n                score += 2\n                details.append(f\"Healthy FCF growth ({fcf_growth:.1%})\")\n            elif fcf_growth > 0:\n                score += 1\n                details.append(f\"Positive FCF growth ({fcf_growth:.1%})\")\n\n    # If FCF yield is already high (>8%), doubling can come from cash generation alone in few years\n    tmp_val = analyze_pabrai_valuation(financial_line_items, market_cap)\n    fcf_yield = tmp_val.get(\"fcf_yield\")\n    if fcf_yield is not None:\n        if fcf_yield > 0.08:\n            score += 3\n            details.append(\"High FCF yield can drive doubling via retained cash/Buybacks\")\n        elif fcf_yield > 0.05:\n            score += 1\n            details.append(\"Reasonable FCF yield supports moderate compounding\")\n\n    return {\"score\": min(10, score), \"details\": \"; \".join(details)}\n\n\ndef generate_pabrai_output(\n    ticker: str,\n    analysis_data: dict[str, any],\n    state: AgentState,\n    agent_id: str,\n) -> MohnishPabraiSignal:\n    \"\"\"Generate Pabrai-style decision focusing on low risk, high uncertainty bets and cloning.\"\"\"\n    template = ChatPromptTemplate.from_messages([\n        (\n          \"system\",\n          \"\"\"You are Mohnish Pabrai. Apply my value investing philosophy:\n\n          - Heads I win; tails I don't lose much: prioritize downside protection first.\n          - Buy businesses with simple, understandable models and durable moats.\n          - Demand high free cash flow yields and low leverage; prefer asset-light models.\n          - Look for situations where intrinsic value is rising and price is significantly lower.\n          - Favor cloning great investors' ideas and checklists over novelty.\n          - Seek potential to double capital in 2-3 years with low risk.\n          - Avoid leverage, complexity, and fragile balance sheets.\n\n            Provide candid, checklist-driven reasoning, with emphasis on capital preservation and expected mispricing.\n            \"\"\",\n        ),\n        (\n          \"human\",\n          \"\"\"Analyze {ticker} using the provided data.\n\n          DATA:\n          {analysis_data}\n\n          Return EXACTLY this JSON:\n          {{\n            \"signal\": \"bullish\" | \"bearish\" | \"neutral\",\n            \"confidence\": float (0-100),\n            \"reasoning\": \"string with Pabrai-style analysis focusing on downside protection, FCF yield, and doubling potential\"\n          }}\n          \"\"\",\n        ),\n    ])\n\n    prompt = template.invoke({\n        \"analysis_data\": json.dumps(analysis_data, indent=2),\n        \"ticker\": ticker,\n    })\n\n    def create_default_pabrai_signal():\n        return MohnishPabraiSignal(signal=\"neutral\", confidence=0.0, reasoning=\"Error in analysis, defaulting to neutral\")\n\n    return call_llm(\n        prompt=prompt,\n        state=state,\n        pydantic_model=MohnishPabraiSignal,\n        agent_name=agent_id,\n        default_factory=create_default_pabrai_signal,\n    ) "
  },
  {
    "path": "src/agents/news_sentiment.py",
    "content": "\n\nfrom langchain_core.messages import HumanMessage\nfrom pydantic import BaseModel, Field\nfrom src.data.models import CompanyNews\nimport pandas as pd\nimport numpy as np\nimport json\n\nfrom src.graph.state import AgentState, show_agent_reasoning\nfrom src.tools.api import get_company_news\nfrom src.utils.api_key import get_api_key_from_state\nfrom src.utils.llm import call_llm\nfrom src.utils.progress import progress\nfrom typing_extensions import Literal\n\n\nclass Sentiment(BaseModel):\n    \"\"\"Represents the sentiment of a news article.\"\"\"\n\n    sentiment: Literal[\"positive\", \"negative\", \"neutral\"]\n    confidence: int = Field(description=\"Confidence 0-100\")\n\n\ndef news_sentiment_agent(state: AgentState, agent_id: str = \"news_sentiment_agent\"):\n    \"\"\"\n    Analyzes news sentiment for a list of tickers and generates trading signals.\n\n    This agent fetches company news, uses an LLM to classify the sentiment of articles\n    with missing sentiment data, and then aggregates the sentiments to produce an\n    overall signal (bullish, bearish, or neutral) and a confidence score for each ticker.\n\n    Args:\n        state: The current state of the agent graph.\n        agent_id: The ID of the agent.\n\n    Returns:\n        A dictionary containing the updated state with the agent's analysis.\n    \"\"\"\n    data = state.get(\"data\", {})\n    end_date = data.get(\"end_date\")\n    tickers = data.get(\"tickers\")\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    sentiment_analysis = {}\n\n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Fetching company news\")\n        company_news = get_company_news(\n            ticker=ticker,\n            end_date=end_date,\n            limit=100,\n            api_key=api_key,\n        )\n\n        news_signals = []\n        sentiment_confidences = {}  # Store confidence scores for each article\n        \n        if company_news:\n            # Check the 10 most recent articles\n            recent_articles = company_news[:10]\n            articles_without_sentiment = [news for news in recent_articles if news.sentiment is None]\n            \n            # Analyze only the 5 most recent articles without sentiment to reduce LLM calls\n            sentiments_classified_by_llm = 0\n            if articles_without_sentiment:\n              # We only take the first 5 articles, but this is configurable\n              num_articles_to_analyze = 5\n              articles_to_analyze = articles_without_sentiment[:num_articles_to_analyze]\n              progress.update_status(agent_id, ticker, f\"Analyzing sentiment for {len(articles_to_analyze)} articles\")\n              \n              for idx, news in enumerate(articles_to_analyze):\n                # We analyze based on title, but can also pass in the entire article text,\n                # but this is more expensive and requires extracting the text from the article.\n                # Note: this is an opportunity for improvement!\n                progress.update_status(agent_id, ticker, f\"Analyzing sentiment for article {idx + 1} of {len(articles_to_analyze)}\")\n                prompt = (\n                    f\"Please analyze the sentiment of the following news headline \"\n                    f\"with the following context: \"\n                    f\"The stock is {ticker}. \"\n                    f\"Determine if sentiment is 'positive', 'negative', or 'neutral' for the stock {ticker} only. \"\n                    f\"Also provide a confidence score for your prediction from 0 to 100. \"\n                    f\"Respond in JSON format.\\n\\n\"\n                    f\"Headline: {news.title}\"\n                )\n                response = call_llm(prompt, Sentiment, agent_name=agent_id, state=state)\n                if response:\n                    news.sentiment = response.sentiment.lower()\n                    sentiment_confidences[id(news)] = response.confidence\n                else:\n                    news.sentiment = \"neutral\"\n                    sentiment_confidences[id(news)] = 0\n                sentiments_classified_by_llm += 1\n\n            # Aggregate sentiment across all articles\n            sentiment = pd.Series([n.sentiment for n in company_news]).dropna()\n            news_signals = np.where(sentiment == \"negative\",\"bearish\", np.where(sentiment == \"positive\", \"bullish\", \"neutral\")).tolist()\n\n        progress.update_status(agent_id, ticker, \"Aggregating signals\")\n\n        # Calculate the sentiment signals\n        bullish_signals = news_signals.count(\"bullish\")\n        bearish_signals = news_signals.count(\"bearish\")\n        neutral_signals = news_signals.count(\"neutral\")\n\n        if bullish_signals > bearish_signals:\n            overall_signal = \"bullish\"\n        elif bearish_signals > bullish_signals:\n            overall_signal = \"bearish\"\n        else:\n            overall_signal = \"neutral\"\n\n        total_signals = len(news_signals)\n        confidence = _calculate_confidence_score(\n            sentiment_confidences=sentiment_confidences,\n            company_news=company_news,\n            overall_signal=overall_signal,\n            bullish_signals=bullish_signals,\n            bearish_signals=bearish_signals,\n            total_signals=total_signals\n        )\n\n        # Create reasoning for the news sentiment\n        reasoning = {\n            \"news_sentiment\": {\n                \"signal\": overall_signal,\n                \"confidence\": confidence,\n                \"metrics\": {\n                    \"total_articles\": total_signals,\n                    \"bullish_articles\": bullish_signals,\n                    \"bearish_articles\": bearish_signals,\n                    \"neutral_articles\": neutral_signals,\n                    \"articles_classified_by_llm\": sentiments_classified_by_llm,\n                },\n            }\n        }\n\n        # Create the sentiment analysis\n        sentiment_analysis[ticker] = {\n            \"signal\": overall_signal,\n            \"confidence\": confidence,\n            \"reasoning\": reasoning,\n        }\n\n        progress.update_status(agent_id, ticker, \"Done\", analysis=json.dumps(reasoning, indent=4))\n\n    message = HumanMessage(\n        content=json.dumps(sentiment_analysis),\n        name=agent_id,\n    )\n\n    if state.get(\"metadata\", {}).get(\"show_reasoning\"):\n        show_agent_reasoning(sentiment_analysis, \"News Sentiment Analysis Agent\")\n\n    if \"analyst_signals\" not in state[\"data\"]:\n        state[\"data\"][\"analyst_signals\"] = {}\n    state[\"data\"][\"analyst_signals\"][agent_id] = sentiment_analysis\n\n    progress.update_status(agent_id, None, \"Done\")\n\n    return {\n        \"messages\": [message],\n        \"data\": state[\"data\"],\n    }\n\n\ndef _calculate_confidence_score(\n    sentiment_confidences: dict,\n    company_news: list,\n    overall_signal: str,\n    bullish_signals: int,\n    bearish_signals: int,\n    total_signals: int\n) -> float:\n    \"\"\"\n    Calculate confidence score for a sentiment signal.\n    \n    Uses a weighted approach combining LLM confidence scores (70%) with \n    signal proportion (30%) when LLM classifications are available.\n    \n    Args:\n        sentiment_confidences: Dictionary mapping news article IDs to confidence scores.\n        company_news: List of CompanyNews objects.\n        overall_signal: The overall sentiment signal (\"bullish\", \"bearish\", or \"neutral\").\n        bullish_signals: Count of bullish signals.\n        bearish_signals: Count of bearish signals.\n        total_signals: Total number of signals.\n        \n    Returns:\n        Confidence score as a float between 0 and 100.\n    \"\"\"\n    if total_signals == 0:\n        return 0.0\n    \n    # Calculate weighted confidence using LLM confidence scores when available\n    if sentiment_confidences:\n        # Get articles that match the overall signal\n        matching_articles = [\n            news for news in company_news \n            if news.sentiment and (\n                (overall_signal == \"bullish\" and news.sentiment == \"positive\") or\n                (overall_signal == \"bearish\" and news.sentiment == \"negative\") or\n                (overall_signal == \"neutral\" and news.sentiment == \"neutral\")\n            )\n        ]\n        \n        # Calculate average confidence from LLM-classified articles that match the signal\n        llm_confidences = [\n            sentiment_confidences[id(news)] \n            for news in matching_articles \n            if id(news) in sentiment_confidences\n        ]\n        \n        if llm_confidences:\n            # Weight: 70% from LLM confidence scores, 30% from signal proportion\n            avg_llm_confidence = sum(llm_confidences) / len(llm_confidences)\n            signal_proportion = (max(bullish_signals, bearish_signals) / total_signals) * 100\n            return round(0.7 * avg_llm_confidence + 0.3 * signal_proportion, 2)\n    \n    # Fallback to proportion-based confidence\n    return round((max(bullish_signals, bearish_signals) / total_signals) * 100, 2)\n"
  },
  {
    "path": "src/agents/peter_lynch.py",
    "content": "from src.graph.state import AgentState, show_agent_reasoning\nfrom src.tools.api import (\n    get_market_cap,\n    search_line_items,\n    get_insider_trades,\n    get_company_news,\n)\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_core.messages import HumanMessage\nfrom pydantic import BaseModel\nimport json\nfrom typing_extensions import Literal\nfrom src.utils.progress import progress\nfrom src.utils.llm import call_llm\nfrom src.utils.api_key import get_api_key_from_state\n\n\nclass PeterLynchSignal(BaseModel):\n    \"\"\"\n    Container for the Peter Lynch-style output signal.\n    \"\"\"\n    signal: Literal[\"bullish\", \"bearish\", \"neutral\"]\n    confidence: float\n    reasoning: str\n\n\ndef peter_lynch_agent(state: AgentState, agent_id: str = \"peter_lynch_agent\"):\n    \"\"\"\n    Analyzes stocks using Peter Lynch's investing principles:\n      - Invest in what you know (clear, understandable businesses).\n      - Growth at a Reasonable Price (GARP), emphasizing the PEG ratio.\n      - Look for consistent revenue & EPS increases and manageable debt.\n      - Be alert for potential \"ten-baggers\" (high-growth opportunities).\n      - Avoid overly complex or highly leveraged businesses.\n      - Use news sentiment and insider trades for secondary inputs.\n      - If fundamentals strongly align with GARP, be more aggressive.\n\n    The result is a bullish/bearish/neutral signal, along with a\n    confidence (0–100) and a textual reasoning explanation.\n    \"\"\"\n\n    data = state[\"data\"]\n    end_date = data[\"end_date\"]\n    tickers = data[\"tickers\"]\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    analysis_data = {}\n    lynch_analysis = {}\n\n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Gathering financial line items\")\n        # Relevant line items for Peter Lynch's approach\n        financial_line_items = search_line_items(\n            ticker,\n            [\n                \"revenue\",\n                \"earnings_per_share\",\n                \"net_income\",\n                \"operating_income\",\n                \"gross_margin\",\n                \"operating_margin\",\n                \"free_cash_flow\",\n                \"capital_expenditure\",\n                \"cash_and_equivalents\",\n                \"total_debt\",\n                \"shareholders_equity\",\n                \"outstanding_shares\",\n            ],\n            end_date,\n            period=\"annual\",\n            limit=5,\n            api_key=api_key,\n        )\n\n        progress.update_status(agent_id, ticker, \"Getting market cap\")\n        market_cap = get_market_cap(ticker, end_date, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Fetching insider trades\")\n        insider_trades = get_insider_trades(ticker, end_date, limit=50, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Fetching company news\")\n        company_news = get_company_news(ticker, end_date, limit=50, api_key=api_key)\n\n        # Perform sub-analyses:\n        progress.update_status(agent_id, ticker, \"Analyzing growth\")\n        growth_analysis = analyze_lynch_growth(financial_line_items)\n\n        progress.update_status(agent_id, ticker, \"Analyzing fundamentals\")\n        fundamentals_analysis = analyze_lynch_fundamentals(financial_line_items)\n\n        progress.update_status(agent_id, ticker, \"Analyzing valuation (focus on PEG)\")\n        valuation_analysis = analyze_lynch_valuation(financial_line_items, market_cap)\n\n        progress.update_status(agent_id, ticker, \"Analyzing sentiment\")\n        sentiment_analysis = analyze_sentiment(company_news)\n\n        progress.update_status(agent_id, ticker, \"Analyzing insider activity\")\n        insider_activity = analyze_insider_activity(insider_trades)\n\n        # Combine partial scores with weights typical for Peter Lynch:\n        #   30% Growth, 25% Valuation, 20% Fundamentals,\n        #   15% Sentiment, 10% Insider Activity = 100%\n        total_score = (\n            growth_analysis[\"score\"] * 0.30\n            + valuation_analysis[\"score\"] * 0.25\n            + fundamentals_analysis[\"score\"] * 0.20\n            + sentiment_analysis[\"score\"] * 0.15\n            + insider_activity[\"score\"] * 0.10\n        )\n\n        max_possible_score = 10.0\n\n        # Map final score to signal\n        if total_score >= 7.5:\n            signal = \"bullish\"\n        elif total_score <= 4.5:\n            signal = \"bearish\"\n        else:\n            signal = \"neutral\"\n\n        analysis_data[ticker] = {\n            \"signal\": signal,\n            \"score\": total_score,\n            \"max_score\": max_possible_score,\n            \"growth_analysis\": growth_analysis,\n            \"valuation_analysis\": valuation_analysis,\n            \"fundamentals_analysis\": fundamentals_analysis,\n            \"sentiment_analysis\": sentiment_analysis,\n            \"insider_activity\": insider_activity,\n        }\n\n        progress.update_status(agent_id, ticker, \"Generating Peter Lynch analysis\")\n        lynch_output = generate_lynch_output(\n            ticker=ticker,\n            analysis_data=analysis_data[ticker],\n            state=state,\n            agent_id=agent_id,\n        )\n\n        lynch_analysis[ticker] = {\n            \"signal\": lynch_output.signal,\n            \"confidence\": lynch_output.confidence,\n            \"reasoning\": lynch_output.reasoning,\n        }\n\n        progress.update_status(agent_id, ticker, \"Done\", analysis=lynch_output.reasoning)\n\n    # Wrap up results\n    message = HumanMessage(content=json.dumps(lynch_analysis), name=agent_id)\n\n    if state[\"metadata\"].get(\"show_reasoning\"):\n        show_agent_reasoning(lynch_analysis, \"Peter Lynch Agent\")\n\n    # Save signals to state\n    state[\"data\"][\"analyst_signals\"][agent_id] = lynch_analysis\n\n    progress.update_status(agent_id, None, \"Done\")\n\n    return {\"messages\": [message], \"data\": state[\"data\"]}\n\n\ndef analyze_lynch_growth(financial_line_items: list) -> dict:\n    \"\"\"\n    Evaluate growth based on revenue and EPS trends:\n      - Consistent revenue growth\n      - Consistent EPS growth\n    Peter Lynch liked companies with steady, understandable growth,\n    often searching for potential 'ten-baggers' with a long runway.\n    \"\"\"\n    if not financial_line_items or len(financial_line_items) < 2:\n        return {\"score\": 0, \"details\": \"Insufficient financial data for growth analysis\"}\n\n    details = []\n    raw_score = 0  # We'll sum up points, then scale to 0–10 eventually\n\n    # 1) Revenue Growth\n    revenues = [fi.revenue for fi in financial_line_items if fi.revenue is not None]\n    if len(revenues) >= 2:\n        latest_rev = revenues[0]\n        older_rev = revenues[-1]\n        if older_rev > 0:\n            rev_growth = (latest_rev - older_rev) / abs(older_rev)\n            if rev_growth > 0.25:\n                raw_score += 3\n                details.append(f\"Strong revenue growth: {rev_growth:.1%}\")\n            elif rev_growth > 0.10:\n                raw_score += 2\n                details.append(f\"Moderate revenue growth: {rev_growth:.1%}\")\n            elif rev_growth > 0.02:\n                raw_score += 1\n                details.append(f\"Slight revenue growth: {rev_growth:.1%}\")\n            else:\n                details.append(f\"Flat or negative revenue growth: {rev_growth:.1%}\")\n        else:\n            details.append(\"Older revenue is zero/negative; can't compute revenue growth.\")\n    else:\n        details.append(\"Not enough revenue data to assess growth.\")\n\n    # 2) EPS Growth\n    eps_values = [fi.earnings_per_share for fi in financial_line_items if fi.earnings_per_share is not None]\n    if len(eps_values) >= 2:\n        latest_eps = eps_values[0]\n        older_eps = eps_values[-1]\n        if abs(older_eps) > 1e-9:\n            eps_growth = (latest_eps - older_eps) / abs(older_eps)\n            if eps_growth > 0.25:\n                raw_score += 3\n                details.append(f\"Strong EPS growth: {eps_growth:.1%}\")\n            elif eps_growth > 0.10:\n                raw_score += 2\n                details.append(f\"Moderate EPS growth: {eps_growth:.1%}\")\n            elif eps_growth > 0.02:\n                raw_score += 1\n                details.append(f\"Slight EPS growth: {eps_growth:.1%}\")\n            else:\n                details.append(f\"Minimal or negative EPS growth: {eps_growth:.1%}\")\n        else:\n            details.append(\"Older EPS is near zero; skipping EPS growth calculation.\")\n    else:\n        details.append(\"Not enough EPS data for growth calculation.\")\n\n    # raw_score can be up to 6 => scale to 0–10\n    final_score = min(10, (raw_score / 6) * 10)\n    return {\"score\": final_score, \"details\": \"; \".join(details)}\n\n\ndef analyze_lynch_fundamentals(financial_line_items: list) -> dict:\n    \"\"\"\n    Evaluate basic fundamentals:\n      - Debt/Equity\n      - Operating margin (or gross margin)\n      - Positive Free Cash Flow\n    Lynch avoided heavily indebted or complicated businesses.\n    \"\"\"\n    if not financial_line_items:\n        return {\"score\": 0, \"details\": \"Insufficient fundamentals data\"}\n\n    details = []\n    raw_score = 0  # We'll accumulate up to 6 points, then scale to 0–10\n\n    # 1) Debt-to-Equity\n    debt_values = [fi.total_debt for fi in financial_line_items if fi.total_debt is not None]\n    eq_values = [fi.shareholders_equity for fi in financial_line_items if fi.shareholders_equity is not None]\n    if debt_values and eq_values and len(debt_values) == len(eq_values) and len(debt_values) > 0:\n        recent_debt = debt_values[0]\n        recent_equity = eq_values[0] if eq_values[0] else 1e-9\n        de_ratio = recent_debt / recent_equity\n        if de_ratio < 0.5:\n            raw_score += 2\n            details.append(f\"Low debt-to-equity: {de_ratio:.2f}\")\n        elif de_ratio < 1.0:\n            raw_score += 1\n            details.append(f\"Moderate debt-to-equity: {de_ratio:.2f}\")\n        else:\n            details.append(f\"High debt-to-equity: {de_ratio:.2f}\")\n    else:\n        details.append(\"No consistent debt/equity data available.\")\n\n    # 2) Operating Margin\n    om_values = [fi.operating_margin for fi in financial_line_items if fi.operating_margin is not None]\n    if om_values:\n        om_recent = om_values[0]\n        if om_recent > 0.20:\n            raw_score += 2\n            details.append(f\"Strong operating margin: {om_recent:.1%}\")\n        elif om_recent > 0.10:\n            raw_score += 1\n            details.append(f\"Moderate operating margin: {om_recent:.1%}\")\n        else:\n            details.append(f\"Low operating margin: {om_recent:.1%}\")\n    else:\n        details.append(\"No operating margin data available.\")\n\n    # 3) Positive Free Cash Flow\n    fcf_values = [fi.free_cash_flow for fi in financial_line_items if fi.free_cash_flow is not None]\n    if fcf_values and fcf_values[0] is not None:\n        if fcf_values[0] > 0:\n            raw_score += 2\n            details.append(f\"Positive free cash flow: {fcf_values[0]:,.0f}\")\n        else:\n            details.append(f\"Recent FCF is negative: {fcf_values[0]:,.0f}\")\n    else:\n        details.append(\"No free cash flow data available.\")\n\n    # raw_score up to 6 => scale to 0–10\n    final_score = min(10, (raw_score / 6) * 10)\n    return {\"score\": final_score, \"details\": \"; \".join(details)}\n\n\ndef analyze_lynch_valuation(financial_line_items: list, market_cap: float | None) -> dict:\n    \"\"\"\n    Peter Lynch's approach to 'Growth at a Reasonable Price' (GARP):\n      - Emphasize the PEG ratio: (P/E) / Growth Rate\n      - Also consider a basic P/E if PEG is unavailable\n    A PEG < 1 is very attractive; 1-2 is fair; >2 is expensive.\n    \"\"\"\n    if not financial_line_items or market_cap is None:\n        return {\"score\": 0, \"details\": \"Insufficient data for valuation\"}\n\n    details = []\n    raw_score = 0\n\n    # Gather data for P/E\n    net_incomes = [fi.net_income for fi in financial_line_items if fi.net_income is not None]\n    eps_values = [fi.earnings_per_share for fi in financial_line_items if fi.earnings_per_share is not None]\n\n    # Approximate P/E via (market cap / net income) if net income is positive\n    pe_ratio = None\n    if net_incomes and net_incomes[0] and net_incomes[0] > 0:\n        pe_ratio = market_cap / net_incomes[0]\n        details.append(f\"Estimated P/E: {pe_ratio:.2f}\")\n    else:\n        details.append(\"No positive net income => can't compute approximate P/E\")\n\n    # If we have at least 2 EPS data points, let's estimate growth\n    eps_growth_rate = None\n    if len(eps_values) >= 2:\n        latest_eps = eps_values[0]\n        older_eps = eps_values[-1]\n        if older_eps > 0:\n            # Calculate annualized growth rate (CAGR) for PEG ratio\n            num_years = len(eps_values) - 1\n            if latest_eps > 0:\n                # CAGR formula: (ending_value/beginning_value)^(1/years) - 1\n                eps_growth_rate = (latest_eps / older_eps) ** (1 / num_years) - 1\n            else:\n                # If latest EPS is negative, use simple average growth\n                eps_growth_rate = (latest_eps - older_eps) / (older_eps * num_years)\n            details.append(f\"Annualized EPS growth rate: {eps_growth_rate:.1%}\")\n        else:\n            details.append(\"Cannot compute EPS growth rate (older EPS <= 0)\")\n    else:\n        details.append(\"Not enough EPS data to compute growth rate\")\n\n    # Compute PEG if possible\n    peg_ratio = None\n    if pe_ratio and eps_growth_rate and eps_growth_rate > 0:\n        # PEG ratio formula: P/E divided by growth rate (as percentage)\n        # Since eps_growth_rate is stored as decimal (0.25 for 25%),\n        # we multiply by 100 to convert to percentage for the PEG calculation\n        # Example: P/E=20, growth=0.25 (25%) => PEG = 20/25 = 0.8\n        peg_ratio = pe_ratio / (eps_growth_rate * 100)\n        details.append(f\"PEG ratio: {peg_ratio:.2f}\")\n\n    # Scoring logic:\n    #   - P/E < 15 => +2, < 25 => +1\n    #   - PEG < 1 => +3, < 2 => +2, < 3 => +1\n    if pe_ratio is not None:\n        if pe_ratio < 15:\n            raw_score += 2\n        elif pe_ratio < 25:\n            raw_score += 1\n\n    if peg_ratio is not None:\n        if peg_ratio < 1:\n            raw_score += 3\n        elif peg_ratio < 2:\n            raw_score += 2\n        elif peg_ratio < 3:\n            raw_score += 1\n\n    final_score = min(10, (raw_score / 5) * 10)\n    return {\"score\": final_score, \"details\": \"; \".join(details)}\n\n\ndef analyze_sentiment(news_items: list) -> dict:\n    \"\"\"\n    Basic news sentiment check. Negative headlines weigh on the final score.\n    \"\"\"\n    if not news_items:\n        return {\"score\": 5, \"details\": \"No news data; default to neutral sentiment\"}\n\n    negative_keywords = [\"lawsuit\", \"fraud\", \"negative\", \"downturn\", \"decline\", \"investigation\", \"recall\"]\n    negative_count = 0\n    for news in news_items:\n        title_lower = (news.title or \"\").lower()\n        if any(word in title_lower for word in negative_keywords):\n            negative_count += 1\n\n    details = []\n    if negative_count > len(news_items) * 0.3:\n        # More than 30% negative => somewhat bearish => 3/10\n        score = 3\n        details.append(f\"High proportion of negative headlines: {negative_count}/{len(news_items)}\")\n    elif negative_count > 0:\n        # Some negativity => 6/10\n        score = 6\n        details.append(f\"Some negative headlines: {negative_count}/{len(news_items)}\")\n    else:\n        # Mostly positive => 8/10\n        score = 8\n        details.append(\"Mostly positive or neutral headlines\")\n\n    return {\"score\": score, \"details\": \"; \".join(details)}\n\n\ndef analyze_insider_activity(insider_trades: list) -> dict:\n    \"\"\"\n    Simple insider-trade analysis:\n      - If there's heavy insider buying, it's a positive sign.\n      - If there's mostly selling, it's a negative sign.\n      - Otherwise, neutral.\n    \"\"\"\n    # Default 5 (neutral)\n    score = 5\n    details = []\n\n    if not insider_trades:\n        details.append(\"No insider trades data; defaulting to neutral\")\n        return {\"score\": score, \"details\": \"; \".join(details)}\n\n    buys, sells = 0, 0\n    for trade in insider_trades:\n        if trade.transaction_shares is not None:\n            if trade.transaction_shares > 0:\n                buys += 1\n            elif trade.transaction_shares < 0:\n                sells += 1\n\n    total = buys + sells\n    if total == 0:\n        details.append(\"No significant buy/sell transactions found; neutral stance\")\n        return {\"score\": score, \"details\": \"; \".join(details)}\n\n    buy_ratio = buys / total\n    if buy_ratio > 0.7:\n        # Heavy buying => +3 => total 8\n        score = 8\n        details.append(f\"Heavy insider buying: {buys} buys vs. {sells} sells\")\n    elif buy_ratio > 0.4:\n        # Some buying => +1 => total 6\n        score = 6\n        details.append(f\"Moderate insider buying: {buys} buys vs. {sells} sells\")\n    else:\n        # Mostly selling => -1 => total 4\n        score = 4\n        details.append(f\"Mostly insider selling: {buys} buys vs. {sells} sells\")\n\n    return {\"score\": score, \"details\": \"; \".join(details)}\n\n\ndef generate_lynch_output(\n    ticker: str,\n    analysis_data: dict[str, any],\n    state: AgentState,\n    agent_id: str,\n) -> PeterLynchSignal:\n    \"\"\"\n    Generates a final JSON signal in Peter Lynch's voice & style.\n    \"\"\"\n    template = ChatPromptTemplate.from_messages(\n        [\n            (\n                \"system\",\n                \"\"\"You are a Peter Lynch AI agent. You make investment decisions based on Peter Lynch's well-known principles:\n                \n                1. Invest in What You Know: Emphasize understandable businesses, possibly discovered in everyday life.\n                2. Growth at a Reasonable Price (GARP): Rely on the PEG ratio as a prime metric.\n                3. Look for 'Ten-Baggers': Companies capable of growing earnings and share price substantially.\n                4. Steady Growth: Prefer consistent revenue/earnings expansion, less concern about short-term noise.\n                5. Avoid High Debt: Watch for dangerous leverage.\n                6. Management & Story: A good 'story' behind the stock, but not overhyped or too complex.\n                \n                When you provide your reasoning, do it in Peter Lynch's voice:\n                - Cite the PEG ratio\n                - Mention 'ten-bagger' potential if applicable\n                - Refer to personal or anecdotal observations (e.g., \"If my kids love the product...\")\n                - Use practical, folksy language\n                - Provide key positives and negatives\n                - Conclude with a clear stance (bullish, bearish, or neutral)\n                \n                Return your final output strictly in JSON with the fields:\n                {{\n                  \"signal\": \"bullish\" | \"bearish\" | \"neutral\",\n                  \"confidence\": 0 to 100,\n                  \"reasoning\": \"string\"\n                }}\n                \"\"\",\n            ),\n            (\n                \"human\",\n                \"\"\"Based on the following analysis data for {ticker}, produce your Peter Lynch–style investment signal.\n\n                Analysis Data:\n                {analysis_data}\n\n                Return only valid JSON with \"signal\", \"confidence\", and \"reasoning\".\n                \"\"\",\n            ),\n        ]\n    )\n\n    prompt = template.invoke({\"analysis_data\": json.dumps(analysis_data, indent=2), \"ticker\": ticker})\n\n    def create_default_signal():\n        return PeterLynchSignal(\n            signal=\"neutral\",\n            confidence=0.0,\n            reasoning=\"Error in analysis; defaulting to neutral\"\n        )\n\n    return call_llm(\n        prompt=prompt,\n        pydantic_model=PeterLynchSignal,\n        agent_name=agent_id,\n        state=state,\n        default_factory=create_default_signal,\n    )\n"
  },
  {
    "path": "src/agents/phil_fisher.py",
    "content": "from src.graph.state import AgentState, show_agent_reasoning\nfrom src.tools.api import (\n    get_market_cap,\n    search_line_items,\n    get_insider_trades,\n    get_company_news,\n)\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_core.messages import HumanMessage\nfrom pydantic import BaseModel\nimport json\nfrom typing_extensions import Literal\nfrom src.utils.progress import progress\nfrom src.utils.llm import call_llm\nimport statistics\nfrom src.utils.api_key import get_api_key_from_state\n\nclass PhilFisherSignal(BaseModel):\n    signal: Literal[\"bullish\", \"bearish\", \"neutral\"]\n    confidence: float\n    reasoning: str\n\n\ndef phil_fisher_agent(state: AgentState, agent_id: str = \"phil_fisher_agent\"):\n    \"\"\"\n    Analyzes stocks using Phil Fisher's investing principles:\n      - Seek companies with long-term above-average growth potential\n      - Emphasize quality of management and R&D\n      - Look for strong margins, consistent growth, and manageable leverage\n      - Combine fundamental 'scuttlebutt' style checks with basic sentiment and insider data\n      - Willing to pay up for quality, but still mindful of valuation\n      - Generally focuses on long-term compounding\n\n    Returns a bullish/bearish/neutral signal with confidence and reasoning.\n    \"\"\"\n    data = state[\"data\"]\n    end_date = data[\"end_date\"]\n    tickers = data[\"tickers\"]\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    analysis_data = {}\n    fisher_analysis = {}\n\n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Gathering financial line items\")\n        # Include relevant line items for Phil Fisher's approach:\n        #   - Growth & Quality: revenue, net_income, earnings_per_share, R&D expense\n        #   - Margins & Stability: operating_income, operating_margin, gross_margin\n        #   - Management Efficiency & Leverage: total_debt, shareholders_equity, free_cash_flow\n        #   - Valuation: net_income, free_cash_flow (for P/E, P/FCF), ebit, ebitda\n        financial_line_items = search_line_items(\n            ticker,\n            [\n                \"revenue\",\n                \"net_income\",\n                \"earnings_per_share\",\n                \"free_cash_flow\",\n                \"research_and_development\",\n                \"operating_income\",\n                \"operating_margin\",\n                \"gross_margin\",\n                \"total_debt\",\n                \"shareholders_equity\",\n                \"cash_and_equivalents\",\n                \"ebit\",\n                \"ebitda\",\n            ],\n            end_date,\n            period=\"annual\",\n            limit=5,\n            api_key=api_key,\n        )\n\n        progress.update_status(agent_id, ticker, \"Getting market cap\")\n        market_cap = get_market_cap(ticker, end_date, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Fetching insider trades\")\n        insider_trades = get_insider_trades(ticker, end_date, limit=50, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Fetching company news\")\n        company_news = get_company_news(ticker, end_date, limit=50, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Analyzing growth & quality\")\n        growth_quality = analyze_fisher_growth_quality(financial_line_items)\n\n        progress.update_status(agent_id, ticker, \"Analyzing margins & stability\")\n        margins_stability = analyze_margins_stability(financial_line_items)\n\n        progress.update_status(agent_id, ticker, \"Analyzing management efficiency & leverage\")\n        mgmt_efficiency = analyze_management_efficiency_leverage(financial_line_items)\n\n        progress.update_status(agent_id, ticker, \"Analyzing valuation (Fisher style)\")\n        fisher_valuation = analyze_fisher_valuation(financial_line_items, market_cap)\n\n        progress.update_status(agent_id, ticker, \"Analyzing insider activity\")\n        insider_activity = analyze_insider_activity(insider_trades)\n\n        progress.update_status(agent_id, ticker, \"Analyzing sentiment\")\n        sentiment_analysis = analyze_sentiment(company_news)\n\n        # Combine partial scores with weights typical for Fisher:\n        #   30% Growth & Quality\n        #   25% Margins & Stability\n        #   20% Management Efficiency\n        #   15% Valuation\n        #   5% Insider Activity\n        #   5% Sentiment\n        total_score = (\n            growth_quality[\"score\"] * 0.30\n            + margins_stability[\"score\"] * 0.25\n            + mgmt_efficiency[\"score\"] * 0.20\n            + fisher_valuation[\"score\"] * 0.15\n            + insider_activity[\"score\"] * 0.05\n            + sentiment_analysis[\"score\"] * 0.05\n        )\n\n        max_possible_score = 10\n\n        # Simple bullish/neutral/bearish signal\n        if total_score >= 7.5:\n            signal = \"bullish\"\n        elif total_score <= 4.5:\n            signal = \"bearish\"\n        else:\n            signal = \"neutral\"\n\n        analysis_data[ticker] = {\n            \"signal\": signal,\n            \"score\": total_score,\n            \"max_score\": max_possible_score,\n            \"growth_quality\": growth_quality,\n            \"margins_stability\": margins_stability,\n            \"management_efficiency\": mgmt_efficiency,\n            \"valuation_analysis\": fisher_valuation,\n            \"insider_activity\": insider_activity,\n            \"sentiment_analysis\": sentiment_analysis,\n        }\n\n        progress.update_status(agent_id, ticker, \"Generating Phil Fisher-style analysis\")\n        fisher_output = generate_fisher_output(\n            ticker=ticker,\n            analysis_data=analysis_data,\n            state=state,\n            agent_id=agent_id,\n        )\n\n        fisher_analysis[ticker] = {\n            \"signal\": fisher_output.signal,\n            \"confidence\": fisher_output.confidence,\n            \"reasoning\": fisher_output.reasoning,\n        }\n\n        progress.update_status(agent_id, ticker, \"Done\", analysis=fisher_output.reasoning)\n\n    # Wrap results in a single message\n    message = HumanMessage(content=json.dumps(fisher_analysis), name=agent_id)\n\n    if state[\"metadata\"].get(\"show_reasoning\"):\n        show_agent_reasoning(fisher_analysis, \"Phil Fisher Agent\")\n\n    state[\"data\"][\"analyst_signals\"][agent_id] = fisher_analysis\n\n    progress.update_status(agent_id, None, \"Done\")\n    \n    return {\"messages\": [message], \"data\": state[\"data\"]}\n\n\ndef analyze_fisher_growth_quality(financial_line_items: list) -> dict:\n    \"\"\"\n    Evaluate growth & quality:\n      - Consistent Revenue Growth\n      - Consistent EPS Growth\n      - R&D as a % of Revenue (if relevant, indicative of future-oriented spending)\n    \"\"\"\n    if not financial_line_items or len(financial_line_items) < 2:\n        return {\n            \"score\": 0,\n            \"details\": \"Insufficient financial data for growth/quality analysis\",\n        }\n\n    details = []\n    raw_score = 0  # up to 9 raw points => scale to 0–10\n\n    # 1. Revenue Growth (annualized CAGR)\n    revenues = [fi.revenue for fi in financial_line_items if fi.revenue is not None]\n    if len(revenues) >= 2:\n        # Calculate annualized growth rate (CAGR) for proper comparison\n        latest_rev = revenues[0]\n        oldest_rev = revenues[-1]\n        num_years = len(revenues) - 1\n        if oldest_rev > 0 and latest_rev > 0:\n            # CAGR formula: (ending_value/beginning_value)^(1/years) - 1\n            rev_growth = (latest_rev / oldest_rev) ** (1 / num_years) - 1\n            if rev_growth > 0.20:  # 20% annualized\n                raw_score += 3\n                details.append(f\"Very strong annualized revenue growth: {rev_growth:.1%}\")\n            elif rev_growth > 0.10:  # 10% annualized\n                raw_score += 2\n                details.append(f\"Moderate annualized revenue growth: {rev_growth:.1%}\")\n            elif rev_growth > 0.03:  # 3% annualized\n                raw_score += 1\n                details.append(f\"Slight annualized revenue growth: {rev_growth:.1%}\")\n            else:\n                details.append(f\"Minimal or negative annualized revenue growth: {rev_growth:.1%}\")\n        else:\n            details.append(\"Oldest revenue is zero/negative; cannot compute growth.\")\n    else:\n        details.append(\"Not enough revenue data points for growth calculation.\")\n\n    # 2. EPS Growth (annualized CAGR)\n    eps_values = [fi.earnings_per_share for fi in financial_line_items if fi.earnings_per_share is not None]\n    if len(eps_values) >= 2:\n        latest_eps = eps_values[0]\n        oldest_eps = eps_values[-1]\n        num_years = len(eps_values) - 1\n        if oldest_eps > 0 and latest_eps > 0:\n            # CAGR formula for EPS\n            eps_growth = (latest_eps / oldest_eps) ** (1 / num_years) - 1\n            if eps_growth > 0.20:  # 20% annualized\n                raw_score += 3\n                details.append(f\"Very strong annualized EPS growth: {eps_growth:.1%}\")\n            elif eps_growth > 0.10:  # 10% annualized\n                raw_score += 2\n                details.append(f\"Moderate annualized EPS growth: {eps_growth:.1%}\")\n            elif eps_growth > 0.03:  # 3% annualized\n                raw_score += 1\n                details.append(f\"Slight annualized EPS growth: {eps_growth:.1%}\")\n            else:\n                details.append(f\"Minimal or negative annualized EPS growth: {eps_growth:.1%}\")\n        else:\n            details.append(\"Oldest EPS near zero; skipping EPS growth calculation.\")\n    else:\n        details.append(\"Not enough EPS data points for growth calculation.\")\n\n    # 3. R&D as % of Revenue (if we have R&D data)\n    rnd_values = [fi.research_and_development for fi in financial_line_items if fi.research_and_development is not None]\n    if rnd_values and revenues and len(rnd_values) == len(revenues):\n        # We'll just look at the most recent for a simple measure\n        recent_rnd = rnd_values[0]\n        recent_rev = revenues[0] if revenues[0] else 1e-9\n        rnd_ratio = recent_rnd / recent_rev\n        # Generally, Fisher admired companies that invest aggressively in R&D,\n        # but it must be appropriate. We'll assume \"3%-15%\" is healthy, just as an example.\n        if 0.03 <= rnd_ratio <= 0.15:\n            raw_score += 3\n            details.append(f\"R&D ratio {rnd_ratio:.1%} indicates significant investment in future growth\")\n        elif rnd_ratio > 0.15:\n            raw_score += 2\n            details.append(f\"R&D ratio {rnd_ratio:.1%} is very high (could be good if well-managed)\")\n        elif rnd_ratio > 0.0:\n            raw_score += 1\n            details.append(f\"R&D ratio {rnd_ratio:.1%} is somewhat low but still positive\")\n        else:\n            details.append(\"No meaningful R&D expense ratio\")\n    else:\n        details.append(\"Insufficient R&D data to evaluate\")\n\n    # scale raw_score (max 9) to 0–10\n    final_score = min(10, (raw_score / 9) * 10)\n    return {\"score\": final_score, \"details\": \"; \".join(details)}\n\n\ndef analyze_margins_stability(financial_line_items: list) -> dict:\n    \"\"\"\n    Looks at margin consistency (gross/operating margin) and general stability over time.\n    \"\"\"\n    if not financial_line_items or len(financial_line_items) < 2:\n        return {\n            \"score\": 0,\n            \"details\": \"Insufficient data for margin stability analysis\",\n        }\n\n    details = []\n    raw_score = 0  # up to 6 => scale to 0-10\n\n    # 1. Operating Margin Consistency\n    op_margins = [fi.operating_margin for fi in financial_line_items if fi.operating_margin is not None]\n    if len(op_margins) >= 2:\n        # Check if margins are stable or improving (comparing oldest to newest)\n        oldest_op_margin = op_margins[-1]\n        newest_op_margin = op_margins[0]\n        if newest_op_margin >= oldest_op_margin > 0:\n            raw_score += 2\n            details.append(f\"Operating margin stable or improving ({oldest_op_margin:.1%} -> {newest_op_margin:.1%})\")\n        elif newest_op_margin > 0:\n            raw_score += 1\n            details.append(f\"Operating margin positive but slightly declined\")\n        else:\n            details.append(f\"Operating margin may be negative or uncertain\")\n    else:\n        details.append(\"Not enough operating margin data points\")\n\n    # 2. Gross Margin Level\n    gm_values = [fi.gross_margin for fi in financial_line_items if fi.gross_margin is not None]\n    if gm_values:\n        # We'll just take the most recent\n        recent_gm = gm_values[0]\n        if recent_gm > 0.5:\n            raw_score += 2\n            details.append(f\"Strong gross margin: {recent_gm:.1%}\")\n        elif recent_gm > 0.3:\n            raw_score += 1\n            details.append(f\"Moderate gross margin: {recent_gm:.1%}\")\n        else:\n            details.append(f\"Low gross margin: {recent_gm:.1%}\")\n    else:\n        details.append(\"No gross margin data available\")\n\n    # 3. Multi-year Margin Stability\n    #   e.g. if we have at least 3 data points, see if standard deviation is low.\n    if len(op_margins) >= 3:\n        stdev = statistics.pstdev(op_margins)\n        if stdev < 0.02:\n            raw_score += 2\n            details.append(\"Operating margin extremely stable over multiple years\")\n        elif stdev < 0.05:\n            raw_score += 1\n            details.append(\"Operating margin reasonably stable\")\n        else:\n            details.append(\"Operating margin volatility is high\")\n    else:\n        details.append(\"Not enough margin data points for volatility check\")\n\n    # scale raw_score (max 6) to 0-10\n    final_score = min(10, (raw_score / 6) * 10)\n    return {\"score\": final_score, \"details\": \"; \".join(details)}\n\n\ndef analyze_management_efficiency_leverage(financial_line_items: list) -> dict:\n    \"\"\"\n    Evaluate management efficiency & leverage:\n      - Return on Equity (ROE)\n      - Debt-to-Equity ratio\n      - Possibly check if free cash flow is consistently positive\n    \"\"\"\n    if not financial_line_items:\n        return {\n            \"score\": 0,\n            \"details\": \"No financial data for management efficiency analysis\",\n        }\n\n    details = []\n    raw_score = 0  # up to 6 => scale to 0–10\n\n    # 1. Return on Equity (ROE)\n    ni_values = [fi.net_income for fi in financial_line_items if fi.net_income is not None]\n    eq_values = [fi.shareholders_equity for fi in financial_line_items if fi.shareholders_equity is not None]\n    if ni_values and eq_values and len(ni_values) == len(eq_values):\n        recent_ni = ni_values[0]\n        recent_eq = eq_values[0] if eq_values[0] else 1e-9\n        if recent_ni > 0:\n            roe = recent_ni / recent_eq\n            if roe > 0.2:\n                raw_score += 3\n                details.append(f\"High ROE: {roe:.1%}\")\n            elif roe > 0.1:\n                raw_score += 2\n                details.append(f\"Moderate ROE: {roe:.1%}\")\n            elif roe > 0:\n                raw_score += 1\n                details.append(f\"Positive but low ROE: {roe:.1%}\")\n            else:\n                details.append(f\"ROE is near zero or negative: {roe:.1%}\")\n        else:\n            details.append(\"Recent net income is zero or negative, hurting ROE\")\n    else:\n        details.append(\"Insufficient data for ROE calculation\")\n\n    # 2. Debt-to-Equity\n    debt_values = [fi.total_debt for fi in financial_line_items if fi.total_debt is not None]\n    if debt_values and eq_values and len(debt_values) == len(eq_values):\n        recent_debt = debt_values[0]\n        recent_equity = eq_values[0] if eq_values[0] else 1e-9\n        dte = recent_debt / recent_equity\n        if dte < 0.3:\n            raw_score += 2\n            details.append(f\"Low debt-to-equity: {dte:.2f}\")\n        elif dte < 1.0:\n            raw_score += 1\n            details.append(f\"Manageable debt-to-equity: {dte:.2f}\")\n        else:\n            details.append(f\"High debt-to-equity: {dte:.2f}\")\n    else:\n        details.append(\"Insufficient data for debt/equity analysis\")\n\n    # 3. FCF Consistency\n    fcf_values = [fi.free_cash_flow for fi in financial_line_items if fi.free_cash_flow is not None]\n    if fcf_values and len(fcf_values) >= 2:\n        # Check if FCF is positive in recent years\n        positive_fcf_count = sum(1 for x in fcf_values if x and x > 0)\n        # We'll be simplistic: if most are positive, reward\n        ratio = positive_fcf_count / len(fcf_values)\n        if ratio > 0.8:\n            raw_score += 1\n            details.append(f\"Majority of periods have positive FCF ({positive_fcf_count}/{len(fcf_values)})\")\n        else:\n            details.append(f\"Free cash flow is inconsistent or often negative\")\n    else:\n        details.append(\"Insufficient or no FCF data to check consistency\")\n\n    final_score = min(10, (raw_score / 6) * 10)\n    return {\"score\": final_score, \"details\": \"; \".join(details)}\n\n\ndef analyze_fisher_valuation(financial_line_items: list, market_cap: float | None) -> dict:\n    \"\"\"\n    Phil Fisher is willing to pay for quality and growth, but still checks:\n      - P/E\n      - P/FCF\n      - (Optionally) Enterprise Value metrics, but simpler approach is typical\n    We will grant up to 2 points for each of two metrics => max 4 raw => scale to 0–10.\n    \"\"\"\n    if not financial_line_items or market_cap is None:\n        return {\"score\": 0, \"details\": \"Insufficient data to perform valuation\"}\n\n    details = []\n    raw_score = 0\n\n    # Gather needed data\n    net_incomes = [fi.net_income for fi in financial_line_items if fi.net_income is not None]\n    fcf_values = [fi.free_cash_flow for fi in financial_line_items if fi.free_cash_flow is not None]\n\n    # 1) P/E\n    recent_net_income = net_incomes[0] if net_incomes else None\n    if recent_net_income and recent_net_income > 0:\n        pe = market_cap / recent_net_income\n        pe_points = 0\n        if pe < 20:\n            pe_points = 2\n            details.append(f\"Reasonably attractive P/E: {pe:.2f}\")\n        elif pe < 30:\n            pe_points = 1\n            details.append(f\"Somewhat high but possibly justifiable P/E: {pe:.2f}\")\n        else:\n            details.append(f\"Very high P/E: {pe:.2f}\")\n        raw_score += pe_points\n    else:\n        details.append(\"No positive net income for P/E calculation\")\n\n    # 2) P/FCF\n    recent_fcf = fcf_values[0] if fcf_values else None\n    if recent_fcf and recent_fcf > 0:\n        pfcf = market_cap / recent_fcf\n        pfcf_points = 0\n        if pfcf < 20:\n            pfcf_points = 2\n            details.append(f\"Reasonable P/FCF: {pfcf:.2f}\")\n        elif pfcf < 30:\n            pfcf_points = 1\n            details.append(f\"Somewhat high P/FCF: {pfcf:.2f}\")\n        else:\n            details.append(f\"Excessively high P/FCF: {pfcf:.2f}\")\n        raw_score += pfcf_points\n    else:\n        details.append(\"No positive free cash flow for P/FCF calculation\")\n\n    # scale raw_score (max 4) to 0–10\n    final_score = min(10, (raw_score / 4) * 10)\n    return {\"score\": final_score, \"details\": \"; \".join(details)}\n\n\ndef analyze_insider_activity(insider_trades: list) -> dict:\n    \"\"\"\n    Simple insider-trade analysis:\n      - If there's heavy insider buying, we nudge the score up.\n      - If there's mostly selling, we reduce it.\n      - Otherwise, neutral.\n    \"\"\"\n    # Default is neutral (5/10).\n    score = 5\n    details = []\n\n    if not insider_trades:\n        details.append(\"No insider trades data; defaulting to neutral\")\n        return {\"score\": score, \"details\": \"; \".join(details)}\n\n    buys, sells = 0, 0\n    for trade in insider_trades:\n        if trade.transaction_shares is not None:\n            if trade.transaction_shares > 0:\n                buys += 1\n            elif trade.transaction_shares < 0:\n                sells += 1\n\n    total = buys + sells\n    if total == 0:\n        details.append(\"No buy/sell transactions found; neutral\")\n        return {\"score\": score, \"details\": \"; \".join(details)}\n\n    buy_ratio = buys / total\n    if buy_ratio > 0.7:\n        score = 8\n        details.append(f\"Heavy insider buying: {buys} buys vs. {sells} sells\")\n    elif buy_ratio > 0.4:\n        score = 6\n        details.append(f\"Moderate insider buying: {buys} buys vs. {sells} sells\")\n    else:\n        score = 4\n        details.append(f\"Mostly insider selling: {buys} buys vs. {sells} sells\")\n\n    return {\"score\": score, \"details\": \"; \".join(details)}\n\n\ndef analyze_sentiment(news_items: list) -> dict:\n    \"\"\"\n    Basic news sentiment: negative keyword check vs. overall volume.\n    \"\"\"\n    if not news_items:\n        return {\"score\": 5, \"details\": \"No news data; defaulting to neutral sentiment\"}\n\n    negative_keywords = [\"lawsuit\", \"fraud\", \"negative\", \"downturn\", \"decline\", \"investigation\", \"recall\"]\n    negative_count = 0\n    for news in news_items:\n        title_lower = (news.title or \"\").lower()\n        if any(word in title_lower for word in negative_keywords):\n            negative_count += 1\n\n    details = []\n    if negative_count > len(news_items) * 0.3:\n        score = 3\n        details.append(f\"High proportion of negative headlines: {negative_count}/{len(news_items)}\")\n    elif negative_count > 0:\n        score = 6\n        details.append(f\"Some negative headlines: {negative_count}/{len(news_items)}\")\n    else:\n        score = 8\n        details.append(\"Mostly positive/neutral headlines\")\n\n    return {\"score\": score, \"details\": \"; \".join(details)}\n\n\ndef generate_fisher_output(\n    ticker: str,\n    analysis_data: dict[str, any],\n    state: AgentState,\n    agent_id: str,\n) -> PhilFisherSignal:\n    \"\"\"\n    Generates a JSON signal in the style of Phil Fisher.\n    \"\"\"\n    template = ChatPromptTemplate.from_messages(\n        [\n            (\n              \"system\",\n              \"\"\"You are a Phil Fisher AI agent, making investment decisions using his principles:\n  \n              1. Emphasize long-term growth potential and quality of management.\n              2. Focus on companies investing in R&D for future products/services.\n              3. Look for strong profitability and consistent margins.\n              4. Willing to pay more for exceptional companies but still mindful of valuation.\n              5. Rely on thorough research (scuttlebutt) and thorough fundamental checks.\n              \n              When providing your reasoning, be thorough and specific by:\n              1. Discussing the company's growth prospects in detail with specific metrics and trends\n              2. Evaluating management quality and their capital allocation decisions\n              3. Highlighting R&D investments and product pipeline that could drive future growth\n              4. Assessing consistency of margins and profitability metrics with precise numbers\n              5. Explaining competitive advantages that could sustain growth over 3-5+ years\n              6. Using Phil Fisher's methodical, growth-focused, and long-term oriented voice\n              \n              For example, if bullish: \"This company exhibits the sustained growth characteristics we seek, with revenue increasing at 18% annually over five years. Management has demonstrated exceptional foresight by allocating 15% of revenue to R&D, which has produced three promising new product lines. The consistent operating margins of 22-24% indicate pricing power and operational efficiency that should continue to...\"\n              \n              For example, if bearish: \"Despite operating in a growing industry, management has failed to translate R&D investments (only 5% of revenue) into meaningful new products. Margins have fluctuated between 10-15%, showing inconsistent operational execution. The company faces increasing competition from three larger competitors with superior distribution networks. Given these concerns about long-term growth sustainability...\"\n              \n              You must output a JSON object with:\n                - \"signal\": \"bullish\" or \"bearish\" or \"neutral\"\n                - \"confidence\": a float between 0 and 100\n                - \"reasoning\": a detailed explanation\n              \"\"\",\n            ),\n            (\n              \"human\",\n              \"\"\"Based on the following analysis, create a Phil Fisher-style investment signal.\n\n              Analysis Data for {ticker}:\n              {analysis_data}\n\n              Return the trading signal in this JSON format:\n              {{\n                \"signal\": \"bullish/bearish/neutral\",\n                \"confidence\": float (0-100),\n                \"reasoning\": \"string\"\n              }}\n              \"\"\",\n            ),\n        ]\n    )\n\n    prompt = template.invoke({\"analysis_data\": json.dumps(analysis_data, indent=2), \"ticker\": ticker})\n\n    def create_default_signal():\n        return PhilFisherSignal(\n            signal=\"neutral\",\n            confidence=0.0,\n            reasoning=\"Error in analysis, defaulting to neutral\"\n        )\n\n    return call_llm(\n        prompt=prompt,\n        pydantic_model=PhilFisherSignal,\n        state=state,\n        agent_name=agent_id,\n        default_factory=create_default_signal,\n    )\n"
  },
  {
    "path": "src/agents/portfolio_manager.py",
    "content": "import json\nimport time\nfrom langchain_core.messages import HumanMessage\nfrom langchain_core.prompts import ChatPromptTemplate\n\nfrom src.graph.state import AgentState, show_agent_reasoning\nfrom pydantic import BaseModel, Field\nfrom typing_extensions import Literal\nfrom src.utils.progress import progress\nfrom src.utils.llm import call_llm\n\n\nclass PortfolioDecision(BaseModel):\n    action: Literal[\"buy\", \"sell\", \"short\", \"cover\", \"hold\"]\n    quantity: int = Field(description=\"Number of shares to trade\")\n    confidence: int = Field(description=\"Confidence 0-100\")\n    reasoning: str = Field(description=\"Reasoning for the decision\")\n\n\nclass PortfolioManagerOutput(BaseModel):\n    decisions: dict[str, PortfolioDecision] = Field(description=\"Dictionary of ticker to trading decisions\")\n\n\n##### Portfolio Management Agent #####\ndef portfolio_management_agent(state: AgentState, agent_id: str = \"portfolio_manager\"):\n    \"\"\"Makes final trading decisions and generates orders for multiple tickers\"\"\"\n\n    portfolio = state[\"data\"][\"portfolio\"]\n    analyst_signals = state[\"data\"][\"analyst_signals\"]\n    tickers = state[\"data\"][\"tickers\"]\n\n    position_limits = {}\n    current_prices = {}\n    max_shares = {}\n    signals_by_ticker = {}\n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Processing analyst signals\")\n\n        # Find the corresponding risk manager for this portfolio manager\n        if agent_id.startswith(\"portfolio_manager_\"):\n            suffix = agent_id.split('_')[-1]\n            risk_manager_id = f\"risk_management_agent_{suffix}\"\n        else:\n            risk_manager_id = \"risk_management_agent\"  # Fallback for CLI\n\n        risk_data = analyst_signals.get(risk_manager_id, {}).get(ticker, {})\n        position_limits[ticker] = risk_data.get(\"remaining_position_limit\", 0.0)\n        current_prices[ticker] = float(risk_data.get(\"current_price\", 0.0))\n\n        # Calculate maximum shares allowed based on position limit and price\n        if current_prices[ticker] > 0:\n            max_shares[ticker] = int(position_limits[ticker] // current_prices[ticker])\n        else:\n            max_shares[ticker] = 0\n\n        # Compress analyst signals to {sig, conf}\n        ticker_signals = {}\n        for agent, signals in analyst_signals.items():\n            if not agent.startswith(\"risk_management_agent\") and ticker in signals:\n                sig = signals[ticker].get(\"signal\")\n                conf = signals[ticker].get(\"confidence\")\n                if sig is not None and conf is not None:\n                    ticker_signals[agent] = {\"sig\": sig, \"conf\": conf}\n        signals_by_ticker[ticker] = ticker_signals\n\n    state[\"data\"][\"current_prices\"] = current_prices\n\n    progress.update_status(agent_id, None, \"Generating trading decisions\")\n\n    result = generate_trading_decision(\n        tickers=tickers,\n        signals_by_ticker=signals_by_ticker,\n        current_prices=current_prices,\n        max_shares=max_shares,\n        portfolio=portfolio,\n        agent_id=agent_id,\n        state=state,\n    )\n    message = HumanMessage(\n        content=json.dumps({ticker: decision.model_dump() for ticker, decision in result.decisions.items()}),\n        name=agent_id,\n    )\n\n    if state[\"metadata\"][\"show_reasoning\"]:\n        show_agent_reasoning({ticker: decision.model_dump() for ticker, decision in result.decisions.items()},\n                             \"Portfolio Manager\")\n\n    progress.update_status(agent_id, None, \"Done\")\n\n    return {\n        \"messages\": state[\"messages\"] + [message],\n        \"data\": state[\"data\"],\n    }\n\n\ndef compute_allowed_actions(\n        tickers: list[str],\n        current_prices: dict[str, float],\n        max_shares: dict[str, int],\n        portfolio: dict[str, float],\n) -> dict[str, dict[str, int]]:\n    \"\"\"Compute allowed actions and max quantities for each ticker deterministically.\"\"\"\n    allowed = {}\n    cash = float(portfolio.get(\"cash\", 0.0))\n    positions = portfolio.get(\"positions\", {}) or {}\n    margin_requirement = float(portfolio.get(\"margin_requirement\", 0.5))\n    margin_used = float(portfolio.get(\"margin_used\", 0.0))\n    equity = float(portfolio.get(\"equity\", cash))\n\n    for ticker in tickers:\n        price = float(current_prices.get(ticker, 0.0))\n        pos = positions.get(\n            ticker,\n            {\"long\": 0, \"long_cost_basis\": 0.0, \"short\": 0, \"short_cost_basis\": 0.0},\n        )\n        long_shares = int(pos.get(\"long\", 0) or 0)\n        short_shares = int(pos.get(\"short\", 0) or 0)\n        max_qty = int(max_shares.get(ticker, 0) or 0)\n\n        # Start with zeros\n        actions = {\"buy\": 0, \"sell\": 0, \"short\": 0, \"cover\": 0, \"hold\": 0}\n\n        # Long side\n        if long_shares > 0:\n            actions[\"sell\"] = long_shares\n        if cash > 0 and price > 0:\n            max_buy_cash = int(cash // price)\n            max_buy = max(0, min(max_qty, max_buy_cash))\n            if max_buy > 0:\n                actions[\"buy\"] = max_buy\n\n        # Short side\n        if short_shares > 0:\n            actions[\"cover\"] = short_shares\n        if price > 0 and max_qty > 0:\n            if margin_requirement <= 0.0:\n                # If margin requirement is zero or unset, only cap by max_qty\n                max_short = max_qty\n            else:\n                available_margin = max(0.0, (equity / margin_requirement) - margin_used)\n                max_short_margin = int(available_margin // price)\n                max_short = max(0, min(max_qty, max_short_margin))\n            if max_short > 0:\n                actions[\"short\"] = max_short\n\n        # Hold always valid\n        actions[\"hold\"] = 0\n\n        # Prune zero-capacity actions to reduce tokens, keep hold\n        pruned = {\"hold\": 0}\n        for k, v in actions.items():\n            if k != \"hold\" and v > 0:\n                pruned[k] = v\n\n        allowed[ticker] = pruned\n\n    return allowed\n\n\ndef _compact_signals(signals_by_ticker: dict[str, dict]) -> dict[str, dict]:\n    \"\"\"Keep only {agent: {sig, conf}} and drop empty agents.\"\"\"\n    out = {}\n    for t, agents in signals_by_ticker.items():\n        if not agents:\n            out[t] = {}\n            continue\n        compact = {}\n        for agent, payload in agents.items():\n            sig = payload.get(\"sig\") or payload.get(\"signal\")\n            conf = payload.get(\"conf\") if \"conf\" in payload else payload.get(\"confidence\")\n            if sig is not None and conf is not None:\n                compact[agent] = {\"sig\": sig, \"conf\": conf}\n        out[t] = compact\n    return out\n\n\ndef generate_trading_decision(\n        tickers: list[str],\n        signals_by_ticker: dict[str, dict],\n        current_prices: dict[str, float],\n        max_shares: dict[str, int],\n        portfolio: dict[str, float],\n        agent_id: str,\n        state: AgentState,\n) -> PortfolioManagerOutput:\n    \"\"\"Get decisions from the LLM with deterministic constraints and a minimal prompt.\"\"\"\n\n    # Deterministic constraints\n    allowed_actions_full = compute_allowed_actions(tickers, current_prices, max_shares, portfolio)\n\n    # Pre-fill pure holds to avoid sending them to the LLM at all\n    prefilled_decisions: dict[str, PortfolioDecision] = {}\n    tickers_for_llm: list[str] = []\n    for t in tickers:\n        aa = allowed_actions_full.get(t, {\"hold\": 0})\n        # If only 'hold' key exists, there is no trade possible\n        if set(aa.keys()) == {\"hold\"}:\n            prefilled_decisions[t] = PortfolioDecision(\n                action=\"hold\", quantity=0, confidence=100.0, reasoning=\"No valid trade available\"\n            )\n        else:\n            tickers_for_llm.append(t)\n\n    if not tickers_for_llm:\n        return PortfolioManagerOutput(decisions=prefilled_decisions)\n\n    # Build compact payloads only for tickers sent to LLM\n    compact_signals = _compact_signals({t: signals_by_ticker.get(t, {}) for t in tickers_for_llm})\n    compact_allowed = {t: allowed_actions_full[t] for t in tickers_for_llm}\n\n    # Minimal prompt template\n    template = ChatPromptTemplate.from_messages(\n        [\n            (\n                \"system\",\n                \"You are a portfolio manager.\\n\"\n                \"Inputs per ticker: analyst signals and allowed actions with max qty (already validated).\\n\"\n                \"Pick one allowed action per ticker and a quantity ≤ the max. \"\n                \"Keep reasoning very concise (max 100 chars). No cash or margin math. Return JSON only.\"\n            ),\n            (\n                \"human\",\n                \"Signals:\\n{signals}\\n\\n\"\n                \"Allowed:\\n{allowed}\\n\\n\"\n                \"Format:\\n\"\n                \"{{\\n\"\n                '  \"decisions\": {{\\n'\n                '    \"TICKER\": {{\"action\":\"...\",\"quantity\":int,\"confidence\":int,\"reasoning\":\"...\"}}\\n'\n                \"  }}\\n\"\n                \"}}\"\n            ),\n        ]\n    )\n\n    prompt_data = {\n        \"signals\": json.dumps(compact_signals, separators=(\",\", \":\"), ensure_ascii=False),\n        \"allowed\": json.dumps(compact_allowed, separators=(\",\", \":\"), ensure_ascii=False),\n    }\n    prompt = template.invoke(prompt_data)\n\n    # Default factory fills remaining tickers as hold if the LLM fails\n    def create_default_portfolio_output():\n        # start from prefilled\n        decisions = dict(prefilled_decisions)\n        for t in tickers_for_llm:\n            decisions[t] = PortfolioDecision(\n                action=\"hold\", quantity=0, confidence=0.0, reasoning=\"Default decision: hold\"\n            )\n        return PortfolioManagerOutput(decisions=decisions)\n\n    llm_out = call_llm(\n        prompt=prompt,\n        pydantic_model=PortfolioManagerOutput,\n        agent_name=agent_id,\n        state=state,\n        default_factory=create_default_portfolio_output,\n    )\n\n    # Merge prefilled holds with LLM results\n    merged = dict(prefilled_decisions)\n    merged.update(llm_out.decisions)\n    return PortfolioManagerOutput(decisions=merged)\n"
  },
  {
    "path": "src/agents/rakesh_jhunjhunwala.py",
    "content": "from src.graph.state import AgentState, show_agent_reasoning\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_core.messages import HumanMessage\nfrom pydantic import BaseModel\nimport json\nfrom typing_extensions import Literal\nfrom src.tools.api import get_financial_metrics, get_market_cap, search_line_items\nfrom src.utils.llm import call_llm\nfrom src.utils.progress import progress\nfrom src.utils.api_key import get_api_key_from_state\n\nclass RakeshJhunjhunwalaSignal(BaseModel):\n    signal: Literal[\"bullish\", \"bearish\", \"neutral\"]\n    confidence: float\n    reasoning: str\n\ndef rakesh_jhunjhunwala_agent(state: AgentState, agent_id: str = \"rakesh_jhunjhunwala_agent\"):\n    \"\"\"Analyzes stocks using Rakesh Jhunjhunwala's principles and LLM reasoning.\"\"\"\n    data = state[\"data\"]\n    end_date = data[\"end_date\"]\n    tickers = data[\"tickers\"]\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    # Collect all analysis for LLM reasoning\n    analysis_data = {}\n    jhunjhunwala_analysis = {}\n\n    for ticker in tickers:\n\n        # Core Data\n        progress.update_status(agent_id, ticker, \"Fetching financial metrics\")\n        metrics = get_financial_metrics(ticker, end_date, period=\"ttm\", limit=5, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Fetching financial line items\")\n        financial_line_items = search_line_items(\n            ticker,\n            [\n                \"net_income\",\n                \"earnings_per_share\",\n                \"ebit\",\n                \"operating_income\",\n                \"revenue\",\n                \"operating_margin\",\n                \"total_assets\",\n                \"total_liabilities\",\n                \"current_assets\",\n                \"current_liabilities\",\n                \"free_cash_flow\",\n                \"dividends_and_other_cash_distributions\",\n                \"issuance_or_purchase_of_equity_shares\"\n            ],\n            end_date,\n            api_key=api_key,\n        )\n\n        progress.update_status(agent_id, ticker, \"Getting market cap\")\n        market_cap = get_market_cap(ticker, end_date, api_key=api_key)\n\n        # ─── Analyses ───────────────────────────────────────────────────────────\n        progress.update_status(agent_id, ticker, \"Analyzing growth\")\n        growth_analysis = analyze_growth(financial_line_items)\n\n        progress.update_status(agent_id, ticker, \"Analyzing profitability\")\n        profitability_analysis = analyze_profitability(financial_line_items)\n        \n        progress.update_status(agent_id, ticker, \"Analyzing balance sheet\")\n        balancesheet_analysis = analyze_balance_sheet(financial_line_items)\n        \n        progress.update_status(agent_id, ticker, \"Analyzing cash flow\")\n        cashflow_analysis = analyze_cash_flow(financial_line_items)\n        \n        progress.update_status(agent_id, ticker, \"Analyzing management actions\")\n        management_analysis = analyze_management_actions(financial_line_items)\n        \n        progress.update_status(agent_id, ticker, \"Calculating intrinsic value\")\n        # Calculate intrinsic value once\n        intrinsic_value = calculate_intrinsic_value(financial_line_items, market_cap)\n\n        # ─── Score & margin of safety ──────────────────────────────────────────\n        total_score = (\n            growth_analysis[\"score\"]\n            + profitability_analysis[\"score\"]\n            + balancesheet_analysis[\"score\"]\n            + cashflow_analysis[\"score\"]\n            + management_analysis[\"score\"]\n        )\n        # Fixed: Correct max_score calculation based on actual scoring breakdown\n        max_score = 24  # 8(prof) + 7(growth) + 4(bs) + 3(cf) + 2(mgmt) = 24\n\n        # Calculate margin of safety\n        margin_of_safety = (\n            (intrinsic_value - market_cap) / market_cap if intrinsic_value and market_cap else None\n        )\n\n        # Jhunjhunwala's decision rules (30% minimum margin of safety for conviction)\n        if margin_of_safety is not None and margin_of_safety >= 0.30:\n            signal = \"bullish\"\n        elif margin_of_safety is not None and margin_of_safety <= -0.30:\n            signal = \"bearish\"\n        else:\n            # Use quality score as tie-breaker for neutral cases\n            quality_score = assess_quality_metrics(financial_line_items)\n            if quality_score >= 0.7 and total_score >= max_score * 0.6:\n                signal = \"bullish\"  # High quality company at fair price\n            elif quality_score <= 0.4 or total_score <= max_score * 0.3:\n                signal = \"bearish\"  # Poor quality or fundamentals\n            else:\n                signal = \"neutral\"\n\n        # Confidence based on margin of safety and quality\n        if margin_of_safety is not None:\n            confidence = min(max(abs(margin_of_safety) * 150, 20), 95)  # 20-95% range\n        else:\n            confidence = min(max((total_score / max_score) * 100, 10), 80)  # Based on score\n\n        # Create comprehensive analysis summary\n        intrinsic_value_analysis = analyze_rakesh_jhunjhunwala_style(\n            financial_line_items, \n            intrinsic_value=intrinsic_value,\n            current_price=market_cap\n        )\n\n        analysis_data[ticker] = {\n            \"signal\": signal,\n            \"score\": total_score,\n            \"max_score\": max_score,\n            \"margin_of_safety\": margin_of_safety,\n            \"growth_analysis\": growth_analysis,\n            \"profitability_analysis\": profitability_analysis,\n            \"balancesheet_analysis\": balancesheet_analysis,\n            \"cashflow_analysis\": cashflow_analysis,\n            \"management_analysis\": management_analysis,\n            \"intrinsic_value_analysis\": intrinsic_value_analysis,\n            \"intrinsic_value\": intrinsic_value,\n            \"market_cap\": market_cap,\n        }\n\n        # ─── LLM: craft Jhunjhunwala‑style narrative ──────────────────────────────\n        progress.update_status(agent_id, ticker, \"Generating Jhunjhunwala analysis\")\n        jhunjhunwala_output = generate_jhunjhunwala_output(\n            ticker=ticker,\n            analysis_data=analysis_data[ticker],\n            state=state,\n            agent_id=agent_id,\n        )\n\n        jhunjhunwala_analysis[ticker] = jhunjhunwala_output.model_dump()\n\n        progress.update_status(agent_id, ticker, \"Done\", analysis=jhunjhunwala_output.reasoning)\n\n    # ─── Push message back to graph state ──────────────────────────────────────\n    message = HumanMessage(content=json.dumps(jhunjhunwala_analysis), name=agent_id)\n\n    if state[\"metadata\"][\"show_reasoning\"]:\n        show_agent_reasoning(jhunjhunwala_analysis, \"Rakesh Jhunjhunwala Agent\")\n\n    state[\"data\"][\"analyst_signals\"][agent_id] = jhunjhunwala_analysis\n    progress.update_status(agent_id, None, \"Done\")\n\n    return {\"messages\": [message], \"data\": state[\"data\"]}\n\n\ndef analyze_profitability(financial_line_items: list) -> dict[str, any]:\n    \"\"\"\n    Analyze profitability metrics like net income, EBIT, EPS, operating income.\n    Focus on strong, consistent earnings growth and operating efficiency.\n    \"\"\"\n    if not financial_line_items:\n        return {\"score\": 0, \"details\": \"No profitability data available\"}\n\n    latest = financial_line_items[0]\n    score = 0\n    reasoning = []\n\n    # Calculate ROE (Return on Equity) - Jhunjhunwala's key metric\n    if (getattr(latest, 'net_income', None) and latest.net_income > 0 and\n        getattr(latest, 'total_assets', None) and getattr(latest, 'total_liabilities', None) and \n        latest.total_assets and latest.total_liabilities):\n        \n        shareholders_equity = latest.total_assets - latest.total_liabilities\n        if shareholders_equity > 0:\n            roe = (latest.net_income / shareholders_equity) * 100\n            if roe > 20:  # Excellent ROE\n                score += 3\n                reasoning.append(f\"Excellent ROE: {roe:.1f}%\")\n            elif roe > 15:  # Good ROE\n                score += 2\n                reasoning.append(f\"Good ROE: {roe:.1f}%\")\n            elif roe > 10:  # Decent ROE\n                score += 1\n                reasoning.append(f\"Decent ROE: {roe:.1f}%\")\n            else:\n                reasoning.append(f\"Low ROE: {roe:.1f}%\")\n        else:\n            reasoning.append(\"Negative shareholders equity\")\n    else:\n        reasoning.append(\"Unable to calculate ROE - missing data\")\n\n    # Operating Margin Analysis\n    if (getattr(latest, \"operating_income\", None) and latest.operating_income and \n        getattr(latest, \"revenue\", None) and latest.revenue and latest.revenue > 0):\n        operating_margin = (latest.operating_income / latest.revenue) * 100\n        if operating_margin > 20:  # Excellent margin\n            score += 2\n            reasoning.append(f\"Excellent operating margin: {operating_margin:.1f}%\")\n        elif operating_margin > 15:  # Good margin\n            score += 1\n            reasoning.append(f\"Good operating margin: {operating_margin:.1f}%\")\n        elif operating_margin > 0:\n            reasoning.append(f\"Positive operating margin: {operating_margin:.1f}%\")\n        else:\n            reasoning.append(f\"Negative operating margin: {operating_margin:.1f}%\")\n    else:\n        reasoning.append(\"Unable to calculate operating margin\")\n\n    # EPS Growth Consistency (3-year trend)\n    eps_values = [getattr(item, \"earnings_per_share\", None) for item in financial_line_items \n                  if getattr(item, \"earnings_per_share\", None) is not None and getattr(item, \"earnings_per_share\", None) > 0]\n    \n    if len(eps_values) >= 3:\n        # Calculate CAGR for EPS\n        initial_eps = eps_values[-1]  # Oldest value\n        final_eps = eps_values[0]     # Latest value\n        years = len(eps_values) - 1\n        \n        if initial_eps > 0:\n            eps_cagr = ((final_eps / initial_eps) ** (1/years) - 1) * 100\n            if eps_cagr > 20:  # High growth\n                score += 3\n                reasoning.append(f\"High EPS CAGR: {eps_cagr:.1f}%\")\n            elif eps_cagr > 15:  # Good growth\n                score += 2\n                reasoning.append(f\"Good EPS CAGR: {eps_cagr:.1f}%\")\n            elif eps_cagr > 10:  # Moderate growth\n                score += 1\n                reasoning.append(f\"Moderate EPS CAGR: {eps_cagr:.1f}%\")\n            else:\n                reasoning.append(f\"Low EPS CAGR: {eps_cagr:.1f}%\")\n        else:\n            reasoning.append(\"Cannot calculate EPS growth from negative base\")\n    else:\n        reasoning.append(\"Insufficient EPS data for growth analysis\")\n\n    return {\"score\": score, \"details\": \"; \".join(reasoning)}\n\n\ndef analyze_growth(financial_line_items: list) -> dict[str, any]:\n    \"\"\"\n    Analyze revenue and net income growth trends using CAGR.\n    Jhunjhunwala favored companies with strong, consistent compound growth.\n    \"\"\"\n    if len(financial_line_items) < 3:\n        return {\"score\": 0, \"details\": \"Insufficient data for growth analysis\"}\n\n    score = 0\n    reasoning = []\n\n    # Revenue CAGR Analysis\n    revenues = [getattr(item, \"revenue\", None) for item in financial_line_items \n                if getattr(item, \"revenue\", None) is not None and getattr(item, \"revenue\", None) > 0]\n    \n    if len(revenues) >= 3:\n        initial_revenue = revenues[-1]  # Oldest\n        final_revenue = revenues[0]     # Latest\n        years = len(revenues) - 1\n        \n        if initial_revenue > 0:  # Fixed: Add zero check\n            revenue_cagr = ((final_revenue / initial_revenue) ** (1/years) - 1) * 100\n            \n            if revenue_cagr > 20:  # High growth\n                score += 3\n                reasoning.append(f\"Excellent revenue CAGR: {revenue_cagr:.1f}%\")\n            elif revenue_cagr > 15:  # Good growth\n                score += 2\n                reasoning.append(f\"Good revenue CAGR: {revenue_cagr:.1f}%\")\n            elif revenue_cagr > 10:  # Moderate growth\n                score += 1\n                reasoning.append(f\"Moderate revenue CAGR: {revenue_cagr:.1f}%\")\n            else:\n                reasoning.append(f\"Low revenue CAGR: {revenue_cagr:.1f}%\")\n        else:\n            reasoning.append(\"Cannot calculate revenue CAGR from zero base\")\n    else:\n        reasoning.append(\"Insufficient revenue data for CAGR calculation\")\n\n    # Net Income CAGR Analysis\n    net_incomes = [getattr(item, \"net_income\", None) for item in financial_line_items \n                   if getattr(item, \"net_income\", None) is not None and getattr(item, \"net_income\", None) > 0]\n    \n    if len(net_incomes) >= 3:\n        initial_income = net_incomes[-1]  # Oldest\n        final_income = net_incomes[0]     # Latest\n        years = len(net_incomes) - 1\n        \n        if initial_income > 0:  # Fixed: Add zero check\n            income_cagr = ((final_income / initial_income) ** (1/years) - 1) * 100\n            \n            if income_cagr > 25:  # Very high growth\n                score += 3\n                reasoning.append(f\"Excellent income CAGR: {income_cagr:.1f}%\")\n            elif income_cagr > 20:  # High growth\n                score += 2\n                reasoning.append(f\"High income CAGR: {income_cagr:.1f}%\")\n            elif income_cagr > 15:  # Good growth\n                score += 1\n                reasoning.append(f\"Good income CAGR: {income_cagr:.1f}%\")\n            else:\n                reasoning.append(f\"Moderate income CAGR: {income_cagr:.1f}%\")\n        else:\n            reasoning.append(\"Cannot calculate income CAGR from zero base\")\n    else:\n        reasoning.append(\"Insufficient net income data for CAGR calculation\")\n\n    # Revenue Consistency Check (year-over-year)\n    if len(revenues) >= 3:\n        declining_years = sum(1 for i in range(1, len(revenues)) if revenues[i-1] > revenues[i])\n        consistency_ratio = 1 - (declining_years / (len(revenues) - 1))\n        \n        if consistency_ratio >= 0.8:  # 80% or more years with growth\n            score += 1\n            reasoning.append(f\"Consistent growth pattern ({consistency_ratio*100:.0f}% of years)\")\n        else:\n            reasoning.append(f\"Inconsistent growth pattern ({consistency_ratio*100:.0f}% of years)\")\n\n    return {\"score\": score, \"details\": \"; \".join(reasoning)}\n\n\ndef analyze_balance_sheet(financial_line_items: list) -> dict[str, any]:\n    \"\"\"\n    Check financial strength - healthy asset/liability structure, liquidity.\n    Jhunjhunwala favored companies with clean balance sheets and manageable debt.\n    \"\"\"\n    if not financial_line_items:\n        return {\"score\": 0, \"details\": \"No balance sheet data\"}\n\n    latest = financial_line_items[0]\n    score = 0\n    reasoning = []\n\n    # Debt to asset ratio\n    if (getattr(latest, \"total_assets\", None) and getattr(latest, \"total_liabilities\", None) \n        and latest.total_assets and latest.total_liabilities \n        and latest.total_assets > 0):\n        debt_ratio = latest.total_liabilities / latest.total_assets\n        if debt_ratio < 0.5:\n            score += 2\n            reasoning.append(f\"Low debt ratio: {debt_ratio:.2f}\")\n        elif debt_ratio < 0.7:\n            score += 1\n            reasoning.append(f\"Moderate debt ratio: {debt_ratio:.2f}\")\n        else:\n            reasoning.append(f\"High debt ratio: {debt_ratio:.2f}\")\n    else:\n        reasoning.append(\"Insufficient data to calculate debt ratio\")\n\n    # Current ratio (liquidity)\n    if (getattr(latest, \"current_assets\", None) and getattr(latest, \"current_liabilities\", None) \n        and latest.current_assets and latest.current_liabilities \n        and latest.current_liabilities > 0):\n        current_ratio = latest.current_assets / latest.current_liabilities\n        if current_ratio > 2.0:\n            score += 2\n            reasoning.append(f\"Excellent liquidity with current ratio: {current_ratio:.2f}\")\n        elif current_ratio > 1.5:\n            score += 1\n            reasoning.append(f\"Good liquidity with current ratio: {current_ratio:.2f}\")\n        else:\n            reasoning.append(f\"Weak liquidity with current ratio: {current_ratio:.2f}\")\n    else:\n        reasoning.append(\"Insufficient data to calculate current ratio\")\n\n    return {\"score\": score, \"details\": \"; \".join(reasoning)}\n\n\ndef analyze_cash_flow(financial_line_items: list) -> dict[str, any]:\n    \"\"\"\n    Evaluate free cash flow and dividend behavior.\n    Jhunjhunwala appreciated companies generating strong free cash flow and rewarding shareholders.\n    \"\"\"\n    if not financial_line_items:\n        return {\"score\": 0, \"details\": \"No cash flow data\"}\n\n    latest = financial_line_items[0]\n    score = 0\n    reasoning = []\n\n    # Free cash flow analysis\n    if getattr(latest, \"free_cash_flow\", None) and latest.free_cash_flow:\n        if latest.free_cash_flow > 0:\n            score += 2\n            reasoning.append(f\"Positive free cash flow: {latest.free_cash_flow}\")\n        else:\n            reasoning.append(f\"Negative free cash flow: {latest.free_cash_flow}\")\n    else:\n        reasoning.append(\"Free cash flow data not available\")\n\n    # Dividend analysis\n    if getattr(latest, \"dividends_and_other_cash_distributions\", None) and latest.dividends_and_other_cash_distributions:\n        if latest.dividends_and_other_cash_distributions < 0:  # Negative indicates cash outflow for dividends\n            score += 1\n            reasoning.append(\"Company pays dividends to shareholders\")\n        else:\n            reasoning.append(\"No significant dividend payments\")\n    else:\n        reasoning.append(\"No dividend payment data available\")\n\n    return {\"score\": score, \"details\": \"; \".join(reasoning)}\n\n\ndef analyze_management_actions(financial_line_items: list) -> dict[str, any]:\n    \"\"\"\n    Look at share issuance or buybacks to assess shareholder friendliness.\n    Jhunjhunwala liked managements who buy back shares or avoid dilution.\n    \"\"\"\n    if not financial_line_items:\n        return {\"score\": 0, \"details\": \"No management action data\"}\n\n    latest = financial_line_items[0]\n    score = 0\n    reasoning = []\n\n    issuance = getattr(latest, \"issuance_or_purchase_of_equity_shares\", None)\n    if issuance is not None:\n        if issuance < 0:  # Negative indicates share buybacks\n            score += 2\n            reasoning.append(f\"Company buying back shares: {abs(issuance)}\")\n        elif issuance > 0:\n            reasoning.append(f\"Share issuance detected (potential dilution): {issuance}\")\n        else:\n            score += 1\n            reasoning.append(\"No recent share issuance or buyback\")\n    else:\n        reasoning.append(\"No data on share issuance or buybacks\")\n\n    return {\"score\": score, \"details\": \"; \".join(reasoning)}\n\n\ndef assess_quality_metrics(financial_line_items: list) -> float:\n    \"\"\"\n    Assess company quality based on Jhunjhunwala's criteria.\n    Returns a score between 0 and 1.\n    \"\"\"\n    if not financial_line_items:\n        return 0.5  # Neutral score\n    \n    latest = financial_line_items[0]\n    quality_factors = []\n    \n    # ROE consistency and level\n    if (getattr(latest, 'net_income', None) and getattr(latest, 'total_assets', None) and \n        getattr(latest, 'total_liabilities', None) and latest.total_assets and latest.total_liabilities):\n        \n        shareholders_equity = latest.total_assets - latest.total_liabilities\n        if shareholders_equity > 0 and latest.net_income:\n            roe = latest.net_income / shareholders_equity\n            if roe > 0.20:  # ROE > 20%\n                quality_factors.append(1.0)\n            elif roe > 0.15:  # ROE > 15%\n                quality_factors.append(0.8)\n            elif roe > 0.10:  # ROE > 10%\n                quality_factors.append(0.6)\n            else:\n                quality_factors.append(0.3)\n        else:\n            quality_factors.append(0.0)\n    else:\n        quality_factors.append(0.5)\n    \n    # Debt levels (lower is better)\n    if (getattr(latest, 'total_assets', None) and getattr(latest, 'total_liabilities', None) and \n        latest.total_assets and latest.total_liabilities):\n        debt_ratio = latest.total_liabilities / latest.total_assets\n        if debt_ratio < 0.3:  # Low debt\n            quality_factors.append(1.0)\n        elif debt_ratio < 0.5:  # Moderate debt\n            quality_factors.append(0.7)\n        elif debt_ratio < 0.7:  # High debt\n            quality_factors.append(0.4)\n        else:  # Very high debt\n            quality_factors.append(0.1)\n    else:\n        quality_factors.append(0.5)\n    \n    # Growth consistency\n    net_incomes = [getattr(item, \"net_income\", None) for item in financial_line_items[:4] \n                   if getattr(item, \"net_income\", None) is not None and getattr(item, \"net_income\", None) > 0]\n    \n    if len(net_incomes) >= 3:\n        declining_years = sum(1 for i in range(1, len(net_incomes)) if net_incomes[i-1] > net_incomes[i])\n        consistency = 1 - (declining_years / (len(net_incomes) - 1))\n        quality_factors.append(consistency)\n    else:\n        quality_factors.append(0.5)\n    \n    # Return average quality score\n    return sum(quality_factors) / len(quality_factors) if quality_factors else 0.5\n\n\ndef calculate_intrinsic_value(financial_line_items: list, market_cap: float) -> float:\n    \"\"\"\n    Calculate intrinsic value using Rakesh Jhunjhunwala's approach:\n    - Focus on earnings power and growth\n    - Conservative discount rates\n    - Quality premium for consistent performers\n    \"\"\"\n    if not financial_line_items or not market_cap:\n        return None\n    \n    try:\n        latest = financial_line_items[0]\n        \n        # Need positive earnings as base\n        if not getattr(latest, 'net_income', None) or latest.net_income <= 0:\n            return None\n        \n        # Get historical earnings for growth calculation\n        net_incomes = [getattr(item, \"net_income\", None) for item in financial_line_items[:5] \n                       if getattr(item, \"net_income\", None) is not None and getattr(item, \"net_income\", None) > 0]\n        \n        if len(net_incomes) < 2:\n            # Use current earnings with conservative multiple for stable companies\n            return latest.net_income * 12  # Conservative P/E of 12\n        \n        # Calculate sustainable growth rate using historical data\n        initial_income = net_incomes[-1]  # Oldest\n        final_income = net_incomes[0]     # Latest\n        years = len(net_incomes) - 1\n        \n        # Calculate historical CAGR\n        if initial_income > 0:  # Fixed: Add zero check\n            historical_growth = ((final_income / initial_income) ** (1/years) - 1)\n        else:\n            historical_growth = 0.05  # Default to 5%\n        \n        # Conservative growth assumptions (Jhunjhunwala style)\n        if historical_growth > 0.25:  # Cap at 25% for sustainability\n            sustainable_growth = 0.20  # Conservative 20%\n        elif historical_growth > 0.15:\n            sustainable_growth = historical_growth * 0.8  # 80% of historical\n        elif historical_growth > 0.05:\n            sustainable_growth = historical_growth * 0.9  # 90% of historical\n        else:\n            sustainable_growth = 0.05  # Minimum 5% for inflation\n        \n        # Quality assessment affects discount rate\n        quality_score = assess_quality_metrics(financial_line_items)\n        \n        # Discount rate based on quality (Jhunjhunwala preferred quality)\n        if quality_score >= 0.8:  # High quality\n            discount_rate = 0.12  # 12% for high quality companies\n            terminal_multiple = 18\n        elif quality_score >= 0.6:  # Medium quality\n            discount_rate = 0.15  # 15% for medium quality\n            terminal_multiple = 15\n        else:  # Lower quality\n            discount_rate = 0.18  # 18% for riskier companies\n            terminal_multiple = 12\n        \n        # Simple DCF with terminal value\n        current_earnings = latest.net_income\n        terminal_value = 0\n        dcf_value = 0\n        \n        # Project 5 years of earnings\n        for year in range(1, 6):\n            projected_earnings = current_earnings * ((1 + sustainable_growth) ** year)\n            present_value = projected_earnings / ((1 + discount_rate) ** year)\n            dcf_value += present_value\n        \n        # Terminal value (year 5 earnings * terminal multiple)\n        year_5_earnings = current_earnings * ((1 + sustainable_growth) ** 5)\n        terminal_value = (year_5_earnings * terminal_multiple) / ((1 + discount_rate) ** 5)\n        \n        total_intrinsic_value = dcf_value + terminal_value\n        \n        return total_intrinsic_value\n        \n    except Exception:\n        # Fallback to simple earnings multiple\n        if getattr(latest, 'net_income', None) and latest.net_income > 0:\n            return latest.net_income * 15\n        return None\n\n\ndef analyze_rakesh_jhunjhunwala_style(\n    financial_line_items: list,\n    owner_earnings: float = None,\n    intrinsic_value: float = None,\n    current_price: float = None,\n) -> dict[str, any]:\n    \"\"\"\n    Comprehensive analysis in Rakesh Jhunjhunwala's investment style.\n    \"\"\"\n    # Run sub-analyses\n    profitability = analyze_profitability(financial_line_items)\n    growth = analyze_growth(financial_line_items)\n    balance_sheet = analyze_balance_sheet(financial_line_items)\n    cash_flow = analyze_cash_flow(financial_line_items)\n    management = analyze_management_actions(financial_line_items)\n\n    total_score = (\n        profitability[\"score\"]\n        + growth[\"score\"]\n        + balance_sheet[\"score\"]\n        + cash_flow[\"score\"]\n        + management[\"score\"]\n    )\n\n    details = (\n        f\"Profitability: {profitability['details']}\\n\"\n        f\"Growth: {growth['details']}\\n\"\n        f\"Balance Sheet: {balance_sheet['details']}\\n\"\n        f\"Cash Flow: {cash_flow['details']}\\n\"\n        f\"Management Actions: {management['details']}\"\n    )\n\n    # Use provided intrinsic value or calculate if not provided\n    if not intrinsic_value:\n        intrinsic_value = calculate_intrinsic_value(financial_line_items, current_price)\n\n    valuation_gap = None\n    if intrinsic_value and current_price:\n        valuation_gap = intrinsic_value - current_price\n\n    return {\n        \"total_score\": total_score,\n        \"details\": details,\n        \"owner_earnings\": owner_earnings,\n        \"intrinsic_value\": intrinsic_value,\n        \"current_price\": current_price,\n        \"valuation_gap\": valuation_gap,\n        \"breakdown\": {\n            \"profitability\": profitability,\n            \"growth\": growth,\n            \"balance_sheet\": balance_sheet,\n            \"cash_flow\": cash_flow,\n            \"management\": management,\n        },\n    }\n\n\n# ────────────────────────────────────────────────────────────────────────────────\n# LLM generation\n# ────────────────────────────────────────────────────────────────────────────────\ndef generate_jhunjhunwala_output(\n    ticker: str,\n    analysis_data: dict[str, any],\n    state: AgentState,\n    agent_id: str,\n) -> RakeshJhunjhunwalaSignal:\n    \"\"\"Get investment decision from LLM with Jhunjhunwala's principles\"\"\"\n    template = ChatPromptTemplate.from_messages(\n        [\n            (\n                \"system\",\n                \"\"\"You are a Rakesh Jhunjhunwala AI agent. Decide on investment signals based on Rakesh Jhunjhunwala's principles:\n                - Circle of Competence: Only invest in businesses you understand\n                - Margin of Safety (> 30%): Buy at a significant discount to intrinsic value\n                - Economic Moat: Look for durable competitive advantages\n                - Quality Management: Seek conservative, shareholder-oriented teams\n                - Financial Strength: Favor low debt, strong returns on equity\n                - Long-term Horizon: Invest in businesses, not just stocks\n                - Growth Focus: Look for companies with consistent earnings and revenue growth\n                - Sell only if fundamentals deteriorate or valuation far exceeds intrinsic value\n\n                When providing your reasoning, be thorough and specific by:\n                1. Explaining the key factors that influenced your decision the most (both positive and negative)\n                2. Highlighting how the company aligns with or violates specific Jhunjhunwala principles\n                3. Providing quantitative evidence where relevant (e.g., specific margins, ROE values, debt levels)\n                4. Concluding with a Jhunjhunwala-style assessment of the investment opportunity\n                5. Using Rakesh Jhunjhunwala's voice and conversational style in your explanation\n\n                For example, if bullish: \"I'm particularly impressed with the consistent growth and strong balance sheet, reminiscent of quality companies that create long-term wealth...\"\n                For example, if bearish: \"The deteriorating margins and high debt levels concern me - this doesn't fit the profile of companies that build lasting value...\"\n\n                Follow these guidelines strictly.\n                \"\"\",\n            ),\n            (\n                \"human\",\n                \"\"\"Based on the following data, create the investment signal as Rakesh Jhunjhunwala would:\n\n                Analysis Data for {ticker}:\n                {analysis_data}\n\n                Return the trading signal in the following JSON format exactly:\n                {{\n                  \"signal\": \"bullish\" | \"bearish\" | \"neutral\",\n                  \"confidence\": float between 0 and 100,\n                  \"reasoning\": \"string\"\n                }}\n                \"\"\",\n            ),\n        ]\n    )\n\n    prompt = template.invoke({\"analysis_data\": json.dumps(analysis_data, indent=2), \"ticker\": ticker})\n\n    # Default fallback signal in case parsing fails\n    def create_default_rakesh_jhunjhunwala_signal():\n        return RakeshJhunjhunwalaSignal(signal=\"neutral\", confidence=0.0, reasoning=\"Error in analysis, defaulting to neutral\")\n\n    return call_llm(\n        prompt=prompt,\n        pydantic_model=RakeshJhunjhunwalaSignal,\n        state=state,\n        agent_name=agent_id,\n        default_factory=create_default_rakesh_jhunjhunwala_signal,\n    )"
  },
  {
    "path": "src/agents/risk_manager.py",
    "content": "from langchain_core.messages import HumanMessage\nfrom src.graph.state import AgentState, show_agent_reasoning\nfrom src.utils.progress import progress\nfrom src.tools.api import get_prices, prices_to_df\nimport json\nimport numpy as np\nimport pandas as pd\nfrom src.utils.api_key import get_api_key_from_state\n\n##### Risk Management Agent #####\ndef risk_management_agent(state: AgentState, agent_id: str = \"risk_management_agent\"):\n    \"\"\"Controls position sizing based on volatility-adjusted risk factors for multiple tickers.\"\"\"\n    portfolio = state[\"data\"][\"portfolio\"]\n    data = state[\"data\"]\n    tickers = data[\"tickers\"]\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    \n    # Initialize risk analysis for each ticker\n    risk_analysis = {}\n    current_prices = {}  # Store prices here to avoid redundant API calls\n    volatility_data = {}  # Store volatility metrics\n    returns_by_ticker: dict[str, pd.Series] = {}  # For correlation analysis\n\n    # First, fetch prices and calculate volatility for all relevant tickers\n    all_tickers = set(tickers) | set(portfolio.get(\"positions\", {}).keys())\n    \n    for ticker in all_tickers:\n        progress.update_status(agent_id, ticker, \"Fetching price data and calculating volatility\")\n        \n        prices = get_prices(\n            ticker=ticker,\n            start_date=data[\"start_date\"],\n            end_date=data[\"end_date\"],\n            api_key=api_key,\n        )\n\n        if not prices:\n            progress.update_status(agent_id, ticker, \"Warning: No price data found\")\n            volatility_data[ticker] = {\n                \"daily_volatility\": 0.05,  # Default fallback volatility (5% daily)\n                \"annualized_volatility\": 0.05 * np.sqrt(252),\n                \"volatility_percentile\": 100,  # Assume high risk if no data\n                \"data_points\": 0\n            }\n            continue\n\n        prices_df = prices_to_df(prices)\n        \n        if not prices_df.empty and len(prices_df) > 1:\n            current_price = prices_df[\"close\"].iloc[-1]\n            current_prices[ticker] = current_price\n            \n            # Calculate volatility metrics\n            volatility_metrics = calculate_volatility_metrics(prices_df)\n            volatility_data[ticker] = volatility_metrics\n\n            # Store returns for correlation analysis (use close-to-close returns)\n            daily_returns = prices_df[\"close\"].pct_change().dropna()\n            if len(daily_returns) > 0:\n                returns_by_ticker[ticker] = daily_returns\n            \n            progress.update_status(\n                agent_id, \n                ticker, \n                f\"Price: {current_price:.2f}, Ann. Vol: {volatility_metrics['annualized_volatility']:.1%}\"\n            )\n        else:\n            progress.update_status(agent_id, ticker, \"Warning: Insufficient price data\")\n            current_prices[ticker] = 0\n            volatility_data[ticker] = {\n                \"daily_volatility\": 0.05,\n                \"annualized_volatility\": 0.05 * np.sqrt(252),\n                \"volatility_percentile\": 100,\n                \"data_points\": len(prices_df) if not prices_df.empty else 0\n            }\n\n    # Build returns DataFrame aligned across tickers for correlation analysis\n    correlation_matrix = None\n    if len(returns_by_ticker) >= 2:\n        try:\n            returns_df = pd.DataFrame(returns_by_ticker).dropna(how=\"any\")\n            if returns_df.shape[1] >= 2 and returns_df.shape[0] >= 5:\n                correlation_matrix = returns_df.corr()\n        except Exception:\n            correlation_matrix = None\n\n    # Determine which tickers currently have exposure (non-zero absolute position)\n    active_positions = {\n        t for t, pos in portfolio.get(\"positions\", {}).items()\n        if abs(pos.get(\"long\", 0) - pos.get(\"short\", 0)) > 0\n    }\n\n    # Calculate total portfolio value based on current market prices (Net Liquidation Value)\n    total_portfolio_value = portfolio.get(\"cash\", 0.0)\n    \n    for ticker, position in portfolio.get(\"positions\", {}).items():\n        if ticker in current_prices:\n            # Add market value of long positions\n            total_portfolio_value += position.get(\"long\", 0) * current_prices[ticker]\n            # Subtract market value of short positions\n            total_portfolio_value -= position.get(\"short\", 0) * current_prices[ticker]\n    \n    progress.update_status(agent_id, None, f\"Total portfolio value: {total_portfolio_value:.2f}\")\n\n    # Calculate volatility- and correlation-adjusted risk limits for each ticker\n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Calculating volatility- and correlation-adjusted limits\")\n        \n        if ticker not in current_prices or current_prices[ticker] <= 0:\n            progress.update_status(agent_id, ticker, \"Failed: No valid price data\")\n            risk_analysis[ticker] = {\n                \"remaining_position_limit\": 0.0,\n                \"current_price\": 0.0,\n                \"reasoning\": {\n                    \"error\": \"Missing price data for risk calculation\"\n                }\n            }\n            continue\n            \n        current_price = current_prices[ticker]\n        vol_data = volatility_data.get(ticker, {})\n        \n        # Calculate current market value of this position\n        position = portfolio.get(\"positions\", {}).get(ticker, {})\n        long_value = position.get(\"long\", 0) * current_price\n        short_value = position.get(\"short\", 0) * current_price\n        current_position_value = abs(long_value - short_value)  # Use absolute exposure\n        \n        # Volatility-adjusted limit pct\n        vol_adjusted_limit_pct = calculate_volatility_adjusted_limit(\n            vol_data.get(\"annualized_volatility\", 0.25)\n        )\n\n        # Correlation adjustment\n        corr_metrics = {\n            \"avg_correlation_with_active\": None,\n            \"max_correlation_with_active\": None,\n            \"top_correlated_tickers\": [],\n        }\n        corr_multiplier = 1.0\n        if correlation_matrix is not None and ticker in correlation_matrix.columns:\n            # Compute correlations with active positions (exclude self)\n            comparable = [t for t in active_positions if t in correlation_matrix.columns and t != ticker]\n            if not comparable:\n                # If no active positions, compare with all other available tickers\n                comparable = [t for t in correlation_matrix.columns if t != ticker]\n            if comparable:\n                series = correlation_matrix.loc[ticker, comparable]\n                # Drop NaNs just in case\n                series = series.dropna()\n                if len(series) > 0:\n                    avg_corr = float(series.mean())\n                    max_corr = float(series.max())\n                    corr_metrics[\"avg_correlation_with_active\"] = avg_corr\n                    corr_metrics[\"max_correlation_with_active\"] = max_corr\n                    # Top 3 most correlated tickers\n                    top_corr = series.sort_values(ascending=False).head(3)\n                    corr_metrics[\"top_correlated_tickers\"] = [\n                        {\"ticker\": idx, \"correlation\": float(val)} for idx, val in top_corr.items()\n                    ]\n                    corr_multiplier = calculate_correlation_multiplier(avg_corr)\n        \n        # Combine volatility and correlation adjustments\n        combined_limit_pct = vol_adjusted_limit_pct * corr_multiplier\n        # Convert to dollar position limit\n        position_limit = total_portfolio_value * combined_limit_pct\n        \n        # Calculate remaining limit for this position\n        remaining_position_limit = position_limit - current_position_value\n        \n        # Ensure we don't exceed available cash\n        max_position_size = min(remaining_position_limit, portfolio.get(\"cash\", 0))\n        \n        risk_analysis[ticker] = {\n            \"remaining_position_limit\": float(max_position_size),\n            \"current_price\": float(current_price),\n            \"volatility_metrics\": {\n                \"daily_volatility\": float(vol_data.get(\"daily_volatility\", 0.05)),\n                \"annualized_volatility\": float(vol_data.get(\"annualized_volatility\", 0.25)),\n                \"volatility_percentile\": float(vol_data.get(\"volatility_percentile\", 100)),\n                \"data_points\": int(vol_data.get(\"data_points\", 0))\n            },\n            \"correlation_metrics\": corr_metrics,\n            \"reasoning\": {\n                \"portfolio_value\": float(total_portfolio_value),\n                \"current_position_value\": float(current_position_value),\n                \"base_position_limit_pct\": float(vol_adjusted_limit_pct),\n                \"correlation_multiplier\": float(corr_multiplier),\n                \"combined_position_limit_pct\": float(combined_limit_pct),\n                \"position_limit\": float(position_limit),\n                \"remaining_limit\": float(remaining_position_limit),\n                \"available_cash\": float(portfolio.get(\"cash\", 0)),\n                \"risk_adjustment\": f\"Volatility x Correlation adjusted: {combined_limit_pct:.1%} (base {vol_adjusted_limit_pct:.1%})\"\n            },\n        }\n        \n        progress.update_status(\n            agent_id, \n            ticker, \n            f\"Adj. limit: {combined_limit_pct:.1%}, Available: ${max_position_size:.0f}\"\n        )\n\n    progress.update_status(agent_id, None, \"Done\")\n\n    message = HumanMessage(\n        content=json.dumps(risk_analysis),\n        name=agent_id,\n    )\n\n    if state[\"metadata\"][\"show_reasoning\"]:\n        show_agent_reasoning(risk_analysis, \"Volatility-Adjusted Risk Management Agent\")\n\n    # Add the signal to the analyst_signals list\n    state[\"data\"][\"analyst_signals\"][agent_id] = risk_analysis\n\n    return {\n        \"messages\": state[\"messages\"] + [message],\n        \"data\": data,\n    }\n\n\ndef calculate_volatility_metrics(prices_df: pd.DataFrame, lookback_days: int = 60) -> dict:\n    \"\"\"Calculate comprehensive volatility metrics from price data.\"\"\"\n    if len(prices_df) < 2:\n        return {\n            \"daily_volatility\": 0.05,\n            \"annualized_volatility\": 0.05 * np.sqrt(252),\n            \"volatility_percentile\": 100,\n            \"data_points\": len(prices_df)\n        }\n    \n    # Calculate daily returns\n    daily_returns = prices_df[\"close\"].pct_change().dropna()\n    \n    if len(daily_returns) < 2:\n        return {\n            \"daily_volatility\": 0.05,\n            \"annualized_volatility\": 0.05 * np.sqrt(252),\n            \"volatility_percentile\": 100,\n            \"data_points\": len(daily_returns)\n        }\n    \n    # Use the most recent lookback_days for volatility calculation\n    recent_returns = daily_returns.tail(min(lookback_days, len(daily_returns)))\n    \n    # Calculate volatility metrics\n    daily_vol = recent_returns.std()\n    annualized_vol = daily_vol * np.sqrt(252)  # Annualize assuming 252 trading days\n    \n    # Calculate percentile rank of recent volatility vs historical volatility\n    if len(daily_returns) >= 30:  # Need sufficient history for percentile calculation\n        # Calculate 30-day rolling volatility for the full history\n        rolling_vol = daily_returns.rolling(window=30).std().dropna()\n        if len(rolling_vol) > 0:\n            # Compare current volatility against historical rolling volatilities\n            current_vol_percentile = (rolling_vol <= daily_vol).mean() * 100\n        else:\n            current_vol_percentile = 50  # Default to median\n    else:\n        current_vol_percentile = 50  # Default to median if insufficient data\n    \n    return {\n        \"daily_volatility\": float(daily_vol) if not np.isnan(daily_vol) else 0.025,\n        \"annualized_volatility\": float(annualized_vol) if not np.isnan(annualized_vol) else 0.25,\n        \"volatility_percentile\": float(current_vol_percentile) if not np.isnan(current_vol_percentile) else 50.0,\n        \"data_points\": len(recent_returns)\n    }\n\n\ndef calculate_volatility_adjusted_limit(annualized_volatility: float) -> float:\n    \"\"\"\n    Calculate position limit as percentage of portfolio based on volatility.\n    \n    Logic:\n    - Low volatility (<15%): Up to 25% allocation\n    - Medium volatility (15-30%): 15-20% allocation  \n    - High volatility (>30%): 10-15% allocation\n    - Very high volatility (>50%): Max 10% allocation\n    \"\"\"\n    base_limit = 0.20  # 20% baseline\n    \n    if annualized_volatility < 0.15:  # Low volatility\n        # Allow higher allocation for stable stocks\n        vol_multiplier = 1.25  # Up to 25%\n    elif annualized_volatility < 0.30:  # Medium volatility  \n        # Standard allocation with slight adjustment based on volatility\n        vol_multiplier = 1.0 - (annualized_volatility - 0.15) * 0.5  # 20% -> 12.5%\n    elif annualized_volatility < 0.50:  # High volatility\n        # Reduce allocation significantly\n        vol_multiplier = 0.75 - (annualized_volatility - 0.30) * 0.5  # 15% -> 5%\n    else:  # Very high volatility (>50%)\n        # Minimum allocation for very risky stocks\n        vol_multiplier = 0.50  # Max 10%\n    \n    # Apply bounds to ensure reasonable limits\n    vol_multiplier = max(0.25, min(1.25, vol_multiplier))  # 5% to 25% range\n    \n    return base_limit * vol_multiplier\n\n\ndef calculate_correlation_multiplier(avg_correlation: float) -> float:\n    \"\"\"Map average correlation to an adjustment multiplier.\n    - Very high correlation (>= 0.8): reduce limit sharply (0.7x)\n    - High correlation (0.6-0.8): reduce (0.85x)\n    - Moderate correlation (0.4-0.6): neutral (1.0x)\n    - Low correlation (0.2-0.4): slight increase (1.05x)\n    - Very low correlation (< 0.2): increase (1.10x)\n    \"\"\"\n    if avg_correlation >= 0.80:\n        return 0.70\n    if avg_correlation >= 0.60:\n        return 0.85\n    if avg_correlation >= 0.40:\n        return 1.00\n    if avg_correlation >= 0.20:\n        return 1.05\n    return 1.10\n"
  },
  {
    "path": "src/agents/sentiment.py",
    "content": "from langchain_core.messages import HumanMessage\nfrom src.graph.state import AgentState, show_agent_reasoning\nfrom src.utils.progress import progress\nimport pandas as pd\nimport numpy as np\nimport json\nfrom src.utils.api_key import get_api_key_from_state\nfrom src.tools.api import get_insider_trades, get_company_news\n\n\n##### Sentiment Agent #####\ndef sentiment_analyst_agent(state: AgentState, agent_id: str = \"sentiment_analyst_agent\"):\n    \"\"\"Analyzes market sentiment and generates trading signals for multiple tickers.\"\"\"\n    data = state.get(\"data\", {})\n    end_date = data.get(\"end_date\")\n    tickers = data.get(\"tickers\")\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    # Initialize sentiment analysis for each ticker\n    sentiment_analysis = {}\n\n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Fetching insider trades\")\n\n        # Get the insider trades\n        insider_trades = get_insider_trades(\n            ticker=ticker,\n            end_date=end_date,\n            limit=1000,\n            api_key=api_key,\n        )\n\n        progress.update_status(agent_id, ticker, \"Analyzing trading patterns\")\n\n        # Get the signals from the insider trades\n        transaction_shares = pd.Series([t.transaction_shares for t in insider_trades]).dropna()\n        insider_signals = np.where(transaction_shares < 0, \"bearish\", \"bullish\").tolist()\n\n        progress.update_status(agent_id, ticker, \"Fetching company news\")\n\n        # Get the company news\n        company_news = get_company_news(ticker, end_date, limit=100, api_key=api_key)\n\n        # Get the sentiment from the company news\n        sentiment = pd.Series([n.sentiment for n in company_news]).dropna()\n        news_signals = np.where(sentiment == \"negative\", \"bearish\", \n                              np.where(sentiment == \"positive\", \"bullish\", \"neutral\")).tolist()\n        \n        progress.update_status(agent_id, ticker, \"Combining signals\")\n        # Combine signals from both sources with weights\n        insider_weight = 0.3\n        news_weight = 0.7\n        \n        # Calculate weighted signal counts\n        bullish_signals = (\n            insider_signals.count(\"bullish\") * insider_weight +\n            news_signals.count(\"bullish\") * news_weight\n        )\n        bearish_signals = (\n            insider_signals.count(\"bearish\") * insider_weight +\n            news_signals.count(\"bearish\") * news_weight\n        )\n\n        if bullish_signals > bearish_signals:\n            overall_signal = \"bullish\"\n        elif bearish_signals > bullish_signals:\n            overall_signal = \"bearish\"\n        else:\n            overall_signal = \"neutral\"\n\n        # Calculate confidence level based on the weighted proportion\n        total_weighted_signals = len(insider_signals) * insider_weight + len(news_signals) * news_weight\n        confidence = 0  # Default confidence when there are no signals\n        if total_weighted_signals > 0:\n            confidence = round((max(bullish_signals, bearish_signals) / total_weighted_signals) * 100, 2)\n        \n        # Create structured reasoning similar to technical analysis\n        reasoning = {\n            \"insider_trading\": {\n                \"signal\": \"bullish\" if insider_signals.count(\"bullish\") > insider_signals.count(\"bearish\") else \n                         \"bearish\" if insider_signals.count(\"bearish\") > insider_signals.count(\"bullish\") else \"neutral\",\n                \"confidence\": round((max(insider_signals.count(\"bullish\"), insider_signals.count(\"bearish\")) / max(len(insider_signals), 1)) * 100),\n                \"metrics\": {\n                    \"total_trades\": len(insider_signals),\n                    \"bullish_trades\": insider_signals.count(\"bullish\"),\n                    \"bearish_trades\": insider_signals.count(\"bearish\"),\n                    \"weight\": insider_weight,\n                    \"weighted_bullish\": round(insider_signals.count(\"bullish\") * insider_weight, 1),\n                    \"weighted_bearish\": round(insider_signals.count(\"bearish\") * insider_weight, 1),\n                }\n            },\n            \"news_sentiment\": {\n                \"signal\": \"bullish\" if news_signals.count(\"bullish\") > news_signals.count(\"bearish\") else \n                         \"bearish\" if news_signals.count(\"bearish\") > news_signals.count(\"bullish\") else \"neutral\",\n                \"confidence\": round((max(news_signals.count(\"bullish\"), news_signals.count(\"bearish\")) / max(len(news_signals), 1)) * 100),\n                \"metrics\": {\n                    \"total_articles\": len(news_signals),\n                    \"bullish_articles\": news_signals.count(\"bullish\"),\n                    \"bearish_articles\": news_signals.count(\"bearish\"),\n                    \"neutral_articles\": news_signals.count(\"neutral\"),\n                    \"weight\": news_weight,\n                    \"weighted_bullish\": round(news_signals.count(\"bullish\") * news_weight, 1),\n                    \"weighted_bearish\": round(news_signals.count(\"bearish\") * news_weight, 1),\n                }\n            },\n            \"combined_analysis\": {\n                \"total_weighted_bullish\": round(bullish_signals, 1),\n                \"total_weighted_bearish\": round(bearish_signals, 1),\n                \"signal_determination\": f\"{'Bullish' if bullish_signals > bearish_signals else 'Bearish' if bearish_signals > bullish_signals else 'Neutral'} based on weighted signal comparison\"\n            }\n        }\n\n        sentiment_analysis[ticker] = {\n            \"signal\": overall_signal,\n            \"confidence\": confidence,\n            \"reasoning\": reasoning,\n        }\n\n        progress.update_status(agent_id, ticker, \"Done\", analysis=json.dumps(reasoning, indent=4))\n\n    # Create the sentiment message\n    message = HumanMessage(\n        content=json.dumps(sentiment_analysis),\n        name=agent_id,\n    )\n\n    # Print the reasoning if the flag is set\n    if state[\"metadata\"][\"show_reasoning\"]:\n        show_agent_reasoning(sentiment_analysis, \"Sentiment Analysis Agent\")\n\n    # Add the signal to the analyst_signals list\n    state[\"data\"][\"analyst_signals\"][agent_id] = sentiment_analysis\n\n    progress.update_status(agent_id, None, \"Done\")\n\n    return {\n        \"messages\": [message],\n        \"data\": data,\n    }\n"
  },
  {
    "path": "src/agents/stanley_druckenmiller.py",
    "content": "from src.graph.state import AgentState, show_agent_reasoning\nfrom src.tools.api import (\n    get_financial_metrics,\n    get_market_cap,\n    search_line_items,\n    get_insider_trades,\n    get_company_news,\n    get_prices,\n)\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_core.messages import HumanMessage\nfrom pydantic import BaseModel\nimport json\nfrom typing_extensions import Literal\nfrom src.utils.progress import progress\nfrom src.utils.llm import call_llm\nimport statistics\nfrom src.utils.api_key import get_api_key_from_state\n\nclass StanleyDruckenmillerSignal(BaseModel):\n    signal: Literal[\"bullish\", \"bearish\", \"neutral\"]\n    confidence: float\n    reasoning: str\n\n\ndef stanley_druckenmiller_agent(state: AgentState, agent_id: str = \"stanley_druckenmiller_agent\"):\n    \"\"\"\n    Analyzes stocks using Stanley Druckenmiller's investing principles:\n      - Seeking asymmetric risk-reward opportunities\n      - Emphasizing growth, momentum, and sentiment\n      - Willing to be aggressive if conditions are favorable\n      - Focus on preserving capital by avoiding high-risk, low-reward bets\n\n    Returns a bullish/bearish/neutral signal with confidence and reasoning.\n    \"\"\"\n    data = state[\"data\"]\n    start_date = data[\"start_date\"]\n    end_date = data[\"end_date\"]\n    tickers = data[\"tickers\"]\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    analysis_data = {}\n    druck_analysis = {}\n\n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Fetching financial metrics\")\n        metrics = get_financial_metrics(ticker, end_date, period=\"annual\", limit=5, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Gathering financial line items\")\n        # Include relevant line items for Stan Druckenmiller's approach:\n        #   - Growth & momentum: revenue, EPS, operating_income, ...\n        #   - Valuation: net_income, free_cash_flow, ebit, ebitda\n        #   - Leverage: total_debt, shareholders_equity\n        #   - Liquidity: cash_and_equivalents\n        financial_line_items = search_line_items(\n            ticker,\n            [\n                \"revenue\",\n                \"earnings_per_share\",\n                \"net_income\",\n                \"operating_income\",\n                \"gross_margin\",\n                \"operating_margin\",\n                \"free_cash_flow\",\n                \"capital_expenditure\",\n                \"cash_and_equivalents\",\n                \"total_debt\",\n                \"shareholders_equity\",\n                \"outstanding_shares\",\n                \"ebit\",\n                \"ebitda\",\n            ],\n            end_date,\n            period=\"annual\",\n            limit=5,\n            api_key=api_key,\n        )\n\n        progress.update_status(agent_id, ticker, \"Getting market cap\")\n        market_cap = get_market_cap(ticker, end_date, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Fetching insider trades\")\n        insider_trades = get_insider_trades(ticker, end_date, limit=50, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Fetching company news\")\n        company_news = get_company_news(ticker, end_date, limit=50, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Fetching recent price data for momentum\")\n        prices = get_prices(ticker, start_date=start_date, end_date=end_date, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Analyzing growth & momentum\")\n        growth_momentum_analysis = analyze_growth_and_momentum(financial_line_items, prices)\n\n        progress.update_status(agent_id, ticker, \"Analyzing sentiment\")\n        sentiment_analysis = analyze_sentiment(company_news)\n\n        progress.update_status(agent_id, ticker, \"Analyzing insider activity\")\n        insider_activity = analyze_insider_activity(insider_trades)\n\n        progress.update_status(agent_id, ticker, \"Analyzing risk-reward\")\n        risk_reward_analysis = analyze_risk_reward(financial_line_items, prices)\n\n        progress.update_status(agent_id, ticker, \"Performing Druckenmiller-style valuation\")\n        valuation_analysis = analyze_druckenmiller_valuation(financial_line_items, market_cap)\n\n        # Combine partial scores with weights typical for Druckenmiller:\n        #   35% Growth/Momentum, 20% Risk/Reward, 20% Valuation,\n        #   15% Sentiment, 10% Insider Activity = 100%\n        total_score = (\n            growth_momentum_analysis[\"score\"] * 0.35\n            + risk_reward_analysis[\"score\"] * 0.20\n            + valuation_analysis[\"score\"] * 0.20\n            + sentiment_analysis[\"score\"] * 0.15\n            + insider_activity[\"score\"] * 0.10\n        )\n\n        max_possible_score = 10\n\n        # Simple bullish/neutral/bearish signal\n        if total_score >= 7.5:\n            signal = \"bullish\"\n        elif total_score <= 4.5:\n            signal = \"bearish\"\n        else:\n            signal = \"neutral\"\n\n        analysis_data[ticker] = {\n            \"signal\": signal,\n            \"score\": total_score,\n            \"max_score\": max_possible_score,\n            \"growth_momentum_analysis\": growth_momentum_analysis,\n            \"sentiment_analysis\": sentiment_analysis,\n            \"insider_activity\": insider_activity,\n            \"risk_reward_analysis\": risk_reward_analysis,\n            \"valuation_analysis\": valuation_analysis,\n        }\n\n        progress.update_status(agent_id, ticker, \"Generating Stanley Druckenmiller analysis\")\n        druck_output = generate_druckenmiller_output(\n            ticker=ticker,\n            analysis_data=analysis_data,\n            state=state,\n            agent_id=agent_id,\n        )\n\n        druck_analysis[ticker] = {\n            \"signal\": druck_output.signal,\n            \"confidence\": druck_output.confidence,\n            \"reasoning\": druck_output.reasoning,\n        }\n\n        progress.update_status(agent_id, ticker, \"Done\", analysis=druck_output.reasoning)\n\n    # Wrap results in a single message\n    message = HumanMessage(content=json.dumps(druck_analysis), name=agent_id)\n\n    if state[\"metadata\"].get(\"show_reasoning\"):\n        show_agent_reasoning(druck_analysis, \"Stanley Druckenmiller Agent\")\n\n    state[\"data\"][\"analyst_signals\"][agent_id] = druck_analysis\n\n    progress.update_status(agent_id, None, \"Done\")\n    \n    return {\"messages\": [message], \"data\": state[\"data\"]}\n\n\ndef analyze_growth_and_momentum(financial_line_items: list, prices: list) -> dict:\n    \"\"\"\n    Evaluate:\n      - Revenue Growth (YoY)\n      - EPS Growth (YoY)\n      - Price Momentum\n    \"\"\"\n    if not financial_line_items or len(financial_line_items) < 2:\n        return {\"score\": 0, \"details\": \"Insufficient financial data for growth analysis\"}\n\n    details = []\n    raw_score = 0  # We'll sum up a maximum of 9 raw points, then scale to 0–10\n\n    #\n    # 1. Revenue Growth (annualized CAGR)\n    #\n    revenues = [fi.revenue for fi in financial_line_items if fi.revenue is not None]\n    if len(revenues) >= 2:\n        latest_rev = revenues[0]\n        older_rev = revenues[-1]\n        num_years = len(revenues) - 1\n        if older_rev > 0 and latest_rev > 0:\n            # CAGR formula: (ending_value/beginning_value)^(1/years) - 1\n            rev_growth = (latest_rev / older_rev) ** (1 / num_years) - 1\n            if rev_growth > 0.08:  # 8% annualized (adjusted for CAGR)\n                raw_score += 3\n                details.append(f\"Strong annualized revenue growth: {rev_growth:.1%}\")\n            elif rev_growth > 0.04:  # 4% annualized\n                raw_score += 2\n                details.append(f\"Moderate annualized revenue growth: {rev_growth:.1%}\")\n            elif rev_growth > 0.01:  # 1% annualized\n                raw_score += 1\n                details.append(f\"Slight annualized revenue growth: {rev_growth:.1%}\")\n            else:\n                details.append(f\"Minimal/negative revenue growth: {rev_growth:.1%}\")\n        else:\n            details.append(\"Older revenue is zero/negative; can't compute revenue growth.\")\n    else:\n        details.append(\"Not enough revenue data points for growth calculation.\")\n\n    #\n    # 2. EPS Growth (annualized CAGR)\n    #\n    eps_values = [fi.earnings_per_share for fi in financial_line_items if fi.earnings_per_share is not None]\n    if len(eps_values) >= 2:\n        latest_eps = eps_values[0]\n        older_eps = eps_values[-1]\n        num_years = len(eps_values) - 1\n        # Calculate CAGR for positive EPS values\n        if older_eps > 0 and latest_eps > 0:\n            # CAGR formula for EPS\n            eps_growth = (latest_eps / older_eps) ** (1 / num_years) - 1\n            if eps_growth > 0.08:  # 8% annualized (adjusted for CAGR)\n                raw_score += 3\n                details.append(f\"Strong annualized EPS growth: {eps_growth:.1%}\")\n            elif eps_growth > 0.04:  # 4% annualized\n                raw_score += 2\n                details.append(f\"Moderate annualized EPS growth: {eps_growth:.1%}\")\n            elif eps_growth > 0.01:  # 1% annualized\n                raw_score += 1\n                details.append(f\"Slight annualized EPS growth: {eps_growth:.1%}\")\n            else:\n                details.append(f\"Minimal/negative annualized EPS growth: {eps_growth:.1%}\")\n        else:\n            details.append(\"Older EPS is near zero; skipping EPS growth calculation.\")\n    else:\n        details.append(\"Not enough EPS data points for growth calculation.\")\n\n    #\n    # 3. Price Momentum\n    #\n    # We'll give up to 3 points for strong momentum\n    if prices and len(prices) > 30:\n        sorted_prices = sorted(prices, key=lambda p: p.time)\n        close_prices = [p.close for p in sorted_prices if p.close is not None]\n        if len(close_prices) >= 2:\n            start_price = close_prices[0]\n            end_price = close_prices[-1]\n            if start_price > 0:\n                pct_change = (end_price - start_price) / start_price\n                if pct_change > 0.50:\n                    raw_score += 3\n                    details.append(f\"Very strong price momentum: {pct_change:.1%}\")\n                elif pct_change > 0.20:\n                    raw_score += 2\n                    details.append(f\"Moderate price momentum: {pct_change:.1%}\")\n                elif pct_change > 0:\n                    raw_score += 1\n                    details.append(f\"Slight positive momentum: {pct_change:.1%}\")\n                else:\n                    details.append(f\"Negative price momentum: {pct_change:.1%}\")\n            else:\n                details.append(\"Invalid start price (<= 0); can't compute momentum.\")\n        else:\n            details.append(\"Insufficient price data for momentum calculation.\")\n    else:\n        details.append(\"Not enough recent price data for momentum analysis.\")\n\n    # We assigned up to 3 points each for:\n    #   revenue growth, eps growth, momentum\n    # => max raw_score = 9\n    # Scale to 0–10\n    final_score = min(10, (raw_score / 9) * 10)\n\n    return {\"score\": final_score, \"details\": \"; \".join(details)}\n\n\ndef analyze_insider_activity(insider_trades: list) -> dict:\n    \"\"\"\n    Simple insider-trade analysis:\n      - If there's heavy insider buying, we nudge the score up.\n      - If there's mostly selling, we reduce it.\n      - Otherwise, neutral.\n    \"\"\"\n    # Default is neutral (5/10).\n    score = 5\n    details = []\n\n    if not insider_trades:\n        details.append(\"No insider trades data; defaulting to neutral\")\n        return {\"score\": score, \"details\": \"; \".join(details)}\n\n    buys, sells = 0, 0\n    for trade in insider_trades:\n        # Use transaction_shares to determine if it's a buy or sell\n        # Negative shares = sell, positive shares = buy\n        if trade.transaction_shares is not None:\n            if trade.transaction_shares > 0:\n                buys += 1\n            elif trade.transaction_shares < 0:\n                sells += 1\n\n    total = buys + sells\n    if total == 0:\n        details.append(\"No buy/sell transactions found; neutral\")\n        return {\"score\": score, \"details\": \"; \".join(details)}\n\n    buy_ratio = buys / total\n    if buy_ratio > 0.7:\n        # Heavy buying => +3 points from the neutral 5 => 8\n        score = 8\n        details.append(f\"Heavy insider buying: {buys} buys vs. {sells} sells\")\n    elif buy_ratio > 0.4:\n        # Moderate buying => +1 => 6\n        score = 6\n        details.append(f\"Moderate insider buying: {buys} buys vs. {sells} sells\")\n    else:\n        # Low insider buying => -1 => 4\n        score = 4\n        details.append(f\"Mostly insider selling: {buys} buys vs. {sells} sells\")\n\n    return {\"score\": score, \"details\": \"; \".join(details)}\n\n\ndef analyze_sentiment(news_items: list) -> dict:\n    \"\"\"\n    Basic news sentiment: negative keyword check vs. overall volume.\n    \"\"\"\n    if not news_items:\n        return {\"score\": 5, \"details\": \"No news data; defaulting to neutral sentiment\"}\n\n    negative_keywords = [\"lawsuit\", \"fraud\", \"negative\", \"downturn\", \"decline\", \"investigation\", \"recall\"]\n    negative_count = 0\n    for news in news_items:\n        title_lower = (news.title or \"\").lower()\n        if any(word in title_lower for word in negative_keywords):\n            negative_count += 1\n\n    details = []\n    if negative_count > len(news_items) * 0.3:\n        # More than 30% negative => somewhat bearish => 3/10\n        score = 3\n        details.append(f\"High proportion of negative headlines: {negative_count}/{len(news_items)}\")\n    elif negative_count > 0:\n        # Some negativity => 6/10\n        score = 6\n        details.append(f\"Some negative headlines: {negative_count}/{len(news_items)}\")\n    else:\n        # Mostly positive => 8/10\n        score = 8\n        details.append(\"Mostly positive/neutral headlines\")\n\n    return {\"score\": score, \"details\": \"; \".join(details)}\n\n\ndef analyze_risk_reward(financial_line_items: list, prices: list) -> dict:\n    \"\"\"\n    Assesses risk via:\n      - Debt-to-Equity\n      - Price Volatility\n    Aims for strong upside with contained downside.\n    \"\"\"\n    if not financial_line_items or not prices:\n        return {\"score\": 0, \"details\": \"Insufficient data for risk-reward analysis\"}\n\n    details = []\n    raw_score = 0  # We'll accumulate up to 6 raw points, then scale to 0-10\n\n    #\n    # 1. Debt-to-Equity\n    #\n    debt_values = [fi.total_debt for fi in financial_line_items if fi.total_debt is not None]\n    equity_values = [fi.shareholders_equity for fi in financial_line_items if fi.shareholders_equity is not None]\n\n    if debt_values and equity_values and len(debt_values) == len(equity_values) and len(debt_values) > 0:\n        recent_debt = debt_values[0]\n        recent_equity = equity_values[0] if equity_values[0] else 1e-9\n        de_ratio = recent_debt / recent_equity\n        if de_ratio < 0.3:\n            raw_score += 3\n            details.append(f\"Low debt-to-equity: {de_ratio:.2f}\")\n        elif de_ratio < 0.7:\n            raw_score += 2\n            details.append(f\"Moderate debt-to-equity: {de_ratio:.2f}\")\n        elif de_ratio < 1.5:\n            raw_score += 1\n            details.append(f\"Somewhat high debt-to-equity: {de_ratio:.2f}\")\n        else:\n            details.append(f\"High debt-to-equity: {de_ratio:.2f}\")\n    else:\n        details.append(\"No consistent debt/equity data available.\")\n\n    #\n    # 2. Price Volatility\n    #\n    if len(prices) > 10:\n        sorted_prices = sorted(prices, key=lambda p: p.time)\n        close_prices = [p.close for p in sorted_prices if p.close is not None]\n        if len(close_prices) > 10:\n            daily_returns = []\n            for i in range(1, len(close_prices)):\n                prev_close = close_prices[i - 1]\n                if prev_close > 0:\n                    daily_returns.append((close_prices[i] - prev_close) / prev_close)\n            if daily_returns:\n                stdev = statistics.pstdev(daily_returns)  # population stdev\n                if stdev < 0.01:\n                    raw_score += 3\n                    details.append(f\"Low volatility: daily returns stdev {stdev:.2%}\")\n                elif stdev < 0.02:\n                    raw_score += 2\n                    details.append(f\"Moderate volatility: daily returns stdev {stdev:.2%}\")\n                elif stdev < 0.04:\n                    raw_score += 1\n                    details.append(f\"High volatility: daily returns stdev {stdev:.2%}\")\n                else:\n                    details.append(f\"Very high volatility: daily returns stdev {stdev:.2%}\")\n            else:\n                details.append(\"Insufficient daily returns data for volatility calc.\")\n        else:\n            details.append(\"Not enough close-price data points for volatility analysis.\")\n    else:\n        details.append(\"Not enough price data for volatility analysis.\")\n\n    # raw_score out of 6 => scale to 0–10\n    final_score = min(10, (raw_score / 6) * 10)\n    return {\"score\": final_score, \"details\": \"; \".join(details)}\n\n\ndef analyze_druckenmiller_valuation(financial_line_items: list, market_cap: float | None) -> dict:\n    \"\"\"\n    Druckenmiller is willing to pay up for growth, but still checks:\n      - P/E\n      - P/FCF\n      - EV/EBIT\n      - EV/EBITDA\n    Each can yield up to 2 points => max 8 raw points => scale to 0–10.\n    \"\"\"\n    if not financial_line_items or market_cap is None:\n        return {\"score\": 0, \"details\": \"Insufficient data to perform valuation\"}\n\n    details = []\n    raw_score = 0\n\n    # Gather needed data\n    net_incomes = [fi.net_income for fi in financial_line_items if fi.net_income is not None]\n    fcf_values = [fi.free_cash_flow for fi in financial_line_items if fi.free_cash_flow is not None]\n    ebit_values = [fi.ebit for fi in financial_line_items if fi.ebit is not None]\n    ebitda_values = [fi.ebitda for fi in financial_line_items if fi.ebitda is not None]\n\n    # For EV calculation, let's get the most recent total_debt & cash\n    debt_values = [fi.total_debt for fi in financial_line_items if fi.total_debt is not None]\n    cash_values = [fi.cash_and_equivalents for fi in financial_line_items if fi.cash_and_equivalents is not None]\n    recent_debt = debt_values[0] if debt_values else 0\n    recent_cash = cash_values[0] if cash_values else 0\n\n    enterprise_value = market_cap + recent_debt - recent_cash\n\n    # 1) P/E\n    recent_net_income = net_incomes[0] if net_incomes else None\n    if recent_net_income and recent_net_income > 0:\n        pe = market_cap / recent_net_income\n        pe_points = 0\n        if pe < 15:\n            pe_points = 2\n            details.append(f\"Attractive P/E: {pe:.2f}\")\n        elif pe < 25:\n            pe_points = 1\n            details.append(f\"Fair P/E: {pe:.2f}\")\n        else:\n            details.append(f\"High or Very high P/E: {pe:.2f}\")\n        raw_score += pe_points\n    else:\n        details.append(\"No positive net income for P/E calculation\")\n\n    # 2) P/FCF\n    recent_fcf = fcf_values[0] if fcf_values else None\n    if recent_fcf and recent_fcf > 0:\n        pfcf = market_cap / recent_fcf\n        pfcf_points = 0\n        if pfcf < 15:\n            pfcf_points = 2\n            details.append(f\"Attractive P/FCF: {pfcf:.2f}\")\n        elif pfcf < 25:\n            pfcf_points = 1\n            details.append(f\"Fair P/FCF: {pfcf:.2f}\")\n        else:\n            details.append(f\"High/Very high P/FCF: {pfcf:.2f}\")\n        raw_score += pfcf_points\n    else:\n        details.append(\"No positive free cash flow for P/FCF calculation\")\n\n    # 3) EV/EBIT\n    recent_ebit = ebit_values[0] if ebit_values else None\n    if enterprise_value > 0 and recent_ebit and recent_ebit > 0:\n        ev_ebit = enterprise_value / recent_ebit\n        ev_ebit_points = 0\n        if ev_ebit < 15:\n            ev_ebit_points = 2\n            details.append(f\"Attractive EV/EBIT: {ev_ebit:.2f}\")\n        elif ev_ebit < 25:\n            ev_ebit_points = 1\n            details.append(f\"Fair EV/EBIT: {ev_ebit:.2f}\")\n        else:\n            details.append(f\"High EV/EBIT: {ev_ebit:.2f}\")\n        raw_score += ev_ebit_points\n    else:\n        details.append(\"No valid EV/EBIT because EV <= 0 or EBIT <= 0\")\n\n    # 4) EV/EBITDA\n    recent_ebitda = ebitda_values[0] if ebitda_values else None\n    if enterprise_value > 0 and recent_ebitda and recent_ebitda > 0:\n        ev_ebitda = enterprise_value / recent_ebitda\n        ev_ebitda_points = 0\n        if ev_ebitda < 10:\n            ev_ebitda_points = 2\n            details.append(f\"Attractive EV/EBITDA: {ev_ebitda:.2f}\")\n        elif ev_ebitda < 18:\n            ev_ebitda_points = 1\n            details.append(f\"Fair EV/EBITDA: {ev_ebitda:.2f}\")\n        else:\n            details.append(f\"High EV/EBITDA: {ev_ebitda:.2f}\")\n        raw_score += ev_ebitda_points\n    else:\n        details.append(\"No valid EV/EBITDA because EV <= 0 or EBITDA <= 0\")\n\n    # We have up to 2 points for each of the 4 metrics => 8 raw points max\n    # Scale raw_score to 0–10\n    final_score = min(10, (raw_score / 8) * 10)\n\n    return {\"score\": final_score, \"details\": \"; \".join(details)}\n\n\ndef generate_druckenmiller_output(\n    ticker: str,\n    analysis_data: dict[str, any],\n    state: AgentState,\n    agent_id: str,\n) -> StanleyDruckenmillerSignal:\n    \"\"\"\n    Generates a JSON signal in the style of Stanley Druckenmiller.\n    \"\"\"\n    template = ChatPromptTemplate.from_messages(\n        [\n            (\n              \"system\",\n              \"\"\"You are a Stanley Druckenmiller AI agent, making investment decisions using his principles:\n            \n              1. Seek asymmetric risk-reward opportunities (large upside, limited downside).\n              2. Emphasize growth, momentum, and market sentiment.\n              3. Preserve capital by avoiding major drawdowns.\n              4. Willing to pay higher valuations for true growth leaders.\n              5. Be aggressive when conviction is high.\n              6. Cut losses quickly if the thesis changes.\n                            \n              Rules:\n              - Reward companies showing strong revenue/earnings growth and positive stock momentum.\n              - Evaluate sentiment and insider activity as supportive or contradictory signals.\n              - Watch out for high leverage or extreme volatility that threatens capital.\n              - Output a JSON object with signal, confidence, and a reasoning string.\n              \n              When providing your reasoning, be thorough and specific by:\n              1. Explaining the growth and momentum metrics that most influenced your decision\n              2. Highlighting the risk-reward profile with specific numerical evidence\n              3. Discussing market sentiment and catalysts that could drive price action\n              4. Addressing both upside potential and downside risks\n              5. Providing specific valuation context relative to growth prospects\n              6. Using Stanley Druckenmiller's decisive, momentum-focused, and conviction-driven voice\n              \n              For example, if bullish: \"The company shows exceptional momentum with revenue accelerating from 22% to 35% YoY and the stock up 28% over the past three months. Risk-reward is highly asymmetric with 70% upside potential based on FCF multiple expansion and only 15% downside risk given the strong balance sheet with 3x cash-to-debt. Insider buying and positive market sentiment provide additional tailwinds...\"\n              For example, if bearish: \"Despite recent stock momentum, revenue growth has decelerated from 30% to 12% YoY, and operating margins are contracting. The risk-reward proposition is unfavorable with limited 10% upside potential against 40% downside risk. The competitive landscape is intensifying, and insider selling suggests waning confidence. I'm seeing better opportunities elsewhere with more favorable setups...\"\n              \"\"\",\n            ),\n            (\n              \"human\",\n              \"\"\"Based on the following analysis, create a Druckenmiller-style investment signal.\n\n              Analysis Data for {ticker}:\n              {analysis_data}\n\n              Return the trading signal in this JSON format:\n              {{\n                \"signal\": \"bullish/bearish/neutral\",\n                \"confidence\": float (0-100),\n                \"reasoning\": \"string\"\n              }}\n              \"\"\",\n            ),\n        ]\n    )\n\n    prompt = template.invoke({\"analysis_data\": json.dumps(analysis_data, indent=2), \"ticker\": ticker})\n\n    def create_default_signal():\n        return StanleyDruckenmillerSignal(\n            signal=\"neutral\",\n            confidence=0.0,\n            reasoning=\"Error in analysis, defaulting to neutral\"\n        )\n\n    return call_llm(\n        prompt=prompt,\n        pydantic_model=StanleyDruckenmillerSignal,\n        agent_name=agent_id,\n        state=state,\n        default_factory=create_default_signal,\n    )\n"
  },
  {
    "path": "src/agents/technicals.py",
    "content": "import math\n\nfrom langchain_core.messages import HumanMessage\n\nfrom src.graph.state import AgentState, show_agent_reasoning\nfrom src.utils.api_key import get_api_key_from_state\nimport json\nimport pandas as pd\nimport numpy as np\n\nfrom src.tools.api import get_prices, prices_to_df\nfrom src.utils.progress import progress\n\n\ndef safe_float(value, default=0.0):\n    \"\"\"\n    Safely convert a value to float, handling NaN cases\n    \n    Args:\n        value: The value to convert (can be pandas scalar, numpy value, etc.)\n        default: Default value to return if the input is NaN or invalid\n    \n    Returns:\n        float: The converted value or default if NaN/invalid\n    \"\"\"\n    try:\n        if pd.isna(value) or np.isnan(value):\n            return default\n        return float(value)\n    except (ValueError, TypeError, OverflowError):\n        return default\n\n\n##### Technical Analyst #####\ndef technical_analyst_agent(state: AgentState, agent_id: str = \"technical_analyst_agent\"):\n    \"\"\"\n    Sophisticated technical analysis system that combines multiple trading strategies for multiple tickers:\n    1. Trend Following\n    2. Mean Reversion\n    3. Momentum\n    4. Volatility Analysis\n    5. Statistical Arbitrage Signals\n    \"\"\"\n    data = state[\"data\"]\n    start_date = data[\"start_date\"]\n    end_date = data[\"end_date\"]\n    tickers = data[\"tickers\"]\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    # Initialize analysis for each ticker\n    technical_analysis = {}\n\n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Analyzing price data\")\n\n        # Get the historical price data\n        prices = get_prices(\n            ticker=ticker,\n            start_date=start_date,\n            end_date=end_date,\n            api_key=api_key,\n        )\n\n        if not prices:\n            progress.update_status(agent_id, ticker, \"Failed: No price data found\")\n            continue\n\n        # Convert prices to a DataFrame\n        prices_df = prices_to_df(prices)\n\n        progress.update_status(agent_id, ticker, \"Calculating trend signals\")\n        trend_signals = calculate_trend_signals(prices_df)\n\n        progress.update_status(agent_id, ticker, \"Calculating mean reversion\")\n        mean_reversion_signals = calculate_mean_reversion_signals(prices_df)\n\n        progress.update_status(agent_id, ticker, \"Calculating momentum\")\n        momentum_signals = calculate_momentum_signals(prices_df)\n\n        progress.update_status(agent_id, ticker, \"Analyzing volatility\")\n        volatility_signals = calculate_volatility_signals(prices_df)\n\n        progress.update_status(agent_id, ticker, \"Statistical analysis\")\n        stat_arb_signals = calculate_stat_arb_signals(prices_df)\n\n        # Combine all signals using a weighted ensemble approach\n        strategy_weights = {\n            \"trend\": 0.25,\n            \"mean_reversion\": 0.20,\n            \"momentum\": 0.25,\n            \"volatility\": 0.15,\n            \"stat_arb\": 0.15,\n        }\n\n        progress.update_status(agent_id, ticker, \"Combining signals\")\n        combined_signal = weighted_signal_combination(\n            {\n                \"trend\": trend_signals,\n                \"mean_reversion\": mean_reversion_signals,\n                \"momentum\": momentum_signals,\n                \"volatility\": volatility_signals,\n                \"stat_arb\": stat_arb_signals,\n            },\n            strategy_weights,\n        )\n\n        # Generate detailed analysis report for this ticker\n        technical_analysis[ticker] = {\n            \"signal\": combined_signal[\"signal\"],\n            \"confidence\": round(combined_signal[\"confidence\"] * 100),\n            \"reasoning\": {\n                \"trend_following\": {\n                    \"signal\": trend_signals[\"signal\"],\n                    \"confidence\": round(trend_signals[\"confidence\"] * 100),\n                    \"metrics\": normalize_pandas(trend_signals[\"metrics\"]),\n                },\n                \"mean_reversion\": {\n                    \"signal\": mean_reversion_signals[\"signal\"],\n                    \"confidence\": round(mean_reversion_signals[\"confidence\"] * 100),\n                    \"metrics\": normalize_pandas(mean_reversion_signals[\"metrics\"]),\n                },\n                \"momentum\": {\n                    \"signal\": momentum_signals[\"signal\"],\n                    \"confidence\": round(momentum_signals[\"confidence\"] * 100),\n                    \"metrics\": normalize_pandas(momentum_signals[\"metrics\"]),\n                },\n                \"volatility\": {\n                    \"signal\": volatility_signals[\"signal\"],\n                    \"confidence\": round(volatility_signals[\"confidence\"] * 100),\n                    \"metrics\": normalize_pandas(volatility_signals[\"metrics\"]),\n                },\n                \"statistical_arbitrage\": {\n                    \"signal\": stat_arb_signals[\"signal\"],\n                    \"confidence\": round(stat_arb_signals[\"confidence\"] * 100),\n                    \"metrics\": normalize_pandas(stat_arb_signals[\"metrics\"]),\n                },\n            },\n        }\n        progress.update_status(agent_id, ticker, \"Done\", analysis=json.dumps(technical_analysis, indent=4))\n\n    # Create the technical analyst message\n    message = HumanMessage(\n        content=json.dumps(technical_analysis),\n        name=agent_id,\n    )\n\n    if state[\"metadata\"][\"show_reasoning\"]:\n        show_agent_reasoning(technical_analysis, \"Technical Analyst\")\n\n    # Add the signal to the analyst_signals list\n    state[\"data\"][\"analyst_signals\"][agent_id] = technical_analysis\n\n    progress.update_status(agent_id, None, \"Done\")\n\n    return {\n        \"messages\": state[\"messages\"] + [message],\n        \"data\": data,\n    }\n\n\ndef calculate_trend_signals(prices_df):\n    \"\"\"\n    Advanced trend following strategy using multiple timeframes and indicators\n    \"\"\"\n    # Calculate EMAs for multiple timeframes\n    ema_8 = calculate_ema(prices_df, 8)\n    ema_21 = calculate_ema(prices_df, 21)\n    ema_55 = calculate_ema(prices_df, 55)\n\n    # Calculate ADX for trend strength\n    adx = calculate_adx(prices_df, 14)\n\n    # Determine trend direction and strength\n    short_trend = ema_8 > ema_21\n    medium_trend = ema_21 > ema_55\n\n    # Combine signals with confidence weighting\n    trend_strength = adx[\"adx\"].iloc[-1] / 100.0\n\n    if short_trend.iloc[-1] and medium_trend.iloc[-1]:\n        signal = \"bullish\"\n        confidence = trend_strength\n    elif not short_trend.iloc[-1] and not medium_trend.iloc[-1]:\n        signal = \"bearish\"\n        confidence = trend_strength\n    else:\n        signal = \"neutral\"\n        confidence = 0.5\n\n    return {\n        \"signal\": signal,\n        \"confidence\": confidence,\n        \"metrics\": {\n            \"adx\": safe_float(adx[\"adx\"].iloc[-1]),\n            \"trend_strength\": safe_float(trend_strength),\n        },\n    }\n\n\ndef calculate_mean_reversion_signals(prices_df):\n    \"\"\"\n    Mean reversion strategy using statistical measures and Bollinger Bands\n    \"\"\"\n    # Calculate z-score of price relative to moving average\n    ma_50 = prices_df[\"close\"].rolling(window=50).mean()\n    std_50 = prices_df[\"close\"].rolling(window=50).std()\n    z_score = (prices_df[\"close\"] - ma_50) / std_50\n\n    # Calculate Bollinger Bands\n    bb_upper, bb_lower = calculate_bollinger_bands(prices_df)\n\n    # Calculate RSI with multiple timeframes\n    rsi_14 = calculate_rsi(prices_df, 14)\n    rsi_28 = calculate_rsi(prices_df, 28)\n\n    # Mean reversion signals\n    price_vs_bb = (prices_df[\"close\"].iloc[-1] - bb_lower.iloc[-1]) / (bb_upper.iloc[-1] - bb_lower.iloc[-1])\n\n    # Combine signals\n    if z_score.iloc[-1] < -2 and price_vs_bb < 0.2:\n        signal = \"bullish\"\n        confidence = min(abs(z_score.iloc[-1]) / 4, 1.0)\n    elif z_score.iloc[-1] > 2 and price_vs_bb > 0.8:\n        signal = \"bearish\"\n        confidence = min(abs(z_score.iloc[-1]) / 4, 1.0)\n    else:\n        signal = \"neutral\"\n        confidence = 0.5\n\n    return {\n        \"signal\": signal,\n        \"confidence\": confidence,\n        \"metrics\": {\n            \"z_score\": safe_float(z_score.iloc[-1]),\n            \"price_vs_bb\": safe_float(price_vs_bb),\n            \"rsi_14\": safe_float(rsi_14.iloc[-1]),\n            \"rsi_28\": safe_float(rsi_28.iloc[-1]),\n        },\n    }\n\n\ndef calculate_momentum_signals(prices_df):\n    \"\"\"\n    Multi-factor momentum strategy\n    \"\"\"\n    # Price momentum\n    returns = prices_df[\"close\"].pct_change()\n    mom_1m = returns.rolling(21).sum()\n    mom_3m = returns.rolling(63).sum()\n    mom_6m = returns.rolling(126).sum()\n\n    # Volume momentum\n    volume_ma = prices_df[\"volume\"].rolling(21).mean()\n    volume_momentum = prices_df[\"volume\"] / volume_ma\n\n    # Relative strength\n    # (would compare to market/sector in real implementation)\n\n    # Calculate momentum score\n    momentum_score = (0.4 * mom_1m + 0.3 * mom_3m + 0.3 * mom_6m).iloc[-1]\n\n    # Volume confirmation\n    volume_confirmation = volume_momentum.iloc[-1] > 1.0\n\n    if momentum_score > 0.05 and volume_confirmation:\n        signal = \"bullish\"\n        confidence = min(abs(momentum_score) * 5, 1.0)\n    elif momentum_score < -0.05 and volume_confirmation:\n        signal = \"bearish\"\n        confidence = min(abs(momentum_score) * 5, 1.0)\n    else:\n        signal = \"neutral\"\n        confidence = 0.5\n\n    return {\n        \"signal\": signal,\n        \"confidence\": confidence,\n        \"metrics\": {\n            \"momentum_1m\": safe_float(mom_1m.iloc[-1]),\n            \"momentum_3m\": safe_float(mom_3m.iloc[-1]),\n            \"momentum_6m\": safe_float(mom_6m.iloc[-1]),\n            \"volume_momentum\": safe_float(volume_momentum.iloc[-1]),\n        },\n    }\n\n\ndef calculate_volatility_signals(prices_df):\n    \"\"\"\n    Volatility-based trading strategy\n    \"\"\"\n    # Calculate various volatility metrics\n    returns = prices_df[\"close\"].pct_change()\n\n    # Historical volatility\n    hist_vol = returns.rolling(21).std() * math.sqrt(252)\n\n    # Volatility regime detection\n    vol_ma = hist_vol.rolling(63).mean()\n    vol_regime = hist_vol / vol_ma\n\n    # Volatility mean reversion\n    vol_z_score = (hist_vol - vol_ma) / hist_vol.rolling(63).std()\n\n    # ATR ratio\n    atr = calculate_atr(prices_df)\n    atr_ratio = atr / prices_df[\"close\"]\n\n    # Generate signal based on volatility regime\n    current_vol_regime = vol_regime.iloc[-1]\n    vol_z = vol_z_score.iloc[-1]\n\n    if current_vol_regime < 0.8 and vol_z < -1:\n        signal = \"bullish\"  # Low vol regime, potential for expansion\n        confidence = min(abs(vol_z) / 3, 1.0)\n    elif current_vol_regime > 1.2 and vol_z > 1:\n        signal = \"bearish\"  # High vol regime, potential for contraction\n        confidence = min(abs(vol_z) / 3, 1.0)\n    else:\n        signal = \"neutral\"\n        confidence = 0.5\n\n    return {\n        \"signal\": signal,\n        \"confidence\": confidence,\n        \"metrics\": {\n            \"historical_volatility\": safe_float(hist_vol.iloc[-1]),\n            \"volatility_regime\": safe_float(current_vol_regime),\n            \"volatility_z_score\": safe_float(vol_z),\n            \"atr_ratio\": safe_float(atr_ratio.iloc[-1]),\n        },\n    }\n\n\ndef calculate_stat_arb_signals(prices_df):\n    \"\"\"\n    Statistical arbitrage signals based on price action analysis\n    \"\"\"\n    # Calculate price distribution statistics\n    returns = prices_df[\"close\"].pct_change()\n\n    # Skewness and kurtosis\n    skew = returns.rolling(63).skew()\n    kurt = returns.rolling(63).kurt()\n\n    # Test for mean reversion using Hurst exponent\n    hurst = calculate_hurst_exponent(prices_df[\"close\"])\n\n    # Correlation analysis\n    # (would include correlation with related securities in real implementation)\n\n    # Generate signal based on statistical properties\n    if hurst < 0.4 and skew.iloc[-1] > 1:\n        signal = \"bullish\"\n        confidence = (0.5 - hurst) * 2\n    elif hurst < 0.4 and skew.iloc[-1] < -1:\n        signal = \"bearish\"\n        confidence = (0.5 - hurst) * 2\n    else:\n        signal = \"neutral\"\n        confidence = 0.5\n\n    return {\n        \"signal\": signal,\n        \"confidence\": confidence,\n        \"metrics\": {\n            \"hurst_exponent\": safe_float(hurst),\n            \"skewness\": safe_float(skew.iloc[-1]),\n            \"kurtosis\": safe_float(kurt.iloc[-1]),\n        },\n    }\n\n\ndef weighted_signal_combination(signals, weights):\n    \"\"\"\n    Combines multiple trading signals using a weighted approach\n    \"\"\"\n    # Convert signals to numeric values\n    signal_values = {\"bullish\": 1, \"neutral\": 0, \"bearish\": -1}\n\n    weighted_sum = 0\n    total_confidence = 0\n\n    for strategy, signal in signals.items():\n        numeric_signal = signal_values[signal[\"signal\"]]\n        weight = weights[strategy]\n        confidence = signal[\"confidence\"]\n\n        weighted_sum += numeric_signal * weight * confidence\n        total_confidence += weight * confidence\n\n    # Normalize the weighted sum\n    if total_confidence > 0:\n        final_score = weighted_sum / total_confidence\n    else:\n        final_score = 0\n\n    # Convert back to signal\n    if final_score > 0.2:\n        signal = \"bullish\"\n    elif final_score < -0.2:\n        signal = \"bearish\"\n    else:\n        signal = \"neutral\"\n\n    return {\"signal\": signal, \"confidence\": abs(final_score)}\n\n\ndef normalize_pandas(obj):\n    \"\"\"Convert pandas Series/DataFrames to primitive Python types\"\"\"\n    if isinstance(obj, pd.Series):\n        return obj.tolist()\n    elif isinstance(obj, pd.DataFrame):\n        return obj.to_dict(\"records\")\n    elif isinstance(obj, dict):\n        return {k: normalize_pandas(v) for k, v in obj.items()}\n    elif isinstance(obj, (list, tuple)):\n        return [normalize_pandas(item) for item in obj]\n    return obj\n\n\ndef calculate_rsi(prices_df: pd.DataFrame, period: int = 14) -> pd.Series:\n    delta = prices_df[\"close\"].diff()\n    gain = (delta.where(delta > 0, 0)).fillna(0)\n    loss = (-delta.where(delta < 0, 0)).fillna(0)\n    avg_gain = gain.rolling(window=period).mean()\n    avg_loss = loss.rolling(window=period).mean()\n    rs = avg_gain / avg_loss\n    rsi = 100 - (100 / (1 + rs))\n    return rsi\n\n\ndef calculate_bollinger_bands(prices_df: pd.DataFrame, window: int = 20) -> tuple[pd.Series, pd.Series]:\n    sma = prices_df[\"close\"].rolling(window).mean()\n    std_dev = prices_df[\"close\"].rolling(window).std()\n    upper_band = sma + (std_dev * 2)\n    lower_band = sma - (std_dev * 2)\n    return upper_band, lower_band\n\n\ndef calculate_ema(df: pd.DataFrame, window: int) -> pd.Series:\n    \"\"\"\n    Calculate Exponential Moving Average\n\n    Args:\n        df: DataFrame with price data\n        window: EMA period\n\n    Returns:\n        pd.Series: EMA values\n    \"\"\"\n    return df[\"close\"].ewm(span=window, adjust=False).mean()\n\n\ndef calculate_adx(df: pd.DataFrame, period: int = 14) -> pd.DataFrame:\n    \"\"\"\n    Calculate Average Directional Index (ADX)\n\n    Args:\n        df: DataFrame with OHLC data\n        period: Period for calculations\n\n    Returns:\n        DataFrame with ADX values\n    \"\"\"\n    # Calculate True Range\n    df[\"high_low\"] = df[\"high\"] - df[\"low\"]\n    df[\"high_close\"] = abs(df[\"high\"] - df[\"close\"].shift())\n    df[\"low_close\"] = abs(df[\"low\"] - df[\"close\"].shift())\n    df[\"tr\"] = df[[\"high_low\", \"high_close\", \"low_close\"]].max(axis=1)\n\n    # Calculate Directional Movement\n    df[\"up_move\"] = df[\"high\"] - df[\"high\"].shift()\n    df[\"down_move\"] = df[\"low\"].shift() - df[\"low\"]\n\n    df[\"plus_dm\"] = np.where((df[\"up_move\"] > df[\"down_move\"]) & (df[\"up_move\"] > 0), df[\"up_move\"], 0)\n    df[\"minus_dm\"] = np.where((df[\"down_move\"] > df[\"up_move\"]) & (df[\"down_move\"] > 0), df[\"down_move\"], 0)\n\n    # Calculate ADX\n    df[\"+di\"] = 100 * (df[\"plus_dm\"].ewm(span=period).mean() / df[\"tr\"].ewm(span=period).mean())\n    df[\"-di\"] = 100 * (df[\"minus_dm\"].ewm(span=period).mean() / df[\"tr\"].ewm(span=period).mean())\n    df[\"dx\"] = 100 * abs(df[\"+di\"] - df[\"-di\"]) / (df[\"+di\"] + df[\"-di\"])\n    df[\"adx\"] = df[\"dx\"].ewm(span=period).mean()\n\n    return df[[\"adx\", \"+di\", \"-di\"]]\n\n\ndef calculate_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:\n    \"\"\"\n    Calculate Average True Range\n\n    Args:\n        df: DataFrame with OHLC data\n        period: Period for ATR calculation\n\n    Returns:\n        pd.Series: ATR values\n    \"\"\"\n    high_low = df[\"high\"] - df[\"low\"]\n    high_close = abs(df[\"high\"] - df[\"close\"].shift())\n    low_close = abs(df[\"low\"] - df[\"close\"].shift())\n\n    ranges = pd.concat([high_low, high_close, low_close], axis=1)\n    true_range = ranges.max(axis=1)\n\n    return true_range.rolling(period).mean()\n\n\ndef calculate_hurst_exponent(price_series: pd.Series, max_lag: int = 20) -> float:\n    \"\"\"\n    Calculate Hurst Exponent to determine long-term memory of time series\n    H < 0.5: Mean reverting series\n    H = 0.5: Random walk\n    H > 0.5: Trending series\n\n    Args:\n        price_series: Array-like price data\n        max_lag: Maximum lag for R/S calculation\n\n    Returns:\n        float: Hurst exponent\n    \"\"\"\n    lags = range(2, max_lag)\n    # Add small epsilon to avoid log(0)\n    tau = [max(1e-8, np.sqrt(np.std(np.subtract(price_series[lag:], price_series[:-lag])))) for lag in lags]\n\n    # Return the Hurst exponent from linear fit\n    try:\n        reg = np.polyfit(np.log(lags), np.log(tau), 1)\n        return reg[0]  # Hurst exponent is the slope\n    except (ValueError, RuntimeWarning):\n        # Return 0.5 (random walk) if calculation fails\n        return 0.5\n"
  },
  {
    "path": "src/agents/valuation.py",
    "content": "from __future__ import annotations\n\n\"\"\"Valuation Agent\n\nImplements four complementary valuation methodologies and aggregates them with\nconfigurable weights. \n\"\"\"\n\nimport json\nimport statistics\nfrom langchain_core.messages import HumanMessage\nfrom src.graph.state import AgentState, show_agent_reasoning\nfrom src.utils.progress import progress\nfrom src.utils.api_key import get_api_key_from_state\nfrom src.tools.api import (\n    get_financial_metrics,\n    get_market_cap,\n    search_line_items,\n)\n\ndef valuation_analyst_agent(state: AgentState, agent_id: str = \"valuation_analyst_agent\"):\n    \"\"\"Run valuation across tickers and write signals back to `state`.\"\"\"\n\n    data = state[\"data\"]\n    end_date = data[\"end_date\"]\n    tickers = data[\"tickers\"]\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    valuation_analysis: dict[str, dict] = {}\n\n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Fetching financial data\")\n\n        # --- Historical financial metrics ---\n        financial_metrics = get_financial_metrics(\n            ticker=ticker,\n            end_date=end_date,\n            period=\"ttm\",\n            limit=8,\n            api_key=api_key,\n        )\n        if not financial_metrics:\n            progress.update_status(agent_id, ticker, \"Failed: No financial metrics found\")\n            continue\n        most_recent_metrics = financial_metrics[0]\n\n        # --- Enhanced line‑items ---\n        progress.update_status(agent_id, ticker, \"Gathering comprehensive line items\")\n        line_items = search_line_items(\n            ticker=ticker,\n            line_items=[\n                \"free_cash_flow\",\n                \"net_income\",\n                \"depreciation_and_amortization\",\n                \"capital_expenditure\",\n                \"working_capital\",\n                \"total_debt\",\n                \"cash_and_equivalents\", \n                \"interest_expense\",\n                \"revenue\",\n                \"operating_income\",\n                \"ebit\",\n                \"ebitda\"\n            ],\n            end_date=end_date,\n            period=\"ttm\",\n            limit=8,\n            api_key=api_key,\n        )\n        if len(line_items) < 2:\n            progress.update_status(agent_id, ticker, \"Failed: Insufficient financial line items\")\n            continue\n        li_curr, li_prev = line_items[0], line_items[1]\n\n        # ------------------------------------------------------------------\n        # Valuation models\n        # ------------------------------------------------------------------\n        # Handle potential None values for working capital\n        if li_curr.working_capital is not None and li_prev.working_capital is not None:\n            wc_change = li_curr.working_capital - li_prev.working_capital\n        else:\n            wc_change = 0  # Default to 0 if working capital data is unavailable\n\n        # Owner Earnings\n        owner_val = calculate_owner_earnings_value(\n            net_income=li_curr.net_income,\n            depreciation=li_curr.depreciation_and_amortization,\n            capex=li_curr.capital_expenditure,\n            working_capital_change=wc_change,\n            growth_rate=most_recent_metrics.earnings_growth or 0.05,\n        )\n\n        # Enhanced Discounted Cash Flow with WACC and scenarios\n        progress.update_status(agent_id, ticker, \"Calculating WACC and enhanced DCF\")\n        \n        # Calculate WACC\n        wacc = calculate_wacc(\n            market_cap=most_recent_metrics.market_cap or 0,\n            total_debt=getattr(li_curr, 'total_debt', None),\n            cash=getattr(li_curr, 'cash_and_equivalents', None),\n            interest_coverage=most_recent_metrics.interest_coverage,\n            debt_to_equity=most_recent_metrics.debt_to_equity,\n        )\n        \n        # Prepare FCF history for enhanced DCF\n        fcf_history = []\n        for li in line_items:\n            if hasattr(li, 'free_cash_flow') and li.free_cash_flow is not None:\n                fcf_history.append(li.free_cash_flow)\n        \n        # Enhanced DCF with scenarios\n        dcf_results = calculate_dcf_scenarios(\n            fcf_history=fcf_history,\n            growth_metrics={\n                'revenue_growth': most_recent_metrics.revenue_growth,\n                'fcf_growth': most_recent_metrics.free_cash_flow_growth,\n                'earnings_growth': most_recent_metrics.earnings_growth\n            },\n            wacc=wacc,\n            market_cap=most_recent_metrics.market_cap or 0,\n            revenue_growth=most_recent_metrics.revenue_growth\n        )\n        \n        dcf_val = dcf_results['expected_value']\n\n        # Implied Equity Value\n        ev_ebitda_val = calculate_ev_ebitda_value(financial_metrics)\n\n        # Residual Income Model\n        rim_val = calculate_residual_income_value(\n            market_cap=most_recent_metrics.market_cap,\n            net_income=li_curr.net_income,\n            price_to_book_ratio=most_recent_metrics.price_to_book_ratio,\n            book_value_growth=most_recent_metrics.book_value_growth or 0.03,\n        )\n\n        # ------------------------------------------------------------------\n        # Aggregate & signal\n        # ------------------------------------------------------------------\n        market_cap = get_market_cap(ticker, end_date, api_key=api_key)\n        if not market_cap:\n            progress.update_status(agent_id, ticker, \"Failed: Market cap unavailable\")\n            continue\n\n        method_values = {\n            \"dcf\": {\"value\": dcf_val, \"weight\": 0.35},\n            \"owner_earnings\": {\"value\": owner_val, \"weight\": 0.35},\n            \"ev_ebitda\": {\"value\": ev_ebitda_val, \"weight\": 0.20},\n            \"residual_income\": {\"value\": rim_val, \"weight\": 0.10},\n        }\n\n        total_weight = sum(v[\"weight\"] for v in method_values.values() if v[\"value\"] > 0)\n        if total_weight == 0:\n            progress.update_status(agent_id, ticker, \"Failed: All valuation methods zero\")\n            continue\n\n        for v in method_values.values():\n            v[\"gap\"] = (v[\"value\"] - market_cap) / market_cap if v[\"value\"] > 0 else None\n\n        weighted_gap = sum(\n            v[\"weight\"] * v[\"gap\"] for v in method_values.values() if v[\"gap\"] is not None\n        ) / total_weight\n\n        signal = \"bullish\" if weighted_gap > 0.15 else \"bearish\" if weighted_gap < -0.15 else \"neutral\"\n        confidence = round(min(abs(weighted_gap) / 0.30 * 100, 100))\n\n        # Enhanced reasoning with DCF scenario details\n        reasoning = {}\n        for m, vals in method_values.items():\n            if vals[\"value\"] > 0:\n                base_details = (\n                    f\"Value: ${vals['value']:,.2f}, Market Cap: ${market_cap:,.2f}, \"\n                    f\"Gap: {vals['gap']:.1%}, Weight: {vals['weight']*100:.0f}%\"\n                )\n                \n                # Add enhanced DCF details\n                if m == \"dcf\" and 'dcf_results' in locals():\n                    enhanced_details = (\n                        f\"{base_details}\\n\"\n                        f\"  WACC: {wacc:.1%}, Bear: ${dcf_results['downside']:,.2f}, \"\n                        f\"Bull: ${dcf_results['upside']:,.2f}, Range: ${dcf_results['range']:,.2f}\"\n                    )\n                else:\n                    enhanced_details = base_details\n                \n                reasoning[f\"{m}_analysis\"] = {\n                    \"signal\": (\n                        \"bullish\" if vals[\"gap\"] and vals[\"gap\"] > 0.15 else\n                        \"bearish\" if vals[\"gap\"] and vals[\"gap\"] < -0.15 else \"neutral\"\n                    ),\n                    \"details\": enhanced_details,\n                }\n        \n        # Add overall DCF scenario summary if available\n        if 'dcf_results' in locals():\n            reasoning[\"dcf_scenario_analysis\"] = {\n                \"bear_case\": f\"${dcf_results['downside']:,.2f}\",\n                \"base_case\": f\"${dcf_results['scenarios']['base']:,.2f}\",  \n                \"bull_case\": f\"${dcf_results['upside']:,.2f}\",\n                \"wacc_used\": f\"{wacc:.1%}\",\n                \"fcf_periods_analyzed\": len(fcf_history)\n            }\n\n        valuation_analysis[ticker] = {\n            \"signal\": signal,\n            \"confidence\": confidence,\n            \"reasoning\": reasoning,\n        }\n        progress.update_status(agent_id, ticker, \"Done\", analysis=json.dumps(reasoning, indent=4))\n\n    # ---- Emit message (for LLM tool chain) ----\n    msg = HumanMessage(content=json.dumps(valuation_analysis), name=agent_id)\n    if state[\"metadata\"].get(\"show_reasoning\"):\n        show_agent_reasoning(valuation_analysis, \"Valuation Analysis Agent\")\n\n    # Add the signal to the analyst_signals list\n    state[\"data\"][\"analyst_signals\"][agent_id] = valuation_analysis\n\n    progress.update_status(agent_id, None, \"Done\")\n    \n    return {\"messages\": [msg], \"data\": data}\n\n#############################\n# Helper Valuation Functions\n#############################\n\ndef calculate_owner_earnings_value(\n    net_income: float | None,\n    depreciation: float | None,\n    capex: float | None,\n    working_capital_change: float | None,\n    growth_rate: float = 0.05,\n    required_return: float = 0.15,\n    margin_of_safety: float = 0.25,\n    num_years: int = 5,\n) -> float:\n    \"\"\"Buffett owner‑earnings valuation with margin‑of‑safety.\"\"\"\n    if not all(isinstance(x, (int, float)) for x in [net_income, depreciation, capex, working_capital_change]):\n        return 0\n\n    owner_earnings = net_income + depreciation - capex - working_capital_change\n    if owner_earnings <= 0:\n        return 0\n\n    pv = 0.0\n    for yr in range(1, num_years + 1):\n        future = owner_earnings * (1 + growth_rate) ** yr\n        pv += future / (1 + required_return) ** yr\n\n    terminal_growth = min(growth_rate, 0.03)\n    term_val = (owner_earnings * (1 + growth_rate) ** num_years * (1 + terminal_growth)) / (\n        required_return - terminal_growth\n    )\n    pv_term = term_val / (1 + required_return) ** num_years\n\n    intrinsic = pv + pv_term\n    return intrinsic * (1 - margin_of_safety)\n\n\ndef calculate_intrinsic_value(\n    free_cash_flow: float | None,\n    growth_rate: float = 0.05,\n    discount_rate: float = 0.10,\n    terminal_growth_rate: float = 0.02,\n    num_years: int = 5,\n) -> float:\n    \"\"\"Classic DCF on FCF with constant growth and terminal value.\"\"\"\n    if free_cash_flow is None or free_cash_flow <= 0:\n        return 0\n\n    pv = 0.0\n    for yr in range(1, num_years + 1):\n        fcft = free_cash_flow * (1 + growth_rate) ** yr\n        pv += fcft / (1 + discount_rate) ** yr\n\n    term_val = (\n        free_cash_flow * (1 + growth_rate) ** num_years * (1 + terminal_growth_rate)\n    ) / (discount_rate - terminal_growth_rate)\n    pv_term = term_val / (1 + discount_rate) ** num_years\n\n    return pv + pv_term\n\n\ndef calculate_ev_ebitda_value(financial_metrics: list):\n    \"\"\"Implied equity value via median EV/EBITDA multiple.\"\"\"\n    if not financial_metrics:\n        return 0\n    m0 = financial_metrics[0]\n    if not (m0.enterprise_value and m0.enterprise_value_to_ebitda_ratio):\n        return 0\n    if m0.enterprise_value_to_ebitda_ratio == 0:\n        return 0\n\n    ebitda_now = m0.enterprise_value / m0.enterprise_value_to_ebitda_ratio\n    med_mult = statistics.median([\n        m.enterprise_value_to_ebitda_ratio for m in financial_metrics if m.enterprise_value_to_ebitda_ratio\n    ])\n    ev_implied = med_mult * ebitda_now\n    net_debt = (m0.enterprise_value or 0) - (m0.market_cap or 0)\n    return max(ev_implied - net_debt, 0)\n\n\ndef calculate_residual_income_value(\n    market_cap: float | None,\n    net_income: float | None,\n    price_to_book_ratio: float | None,\n    book_value_growth: float = 0.03,\n    cost_of_equity: float = 0.10,\n    terminal_growth_rate: float = 0.03,\n    num_years: int = 5,\n):\n    \"\"\"Residual Income Model (Edwards‑Bell‑Ohlson).\"\"\"\n    if not (market_cap and net_income and price_to_book_ratio and price_to_book_ratio > 0):\n        return 0\n\n    book_val = market_cap / price_to_book_ratio\n    ri0 = net_income - cost_of_equity * book_val\n    if ri0 <= 0:\n        return 0\n\n    pv_ri = 0.0\n    for yr in range(1, num_years + 1):\n        ri_t = ri0 * (1 + book_value_growth) ** yr\n        pv_ri += ri_t / (1 + cost_of_equity) ** yr\n\n    term_ri = ri0 * (1 + book_value_growth) ** (num_years + 1) / (\n        cost_of_equity - terminal_growth_rate\n    )\n    pv_term = term_ri / (1 + cost_of_equity) ** num_years\n\n    intrinsic = book_val + pv_ri + pv_term\n    return intrinsic * 0.8  # 20% margin of safety\n\n\n####################################\n# Enhanced DCF Helper Functions\n####################################\n\ndef calculate_wacc(\n    market_cap: float,\n    total_debt: float | None,\n    cash: float | None,\n    interest_coverage: float | None,\n    debt_to_equity: float | None,\n    beta_proxy: float = 1.0,\n    risk_free_rate: float = 0.045,\n    market_risk_premium: float = 0.06\n) -> float:\n    \"\"\"Calculate WACC using available financial data.\"\"\"\n    \n    # Cost of Equity (CAPM)\n    cost_of_equity = risk_free_rate + beta_proxy * market_risk_premium\n    \n    # Cost of Debt - estimate from interest coverage\n    if interest_coverage and interest_coverage > 0:\n        # Higher coverage = lower cost of debt\n        cost_of_debt = max(risk_free_rate + 0.01, risk_free_rate + (10 / interest_coverage))\n    else:\n        cost_of_debt = risk_free_rate + 0.05  # Default spread\n    \n    # Weights\n    net_debt = max((total_debt or 0) - (cash or 0), 0)\n    total_value = market_cap + net_debt\n    \n    if total_value > 0:\n        weight_equity = market_cap / total_value\n        weight_debt = net_debt / total_value\n        \n        # Tax shield (assume 25% corporate tax rate)\n        wacc = (weight_equity * cost_of_equity) + (weight_debt * cost_of_debt * 0.75)\n    else:\n        wacc = cost_of_equity\n    \n    return min(max(wacc, 0.06), 0.20)  # Floor 6%, cap 20%\n\n\ndef calculate_fcf_volatility(fcf_history: list[float]) -> float:\n    \"\"\"Calculate FCF volatility as coefficient of variation.\"\"\"\n    if len(fcf_history) < 3:\n        return 0.5  # Default moderate volatility\n    \n    # Filter out zeros and negatives for volatility calc\n    positive_fcf = [fcf for fcf in fcf_history if fcf > 0]\n    if len(positive_fcf) < 2:\n        return 0.8  # High volatility if mostly negative FCF\n    \n    try:\n        mean_fcf = statistics.mean(positive_fcf)\n        std_fcf = statistics.stdev(positive_fcf)\n        return min(std_fcf / mean_fcf, 1.0) if mean_fcf > 0 else 0.8\n    except:\n        return 0.5\n\n\ndef calculate_enhanced_dcf_value(\n    fcf_history: list[float],\n    growth_metrics: dict,\n    wacc: float,\n    market_cap: float,\n    revenue_growth: float | None = None\n) -> float:\n    \"\"\"Enhanced DCF with multi-stage growth.\"\"\"\n    \n    if not fcf_history or fcf_history[0] <= 0:\n        return 0\n    \n    # Analyze FCF trend and quality\n    fcf_current = fcf_history[0]\n    fcf_avg_3yr = sum(fcf_history[:3]) / min(3, len(fcf_history))\n    fcf_volatility = calculate_fcf_volatility(fcf_history)\n    \n    # Stage 1: High Growth (Years 1-3)\n    # Use revenue growth but cap based on business maturity\n    high_growth = min(revenue_growth or 0.05, 0.25) if revenue_growth else 0.05\n    if market_cap > 50_000_000_000:  # Large cap\n        high_growth = min(high_growth, 0.10)\n    \n    # Stage 2: Transition (Years 4-7)\n    transition_growth = (high_growth + 0.03) / 2\n    \n    # Stage 3: Terminal (steady state)\n    terminal_growth = min(0.03, high_growth * 0.6)\n    \n    # Project FCF with stages\n    pv = 0\n    base_fcf = max(fcf_current, fcf_avg_3yr * 0.85)  # Conservative base\n    \n    # High growth stage\n    for year in range(1, 4):\n        fcf_projected = base_fcf * (1 + high_growth) ** year\n        pv += fcf_projected / (1 + wacc) ** year\n    \n    # Transition stage\n    for year in range(4, 8):\n        transition_rate = transition_growth * (8 - year) / 4  # Declining\n        fcf_projected = base_fcf * (1 + high_growth) ** 3 * (1 + transition_rate) ** (year - 3)\n        pv += fcf_projected / (1 + wacc) ** year\n    \n    # Terminal value\n    final_fcf = base_fcf * (1 + high_growth) ** 3 * (1 + transition_growth) ** 4\n    if wacc <= terminal_growth:\n        terminal_growth = wacc * 0.8  # Adjust if invalid\n    terminal_value = (final_fcf * (1 + terminal_growth)) / (wacc - terminal_growth)\n    pv_terminal = terminal_value / (1 + wacc) ** 7\n    \n    # Quality adjustment based on FCF volatility\n    quality_factor = max(0.7, 1 - (fcf_volatility * 0.5))\n    \n    return (pv + pv_terminal) * quality_factor\n\n\ndef calculate_dcf_scenarios(\n    fcf_history: list[float],\n    growth_metrics: dict,\n    wacc: float,\n    market_cap: float,\n    revenue_growth: float | None = None\n) -> dict:\n    \"\"\"Calculate DCF under multiple scenarios.\"\"\"\n    \n    scenarios = {\n        'bear': {'growth_adj': 0.5, 'wacc_adj': 1.2, 'terminal_adj': 0.8},\n        'base': {'growth_adj': 1.0, 'wacc_adj': 1.0, 'terminal_adj': 1.0},\n        'bull': {'growth_adj': 1.5, 'wacc_adj': 0.9, 'terminal_adj': 1.2}\n    }\n    \n    results = {}\n    base_revenue_growth = revenue_growth or 0.05\n    \n    for scenario, adjustments in scenarios.items():\n        adjusted_revenue_growth = base_revenue_growth * adjustments['growth_adj']\n        adjusted_wacc = wacc * adjustments['wacc_adj']\n        \n        results[scenario] = calculate_enhanced_dcf_value(\n            fcf_history=fcf_history,\n            growth_metrics=growth_metrics,\n            wacc=adjusted_wacc,\n            market_cap=market_cap,\n            revenue_growth=adjusted_revenue_growth\n        )\n    \n    # Probability-weighted average\n    expected_value = (\n        results['bear'] * 0.2 + \n        results['base'] * 0.6 + \n        results['bull'] * 0.2\n    )\n    \n    return {\n        'scenarios': results,\n        'expected_value': expected_value,\n        'range': results['bull'] - results['bear'],\n        'upside': results['bull'],\n        'downside': results['bear']\n    }\n"
  },
  {
    "path": "src/agents/warren_buffett.py",
    "content": "from src.graph.state import AgentState, show_agent_reasoning\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_core.messages import HumanMessage\nfrom pydantic import BaseModel, Field\nimport json\nfrom typing_extensions import Literal\nfrom src.tools.api import get_financial_metrics, get_market_cap, search_line_items\nfrom src.utils.llm import call_llm\nfrom src.utils.progress import progress\nfrom src.utils.api_key import get_api_key_from_state\n\n\nclass WarrenBuffettSignal(BaseModel):\n    signal: Literal[\"bullish\", \"bearish\", \"neutral\"]\n    confidence: int = Field(description=\"Confidence 0-100\")\n    reasoning: str = Field(description=\"Reasoning for the decision\")\n\n\ndef warren_buffett_agent(state: AgentState, agent_id: str = \"warren_buffett_agent\"):\n    \"\"\"Analyzes stocks using Buffett's principles and LLM reasoning.\"\"\"\n    data = state[\"data\"]\n    end_date = data[\"end_date\"]\n    tickers = data[\"tickers\"]\n    api_key = get_api_key_from_state(state, \"FINANCIAL_DATASETS_API_KEY\")\n    # Collect all analysis for LLM reasoning\n    analysis_data = {}\n    buffett_analysis = {}\n\n    for ticker in tickers:\n        progress.update_status(agent_id, ticker, \"Fetching financial metrics\")\n        # Fetch required data - request more periods for better trend analysis\n        metrics = get_financial_metrics(ticker, end_date, period=\"ttm\", limit=10, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Gathering financial line items\")\n        financial_line_items = search_line_items(\n            ticker,\n            [\n                \"capital_expenditure\",\n                \"depreciation_and_amortization\",\n                \"net_income\",\n                \"outstanding_shares\",\n                \"total_assets\",\n                \"total_liabilities\",\n                \"shareholders_equity\",\n                \"dividends_and_other_cash_distributions\",\n                \"issuance_or_purchase_of_equity_shares\",\n                \"gross_profit\",\n                \"revenue\",\n                \"free_cash_flow\",\n            ],\n            end_date,\n            period=\"ttm\",\n            limit=10,\n            api_key=api_key,\n        )\n\n        progress.update_status(agent_id, ticker, \"Getting market cap\")\n        # Get current market cap\n        market_cap = get_market_cap(ticker, end_date, api_key=api_key)\n\n        progress.update_status(agent_id, ticker, \"Analyzing fundamentals\")\n        # Analyze fundamentals\n        fundamental_analysis = analyze_fundamentals(metrics)\n\n        progress.update_status(agent_id, ticker, \"Analyzing consistency\")\n        consistency_analysis = analyze_consistency(financial_line_items)\n\n        progress.update_status(agent_id, ticker, \"Analyzing competitive moat\")\n        moat_analysis = analyze_moat(metrics)\n\n        progress.update_status(agent_id, ticker, \"Analyzing pricing power\")\n        pricing_power_analysis = analyze_pricing_power(financial_line_items, metrics)\n\n        progress.update_status(agent_id, ticker, \"Analyzing book value growth\")\n        book_value_analysis = analyze_book_value_growth(financial_line_items)\n\n        progress.update_status(agent_id, ticker, \"Analyzing management quality\")\n        mgmt_analysis = analyze_management_quality(financial_line_items)\n\n        progress.update_status(agent_id, ticker, \"Calculating intrinsic value\")\n        intrinsic_value_analysis = calculate_intrinsic_value(financial_line_items)\n\n        # Calculate total score without circle of competence (LLM will handle that)\n        total_score = (\n                fundamental_analysis[\"score\"] +\n                consistency_analysis[\"score\"] +\n                moat_analysis[\"score\"] +\n                mgmt_analysis[\"score\"] +\n                pricing_power_analysis[\"score\"] +\n                book_value_analysis[\"score\"]\n        )\n\n        # Update max possible score calculation\n        max_possible_score = (\n                10 +  # fundamental_analysis (ROE, debt, margins, current ratio)\n                moat_analysis[\"max_score\"] +\n                mgmt_analysis[\"max_score\"] +\n                5 +  # pricing_power (0-5)\n                5  # book_value_growth (0-5)\n        )\n\n        # Add margin of safety analysis if we have both intrinsic value and current price\n        margin_of_safety = None\n        intrinsic_value = intrinsic_value_analysis[\"intrinsic_value\"]\n        if intrinsic_value and market_cap:\n            margin_of_safety = (intrinsic_value - market_cap) / market_cap\n\n        # Combine all analysis results for LLM evaluation\n        analysis_data[ticker] = {\n            \"ticker\": ticker,\n            \"score\": total_score,\n            \"max_score\": max_possible_score,\n            \"fundamental_analysis\": fundamental_analysis,\n            \"consistency_analysis\": consistency_analysis,\n            \"moat_analysis\": moat_analysis,\n            \"pricing_power_analysis\": pricing_power_analysis,\n            \"book_value_analysis\": book_value_analysis,\n            \"management_analysis\": mgmt_analysis,\n            \"intrinsic_value_analysis\": intrinsic_value_analysis,\n            \"market_cap\": market_cap,\n            \"margin_of_safety\": margin_of_safety,\n        }\n\n        progress.update_status(agent_id, ticker, \"Generating Warren Buffett analysis\")\n        buffett_output = generate_buffett_output(\n            ticker=ticker,\n            analysis_data=analysis_data[ticker],\n            state=state,\n            agent_id=agent_id,\n        )\n\n        # Store analysis in consistent format with other agents\n        buffett_analysis[ticker] = {\n            \"signal\": buffett_output.signal,\n            \"confidence\": buffett_output.confidence,\n            \"reasoning\": buffett_output.reasoning,\n        }\n\n        progress.update_status(agent_id, ticker, \"Done\", analysis=buffett_output.reasoning)\n\n    # Create the message\n    message = HumanMessage(content=json.dumps(buffett_analysis), name=agent_id)\n\n    # Show reasoning if requested\n    if state[\"metadata\"][\"show_reasoning\"]:\n        show_agent_reasoning(buffett_analysis, agent_id)\n\n    # Add the signal to the analyst_signals list\n    state[\"data\"][\"analyst_signals\"][agent_id] = buffett_analysis\n\n    progress.update_status(agent_id, None, \"Done\")\n\n    return {\"messages\": [message], \"data\": state[\"data\"]}\n\n\ndef analyze_fundamentals(metrics: list) -> dict[str, any]:\n    \"\"\"Analyze company fundamentals based on Buffett's criteria.\"\"\"\n    if not metrics:\n        return {\"score\": 0, \"details\": \"Insufficient fundamental data\"}\n\n    latest_metrics = metrics[0]\n\n    score = 0\n    reasoning = []\n\n    # Check ROE (Return on Equity)\n    if latest_metrics.return_on_equity and latest_metrics.return_on_equity > 0.15:  # 15% ROE threshold\n        score += 2\n        reasoning.append(f\"Strong ROE of {latest_metrics.return_on_equity:.1%}\")\n    elif latest_metrics.return_on_equity:\n        reasoning.append(f\"Weak ROE of {latest_metrics.return_on_equity:.1%}\")\n    else:\n        reasoning.append(\"ROE data not available\")\n\n    # Check Debt to Equity\n    if latest_metrics.debt_to_equity and latest_metrics.debt_to_equity < 0.5:\n        score += 2\n        reasoning.append(\"Conservative debt levels\")\n    elif latest_metrics.debt_to_equity:\n        reasoning.append(f\"High debt to equity ratio of {latest_metrics.debt_to_equity:.1f}\")\n    else:\n        reasoning.append(\"Debt to equity data not available\")\n\n    # Check Operating Margin\n    if latest_metrics.operating_margin and latest_metrics.operating_margin > 0.15:\n        score += 2\n        reasoning.append(\"Strong operating margins\")\n    elif latest_metrics.operating_margin:\n        reasoning.append(f\"Weak operating margin of {latest_metrics.operating_margin:.1%}\")\n    else:\n        reasoning.append(\"Operating margin data not available\")\n\n    # Check Current Ratio\n    if latest_metrics.current_ratio and latest_metrics.current_ratio > 1.5:\n        score += 1\n        reasoning.append(\"Good liquidity position\")\n    elif latest_metrics.current_ratio:\n        reasoning.append(f\"Weak liquidity with current ratio of {latest_metrics.current_ratio:.1f}\")\n    else:\n        reasoning.append(\"Current ratio data not available\")\n\n    return {\"score\": score, \"details\": \"; \".join(reasoning), \"metrics\": latest_metrics.model_dump()}\n\n\ndef analyze_consistency(financial_line_items: list) -> dict[str, any]:\n    \"\"\"Analyze earnings consistency and growth.\"\"\"\n    if len(financial_line_items) < 4:  # Need at least 4 periods for trend analysis\n        return {\"score\": 0, \"details\": \"Insufficient historical data\"}\n\n    score = 0\n    reasoning = []\n\n    # Check earnings growth trend\n    earnings_values = [item.net_income for item in financial_line_items if item.net_income]\n    if len(earnings_values) >= 4:\n        # Simple check: is each period's earnings bigger than the next?\n        earnings_growth = all(earnings_values[i] > earnings_values[i + 1] for i in range(len(earnings_values) - 1))\n\n        if earnings_growth:\n            score += 3\n            reasoning.append(\"Consistent earnings growth over past periods\")\n        else:\n            reasoning.append(\"Inconsistent earnings growth pattern\")\n\n        # Calculate total growth rate from oldest to latest\n        if len(earnings_values) >= 2 and earnings_values[-1] != 0:\n            growth_rate = (earnings_values[0] - earnings_values[-1]) / abs(earnings_values[-1])\n            reasoning.append(f\"Total earnings growth of {growth_rate:.1%} over past {len(earnings_values)} periods\")\n    else:\n        reasoning.append(\"Insufficient earnings data for trend analysis\")\n\n    return {\n        \"score\": score,\n        \"details\": \"; \".join(reasoning),\n    }\n\n\ndef analyze_moat(metrics: list) -> dict[str, any]:\n    \"\"\"\n    Evaluate whether the company likely has a durable competitive advantage (moat).\n    Enhanced to include multiple moat indicators that Buffett actually looks for:\n    1. Consistent high returns on capital\n    2. Pricing power (stable/growing margins)\n    3. Scale advantages (improving metrics with size)\n    4. Brand strength (inferred from margins and consistency)\n    5. Switching costs (inferred from customer retention)\n    \"\"\"\n    if not metrics or len(metrics) < 5:  # Need more data for proper moat analysis\n        return {\"score\": 0, \"max_score\": 5, \"details\": \"Insufficient data for comprehensive moat analysis\"}\n\n    reasoning = []\n    moat_score = 0\n    max_score = 5\n\n    # 1. Return on Capital Consistency (Buffett's favorite moat indicator)\n    historical_roes = [m.return_on_equity for m in metrics if m.return_on_equity is not None]\n    historical_roics = [m.return_on_invested_capital for m in metrics if\n                        hasattr(m, 'return_on_invested_capital') and m.return_on_invested_capital is not None]\n\n    if len(historical_roes) >= 5:\n        # Check for consistently high ROE (>15% for most periods)\n        high_roe_periods = sum(1 for roe in historical_roes if roe > 0.15)\n        roe_consistency = high_roe_periods / len(historical_roes)\n\n        if roe_consistency >= 0.8:  # 80%+ of periods with ROE > 15%\n            moat_score += 2\n            avg_roe = sum(historical_roes) / len(historical_roes)\n            reasoning.append(\n                f\"Excellent ROE consistency: {high_roe_periods}/{len(historical_roes)} periods >15% (avg: {avg_roe:.1%}) - indicates durable competitive advantage\")\n        elif roe_consistency >= 0.6:\n            moat_score += 1\n            reasoning.append(f\"Good ROE performance: {high_roe_periods}/{len(historical_roes)} periods >15%\")\n        else:\n            reasoning.append(f\"Inconsistent ROE: only {high_roe_periods}/{len(historical_roes)} periods >15%\")\n    else:\n        reasoning.append(\"Insufficient ROE history for moat analysis\")\n\n    # 2. Operating Margin Stability (Pricing Power Indicator)\n    historical_margins = [m.operating_margin for m in metrics if m.operating_margin is not None]\n    if len(historical_margins) >= 5:\n        # Check for stable or improving margins (sign of pricing power)\n        avg_margin = sum(historical_margins) / len(historical_margins)\n        recent_margins = historical_margins[:3]  # Last 3 periods\n        older_margins = historical_margins[-3:]  # First 3 periods\n\n        recent_avg = sum(recent_margins) / len(recent_margins)\n        older_avg = sum(older_margins) / len(older_margins)\n\n        if avg_margin > 0.2 and recent_avg >= older_avg:  # 20%+ margins and stable/improving\n            moat_score += 1\n            reasoning.append(f\"Strong and stable operating margins (avg: {avg_margin:.1%}) indicate pricing power moat\")\n        elif avg_margin > 0.15:  # At least decent margins\n            reasoning.append(f\"Decent operating margins (avg: {avg_margin:.1%}) suggest some competitive advantage\")\n        else:\n            reasoning.append(f\"Low operating margins (avg: {avg_margin:.1%}) suggest limited pricing power\")\n\n    # 3. Asset Efficiency and Scale Advantages\n    if len(metrics) >= 5:\n        # Check asset turnover trends (revenue efficiency)\n        asset_turnovers = []\n        for m in metrics:\n            if hasattr(m, 'asset_turnover') and m.asset_turnover is not None:\n                asset_turnovers.append(m.asset_turnover)\n\n        if len(asset_turnovers) >= 3:\n            if any(turnover > 1.0 for turnover in asset_turnovers):  # Efficient asset use\n                moat_score += 1\n                reasoning.append(\"Efficient asset utilization suggests operational moat\")\n\n    # 4. Competitive Position Strength (inferred from trend stability)\n    if len(historical_roes) >= 5 and len(historical_margins) >= 5:\n        # Calculate coefficient of variation (stability measure)\n        roe_avg = sum(historical_roes) / len(historical_roes)\n        roe_variance = sum((roe - roe_avg) ** 2 for roe in historical_roes) / len(historical_roes)\n        roe_stability = 1 - (roe_variance ** 0.5) / roe_avg if roe_avg > 0 else 0\n\n        margin_avg = sum(historical_margins) / len(historical_margins)\n        margin_variance = sum((margin - margin_avg) ** 2 for margin in historical_margins) / len(historical_margins)\n        margin_stability = 1 - (margin_variance ** 0.5) / margin_avg if margin_avg > 0 else 0\n\n        overall_stability = (roe_stability + margin_stability) / 2\n\n        if overall_stability > 0.7:  # High stability indicates strong competitive position\n            moat_score += 1\n            reasoning.append(f\"High performance stability ({overall_stability:.1%}) suggests strong competitive moat\")\n\n    # Cap the score at max_score\n    moat_score = min(moat_score, max_score)\n\n    return {\n        \"score\": moat_score,\n        \"max_score\": max_score,\n        \"details\": \"; \".join(reasoning) if reasoning else \"Limited moat analysis available\",\n    }\n\n\ndef analyze_management_quality(financial_line_items: list) -> dict[str, any]:\n    \"\"\"\n    Checks for share dilution or consistent buybacks, and some dividend track record.\n    A simplified approach:\n      - if there's net share repurchase or stable share count, it suggests management\n        might be shareholder-friendly.\n      - if there's a big new issuance, it might be a negative sign (dilution).\n    \"\"\"\n    if not financial_line_items:\n        return {\"score\": 0, \"max_score\": 2, \"details\": \"Insufficient data for management analysis\"}\n\n    reasoning = []\n    mgmt_score = 0\n\n    latest = financial_line_items[0]\n    if hasattr(latest,\n               \"issuance_or_purchase_of_equity_shares\") and latest.issuance_or_purchase_of_equity_shares and latest.issuance_or_purchase_of_equity_shares < 0:\n        # Negative means the company spent money on buybacks\n        mgmt_score += 1\n        reasoning.append(\"Company has been repurchasing shares (shareholder-friendly)\")\n\n    if hasattr(latest,\n               \"issuance_or_purchase_of_equity_shares\") and latest.issuance_or_purchase_of_equity_shares and latest.issuance_or_purchase_of_equity_shares > 0:\n        # Positive issuance means new shares => possible dilution\n        reasoning.append(\"Recent common stock issuance (potential dilution)\")\n    else:\n        reasoning.append(\"No significant new stock issuance detected\")\n\n    # Check for any dividends\n    if hasattr(latest,\n               \"dividends_and_other_cash_distributions\") and latest.dividends_and_other_cash_distributions and latest.dividends_and_other_cash_distributions < 0:\n        mgmt_score += 1\n        reasoning.append(\"Company has a track record of paying dividends\")\n    else:\n        reasoning.append(\"No or minimal dividends paid\")\n\n    return {\n        \"score\": mgmt_score,\n        \"max_score\": 2,\n        \"details\": \"; \".join(reasoning),\n    }\n\n\ndef calculate_owner_earnings(financial_line_items: list) -> dict[str, any]:\n    \"\"\"\n    Calculate owner earnings (Buffett's preferred measure of true earnings power).\n    Enhanced methodology: Net Income + Depreciation/Amortization - Maintenance CapEx - Working Capital Changes\n    Uses multi-period analysis for better maintenance capex estimation.\n    \"\"\"\n    if not financial_line_items or len(financial_line_items) < 2:\n        return {\"owner_earnings\": None, \"details\": [\"Insufficient data for owner earnings calculation\"]}\n\n    latest = financial_line_items[0]\n    details = []\n\n    # Core components\n    net_income = latest.net_income\n    depreciation = latest.depreciation_and_amortization\n    capex = latest.capital_expenditure\n\n    if not all([net_income is not None, depreciation is not None, capex is not None]):\n        missing = []\n        if net_income is None: missing.append(\"net income\")\n        if depreciation is None: missing.append(\"depreciation\")\n        if capex is None: missing.append(\"capital expenditure\")\n        return {\"owner_earnings\": None, \"details\": [f\"Missing components: {', '.join(missing)}\"]}\n\n    # Enhanced maintenance capex estimation using historical analysis\n    maintenance_capex = estimate_maintenance_capex(financial_line_items)\n\n    # Working capital change analysis (if data available)\n    working_capital_change = 0\n    if len(financial_line_items) >= 2:\n        try:\n            current_assets_current = getattr(latest, 'current_assets', None)\n            current_liab_current = getattr(latest, 'current_liabilities', None)\n\n            previous = financial_line_items[1]\n            current_assets_previous = getattr(previous, 'current_assets', None)\n            current_liab_previous = getattr(previous, 'current_liabilities', None)\n\n            if all([current_assets_current, current_liab_current, current_assets_previous, current_liab_previous]):\n                wc_current = current_assets_current - current_liab_current\n                wc_previous = current_assets_previous - current_liab_previous\n                working_capital_change = wc_current - wc_previous\n                details.append(f\"Working capital change: ${working_capital_change:,.0f}\")\n        except:\n            pass  # Skip working capital adjustment if data unavailable\n\n    # Calculate owner earnings\n    owner_earnings = net_income + depreciation - maintenance_capex - working_capital_change\n\n    # Sanity checks\n    if owner_earnings < net_income * 0.3:  # Owner earnings shouldn't be less than 30% of net income typically\n        details.append(\"Warning: Owner earnings significantly below net income - high capex intensity\")\n\n    if maintenance_capex > depreciation * 2:  # Maintenance capex shouldn't typically exceed 2x depreciation\n        details.append(\"Warning: Estimated maintenance capex seems high relative to depreciation\")\n\n    details.extend([\n        f\"Net income: ${net_income:,.0f}\",\n        f\"Depreciation: ${depreciation:,.0f}\",\n        f\"Estimated maintenance capex: ${maintenance_capex:,.0f}\",\n        f\"Owner earnings: ${owner_earnings:,.0f}\"\n    ])\n\n    return {\n        \"owner_earnings\": owner_earnings,\n        \"components\": {\n            \"net_income\": net_income,\n            \"depreciation\": depreciation,\n            \"maintenance_capex\": maintenance_capex,\n            \"working_capital_change\": working_capital_change,\n            \"total_capex\": abs(capex) if capex else 0\n        },\n        \"details\": details,\n    }\n\n\ndef estimate_maintenance_capex(financial_line_items: list) -> float:\n    \"\"\"\n    Estimate maintenance capital expenditure using multiple approaches.\n    Buffett considers this crucial for understanding true owner earnings.\n    \"\"\"\n    if not financial_line_items:\n        return 0\n\n    # Approach 1: Historical average as % of revenue\n    capex_ratios = []\n    depreciation_values = []\n\n    for item in financial_line_items[:5]:  # Last 5 periods\n        if hasattr(item, 'capital_expenditure') and hasattr(item, 'revenue'):\n            if item.capital_expenditure and item.revenue and item.revenue > 0:\n                capex_ratio = abs(item.capital_expenditure) / item.revenue\n                capex_ratios.append(capex_ratio)\n\n        if hasattr(item, 'depreciation_and_amortization') and item.depreciation_and_amortization:\n            depreciation_values.append(item.depreciation_and_amortization)\n\n    # Approach 2: Percentage of depreciation (typically 80-120% for maintenance)\n    latest_depreciation = financial_line_items[0].depreciation_and_amortization if financial_line_items[\n        0].depreciation_and_amortization else 0\n\n    # Approach 3: Industry-specific heuristics\n    latest_capex = abs(financial_line_items[0].capital_expenditure) if financial_line_items[\n        0].capital_expenditure else 0\n\n    # Conservative estimate: Use the higher of:\n    # 1. 85% of total capex (assuming 15% is growth capex)\n    # 2. 100% of depreciation (replacement of worn-out assets)\n    # 3. Historical average if stable\n\n    method_1 = latest_capex * 0.85  # 85% of total capex\n    method_2 = latest_depreciation  # 100% of depreciation\n\n    # If we have historical data, use average capex ratio\n    if len(capex_ratios) >= 3:\n        avg_capex_ratio = sum(capex_ratios) / len(capex_ratios)\n        latest_revenue = financial_line_items[0].revenue if hasattr(financial_line_items[0], 'revenue') and \\\n                                                            financial_line_items[0].revenue else 0\n        method_3 = avg_capex_ratio * latest_revenue if latest_revenue else 0\n\n        # Use the median of the three approaches for conservatism\n        estimates = sorted([method_1, method_2, method_3])\n        return estimates[1]  # Median\n    else:\n        # Use the higher of method 1 and 2\n        return max(method_1, method_2)\n\n\ndef calculate_intrinsic_value(financial_line_items: list) -> dict[str, any]:\n    \"\"\"\n    Calculate intrinsic value using enhanced DCF with owner earnings.\n    Uses more sophisticated assumptions and conservative approach like Buffett.\n    \"\"\"\n    if not financial_line_items or len(financial_line_items) < 3:\n        return {\"intrinsic_value\": None, \"details\": [\"Insufficient data for reliable valuation\"]}\n\n    # Calculate owner earnings with better methodology\n    earnings_data = calculate_owner_earnings(financial_line_items)\n    if not earnings_data[\"owner_earnings\"]:\n        return {\"intrinsic_value\": None, \"details\": earnings_data[\"details\"]}\n\n    owner_earnings = earnings_data[\"owner_earnings\"]\n    latest_financial_line_items = financial_line_items[0]\n    shares_outstanding = latest_financial_line_items.outstanding_shares\n\n    if not shares_outstanding or shares_outstanding <= 0:\n        return {\"intrinsic_value\": None, \"details\": [\"Missing or invalid shares outstanding data\"]}\n\n    # Enhanced DCF with more realistic assumptions\n    details = []\n\n    # Estimate growth rate based on historical performance (more conservative)\n    historical_earnings = []\n    for item in financial_line_items[:5]:  # Last 5 years\n        if hasattr(item, 'net_income') and item.net_income:\n            historical_earnings.append(item.net_income)\n\n    # Calculate historical growth rate\n    if len(historical_earnings) >= 3:\n        oldest_earnings = historical_earnings[-1]\n        latest_earnings = historical_earnings[0]\n        years = len(historical_earnings) - 1\n\n        if oldest_earnings > 0:\n            historical_growth = ((latest_earnings / oldest_earnings) ** (1 / years)) - 1\n            # Conservative adjustment - cap growth and apply haircut\n            historical_growth = max(-0.05, min(historical_growth, 0.15))  # Cap between -5% and 15%\n            conservative_growth = historical_growth * 0.7  # Apply 30% haircut for conservatism\n        else:\n            conservative_growth = 0.03  # Default 3% if negative base\n    else:\n        conservative_growth = 0.03  # Default conservative growth\n\n    # Buffett's conservative assumptions\n    stage1_growth = min(conservative_growth, 0.08)  # Stage 1: cap at 8%\n    stage2_growth = min(conservative_growth * 0.5, 0.04)  # Stage 2: half of stage 1, cap at 4%\n    terminal_growth = 0.025  # Long-term GDP growth rate\n\n    # Risk-adjusted discount rate based on business quality\n    base_discount_rate = 0.09  # Base 9%\n\n    # Adjust based on analysis scores (if available in calling context)\n    # For now, use conservative 10%\n    discount_rate = 0.10\n\n    # Three-stage DCF model\n    stage1_years = 5  # High growth phase\n    stage2_years = 5  # Transition phase\n\n    present_value = 0\n    details.append(\n        f\"Using three-stage DCF: Stage 1 ({stage1_growth:.1%}, {stage1_years}y), Stage 2 ({stage2_growth:.1%}, {stage2_years}y), Terminal ({terminal_growth:.1%})\")\n\n    # Stage 1: Higher growth\n    stage1_pv = 0\n    for year in range(1, stage1_years + 1):\n        future_earnings = owner_earnings * (1 + stage1_growth) ** year\n        pv = future_earnings / (1 + discount_rate) ** year\n        stage1_pv += pv\n\n    # Stage 2: Transition growth\n    stage2_pv = 0\n    stage1_final_earnings = owner_earnings * (1 + stage1_growth) ** stage1_years\n    for year in range(1, stage2_years + 1):\n        future_earnings = stage1_final_earnings * (1 + stage2_growth) ** year\n        pv = future_earnings / (1 + discount_rate) ** (stage1_years + year)\n        stage2_pv += pv\n\n    # Terminal value using Gordon Growth Model\n    final_earnings = stage1_final_earnings * (1 + stage2_growth) ** stage2_years\n    terminal_earnings = final_earnings * (1 + terminal_growth)\n    terminal_value = terminal_earnings / (discount_rate - terminal_growth)\n    terminal_pv = terminal_value / (1 + discount_rate) ** (stage1_years + stage2_years)\n\n    # Total intrinsic value\n    intrinsic_value = stage1_pv + stage2_pv + terminal_pv\n\n    # Apply additional margin of safety (Buffett's conservatism)\n    conservative_intrinsic_value = intrinsic_value * 0.85  # 15% additional haircut\n\n    details.extend([\n        f\"Stage 1 PV: ${stage1_pv:,.0f}\",\n        f\"Stage 2 PV: ${stage2_pv:,.0f}\",\n        f\"Terminal PV: ${terminal_pv:,.0f}\",\n        f\"Total IV: ${intrinsic_value:,.0f}\",\n        f\"Conservative IV (15% haircut): ${conservative_intrinsic_value:,.0f}\",\n        f\"Owner earnings: ${owner_earnings:,.0f}\",\n        f\"Discount rate: {discount_rate:.1%}\"\n    ])\n\n    return {\n        \"intrinsic_value\": conservative_intrinsic_value,\n        \"raw_intrinsic_value\": intrinsic_value,\n        \"owner_earnings\": owner_earnings,\n        \"assumptions\": {\n            \"stage1_growth\": stage1_growth,\n            \"stage2_growth\": stage2_growth,\n            \"terminal_growth\": terminal_growth,\n            \"discount_rate\": discount_rate,\n            \"stage1_years\": stage1_years,\n            \"stage2_years\": stage2_years,\n            \"historical_growth\": conservative_growth if 'conservative_growth' in locals() else None,\n        },\n        \"details\": details,\n    }\n\n\ndef analyze_book_value_growth(financial_line_items: list) -> dict[str, any]:\n    \"\"\"Analyze book value per share growth - a key Buffett metric.\"\"\"\n    if len(financial_line_items) < 3:\n        return {\"score\": 0, \"details\": \"Insufficient data for book value analysis\"}\n\n    # Extract book values per share\n    book_values = [\n        item.shareholders_equity / item.outstanding_shares\n        for item in financial_line_items\n        if hasattr(item, 'shareholders_equity') and hasattr(item, 'outstanding_shares')\n        and item.shareholders_equity and item.outstanding_shares\n    ]\n\n    if len(book_values) < 3:\n        return {\"score\": 0, \"details\": \"Insufficient book value data for growth analysis\"}\n\n    score = 0\n    reasoning = []\n\n    # Analyze growth consistency\n    growth_periods = sum(1 for i in range(len(book_values) - 1) if book_values[i] > book_values[i + 1])\n    growth_rate = growth_periods / (len(book_values) - 1)\n\n    # Score based on consistency\n    if growth_rate >= 0.8:\n        score += 3\n        reasoning.append(\"Consistent book value per share growth (Buffett's favorite metric)\")\n    elif growth_rate >= 0.6:\n        score += 2\n        reasoning.append(\"Good book value per share growth pattern\")\n    elif growth_rate >= 0.4:\n        score += 1\n        reasoning.append(\"Moderate book value per share growth\")\n    else:\n        reasoning.append(\"Inconsistent book value per share growth\")\n\n    # Calculate and score CAGR\n    cagr_score, cagr_reason = _calculate_book_value_cagr(book_values)\n    score += cagr_score\n    reasoning.append(cagr_reason)\n\n    return {\"score\": score, \"details\": \"; \".join(reasoning)}\n\n\ndef _calculate_book_value_cagr(book_values: list) -> tuple[int, str]:\n    \"\"\"Helper function to safely calculate book value CAGR and return score + reasoning.\"\"\"\n    if len(book_values) < 2:\n        return 0, \"Insufficient data for CAGR calculation\"\n\n    oldest_bv, latest_bv = book_values[-1], book_values[0]\n    years = len(book_values) - 1\n\n    # Handle different scenarios\n    if oldest_bv > 0 and latest_bv > 0:\n        cagr = ((latest_bv / oldest_bv) ** (1 / years)) - 1\n        if cagr > 0.15:\n            return 2, f\"Excellent book value CAGR: {cagr:.1%}\"\n        elif cagr > 0.1:\n            return 1, f\"Good book value CAGR: {cagr:.1%}\"\n        else:\n            return 0, f\"Book value CAGR: {cagr:.1%}\"\n    elif oldest_bv < 0 < latest_bv:\n        return 3, \"Excellent: Company improved from negative to positive book value\"\n    elif oldest_bv > 0 > latest_bv:\n        return 0, \"Warning: Company declined from positive to negative book value\"\n    else:\n        return 0, \"Unable to calculate meaningful book value CAGR due to negative values\"\n\n\ndef analyze_pricing_power(financial_line_items: list, metrics: list) -> dict[str, any]:\n    \"\"\"\n    Analyze pricing power - Buffett's key indicator of a business moat.\n    Looks at ability to raise prices without losing customers (margin expansion during inflation).\n    \"\"\"\n    if not financial_line_items or not metrics:\n        return {\"score\": 0, \"details\": \"Insufficient data for pricing power analysis\"}\n\n    score = 0\n    reasoning = []\n\n    # Check gross margin trends (ability to maintain/expand margins)\n    gross_margins = []\n    for item in financial_line_items:\n        if hasattr(item, 'gross_margin') and item.gross_margin is not None:\n            gross_margins.append(item.gross_margin)\n\n    if len(gross_margins) >= 3:\n        # Check margin stability/improvement\n        recent_avg = sum(gross_margins[:2]) / 2 if len(gross_margins) >= 2 else gross_margins[0]\n        older_avg = sum(gross_margins[-2:]) / 2 if len(gross_margins) >= 2 else gross_margins[-1]\n\n        if recent_avg > older_avg + 0.02:  # 2%+ improvement\n            score += 3\n            reasoning.append(\"Expanding gross margins indicate strong pricing power\")\n        elif recent_avg > older_avg:\n            score += 2\n            reasoning.append(\"Improving gross margins suggest good pricing power\")\n        elif abs(recent_avg - older_avg) < 0.01:  # Stable within 1%\n            score += 1\n            reasoning.append(\"Stable gross margins during economic uncertainty\")\n        else:\n            reasoning.append(\"Declining gross margins may indicate pricing pressure\")\n\n    # Check if company has been able to maintain high margins consistently\n    if gross_margins:\n        avg_margin = sum(gross_margins) / len(gross_margins)\n        if avg_margin > 0.5:  # 50%+ gross margins\n            score += 2\n            reasoning.append(f\"Consistently high gross margins ({avg_margin:.1%}) indicate strong pricing power\")\n        elif avg_margin > 0.3:  # 30%+ gross margins\n            score += 1\n            reasoning.append(f\"Good gross margins ({avg_margin:.1%}) suggest decent pricing power\")\n\n    return {\n        \"score\": score,\n        \"details\": \"; \".join(reasoning) if reasoning else \"Limited pricing power analysis available\"\n    }\n\n\ndef generate_buffett_output(\n        ticker: str,\n        analysis_data: dict[str, any],\n        state: AgentState,\n        agent_id: str = \"warren_buffett_agent\",\n) -> WarrenBuffettSignal:\n    \"\"\"Get investment decision from LLM with a compact prompt.\"\"\"\n\n    # --- Build compact facts here ---\n    facts = {\n        \"score\": analysis_data.get(\"score\"),\n        \"max_score\": analysis_data.get(\"max_score\"),\n        \"fundamentals\": analysis_data.get(\"fundamental_analysis\", {}).get(\"details\"),\n        \"consistency\": analysis_data.get(\"consistency_analysis\", {}).get(\"details\"),\n        \"moat\": analysis_data.get(\"moat_analysis\", {}).get(\"details\"),\n        \"pricing_power\": analysis_data.get(\"pricing_power_analysis\", {}).get(\"details\"),\n        \"book_value\": analysis_data.get(\"book_value_analysis\", {}).get(\"details\"),\n        \"management\": analysis_data.get(\"management_analysis\", {}).get(\"details\"),\n        \"intrinsic_value\": analysis_data.get(\"intrinsic_value_analysis\", {}).get(\"intrinsic_value\"),\n        \"market_cap\": analysis_data.get(\"market_cap\"),\n        \"margin_of_safety\": analysis_data.get(\"margin_of_safety\"),\n    }\n\n    template = ChatPromptTemplate.from_messages(\n        [\n            (\n                \"system\",\n                \"You are Warren Buffett. Decide bullish, bearish, or neutral using only the provided facts.\\n\"\n                \"\\n\"\n                \"Checklist for decision:\\n\"\n                \"- Circle of competence\\n\"\n                \"- Competitive moat\\n\"\n                \"- Management quality\\n\"\n                \"- Financial strength\\n\"\n                \"- Valuation vs intrinsic value\\n\"\n                \"- Long-term prospects\\n\"\n                \"\\n\"\n                \"Signal rules:\\n\"\n                \"- Bullish: strong business AND margin_of_safety > 0.\\n\"\n                \"- Bearish: poor business OR clearly overvalued.\\n\"\n                \"- Neutral: good business but margin_of_safety <= 0, or mixed evidence.\\n\"\n                \"\\n\"\n                \"Confidence scale:\\n\"\n                \"- 90-100%: Exceptional business within my circle, trading at attractive price\\n\"\n                \"- 70-89%: Good business with decent moat, fair valuation\\n\"\n                \"- 50-69%: Mixed signals, would need more information or better price\\n\"\n                \"- 30-49%: Outside my expertise or concerning fundamentals\\n\"\n                \"- 10-29%: Poor business or significantly overvalued\\n\"\n                \"\\n\"\n                \"Keep reasoning under 120 characters. Do not invent data. Return JSON only.\"\n            ),\n            (\n                \"human\",\n                \"Ticker: {ticker}\\n\"\n                \"Facts:\\n{facts}\\n\\n\"\n                \"Return exactly:\\n\"\n                \"{{\\n\"\n                '  \"signal\": \"bullish\" | \"bearish\" | \"neutral\",\\n'\n                '  \"confidence\": int,\\n'\n                '  \"reasoning\": \"short justification\"\\n'\n                \"}}\"\n            ),\n        ]\n    )\n\n    prompt = template.invoke({\n        \"facts\": json.dumps(facts, separators=(\",\", \":\"), ensure_ascii=False),\n        \"ticker\": ticker,\n    })\n\n    # Default fallback uses int confidence to match schema and avoid parse retries\n    def create_default_warren_buffett_signal():\n        return WarrenBuffettSignal(signal=\"neutral\", confidence=50, reasoning=\"Insufficient data\")\n\n    return call_llm(\n        prompt=prompt,\n        pydantic_model=WarrenBuffettSignal,\n        agent_name=agent_id,\n        state=state,\n        default_factory=create_default_warren_buffett_signal,\n    )\n"
  },
  {
    "path": "src/backtester.py",
    "content": "import sys\n\nfrom colorama import Fore, Style\n\nfrom src.main import run_hedge_fund\nfrom src.backtesting.engine import BacktestEngine\nfrom src.backtesting.types import PerformanceMetrics\nfrom src.cli.input import (\n    parse_cli_inputs,\n)\n\n\ndef run_backtest(backtester: BacktestEngine) -> PerformanceMetrics | None:\n    \"\"\"Run the backtest with graceful KeyboardInterrupt handling.\"\"\"\n    try:\n        performance_metrics = backtester.run_backtest()\n        print(f\"\\n{Fore.GREEN}Backtest completed successfully!{Style.RESET_ALL}\")\n        return performance_metrics\n    except KeyboardInterrupt:\n        print(f\"\\n\\n{Fore.YELLOW}Backtest interrupted by user.{Style.RESET_ALL}\")\n        \n        # Try to show any partial results that were computed\n        try:\n            portfolio_values = backtester.get_portfolio_values()\n            if len(portfolio_values) > 1:\n                print(f\"{Fore.GREEN}Partial results available.{Style.RESET_ALL}\")\n                \n                # Show basic summary from the available portfolio values\n                first_value = portfolio_values[0][\"Portfolio Value\"]\n                last_value = portfolio_values[-1][\"Portfolio Value\"]\n                total_return = ((last_value - first_value) / first_value) * 100\n                \n                print(f\"{Fore.CYAN}Initial Portfolio Value: ${first_value:,.2f}{Style.RESET_ALL}\")\n                print(f\"{Fore.CYAN}Final Portfolio Value: ${last_value:,.2f}{Style.RESET_ALL}\")\n                print(f\"{Fore.CYAN}Total Return: {total_return:+.2f}%{Style.RESET_ALL}\")\n        except Exception as e:\n            print(f\"{Fore.RED}Could not generate partial results: {str(e)}{Style.RESET_ALL}\")\n        \n        sys.exit(0)\n\n\n### Run the Backtest #####\nif __name__ == \"__main__\":\n    inputs = parse_cli_inputs(\n        description=\"Run backtesting simulation\",\n        require_tickers=False,\n        default_months_back=1,\n        include_graph_flag=False,\n        include_reasoning_flag=False,\n    )\n\n    # Create and run the backtester\n    backtester = BacktestEngine(\n        agent=run_hedge_fund,\n        tickers=inputs.tickers,\n        start_date=inputs.start_date,\n        end_date=inputs.end_date,\n        initial_capital=inputs.initial_cash,\n        model_name=inputs.model_name,\n        model_provider=inputs.model_provider,\n        selected_analysts=inputs.selected_analysts,\n        initial_margin_requirement=inputs.margin_requirement,\n    )\n\n    # Run the backtest with graceful exit handling\n    performance_metrics = run_backtest(backtester)\n"
  },
  {
    "path": "src/backtesting/__init__.py",
    "content": "\"\"\"Backtesting package: interfaces and shared types for refactoring.\n\nThis module defines the public contracts (Protocols/ABCs) for the\nbacktesting subsystem. Implementations can live elsewhere and be\nintroduced gradually without changing existing behavior.\n\"\"\"\n\nfrom .types import (\n    ActionLiteral,\n    AgentDecision,\n    AgentDecisions,\n    AgentOutput,\n    AgentSignals,\n    PerformanceMetrics,\n    PortfolioSnapshot,\n    PortfolioValuePoint,\n    PositionState,\n    PriceDataFrame,\n    TickerRealizedGains,\n)\n\nfrom .portfolio import Portfolio\nfrom .trader import TradeExecutor\nfrom .metrics import PerformanceMetricsCalculator\nfrom .controller import AgentController\nfrom .engine import BacktestEngine\nfrom .valuation import calculate_portfolio_value, compute_exposures\nfrom .output import OutputBuilder\n\n__all__ = [\n    # Types\n    \"ActionLiteral\",\n    \"AgentDecision\",\n    \"AgentDecisions\",\n    \"AgentOutput\",\n    \"AgentSignals\",\n    \"PerformanceMetrics\",\n    \"PortfolioSnapshot\",\n    \"PortfolioValuePoint\",\n    \"PositionState\",\n    \"PriceDataFrame\",\n    \"TickerRealizedGains\",\n    # Interfaces\n    \"Portfolio\",\n    \"TradeExecutor\",\n    \"PerformanceMetricsCalculator\",\n    \"AgentController\",\n    \"BacktestEngine\",\n    \"calculate_portfolio_value\",\n    \"compute_exposures\",\n    \"OutputBuilder\",\n]\n\n\n"
  },
  {
    "path": "src/backtesting/benchmarks.py",
    "content": "from __future__ import annotations\n\nimport pandas as pd\n\nfrom src.tools.api import get_price_data\n\n\nclass BenchmarkCalculator:\n    def get_return_pct(self, ticker: str, start_date: str, end_date: str) -> float | None:\n        \"\"\"Compute simple buy-and-hold return % for ticker from start_date to end_date.\n\n        Return is (last_close / first_close - 1) * 100, or None if unavailable.\n        \"\"\"\n        try:\n            df = get_price_data(ticker, start_date, end_date)\n            if df.empty:\n                return None\n            first_close = df.iloc[0][\"close\"]\n            last_close = df.iloc[-1][\"close\"]\n            if first_close is None or pd.isna(first_close):\n                return None\n            if last_close is None or pd.isna(last_close):\n                # Try last valid close\n                last_valid = df[\"close\"].dropna()\n                if last_valid.empty:\n                    return None\n                last_close = float(last_valid.iloc[-1])\n            return (float(last_close) / float(first_close) - 1.0) * 100.0\n        except Exception:\n            return None\n\n\n"
  },
  {
    "path": "src/backtesting/cli.py",
    "content": "from __future__ import annotations\n\nimport sys\nfrom datetime import datetime\nfrom dateutil.relativedelta import relativedelta\nimport argparse\n\nfrom colorama import Fore, Style, init\nimport questionary\n\nfrom .engine import BacktestEngine\nfrom src.llm.models import LLM_ORDER, OLLAMA_LLM_ORDER, get_model_info, ModelProvider\nfrom src.utils.analysts import ANALYST_ORDER\nfrom src.main import run_hedge_fund\nfrom src.utils.ollama import ensure_ollama_and_model\n\n\ndef main() -> int:\n    parser = argparse.ArgumentParser(description=\"Run backtesting engine (modular)\")\n    parser.add_argument(\"--tickers\", type=str, required=False, help=\"Comma-separated tickers\")\n    parser.add_argument(\n        \"--end-date\",\n        type=str,\n        default=datetime.now().strftime(\"%Y-%m-%d\"),\n        help=\"End date YYYY-MM-DD\",\n    )\n    parser.add_argument(\n        \"--start-date\",\n        type=str,\n        default=(datetime.now() - relativedelta(months=1)).strftime(\"%Y-%m-%d\"),\n        help=\"Start date YYYY-MM-DD\",\n    )\n    parser.add_argument(\"--initial-capital\", type=float, default=100000)\n    parser.add_argument(\"--margin-requirement\", type=float, default=0.0)\n    parser.add_argument(\"--analysts\", type=str, required=False)\n    parser.add_argument(\"--analysts-all\", action=\"store_true\")\n    parser.add_argument(\"--ollama\", action=\"store_true\")\n\n    args = parser.parse_args()\n    init(autoreset=True)\n\n    tickers = [t.strip() for t in args.tickers.split(\",\")] if args.tickers else []\n\n    # Analysts selection is simplified; no interactive prompts here\n    if args.analysts_all:\n        selected_analysts = [a[1] for a in ANALYST_ORDER]\n    elif args.analysts:\n        selected_analysts = [a.strip() for a in args.analysts.split(\",\") if a.strip()]\n    else:\n        # Interactive analyst selection (same as legacy backtester)\n        choices = questionary.checkbox(\n            \"Use the Space bar to select/unselect analysts.\",\n            choices=[questionary.Choice(display, value=value) for display, value in ANALYST_ORDER],\n            instruction=\"\\n\\nPress 'a' to toggle all.\\n\\nPress Enter when done to run the hedge fund.\",\n            validate=lambda x: len(x) > 0 or \"You must select at least one analyst.\",\n            style=questionary.Style(\n                [\n                    (\"checkbox-selected\", \"fg:green\"),\n                    (\"selected\", \"fg:green noinherit\"),\n                    (\"highlighted\", \"noinherit\"),\n                    (\"pointer\", \"noinherit\"),\n                ]\n            ),\n        ).ask()\n        if not choices:\n            print(\"\\n\\nInterrupt received. Exiting...\")\n            return 1\n        selected_analysts = choices\n        print(\n            f\"\\nSelected analysts: \"\n            f\"{', '.join(Fore.GREEN + choice.title().replace('_', ' ') + Style.RESET_ALL for choice in choices)}\\n\"\n        )\n\n    # Model selection simplified: default to first ordered model or Ollama flag\n    if args.ollama:\n        print(f\"{Fore.CYAN}Using Ollama for local LLM inference.{Style.RESET_ALL}\")\n        model_name = questionary.select(\n            \"Select your Ollama model:\",\n            choices=[questionary.Choice(display, value=value) for display, value, _ in OLLAMA_LLM_ORDER],\n            style=questionary.Style(\n                [\n                    (\"selected\", \"fg:green bold\"),\n                    (\"pointer\", \"fg:green bold\"),\n                    (\"highlighted\", \"fg:green\"),\n                    (\"answer\", \"fg:green bold\"),\n                ]\n            ),\n        ).ask()\n        if not model_name:\n            print(\"\\n\\nInterrupt received. Exiting...\")\n            return 1\n        if model_name == \"-\":\n            model_name = questionary.text(\"Enter the custom model name:\").ask()\n            if not model_name:\n                print(\"\\n\\nInterrupt received. Exiting...\")\n                return 1\n        if not ensure_ollama_and_model(model_name):\n            print(f\"{Fore.RED}Cannot proceed without Ollama and the selected model.{Style.RESET_ALL}\")\n            return 1\n        model_provider = ModelProvider.OLLAMA.value\n        print(\n            f\"\\nSelected {Fore.CYAN}Ollama{Style.RESET_ALL} model: {Fore.GREEN + Style.BRIGHT}{model_name}{Style.RESET_ALL}\\n\"\n        )\n    else:\n        model_choice = questionary.select(\n            \"Select your LLM model:\",\n            choices=[questionary.Choice(display, value=(name, provider)) for display, name, provider in LLM_ORDER],\n            style=questionary.Style(\n                [\n                    (\"selected\", \"fg:green bold\"),\n                    (\"pointer\", \"fg:green bold\"),\n                    (\"highlighted\", \"fg:green\"),\n                    (\"answer\", \"fg:green bold\"),\n                ]\n            ),\n        ).ask()\n        if not model_choice:\n            print(\"\\n\\nInterrupt received. Exiting...\")\n            return 1\n        model_name, model_provider = model_choice\n        model_info = get_model_info(model_name, model_provider)\n        if model_info and model_info.is_custom():\n            model_name = questionary.text(\"Enter the custom model name:\").ask()\n            if not model_name:\n                print(\"\\n\\nInterrupt received. Exiting...\")\n                return 1\n        print(\n            f\"\\nSelected {Fore.CYAN}{model_provider}{Style.RESET_ALL} model: {Fore.GREEN + Style.BRIGHT}{model_name}{Style.RESET_ALL}\\n\"\n        )\n\n    engine = BacktestEngine(\n        agent=run_hedge_fund,\n        tickers=tickers,\n        start_date=args.start_date,\n        end_date=args.end_date,\n        initial_capital=args.initial_capital,\n        model_name=model_name,\n        model_provider=model_provider,\n        selected_analysts=selected_analysts,\n        initial_margin_requirement=args.margin_requirement,\n    )\n\n    metrics = engine.run_backtest()\n    values = engine.get_portfolio_values()\n\n    # Minimal terminal output (no plots)\n    if values:\n        print(f\"\\n{Fore.WHITE}{Style.BRIGHT}ENGINE RUN COMPLETE{Style.RESET_ALL}\")\n        last_value = values[-1][\"Portfolio Value\"]\n        start_value = values[0][\"Portfolio Value\"]\n        total_return = (last_value / start_value - 1.0) * 100.0 if start_value else 0.0\n        print(f\"Total Return: {Fore.GREEN if total_return >= 0 else Fore.RED}{total_return:.2f}%{Style.RESET_ALL}\")\n    if metrics.get(\"sharpe_ratio\") is not None:\n        print(f\"Sharpe: {metrics['sharpe_ratio']:.2f}\")\n    if metrics.get(\"sortino_ratio\") is not None:\n        print(f\"Sortino: {metrics['sortino_ratio']:.2f}\")\n    if metrics.get(\"max_drawdown\") is not None:\n        md = abs(metrics[\"max_drawdown\"]) if metrics[\"max_drawdown\"] is not None else 0.0\n        if metrics.get(\"max_drawdown_date\"):\n            print(f\"Max DD: {md:.2f}% on {metrics['max_drawdown_date']}\")\n        else:\n            print(f\"Max DD: {md:.2f}%\")\n\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n\n\n\n\n"
  },
  {
    "path": "src/backtesting/controller.py",
    "content": "from __future__ import annotations\n\nfrom typing import Callable, Sequence, Dict, Any\n\nfrom .types import AgentOutput, AgentDecisions, PortfolioSnapshot, ActionLiteral, Action\nfrom .portfolio import Portfolio\n\n\nclass AgentController:\n    \"\"\"Responsible for invoking the trading agent and normalizing outputs.\"\"\"\n\n    def run_agent(\n        self,\n        agent: Callable[..., AgentOutput],\n        *,\n        tickers: Sequence[str],\n        start_date: str,\n        end_date: str,\n        portfolio: Portfolio | PortfolioSnapshot,\n        model_name: str,\n        model_provider: str,\n        selected_analysts: Sequence[str] | None,\n    ) -> AgentOutput:\n        # Ensure we pass a plain snapshot dict to preserve legacy expectations\n        if isinstance(portfolio, Portfolio):\n            portfolio_payload: PortfolioSnapshot = portfolio.get_snapshot()\n        else:\n            portfolio_payload = portfolio\n\n        output = agent(\n            tickers=list(tickers),\n            start_date=start_date,\n            end_date=end_date,\n            portfolio=portfolio_payload,\n            model_name=model_name,\n            model_provider=model_provider,\n            selected_analysts=list(selected_analysts) if selected_analysts is not None else None,\n        )\n\n        # Normalize outputs to avoid None/missing keys\n        decisions_in: Dict[str, Any] = dict(output.get(\"decisions\", {})) if isinstance(output, dict) else {}\n        analyst_signals_in: Dict[str, Any] = dict(output.get(\"analyst_signals\", {})) if isinstance(output, dict) else {}\n\n        normalized_decisions: AgentDecisions = {}\n        for ticker in tickers:\n            d = decisions_in.get(ticker, {})\n            action = d.get(\"action\", \"hold\")\n            qty = d.get(\"quantity\", 0)\n            # Basic coercions mirroring Backtester expectations\n            try:\n                qty_val = float(qty)\n            except Exception:\n                qty_val = 0.0\n            try:\n                action = Action(action).value  # validate/coerce\n            except Exception:\n                action = Action.HOLD.value  # type: ignore[assignment]\n            normalized_decisions[ticker] = {\"action\": action, \"quantity\": qty_val}  # type: ignore[assignment]\n\n        # Preserve any agent-provided analyst signals without modification\n        normalized_output: AgentOutput = {\n            \"decisions\": normalized_decisions,\n            \"analyst_signals\": analyst_signals_in,\n        }\n        return normalized_output\n\n\n"
  },
  {
    "path": "src/backtesting/engine.py",
    "content": "from __future__ import annotations\n\nfrom datetime import datetime\nfrom typing import Sequence, Dict\n\nimport pandas as pd\nfrom dateutil.relativedelta import relativedelta\n\nfrom .controller import AgentController\nfrom .trader import TradeExecutor\nfrom .metrics import PerformanceMetricsCalculator\nfrom .portfolio import Portfolio\nfrom .types import PerformanceMetrics, PortfolioValuePoint\nfrom .valuation import calculate_portfolio_value, compute_exposures\nfrom .output import OutputBuilder\nfrom .benchmarks import BenchmarkCalculator\n\nfrom src.tools.api import (\n    get_company_news,\n    get_price_data,\n    get_prices,\n    get_financial_metrics,\n    get_insider_trades,\n)\n\n\nclass BacktestEngine:\n    \"\"\"Coordinates the backtest loop using the new components.\n\n    This implementation mirrors the semantics of src/backtester.py while\n    avoiding any changes to that file. It orchestrates agent decisions,\n    trade execution, valuation, exposures and performance metrics.\n    \"\"\"\n\n    def __init__(\n        self,\n        *,\n        agent,\n        tickers: list[str],\n        start_date: str,\n        end_date: str,\n        initial_capital: float,\n        model_name: str,\n        model_provider: str,\n        selected_analysts: list[str] | None,\n        initial_margin_requirement: float,\n    ) -> None:\n        self._agent = agent\n        self._tickers = tickers\n        self._start_date = start_date\n        self._end_date = end_date\n        self._initial_capital = float(initial_capital)\n        self._model_name = model_name\n        self._model_provider = model_provider\n        self._selected_analysts = selected_analysts\n\n        self._portfolio = Portfolio(\n            tickers=tickers,\n            initial_cash=initial_capital,\n            margin_requirement=initial_margin_requirement,\n        )\n        self._executor = TradeExecutor()\n        self._agent_controller = AgentController()\n        self._perf = PerformanceMetricsCalculator()\n        self._results = OutputBuilder(initial_capital=self._initial_capital)\n\n        # Benchmark calculator\n        self._benchmark = BenchmarkCalculator()\n\n        self._portfolio_values: list[PortfolioValuePoint] = []\n        self._table_rows: list[list] = []\n        self._performance_metrics: PerformanceMetrics = {\n            \"sharpe_ratio\": None,\n            \"sortino_ratio\": None,\n            \"max_drawdown\": None,\n            \"long_short_ratio\": None,\n            \"gross_exposure\": None,\n            \"net_exposure\": None,\n        }\n\n    def _prefetch_data(self) -> None:\n        end_date_dt = datetime.strptime(self._end_date, \"%Y-%m-%d\")\n        start_date_dt = end_date_dt - relativedelta(years=1)\n        start_date_str = start_date_dt.strftime(\"%Y-%m-%d\")\n\n        for ticker in self._tickers:\n            get_prices(ticker, start_date_str, self._end_date)\n            get_financial_metrics(ticker, self._end_date, limit=10)\n            get_insider_trades(ticker, self._end_date, start_date=self._start_date, limit=1000)\n            get_company_news(ticker, self._end_date, start_date=self._start_date, limit=1000)\n        \n        # Preload data for SPY for benchmark comparison\n        get_prices(\"SPY\", self._start_date, self._end_date)\n\n\n    def run_backtest(self) -> PerformanceMetrics:\n        self._prefetch_data()\n\n        dates = pd.date_range(self._start_date, self._end_date, freq=\"B\")\n        if len(dates) > 0:\n            self._portfolio_values = [\n                {\"Date\": dates[0], \"Portfolio Value\": self._initial_capital}\n            ]\n        else:\n            self._portfolio_values = []\n\n        for current_date in dates:\n            lookback_start = (current_date - relativedelta(months=1)).strftime(\"%Y-%m-%d\")\n            current_date_str = current_date.strftime(\"%Y-%m-%d\")\n            previous_date_str = (current_date - relativedelta(days=1)).strftime(\"%Y-%m-%d\")\n            if lookback_start == current_date_str:\n                continue\n\n            try:\n                current_prices: Dict[str, float] = {}\n                missing_data = False\n                for ticker in self._tickers:\n                    try:\n                        price_data = get_price_data(ticker, previous_date_str, current_date_str)\n                        if price_data.empty:\n                            missing_data = True\n                            break\n                        current_prices[ticker] = float(price_data.iloc[-1][\"close\"])\n                    except Exception:\n                        missing_data = True\n                        break\n                if missing_data:\n                    continue\n            except Exception:\n                continue\n\n            agent_output = self._agent_controller.run_agent(\n                self._agent,\n                tickers=self._tickers,\n                start_date=lookback_start,\n                end_date=current_date_str,\n                portfolio=self._portfolio,\n                model_name=self._model_name,\n                model_provider=self._model_provider,\n                selected_analysts=self._selected_analysts,\n            )\n            decisions = agent_output[\"decisions\"]\n\n            executed_trades: Dict[str, int] = {}\n            for ticker in self._tickers:\n                d = decisions.get(ticker, {\"action\": \"hold\", \"quantity\": 0})\n                action = d.get(\"action\", \"hold\")\n                qty = d.get(\"quantity\", 0)\n                executed_qty = self._executor.execute_trade(ticker, action, qty, current_prices[ticker], self._portfolio)\n                executed_trades[ticker] = executed_qty\n\n            total_value = calculate_portfolio_value(self._portfolio, current_prices)\n            exposures = compute_exposures(self._portfolio, current_prices)\n\n            point: PortfolioValuePoint = {\n                \"Date\": current_date,\n                \"Portfolio Value\": total_value,\n                \"Long Exposure\": exposures[\"Long Exposure\"],\n                \"Short Exposure\": exposures[\"Short Exposure\"],\n                \"Gross Exposure\": exposures[\"Gross Exposure\"],\n                \"Net Exposure\": exposures[\"Net Exposure\"],\n                \"Long/Short Ratio\": exposures[\"Long/Short Ratio\"],\n            }\n            self._portfolio_values.append(point)\n            \n            # Build daily rows (stateless usage)\n            rows = self._results.build_day_rows(\n                date_str=current_date_str,\n                tickers=self._tickers,\n                agent_output=agent_output,\n                executed_trades=executed_trades,\n                current_prices=current_prices,\n                portfolio=self._portfolio,\n                performance_metrics=self._performance_metrics,\n                total_value=total_value,\n                benchmark_return_pct=self._benchmark.get_return_pct(\"SPY\", self._start_date, current_date_str),\n            )\n            # Prepend today's rows to historical rows so latest day is on top\n            self._table_rows = rows + self._table_rows\n            # Print full history with latest day first (matches backtester.py behavior)\n            self._results.print_rows(self._table_rows)\n\n            # Update performance metrics after printing (match original timing)\n            if len(self._portfolio_values) > 3:\n                computed = self._perf.compute_metrics(self._portfolio_values)\n                if computed:\n                    self._performance_metrics.update(computed)\n\n        return self._performance_metrics\n\n    def get_portfolio_values(self) -> Sequence[PortfolioValuePoint]:\n        return list(self._portfolio_values)\n\n\n"
  },
  {
    "path": "src/backtesting/metrics.py",
    "content": "from __future__ import annotations\n\nfrom typing import Sequence\n\nfrom .types import PerformanceMetrics, PortfolioValuePoint\n\n\nclass PerformanceMetricsCalculator:\n    \"\"\"Concrete metrics calculator like sharpe ratio, sortino ratio, max drawdown, etc.\"\"\"\n\n    def __init__(self, *, annual_trading_days: int = 252, annual_rf_rate: float = 0.0434) -> None:\n        self.annual_trading_days = annual_trading_days\n        self.annual_rf_rate = annual_rf_rate\n\n    def update_metrics(self, metrics: PerformanceMetrics, values: Sequence[PortfolioValuePoint]) -> None:\n        \"\"\"Deprecated: mutate provided dict. Kept for backward compatibility.\"\"\"\n        computed = self.compute_metrics(values)\n        if not computed:\n            return\n        metrics.update(computed)  # type: ignore[arg-type]\n\n    def compute_metrics(self, values: Sequence[PortfolioValuePoint]) -> PerformanceMetrics:\n        import pandas as pd\n        import numpy as np\n\n        if not values:\n            return {\"sharpe_ratio\": None, \"sortino_ratio\": None, \"max_drawdown\": None}\n\n        df = pd.DataFrame(values)\n        if df.empty or \"Portfolio Value\" not in df:\n            return {\"sharpe_ratio\": None, \"sortino_ratio\": None, \"max_drawdown\": None}\n\n        df = df.set_index(\"Date\")\n        df[\"Daily Return\"] = df[\"Portfolio Value\"].pct_change()\n        clean_returns = df[\"Daily Return\"].dropna()\n        if len(clean_returns) < 2:\n            return {\"sharpe_ratio\": None, \"sortino_ratio\": None, \"max_drawdown\": None}\n\n        daily_rf = self.annual_rf_rate / self.annual_trading_days\n        excess = clean_returns - daily_rf\n        mean_excess = excess.mean()\n        std_excess = excess.std()\n\n        if std_excess > 1e-12:\n            sharpe = float(np.sqrt(self.annual_trading_days) * (mean_excess / std_excess))\n        else:\n            sharpe = 0.0\n\n        # Target downside deviation: sqrt(mean(min(excess, 0)^2)) over all periods\n        downside_diff = np.minimum(excess, 0)\n        downside_dev = float(np.sqrt(np.mean(downside_diff**2)))\n        if downside_dev > 1e-12:\n            sortino = float(np.sqrt(self.annual_trading_days) * (mean_excess / downside_dev))\n        else:\n            sortino = float(\"inf\") if mean_excess > 0 else 0.0\n\n        rolling_max = df[\"Portfolio Value\"].cummax()\n        drawdown = (df[\"Portfolio Value\"] - rolling_max) / rolling_max\n        if len(drawdown) > 0:\n            min_dd = float(drawdown.min())\n            max_drawdown = float(min_dd * 100.0)\n            if min_dd < 0:\n                max_drawdown_date = drawdown.idxmin().strftime(\"%Y-%m-%d\")\n            else:\n                max_drawdown_date = None\n        else:\n            max_drawdown = 0.0\n            max_drawdown_date = None\n\n        return {\n            \"sharpe_ratio\": sharpe,\n            \"sortino_ratio\": sortino,\n            \"max_drawdown\": max_drawdown,\n            \"max_drawdown_date\": max_drawdown_date,\n        }\n\n\n"
  },
  {
    "path": "src/backtesting/output.py",
    "content": "from __future__ import annotations\n\nfrom typing import List, Mapping, Sequence\n\nfrom .portfolio import Portfolio\nfrom .types import AgentOutput\nfrom src.utils.display import format_backtest_row, print_backtest_results\nfrom .valuation import compute_portfolio_summary\n\n\nclass OutputBuilder:\n    \"\"\"Builds daily output rows and prints results using display utils.\n\n    Stateless: callers provide inputs and receive rows back.\n    \"\"\"\n\n    def __init__(self, *, initial_capital: float | None = None) -> None:\n        self._initial_capital = initial_capital\n\n    def build_day_rows(\n        self,\n        *,\n        date_str: str,\n        tickers: Sequence[str],\n        agent_output: AgentOutput,\n        executed_trades: Mapping[str, int],\n        current_prices: Mapping[str, float],\n        portfolio: Portfolio,\n        performance_metrics: Mapping[str, float | None],\n        total_value: float,\n        benchmark_return_pct: float | None = None,\n    ) -> List[list]:\n        date_rows: List[list] = []\n\n        analyst_signals = agent_output.get(\"analyst_signals\", {})\n        decisions = agent_output.get(\"decisions\", {})\n\n        for ticker in tickers:\n            # Analyst signal counts removed from day table\n\n            pos = portfolio.get_positions()[ticker]\n            long_val = pos[\"long\"] * current_prices[ticker]\n            short_val = pos[\"short\"] * current_prices[ticker]\n            net_position_value = long_val - short_val\n\n            action = decisions.get(ticker, {}).get(\"action\", \"hold\")\n            quantity = executed_trades.get(ticker, 0)\n\n            date_rows.append(\n                format_backtest_row(\n                    date=date_str,\n                    ticker=ticker,\n                    action=action,\n                    quantity=quantity,\n                    price=current_prices[ticker],\n                    long_shares=pos[\"long\"],\n                    short_shares=pos[\"short\"],\n                    position_value=net_position_value,\n                )\n            )\n\n        # Summary row\n        initial_value = self._initial_capital if self._initial_capital is not None else total_value\n        summary = compute_portfolio_summary(\n            portfolio=portfolio,\n            total_value=total_value,\n            initial_value=initial_value,\n            performance_metrics=performance_metrics,\n        )\n\n        date_rows.append(\n            format_backtest_row(\n                date=date_str,\n                ticker=\"\",\n                action=\"\",\n                quantity=0,\n                price=0,\n                long_shares=0,\n                short_shares=0,\n                position_value=0,\n                is_summary=True,\n                total_value=summary[\"total_value\"],\n                return_pct=summary[\"return_pct\"],\n                cash_balance=summary[\"cash_balance\"],\n                total_position_value=summary[\"total_position_value\"],\n                sharpe_ratio=summary[\"sharpe_ratio\"],\n                sortino_ratio=summary[\"sortino_ratio\"],\n                max_drawdown=summary[\"max_drawdown\"],\n                benchmark_return_pct=benchmark_return_pct,\n            )\n        )\n\n        return date_rows\n\n    def print_rows(self, rows: List[list]) -> None:\n        print_backtest_results(rows)\n\n\n"
  },
  {
    "path": "src/backtesting/portfolio.py",
    "content": "from __future__ import annotations\n\nfrom typing import Dict, Mapping\nfrom types import MappingProxyType\n\nfrom .types import PortfolioSnapshot, PositionState, TickerRealizedGains\n\n\nclass Portfolio:\n    \"\"\"Portfolio state management for backtesting operations.\n\n    Encapsulates cash, positions, and margin tracking.\n    Supports both long and short positions with proper cost basis tracking\n    and realized gains/losses calculation.\n    \"\"\"\n\n    def __init__(\n        self,\n        *,\n        tickers: list[str],\n        initial_cash: float,\n        margin_requirement: float,\n    ) -> None:\n        self._portfolio: PortfolioSnapshot = {\n            \"cash\": float(initial_cash),\n            \"margin_used\": 0.0,\n            \"margin_requirement\": float(margin_requirement),\n            \"positions\": {\n                ticker: {\n                    \"long\": 0,\n                    \"short\": 0,\n                    \"long_cost_basis\": 0.0,\n                    \"short_cost_basis\": 0.0,\n                    \"short_margin_used\": 0.0,\n                }\n                for ticker in tickers\n            },\n            \"realized_gains\": {\n                ticker: {\"long\": 0.0, \"short\": 0.0}\n                for ticker in tickers\n            },\n        }\n\n    def get_snapshot(self) -> PortfolioSnapshot:\n        positions_copy: Dict[str, PositionState] = {\n            t: {\n                \"long\": p[\"long\"],\n                \"short\": p[\"short\"],\n                \"long_cost_basis\": p[\"long_cost_basis\"],\n                \"short_cost_basis\": p[\"short_cost_basis\"],\n                \"short_margin_used\": p[\"short_margin_used\"],\n            }\n            for t, p in self._portfolio[\"positions\"].items()\n        }\n        gains_copy: Dict[str, TickerRealizedGains] = {\n            t: {\"long\": g[\"long\"], \"short\": g[\"short\"]}\n            for t, g in self._portfolio[\"realized_gains\"].items()\n        }\n        return {\n            \"cash\": float(self._portfolio[\"cash\"]),\n            \"margin_used\": float(self._portfolio[\"margin_used\"]),\n            \"margin_requirement\": float(self._portfolio[\"margin_requirement\"]),\n            \"positions\": positions_copy,\n            \"realized_gains\": gains_copy,\n        }\n\n    def get_cash(self) -> float:\n        return float(self._portfolio[\"cash\"])\n\n    def get_margin_used(self) -> float:\n        return float(self._portfolio[\"margin_used\"])\n\n    def get_margin_requirement(self) -> float:\n        return float(self._portfolio[\"margin_requirement\"])\n\n    def get_positions(self) -> Mapping[str, PositionState]:\n        return MappingProxyType(self._portfolio[\"positions\"])  # type: ignore[arg-type]\n\n    def get_realized_gains(self) -> Mapping[str, TickerRealizedGains]:\n        return MappingProxyType(self._portfolio[\"realized_gains\"])  # type: ignore[arg-type]\n\n    def apply_long_buy(self, ticker: str, quantity: int, price: float) -> int:\n        if quantity <= 0:\n            return 0\n        quantity = int(quantity)\n        position = self._portfolio[\"positions\"][ticker]\n        cost = quantity * price\n        if cost <= self._portfolio[\"cash\"]:\n            old_shares = position[\"long\"]\n            old_cost_basis = position[\"long_cost_basis\"]\n            total_shares = old_shares + quantity\n            if total_shares > 0:\n                total_old_cost = old_cost_basis * old_shares\n                total_new_cost = cost\n                position[\"long_cost_basis\"] = (total_old_cost + total_new_cost) / total_shares\n            position[\"long\"] = old_shares + quantity\n            self._portfolio[\"cash\"] -= cost\n            return quantity\n        max_quantity = int(self._portfolio[\"cash\"] / price) if price > 0 else 0\n        if max_quantity > 0:\n            cost = max_quantity * price\n            old_shares = position[\"long\"]\n            old_cost_basis = position[\"long_cost_basis\"]\n            total_shares = old_shares + max_quantity\n            if total_shares > 0:\n                total_old_cost = old_cost_basis * old_shares\n                total_new_cost = cost\n                position[\"long_cost_basis\"] = (total_old_cost + total_new_cost) / total_shares\n            position[\"long\"] = old_shares + max_quantity\n            self._portfolio[\"cash\"] -= cost\n            return max_quantity\n        return 0\n\n    def apply_long_sell(self, ticker: str, quantity: int, price: float) -> int:\n        position = self._portfolio[\"positions\"][ticker]\n        quantity = min(int(quantity), position[\"long\"]) if quantity > 0 else 0\n        if quantity <= 0:\n            return 0\n        avg_cost = position[\"long_cost_basis\"] if position[\"long\"] > 0 else 0.0\n        realized_gain = (price - avg_cost) * quantity\n        self._portfolio[\"realized_gains\"][ticker][\"long\"] += realized_gain\n        position[\"long\"] -= quantity\n        self._portfolio[\"cash\"] += quantity * price\n        if position[\"long\"] == 0:\n            position[\"long_cost_basis\"] = 0.0\n        return quantity\n\n    def apply_short_open(self, ticker: str, quantity: int, price: float) -> int:\n        if quantity <= 0:\n            return 0\n        quantity = int(quantity)\n        position = self._portfolio[\"positions\"][ticker]\n        proceeds = price * quantity\n        margin_ratio = self._portfolio[\"margin_requirement\"]\n        margin_required = proceeds * margin_ratio\n        available_cash = max(\n            0.0, self._portfolio[\"cash\"] - self._portfolio[\"margin_used\"]\n        )\n        if margin_required <= available_cash:\n            old_short_shares = position[\"short\"]\n            old_cost_basis = position[\"short_cost_basis\"]\n            total_shares = old_short_shares + quantity\n            if total_shares > 0:\n                total_old_cost = old_cost_basis * old_short_shares\n                total_new_cost = price * quantity\n                position[\"short_cost_basis\"] = (total_old_cost + total_new_cost) / total_shares\n            position[\"short\"] = old_short_shares + quantity\n            position[\"short_margin_used\"] += margin_required\n            self._portfolio[\"margin_used\"] += margin_required\n            self._portfolio[\"cash\"] += proceeds\n            self._portfolio[\"cash\"] -= margin_required\n            return quantity\n        max_quantity = int(available_cash / (price * margin_ratio)) if margin_ratio > 0 and price > 0 else 0\n        if max_quantity > 0:\n            proceeds = price * max_quantity\n            margin_required = proceeds * margin_ratio\n            old_short_shares = position[\"short\"]\n            old_cost_basis = position[\"short_cost_basis\"]\n            total_shares = old_short_shares + max_quantity\n            if total_shares > 0:\n                total_old_cost = old_cost_basis * old_short_shares\n                total_new_cost = price * max_quantity\n                position[\"short_cost_basis\"] = (total_old_cost + total_new_cost) / total_shares\n            position[\"short\"] = old_short_shares + max_quantity\n            position[\"short_margin_used\"] += margin_required\n            self._portfolio[\"margin_used\"] += margin_required\n            self._portfolio[\"cash\"] += proceeds\n            self._portfolio[\"cash\"] -= margin_required\n            return max_quantity\n        return 0\n\n    def apply_short_cover(self, ticker: str, quantity: int, price: float) -> int:\n        position = self._portfolio[\"positions\"][ticker]\n        quantity = min(int(quantity), position[\"short\"]) if quantity > 0 else 0\n        if quantity <= 0:\n            return 0\n        cover_cost = quantity * price\n        avg_short_price = position[\"short_cost_basis\"] if position[\"short\"] > 0 else 0.0\n        realized_gain = (avg_short_price - price) * quantity\n        if position[\"short\"] > 0:\n            portion = quantity / position[\"short\"]\n        else:\n            portion = 1.0\n        margin_to_release = portion * position[\"short_margin_used\"]\n        position[\"short\"] -= quantity\n        position[\"short_margin_used\"] -= margin_to_release\n        self._portfolio[\"margin_used\"] -= margin_to_release\n        self._portfolio[\"cash\"] += margin_to_release\n        self._portfolio[\"cash\"] -= cover_cost\n        self._portfolio[\"realized_gains\"][ticker][\"short\"] += realized_gain\n        if position[\"short\"] == 0:\n            position[\"short_cost_basis\"] = 0.0\n            position[\"short_margin_used\"] = 0.0\n        return quantity\n\n"
  },
  {
    "path": "src/backtesting/trader.py",
    "content": "from __future__ import annotations\n\nfrom .portfolio import Portfolio\nfrom .types import ActionLiteral, Action\n\n\nclass TradeExecutor:\n    \"\"\"Executes trades against a Portfolio with Backtester-identical semantics.\"\"\"\n\n    def execute_trade(\n        self,\n        ticker: str,\n        action: ActionLiteral,\n        quantity: float,\n        current_price: float,\n        portfolio: Portfolio,\n    ) -> int:\n        if quantity is None or quantity <= 0:\n            return 0\n\n        # Coerce to enum if strings provided\n        try:\n            action_enum = Action(action) if not isinstance(action, Action) else action\n        except Exception:\n            action_enum = Action.HOLD\n\n        if action_enum == Action.BUY:\n            return portfolio.apply_long_buy(ticker, int(quantity), float(current_price))\n        if action_enum == Action.SELL:\n            return portfolio.apply_long_sell(ticker, int(quantity), float(current_price))\n        if action_enum == Action.SHORT:\n            return portfolio.apply_short_open(ticker, int(quantity), float(current_price))\n        if action_enum == Action.COVER:\n            return portfolio.apply_short_cover(ticker, int(quantity), float(current_price))\n\n        # hold or unknown action\n        return 0\n\n\n"
  },
  {
    "path": "src/backtesting/types.py",
    "content": "from __future__ import annotations\n\nfrom datetime import datetime\nfrom typing import Any, Dict, Mapping, Optional, Sequence, TypedDict, Literal\nfrom enum import Enum\n\nimport pandas as pd\n\n\nclass Action(str, Enum):\n    BUY = \"buy\"\n    SELL = \"sell\"\n    SHORT = \"short\"\n    COVER = \"cover\"\n    HOLD = \"hold\"\n\n# Backward-compatible alias\nActionLiteral = Literal[\"buy\", \"sell\", \"short\", \"cover\", \"hold\"]\n\n\nclass PositionState(TypedDict):\n    \"\"\"Represents per-ticker position state in the portfolio.\"\"\"\n\n    long: int\n    short: int\n    long_cost_basis: float\n    short_cost_basis: float\n    short_margin_used: float\n\n\nclass TickerRealizedGains(TypedDict):\n    \"\"\"Realized PnL per side for a single ticker.\"\"\"\n\n    long: float\n    short: float\n\n\nclass PortfolioSnapshot(TypedDict):\n    \"\"\"Snapshot of portfolio state.\n\n    The structure mirrors the existing dict used by the current Backtester\n    to ensure drop-in compatibility during incremental refactors.\n    \"\"\"\n\n    cash: float\n    margin_used: float\n    margin_requirement: float\n    positions: Dict[str, PositionState]\n    realized_gains: Dict[str, TickerRealizedGains]\n\n\n# DataFrame alias for clarity in interfaces\nPriceDataFrame = pd.DataFrame\n\n\nclass AgentDecision(TypedDict):\n    action: ActionLiteral\n    quantity: float\n\n\nAgentDecisions = Dict[str, AgentDecision]\n\n\n# Analyst signal payloads can vary by agent; keep as loose dicts\nAnalystSignal = Dict[str, Any]\nAgentSignals = Dict[str, Dict[str, AnalystSignal]]\n\n\nclass AgentOutput(TypedDict):\n    decisions: AgentDecisions\n    analyst_signals: AgentSignals\n\n\n# Use functional style to allow keys with spaces to mirror current code\nPortfolioValuePoint = TypedDict(\n    \"PortfolioValuePoint\",\n    {\n        \"Date\": datetime,\n        \"Portfolio Value\": float,\n        \"Long Exposure\": float,\n        \"Short Exposure\": float,\n        \"Gross Exposure\": float,\n        \"Net Exposure\": float,\n        \"Long/Short Ratio\": float,\n    },\n    total=False,\n)\n\n\nclass PerformanceMetrics(TypedDict, total=False):\n    \"\"\"Performance metrics computed over the equity curve.\n\n    Keys are aligned with the current implementation in src/backtester.py.\n    Values are optional to support progressive calculation over time.\n    \"\"\"\n\n    sharpe_ratio: Optional[float]\n    sortino_ratio: Optional[float]\n    max_drawdown: Optional[float]\n    max_drawdown_date: Optional[str]\n    long_short_ratio: Optional[float]\n    gross_exposure: Optional[float]\n    net_exposure: Optional[float]\n\n\n"
  },
  {
    "path": "src/backtesting/valuation.py",
    "content": "from __future__ import annotations\n\nfrom typing import Dict, Mapping, Mapping as _MappingAny\n\nfrom .portfolio import Portfolio\n\n\ndef calculate_portfolio_value(portfolio: Portfolio, current_prices: Mapping[str, float]) -> float:\n    \"\"\"Compute total portfolio value identical to Backtester.calculate_portfolio_value.\n\n    total_value = cash + market value of longs - market value of shorts\n    \"\"\"\n    total_value = portfolio.get_cash()\n    positions = portfolio.get_positions()\n    for ticker, pos in positions.items():\n        price = float(current_prices[ticker])\n        long_value = pos[\"long\"] * price\n        total_value += long_value\n        if pos[\"short\"] > 0:\n            total_value -= pos[\"short\"] * price\n    return total_value\n\n\ndef compute_exposures(portfolio: Portfolio, current_prices: Mapping[str, float]) -> Dict[str, float]:\n    \"\"\"Compute long/short/gross/net exposures and long/short ratio.\n\n    Mirrors the calculations performed in src/backtester.py run loop.\n    \"\"\"\n    positions = portfolio.get_positions()\n    long_exposure = 0.0\n    short_exposure = 0.0\n    for ticker, pos in positions.items():\n        price = float(current_prices[ticker])\n        long_exposure += pos[\"long\"] * price\n        short_exposure += pos[\"short\"] * price\n\n    gross_exposure = long_exposure + short_exposure\n    net_exposure = long_exposure - short_exposure\n    if short_exposure > 1e-9:\n        long_short_ratio = long_exposure / short_exposure\n    else:\n        long_short_ratio = float(\"inf\")\n\n    return {\n        \"Long Exposure\": long_exposure,\n        \"Short Exposure\": short_exposure,\n        \"Gross Exposure\": gross_exposure,\n        \"Net Exposure\": net_exposure,\n        \"Long/Short Ratio\": long_short_ratio,\n    }\n\n\n\ndef compute_portfolio_summary(\n    *,\n    portfolio: Portfolio,\n    total_value: float,\n    initial_value: float | None,\n    performance_metrics: _MappingAny[str, float | None],\n) -> Dict[str, float | None]:\n    \"\"\"Compute portfolio summary fields in a pure, testable function.\n\n    Returns a dict with keys matching the arguments used by format_backtest_row\n    for the summary row (excluding is_summary and date-specific fields).\n    \"\"\"\n    cash_balance = portfolio.get_cash()\n    total_position_value = total_value - cash_balance\n    if initial_value and initial_value != 0:\n        return_pct = (total_value / initial_value - 1.0) * 100.0\n    else:\n        return_pct = 0.0\n\n    return {\n        \"total_value\": float(total_value),\n        \"return_pct\": float(return_pct),\n        \"cash_balance\": float(cash_balance),\n        \"total_position_value\": float(total_position_value),\n        \"sharpe_ratio\": performance_metrics.get(\"sharpe_ratio\"),\n        \"sortino_ratio\": performance_metrics.get(\"sortino_ratio\"),\n        \"max_drawdown\": performance_metrics.get(\"max_drawdown\"),\n    }\n\n"
  },
  {
    "path": "src/cli/__init__.py",
    "content": ""
  },
  {
    "path": "src/cli/input.py",
    "content": "import sys\nfrom datetime import datetime\nfrom dateutil.relativedelta import relativedelta\nimport argparse\nimport questionary\nfrom colorama import Fore, Style\n\nfrom src.utils.analysts import ANALYST_ORDER\nfrom src.llm.models import LLM_ORDER, OLLAMA_LLM_ORDER, get_model_info, ModelProvider, find_model_by_name\nfrom src.utils.ollama import ensure_ollama_and_model\n\nfrom dataclasses import dataclass\nfrom typing import Optional\n\n\ndef add_common_args(\n    parser: argparse.ArgumentParser,\n    *,\n    require_tickers: bool = False,\n    include_analyst_flags: bool = True,\n    include_ollama: bool = True,\n) -> argparse.ArgumentParser:\n    parser.add_argument(\n        \"--tickers\",\n        type=str,\n        required=require_tickers,\n        help=\"Comma-separated list of stock ticker symbols (e.g., AAPL,MSFT,GOOGL)\",\n    )\n    if include_analyst_flags:\n        parser.add_argument(\n            \"--analysts\",\n            type=str,\n            required=False,\n            help=\"Comma-separated list of analysts to use (e.g., michael_burry,other_analyst)\",\n        )\n        parser.add_argument(\n            \"--analysts-all\",\n            action=\"store_true\",\n            help=\"Use all available analysts (overrides --analysts)\",\n        )\n    if include_ollama:\n        parser.add_argument(\"--ollama\", action=\"store_true\", help=\"Use Ollama for local LLM inference\")\n    parser.add_argument(\"--model\", type=str, required=False, help=\"Model name to use (e.g., gpt-4o)\")\n    return parser\n\n\ndef add_date_args(parser: argparse.ArgumentParser, *, default_months_back: int | None = None) -> argparse.ArgumentParser:\n    if default_months_back is None:\n        parser.add_argument(\"--start-date\", type=str, help=\"Start date (YYYY-MM-DD)\")\n        parser.add_argument(\"--end-date\", type=str, help=\"End date (YYYY-MM-DD)\")\n    else:\n        parser.add_argument(\n            \"--end-date\",\n            type=str,\n            default=datetime.now().strftime(\"%Y-%m-%d\"),\n            help=\"End date in YYYY-MM-DD format\",\n        )\n        parser.add_argument(\n            \"--start-date\",\n            type=str,\n            default=(datetime.now() - relativedelta(months=default_months_back)).strftime(\"%Y-%m-%d\"),\n            help=\"Start date in YYYY-MM-DD format\",\n        )\n    return parser\n\n\ndef parse_tickers(tickers_arg: str | None) -> list[str]:\n    if not tickers_arg:\n        return []\n    return [ticker.strip() for ticker in tickers_arg.split(\",\") if ticker.strip()]\n\n\ndef select_analysts(flags: dict | None = None) -> list[str]:\n    if flags and flags.get(\"analysts_all\"):\n        return [a[1] for a in ANALYST_ORDER]\n\n    if flags and flags.get(\"analysts\"):\n        return [a.strip() for a in flags[\"analysts\"].split(\",\") if a.strip()]\n\n    choices = questionary.checkbox(\n        \"Select your AI analysts.\",\n        choices=[questionary.Choice(display, value=value) for display, value in ANALYST_ORDER],\n        instruction=\"\\n\\nInstructions: \\n1. Press Space to select/unselect analysts.\\n2. Press 'a' to select/unselect all.\\n3. Press Enter when done.\",\n        validate=lambda x: len(x) > 0 or \"You must select at least one analyst.\",\n        style=questionary.Style(\n            [\n                (\"checkbox-selected\", \"fg:green\"),\n                (\"selected\", \"fg:green noinherit\"),\n                (\"highlighted\", \"noinherit\"),\n                (\"pointer\", \"noinherit\"),\n            ]\n        ),\n    ).ask()\n\n    if not choices:\n        print(\"\\n\\nInterrupt received. Exiting...\")\n        sys.exit(0)\n\n    print(\n        f\"\\nSelected analysts: {', '.join(Fore.GREEN + c.title().replace('_', ' ') + Style.RESET_ALL for c in choices)}\\n\"\n    )\n    return choices\n\n\ndef select_model(use_ollama: bool, model_flag: str | None = None) -> tuple[str, str]:\n    model_name: str = \"\"\n    model_provider: str | None = None\n\n    if model_flag:\n        model = find_model_by_name(model_flag)\n        if model:\n            print(\n                f\"\\nUsing specified model: {Fore.CYAN}{model.provider.value}{Style.RESET_ALL} - {Fore.GREEN + Style.BRIGHT}{model.model_name}{Style.RESET_ALL}\\n\"\n            )\n            return model.model_name, model.provider.value\n        else:\n            print(f\"{Fore.RED}Model '{model_flag}' not found. Please select a model.{Style.RESET_ALL}\")\n\n    if use_ollama:\n        print(f\"{Fore.CYAN}Using Ollama for local LLM inference.{Style.RESET_ALL}\")\n        model_name = questionary.select(\n            \"Select your Ollama model:\",\n            choices=[questionary.Choice(display, value=value) for display, value, _ in OLLAMA_LLM_ORDER],\n            style=questionary.Style(\n                [\n                    (\"selected\", \"fg:green bold\"),\n                    (\"pointer\", \"fg:green bold\"),\n                    (\"highlighted\", \"fg:green\"),\n                    (\"answer\", \"fg:green bold\"),\n                ]\n            ),\n        ).ask()\n\n        if not model_name:\n            print(\"\\n\\nInterrupt received. Exiting...\")\n            sys.exit(0)\n\n        if model_name == \"-\":\n            model_name = questionary.text(\"Enter the custom model name:\").ask()\n            if not model_name:\n                print(\"\\n\\nInterrupt received. Exiting...\")\n                sys.exit(0)\n\n        if not ensure_ollama_and_model(model_name):\n            print(f\"{Fore.RED}Cannot proceed without Ollama and the selected model.{Style.RESET_ALL}\")\n            sys.exit(1)\n\n        model_provider = ModelProvider.OLLAMA.value\n        print(\n            f\"\\nSelected {Fore.CYAN}Ollama{Style.RESET_ALL} model: {Fore.GREEN + Style.BRIGHT}{model_name}{Style.RESET_ALL}\\n\"\n        )\n    else:\n        model_choice = questionary.select(\n            \"Select your LLM model:\",\n            choices=[questionary.Choice(display, value=(name, provider)) for display, name, provider in LLM_ORDER],\n            style=questionary.Style(\n                [\n                    (\"selected\", \"fg:green bold\"),\n                    (\"pointer\", \"fg:green bold\"),\n                    (\"highlighted\", \"fg:green\"),\n                    (\"answer\", \"fg:green bold\"),\n                ]\n            ),\n        ).ask()\n\n        if not model_choice:\n            print(\"\\n\\nInterrupt received. Exiting...\")\n            sys.exit(0)\n\n        model_name, model_provider = model_choice\n\n        model_info = get_model_info(model_name, model_provider)\n        if model_info and model_info.is_custom():\n            model_name = questionary.text(\"Enter the custom model name:\").ask()\n            if not model_name:\n                print(\"\\n\\nInterrupt received. Exiting...\")\n                sys.exit(0)\n\n        if model_info:\n            print(\n                f\"\\nSelected {Fore.CYAN}{model_provider}{Style.RESET_ALL} model: {Fore.GREEN + Style.BRIGHT}{model_name}{Style.RESET_ALL}\\n\"\n            )\n        else:\n            model_provider = \"Unknown\"\n            print(f\"\\nSelected model: {Fore.GREEN + Style.BRIGHT}{model_name}{Style.RESET_ALL}\\n\")\n\n    return model_name, model_provider or \"\"\n\n\ndef resolve_dates(start_date: str | None, end_date: str | None, *, default_months_back: int | None = None) -> tuple[str, str]:\n    if start_date:\n        try:\n            datetime.strptime(start_date, \"%Y-%m-%d\")\n        except ValueError:\n            raise ValueError(\"Start date must be in YYYY-MM-DD format\")\n    if end_date:\n        try:\n            datetime.strptime(end_date, \"%Y-%m-%d\")\n        except ValueError:\n            raise ValueError(\"End date must be in YYYY-MM-DD format\")\n\n    final_end = end_date or datetime.now().strftime(\"%Y-%m-%d\")\n    if start_date:\n        final_start = start_date\n    else:\n        months = default_months_back if default_months_back is not None else 3\n        end_date_obj = datetime.strptime(final_end, \"%Y-%m-%d\")\n        final_start = (end_date_obj - relativedelta(months=months)).strftime(\"%Y-%m-%d\")\n    return final_start, final_end\n\n\n@dataclass\nclass CLIInputs:\n    tickers: list[str]\n    selected_analysts: list[str]\n    model_name: str\n    model_provider: str\n    start_date: str\n    end_date: str\n    initial_cash: float\n    margin_requirement: float\n    show_reasoning: bool = False\n    show_agent_graph: bool = False\n    raw_args: Optional[argparse.Namespace] = None\n\n\ndef parse_cli_inputs(\n    *,\n    description: str,\n    require_tickers: bool,\n    default_months_back: int | None,\n    include_graph_flag: bool = False,\n    include_reasoning_flag: bool = False,\n) -> CLIInputs:\n    parser = argparse.ArgumentParser(description=description)\n\n    # Common/interactive flags\n    add_common_args(parser, require_tickers=require_tickers, include_analyst_flags=True, include_ollama=True)\n    add_date_args(parser, default_months_back=default_months_back)\n\n    # Funding flags (standardized, with alias)\n    parser.add_argument(\n        \"--initial-cash\",\n        \"--initial-capital\",\n        dest=\"initial_cash\",\n        type=float,\n        default=100000.0,\n        help=\"Initial cash position (alias: --initial-capital). Defaults to 100000.0\",\n    )\n    parser.add_argument(\n        \"--margin-requirement\",\n        dest=\"margin_requirement\",\n        type=float,\n        default=0.0,\n        help=\"Initial margin requirement ratio for shorts (e.g., 0.5 for 50%%). Defaults to 0.0\",\n    )\n\n    if include_reasoning_flag:\n        parser.add_argument(\"--show-reasoning\", action=\"store_true\", help=\"Show reasoning from each agent\")\n    if include_graph_flag:\n        parser.add_argument(\"--show-agent-graph\", action=\"store_true\", help=\"Show the agent graph\")\n\n    args = parser.parse_args()\n\n    # Normalize parsed values\n    tickers = parse_tickers(getattr(args, \"tickers\", None))\n    selected_analysts = select_analysts({\n        \"analysts_all\": getattr(args, \"analysts_all\", False),\n        \"analysts\": getattr(args, \"analysts\", None),\n    })\n    model_name, model_provider = select_model(getattr(args, \"ollama\", False), getattr(args, \"model\", None))\n    start_date, end_date = resolve_dates(getattr(args, \"start_date\", None), getattr(args, \"end_date\", None), default_months_back=default_months_back)\n\n    return CLIInputs(\n        tickers=tickers,\n        selected_analysts=selected_analysts,\n        model_name=model_name,\n        model_provider=model_provider,\n        start_date=start_date,\n        end_date=end_date,\n        initial_cash=getattr(args, \"initial_cash\", 100000.0),\n        margin_requirement=getattr(args, \"margin_requirement\", 0.0),\n        show_reasoning=getattr(args, \"show_reasoning\", False),\n        show_agent_graph=getattr(args, \"show_agent_graph\", False),\n        raw_args=args,\n    )\n\n\n"
  },
  {
    "path": "src/data/__init__.py",
    "content": ""
  },
  {
    "path": "src/data/cache.py",
    "content": "class Cache:\n    \"\"\"In-memory cache for API responses.\"\"\"\n\n    def __init__(self):\n        self._prices_cache: dict[str, list[dict[str, any]]] = {}\n        self._financial_metrics_cache: dict[str, list[dict[str, any]]] = {}\n        self._line_items_cache: dict[str, list[dict[str, any]]] = {}\n        self._insider_trades_cache: dict[str, list[dict[str, any]]] = {}\n        self._company_news_cache: dict[str, list[dict[str, any]]] = {}\n\n    def _merge_data(self, existing: list[dict] | None, new_data: list[dict], key_field: str) -> list[dict]:\n        \"\"\"Merge existing and new data, avoiding duplicates based on a key field.\"\"\"\n        if not existing:\n            return new_data\n\n        # Create a set of existing keys for O(1) lookup\n        existing_keys = {item[key_field] for item in existing}\n\n        # Only add items that don't exist yet\n        merged = existing.copy()\n        merged.extend([item for item in new_data if item[key_field] not in existing_keys])\n        return merged\n\n    def get_prices(self, ticker: str) -> list[dict[str, any]] | None:\n        \"\"\"Get cached price data if available.\"\"\"\n        return self._prices_cache.get(ticker)\n\n    def set_prices(self, ticker: str, data: list[dict[str, any]]):\n        \"\"\"Append new price data to cache.\"\"\"\n        self._prices_cache[ticker] = self._merge_data(self._prices_cache.get(ticker), data, key_field=\"time\")\n\n    def get_financial_metrics(self, ticker: str) -> list[dict[str, any]]:\n        \"\"\"Get cached financial metrics if available.\"\"\"\n        return self._financial_metrics_cache.get(ticker)\n\n    def set_financial_metrics(self, ticker: str, data: list[dict[str, any]]):\n        \"\"\"Append new financial metrics to cache.\"\"\"\n        self._financial_metrics_cache[ticker] = self._merge_data(self._financial_metrics_cache.get(ticker), data, key_field=\"report_period\")\n\n    def get_line_items(self, ticker: str) -> list[dict[str, any]] | None:\n        \"\"\"Get cached line items if available.\"\"\"\n        return self._line_items_cache.get(ticker)\n\n    def set_line_items(self, ticker: str, data: list[dict[str, any]]):\n        \"\"\"Append new line items to cache.\"\"\"\n        self._line_items_cache[ticker] = self._merge_data(self._line_items_cache.get(ticker), data, key_field=\"report_period\")\n\n    def get_insider_trades(self, ticker: str) -> list[dict[str, any]] | None:\n        \"\"\"Get cached insider trades if available.\"\"\"\n        return self._insider_trades_cache.get(ticker)\n\n    def set_insider_trades(self, ticker: str, data: list[dict[str, any]]):\n        \"\"\"Append new insider trades to cache.\"\"\"\n        self._insider_trades_cache[ticker] = self._merge_data(self._insider_trades_cache.get(ticker), data, key_field=\"filing_date\")  # Could also use transaction_date if preferred\n\n    def get_company_news(self, ticker: str) -> list[dict[str, any]] | None:\n        \"\"\"Get cached company news if available.\"\"\"\n        return self._company_news_cache.get(ticker)\n\n    def set_company_news(self, ticker: str, data: list[dict[str, any]]):\n        \"\"\"Append new company news to cache.\"\"\"\n        self._company_news_cache[ticker] = self._merge_data(self._company_news_cache.get(ticker), data, key_field=\"date\")\n\n\n# Global cache instance\n_cache = Cache()\n\n\ndef get_cache() -> Cache:\n    \"\"\"Get the global cache instance.\"\"\"\n    return _cache\n"
  },
  {
    "path": "src/data/models.py",
    "content": "from pydantic import BaseModel\n\n\nclass Price(BaseModel):\n    open: float\n    close: float\n    high: float\n    low: float\n    volume: int\n    time: str\n\n\nclass PriceResponse(BaseModel):\n    ticker: str\n    prices: list[Price]\n\n\nclass FinancialMetrics(BaseModel):\n    ticker: str\n    report_period: str\n    period: str\n    currency: str\n    market_cap: float | None\n    enterprise_value: float | None\n    price_to_earnings_ratio: float | None\n    price_to_book_ratio: float | None\n    price_to_sales_ratio: float | None\n    enterprise_value_to_ebitda_ratio: float | None\n    enterprise_value_to_revenue_ratio: float | None\n    free_cash_flow_yield: float | None\n    peg_ratio: float | None\n    gross_margin: float | None\n    operating_margin: float | None\n    net_margin: float | None\n    return_on_equity: float | None\n    return_on_assets: float | None\n    return_on_invested_capital: float | None\n    asset_turnover: float | None\n    inventory_turnover: float | None\n    receivables_turnover: float | None\n    days_sales_outstanding: float | None\n    operating_cycle: float | None\n    working_capital_turnover: float | None\n    current_ratio: float | None\n    quick_ratio: float | None\n    cash_ratio: float | None\n    operating_cash_flow_ratio: float | None\n    debt_to_equity: float | None\n    debt_to_assets: float | None\n    interest_coverage: float | None\n    revenue_growth: float | None\n    earnings_growth: float | None\n    book_value_growth: float | None\n    earnings_per_share_growth: float | None\n    free_cash_flow_growth: float | None\n    operating_income_growth: float | None\n    ebitda_growth: float | None\n    payout_ratio: float | None\n    earnings_per_share: float | None\n    book_value_per_share: float | None\n    free_cash_flow_per_share: float | None\n\n\nclass FinancialMetricsResponse(BaseModel):\n    financial_metrics: list[FinancialMetrics]\n\n\nclass LineItem(BaseModel):\n    ticker: str\n    report_period: str\n    period: str\n    currency: str\n\n    # Allow additional fields dynamically\n    model_config = {\"extra\": \"allow\"}\n\n\nclass LineItemResponse(BaseModel):\n    search_results: list[LineItem]\n\n\nclass InsiderTrade(BaseModel):\n    ticker: str\n    issuer: str | None\n    name: str | None\n    title: str | None\n    is_board_director: bool | None\n    transaction_date: str | None\n    transaction_shares: float | None\n    transaction_price_per_share: float | None\n    transaction_value: float | None\n    shares_owned_before_transaction: float | None\n    shares_owned_after_transaction: float | None\n    security_title: str | None\n    filing_date: str\n\n\nclass InsiderTradeResponse(BaseModel):\n    insider_trades: list[InsiderTrade]\n\n\nclass CompanyNews(BaseModel):\n    ticker: str\n    title: str\n    author: str | None = None\n    source: str\n    date: str\n    url: str\n    sentiment: str | None = None\n\n\nclass CompanyNewsResponse(BaseModel):\n    news: list[CompanyNews]\n\n\nclass CompanyFacts(BaseModel):\n    ticker: str\n    name: str\n    cik: str | None = None\n    industry: str | None = None\n    sector: str | None = None\n    category: str | None = None\n    exchange: str | None = None\n    is_active: bool | None = None\n    listing_date: str | None = None\n    location: str | None = None\n    market_cap: float | None = None\n    number_of_employees: int | None = None\n    sec_filings_url: str | None = None\n    sic_code: str | None = None\n    sic_industry: str | None = None\n    sic_sector: str | None = None\n    website_url: str | None = None\n    weighted_average_shares: int | None = None\n\n\nclass CompanyFactsResponse(BaseModel):\n    company_facts: CompanyFacts\n\n\nclass Position(BaseModel):\n    cash: float = 0.0\n    shares: int = 0\n    ticker: str\n\n\nclass Portfolio(BaseModel):\n    positions: dict[str, Position]  # ticker -> Position mapping\n    total_cash: float = 0.0\n\n\nclass AnalystSignal(BaseModel):\n    signal: str | None = None\n    confidence: float | None = None\n    reasoning: dict | str | None = None\n    max_position_size: float | None = None  # For risk management signals\n\n\nclass TickerAnalysis(BaseModel):\n    ticker: str\n    analyst_signals: dict[str, AnalystSignal]  # agent_name -> signal mapping\n\n\nclass AgentStateData(BaseModel):\n    tickers: list[str]\n    portfolio: Portfolio\n    start_date: str\n    end_date: str\n    ticker_analyses: dict[str, TickerAnalysis]  # ticker -> analysis mapping\n\n\nclass AgentStateMetadata(BaseModel):\n    show_reasoning: bool = False\n    model_config = {\"extra\": \"allow\"}\n"
  },
  {
    "path": "src/graph/__init__.py",
    "content": ""
  },
  {
    "path": "src/graph/state.py",
    "content": "from typing_extensions import Annotated, Sequence, TypedDict\n\nimport operator\nfrom langchain_core.messages import BaseMessage\n\n\nimport json\n\n\ndef merge_dicts(a: dict[str, any], b: dict[str, any]) -> dict[str, any]:\n    return {**a, **b}\n\n\n# Define agent state\nclass AgentState(TypedDict):\n    messages: Annotated[Sequence[BaseMessage], operator.add]\n    data: Annotated[dict[str, any], merge_dicts]\n    metadata: Annotated[dict[str, any], merge_dicts]\n\n\ndef show_agent_reasoning(output, agent_name):\n    print(f\"\\n{'=' * 10} {agent_name.center(28)} {'=' * 10}\")\n\n    def convert_to_serializable(obj):\n        if hasattr(obj, \"to_dict\"):  # Handle Pandas Series/DataFrame\n            return obj.to_dict()\n        elif hasattr(obj, \"__dict__\"):  # Handle custom objects\n            return obj.__dict__\n        elif isinstance(obj, (int, float, bool, str)):\n            return obj\n        elif isinstance(obj, (list, tuple)):\n            return [convert_to_serializable(item) for item in obj]\n        elif isinstance(obj, dict):\n            return {key: convert_to_serializable(value) for key, value in obj.items()}\n        else:\n            return str(obj)  # Fallback to string representation\n\n    if isinstance(output, (dict, list)):\n        # Convert the output to JSON-serializable format\n        serializable_output = convert_to_serializable(output)\n        print(json.dumps(serializable_output, indent=2))\n    else:\n        try:\n            # Parse the string as JSON and pretty print it\n            parsed_output = json.loads(output)\n            print(json.dumps(parsed_output, indent=2))\n        except json.JSONDecodeError:\n            # Fallback to original string if not valid JSON\n            print(output)\n\n    print(\"=\" * 48)\n"
  },
  {
    "path": "src/llm/__init__.py",
    "content": ""
  },
  {
    "path": "src/llm/api_models.json",
    "content": "[\n  {\n    \"display_name\": \"Grok 4\",\n    \"model_name\": \"grok-4-0709\",\n    \"provider\": \"xAI\"\n  },\n  {\n    \"display_name\": \"GPT-5.2\",\n    \"model_name\": \"gpt-5.2\",\n    \"provider\": \"OpenAI\"\n  },\n  {\n    \"display_name\": \"GPT-4.1\",\n    \"model_name\": \"gpt-4.1\",\n    \"provider\": \"OpenAI\"\n  },\n  {\n    \"display_name\": \"Claude Sonnet 4.5\",\n    \"model_name\": \"claude-sonnet-4-5-20250929\",\n    \"provider\": \"Anthropic\"\n  },\n  {\n    \"display_name\": \"Claude Haiku 4.5\",\n    \"model_name\": \"claude-haiku-4-5-20251001\",\n    \"provider\": \"Anthropic\"\n  },\n  {\n    \"display_name\": \"Claude Opus 4.5\",\n    \"model_name\": \"claude-opus-4-5-20251101\",\n    \"provider\": \"Anthropic\"\n  },\n  {\n    \"display_name\": \"DeepSeek R1\",\n    \"model_name\": \"deepseek-reasoner\",\n    \"provider\": \"DeepSeek\"\n  },\n  {\n    \"display_name\": \"DeepSeek V3\",\n    \"model_name\": \"deepseek-chat\",\n    \"provider\": \"DeepSeek\"\n  },\n  {\n    \"display_name\": \"Gemini 3 Pro\",\n    \"model_name\": \"gemini-3-pro-preview\",\n    \"provider\": \"Google\"\n  },\n  {\n    \"display_name\": \"GLM-4.5 Air\",\n    \"model_name\": \"z-ai/glm-4.5-air\",\n    \"provider\": \"OpenRouter\"\n  },\n  {\n    \"display_name\": \"GLM-4.5\",\n    \"model_name\": \"z-ai/glm-4.5\",\n    \"provider\": \"OpenRouter\"\n  },\n  {\n    \"display_name\": \"Qwen 3 (235B) Thinking\",\n    \"model_name\": \"qwen/qwen3-235b-a22b-thinking-2507\",\n    \"provider\": \"OpenRouter\"\n  },\n  {\n    \"display_name\": \"GigaChat-2-Max\",\n    \"model_name\": \"GigaChat-2-Max\",\n    \"provider\": \"GigaChat\"\n  },\n  {\n    \"display_name\": \"Azure Open AI Deployment\",\n    \"model_name\": \"\",\n    \"provider\": \"Azure OpenAI\"\n  }\n]"
  },
  {
    "path": "src/llm/models.py",
    "content": "import os\nimport json\nfrom langchain_anthropic import ChatAnthropic\nfrom langchain_deepseek import ChatDeepSeek\nfrom langchain_google_genai import ChatGoogleGenerativeAI\nfrom langchain_groq import ChatGroq\nfrom langchain_xai import ChatXAI\nfrom langchain_openai import ChatOpenAI, AzureChatOpenAI\nfrom langchain_gigachat import GigaChat\nfrom langchain_ollama import ChatOllama\nfrom enum import Enum\nfrom pydantic import BaseModel\nfrom typing import Tuple, List\nfrom pathlib import Path\n\n\nclass ModelProvider(str, Enum):\n    \"\"\"Enum for supported LLM providers\"\"\"\n\n    ALIBABA = \"Alibaba\"\n    ANTHROPIC = \"Anthropic\"\n    DEEPSEEK = \"DeepSeek\"\n    GOOGLE = \"Google\"\n    GROQ = \"Groq\"\n    META = \"Meta\"\n    MISTRAL = \"Mistral\"\n    OPENAI = \"OpenAI\"\n    OLLAMA = \"Ollama\"\n    OPENROUTER = \"OpenRouter\"\n    GIGACHAT = \"GigaChat\"\n    AZURE_OPENAI = \"Azure OpenAI\"\n    XAI = \"xAI\"\n\n\nclass LLMModel(BaseModel):\n    \"\"\"Represents an LLM model configuration\"\"\"\n\n    display_name: str\n    model_name: str\n    provider: ModelProvider\n\n    def to_choice_tuple(self) -> Tuple[str, str, str]:\n        \"\"\"Convert to format needed for questionary choices\"\"\"\n        return (self.display_name, self.model_name, self.provider.value)\n\n    def is_custom(self) -> bool:\n        \"\"\"Check if the model is a Gemini model\"\"\"\n        return self.model_name == \"-\"\n\n    def has_json_mode(self) -> bool:\n        \"\"\"Check if the model supports JSON mode\"\"\"\n        if self.is_deepseek() or self.is_gemini():\n            return False\n        # Only certain Ollama models support JSON mode\n        if self.is_ollama():\n            return \"llama3\" in self.model_name or \"neural-chat\" in self.model_name\n        # OpenRouter models generally support JSON mode\n        if self.provider == ModelProvider.OPENROUTER:\n            return True\n        return True\n\n    def is_deepseek(self) -> bool:\n        \"\"\"Check if the model is a DeepSeek model\"\"\"\n        return self.model_name.startswith(\"deepseek\")\n\n    def is_gemini(self) -> bool:\n        \"\"\"Check if the model is a Gemini model\"\"\"\n        return self.model_name.startswith(\"gemini\")\n\n    def is_ollama(self) -> bool:\n        \"\"\"Check if the model is an Ollama model\"\"\"\n        return self.provider == ModelProvider.OLLAMA\n\n\n# Load models from JSON file\ndef load_models_from_json(json_path: str) -> List[LLMModel]:\n    \"\"\"Load models from a JSON file\"\"\"\n    with open(json_path, 'r') as f:\n        models_data = json.load(f)\n    \n    models = []\n    for model_data in models_data:\n        # Convert string provider to ModelProvider enum\n        provider_enum = ModelProvider(model_data[\"provider\"])\n        models.append(\n            LLMModel(\n                display_name=model_data[\"display_name\"],\n                model_name=model_data[\"model_name\"],\n                provider=provider_enum\n            )\n        )\n    return models\n\n\n# Get the path to the JSON files\ncurrent_dir = Path(__file__).parent\nmodels_json_path = current_dir / \"api_models.json\"\nollama_models_json_path = current_dir / \"ollama_models.json\"\n\n# Load available models from JSON\nAVAILABLE_MODELS = load_models_from_json(str(models_json_path))\n\n# Load Ollama models from JSON\nOLLAMA_MODELS = load_models_from_json(str(ollama_models_json_path))\n\n# Create LLM_ORDER in the format expected by the UI\nLLM_ORDER = [model.to_choice_tuple() for model in AVAILABLE_MODELS]\n\n# Create Ollama LLM_ORDER separately\nOLLAMA_LLM_ORDER = [model.to_choice_tuple() for model in OLLAMA_MODELS]\n\n\ndef get_model_info(model_name: str, model_provider: str) -> LLMModel | None:\n    \"\"\"Get model information by model_name\"\"\"\n    all_models = AVAILABLE_MODELS + OLLAMA_MODELS\n    return next((model for model in all_models if model.model_name == model_name and model.provider == model_provider), None)\n\n\ndef find_model_by_name(model_name: str) -> LLMModel | None:\n    \"\"\"Find a model by its name across all available models.\"\"\"\n    all_models = AVAILABLE_MODELS + OLLAMA_MODELS\n    return next((model for model in all_models if model.model_name == model_name), None)\n\n\ndef get_models_list():\n    \"\"\"Get the list of models for API responses.\"\"\"\n    return [\n        {\n            \"display_name\": model.display_name,\n            \"model_name\": model.model_name,\n            \"provider\": model.provider.value\n        }\n        for model in AVAILABLE_MODELS\n    ]\n\n\ndef get_model(model_name: str, model_provider: ModelProvider, api_keys: dict = None) -> ChatOpenAI | ChatGroq | ChatOllama | GigaChat | None:\n    if model_provider == ModelProvider.GROQ:\n        api_key = (api_keys or {}).get(\"GROQ_API_KEY\") or os.getenv(\"GROQ_API_KEY\")\n        if not api_key:\n            # Print error to console\n            print(f\"API Key Error: Please make sure GROQ_API_KEY is set in your .env file or provided via API keys.\")\n            raise ValueError(\"Groq API key not found.  Please make sure GROQ_API_KEY is set in your .env file or provided via API keys.\")\n        return ChatGroq(model=model_name, api_key=api_key)\n    elif model_provider == ModelProvider.OPENAI:\n        # Get and validate API key\n        api_key = (api_keys or {}).get(\"OPENAI_API_KEY\") or os.getenv(\"OPENAI_API_KEY\")\n        base_url = os.getenv(\"OPENAI_API_BASE\")\n        if not api_key:\n            # Print error to console\n            print(f\"API Key Error: Please make sure OPENAI_API_KEY is set in your .env file or provided via API keys.\")\n            raise ValueError(\"OpenAI API key not found.  Please make sure OPENAI_API_KEY is set in your .env file or provided via API keys.\")\n        return ChatOpenAI(model=model_name, api_key=api_key, base_url=base_url)\n    elif model_provider == ModelProvider.ANTHROPIC:\n        api_key = (api_keys or {}).get(\"ANTHROPIC_API_KEY\") or os.getenv(\"ANTHROPIC_API_KEY\")\n        if not api_key:\n            print(f\"API Key Error: Please make sure ANTHROPIC_API_KEY is set in your .env file or provided via API keys.\")\n            raise ValueError(\"Anthropic API key not found.  Please make sure ANTHROPIC_API_KEY is set in your .env file or provided via API keys.\")\n        return ChatAnthropic(model=model_name, api_key=api_key)\n    elif model_provider == ModelProvider.DEEPSEEK:\n        api_key = (api_keys or {}).get(\"DEEPSEEK_API_KEY\") or os.getenv(\"DEEPSEEK_API_KEY\")\n        if not api_key:\n            print(f\"API Key Error: Please make sure DEEPSEEK_API_KEY is set in your .env file or provided via API keys.\")\n            raise ValueError(\"DeepSeek API key not found.  Please make sure DEEPSEEK_API_KEY is set in your .env file or provided via API keys.\")\n        return ChatDeepSeek(model=model_name, api_key=api_key)\n    elif model_provider == ModelProvider.GOOGLE:\n        api_key = (api_keys or {}).get(\"GOOGLE_API_KEY\") or os.getenv(\"GOOGLE_API_KEY\")\n        if not api_key:\n            print(f\"API Key Error: Please make sure GOOGLE_API_KEY is set in your .env file or provided via API keys.\")\n            raise ValueError(\"Google API key not found.  Please make sure GOOGLE_API_KEY is set in your .env file or provided via API keys.\")\n        return ChatGoogleGenerativeAI(model=model_name, api_key=api_key)\n    elif model_provider == ModelProvider.OLLAMA:\n        # For Ollama, we use a base URL instead of an API key\n        # Check if OLLAMA_HOST is set (for Docker on macOS)\n        ollama_host = os.getenv(\"OLLAMA_HOST\", \"localhost\")\n        base_url = os.getenv(\"OLLAMA_BASE_URL\", f\"http://{ollama_host}:11434\")\n        return ChatOllama(\n            model=model_name,\n            base_url=base_url,\n        )\n    elif model_provider == ModelProvider.OPENROUTER:\n        api_key = (api_keys or {}).get(\"OPENROUTER_API_KEY\") or os.getenv(\"OPENROUTER_API_KEY\")\n        if not api_key:\n            print(f\"API Key Error: Please make sure OPENROUTER_API_KEY is set in your .env file or provided via API keys.\")\n            raise ValueError(\"OpenRouter API key not found. Please make sure OPENROUTER_API_KEY is set in your .env file or provided via API keys.\")\n        \n        # Get optional site URL and name for headers\n        site_url = os.getenv(\"YOUR_SITE_URL\", \"https://github.com/virattt/ai-hedge-fund\")\n        site_name = os.getenv(\"YOUR_SITE_NAME\", \"AI Hedge Fund\")\n        \n        return ChatOpenAI(\n            model=model_name,\n            openai_api_key=api_key,\n            openai_api_base=\"https://openrouter.ai/api/v1\",\n            model_kwargs={\n                \"extra_headers\": {\n                    \"HTTP-Referer\": site_url,\n                    \"X-Title\": site_name,\n                }\n            }\n        )\n    elif model_provider == ModelProvider.XAI:\n        api_key = (api_keys or {}).get(\"XAI_API_KEY\") or os.getenv(\"XAI_API_KEY\")\n        if not api_key:\n            print(f\"API Key Error: Please make sure XAI_API_KEY is set in your .env file or provided via API keys.\")\n            raise ValueError(\"xAI API key not found. Please make sure XAI_API_KEY is set in your .env file or provided via API keys.\")\n        return ChatXAI(model=model_name, api_key=api_key)\n    elif model_provider == ModelProvider.GIGACHAT:\n        if os.getenv(\"GIGACHAT_USER\") or os.getenv(\"GIGACHAT_PASSWORD\"):\n            return GigaChat(model=model_name)\n        else: \n            api_key = (api_keys or {}).get(\"GIGACHAT_API_KEY\") or os.getenv(\"GIGACHAT_API_KEY\") or os.getenv(\"GIGACHAT_CREDENTIALS\")\n            if not api_key:\n                print(\"API Key Error: Please make sure api_keys is set in your .env file or provided via API keys.\")\n                raise ValueError(\"GigaChat API key not found. Please make sure GIGACHAT_API_KEY is set in your .env file or provided via API keys.\")\n\n            return GigaChat(credentials=api_key, model=model_name)\n    elif model_provider == ModelProvider.AZURE_OPENAI:\n        # Get and validate API key\n        api_key = os.getenv(\"AZURE_OPENAI_API_KEY\")\n        if not api_key:\n            # Print error to console\n            print(f\"API Key Error: Please make sure AZURE_OPENAI_API_KEY is set in your .env file.\")\n            raise ValueError(\"Azure OpenAI API key not found.  Please make sure AZURE_OPENAI_API_KEY is set in your .env file.\")\n        # Get and validate Azure Endpoint\n        azure_endpoint = os.getenv(\"AZURE_OPENAI_ENDPOINT\")\n        if not azure_endpoint:\n            # Print error to console\n            print(f\"Azure Endpoint Error: Please make sure AZURE_OPENAI_ENDPOINT is set in your .env file.\")\n            raise ValueError(\"Azure OpenAI endpoint not found.  Please make sure AZURE_OPENAI_ENDPOINT is set in your .env file.\")\n        # get and validate deployment name\n        azure_deployment_name = os.getenv(\"AZURE_OPENAI_DEPLOYMENT_NAME\")\n        if not azure_deployment_name:\n            # Print error to console\n            print(f\"Azure Deployment Name Error: Please make sure AZURE_OPENAI_DEPLOYMENT_NAME is set in your .env file.\")\n            raise ValueError(\"Azure OpenAI deployment name not found.  Please make sure AZURE_OPENAI_DEPLOYMENT_NAME is set in your .env file.\")\n        return AzureChatOpenAI(azure_endpoint=azure_endpoint, azure_deployment=azure_deployment_name, api_key=api_key, api_version=\"2024-10-21\")\n    else:\n        raise ValueError(\n            f\"Unsupported model provider: {model_provider}. \"\n            f\"Supported providers: {', '.join(p.value for p in ModelProvider)}\"\n        )\n"
  },
  {
    "path": "src/llm/ollama_models.json",
    "content": "[\n  {\n    \"display_name\": \"gpt-oss (20B)\",\n    \"model_name\": \"gpt-oss:20b\",\n    \"provider\": \"OpenAI\"\n  },\n  {\n    \"display_name\": \"gpt-oss (120B)\",\n    \"model_name\": \"gpt-oss:120b\",\n    \"provider\": \"OpenAI\"\n  },\n  {\n    \"display_name\": \"Gemma 3 (4B)\",\n    \"model_name\": \"gemma3:4b\",\n    \"provider\": \"Google\"\n  },\n  {\n    \"display_name\": \"Qwen 3 (4B)\",\n    \"model_name\": \"qwen3:4b\",\n    \"provider\": \"Alibaba\"\n  },\n  {\n    \"display_name\": \"Qwen 3 (8B)\",\n    \"model_name\": \"qwen3:8b\",\n    \"provider\": \"Alibaba\"\n  },\n  {\n    \"display_name\": \"Llama 3.1 (8B)\",\n    \"model_name\": \"llama3.1:latest\",\n    \"provider\": \"Meta\"\n  },\n  {\n    \"display_name\": \"Gemma 3 (12B)\",\n    \"model_name\": \"gemma3:12b\",\n    \"provider\": \"Google\"\n  },\n  {\n    \"display_name\": \"Mistral Small 3.1 (24B)\",\n    \"model_name\": \"mistral-small3.1\",\n    \"provider\": \"Mistral\"\n  },\n  {\n    \"display_name\": \"Gemma 3 (27B)\",\n    \"model_name\": \"gemma3:27b\",\n    \"provider\": \"Google\"\n  },\n  {\n    \"display_name\": \"Qwen 3 (30B-a3B)\",\n    \"model_name\": \"qwen3:30b-a3b\",\n    \"provider\": \"Alibaba\"\n  },\n  {\n    \"display_name\": \"Llama 3.3 (70B)\",\n    \"model_name\": \"llama3.3:70b-instruct-q4_0\",\n    \"provider\": \"Meta\"\n  }\n] "
  },
  {
    "path": "src/main.py",
    "content": "import sys\n\nfrom dotenv import load_dotenv\nfrom langchain_core.messages import HumanMessage\nfrom langgraph.graph import END, StateGraph\nfrom colorama import Fore, Style, init\nimport questionary\nfrom src.agents.portfolio_manager import portfolio_management_agent\nfrom src.agents.risk_manager import risk_management_agent\nfrom src.graph.state import AgentState\nfrom src.utils.display import print_trading_output\nfrom src.utils.analysts import ANALYST_ORDER, get_analyst_nodes\nfrom src.utils.progress import progress\nfrom src.utils.visualize import save_graph_as_png\nfrom src.cli.input import (\n    parse_cli_inputs,\n)\n\nimport argparse\nfrom datetime import datetime\nfrom dateutil.relativedelta import relativedelta\nimport json\n\n# Load environment variables from .env file\nload_dotenv()\n\ninit(autoreset=True)\n\n\ndef parse_hedge_fund_response(response):\n    \"\"\"Parses a JSON string and returns a dictionary.\"\"\"\n    try:\n        return json.loads(response)\n    except json.JSONDecodeError as e:\n        print(f\"JSON decoding error: {e}\\nResponse: {repr(response)}\")\n        return None\n    except TypeError as e:\n        print(f\"Invalid response type (expected string, got {type(response).__name__}): {e}\")\n        return None\n    except Exception as e:\n        print(f\"Unexpected error while parsing response: {e}\\nResponse: {repr(response)}\")\n        return None\n\n\n##### Run the Hedge Fund #####\ndef run_hedge_fund(\n    tickers: list[str],\n    start_date: str,\n    end_date: str,\n    portfolio: dict,\n    show_reasoning: bool = False,\n    selected_analysts: list[str] = [],\n    model_name: str = \"gpt-4.1\",\n    model_provider: str = \"OpenAI\",\n):\n    # Start progress tracking\n    progress.start()\n\n    try:\n        # Build workflow (default to all analysts when none provided)\n        workflow = create_workflow(selected_analysts if selected_analysts else None)\n        agent = workflow.compile()\n\n        final_state = agent.invoke(\n            {\n                \"messages\": [\n                    HumanMessage(\n                        content=\"Make trading decisions based on the provided data.\",\n                    )\n                ],\n                \"data\": {\n                    \"tickers\": tickers,\n                    \"portfolio\": portfolio,\n                    \"start_date\": start_date,\n                    \"end_date\": end_date,\n                    \"analyst_signals\": {},\n                },\n                \"metadata\": {\n                    \"show_reasoning\": show_reasoning,\n                    \"model_name\": model_name,\n                    \"model_provider\": model_provider,\n                },\n            },\n        )\n\n        return {\n            \"decisions\": parse_hedge_fund_response(final_state[\"messages\"][-1].content),\n            \"analyst_signals\": final_state[\"data\"][\"analyst_signals\"],\n        }\n    finally:\n        # Stop progress tracking\n        progress.stop()\n\n\ndef start(state: AgentState):\n    \"\"\"Initialize the workflow with the input message.\"\"\"\n    return state\n\n\ndef create_workflow(selected_analysts=None):\n    \"\"\"Create the workflow with selected analysts.\"\"\"\n    workflow = StateGraph(AgentState)\n    workflow.add_node(\"start_node\", start)\n\n    # Get analyst nodes from the configuration\n    analyst_nodes = get_analyst_nodes()\n\n    # Default to all analysts if none selected\n    if selected_analysts is None:\n        selected_analysts = list(analyst_nodes.keys())\n    # Add selected analyst nodes\n    for analyst_key in selected_analysts:\n        node_name, node_func = analyst_nodes[analyst_key]\n        workflow.add_node(node_name, node_func)\n        workflow.add_edge(\"start_node\", node_name)\n\n    # Always add risk and portfolio management\n    workflow.add_node(\"risk_management_agent\", risk_management_agent)\n    workflow.add_node(\"portfolio_manager\", portfolio_management_agent)\n\n    # Connect selected analysts to risk management\n    for analyst_key in selected_analysts:\n        node_name = analyst_nodes[analyst_key][0]\n        workflow.add_edge(node_name, \"risk_management_agent\")\n\n    workflow.add_edge(\"risk_management_agent\", \"portfolio_manager\")\n    workflow.add_edge(\"portfolio_manager\", END)\n\n    workflow.set_entry_point(\"start_node\")\n    return workflow\n\n\nif __name__ == \"__main__\":\n    inputs = parse_cli_inputs(\n        description=\"Run the hedge fund trading system\",\n        require_tickers=True,\n        default_months_back=None,\n        include_graph_flag=True,\n        include_reasoning_flag=True,\n    )\n\n    tickers = inputs.tickers\n    selected_analysts = inputs.selected_analysts\n\n    # Construct portfolio here\n    portfolio = {\n        \"cash\": inputs.initial_cash,\n        \"margin_requirement\": inputs.margin_requirement,\n        \"margin_used\": 0.0,\n        \"positions\": {\n            ticker: {\n                \"long\": 0,\n                \"short\": 0,\n                \"long_cost_basis\": 0.0,\n                \"short_cost_basis\": 0.0,\n                \"short_margin_used\": 0.0,\n            }\n            for ticker in tickers\n        },\n        \"realized_gains\": {\n            ticker: {\n                \"long\": 0.0,\n                \"short\": 0.0,\n            }\n            for ticker in tickers\n        },\n    }\n\n    result = run_hedge_fund(\n        tickers=tickers,\n        start_date=inputs.start_date,\n        end_date=inputs.end_date,\n        portfolio=portfolio,\n        show_reasoning=inputs.show_reasoning,\n        selected_analysts=inputs.selected_analysts,\n        model_name=inputs.model_name,\n        model_provider=inputs.model_provider,\n    )\n    print_trading_output(result)\n"
  },
  {
    "path": "src/tools/__init__.py",
    "content": ""
  },
  {
    "path": "src/tools/api.py",
    "content": "import datetime\nimport logging\nimport os\nimport pandas as pd\nimport requests\nimport time\n\nlogger = logging.getLogger(__name__)\n\nfrom src.data.cache import get_cache\nfrom src.data.models import (\n    CompanyNews,\n    CompanyNewsResponse,\n    FinancialMetrics,\n    FinancialMetricsResponse,\n    Price,\n    PriceResponse,\n    LineItem,\n    LineItemResponse,\n    InsiderTrade,\n    InsiderTradeResponse,\n    CompanyFactsResponse,\n)\n\n# Global cache instance\n_cache = get_cache()\n\n\ndef _make_api_request(url: str, headers: dict, method: str = \"GET\", json_data: dict = None, max_retries: int = 3) -> requests.Response:\n    \"\"\"\n    Make an API request with rate limiting handling and moderate backoff.\n    \n    Args:\n        url: The URL to request\n        headers: Headers to include in the request\n        method: HTTP method (GET or POST)\n        json_data: JSON data for POST requests\n        max_retries: Maximum number of retries (default: 3)\n    \n    Returns:\n        requests.Response: The response object\n    \n    Raises:\n        Exception: If the request fails with a non-429 error\n    \"\"\"\n    for attempt in range(max_retries + 1):  # +1 for initial attempt\n        if method.upper() == \"POST\":\n            response = requests.post(url, headers=headers, json=json_data)\n        else:\n            response = requests.get(url, headers=headers)\n        \n        if response.status_code == 429 and attempt < max_retries:\n            # Linear backoff: 60s, 90s, 120s, 150s...\n            delay = 60 + (30 * attempt)\n            print(f\"Rate limited (429). Attempt {attempt + 1}/{max_retries + 1}. Waiting {delay}s before retrying...\")\n            time.sleep(delay)\n            continue\n        \n        # Return the response (whether success, other errors, or final 429)\n        return response\n\n\ndef get_prices(ticker: str, start_date: str, end_date: str, api_key: str = None) -> list[Price]:\n    \"\"\"Fetch price data from cache or API.\"\"\"\n    # Create a cache key that includes all parameters to ensure exact matches\n    cache_key = f\"{ticker}_{start_date}_{end_date}\"\n    \n    # Check cache first - simple exact match\n    if cached_data := _cache.get_prices(cache_key):\n        return [Price(**price) for price in cached_data]\n\n    # If not in cache, fetch from API\n    headers = {}\n    financial_api_key = api_key or os.environ.get(\"FINANCIAL_DATASETS_API_KEY\")\n    if financial_api_key:\n        headers[\"X-API-KEY\"] = financial_api_key\n\n    url = f\"https://api.financialdatasets.ai/prices/?ticker={ticker}&interval=day&interval_multiplier=1&start_date={start_date}&end_date={end_date}\"\n    response = _make_api_request(url, headers)\n    if response.status_code != 200:\n        return []\n\n    # Parse response with Pydantic model\n    try:\n        price_response = PriceResponse(**response.json())\n        prices = price_response.prices\n    except Exception as e:\n        logger.warning(\"Failed to parse price response for %s: %s\", ticker, e)\n        return []\n\n    if not prices:\n        return []\n\n    # Cache the results using the comprehensive cache key\n    _cache.set_prices(cache_key, [p.model_dump() for p in prices])\n    return prices\n\n\ndef get_financial_metrics(\n    ticker: str,\n    end_date: str,\n    period: str = \"ttm\",\n    limit: int = 10,\n    api_key: str = None,\n) -> list[FinancialMetrics]:\n    \"\"\"Fetch financial metrics from cache or API.\"\"\"\n    # Create a cache key that includes all parameters to ensure exact matches\n    cache_key = f\"{ticker}_{period}_{end_date}_{limit}\"\n    \n    # Check cache first - simple exact match\n    if cached_data := _cache.get_financial_metrics(cache_key):\n        return [FinancialMetrics(**metric) for metric in cached_data]\n\n    # If not in cache, fetch from API\n    headers = {}\n    financial_api_key = api_key or os.environ.get(\"FINANCIAL_DATASETS_API_KEY\")\n    if financial_api_key:\n        headers[\"X-API-KEY\"] = financial_api_key\n\n    url = f\"https://api.financialdatasets.ai/financial-metrics/?ticker={ticker}&report_period_lte={end_date}&limit={limit}&period={period}\"\n    response = _make_api_request(url, headers)\n    if response.status_code != 200:\n        return []\n\n    # Parse response with Pydantic model\n    try:\n        metrics_response = FinancialMetricsResponse(**response.json())\n        financial_metrics = metrics_response.financial_metrics\n    except Exception as e:\n        logger.warning(\"Failed to parse financial metrics response for %s: %s\", ticker, e)\n        return []\n\n    if not financial_metrics:\n        return []\n\n    # Cache the results as dicts using the comprehensive cache key\n    _cache.set_financial_metrics(cache_key, [m.model_dump() for m in financial_metrics])\n    return financial_metrics\n\n\ndef search_line_items(\n    ticker: str,\n    line_items: list[str],\n    end_date: str,\n    period: str = \"ttm\",\n    limit: int = 10,\n    api_key: str = None,\n) -> list[LineItem]:\n    \"\"\"Fetch line items from API.\"\"\"\n    # If not in cache or insufficient data, fetch from API\n    headers = {}\n    financial_api_key = api_key or os.environ.get(\"FINANCIAL_DATASETS_API_KEY\")\n    if financial_api_key:\n        headers[\"X-API-KEY\"] = financial_api_key\n\n    url = \"https://api.financialdatasets.ai/financials/search/line-items\"\n\n    body = {\n        \"tickers\": [ticker],\n        \"line_items\": line_items,\n        \"end_date\": end_date,\n        \"period\": period,\n        \"limit\": limit,\n    }\n    response = _make_api_request(url, headers, method=\"POST\", json_data=body)\n    if response.status_code != 200:\n        return []\n    \n    try:\n        data = response.json()\n        response_model = LineItemResponse(**data)\n        search_results = response_model.search_results\n    except Exception as e:\n        logger.warning(\"Failed to parse line items response for %s: %s\", ticker, e)\n        return []\n    if not search_results:\n        return []\n\n    # Cache the results\n    return search_results[:limit]\n\n\ndef get_insider_trades(\n    ticker: str,\n    end_date: str,\n    start_date: str | None = None,\n    limit: int = 1000,\n    api_key: str = None,\n) -> list[InsiderTrade]:\n    \"\"\"Fetch insider trades from cache or API.\"\"\"\n    # Create a cache key that includes all parameters to ensure exact matches\n    cache_key = f\"{ticker}_{start_date or 'none'}_{end_date}_{limit}\"\n    \n    # Check cache first - simple exact match\n    if cached_data := _cache.get_insider_trades(cache_key):\n        return [InsiderTrade(**trade) for trade in cached_data]\n\n    # If not in cache, fetch from API\n    headers = {}\n    financial_api_key = api_key or os.environ.get(\"FINANCIAL_DATASETS_API_KEY\")\n    if financial_api_key:\n        headers[\"X-API-KEY\"] = financial_api_key\n\n    all_trades = []\n    current_end_date = end_date\n\n    while True:\n        url = f\"https://api.financialdatasets.ai/insider-trades/?ticker={ticker}&filing_date_lte={current_end_date}\"\n        if start_date:\n            url += f\"&filing_date_gte={start_date}\"\n        url += f\"&limit={limit}\"\n\n        response = _make_api_request(url, headers)\n        if response.status_code != 200:\n            break\n\n        try:\n            data = response.json()\n            response_model = InsiderTradeResponse(**data)\n            insider_trades = response_model.insider_trades\n        except Exception as e:\n            logger.warning(\"Failed to parse insider trades response for %s: %s\", ticker, e)\n            break\n\n        if not insider_trades:\n            break\n\n        all_trades.extend(insider_trades)\n\n        # Only continue pagination if we have a start_date and got a full page\n        if not start_date or len(insider_trades) < limit:\n            break\n\n        # Update end_date to the oldest filing date from current batch for next iteration\n        current_end_date = min(trade.filing_date for trade in insider_trades).split(\"T\")[0]\n\n        # If we've reached or passed the start_date, we can stop\n        if current_end_date <= start_date:\n            break\n\n    if not all_trades:\n        return []\n\n    # Cache the results using the comprehensive cache key\n    _cache.set_insider_trades(cache_key, [trade.model_dump() for trade in all_trades])\n    return all_trades\n\n\ndef get_company_news(\n    ticker: str,\n    end_date: str,\n    start_date: str | None = None,\n    limit: int = 1000,\n    api_key: str = None,\n) -> list[CompanyNews]:\n    \"\"\"Fetch company news from cache or API.\"\"\"\n    # Create a cache key that includes all parameters to ensure exact matches\n    cache_key = f\"{ticker}_{start_date or 'none'}_{end_date}_{limit}\"\n    \n    # Check cache first - simple exact match\n    if cached_data := _cache.get_company_news(cache_key):\n        return [CompanyNews(**news) for news in cached_data]\n\n    # If not in cache, fetch from API\n    headers = {}\n    financial_api_key = api_key or os.environ.get(\"FINANCIAL_DATASETS_API_KEY\")\n    if financial_api_key:\n        headers[\"X-API-KEY\"] = financial_api_key\n\n    all_news = []\n    current_end_date = end_date\n\n    while True:\n        url = f\"https://api.financialdatasets.ai/news/?ticker={ticker}&end_date={current_end_date}\"\n        if start_date:\n            url += f\"&start_date={start_date}\"\n        url += f\"&limit={limit}\"\n\n        response = _make_api_request(url, headers)\n        if response.status_code != 200:\n            break\n\n        try:\n            data = response.json()\n            response_model = CompanyNewsResponse(**data)\n            company_news = response_model.news\n        except Exception as e:\n            logger.warning(\"Failed to parse company news response for %s: %s\", ticker, e)\n            break\n\n        if not company_news:\n            break\n\n        all_news.extend(company_news)\n\n        # Only continue pagination if we have a start_date and got a full page\n        if not start_date or len(company_news) < limit:\n            break\n\n        # Update end_date to the oldest date from current batch for next iteration\n        current_end_date = min(news.date for news in company_news).split(\"T\")[0]\n\n        # If we've reached or passed the start_date, we can stop\n        if current_end_date <= start_date:\n            break\n\n    if not all_news:\n        return []\n\n    # Cache the results using the comprehensive cache key\n    _cache.set_company_news(cache_key, [news.model_dump() for news in all_news])\n    return all_news\n\n\ndef get_market_cap(\n    ticker: str,\n    end_date: str,\n    api_key: str = None,\n) -> float | None:\n    \"\"\"Fetch market cap from the API.\"\"\"\n    # Check if end_date is today\n    if end_date == datetime.datetime.now().strftime(\"%Y-%m-%d\"):\n        # Get the market cap from company facts API\n        headers = {}\n        financial_api_key = api_key or os.environ.get(\"FINANCIAL_DATASETS_API_KEY\")\n        if financial_api_key:\n            headers[\"X-API-KEY\"] = financial_api_key\n\n        url = f\"https://api.financialdatasets.ai/company/facts/?ticker={ticker}\"\n        response = _make_api_request(url, headers)\n        if response.status_code != 200:\n            print(f\"Error fetching company facts: {ticker} - {response.status_code}\")\n            return None\n\n        data = response.json()\n        response_model = CompanyFactsResponse(**data)\n        return response_model.company_facts.market_cap\n\n    financial_metrics = get_financial_metrics(ticker, end_date, api_key=api_key)\n    if not financial_metrics:\n        return None\n\n    market_cap = financial_metrics[0].market_cap\n\n    if not market_cap:\n        return None\n\n    return market_cap\n\n\ndef prices_to_df(prices: list[Price]) -> pd.DataFrame:\n    \"\"\"Convert prices to a DataFrame.\"\"\"\n    df = pd.DataFrame([p.model_dump() for p in prices])\n    df[\"Date\"] = pd.to_datetime(df[\"time\"])\n    df.set_index(\"Date\", inplace=True)\n    numeric_cols = [\"open\", \"close\", \"high\", \"low\", \"volume\"]\n    for col in numeric_cols:\n        df[col] = pd.to_numeric(df[col], errors=\"coerce\")\n    df.sort_index(inplace=True)\n    return df\n\n\n# Update the get_price_data function to use the new functions\ndef get_price_data(ticker: str, start_date: str, end_date: str, api_key: str = None) -> pd.DataFrame:\n    prices = get_prices(ticker, start_date, end_date, api_key=api_key)\n    return prices_to_df(prices)\n"
  },
  {
    "path": "src/utils/__init__.py",
    "content": "# This file can be empty\n\n\"\"\"Utility modules for the application.\"\"\"\n"
  },
  {
    "path": "src/utils/analysts.py",
    "content": "\"\"\"Constants and utilities related to analysts configuration.\"\"\"\n\nfrom src.agents import portfolio_manager\nfrom src.agents.aswath_damodaran import aswath_damodaran_agent\nfrom src.agents.ben_graham import ben_graham_agent\nfrom src.agents.bill_ackman import bill_ackman_agent\nfrom src.agents.cathie_wood import cathie_wood_agent\nfrom src.agents.charlie_munger import charlie_munger_agent\nfrom src.agents.fundamentals import fundamentals_analyst_agent\nfrom src.agents.michael_burry import michael_burry_agent\nfrom src.agents.phil_fisher import phil_fisher_agent\nfrom src.agents.peter_lynch import peter_lynch_agent\nfrom src.agents.sentiment import sentiment_analyst_agent\nfrom src.agents.stanley_druckenmiller import stanley_druckenmiller_agent\nfrom src.agents.technicals import technical_analyst_agent\nfrom src.agents.valuation import valuation_analyst_agent\nfrom src.agents.warren_buffett import warren_buffett_agent\nfrom src.agents.rakesh_jhunjhunwala import rakesh_jhunjhunwala_agent\nfrom src.agents.mohnish_pabrai import mohnish_pabrai_agent\nfrom src.agents.news_sentiment import news_sentiment_agent\nfrom src.agents.growth_agent import growth_analyst_agent\n\n# Define analyst configuration - single source of truth\nANALYST_CONFIG = {\n    \"aswath_damodaran\": {\n        \"display_name\": \"Aswath Damodaran\",\n        \"description\": \"The Dean of Valuation\",\n        \"investing_style\": \"Focuses on intrinsic value and financial metrics to assess investment opportunities through rigorous valuation analysis.\",\n        \"agent_func\": aswath_damodaran_agent,\n        \"type\": \"analyst\",\n        \"order\": 0,\n    },\n    \"ben_graham\": {\n        \"display_name\": \"Ben Graham\",\n        \"description\": \"The Father of Value Investing\",\n        \"investing_style\": \"Emphasizes a margin of safety and invests in undervalued companies with strong fundamentals through systematic value analysis.\",\n        \"agent_func\": ben_graham_agent,\n        \"type\": \"analyst\",\n        \"order\": 1,\n    },\n    \"bill_ackman\": {\n        \"display_name\": \"Bill Ackman\",\n        \"description\": \"The Activist Investor\",\n        \"investing_style\": \"Seeks to influence management and unlock value through strategic activism and contrarian investment positions.\",\n        \"agent_func\": bill_ackman_agent,\n        \"type\": \"analyst\",\n        \"order\": 2,\n    },\n    \"cathie_wood\": {\n        \"display_name\": \"Cathie Wood\",\n        \"description\": \"The Queen of Growth Investing\",\n        \"investing_style\": \"Focuses on disruptive innovation and growth, investing in companies that are leading technological advancements and market disruption.\",\n        \"agent_func\": cathie_wood_agent,\n        \"type\": \"analyst\",\n        \"order\": 3,\n    },\n    \"charlie_munger\": {\n        \"display_name\": \"Charlie Munger\",\n        \"description\": \"The Rational Thinker\",\n        \"investing_style\": \"Advocates for value investing with a focus on quality businesses and long-term growth through rational decision-making.\",\n        \"agent_func\": charlie_munger_agent,\n        \"type\": \"analyst\",\n        \"order\": 4,\n    },\n    \"michael_burry\": {\n        \"display_name\": \"Michael Burry\",\n        \"description\": \"The Big Short Contrarian\",\n        \"investing_style\": \"Makes contrarian bets, often shorting overvalued markets and investing in undervalued assets through deep fundamental analysis.\",\n        \"agent_func\": michael_burry_agent,\n        \"type\": \"analyst\",\n        \"order\": 5,\n    },\n    \"mohnish_pabrai\": {\n        \"display_name\": \"Mohnish Pabrai\",\n        \"description\": \"The Dhandho Investor\",\n        \"investing_style\": \"Focuses on value investing and long-term growth through fundamental analysis and a margin of safety.\",\n        \"agent_func\": mohnish_pabrai_agent,\n        \"type\": \"analyst\",\n        \"order\": 6,\n    },\n    \"peter_lynch\": {\n        \"display_name\": \"Peter Lynch\",\n        \"description\": \"The 10-Bagger Investor\",\n        \"investing_style\": \"Invests in companies with understandable business models and strong growth potential using the 'buy what you know' strategy.\",\n        \"agent_func\": peter_lynch_agent,\n        \"type\": \"analyst\",\n        \"order\": 6,\n    },\n    \"phil_fisher\": {\n        \"display_name\": \"Phil Fisher\",\n        \"description\": \"The Scuttlebutt Investor\",\n        \"investing_style\": \"Emphasizes investing in companies with strong management and innovative products, focusing on long-term growth through scuttlebutt research.\",\n        \"agent_func\": phil_fisher_agent,\n        \"type\": \"analyst\",\n        \"order\": 7,\n    },\n    \"rakesh_jhunjhunwala\": {\n        \"display_name\": \"Rakesh Jhunjhunwala\",\n        \"description\": \"The Big Bull Of India\",\n        \"investing_style\": \"Leverages macroeconomic insights to invest in high-growth sectors, particularly within emerging markets and domestic opportunities.\",\n        \"agent_func\": rakesh_jhunjhunwala_agent,\n        \"type\": \"analyst\",\n        \"order\": 8,\n    },\n    \"stanley_druckenmiller\": {\n        \"display_name\": \"Stanley Druckenmiller\",\n        \"description\": \"The Macro Investor\",\n        \"investing_style\": \"Focuses on macroeconomic trends, making large bets on currencies, commodities, and interest rates through top-down analysis.\",\n        \"agent_func\": stanley_druckenmiller_agent,\n        \"type\": \"analyst\",\n        \"order\": 9,\n    },\n    \"warren_buffett\": {\n        \"display_name\": \"Warren Buffett\",\n        \"description\": \"The Oracle of Omaha\",\n        \"investing_style\": \"Seeks companies with strong fundamentals and competitive advantages through value investing and long-term ownership.\",\n        \"agent_func\": warren_buffett_agent,\n        \"type\": \"analyst\",\n        \"order\": 10,\n    },\n    \"technical_analyst\": {\n        \"display_name\": \"Technical Analyst\",\n        \"description\": \"Chart Pattern Specialist\",\n        \"investing_style\": \"Focuses on chart patterns and market trends to make investment decisions, often using technical indicators and price action analysis.\",\n        \"agent_func\": technical_analyst_agent,\n        \"type\": \"analyst\",\n        \"order\": 11,\n    },\n    \"fundamentals_analyst\": {\n        \"display_name\": \"Fundamentals Analyst\",\n        \"description\": \"Financial Statement Specialist\",\n        \"investing_style\": \"Delves into financial statements and economic indicators to assess the intrinsic value of companies through fundamental analysis.\",\n        \"agent_func\": fundamentals_analyst_agent,\n        \"type\": \"analyst\",\n        \"order\": 12,\n    },\n    \"growth_analyst\": {\n        \"display_name\": \"Growth Analyst\",\n        \"description\": \"Growth Specialist\",\n        \"investing_style\": \"Analyzes growth trends and valuation to identify growth opportunities through growth analysis.\",\n        \"agent_func\": growth_analyst_agent,\n        \"type\": \"analyst\",\n        \"order\": 13,\n    },\n    \"news_sentiment_analyst\": {\n        \"display_name\": \"News Sentiment Analyst\",\n        \"description\": \"News Sentiment Specialist\",\n        \"investing_style\": \"Analyzes news sentiment to predict market movements and identify opportunities through news analysis.\",\n        \"agent_func\": news_sentiment_agent,\n        \"type\": \"analyst\",\n        \"order\": 14,\n    },\n    \"sentiment_analyst\": {\n        \"display_name\": \"Sentiment Analyst\",\n        \"description\": \"Market Sentiment Specialist\",\n        \"investing_style\": \"Gauges market sentiment and investor behavior to predict market movements and identify opportunities through behavioral analysis.\",\n        \"agent_func\": sentiment_analyst_agent,\n        \"type\": \"analyst\",\n        \"order\": 15,\n    },\n    \"valuation_analyst\": {\n        \"display_name\": \"Valuation Analyst\",\n        \"description\": \"Company Valuation Specialist\",\n        \"investing_style\": \"Specializes in determining the fair value of companies, using various valuation models and financial metrics for investment decisions.\",\n        \"agent_func\": valuation_analyst_agent,\n        \"type\": \"analyst\",\n        \"order\": 16,\n    },\n}\n\n# Derive ANALYST_ORDER from ANALYST_CONFIG for backwards compatibility\nANALYST_ORDER = [(config[\"display_name\"], key) for key, config in sorted(ANALYST_CONFIG.items(), key=lambda x: x[1][\"order\"])]\n\n\ndef get_analyst_nodes():\n    \"\"\"Get the mapping of analyst keys to their (node_name, agent_func) tuples.\"\"\"\n    return {key: (f\"{key}_agent\", config[\"agent_func\"]) for key, config in ANALYST_CONFIG.items()}\n\n\ndef get_agents_list():\n    \"\"\"Get the list of agents for API responses.\"\"\"\n    return [\n        {\n            \"key\": key,\n            \"display_name\": config[\"display_name\"],\n            \"description\": config[\"description\"],\n            \"investing_style\": config[\"investing_style\"],\n            \"order\": config[\"order\"]\n        }\n        for key, config in sorted(ANALYST_CONFIG.items(), key=lambda x: x[1][\"order\"])\n    ]\n"
  },
  {
    "path": "src/utils/api_key.py",
    "content": "\n\ndef get_api_key_from_state(state: dict, api_key_name: str) -> str:\n    \"\"\"Get an API key from the state object.\"\"\"\n    if state and state.get(\"metadata\", {}).get(\"request\"):\n        request = state[\"metadata\"][\"request\"]\n        if hasattr(request, 'api_keys') and request.api_keys:\n            return request.api_keys.get(api_key_name)\n    return None"
  },
  {
    "path": "src/utils/display.py",
    "content": "from colorama import Fore, Style\nfrom tabulate import tabulate\nfrom .analysts import ANALYST_ORDER\nimport os\nimport json\n\n\ndef sort_agent_signals(signals):\n    \"\"\"Sort agent signals in a consistent order.\"\"\"\n    # Create order mapping from ANALYST_ORDER\n    analyst_order = {display: idx for idx, (display, _) in enumerate(ANALYST_ORDER)}\n    analyst_order[\"Risk Management\"] = len(ANALYST_ORDER)  # Add Risk Management at the end\n\n    return sorted(signals, key=lambda x: analyst_order.get(x[0], 999))\n\n\ndef print_trading_output(result: dict) -> None:\n    \"\"\"\n    Print formatted trading results with colored tables for multiple tickers.\n\n    Args:\n        result (dict): Dictionary containing decisions and analyst signals for multiple tickers\n    \"\"\"\n    decisions = result.get(\"decisions\")\n    if not decisions:\n        print(f\"{Fore.RED}No trading decisions available{Style.RESET_ALL}\")\n        return\n\n    # Print decisions for each ticker\n    for ticker, decision in decisions.items():\n        print(f\"\\n{Fore.WHITE}{Style.BRIGHT}Analysis for {Fore.CYAN}{ticker}{Style.RESET_ALL}\")\n        print(f\"{Fore.WHITE}{Style.BRIGHT}{'=' * 50}{Style.RESET_ALL}\")\n\n        # Prepare analyst signals table for this ticker\n        table_data = []\n        for agent, signals in result.get(\"analyst_signals\", {}).items():\n            if ticker not in signals:\n                continue\n                \n            # Skip Risk Management agent in the signals section\n            if agent == \"risk_management_agent\":\n                continue\n\n            signal = signals[ticker]\n            agent_name = agent.replace(\"_agent\", \"\").replace(\"_\", \" \").title()\n            signal_type = signal.get(\"signal\", \"\").upper()\n            confidence = signal.get(\"confidence\", 0)\n\n            signal_color = {\n                \"BULLISH\": Fore.GREEN,\n                \"BEARISH\": Fore.RED,\n                \"NEUTRAL\": Fore.YELLOW,\n            }.get(signal_type, Fore.WHITE)\n            \n            # Get reasoning if available\n            reasoning_str = \"\"\n            if \"reasoning\" in signal and signal[\"reasoning\"]:\n                reasoning = signal[\"reasoning\"]\n                \n                # Handle different types of reasoning (string, dict, etc.)\n                if isinstance(reasoning, str):\n                    reasoning_str = reasoning\n                elif isinstance(reasoning, dict):\n                    # Convert dict to string representation\n                    reasoning_str = json.dumps(reasoning, indent=2)\n                else:\n                    # Convert any other type to string\n                    reasoning_str = str(reasoning)\n                \n                # Wrap long reasoning text to make it more readable\n                wrapped_reasoning = \"\"\n                current_line = \"\"\n                # Use a fixed width of 60 characters to match the table column width\n                max_line_length = 60\n                for word in reasoning_str.split():\n                    if len(current_line) + len(word) + 1 > max_line_length:\n                        wrapped_reasoning += current_line + \"\\n\"\n                        current_line = word\n                    else:\n                        if current_line:\n                            current_line += \" \" + word\n                        else:\n                            current_line = word\n                if current_line:\n                    wrapped_reasoning += current_line\n                \n                reasoning_str = wrapped_reasoning\n\n            table_data.append(\n                [\n                    f\"{Fore.CYAN}{agent_name}{Style.RESET_ALL}\",\n                    f\"{signal_color}{signal_type}{Style.RESET_ALL}\",\n                    f\"{Fore.WHITE}{confidence}%{Style.RESET_ALL}\",\n                    f\"{Fore.WHITE}{reasoning_str}{Style.RESET_ALL}\",\n                ]\n            )\n\n        # Sort the signals according to the predefined order\n        table_data = sort_agent_signals(table_data)\n\n        print(f\"\\n{Fore.WHITE}{Style.BRIGHT}AGENT ANALYSIS:{Style.RESET_ALL} [{Fore.CYAN}{ticker}{Style.RESET_ALL}]\")\n        print(\n            tabulate(\n                table_data,\n                headers=[f\"{Fore.WHITE}Agent\", \"Signal\", \"Confidence\", \"Reasoning\"],\n                tablefmt=\"grid\",\n                colalign=(\"left\", \"center\", \"right\", \"left\"),\n            )\n        )\n\n        # Print Trading Decision Table\n        action = decision.get(\"action\", \"\").upper()\n        action_color = {\n            \"BUY\": Fore.GREEN,\n            \"SELL\": Fore.RED,\n            \"HOLD\": Fore.YELLOW,\n            \"COVER\": Fore.GREEN,\n            \"SHORT\": Fore.RED,\n        }.get(action, Fore.WHITE)\n\n        # Get reasoning and format it\n        reasoning = decision.get(\"reasoning\", \"\")\n        # Wrap long reasoning text to make it more readable\n        wrapped_reasoning = \"\"\n        if reasoning:\n            current_line = \"\"\n            # Use a fixed width of 60 characters to match the table column width\n            max_line_length = 60\n            for word in reasoning.split():\n                if len(current_line) + len(word) + 1 > max_line_length:\n                    wrapped_reasoning += current_line + \"\\n\"\n                    current_line = word\n                else:\n                    if current_line:\n                        current_line += \" \" + word\n                    else:\n                        current_line = word\n            if current_line:\n                wrapped_reasoning += current_line\n\n        decision_data = [\n            [\"Action\", f\"{action_color}{action}{Style.RESET_ALL}\"],\n            [\"Quantity\", f\"{action_color}{decision.get('quantity')}{Style.RESET_ALL}\"],\n            [\n                \"Confidence\",\n                f\"{Fore.WHITE}{decision.get('confidence'):.1f}%{Style.RESET_ALL}\",\n            ],\n            [\"Reasoning\", f\"{Fore.WHITE}{wrapped_reasoning}{Style.RESET_ALL}\"],\n        ]\n        \n        print(f\"\\n{Fore.WHITE}{Style.BRIGHT}TRADING DECISION:{Style.RESET_ALL} [{Fore.CYAN}{ticker}{Style.RESET_ALL}]\")\n        print(tabulate(decision_data, tablefmt=\"grid\", colalign=(\"left\", \"left\")))\n\n    # Print Portfolio Summary\n    print(f\"\\n{Fore.WHITE}{Style.BRIGHT}PORTFOLIO SUMMARY:{Style.RESET_ALL}\")\n    portfolio_data = []\n    \n    # Extract portfolio manager reasoning (common for all tickers)\n    portfolio_manager_reasoning = None\n    for ticker, decision in decisions.items():\n        if decision.get(\"reasoning\"):\n            portfolio_manager_reasoning = decision.get(\"reasoning\")\n            break\n            \n    analyst_signals = result.get(\"analyst_signals\", {})\n    for ticker, decision in decisions.items():\n        action = decision.get(\"action\", \"\").upper()\n        action_color = {\n            \"BUY\": Fore.GREEN,\n            \"SELL\": Fore.RED,\n            \"HOLD\": Fore.YELLOW,\n            \"COVER\": Fore.GREEN,\n            \"SHORT\": Fore.RED,\n        }.get(action, Fore.WHITE)\n\n        # Calculate analyst signal counts\n        bullish_count = 0\n        bearish_count = 0\n        neutral_count = 0\n        if analyst_signals:\n            for agent, signals in analyst_signals.items():\n                if ticker in signals:\n                    signal = signals[ticker].get(\"signal\", \"\").upper()\n                    if signal == \"BULLISH\":\n                        bullish_count += 1\n                    elif signal == \"BEARISH\":\n                        bearish_count += 1\n                    elif signal == \"NEUTRAL\":\n                        neutral_count += 1\n\n        portfolio_data.append(\n            [\n                f\"{Fore.CYAN}{ticker}{Style.RESET_ALL}\",\n                f\"{action_color}{action}{Style.RESET_ALL}\",\n                f\"{action_color}{decision.get('quantity')}{Style.RESET_ALL}\",\n                f\"{Fore.WHITE}{decision.get('confidence'):.1f}%{Style.RESET_ALL}\",\n                f\"{Fore.GREEN}{bullish_count}{Style.RESET_ALL}\",\n                f\"{Fore.RED}{bearish_count}{Style.RESET_ALL}\",\n                f\"{Fore.YELLOW}{neutral_count}{Style.RESET_ALL}\",\n            ]\n        )\n\n    headers = [\n        f\"{Fore.WHITE}Ticker\",\n        f\"{Fore.WHITE}Action\",\n        f\"{Fore.WHITE}Quantity\",\n        f\"{Fore.WHITE}Confidence\",\n        f\"{Fore.WHITE}Bullish\",\n        f\"{Fore.WHITE}Bearish\",\n        f\"{Fore.WHITE}Neutral\",\n    ]\n    \n    # Print the portfolio summary table\n    print(\n        tabulate(\n            portfolio_data,\n            headers=headers,\n            tablefmt=\"grid\",\n            colalign=(\"left\", \"center\", \"right\", \"right\", \"center\", \"center\", \"center\"),\n        )\n    )\n    \n    # Print Portfolio Manager's reasoning if available\n    if portfolio_manager_reasoning:\n        # Handle different types of reasoning (string, dict, etc.)\n        reasoning_str = \"\"\n        if isinstance(portfolio_manager_reasoning, str):\n            reasoning_str = portfolio_manager_reasoning\n        elif isinstance(portfolio_manager_reasoning, dict):\n            # Convert dict to string representation\n            reasoning_str = json.dumps(portfolio_manager_reasoning, indent=2)\n        else:\n            # Convert any other type to string\n            reasoning_str = str(portfolio_manager_reasoning)\n            \n        # Wrap long reasoning text to make it more readable\n        wrapped_reasoning = \"\"\n        current_line = \"\"\n        # Use a fixed width of 60 characters to match the table column width\n        max_line_length = 60\n        for word in reasoning_str.split():\n            if len(current_line) + len(word) + 1 > max_line_length:\n                wrapped_reasoning += current_line + \"\\n\"\n                current_line = word\n            else:\n                if current_line:\n                    current_line += \" \" + word\n                else:\n                    current_line = word\n        if current_line:\n            wrapped_reasoning += current_line\n            \n        print(f\"\\n{Fore.WHITE}{Style.BRIGHT}Portfolio Strategy:{Style.RESET_ALL}\")\n        print(f\"{Fore.CYAN}{wrapped_reasoning}{Style.RESET_ALL}\")\n\n\ndef print_backtest_results(table_rows: list) -> None:\n    \"\"\"Print the backtest results in a nicely formatted table\"\"\"\n    # Clear the screen\n    os.system(\"cls\" if os.name == \"nt\" else \"clear\")\n\n    # Split rows into ticker rows and summary rows\n    ticker_rows = []\n    summary_rows = []\n\n    for row in table_rows:\n        if isinstance(row[1], str) and \"PORTFOLIO SUMMARY\" in row[1]:\n            summary_rows.append(row)\n        else:\n            ticker_rows.append(row)\n\n    # Display latest portfolio summary\n    if summary_rows:\n        # Pick the most recent summary by date (YYYY-MM-DD)\n        latest_summary = max(summary_rows, key=lambda r: r[0])\n        print(f\"\\n{Fore.WHITE}{Style.BRIGHT}PORTFOLIO SUMMARY:{Style.RESET_ALL}\")\n\n        # Adjusted indexes after adding Long/Short Shares\n        position_str = latest_summary[7].split(\"$\")[1].split(Style.RESET_ALL)[0].replace(\",\", \"\")\n        cash_str     = latest_summary[8].split(\"$\")[1].split(Style.RESET_ALL)[0].replace(\",\", \"\")\n        total_str    = latest_summary[9].split(\"$\")[1].split(Style.RESET_ALL)[0].replace(\",\", \"\")\n\n        print(f\"Cash Balance: {Fore.CYAN}${float(cash_str):,.2f}{Style.RESET_ALL}\")\n        print(f\"Total Position Value: {Fore.YELLOW}${float(position_str):,.2f}{Style.RESET_ALL}\")\n        print(f\"Total Value: {Fore.WHITE}${float(total_str):,.2f}{Style.RESET_ALL}\")\n        print(f\"Portfolio Return: {latest_summary[10]}\")\n        if len(latest_summary) > 14 and latest_summary[14]:\n            print(f\"Benchmark Return: {latest_summary[14]}\")\n\n        # Display performance metrics if available\n        if latest_summary[11]:  # Sharpe ratio\n            print(f\"Sharpe Ratio: {latest_summary[11]}\")\n        if latest_summary[12]:  # Sortino ratio\n            print(f\"Sortino Ratio: {latest_summary[12]}\")\n        if latest_summary[13]:  # Max drawdown\n            print(f\"Max Drawdown: {latest_summary[13]}\")\n\n    # Add vertical spacing\n    print(\"\\n\" * 2)\n\n    # Print the table with just ticker rows\n    print(\n        tabulate(\n            ticker_rows,\n            headers=[\n                \"Date\",\n                \"Ticker\",\n                \"Action\",\n                \"Quantity\",\n                \"Price\",\n                \"Long Shares\",\n                \"Short Shares\",\n                \"Position Value\",\n            ],\n            tablefmt=\"grid\",\n            colalign=(\n                \"left\",    # Date\n                \"left\",    # Ticker\n                \"center\",  # Action\n                \"right\",   # Quantity\n                \"right\",   # Price\n                \"right\",   # Long Shares\n                \"right\",   # Short Shares\n                \"right\",   # Position Value\n            ),\n        )\n    )\n\n    # Add vertical spacing\n    print(\"\\n\" * 4)\n\n\ndef format_backtest_row(\n    date: str,\n    ticker: str,\n    action: str,\n    quantity: float,\n    price: float,\n    long_shares: float = 0,\n    short_shares: float = 0,\n    position_value: float = 0,\n    is_summary: bool = False,\n    total_value: float = None,\n    return_pct: float = None,\n    cash_balance: float = None,\n    total_position_value: float = None,\n    sharpe_ratio: float = None,\n    sortino_ratio: float = None,\n    max_drawdown: float = None,\n    benchmark_return_pct: float | None = None,\n) -> list[any]:\n    \"\"\"Format a row for the backtest results table\"\"\"\n    # Color the action\n    action_color = {\n        \"BUY\": Fore.GREEN,\n        \"COVER\": Fore.GREEN,\n        \"SELL\": Fore.RED,\n        \"SHORT\": Fore.RED,\n        \"HOLD\": Fore.WHITE,\n    }.get(action.upper(), Fore.WHITE)\n\n    if is_summary:\n        return_color = Fore.GREEN if return_pct >= 0 else Fore.RED\n        benchmark_str = \"\"\n        if benchmark_return_pct is not None:\n            bench_color = Fore.GREEN if benchmark_return_pct >= 0 else Fore.RED\n            benchmark_str = f\"{bench_color}{benchmark_return_pct:+.2f}%{Style.RESET_ALL}\"\n        return [\n            date,\n            f\"{Fore.WHITE}{Style.BRIGHT}PORTFOLIO SUMMARY{Style.RESET_ALL}\",\n            \"\",  # Action\n            \"\",  # Quantity\n            \"\",  # Price\n            \"\",  # Long Shares\n            \"\",  # Short Shares\n            f\"{Fore.YELLOW}${total_position_value:,.2f}{Style.RESET_ALL}\",  # Total Position Value\n            f\"{Fore.CYAN}${cash_balance:,.2f}{Style.RESET_ALL}\",  # Cash Balance\n            f\"{Fore.WHITE}${total_value:,.2f}{Style.RESET_ALL}\",  # Total Value\n            f\"{return_color}{return_pct:+.2f}%{Style.RESET_ALL}\",  # Return\n            f\"{Fore.YELLOW}{sharpe_ratio:.2f}{Style.RESET_ALL}\" if sharpe_ratio is not None else \"\",  # Sharpe Ratio\n            f\"{Fore.YELLOW}{sortino_ratio:.2f}{Style.RESET_ALL}\" if sortino_ratio is not None else \"\",  # Sortino Ratio\n            f\"{Fore.RED}{max_drawdown:.2f}%{Style.RESET_ALL}\" if max_drawdown is not None else \"\",  # Max Drawdown (signed)\n            benchmark_str,  # Benchmark (S&P 500)\n        ]\n    else:\n        return [\n            date,\n            f\"{Fore.CYAN}{ticker}{Style.RESET_ALL}\",\n            f\"{action_color}{action.upper()}{Style.RESET_ALL}\",\n            f\"{action_color}{quantity:,.0f}{Style.RESET_ALL}\",\n            f\"{Fore.WHITE}{price:,.2f}{Style.RESET_ALL}\",\n            f\"{Fore.GREEN}{long_shares:,.0f}{Style.RESET_ALL}\",   # Long Shares\n            f\"{Fore.RED}{short_shares:,.0f}{Style.RESET_ALL}\",    # Short Shares\n            f\"{Fore.YELLOW}{position_value:,.2f}{Style.RESET_ALL}\",\n        ]\n"
  },
  {
    "path": "src/utils/docker.py",
    "content": "\"\"\"Utilities for working with Ollama models in Docker environments\"\"\"\n\nimport requests\nimport time\nfrom colorama import Fore, Style\nimport questionary\n\ndef ensure_ollama_and_model(model_name: str, ollama_url: str) -> bool:\n    \"\"\"Ensure the Ollama model is available at the target Ollama endpoint.\"\"\"\n    print(f\"{Fore.CYAN}Using Ollama endpoint at {ollama_url}{Style.RESET_ALL}\")\n    \n    # Step 1: Check if Ollama service is available\n    if not is_ollama_available(ollama_url):\n        return False\n        \n    # Step 2: Check if model is already available\n    available_models = get_available_models(ollama_url)\n    if model_name in available_models:\n        print(f\"{Fore.GREEN}Model {model_name} is available in the Docker Ollama container.{Style.RESET_ALL}\")\n        return True\n        \n    # Step 3: Model not available - ask if user wants to download\n    print(f\"{Fore.YELLOW}Model {model_name} is not available in the Docker Ollama container.{Style.RESET_ALL}\")\n    \n    if not questionary.confirm(f\"Do you want to download {model_name}?\").ask():\n        print(f\"{Fore.RED}Cannot proceed without the model.{Style.RESET_ALL}\")\n        return False\n        \n    # Step 4: Download the model\n    return download_model(model_name, ollama_url)\n\n\ndef is_ollama_available(ollama_url: str) -> bool:\n    \"\"\"Check if Ollama service is available in Docker environment.\"\"\"\n    try:\n        response = requests.get(f\"{ollama_url}/api/version\", timeout=5)\n        if response.status_code == 200:\n            return True\n            \n        print(f\"{Fore.RED}Cannot connect to Ollama service at {ollama_url}.{Style.RESET_ALL}\")\n        print(f\"{Fore.YELLOW}Make sure the Ollama service is running in your Docker environment.{Style.RESET_ALL}\")\n        return False\n    except requests.RequestException as e:\n        print(f\"{Fore.RED}Error connecting to Ollama service: {e}{Style.RESET_ALL}\")\n        return False\n\n\ndef get_available_models(ollama_url: str) -> list:\n    \"\"\"Get list of available models in Docker environment.\"\"\"\n    try:\n        response = requests.get(f\"{ollama_url}/api/tags\", timeout=5)\n        if response.status_code == 200:\n            models = response.json().get(\"models\", [])\n            return [m[\"name\"] for m in models]\n            \n        print(f\"{Fore.RED}Failed to get available models from Ollama service. Status code: {response.status_code}{Style.RESET_ALL}\")\n        return []\n    except requests.RequestException as e:\n        print(f\"{Fore.RED}Error getting available models: {e}{Style.RESET_ALL}\")\n        return []\n\n\ndef download_model(model_name: str, ollama_url: str) -> bool:\n    \"\"\"Download a model in Docker environment.\"\"\"\n    print(f\"{Fore.YELLOW}Downloading model {model_name} to the Docker Ollama container...{Style.RESET_ALL}\")\n    print(f\"{Fore.CYAN}This may take some time. Please be patient.{Style.RESET_ALL}\")\n    \n    # Step 1: Initiate the download\n    try:\n        response = requests.post(f\"{ollama_url}/api/pull\", json={\"name\": model_name}, timeout=10)\n        if response.status_code != 200:\n            print(f\"{Fore.RED}Failed to initiate model download. Status code: {response.status_code}{Style.RESET_ALL}\")\n            if response.text:\n                print(f\"{Fore.RED}Error: {response.text}{Style.RESET_ALL}\")\n            return False\n    except requests.RequestException as e:\n        print(f\"{Fore.RED}Error initiating download request: {e}{Style.RESET_ALL}\")\n        return False\n    \n    # Step 2: Monitor the download progress\n    print(f\"{Fore.CYAN}Download initiated. Checking periodically for completion...{Style.RESET_ALL}\")\n    \n    total_wait_time = 0\n    max_wait_time = 1800  # 30 minutes max wait\n    check_interval = 10  # Check every 10 seconds\n    \n    while total_wait_time < max_wait_time:\n        # Check if the model has been downloaded\n        available_models = get_available_models(ollama_url)\n        if model_name in available_models:\n            print(f\"{Fore.GREEN}Model {model_name} downloaded successfully.{Style.RESET_ALL}\")\n            return True\n            \n        # Wait before checking again\n        time.sleep(check_interval)\n        total_wait_time += check_interval\n        \n        # Print a status message every minute\n        if total_wait_time % 60 == 0:\n            minutes = total_wait_time // 60\n            print(f\"{Fore.CYAN}Download in progress... ({minutes} minute{'s' if minutes != 1 else ''} elapsed){Style.RESET_ALL}\")\n    \n    # If we get here, we've timed out\n    print(f\"{Fore.RED}Timed out waiting for model download to complete after {max_wait_time // 60} minutes.{Style.RESET_ALL}\")\n    return False\n\n\ndef delete_model(model_name: str, ollama_url: str) -> bool:\n    \"\"\"Delete a model in Docker environment.\"\"\"\n    print(f\"{Fore.YELLOW}Deleting model {model_name} from Docker container...{Style.RESET_ALL}\")\n    \n    try:\n        response = requests.delete(f\"{ollama_url}/api/delete\", json={\"name\": model_name}, timeout=10)\n        if response.status_code == 200:\n            print(f\"{Fore.GREEN}Model {model_name} deleted successfully.{Style.RESET_ALL}\")\n            return True\n        else:\n            print(f\"{Fore.RED}Failed to delete model. Status code: {response.status_code}{Style.RESET_ALL}\")\n            if response.text:\n                print(f\"{Fore.RED}Error: {response.text}{Style.RESET_ALL}\")\n            return False\n    except requests.RequestException as e:\n        print(f\"{Fore.RED}Error deleting model: {e}{Style.RESET_ALL}\")\n        return False "
  },
  {
    "path": "src/utils/llm.py",
    "content": "\"\"\"Helper functions for LLM\"\"\"\n\nimport json\nfrom pydantic import BaseModel\nfrom src.llm.models import get_model, get_model_info\nfrom src.utils.progress import progress\nfrom src.graph.state import AgentState\n\n\ndef call_llm(\n    prompt: any,\n    pydantic_model: type[BaseModel],\n    agent_name: str | None = None,\n    state: AgentState | None = None,\n    max_retries: int = 3,\n    default_factory=None,\n) -> BaseModel:\n    \"\"\"\n    Makes an LLM call with retry logic, handling both JSON supported and non-JSON supported models.\n\n    Args:\n        prompt: The prompt to send to the LLM\n        pydantic_model: The Pydantic model class to structure the output\n        agent_name: Optional name of the agent for progress updates and model config extraction\n        state: Optional state object to extract agent-specific model configuration\n        max_retries: Maximum number of retries (default: 3)\n        default_factory: Optional factory function to create default response on failure\n\n    Returns:\n        An instance of the specified Pydantic model\n    \"\"\"\n    \n    # Extract model configuration if state is provided and agent_name is available\n    if state and agent_name:\n        model_name, model_provider = get_agent_model_config(state, agent_name)\n    else:\n        # Use system defaults when no state or agent_name is provided\n        model_name = \"gpt-4.1\"\n        model_provider = \"OPENAI\"\n\n    # Extract API keys from state if available\n    api_keys = None\n    if state:\n        request = state.get(\"metadata\", {}).get(\"request\")\n        if request and hasattr(request, 'api_keys'):\n            api_keys = request.api_keys\n\n    model_info = get_model_info(model_name, model_provider)\n    llm = get_model(model_name, model_provider, api_keys)\n\n    # For non-JSON support models, we can use structured output\n    if not (model_info and not model_info.has_json_mode()):\n        llm = llm.with_structured_output(\n            pydantic_model,\n            method=\"json_mode\",\n        )\n\n    # Call the LLM with retries\n    for attempt in range(max_retries):\n        try:\n            # Call the LLM\n            result = llm.invoke(prompt)\n\n            # For non-JSON support models, we need to extract and parse the JSON manually\n            if model_info and not model_info.has_json_mode():\n                parsed_result = extract_json_from_response(result.content)\n                if parsed_result:\n                    return pydantic_model(**parsed_result)\n            else:\n                return result\n\n        except Exception as e:\n            if agent_name:\n                progress.update_status(agent_name, None, f\"Error - retry {attempt + 1}/{max_retries}\")\n\n            if attempt == max_retries - 1:\n                print(f\"Error in LLM call after {max_retries} attempts: {e}\")\n                # Use default_factory if provided, otherwise create a basic default\n                if default_factory:\n                    return default_factory()\n                return create_default_response(pydantic_model)\n\n    # This should never be reached due to the retry logic above\n    return create_default_response(pydantic_model)\n\n\ndef create_default_response(model_class: type[BaseModel]) -> BaseModel:\n    \"\"\"Creates a safe default response based on the model's fields.\"\"\"\n    default_values = {}\n    for field_name, field in model_class.model_fields.items():\n        if field.annotation == str:\n            default_values[field_name] = \"Error in analysis, using default\"\n        elif field.annotation == float:\n            default_values[field_name] = 0.0\n        elif field.annotation == int:\n            default_values[field_name] = 0\n        elif hasattr(field.annotation, \"__origin__\") and field.annotation.__origin__ == dict:\n            default_values[field_name] = {}\n        else:\n            # For other types (like Literal), try to use the first allowed value\n            if hasattr(field.annotation, \"__args__\"):\n                default_values[field_name] = field.annotation.__args__[0]\n            else:\n                default_values[field_name] = None\n\n    return model_class(**default_values)\n\n\ndef extract_json_from_response(content: str) -> dict | None:\n    \"\"\"Extracts JSON from markdown-formatted response.\"\"\"\n    try:\n        json_start = content.find(\"```json\")\n        if json_start != -1:\n            json_text = content[json_start + 7 :]  # Skip past ```json\n            json_end = json_text.find(\"```\")\n            if json_end != -1:\n                json_text = json_text[:json_end].strip()\n                return json.loads(json_text)\n    except Exception as e:\n        print(f\"Error extracting JSON from response: {e}\")\n    return None\n\n\ndef get_agent_model_config(state, agent_name):\n    \"\"\"\n    Get model configuration for a specific agent from the state.\n    Falls back to global model configuration if agent-specific config is not available.\n    Always returns valid model_name and model_provider values.\n    \"\"\"\n    request = state.get(\"metadata\", {}).get(\"request\")\n    \n    if request and hasattr(request, 'get_agent_model_config'):\n        # Get agent-specific model configuration\n        model_name, model_provider = request.get_agent_model_config(agent_name)\n        # Ensure we have valid values\n        if model_name and model_provider:\n            return model_name, model_provider.value if hasattr(model_provider, 'value') else str(model_provider)\n    \n    # Fall back to global configuration (system defaults)\n    model_name = state.get(\"metadata\", {}).get(\"model_name\") or \"gpt-4.1\"\n    model_provider = state.get(\"metadata\", {}).get(\"model_provider\") or \"OPENAI\"\n    \n    # Convert enum to string if necessary\n    if hasattr(model_provider, 'value'):\n        model_provider = model_provider.value\n    \n    return model_name, model_provider\n"
  },
  {
    "path": "src/utils/ollama.py",
    "content": "\"\"\"Utilities for working with Ollama models\"\"\"\n\nimport platform\nimport subprocess\nimport requests\nimport time\nfrom typing import List\nimport questionary\nfrom colorama import Fore, Style\nimport os\nfrom . import docker\n\n# Constants\nDEFAULT_OLLAMA_SERVER_URL = \"http://localhost:11434\"\n\n\ndef _get_ollama_base_url() -> str:\n    \"\"\"Return the configured Ollama base URL, trimming any trailing slash.\"\"\"\n    url = os.environ.get(\"OLLAMA_BASE_URL\", DEFAULT_OLLAMA_SERVER_URL)\n    if not url:\n        url = DEFAULT_OLLAMA_SERVER_URL\n    return url.rstrip(\"/\")\n\n\ndef _get_ollama_endpoint(path: str) -> str:\n    \"\"\"Build a full Ollama API endpoint from the configured base URL.\"\"\"\n    base = _get_ollama_base_url()\n    if not path.startswith(\"/\"):\n        path = f\"/{path}\"\n    return f\"{base}{path}\"\n\n\nOLLAMA_DOWNLOAD_URL = {\"darwin\": \"https://ollama.com/download/darwin\", \"windows\": \"https://ollama.com/download/windows\", \"linux\": \"https://ollama.com/download/linux\"}  # macOS  # Windows  # Linux\nINSTALLATION_INSTRUCTIONS = {\"darwin\": \"curl -fsSL https://ollama.com/install.sh | sh\", \"windows\": \"# Download from https://ollama.com/download/windows and run the installer\", \"linux\": \"curl -fsSL https://ollama.com/install.sh | sh\"}\n\n\ndef is_ollama_installed() -> bool:\n    \"\"\"Check if Ollama is installed on the system.\"\"\"\n    system = platform.system().lower()\n\n    if system == \"darwin\" or system == \"linux\":  # macOS or Linux\n        try:\n            result = subprocess.run([\"which\", \"ollama\"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)\n            return result.returncode == 0\n        except Exception:\n            return False\n    elif system == \"windows\":  # Windows\n        try:\n            result = subprocess.run([\"where\", \"ollama\"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, shell=True)\n            return result.returncode == 0\n        except Exception:\n            return False\n    else:\n        return False  # Unsupported OS\n\n\ndef is_ollama_server_running() -> bool:\n    \"\"\"Check if the Ollama server is running.\"\"\"\n    endpoint = _get_ollama_endpoint(\"/api/tags\")\n    try:\n        response = requests.get(endpoint, timeout=2)\n        return response.status_code == 200\n    except requests.RequestException:\n        return False\n\n\ndef get_locally_available_models() -> List[str]:\n    \"\"\"Get a list of models that are already downloaded locally.\"\"\"\n    if not is_ollama_server_running():\n        return []\n\n    try:\n        endpoint = _get_ollama_endpoint(\"/api/tags\")\n        response = requests.get(endpoint, timeout=5)\n        if response.status_code == 200:\n            data = response.json()\n            return [model[\"name\"] for model in data[\"models\"]] if \"models\" in data else []\n        return []\n    except requests.RequestException:\n        return []\n\n\ndef start_ollama_server() -> bool:\n    \"\"\"Start the Ollama server if it's not already running.\"\"\"\n    if is_ollama_server_running():\n        print(f\"{Fore.GREEN}Ollama server is already running.{Style.RESET_ALL}\")\n        return True\n\n    system = platform.system().lower()\n\n    try:\n        if system == \"darwin\" or system == \"linux\":  # macOS or Linux\n            subprocess.Popen([\"ollama\", \"serve\"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n        elif system == \"windows\":  # Windows\n            subprocess.Popen([\"ollama\", \"serve\"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)\n        else:\n            print(f\"{Fore.RED}Unsupported operating system: {system}{Style.RESET_ALL}\")\n            return False\n\n        # Wait for server to start\n        for _ in range(10):  # Try for 10 seconds\n            if is_ollama_server_running():\n                print(f\"{Fore.GREEN}Ollama server started successfully.{Style.RESET_ALL}\")\n                return True\n            time.sleep(1)\n\n        print(f\"{Fore.RED}Failed to start Ollama server. Timed out waiting for server to become available.{Style.RESET_ALL}\")\n        return False\n    except Exception as e:\n        print(f\"{Fore.RED}Error starting Ollama server: {e}{Style.RESET_ALL}\")\n        return False\n\n\ndef install_ollama() -> bool:\n    \"\"\"Install Ollama on the system.\"\"\"\n    system = platform.system().lower()\n    if system not in OLLAMA_DOWNLOAD_URL:\n        print(f\"{Fore.RED}Unsupported operating system for automatic installation: {system}{Style.RESET_ALL}\")\n        print(f\"Please visit https://ollama.com/download to install Ollama manually.\")\n        return False\n\n    if system == \"darwin\":  # macOS\n        print(f\"{Fore.YELLOW}Ollama for Mac is available as an application download.{Style.RESET_ALL}\")\n\n        # Default to offering the app download first for macOS users\n        if questionary.confirm(\"Would you like to download the Ollama application?\", default=True).ask():\n            try:\n                import webbrowser\n\n                webbrowser.open(OLLAMA_DOWNLOAD_URL[\"darwin\"])\n                print(f\"{Fore.YELLOW}Please download and install the application, then restart this program.{Style.RESET_ALL}\")\n                print(f\"{Fore.CYAN}After installation, you may need to open the Ollama app once before continuing.{Style.RESET_ALL}\")\n\n                # Ask if they want to try continuing after installation\n                if questionary.confirm(\"Have you installed the Ollama app and opened it at least once?\", default=False).ask():\n                    # Check if it's now installed\n                    if is_ollama_installed() and start_ollama_server():\n                        print(f\"{Fore.GREEN}Ollama is now properly installed and running!{Style.RESET_ALL}\")\n                        return True\n                    else:\n                        print(f\"{Fore.RED}Ollama installation not detected. Please restart this application after installing Ollama.{Style.RESET_ALL}\")\n                        return False\n                return False\n            except Exception as e:\n                print(f\"{Fore.RED}Failed to open browser: {e}{Style.RESET_ALL}\")\n                return False\n        else:\n            # Only offer command-line installation as a fallback for advanced users\n            if questionary.confirm(\"Would you like to try the command-line installation instead? (For advanced users)\", default=False).ask():\n                print(f\"{Fore.YELLOW}Attempting command-line installation...{Style.RESET_ALL}\")\n                try:\n                    install_process = subprocess.run([\"bash\", \"-c\", \"curl -fsSL https://ollama.com/install.sh | sh\"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)\n\n                    if install_process.returncode == 0:\n                        print(f\"{Fore.GREEN}Ollama installed successfully via command line.{Style.RESET_ALL}\")\n                        return True\n                    else:\n                        print(f\"{Fore.RED}Command-line installation failed. Please use the app download method instead.{Style.RESET_ALL}\")\n                        return False\n                except Exception as e:\n                    print(f\"{Fore.RED}Error during command-line installation: {e}{Style.RESET_ALL}\")\n                    return False\n            return False\n    elif system == \"linux\":  # Linux\n        print(f\"{Fore.YELLOW}Installing Ollama...{Style.RESET_ALL}\")\n        try:\n            # Run the installation command as a single command\n            install_process = subprocess.run([\"bash\", \"-c\", \"curl -fsSL https://ollama.com/install.sh | sh\"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)\n\n            if install_process.returncode == 0:\n                print(f\"{Fore.GREEN}Ollama installed successfully.{Style.RESET_ALL}\")\n                return True\n            else:\n                print(f\"{Fore.RED}Failed to install Ollama. Error: {install_process.stderr}{Style.RESET_ALL}\")\n                return False\n        except Exception as e:\n            print(f\"{Fore.RED}Error during Ollama installation: {e}{Style.RESET_ALL}\")\n            return False\n    elif system == \"windows\":  # Windows\n        print(f\"{Fore.YELLOW}Automatic installation on Windows is not supported.{Style.RESET_ALL}\")\n        print(f\"Please download and install Ollama from: {OLLAMA_DOWNLOAD_URL['windows']}\")\n\n        # Ask if they want to open the download page\n        if questionary.confirm(\"Do you want to open the Ollama download page in your browser?\").ask():\n            try:\n                import webbrowser\n\n                webbrowser.open(OLLAMA_DOWNLOAD_URL[\"windows\"])\n                print(f\"{Fore.YELLOW}After installation, please restart this application.{Style.RESET_ALL}\")\n\n                # Ask if they want to try continuing after installation\n                if questionary.confirm(\"Have you installed Ollama?\", default=False).ask():\n                    # Check if it's now installed\n                    if is_ollama_installed() and start_ollama_server():\n                        print(f\"{Fore.GREEN}Ollama is now properly installed and running!{Style.RESET_ALL}\")\n                        return True\n                    else:\n                        print(f\"{Fore.RED}Ollama installation not detected. Please restart this application after installing Ollama.{Style.RESET_ALL}\")\n                        return False\n            except Exception as e:\n                print(f\"{Fore.RED}Failed to open browser: {e}{Style.RESET_ALL}\")\n        return False\n\n    return False\n\n\ndef download_model(model_name: str) -> bool:\n    \"\"\"Download an Ollama model.\"\"\"\n    if not is_ollama_server_running():\n        if not start_ollama_server():\n            return False\n\n    print(f\"{Fore.YELLOW}Downloading model {model_name}...{Style.RESET_ALL}\")\n    print(f\"{Fore.CYAN}This may take a while depending on your internet speed and the model size.{Style.RESET_ALL}\")\n    print(f\"{Fore.CYAN}The download is happening in the background. Please be patient...{Style.RESET_ALL}\")\n\n    try:\n        # Use the Ollama CLI to download the model\n        process = subprocess.Popen(\n            [\"ollama\", \"pull\", model_name],\n            stdout=subprocess.PIPE, \n            stderr=subprocess.STDOUT,  # Redirect stderr to stdout to capture all output\n            text=True,\n            bufsize=1,  # Line buffered\n            encoding='utf-8',  # Explicitly use UTF-8 encoding\n            errors='replace'   # Replace any characters that cannot be decoded\n        )\n        \n        # Show some progress to the user\n        print(f\"{Fore.CYAN}Download progress:{Style.RESET_ALL}\")\n\n        # For tracking progress\n        last_percentage = 0\n        last_phase = \"\"\n        bar_length = 40\n\n        while True:\n            output = process.stdout.readline()\n            if output == \"\" and process.poll() is not None:\n                break\n            if output:\n                output = output.strip()\n                # Try to extract percentage information using a more lenient approach\n                percentage = None\n                current_phase = None\n\n                # Example patterns in Ollama output:\n                # \"downloading: 23.45 MB / 42.19 MB [================>-------------] 55.59%\"\n                # \"downloading model: 76%\"\n                # \"pulling manifest: 100%\"\n\n                # Check for percentage in the output\n                import re\n\n                percentage_match = re.search(r\"(\\d+(\\.\\d+)?)%\", output)\n                if percentage_match:\n                    try:\n                        percentage = float(percentage_match.group(1))\n                    except ValueError:\n                        percentage = None\n\n                # Try to determine the current phase (downloading, extracting, etc.)\n                phase_match = re.search(r\"^([a-zA-Z\\s]+):\", output)\n                if phase_match:\n                    current_phase = phase_match.group(1).strip()\n\n                # If we found a percentage, display a progress bar\n                if percentage is not None:\n                    # Only update if there's a significant change (avoid flickering)\n                    if abs(percentage - last_percentage) >= 1 or (current_phase and current_phase != last_phase):\n                        last_percentage = percentage\n                        if current_phase:\n                            last_phase = current_phase\n\n                        # Create a progress bar\n                        filled_length = int(bar_length * percentage / 100)\n                        bar = \"█\" * filled_length + \"░\" * (bar_length - filled_length)\n\n                        # Build the status line with the phase if available\n                        phase_display = f\"{Fore.CYAN}{last_phase.capitalize()}{Style.RESET_ALL}: \" if last_phase else \"\"\n                        status_line = f\"\\r{phase_display}{Fore.GREEN}{bar}{Style.RESET_ALL} {Fore.YELLOW}{percentage:.1f}%{Style.RESET_ALL}\"\n\n                        # Print the status line without a newline to update in place\n                        print(status_line, end=\"\", flush=True)\n                else:\n                    # If we couldn't extract a percentage but have identifiable output\n                    if \"download\" in output.lower() or \"extract\" in output.lower() or \"pulling\" in output.lower():\n                        # Don't print a newline for percentage updates\n                        if \"%\" in output:\n                            print(f\"\\r{Fore.GREEN}{output}{Style.RESET_ALL}\", end=\"\", flush=True)\n                        else:\n                            print(f\"{Fore.GREEN}{output}{Style.RESET_ALL}\")\n\n        # Wait for the process to finish\n        return_code = process.wait()\n\n        # Ensure we print a newline after the progress bar\n        print()\n\n        if return_code == 0:\n            print(f\"{Fore.GREEN}Model {model_name} downloaded successfully!{Style.RESET_ALL}\")\n            return True\n        else:\n            print(f\"{Fore.RED}Failed to download model {model_name}. Check your internet connection and try again.{Style.RESET_ALL}\")\n            return False\n    except Exception as e:\n        print(f\"\\n{Fore.RED}Error downloading model {model_name}: {e}{Style.RESET_ALL}\")\n        return False\n\n\ndef ensure_ollama_and_model(model_name: str) -> bool:\n    \"\"\"Ensure Ollama is installed, running, and the requested model is available.\"\"\"\n    ollama_url = _get_ollama_base_url()\n    env_override = os.environ.get(\"OLLAMA_BASE_URL\")\n\n    # If an explicit base URL is provided (including Docker defaults), use the remote workflow\n    if env_override or ollama_url.startswith(\"http://ollama:\") or ollama_url.startswith(\"http://host.docker.internal:\"):\n        return docker.ensure_ollama_and_model(model_name, ollama_url)\n\n    # Regular flow for environments that rely on the local Ollama install\n    # Check if Ollama is installed\n    if not is_ollama_installed():\n        print(f\"{Fore.YELLOW}Ollama is not installed on your system.{Style.RESET_ALL}\")\n        \n        # Ask if they want to install it\n        if questionary.confirm(\"Do you want to install Ollama?\").ask():\n            if not install_ollama():\n                return False\n        else:\n            print(f\"{Fore.RED}Ollama is required to use local models.{Style.RESET_ALL}\")\n            return False\n    \n    # Make sure the server is running\n    if not is_ollama_server_running():\n        print(f\"{Fore.YELLOW}Starting Ollama server...{Style.RESET_ALL}\")\n        if not start_ollama_server():\n            return False\n    \n    # Check if the model is already downloaded\n    available_models = get_locally_available_models()\n    if model_name not in available_models:\n        print(f\"{Fore.YELLOW}Model {model_name} is not available locally.{Style.RESET_ALL}\")\n        \n        # Ask if they want to download it\n        model_size_info = \"\"\n        if \"70b\" in model_name:\n            model_size_info = \" This is a large model (up to several GB) and may take a while to download.\"\n        elif \"34b\" in model_name or \"8x7b\" in model_name:\n            model_size_info = \" This is a medium-sized model (1-2 GB) and may take a few minutes to download.\"\n        \n        if questionary.confirm(f\"Do you want to download the {model_name} model?{model_size_info} The download will happen in the background.\").ask():\n            return download_model(model_name)\n        else:\n            print(f\"{Fore.RED}The model is required to proceed.{Style.RESET_ALL}\")\n            return False\n    \n    return True\n\n\ndef delete_model(model_name: str) -> bool:\n    \"\"\"Delete a locally downloaded Ollama model.\"\"\"\n    # Check if we're running in Docker\n    in_docker = os.environ.get(\"OLLAMA_BASE_URL\", \"\").startswith(\"http://ollama:\") or os.environ.get(\"OLLAMA_BASE_URL\", \"\").startswith(\"http://host.docker.internal:\")\n    \n    # In Docker environment, delegate to docker module\n    if in_docker:\n        ollama_url = os.environ.get(\"OLLAMA_BASE_URL\", \"http://ollama:11434\")\n        return docker.delete_model(model_name, ollama_url)\n        \n    # Non-Docker environment\n    if not is_ollama_server_running():\n        if not start_ollama_server():\n            return False\n    \n    print(f\"{Fore.YELLOW}Deleting model {model_name}...{Style.RESET_ALL}\")\n    \n    try:\n        # Use the Ollama CLI to delete the model\n        process = subprocess.run([\"ollama\", \"rm\", model_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)\n        \n        if process.returncode == 0:\n            print(f\"{Fore.GREEN}Model {model_name} deleted successfully.{Style.RESET_ALL}\")\n            return True\n        else:\n            print(f\"{Fore.RED}Failed to delete model {model_name}. Error: {process.stderr}{Style.RESET_ALL}\")\n            return False\n    except Exception as e:\n        print(f\"{Fore.RED}Error deleting model {model_name}: {e}{Style.RESET_ALL}\")\n        return False\n\n\n# Add this at the end of the file for command-line usage\nif __name__ == \"__main__\":\n    import sys\n    import argparse\n\n    parser = argparse.ArgumentParser(description=\"Ollama model manager\")\n    parser.add_argument(\"--check-model\", help=\"Check if model exists and download if needed\")\n    args = parser.parse_args()\n\n    if args.check_model:\n        print(f\"Ensuring Ollama is installed and model {args.check_model} is available...\")\n        result = ensure_ollama_and_model(args.check_model)\n        sys.exit(0 if result else 1)\n    else:\n        print(\"No action specified. Use --check-model to check if a model exists.\")\n        sys.exit(1)\n"
  },
  {
    "path": "src/utils/progress.py",
    "content": "from datetime import datetime, timezone\nfrom rich.console import Console\nfrom rich.live import Live\nfrom rich.table import Table\nfrom rich.style import Style\nfrom rich.text import Text\nfrom typing import Dict, Optional, Callable, List\n\nconsole = Console()\n\n\nclass AgentProgress:\n    \"\"\"Manages progress tracking for multiple agents.\"\"\"\n\n    def __init__(self):\n        self.agent_status: Dict[str, Dict[str, str]] = {}\n        self.table = Table(show_header=False, box=None, padding=(0, 1))\n        self.live = Live(self.table, console=console, refresh_per_second=4)\n        self.started = False\n        self.update_handlers: List[Callable[[str, Optional[str], str], None]] = []\n\n    def register_handler(self, handler: Callable[[str, Optional[str], str], None]):\n        \"\"\"Register a handler to be called when agent status updates.\"\"\"\n        self.update_handlers.append(handler)\n        return handler  # Return handler to support use as decorator\n\n    def unregister_handler(self, handler: Callable[[str, Optional[str], str], None]):\n        \"\"\"Unregister a previously registered handler.\"\"\"\n        if handler in self.update_handlers:\n            self.update_handlers.remove(handler)\n\n    def start(self):\n        \"\"\"Start the progress display.\"\"\"\n        if not self.started:\n            self.live.start()\n            self.started = True\n\n    def stop(self):\n        \"\"\"Stop the progress display.\"\"\"\n        if self.started:\n            self.live.stop()\n            self.started = False\n\n    def update_status(self, agent_name: str, ticker: Optional[str] = None, status: str = \"\", analysis: Optional[str] = None):\n        \"\"\"Update the status of an agent.\"\"\"\n        if agent_name not in self.agent_status:\n            self.agent_status[agent_name] = {\"status\": \"\", \"ticker\": None}\n\n        if ticker:\n            self.agent_status[agent_name][\"ticker\"] = ticker\n        if status:\n            self.agent_status[agent_name][\"status\"] = status\n        if analysis:\n            self.agent_status[agent_name][\"analysis\"] = analysis\n        \n        # Set the timestamp as UTC datetime\n        timestamp = datetime.now(timezone.utc).isoformat()\n        self.agent_status[agent_name][\"timestamp\"] = timestamp\n\n        # Notify all registered handlers\n        for handler in self.update_handlers:\n            handler(agent_name, ticker, status, analysis, timestamp)\n\n        self._refresh_display()\n\n    def get_all_status(self):\n        \"\"\"Get the current status of all agents as a dictionary.\"\"\"\n        return {agent_name: {\"ticker\": info[\"ticker\"], \"status\": info[\"status\"], \"display_name\": self._get_display_name(agent_name)} for agent_name, info in self.agent_status.items()}\n\n    def _get_display_name(self, agent_name: str) -> str:\n        \"\"\"Convert agent_name to a display-friendly format.\"\"\"\n        return agent_name.replace(\"_agent\", \"\").replace(\"_\", \" \").title()\n\n    def _refresh_display(self):\n        \"\"\"Refresh the progress display.\"\"\"\n        self.table.columns.clear()\n        self.table.add_column(width=100)\n\n        # Sort agents with Risk Management and Portfolio Management at the bottom\n        def sort_key(item):\n            agent_name = item[0]\n            if \"risk_management\" in agent_name:\n                return (2, agent_name)\n            elif \"portfolio_management\" in agent_name:\n                return (3, agent_name)\n            else:\n                return (1, agent_name)\n\n        for agent_name, info in sorted(self.agent_status.items(), key=sort_key):\n            status = info[\"status\"]\n            ticker = info[\"ticker\"]\n            # Create the status text with appropriate styling\n            if status.lower() == \"done\":\n                style = Style(color=\"green\", bold=True)\n                symbol = \"✓\"\n            elif status.lower() == \"error\":\n                style = Style(color=\"red\", bold=True)\n                symbol = \"✗\"\n            else:\n                style = Style(color=\"yellow\")\n                symbol = \"⋯\"\n\n            agent_display = self._get_display_name(agent_name)\n            status_text = Text()\n            status_text.append(f\"{symbol} \", style=style)\n            status_text.append(f\"{agent_display:<20}\", style=Style(bold=True))\n\n            if ticker:\n                status_text.append(f\"[{ticker}] \", style=Style(color=\"cyan\"))\n            status_text.append(status, style=style)\n\n            self.table.add_row(status_text)\n\n\n# Create a global instance\nprogress = AgentProgress()\n"
  },
  {
    "path": "src/utils/visualize.py",
    "content": "from langgraph.graph.state import CompiledGraph\nfrom langchain_core.runnables.graph import MermaidDrawMethod\n\n\ndef save_graph_as_png(app: CompiledGraph, output_file_path) -> None:\n    png_image = app.get_graph().draw_mermaid_png(draw_method=MermaidDrawMethod.API)\n    file_path = output_file_path if len(output_file_path) > 0 else \"graph.png\"\n    with open(file_path, \"wb\") as f:\n        f.write(png_image)"
  },
  {
    "path": "tests/__init__.py",
    "content": "# Tests package "
  },
  {
    "path": "tests/backtesting/conftest.py",
    "content": "import pytest\n\nfrom src.backtesting.portfolio import Portfolio\n\n\n@pytest.fixture()\ndef portfolio() -> Portfolio:\n    return Portfolio(tickers=[\"AAPL\", \"MSFT\"], initial_cash=100_000.0, margin_requirement=0.5)\n\n\n@pytest.fixture()\ndef prices() -> dict[str, float]:\n    return {\"AAPL\": 100.0, \"MSFT\": 200.0}\n\n\n@pytest.fixture()\ndef price_df_factory():\n    import pandas as pd\n\n    def _factory(closes: list[float]):\n        # Minimal price DataFrame with a close column\n        return pd.DataFrame({\"close\": closes})\n\n    return _factory\n\n\n"
  },
  {
    "path": "tests/backtesting/integration/conftest.py",
    "content": "import json\nfrom pathlib import Path\n\nimport pandas as pd\nimport pytest\n\n\nPRICES_ROOT = Path(__file__).resolve().parents[3] / \"tests\" / \"fixtures\" / \"api\" / \"prices\"\nFM_ROOT = Path(__file__).resolve().parents[3] / \"tests\" / \"fixtures\" / \"api\" / \"financial_metrics\"\nNEWS_ROOT = Path(__file__).resolve().parents[3] / \"tests\" / \"fixtures\" / \"api\" / \"news\"\nINSIDER_ROOT = Path(__file__).resolve().parents[3] / \"tests\" / \"fixtures\" / \"api\" / \"insider_trades\"\n\n\ndef _find_price_fixture_file(ticker: str, start: str, end: str) -> Path | None:\n    # Find a fixture whose filename date range overlaps [start, end]\n    # Filenames: {TICKER}_{START}_{END}.json\n    candidates = sorted(PRICES_ROOT.glob(f\"{ticker}_*.json\"))\n    for p in candidates:\n        try:\n            parts = p.stem.split(\"_\")\n            _, start_str, end_str = parts[0], parts[1], parts[2]\n            # overlap if requested window intersects file window\n            if not (end < start_str or start > end_str):\n                return p\n        except Exception:\n            continue\n    return None\n\n\ndef _load_price_df_from_fixture(ticker: str, start: str, end: str) -> pd.DataFrame:\n    fixture_path = _find_price_fixture_file(ticker, start, end)\n    assert fixture_path is not None, f\"Missing price fixture for {ticker} covering {start}..{end}\"\n    with fixture_path.open(\"r\") as f:\n        data = json.load(f)\n    # Build DataFrame similar to prices_to_df output\n    df = pd.DataFrame([p for p in data[\"prices\"]])\n    df[\"Date\"] = pd.to_datetime(df[\"time\"]).dt.tz_convert('UTC')  # align with prices_to_df index name\n    df.set_index(\"Date\", inplace=True)\n    for col in (\"open\", \"close\", \"high\", \"low\", \"volume\"):\n        df[col] = pd.to_numeric(df[col], errors=\"coerce\")\n    df.sort_index(inplace=True)\n    # Filter by requested window\n    start_ts = pd.to_datetime(start).tz_localize('UTC')\n    end_ts = pd.to_datetime(end).tz_localize('UTC')\n    df = df.loc[(df.index >= start_ts) & (df.index <= end_ts)]\n    return df[[\"open\", \"close\", \"high\", \"low\", \"volume\"]]\n\n\ndef _find_fm_fixture_file(ticker: str, end: str) -> Path | None:\n    candidates = sorted(FM_ROOT.glob(f\"{ticker}_*.json\"))\n    for p in candidates:\n        try:\n            parts = p.stem.split(\"_\")\n            # {TICKER}_{START}_{END}\n            _, start_str, end_str = parts[0], parts[1], parts[2]\n            if start_str <= end <= end_str:\n                return p\n        except Exception:\n            continue\n    return None\n\n\ndef _load_financial_metrics_from_fixture(ticker: str, end: str, limit: int) -> list[dict]:\n    fixture_path = _find_fm_fixture_file(ticker, end)\n    assert fixture_path is not None, f\"Missing financial metrics fixture for {ticker} covering ..{end}\"\n    with fixture_path.open(\"r\") as f:\n        data = json.load(f)\n    # data should match FinancialMetricsResponse\n    items = data.get(\"financial_metrics\", [])\n    # Mimic API limit behavior\n    return items[:limit]\n\n\ndef _load_news_from_fixture(ticker: str, start: str | None, end: str, limit: int) -> list[dict]:\n    # Expect exact match filename {TICKER}_{START}_{END}.json\n    start_key = start or \"none\"\n    fixture_path = NEWS_ROOT / f\"{ticker}_{start or 'none'}_{end}.json\"\n    if not fixture_path.exists():\n        # Fallback: any file that covers end\n        candidates = sorted(NEWS_ROOT.glob(f\"{ticker}_*.json\"))\n        for p in candidates:\n            parts = p.stem.split(\"_\")\n            if len(parts) >= 3 and parts[1] <= end <= parts[2]:\n                fixture_path = p\n                break\n    with fixture_path.open(\"r\") as f:\n        data = json.load(f)\n    items = data.get(\"news\", [])\n    return items[:limit]\n\n\ndef _load_insider_from_fixture(ticker: str, start: str | None, end: str, limit: int) -> list[dict]:\n    fixture_path = INSIDER_ROOT / f\"{ticker}_{start or 'none'}_{end}.json\"\n    if not fixture_path.exists():\n        candidates = sorted(INSIDER_ROOT.glob(f\"{ticker}_*.json\"))\n        for p in candidates:\n            parts = p.stem.split(\"_\")\n            if len(parts) >= 3 and parts[1] <= end <= parts[2]:\n                fixture_path = p\n                break\n    with fixture_path.open(\"r\") as f:\n        data = json.load(f)\n    items = data.get(\"insider_trades\", [])\n    return items[:limit]\n\n\n@pytest.fixture(autouse=True)\ndef patch_engine_prices(monkeypatch):\n    # No-op non-price endpoints\n    monkeypatch.setattr(\"src.backtesting.engine.get_prices\", lambda *a, **k: None)\n    def _fake_get_financial_metrics(ticker: str, end_date: str, period: str = \"ttm\", limit: int = 10, api_key: str | None = None):\n        return _load_financial_metrics_from_fixture(ticker, end_date, limit)\n    monkeypatch.setattr(\"src.backtesting.engine.get_financial_metrics\", _fake_get_financial_metrics)\n    def _fake_get_insider_trades(ticker: str, end_date: str, start_date: str | None = None, limit: int = 1000, api_key: str | None = None):\n        return _load_insider_from_fixture(ticker, start_date, end_date, limit)\n    def _fake_get_company_news(ticker: str, end_date: str, start_date: str | None = None, limit: int = 1000, api_key: str | None = None):\n        return _load_news_from_fixture(ticker, start_date, end_date, limit)\n    monkeypatch.setattr(\"src.backtesting.engine.get_insider_trades\", _fake_get_insider_trades)\n    monkeypatch.setattr(\"src.backtesting.engine.get_company_news\", _fake_get_company_news)\n\n    # Patch price data loader to use fixtures\n    def _fake_get_price_data(ticker: str, start_date: str, end_date: str, api_key: str | None = None):\n        return _load_price_df_from_fixture(ticker, start_date, end_date)\n\n    monkeypatch.setattr(\"src.backtesting.engine.get_price_data\", _fake_get_price_data)\n    yield\n\n\n"
  },
  {
    "path": "tests/backtesting/integration/mocks.py",
    "content": "from src.backtesting.types import AgentOutput\n\n\nclass MockConfigurableAgent:\n    \"\"\"Mock agent that executes a predefined sequence of trading decisions.\"\"\"\n    \n    def __init__(self, decision_sequence: list[dict], tickers: list[str]):\n        \"\"\"\n        Args:\n            decision_sequence: List of decision dicts for each day/call\n            tickers: List of tickers to include in decisions\n            \n        Example:\n            decision_sequence = [\n                {\"AAPL\": {\"action\": \"buy\", \"quantity\": 100}, \"MSFT\": {\"action\": \"buy\", \"quantity\": 30}},\n                {},  # Hold all (empty dict means hold)\n                {\"AAPL\": {\"action\": \"sell\", \"quantity\": 30}},  # Partial sell\n            ]\n        \"\"\"\n        self.decision_sequence = decision_sequence\n        self.tickers = tickers\n        self.call_count = 0\n    \n    def __call__(self, **kwargs) -> AgentOutput:\n        \"\"\"Execute the predefined decision sequence.\"\"\"\n        tickers = kwargs.get(\"tickers\", self.tickers)\n        \n        # Get decisions for current call\n        if self.call_count < len(self.decision_sequence):\n            day_decisions = self.decision_sequence[self.call_count]\n        else:\n            day_decisions = {}  # Default to hold if past sequence\n        \n        self.call_count += 1\n        \n        # Build full decision dict, defaulting to hold for any missing tickers\n        decisions = {}\n        for ticker in tickers:\n            if ticker in day_decisions:\n                decisions[ticker] = day_decisions[ticker]\n            else:\n                decisions[ticker] = {\"action\": \"hold\", \"quantity\": 0}\n        \n        return {\n            \"decisions\": decisions,\n            \"analyst_signals\": {}\n        }"
  },
  {
    "path": "tests/backtesting/integration/test_integration_long_only.py",
    "content": "from src.backtesting.engine import BacktestEngine\nfrom tests.backtesting.integration.mocks import MockConfigurableAgent\n\ndef test_long_only_strategy_buys_and_sells():\n    \"\"\"Test a strategy that buys shares, holds, then sells some shares to test realized gains/losses.\"\"\"\n    \n    # Test parameters\n    tickers = [\"AAPL\", \"MSFT\", \"TSLA\"]\n    start_date = \"2024-03-01\"  \n    end_date = \"2024-03-08\"    \n    initial_capital = 100000.0  # $100k starting capital\n    margin_requirement = 0.5   \n    \n    # Define the exact trading sequence we want to test\n    decision_sequence = [\n        # Day 1: Initial purchases\n        {\n            \"AAPL\": {\"action\": \"buy\", \"quantity\": 100},  # Buy 100 AAPL shares\n            \"MSFT\": {\"action\": \"buy\", \"quantity\": 30},   # Buy 30 MSFT shares\n            # TSLA will default to hold\n        },\n        # Day 2: Hold all positions (empty dict = hold all)\n        {},\n        # Day 3: Partial sell of AAPL\n        {\n            \"AAPL\": {\"action\": \"sell\", \"quantity\": 30},  # Sell 30 of 100 AAPL shares\n            # MSFT and TSLA will default to hold\n        },\n        # Day 4+: Hold remaining positions (empty dict = hold all)\n        {}\n    ]\n    \n    # Create configurable agent with explicit trading plan\n    agent = MockConfigurableAgent(decision_sequence, tickers)\n    \n    # Create and run backtest\n    engine = BacktestEngine(\n        agent=agent,\n        tickers=tickers,\n        start_date=start_date,\n        end_date=end_date,\n        initial_capital=initial_capital,\n        model_name=\"test-model\",\n        model_provider=\"test-provider\",\n        selected_analysts=None,\n        initial_margin_requirement=margin_requirement,\n    )\n    \n    # Run the backtest\n    performance_metrics = engine.run_backtest()\n    portfolio_values = engine.get_portfolio_values()\n    \n    # Get final portfolio state\n    final_portfolio = engine._portfolio.get_snapshot()\n    positions = final_portfolio[\"positions\"]\n    realized_gains = final_portfolio[\"realized_gains\"]\n    \n    # Extract key values from our configuration for high-level verification\n    initial_aapl_purchase = decision_sequence[0][\"AAPL\"][\"quantity\"]  # 100 \n    aapl_sell_quantity = decision_sequence[2][\"AAPL\"][\"quantity\"]     # 30\n    expected_final_aapl = initial_aapl_purchase - aapl_sell_quantity  # 70\n    \n    # Verify the final positions match our trading plan\n    assert positions[\"AAPL\"][\"long\"] == expected_final_aapl, f\"AAPL position mismatch: expected {expected_final_aapl} shares, got {positions['AAPL']['long']}\"\n    assert positions[\"MSFT\"][\"long\"] == 30, f\"MSFT position mismatch: expected 30 shares, got {positions['MSFT']['long']}\"\n    assert positions[\"TSLA\"][\"long\"] == 0, f\"TSLA position mismatch: expected 0 shares, got {positions['TSLA']['long']}\"\n    \n    # Verify the AAPL sale generated realized gains (proves sale happened)\n    assert realized_gains[\"AAPL\"][\"long\"] != 0.0, \"AAPL should have realized gains from sale\"\n    assert realized_gains[\"MSFT\"][\"long\"] == 0.0, \"MSFT should have no realized gains (no sales)\"\n    \n    # PORTFOLIO SUMMARY VERIFICATION: Focus on what matters most\n    final_portfolio_value = portfolio_values[-1][\"Portfolio Value\"]\n    final_cash = final_portfolio[\"cash\"]\n    \n    from src.backtesting.valuation import compute_portfolio_summary\n    portfolio_summary = compute_portfolio_summary(\n        portfolio=engine._portfolio,\n        total_value=final_portfolio_value,\n        initial_value=initial_capital,\n        performance_metrics=performance_metrics\n    )\n    \n    # Core assertions: Portfolio summary calculations should be internally consistent\n    actual_return_pct = portfolio_summary[\"return_pct\"] \n    expected_return_pct = (final_portfolio_value / initial_capital - 1.0) * 100.0\n    assert actual_return_pct == expected_return_pct, f\"Return percentage should {expected_return_pct}\"\n    \n    # Final portfolio value should be correct\n    expected_total_value = final_cash + portfolio_summary[\"total_position_value\"]\n    assert final_portfolio_value == expected_total_value, f\"Final portfolio value should be {expected_total_value}\"\n\n\ndef test_long_only_strategy_full_liquidation_cycle():\n    \"\"\"Test a strategy that buys multiple positions, holds, then sells everything back to cash.\"\"\"\n    \n    # Test parameters\n    tickers = [\"AAPL\", \"MSFT\", \"TSLA\"]\n    start_date = \"2024-03-01\"  \n    end_date = \"2024-03-08\"    \n    initial_capital = 100000.0  # $100k starting capital\n    margin_requirement = 0.5   \n    \n    # Define the exact trading sequence we want to test\n    decision_sequence = [\n        # Day 1: Initial purchases - diversify across all tickers\n        {\n            \"AAPL\": {\"action\": \"buy\", \"quantity\": 50},   # Buy 50 AAPL shares\n            \"MSFT\": {\"action\": \"buy\", \"quantity\": 25},   # Buy 25 MSFT shares\n            \"TSLA\": {\"action\": \"buy\", \"quantity\": 30},   # Buy 30 TSLA shares\n        },\n        # Day 2: Hold all positions (empty dict = hold all)\n        {},\n        # Day 3: Begin liquidation - sell AAPL completely\n        {\n            \"AAPL\": {\"action\": \"sell\", \"quantity\": 50},  # Sell all AAPL\n        },\n        # Day 4: Complete liquidation - sell MSFT and TSLA completely\n        {\n            \"MSFT\": {\"action\": \"sell\", \"quantity\": 25},  # Sell all MSFT\n            \"TSLA\": {\"action\": \"sell\", \"quantity\": 30},  # Sell all TSLA\n        }\n    ]\n    \n    # Create configurable agent with explicit trading plan\n    agent = MockConfigurableAgent(decision_sequence, tickers)\n    \n    # Create and run backtest\n    engine = BacktestEngine(\n        agent=agent,\n        tickers=tickers,\n        start_date=start_date,\n        end_date=end_date,\n        initial_capital=initial_capital,\n        model_name=\"test-model\",\n        model_provider=\"test-provider\",\n        selected_analysts=None,\n        initial_margin_requirement=margin_requirement,\n    )\n    \n    # Run the backtest\n    performance_metrics = engine.run_backtest()\n    portfolio_values = engine.get_portfolio_values()\n    \n    # Get final portfolio state\n    final_portfolio = engine._portfolio.get_snapshot()\n    positions = final_portfolio[\"positions\"]\n    realized_gains = final_portfolio[\"realized_gains\"]\n    \n    # Verify complete liquidation - should have no positions left\n    assert positions[\"AAPL\"][\"long\"] == 0, f\"AAPL should be fully sold, got {positions['AAPL']['long']}\"\n    assert positions[\"MSFT\"][\"long\"] == 0, f\"MSFT should be fully sold, got {positions['MSFT']['long']}\"\n    assert positions[\"TSLA\"][\"long\"] == 0, f\"TSLA should be fully sold, got {positions['TSLA']['long']}\"\n    \n    # Should have no short positions (long-only strategy)\n    for ticker in tickers:\n        assert positions[ticker][\"short\"] == 0, f\"Expected no short position in {ticker}\"\n    \n    # All tickers should have realized gains from complete liquidation\n    assert realized_gains[\"AAPL\"][\"long\"] != 0.0, \"AAPL should have realized gains from sale\"\n    assert realized_gains[\"MSFT\"][\"long\"] != 0.0, \"MSFT should have realized gains from sale\"\n    assert realized_gains[\"TSLA\"][\"long\"] != 0.0, \"TSLA should have realized gains from sale\"\n    \n    # Cost basis should be reset to zero after complete sales\n    assert positions[\"AAPL\"][\"long_cost_basis\"] == 0.0, \"AAPL cost basis should be reset to 0\"\n    assert positions[\"MSFT\"][\"long_cost_basis\"] == 0.0, \"MSFT cost basis should be reset to 0\"\n    assert positions[\"TSLA\"][\"long_cost_basis\"] == 0.0, \"TSLA cost basis should be reset to 0\"\n    \n    # PORTFOLIO SUMMARY VERIFICATION: Focus on what matters most\n    final_portfolio_value = portfolio_values[-1][\"Portfolio Value\"]\n    final_cash = final_portfolio[\"cash\"]\n    \n    from src.backtesting.valuation import compute_portfolio_summary\n    portfolio_summary = compute_portfolio_summary(\n        portfolio=engine._portfolio,\n        total_value=final_portfolio_value,\n        initial_value=initial_capital,\n        performance_metrics=performance_metrics\n    )\n    \n    # Core assertions: Portfolio summary calculations should be internally consistent\n    actual_return_pct = portfolio_summary[\"return_pct\"] \n    expected_return_pct = (final_portfolio_value / initial_capital - 1.0) * 100.0\n    assert actual_return_pct == expected_return_pct, f\"Return percentage should be {expected_return_pct}\"\n    \n    # After complete liquidation, portfolio should be mostly cash with no positions\n    expected_total_position_value = 0.0  # No positions left\n    assert portfolio_summary[\"total_position_value\"] == expected_total_position_value, \\\n        f\"Total position value should be 0 after liquidation, got {portfolio_summary['total_position_value']}\"\n    \n    # Final portfolio value should equal cash balance (since no positions)\n    assert abs(final_portfolio_value - final_cash) < 0.01, \\\n        f\"Portfolio value should equal cash after liquidation: value={final_portfolio_value}, cash={final_cash}\"\n\n\ndef test_long_only_strategy_portfolio_rebalancing():\n    \"\"\"Test a strategy that rebalances between stocks over time, validating complex position transitions.\"\"\"\n    \n    # Test parameters\n    tickers = [\"AAPL\", \"MSFT\", \"TSLA\"]\n    start_date = \"2024-03-01\"  \n    end_date = \"2024-03-08\"    \n    initial_capital = 100000.0  # $100k starting capital\n    margin_requirement = 0.5   \n    \n    # Define the exact trading sequence we want to test\n    decision_sequence = [\n        # Day 1: Initial allocation - focus on AAPL and MSFT\n        {\n            \"AAPL\": {\"action\": \"buy\", \"quantity\": 100},  # Buy 100 AAPL shares\n            \"MSFT\": {\"action\": \"buy\", \"quantity\": 25},   # Buy 25 MSFT shares\n            # TSLA remains empty (no position)\n        },\n        # Day 2: First rebalance - reduce AAPL, add TSLA\n        {\n            \"AAPL\": {\"action\": \"sell\", \"quantity\": 40},  # Sell 40 of 100 AAPL (60 remaining)\n            \"TSLA\": {\"action\": \"buy\", \"quantity\": 30},   # Add 30 TSLA shares\n            # MSFT holds at 25 shares\n        },\n        # Day 3: Hold all current positions\n        {},\n        # Day 4: Final rebalance - exit AAPL completely, increase MSFT\n        {\n            \"AAPL\": {\"action\": \"sell\", \"quantity\": 60},  # Sell remaining 60 AAPL\n            \"MSFT\": {\"action\": \"buy\", \"quantity\": 15},   # Add 15 more MSFT (40 total)\n            # TSLA holds at 30 shares\n        }\n    ]\n    \n    # Create configurable agent with explicit trading plan\n    agent = MockConfigurableAgent(decision_sequence, tickers)\n    \n    # Create and run backtest\n    engine = BacktestEngine(\n        agent=agent,\n        tickers=tickers,\n        start_date=start_date,\n        end_date=end_date,\n        initial_capital=initial_capital,\n        model_name=\"test-model\",\n        model_provider=\"test-provider\",\n        selected_analysts=None,\n        initial_margin_requirement=margin_requirement,\n    )\n    \n    # Run the backtest\n    performance_metrics = engine.run_backtest()\n    portfolio_values = engine.get_portfolio_values()\n    \n    # Get final portfolio state\n    final_portfolio = engine._portfolio.get_snapshot()\n    positions = final_portfolio[\"positions\"]\n    realized_gains = final_portfolio[\"realized_gains\"]\n    \n    # Extract expected values from our rebalancing configuration\n    final_aapl_expected = 0      # Sold all: 100 initial - 40 - 60 = 0\n    final_msft_expected = 40     # Accumulated: 25 initial + 15 = 40\n    final_tsla_expected = 30     # Added: 0 initial + 30 = 30\n    \n    # Verify the final positions match our rebalancing plan\n    assert positions[\"AAPL\"][\"long\"] == final_aapl_expected, f\"AAPL position mismatch: expected {final_aapl_expected} shares, got {positions['AAPL']['long']}\"\n    assert positions[\"MSFT\"][\"long\"] == final_msft_expected, f\"MSFT position mismatch: expected {final_msft_expected} shares, got {positions['MSFT']['long']}\"\n    assert positions[\"TSLA\"][\"long\"] == final_tsla_expected, f\"TSLA position mismatch: expected {final_tsla_expected} shares, got {positions['TSLA']['long']}\"\n    \n    # Should have no short positions (long-only strategy)\n    for ticker in tickers:\n        assert positions[ticker][\"short\"] == 0, f\"Expected no short position in {ticker}\"\n    \n    # Verify realized gains from rebalancing activities\n    assert realized_gains[\"AAPL\"][\"long\"] != 0.0, \"AAPL should have realized gains from partial and complete sales\"\n    assert realized_gains[\"MSFT\"][\"long\"] == 0.0, \"MSFT should have no realized gains (only bought, never sold)\"\n    assert realized_gains[\"TSLA\"][\"long\"] == 0.0, \"TSLA should have no realized gains (only bought, never sold)\"\n    \n    # AAPL should have zero cost basis (completely sold)\n    assert positions[\"AAPL\"][\"long_cost_basis\"] == 0.0, \"AAPL cost basis should be reset to 0 after complete sale\"\n    # MSFT and TSLA should have positive cost bases (still holding)\n    assert positions[\"MSFT\"][\"long_cost_basis\"] > 0.0, \"MSFT should have positive cost basis (still holding)\"\n    assert positions[\"TSLA\"][\"long_cost_basis\"] > 0.0, \"TSLA should have positive cost basis (still holding)\"\n    \n    # PORTFOLIO SUMMARY VERIFICATION: Focus on what matters most\n    final_portfolio_value = portfolio_values[-1][\"Portfolio Value\"]\n    final_cash = final_portfolio[\"cash\"]\n    \n    from src.backtesting.valuation import compute_portfolio_summary\n    portfolio_summary = compute_portfolio_summary(\n        portfolio=engine._portfolio,\n        total_value=final_portfolio_value,\n        initial_value=initial_capital,\n        performance_metrics=performance_metrics\n    )\n    \n    # Core assertions: Portfolio summary calculations should be internally consistent\n    actual_return_pct = portfolio_summary[\"return_pct\"] \n    expected_return_pct = (final_portfolio_value / initial_capital - 1.0) * 100.0\n    assert actual_return_pct == expected_return_pct, f\"Return percentage should be {expected_return_pct}\"\n    \n    # Portfolio should have mixed cash and positions after rebalancing\n    expected_total_value = final_cash + portfolio_summary[\"total_position_value\"]\n    assert final_portfolio_value == expected_total_value, f\"Final portfolio value should be {expected_total_value}\"\n    \n    # Should have meaningful position values (not all cash, not zero cash)\n    assert portfolio_summary[\"total_position_value\"] > 0.0, \"Should have position value after rebalancing\"\n    assert final_cash > 0.0, \"Should have some cash remaining after rebalancing\"\n    \n    # Verify that we successfully shifted from AAPL-heavy to MSFT+TSLA portfolio\n    # Final positions should be worth a meaningful portion of the portfolio\n    expected_min_position_value = initial_capital * 0.15  # At least 15% should be in positions after rebalancing\n    assert portfolio_summary[\"total_position_value\"] >= expected_min_position_value, \\\n        f\"Total position value should be at least {expected_min_position_value}, got {portfolio_summary['total_position_value']}\"\n\n\ndef test_long_only_strategy_multiple_entry_exit_cycles():\n    \"\"\"Test a strategy that performs multiple entry/exit cycles on the same ticker.\n\n    Objective: validate realized gains aggregation across cycles, cost basis resets on full exits,\n    and portfolio summary correctness at the end of the run.\n    \"\"\"\n    \n    # Test parameters\n    tickers = [\"AAPL\", \"MSFT\", \"TSLA\"]\n    start_date = \"2024-03-01\"  \n    end_date = \"2024-03-08\"    \n    initial_capital = 100000.0  # $100k starting capital\n    margin_requirement = 0.5   \n    \n    # Multiple cycles on AAPL and MSFT within the available 4 business days (Mar 5-8)\n    # Day 1: Buy AAPL 60, MSFT 20\n    # Day 2: Sell AAPL 60, MSFT 20 (flat both)\n    # Day 3: Buy AAPL 30, MSFT 10 (re-entry both)\n    # Day 4: Sell AAPL 30, MSFT 10 (flat both again)\n    decision_sequence = [\n        {\"AAPL\": {\"action\": \"buy\", \"quantity\": 60}, \"MSFT\": {\"action\": \"buy\", \"quantity\": 20}},\n        {\"AAPL\": {\"action\": \"sell\", \"quantity\": 60}, \"MSFT\": {\"action\": \"sell\", \"quantity\": 20}},\n        {\"AAPL\": {\"action\": \"buy\", \"quantity\": 30}, \"MSFT\": {\"action\": \"buy\", \"quantity\": 10}},\n        {\"AAPL\": {\"action\": \"sell\", \"quantity\": 30}, \"MSFT\": {\"action\": \"sell\", \"quantity\": 10}},\n    ]\n    \n    # Create configurable agent with explicit trading plan\n    agent = MockConfigurableAgent(decision_sequence, tickers)\n    \n    # Create and run backtest\n    engine = BacktestEngine(\n        agent=agent,\n        tickers=tickers,\n        start_date=start_date,\n        end_date=end_date,\n        initial_capital=initial_capital,\n        model_name=\"test-model\",\n        model_provider=\"test-provider\",\n        selected_analysts=None,\n        initial_margin_requirement=margin_requirement,\n    )\n    \n    # Run the backtest\n    performance_metrics = engine.run_backtest()\n    portfolio_values = engine.get_portfolio_values()\n    \n    # Get final portfolio state\n    final_portfolio = engine._portfolio.get_snapshot()\n    positions = final_portfolio[\"positions\"]\n    realized_gains = final_portfolio[\"realized_gains\"]\n    \n    # Verify final positions are flat after multiple cycles\n    assert positions[\"AAPL\"][\"long\"] == 0, f\"AAPL should be fully sold after cycles, got {positions['AAPL']['long']}\"\n    assert positions[\"MSFT\"][\"long\"] == 0, f\"MSFT should be fully sold after cycles, got {positions['MSFT']['long']}\"\n    assert positions[\"TSLA\"][\"long\"] == 0, f\"TSLA should be 0 shares (never traded), got {positions['TSLA']['long']}\"\n    \n    # Should have no short positions (long-only strategy)\n    for ticker in tickers:\n        assert positions[ticker][\"short\"] == 0, f\"Expected no short position in {ticker}\"\n    \n    # Realized gains should be non-zero for AAPL and MSFT due to two completed round trips\n    assert realized_gains[\"AAPL\"][\"long\"] != 0.0, \"AAPL should have realized gains/losses from multiple cycles\"\n    assert realized_gains[\"MSFT\"][\"long\"] != 0.0, \"MSFT should have realized gains/losses from multiple cycles\"\n    assert realized_gains[\"TSLA\"][\"long\"] == 0.0, \"TSLA should have no realized gains (not traded)\"\n    \n    # Cost basis should be reset to zero for AAPL and MSFT after final full exit\n    assert positions[\"AAPL\"][\"long_cost_basis\"] == 0.0, \"AAPL cost basis should reset to 0 after full exit\"\n    assert positions[\"MSFT\"][\"long_cost_basis\"] == 0.0, \"MSFT cost basis should reset to 0 after full exit\"\n    \n    # PORTFOLIO SUMMARY VERIFICATION\n    final_portfolio_value = portfolio_values[-1][\"Portfolio Value\"]\n    final_cash = final_portfolio[\"cash\"]\n    \n    from src.backtesting.valuation import compute_portfolio_summary\n    portfolio_summary = compute_portfolio_summary(\n        portfolio=engine._portfolio,\n        total_value=final_portfolio_value,\n        initial_value=initial_capital,\n        performance_metrics=performance_metrics\n    )\n    \n    # Core assertions: Portfolio summary calculations should be internally consistent\n    actual_return_pct = portfolio_summary[\"return_pct\"] \n    expected_return_pct = (final_portfolio_value / initial_capital - 1.0) * 100.0\n    assert actual_return_pct == expected_return_pct, f\"Return percentage should be {expected_return_pct}\"\n    \n    # After final liquidation, no positions should remain\n    assert portfolio_summary[\"total_position_value\"] == 0.0, \\\n        f\"Total position value should be 0 after final liquidation, got {portfolio_summary['total_position_value']}\"\n    assert abs(final_portfolio_value - final_cash) < 0.01, \\\n        f\"Portfolio value should equal cash after final liquidation: value={final_portfolio_value}, cash={final_cash}\"\n"
  },
  {
    "path": "tests/backtesting/integration/test_integration_long_short.py",
    "content": "from src.backtesting.engine import BacktestEngine\nfrom tests.backtesting.integration.mocks import MockConfigurableAgent\n\n\ndef test_long_short_strategy_partial_exits():\n    \"\"\"Simultaneous long and short with partial exits on both sides.\"\"\"\n\n    tickers = [\"AAPL\", \"MSFT\", \"TSLA\"]\n    start_date = \"2024-03-01\"\n    end_date = \"2024-03-08\"\n    initial_capital = 100000.0\n    margin_requirement = 0.5\n\n    # Day 1: Long AAPL 60, Short MSFT 20\n    # Day 2: Hold\n    # Day 3: Sell 20 AAPL (partial), Cover 10 MSFT (partial)\n    # Day 4: Hold\n    decision_sequence = [\n        {\"AAPL\": {\"action\": \"buy\", \"quantity\": 60}, \"MSFT\": {\"action\": \"short\", \"quantity\": 20}},\n        {},\n        {\"AAPL\": {\"action\": \"sell\", \"quantity\": 20}, \"MSFT\": {\"action\": \"cover\", \"quantity\": 10}},\n        {},\n    ]\n\n    agent = MockConfigurableAgent(decision_sequence, tickers)\n\n    engine = BacktestEngine(\n        agent=agent,\n        tickers=tickers,\n        start_date=start_date,\n        end_date=end_date,\n        initial_capital=initial_capital,\n        model_name=\"test-model\",\n        model_provider=\"test-provider\",\n        selected_analysts=None,\n        initial_margin_requirement=margin_requirement,\n    )\n\n    performance_metrics = engine.run_backtest()\n    portfolio_values = engine.get_portfolio_values()\n\n    final_portfolio = engine._portfolio.get_snapshot()\n    positions = final_portfolio[\"positions\"]\n    realized_gains = final_portfolio[\"realized_gains\"]\n\n    # Final positions: AAPL long 40, MSFT short 10, TSLA flat\n    assert positions[\"AAPL\"][\"long\"] == 40\n    assert positions[\"AAPL\"][\"short\"] == 0\n    assert positions[\"MSFT\"][\"short\"] == 10\n    assert positions[\"MSFT\"][\"long\"] == 0\n    assert positions[\"TSLA\"][\"long\"] == 0\n    assert positions[\"TSLA\"][\"short\"] == 0\n\n    # Realized PnL on both sides where we exited partially\n    assert realized_gains[\"AAPL\"][\"long\"] != 0.0\n    assert realized_gains[\"MSFT\"][\"short\"] != 0.0\n    assert realized_gains[\"TSLA\"][\"long\"] == 0.0\n    assert realized_gains[\"TSLA\"][\"short\"] == 0.0\n\n    # Cost bases: remaining open legs should have positive cost basis\n    assert positions[\"AAPL\"][\"long_cost_basis\"] > 0.0\n    assert positions[\"MSFT\"][\"short_cost_basis\"] > 0.0\n\n    final_portfolio_value = portfolio_values[-1][\"Portfolio Value\"]\n    final_cash = final_portfolio[\"cash\"]\n\n    from src.backtesting.valuation import compute_portfolio_summary\n\n    portfolio_summary = compute_portfolio_summary(\n        portfolio=engine._portfolio,\n        total_value=final_portfolio_value,\n        initial_value=initial_capital,\n        performance_metrics=performance_metrics,\n    )\n\n    # Summary consistency\n    expected_return_pct = (final_portfolio_value / initial_capital - 1.0) * 100.0\n    assert portfolio_summary[\"return_pct\"] == expected_return_pct\n    expected_total_value = final_cash + portfolio_summary[\"total_position_value\"]\n    assert final_portfolio_value == expected_total_value\n\n\ndef test_long_short_strategy_full_liquidation_to_cash():\n    \"\"\"Start with mixed longs and shorts, then fully exit to all cash.\"\"\"\n\n    tickers = [\"AAPL\", \"MSFT\", \"TSLA\"]\n    start_date = \"2024-03-01\"\n    end_date = \"2024-03-08\"\n    initial_capital = 100000.0\n    margin_requirement = 0.5\n\n    decision_sequence = [\n        # Day 1: Open mixed book\n        {\"AAPL\": {\"action\": \"buy\", \"quantity\": 50}, \"MSFT\": {\"action\": \"short\", \"quantity\": 25}, \"TSLA\": {\"action\": \"buy\", \"quantity\": 30}},\n        # Day 2: Hold\n        {},\n        # Day 3: Exit longs\n        {\"AAPL\": {\"action\": \"sell\", \"quantity\": 50}, \"TSLA\": {\"action\": \"sell\", \"quantity\": 30}},\n        # Day 4: Cover remaining shorts\n        {\"MSFT\": {\"action\": \"cover\", \"quantity\": 25}},\n    ]\n\n    agent = MockConfigurableAgent(decision_sequence, tickers)\n\n    engine = BacktestEngine(\n        agent=agent,\n        tickers=tickers,\n        start_date=start_date,\n        end_date=end_date,\n        initial_capital=initial_capital,\n        model_name=\"test-model\",\n        model_provider=\"test-provider\",\n        selected_analysts=None,\n        initial_margin_requirement=margin_requirement,\n    )\n\n    performance_metrics = engine.run_backtest()\n    portfolio_values = engine.get_portfolio_values()\n\n    final_portfolio = engine._portfolio.get_snapshot()\n    positions = final_portfolio[\"positions\"]\n    realized_gains = final_portfolio[\"realized_gains\"]\n\n    # All flat after liquidation\n    for t in tickers:\n        assert positions[t][\"long\"] == 0\n        assert positions[t][\"short\"] == 0\n\n    # Realized PnL on all tickers as they were exited\n    assert realized_gains[\"AAPL\"][\"long\"] != 0.0\n    assert realized_gains[\"TSLA\"][\"long\"] != 0.0\n    assert realized_gains[\"MSFT\"][\"short\"] != 0.0\n\n    # Cost basis reset on both sides\n    assert positions[\"AAPL\"][\"long_cost_basis\"] == 0.0\n    assert positions[\"TSLA\"][\"long_cost_basis\"] == 0.0\n    assert positions[\"MSFT\"][\"short_cost_basis\"] == 0.0\n\n    final_portfolio_value = portfolio_values[-1][\"Portfolio Value\"]\n    final_cash = final_portfolio[\"cash\"]\n\n    from src.backtesting.valuation import compute_portfolio_summary\n\n    portfolio_summary = compute_portfolio_summary(\n        portfolio=engine._portfolio,\n        total_value=final_portfolio_value,\n        initial_value=initial_capital,\n        performance_metrics=performance_metrics,\n    )\n\n    expected_return_pct = (final_portfolio_value / initial_capital - 1.0) * 100.0\n    assert portfolio_summary[\"return_pct\"] == expected_return_pct\n\n    # With all positions closed, total position value should be zero and value ~ cash\n    assert portfolio_summary[\"total_position_value\"] == 0.0\n    assert abs(final_portfolio_value - final_cash) < 0.01\n\n\ndef test_long_short_strategy_directional_flip_on_ticker():\n    \"\"\"Exit long fully, then open short on same ticker later (and vice versa on another).\"\"\"\n\n    tickers = [\"AAPL\", \"MSFT\", \"TSLA\"]\n    start_date = \"2024-03-01\"\n    end_date = \"2024-03-08\"\n    initial_capital = 100000.0\n    margin_requirement = 0.5\n\n    decision_sequence = [\n        # Day 1: Long AAPL 40, Short TSLA 20\n        {\"AAPL\": {\"action\": \"buy\", \"quantity\": 40}, \"TSLA\": {\"action\": \"short\", \"quantity\": 20}},\n        # Day 2: Exit AAPL long fully; exit TSLA short fully\n        {\"AAPL\": {\"action\": \"sell\", \"quantity\": 40}, \"TSLA\": {\"action\": \"cover\", \"quantity\": 20}},\n        # Day 3: Flip directions: Short AAPL 25, Long TSLA 15\n        {\"AAPL\": {\"action\": \"short\", \"quantity\": 25}, \"TSLA\": {\"action\": \"buy\", \"quantity\": 15}},\n        # Day 4: Hold\n        {},\n    ]\n\n    agent = MockConfigurableAgent(decision_sequence, tickers)\n\n    engine = BacktestEngine(\n        agent=agent,\n        tickers=tickers,\n        start_date=start_date,\n        end_date=end_date,\n        initial_capital=initial_capital,\n        model_name=\"test-model\",\n        model_provider=\"test-provider\",\n        selected_analysts=None,\n        initial_margin_requirement=margin_requirement,\n    )\n\n    performance_metrics = engine.run_backtest()\n    portfolio_values = engine.get_portfolio_values()\n\n    final_portfolio = engine._portfolio.get_snapshot()\n    positions = final_portfolio[\"positions\"]\n    realized_gains = final_portfolio[\"realized_gains\"]\n\n    # Final: AAPL short 25, TSLA long 15, MSFT flat\n    assert positions[\"AAPL\"][\"short\"] == 25\n    assert positions[\"AAPL\"][\"long\"] == 0\n    assert positions[\"TSLA\"][\"long\"] == 15\n    assert positions[\"TSLA\"][\"short\"] == 0\n    assert positions[\"MSFT\"][\"long\"] == 0\n    assert positions[\"MSFT\"][\"short\"] == 0\n\n    # After flipping: earlier legs realized PnL and cost bases reset\n    assert realized_gains[\"AAPL\"][\"long\"] != 0.0  # from exit of long\n    assert realized_gains[\"TSLA\"][\"short\"] != 0.0  # from cover of short\n    assert positions[\"AAPL\"][\"long_cost_basis\"] == 0.0\n    assert positions[\"TSLA\"][\"short_cost_basis\"] == 0.0\n\n    # New legs have cost bases initialized\n    assert positions[\"AAPL\"][\"short_cost_basis\"] > 0.0\n    assert positions[\"TSLA\"][\"long_cost_basis\"] > 0.0\n\n    final_portfolio_value = portfolio_values[-1][\"Portfolio Value\"]\n    final_cash = final_portfolio[\"cash\"]\n\n    from src.backtesting.valuation import compute_portfolio_summary\n\n    portfolio_summary = compute_portfolio_summary(\n        portfolio=engine._portfolio,\n        total_value=final_portfolio_value,\n        initial_value=initial_capital,\n        performance_metrics=performance_metrics,\n    )\n\n    expected_return_pct = (final_portfolio_value / initial_capital - 1.0) * 100.0\n    assert portfolio_summary[\"return_pct\"] == expected_return_pct\n    expected_total_value = final_cash + portfolio_summary[\"total_position_value\"]\n    assert final_portfolio_value == expected_total_value\n\n\ndef test_long_short_strategy_dca_both_sides():\n    \"\"\"Add to existing long and short (averaging), then partially exit both.\"\"\"\n\n    tickers = [\"AAPL\", \"MSFT\", \"TSLA\"]\n    start_date = \"2024-03-01\"\n    end_date = \"2024-03-08\"\n    initial_capital = 100000.0\n    margin_requirement = 0.5\n\n    decision_sequence = [\n        # Day 1: seed positions\n        {\"AAPL\": {\"action\": \"buy\", \"quantity\": 30}, \"MSFT\": {\"action\": \"short\", \"quantity\": 15}},\n        # Day 2: add to both sides (new prices -> test weighted cost bases)\n        {\"AAPL\": {\"action\": \"buy\", \"quantity\": 20}, \"MSFT\": {\"action\": \"short\", \"quantity\": 10}},\n        # Day 3: hold\n        {},\n        # Day 4: partial exits: sell some AAPL, cover some MSFT\n        {\"AAPL\": {\"action\": \"sell\", \"quantity\": 25}, \"MSFT\": {\"action\": \"cover\", \"quantity\": 12}},\n    ]\n\n    agent = MockConfigurableAgent(decision_sequence, tickers)\n\n    engine = BacktestEngine(\n        agent=agent,\n        tickers=tickers,\n        start_date=start_date,\n        end_date=end_date,\n        initial_capital=initial_capital,\n        model_name=\"test-model\",\n        model_provider=\"test-provider\",\n        selected_analysts=None,\n        initial_margin_requirement=margin_requirement,\n    )\n\n    performance_metrics = engine.run_backtest()\n    portfolio_values = engine.get_portfolio_values()\n\n    final_portfolio = engine._portfolio.get_snapshot()\n    positions = final_portfolio[\"positions\"]\n    realized_gains = final_portfolio[\"realized_gains\"]\n\n    # AAPL: 30+20=50 then sell 25 -> 25 remaining long\n    assert positions[\"AAPL\"][\"long\"] == 25\n    # MSFT: 15+10=25 then cover 12 -> 13 remaining short\n    assert positions[\"MSFT\"][\"short\"] == 13\n    # TSLA unused\n    assert positions[\"TSLA\"][\"long\"] == 0\n    assert positions[\"TSLA\"][\"short\"] == 0\n\n    # Weighted cost bases should be positive for both remaining open legs\n    assert positions[\"AAPL\"][\"long_cost_basis\"] > 0.0\n    assert positions[\"MSFT\"][\"short_cost_basis\"] > 0.0\n\n    # Realized PnL should be non-zero on both partial exits\n    assert realized_gains[\"AAPL\"][\"long\"] != 0.0\n    assert realized_gains[\"MSFT\"][\"short\"] != 0.0\n\n    final_portfolio_value = portfolio_values[-1][\"Portfolio Value\"]\n    final_cash = final_portfolio[\"cash\"]\n\n    from src.backtesting.valuation import compute_portfolio_summary\n\n    portfolio_summary = compute_portfolio_summary(\n        portfolio=engine._portfolio,\n        total_value=final_portfolio_value,\n        initial_value=initial_capital,\n        performance_metrics=performance_metrics,\n    )\n\n    expected_return_pct = (final_portfolio_value / initial_capital - 1.0) * 100.0\n    assert portfolio_summary[\"return_pct\"] == expected_return_pct\n    expected_total_value = final_cash + portfolio_summary[\"total_position_value\"]\n    assert final_portfolio_value == expected_total_value\n    # Mixed book remains -> non-zero position value magnitude\n    assert abs(portfolio_summary[\"total_position_value\"]) > 0.0\n\n\n"
  },
  {
    "path": "tests/backtesting/integration/test_integration_short_only.py",
    "content": "from src.backtesting.engine import BacktestEngine\nfrom tests.backtesting.integration.mocks import MockConfigurableAgent\n\n\ndef test_short_only_strategy_shorts_and_covers():\n    \"\"\"Short, hold, then partial cover. Validate positions, realized gains, and summary consistency.\"\"\"\n\n    tickers = [\"AAPL\", \"MSFT\", \"TSLA\"]\n    start_date = \"2024-03-01\"\n    end_date = \"2024-03-08\"\n    initial_capital = 100000.0\n    margin_requirement = 0.5\n\n    # Day1: open shorts; Day2: hold; Day3: partial cover AAPL; Day4: hold\n    decision_sequence = [\n        {\n            \"AAPL\": {\"action\": \"short\", \"quantity\": 100},\n            \"MSFT\": {\"action\": \"short\", \"quantity\": 30},\n        },\n        {},\n        {\n            \"AAPL\": {\"action\": \"cover\", \"quantity\": 30},\n        },\n        {},\n    ]\n\n    agent = MockConfigurableAgent(decision_sequence, tickers)\n\n    engine = BacktestEngine(\n        agent=agent,\n        tickers=tickers,\n        start_date=start_date,\n        end_date=end_date,\n        initial_capital=initial_capital,\n        model_name=\"test-model\",\n        model_provider=\"test-provider\",\n        selected_analysts=None,\n        initial_margin_requirement=margin_requirement,\n    )\n\n    performance_metrics = engine.run_backtest()\n    portfolio_values = engine.get_portfolio_values()\n\n    final_portfolio = engine._portfolio.get_snapshot()\n    positions = final_portfolio[\"positions\"]\n    realized_gains = final_portfolio[\"realized_gains\"]\n\n    # Expected: AAPL 70 short remaining, MSFT 30 short, TSLA 0\n    assert positions[\"AAPL\"][\"short\"] == 70\n    assert positions[\"MSFT\"][\"short\"] == 30\n    assert positions[\"TSLA\"][\"short\"] == 0\n    # No long positions in a short-only plan\n    for t in tickers:\n        assert positions[t][\"long\"] == 0\n\n    # AAPL partial cover should realize non-zero gains/losses; MSFT none; TSLA none\n    assert realized_gains[\"AAPL\"][\"short\"] != 0.0\n    assert realized_gains[\"MSFT\"][\"short\"] == 0.0\n    assert realized_gains[\"TSLA\"][\"short\"] == 0.0\n\n    final_portfolio_value = portfolio_values[-1][\"Portfolio Value\"]\n    final_cash = final_portfolio[\"cash\"]\n\n    from src.backtesting.valuation import compute_portfolio_summary\n\n    portfolio_summary = compute_portfolio_summary(\n        portfolio=engine._portfolio,\n        total_value=final_portfolio_value,\n        initial_value=initial_capital,\n        performance_metrics=performance_metrics,\n    )\n\n    actual_return_pct = portfolio_summary[\"return_pct\"]\n    expected_return_pct = (final_portfolio_value / initial_capital - 1.0) * 100.0\n    assert actual_return_pct == expected_return_pct\n\n    expected_total_value = final_cash + portfolio_summary[\"total_position_value\"]\n    assert final_portfolio_value == expected_total_value\n\n\ndef test_short_only_strategy_full_cover_cycle():\n    \"\"\"Open shorts, then fully cover all to return to flat and mostly cash.\"\"\"\n\n    tickers = [\"AAPL\", \"MSFT\", \"TSLA\"]\n    start_date = \"2024-03-01\"\n    end_date = \"2024-03-08\"\n    initial_capital = 100000.0\n    margin_requirement = 0.5\n\n    decision_sequence = [\n        {\n            \"AAPL\": {\"action\": \"short\", \"quantity\": 50},\n            \"MSFT\": {\"action\": \"short\", \"quantity\": 25},\n            \"TSLA\": {\"action\": \"short\", \"quantity\": 30},\n        },\n        {},\n        {\n            \"AAPL\": {\"action\": \"cover\", \"quantity\": 50},\n        },\n        {\n            \"MSFT\": {\"action\": \"cover\", \"quantity\": 25},\n            \"TSLA\": {\"action\": \"cover\", \"quantity\": 30},\n        },\n    ]\n\n    agent = MockConfigurableAgent(decision_sequence, tickers)\n\n    engine = BacktestEngine(\n        agent=agent,\n        tickers=tickers,\n        start_date=start_date,\n        end_date=end_date,\n        initial_capital=initial_capital,\n        model_name=\"test-model\",\n        model_provider=\"test-provider\",\n        selected_analysts=None,\n        initial_margin_requirement=margin_requirement,\n    )\n\n    performance_metrics = engine.run_backtest()\n    portfolio_values = engine.get_portfolio_values()\n\n    final_portfolio = engine._portfolio.get_snapshot()\n    positions = final_portfolio[\"positions\"]\n    realized_gains = final_portfolio[\"realized_gains\"]\n\n    # After full cover, all shorts 0\n    assert positions[\"AAPL\"][\"short\"] == 0\n    assert positions[\"MSFT\"][\"short\"] == 0\n    assert positions[\"TSLA\"][\"short\"] == 0\n    # No longs\n    for t in tickers:\n        assert positions[t][\"long\"] == 0\n\n    # All tickers should have realized short-side PnL\n    assert realized_gains[\"AAPL\"][\"short\"] != 0.0\n    assert realized_gains[\"MSFT\"][\"short\"] != 0.0\n    assert realized_gains[\"TSLA\"][\"short\"] != 0.0\n\n    # Cost basis reset after flat\n    assert positions[\"AAPL\"][\"short_cost_basis\"] == 0.0\n    assert positions[\"MSFT\"][\"short_cost_basis\"] == 0.0\n    assert positions[\"TSLA\"][\"short_cost_basis\"] == 0.0\n\n    final_portfolio_value = portfolio_values[-1][\"Portfolio Value\"]\n    final_cash = final_portfolio[\"cash\"]\n\n    from src.backtesting.valuation import compute_portfolio_summary\n\n    portfolio_summary = compute_portfolio_summary(\n        portfolio=engine._portfolio,\n        total_value=final_portfolio_value,\n        initial_value=initial_capital,\n        performance_metrics=performance_metrics,\n    )\n\n    actual_return_pct = portfolio_summary[\"return_pct\"]\n    expected_return_pct = (final_portfolio_value / initial_capital - 1.0) * 100.0\n    assert actual_return_pct == expected_return_pct\n\n    # No positions -> position value 0; portfolio value ~ cash\n    assert portfolio_summary[\"total_position_value\"] == 0.0\n    assert abs(final_portfolio_value - final_cash) < 0.01\n\n\ndef test_short_only_strategy_multiple_short_cover_cycles():\n    \"\"\"Perform two complete short-cover cycles to test realized gains aggregation and resets.\"\"\"\n\n    tickers = [\"AAPL\", \"MSFT\", \"TSLA\"]\n    start_date = \"2024-03-01\"\n    end_date = \"2024-03-08\"\n    initial_capital = 100000.0\n    margin_requirement = 0.5\n\n    decision_sequence = [\n        {\"AAPL\": {\"action\": \"short\", \"quantity\": 60}, \"MSFT\": {\"action\": \"short\", \"quantity\": 20}},\n        {\"AAPL\": {\"action\": \"cover\", \"quantity\": 60}, \"MSFT\": {\"action\": \"cover\", \"quantity\": 20}},\n        {\"AAPL\": {\"action\": \"short\", \"quantity\": 30}, \"MSFT\": {\"action\": \"short\", \"quantity\": 10}},\n        {\"AAPL\": {\"action\": \"cover\", \"quantity\": 30}, \"MSFT\": {\"action\": \"cover\", \"quantity\": 10}},\n    ]\n\n    agent = MockConfigurableAgent(decision_sequence, tickers)\n\n    engine = BacktestEngine(\n        agent=agent,\n        tickers=tickers,\n        start_date=start_date,\n        end_date=end_date,\n        initial_capital=initial_capital,\n        model_name=\"test-model\",\n        model_provider=\"test-provider\",\n        selected_analysts=None,\n        initial_margin_requirement=margin_requirement,\n    )\n\n    performance_metrics = engine.run_backtest()\n    portfolio_values = engine.get_portfolio_values()\n\n    final_portfolio = engine._portfolio.get_snapshot()\n    positions = final_portfolio[\"positions\"]\n    realized_gains = final_portfolio[\"realized_gains\"]\n\n    # Flat after cycles\n    assert positions[\"AAPL\"][\"short\"] == 0\n    assert positions[\"MSFT\"][\"short\"] == 0\n    assert positions[\"TSLA\"][\"short\"] == 0\n    for t in tickers:\n        assert positions[t][\"long\"] == 0\n\n    # Realized gains should be non-zero for cycled names\n    assert realized_gains[\"AAPL\"][\"short\"] != 0.0\n    assert realized_gains[\"MSFT\"][\"short\"] != 0.0\n    assert realized_gains[\"TSLA\"][\"short\"] == 0.0\n\n    # Cost basis resets after final flat\n    assert positions[\"AAPL\"][\"short_cost_basis\"] == 0.0\n    assert positions[\"MSFT\"][\"short_cost_basis\"] == 0.0\n\n    final_portfolio_value = portfolio_values[-1][\"Portfolio Value\"]\n    final_cash = final_portfolio[\"cash\"]\n\n    from src.backtesting.valuation import compute_portfolio_summary\n\n    portfolio_summary = compute_portfolio_summary(\n        portfolio=engine._portfolio,\n        total_value=final_portfolio_value,\n        initial_value=initial_capital,\n        performance_metrics=performance_metrics,\n    )\n\n    actual_return_pct = portfolio_summary[\"return_pct\"]\n    expected_return_pct = (final_portfolio_value / initial_capital - 1.0) * 100.0\n    assert actual_return_pct == expected_return_pct\n\n    assert portfolio_summary[\"total_position_value\"] == 0.0\n    assert abs(final_portfolio_value - final_cash) < 0.01\n\n\n\ndef test_short_only_strategy_portfolio_rebalancing():\n    \"\"\"Rebalance across shorts: reduce AAPL short, add TSLA, then close AAPL and add to MSFT.\"\"\"\n\n    tickers = [\"AAPL\", \"MSFT\", \"TSLA\"]\n    start_date = \"2024-03-01\"\n    end_date = \"2024-03-08\"\n    initial_capital = 100000.0\n    margin_requirement = 0.5\n\n    decision_sequence = [\n        # Day 1: Initial shorts - focus on AAPL and MSFT\n        {\n            \"AAPL\": {\"action\": \"short\", \"quantity\": 100},\n            \"MSFT\": {\"action\": \"short\", \"quantity\": 25},\n        },\n        # Day 2: First rebalance - reduce AAPL, add TSLA\n        {\n            \"AAPL\": {\"action\": \"cover\", \"quantity\": 40},  # 60 short remaining\n            \"TSLA\": {\"action\": \"short\", \"quantity\": 30},   # add new short\n        },\n        # Day 3: Hold\n        {},\n        # Day 4: Final rebalance - close AAPL short, increase MSFT short\n        {\n            \"AAPL\": {\"action\": \"cover\", \"quantity\": 60},   # close AAPL\n            \"MSFT\": {\"action\": \"short\", \"quantity\": 15},   # MSFT 40 total\n        },\n    ]\n\n    agent = MockConfigurableAgent(decision_sequence, tickers)\n\n    engine = BacktestEngine(\n        agent=agent,\n        tickers=tickers,\n        start_date=start_date,\n        end_date=end_date,\n        initial_capital=initial_capital,\n        model_name=\"test-model\",\n        model_provider=\"test-provider\",\n        selected_analysts=None,\n        initial_margin_requirement=margin_requirement,\n    )\n\n    performance_metrics = engine.run_backtest()\n    portfolio_values = engine.get_portfolio_values()\n\n    final_portfolio = engine._portfolio.get_snapshot()\n    positions = final_portfolio[\"positions\"]\n    realized_gains = final_portfolio[\"realized_gains\"]\n\n    # Final expected shorts\n    assert positions[\"AAPL\"][\"short\"] == 0\n    assert positions[\"MSFT\"][\"short\"] == 40\n    assert positions[\"TSLA\"][\"short\"] == 30\n    # No longs\n    for t in tickers:\n        assert positions[t][\"long\"] == 0\n\n    # Realized gains only for AAPL (covered), none for MSFT/TSLA (still open)\n    assert realized_gains[\"AAPL\"][\"short\"] != 0.0\n    assert realized_gains[\"MSFT\"][\"short\"] == 0.0\n    assert realized_gains[\"TSLA\"][\"short\"] == 0.0\n\n    # Cost basis reset for AAPL, positive for open shorts\n    assert positions[\"AAPL\"][\"short_cost_basis\"] == 0.0\n    assert positions[\"MSFT\"][\"short_cost_basis\"] > 0.0\n    assert positions[\"TSLA\"][\"short_cost_basis\"] > 0.0\n\n    final_portfolio_value = portfolio_values[-1][\"Portfolio Value\"]\n    final_cash = final_portfolio[\"cash\"]\n\n    from src.backtesting.valuation import compute_portfolio_summary\n\n    portfolio_summary = compute_portfolio_summary(\n        portfolio=engine._portfolio,\n        total_value=final_portfolio_value,\n        initial_value=initial_capital,\n        performance_metrics=performance_metrics,\n    )\n\n    # Summary math consistency\n    actual_return_pct = portfolio_summary[\"return_pct\"]\n    expected_return_pct = (final_portfolio_value / initial_capital - 1.0) * 100.0\n    assert actual_return_pct == expected_return_pct\n    expected_total_value = final_cash + portfolio_summary[\"total_position_value\"]\n    assert final_portfolio_value == expected_total_value\n    # Still have open shorts, so position value magnitude should be > 0\n    assert abs(portfolio_summary[\"total_position_value\"]) > 0.0\n\n\ndef test_short_only_strategy_dollar_cost_averaging_on_short():\n    \"\"\"Add to an existing short (averaging entry), then partially cover and validate cost basis and PnL.\"\"\"\n\n    tickers = [\"AAPL\", \"MSFT\", \"TSLA\"]\n    start_date = \"2024-03-01\"\n    end_date = \"2024-03-08\"\n    initial_capital = 100000.0\n    margin_requirement = 0.5\n\n    decision_sequence = [\n        # Day 1: initial short\n        {\"AAPL\": {\"action\": \"short\", \"quantity\": 50}},\n        # Day 2: add to short at a new price (tests weighted avg cost)\n        {\"AAPL\": {\"action\": \"short\", \"quantity\": 30}},\n        # Day 3: hold\n        {},\n        # Day 4: partial cover\n        {\"AAPL\": {\"action\": \"cover\", \"quantity\": 40}},\n    ]\n\n    agent = MockConfigurableAgent(decision_sequence, tickers)\n\n    engine = BacktestEngine(\n        agent=agent,\n        tickers=tickers,\n        start_date=start_date,\n        end_date=end_date,\n        initial_capital=initial_capital,\n        model_name=\"test-model\",\n        model_provider=\"test-provider\",\n        selected_analysts=None,\n        initial_margin_requirement=margin_requirement,\n    )\n\n    performance_metrics = engine.run_backtest()\n    portfolio_values = engine.get_portfolio_values()\n\n    final_portfolio = engine._portfolio.get_snapshot()\n    positions = final_portfolio[\"positions\"]\n    realized_gains = final_portfolio[\"realized_gains\"]\n\n    # 80 short opened, 40 covered -> 40 remaining\n    assert positions[\"AAPL\"][\"short\"] == 40\n    assert positions[\"MSFT\"][\"short\"] == 0\n    assert positions[\"TSLA\"][\"short\"] == 0\n    for t in tickers:\n        assert positions[t][\"long\"] == 0\n\n    # Weighted short_cost_basis should be positive (non-zero) while position remains\n    assert positions[\"AAPL\"][\"short_cost_basis\"] > 0.0\n\n    # Partial cover should realize PnL\n    assert realized_gains[\"AAPL\"][\"short\"] != 0.0\n\n    final_portfolio_value = portfolio_values[-1][\"Portfolio Value\"]\n    final_cash = final_portfolio[\"cash\"]\n\n    from src.backtesting.valuation import compute_portfolio_summary\n\n    portfolio_summary = compute_portfolio_summary(\n        portfolio=engine._portfolio,\n        total_value=final_portfolio_value,\n        initial_value=initial_capital,\n        performance_metrics=performance_metrics,\n    )\n\n    actual_return_pct = portfolio_summary[\"return_pct\"]\n    expected_return_pct = (final_portfolio_value / initial_capital - 1.0) * 100.0\n    assert actual_return_pct == expected_return_pct\n    expected_total_value = final_cash + portfolio_summary[\"total_position_value\"]\n    assert final_portfolio_value == expected_total_value\n    # Open short remains -> non-zero position value magnitude\n    assert abs(portfolio_summary[\"total_position_value\"]) > 0.0\n\n"
  },
  {
    "path": "tests/backtesting/test_controller.py",
    "content": "from src.backtesting.controller import AgentController\n\n\ndef dummy_agent(**kwargs):\n    # Echo basic structure with only one decision\n    tickers = kwargs[\"tickers\"]\n    return {\n        \"decisions\": {tickers[0]: {\"action\": \"buy\", \"quantity\": \"10\"}},\n        \"analyst_signals\": {\"agentA\": {tickers[0]: {\"signal\": \"bullish\"}}},\n    }\n\n\ndef test_agent_controller_normalizes_and_snapshots(portfolio):\n    ctrl = AgentController()\n    out = ctrl.run_agent(\n        dummy_agent,\n        tickers=[\"AAPL\", \"MSFT\"],\n        start_date=\"2024-01-01\",\n        end_date=\"2024-01-10\",\n        portfolio=portfolio,\n        model_name=\"m\",\n        model_provider=\"p\",\n        selected_analysts=[\"x\"],\n    )\n\n    # Decisions normalized for all tickers\n    assert out[\"decisions\"][\"AAPL\"][\"action\"] == \"buy\"\n    assert out[\"decisions\"][\"AAPL\"][\"quantity\"] == 10.0\n    # Missing ticker defaults to hold/0\n    assert out[\"decisions\"][\"MSFT\"][\"action\"] == \"hold\"\n    assert out[\"decisions\"][\"MSFT\"][\"quantity\"] == 0.0\n    # Analyst signals are passed through\n    assert \"agentA\" in out[\"analyst_signals\"]\n\n\n"
  },
  {
    "path": "tests/backtesting/test_execution.py",
    "content": "from src.backtesting.trader import TradeExecutor\n\n\ndef test_trade_executor_routes_actions(portfolio):\n    ex = TradeExecutor()\n\n    # buy\n    qty = ex.execute_trade(\"AAPL\", \"buy\", 10, 100.0, portfolio)\n    assert qty == 10\n    # sell\n    qty = ex.execute_trade(\"AAPL\", \"sell\", 5, 100.0, portfolio)\n    assert qty == 5\n    # short\n    qty = ex.execute_trade(\"MSFT\", \"short\", 4, 200.0, portfolio)\n    assert qty == 4\n    # cover\n    qty = ex.execute_trade(\"MSFT\", \"cover\", 1, 200.0, portfolio)\n    assert qty == 1\n\n\ndef test_trade_executor_guards_and_unknown_action(portfolio):\n    ex = TradeExecutor()\n\n    assert ex.execute_trade(\"AAPL\", \"buy\", 0, 10.0, portfolio) == 0\n    assert ex.execute_trade(\"AAPL\", \"buy\", -5, 10.0, portfolio) == 0\n    assert ex.execute_trade(\"AAPL\", \"unknown\", 10, 10.0, portfolio) == 0\n\n"
  },
  {
    "path": "tests/backtesting/test_metrics.py",
    "content": "from datetime import datetime, timedelta\n\nimport numpy as np\n\nfrom src.backtesting.metrics import PerformanceMetricsCalculator\n\n\ndef _build_values(values: list[float]):\n    start = datetime(2024, 1, 1)\n    points = []\n    for i, v in enumerate(values):\n        points.append({\n            \"Date\": start + timedelta(days=i),\n            \"Portfolio Value\": v,\n            \"Long Exposure\": 0.0,\n            \"Short Exposure\": 0.0,\n            \"Gross Exposure\": 0.0,\n            \"Net Exposure\": 0.0,\n            \"Long/Short Ratio\": np.inf,\n        })\n    return points\n\n\ndef test_metrics_insufficient_data_no_update():\n    calc = PerformanceMetricsCalculator()\n    metrics = {\"sharpe_ratio\": None, \"sortino_ratio\": None, \"max_drawdown\": None}\n    calc.update_metrics(metrics, _build_values([100_000.0]))\n    assert metrics[\"sharpe_ratio\"] is None\n    assert metrics[\"sortino_ratio\"] is None\n    assert metrics[\"max_drawdown\"] is None\n\n\ndef test_metrics_basic_sharpe_sortino_and_drawdown():\n    calc = PerformanceMetricsCalculator(annual_trading_days=2, annual_rf_rate=0.0)\n    # Values: up then down → non-zero volatility; drawdown occurs on last day\n    vals = _build_values([100.0, 110.0, 99.0])\n    metrics = {\"sharpe_ratio\": None, \"sortino_ratio\": None, \"max_drawdown\": None}\n    calc.update_metrics(metrics, vals)\n    assert metrics[\"sharpe_ratio\"] is not None\n    assert metrics[\"sortino_ratio\"] is not None\n    assert metrics[\"max_drawdown\"] < 0.0\n    assert isinstance(metrics.get(\"max_drawdown_date\"), str)\n\n\ndef test_metrics_zero_volatility_sharpe_zero():\n    calc = PerformanceMetricsCalculator(annual_trading_days=252, annual_rf_rate=0.0)\n    # Constant portfolio value → zero volatility → Sharpe 0\n    vals = _build_values([100.0, 100.0, 100.0, 100.0])\n    metrics = {\"sharpe_ratio\": None, \"sortino_ratio\": None, \"max_drawdown\": None}\n    calc.update_metrics(metrics, vals)\n    assert metrics[\"sharpe_ratio\"] == 0.0\n\n"
  },
  {
    "path": "tests/backtesting/test_portfolio.py",
    "content": "import math\n\nimport pytest\n\nfrom src.backtesting.portfolio import Portfolio\n\ndef test_apply_long_buy_basic(portfolio: Portfolio) -> None:\n    executed = portfolio.apply_long_buy(\"AAPL\", quantity=100, price=50.0)\n    assert executed == 100\n    snap = portfolio.get_snapshot()\n    assert snap[\"positions\"][\"AAPL\"][\"long\"] == 100\n    assert snap[\"positions\"][\"AAPL\"][\"long_cost_basis\"] == pytest.approx(50.0)\n    # cash reduced by 5,000\n    assert snap[\"cash\"] == pytest.approx(95_000.0)\n\n\ndef test_apply_long_buy_partial_fill_when_insufficient_cash() -> None:\n    p = Portfolio(tickers=[\"AAPL\"], initial_cash=120.0, margin_requirement=0.5)\n    # Request 10 shares at $20 = $200, but only $120 cash → max 6 shares\n    executed = p.apply_long_buy(\"AAPL\", quantity=10, price=20.0)\n    assert executed == 6\n    snap = p.get_snapshot()\n    assert snap[\"positions\"][\"AAPL\"][\"long\"] == 6\n    assert snap[\"cash\"] == pytest.approx(0.0)\n\n\ndef test_apply_long_sell_realized_gain_and_cost_basis_reset(portfolio: Portfolio) -> None:\n    # Buy 100 @ 50, then sell 100 @ 60 → realized gain = 100 * (60-50) = 1000\n    portfolio.apply_long_buy(\"AAPL\", 100, 50.0)\n    executed = portfolio.apply_long_sell(\"AAPL\", 100, 60.0)\n    assert executed == 100\n    snap = portfolio.get_snapshot()\n    assert snap[\"positions\"][\"AAPL\"][\"long\"] == 0\n    assert snap[\"positions\"][\"AAPL\"][\"long_cost_basis\"] == pytest.approx(0.0)\n    assert snap[\"realized_gains\"][\"AAPL\"][\"long\"] == pytest.approx(1_000.0)\n    # Cash: initial 100k - 5k + 6k = 101k\n    assert snap[\"cash\"] == pytest.approx(101_000.0)\n\n\ndef test_apply_long_sell_clamps_to_owned() -> None:\n    p = Portfolio(tickers=[\"AAPL\"], initial_cash=10_000.0, margin_requirement=0.5)\n    p.apply_long_buy(\"AAPL\", 10, 100.0)\n    # Try to sell 20, but only 10 owned\n    executed = p.apply_long_sell(\"AAPL\", 20, 100.0)\n    assert executed == 10\n    assert p.get_snapshot()[\"positions\"][\"AAPL\"][\"long\"] == 0\n\n\ndef test_apply_short_open_basic(portfolio: Portfolio) -> None:\n    # Short 100 @ $30, margin 50% → proceeds 3,000; margin 1,500\n    executed = portfolio.apply_short_open(\"MSFT\", 100, 30.0)\n    assert executed == 100\n    snap = portfolio.get_snapshot()\n    pos = snap[\"positions\"][\"MSFT\"]\n    assert pos[\"short\"] == 100\n    assert pos[\"short_cost_basis\"] == pytest.approx(30.0)\n    assert pos[\"short_margin_used\"] == pytest.approx(1_500.0)\n    assert snap[\"margin_used\"] == pytest.approx(1_500.0)\n    # Cash increases net by proceeds - margin = 3,000 - 1,500 = 1,500\n    assert snap[\"cash\"] == pytest.approx(101_500.0)\n\n\ndef test_apply_short_open_partial_when_insufficient_margin_cash() -> None:\n    # Small cash: only enough margin for 4 shares at 50% of proceeds\n    p = Portfolio(tickers=[\"AAPL\"], initial_cash=200.0, margin_requirement=0.5)\n    # price=100 → margin per share = 50, cash 200 → max 4 shares\n    executed = p.apply_short_open(\"AAPL\", 10, 100.0)\n    assert executed == 4\n    snap = p.get_snapshot()\n    pos = snap[\"positions\"][\"AAPL\"]\n    assert pos[\"short\"] == 4\n    assert pos[\"short_margin_used\"] == pytest.approx(200.0)\n    # cash: + proceeds (400) - margin (200) = +200 → 400 total\n    assert snap[\"cash\"] == pytest.approx(400.0)\n\n\ndef test_apply_short_open_uses_available_cash_not_total_cash() -> None:\n    p = Portfolio(tickers=[\"AAPL\"], initial_cash=1_000.0, margin_requirement=0.5)\n\n    # First short consumes all free margin but increases total cash with proceeds.\n    first = p.apply_short_open(\"AAPL\", 10, 100.0)\n    assert first == 10\n\n    # Available cash should still be 1,000 (cash 1,500 - margin_used 500),\n    # so max additional quantity at $100 with 50% margin is 20 shares.\n    second = p.apply_short_open(\"AAPL\", 30, 100.0)\n    assert second == 20\n\n    snap = p.get_snapshot()\n    pos = snap[\"positions\"][\"AAPL\"]\n    assert pos[\"short\"] == 30\n    assert snap[\"margin_used\"] == pytest.approx(1_500.0)\n\n\ndef test_apply_short_cover_realized_gain_and_margin_release(portfolio: Portfolio) -> None:\n    # Open short 100 @ 50, then cover 40 @ 40 → gain = (50-40)*40 = 400\n    portfolio.apply_short_open(\"AAPL\", 100, 50.0)\n    pre = portfolio.get_snapshot()\n    pre_margin_used = pre[\"positions\"][\"AAPL\"][\"short_margin_used\"]\n    executed = portfolio.apply_short_cover(\"AAPL\", 40, 40.0)\n    assert executed == 40\n    snap = portfolio.get_snapshot()\n    pos = snap[\"positions\"][\"AAPL\"]\n    assert snap[\"realized_gains\"][\"AAPL\"][\"short\"] == pytest.approx(400.0)\n    # Proportional margin released: 40/100 of pre short_margin_used\n    released = (40 / 100.0) * pre_margin_used\n    assert pos[\"short_margin_used\"] == pytest.approx(pre_margin_used - released)\n    # Cash delta = +released - cover_cost(40*40=1600)\n    expected_cash = pre[\"cash\"] + released - 1_600.0\n    assert snap[\"cash\"] == pytest.approx(expected_cash)\n\n\ndef test_apply_short_cover_clamps_to_existing_short() -> None:\n    p = Portfolio(tickers=[\"AAPL\"], initial_cash=10_000.0, margin_requirement=0.5)\n    p.apply_short_open(\"AAPL\", 5, 100.0)\n    executed = p.apply_short_cover(\"AAPL\", 10, 100.0)\n    assert executed == 5\n    assert p.get_snapshot()[\"positions\"][\"AAPL\"][\"short\"] == 0\n\n\n@pytest.mark.parametrize(\"action\", [\n    (\"buy\"), (\"sell\"), (\"short\"), (\"cover\")\n])\ndef test_zero_or_negative_quantity_is_noop(portfolio: Portfolio, action: str) -> None:\n    before = portfolio.get_snapshot()\n    if action == \"buy\":\n        executed = portfolio.apply_long_buy(\"AAPL\", 0, 10.0)\n        executed2 = portfolio.apply_long_buy(\"AAPL\", -5, 10.0)\n    elif action == \"sell\":\n        executed = portfolio.apply_long_sell(\"AAPL\", 0, 10.0)\n        executed2 = portfolio.apply_long_sell(\"AAPL\", -5, 10.0)\n    elif action == \"short\":\n        executed = portfolio.apply_short_open(\"AAPL\", 0, 10.0)\n        executed2 = portfolio.apply_short_open(\"AAPL\", -5, 10.0)\n    else:\n        executed = portfolio.apply_short_cover(\"AAPL\", 0, 10.0)\n        executed2 = portfolio.apply_short_cover(\"AAPL\", -5, 10.0)\n    after = portfolio.get_snapshot()\n    assert executed == 0 and executed2 == 0\n    assert after == before\n\n\n"
  },
  {
    "path": "tests/backtesting/test_results.py",
    "content": "from src.backtesting.output import OutputBuilder\n\n\ndef test_results_builder_builds_rows_and_summary(monkeypatch, portfolio):\n    rows_captured = []\n\n    def fake_format_backtest_row(**kwargs):\n        # Keep a compact tuple to validate ordering and key fields\n        rows_captured.append((\n            kwargs.get(\"date\"),\n            kwargs.get(\"ticker\"),\n            kwargs.get(\"action\"),\n            kwargs.get(\"quantity\"),\n            kwargs.get(\"price\"),\n            kwargs.get(\"is_summary\", False),\n            kwargs.get(\"total_value\"),\n        ))\n        return [kwargs.get(\"date\"), kwargs.get(\"ticker\"), kwargs.get(\"action\"), kwargs.get(\"quantity\")]  # minimal row shape\n\n    printed = {\"called\": False, \"rows\": None}\n\n    def fake_print_backtest_results(rows):\n        printed[\"called\"] = True\n        printed[\"rows\"] = rows\n\n    # OutputBuilder imports these directly, so patch in its module\n    monkeypatch.setattr(\"src.backtesting.output.format_backtest_row\", fake_format_backtest_row)\n    monkeypatch.setattr(\"src.backtesting.output.print_backtest_results\", fake_print_backtest_results)\n\n    rb = OutputBuilder(initial_capital=100_000.0)\n\n    # Prepare state: own 10 AAPL @100, no shorts\n    portfolio.apply_long_buy(\"AAPL\", 10, 100.0)\n    current_prices = {\"AAPL\": 100.0}\n\n    agent_output = {\n        \"decisions\": {\"AAPL\": {\"action\": \"buy\", \"quantity\": 10}},\n        \"analyst_signals\": {\"agentA\": {\"AAPL\": {\"signal\": \"bullish\"}}},\n    }\n\n    rows = rb.build_day_rows(\n        date_str=\"2024-01-02\",\n        tickers=[\"AAPL\"],\n        agent_output=agent_output,\n        executed_trades={\"AAPL\": 10},\n        current_prices=current_prices,\n        portfolio=portfolio,\n        performance_metrics={\"sharpe_ratio\": None, \"sortino_ratio\": None, \"max_drawdown\": None},\n        total_value=100_000.0,\n    )\n    rb.print_rows(rows)\n\n    # We should have 2 rows produced: 1 per-ticker + 1 summary\n    assert len(printed[\"rows\"]) == 2\n    # The captured tuples include a summary row with total_value\n    assert any(r[5] and r[6] == 100_000.0 for r in rows_captured)\n\n"
  },
  {
    "path": "tests/backtesting/test_valuation.py",
    "content": "from src.backtesting.valuation import calculate_portfolio_value, compute_exposures, compute_portfolio_summary\n\n\ndef test_calculate_portfolio_value(portfolio, prices):\n    # Long 10 AAPL @100 and short 5 MSFT @200\n    portfolio.apply_long_buy(\"AAPL\", 10, 100.0)\n    portfolio.apply_short_open(\"MSFT\", 5, 200.0)\n\n    value = calculate_portfolio_value(portfolio, prices)\n    # cash after trades\n    snap = portfolio.get_snapshot()\n    expected = snap[\"cash\"] + 10 * 100.0 - 5 * 200.0\n    assert value == expected\n\n\ndef test_compute_exposures(portfolio, prices):\n    portfolio.apply_long_buy(\"AAPL\", 10, 100.0)\n    portfolio.apply_short_open(\"MSFT\", 5, 200.0)\n\n    exp = compute_exposures(portfolio, prices)\n    assert exp[\"Long Exposure\"] == 1000.0\n    assert exp[\"Short Exposure\"] == 1000.0\n    assert exp[\"Gross Exposure\"] == 2000.0\n    assert exp[\"Net Exposure\"] == 0.0\n    assert exp[\"Long/Short Ratio\"] == 1.0\n\n\ndef test_compute_exposures_with_no_shorts_ratio_inf(portfolio, prices):\n    portfolio.apply_long_buy(\"AAPL\", 1, 100.0)\n    exp = compute_exposures(portfolio, prices)\n    assert exp[\"Short Exposure\"] == 0.0\n    assert exp[\"Long/Short Ratio\"] == float(\"inf\")\n\n\ndef test_compute_portfolio_summary(portfolio, prices):\n    portfolio.apply_long_buy(\"AAPL\", 10, 100.0)\n    total_value = calculate_portfolio_value(portfolio, prices)\n    summary = compute_portfolio_summary(\n        portfolio=portfolio,\n        total_value=total_value,\n        initial_value=100_000.0,\n        performance_metrics={\"sharpe_ratio\": 1.0, \"sortino_ratio\": 2.0, \"max_drawdown\": -5.0},\n    )\n    assert summary[\"cash_balance\"] == 99_000.0\n    assert summary[\"total_position_value\"] == 1_000.0\n    assert summary[\"total_value\"] == total_value\n    assert summary[\"return_pct\"] == 0.0\n    assert summary[\"sharpe_ratio\"] == 1.0\n\n"
  },
  {
    "path": "tests/fixtures/api/financial_metrics/AAPL_2024-03-01_2024-03-08.json",
    "content": "{\n  \"financial_metrics\": [\n      {\n          \"ticker\": \"AAPL\",\n          \"report_period\": \"2023-12-30\",\n          \"fiscal_period\": \"2024-Q1\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2994371342560.0,\n          \"enterprise_value\": 3075494342560.0,\n          \"price_to_earnings_ratio\": 29.673,\n          \"price_to_book_ratio\": 40.41,\n          \"price_to_sales_ratio\": 7.763,\n          \"enterprise_value_to_ebitda_ratio\": 23.678,\n          \"enterprise_value_to_revenue_ratio\": 7.937785107206007,\n          \"free_cash_flow_yield\": 0.03568996219040545,\n          \"peg_ratio\": 6.472502805727131,\n          \"gross_margin\": 0.45,\n          \"operating_margin\": 0.30706289246213436,\n          \"net_margin\": 0.262,\n          \"return_on_equity\": 1.56,\n          \"return_on_assets\": 0.294,\n          \"return_on_invested_capital\": 0.407,\n          \"asset_turnover\": 1.123,\n          \"inventory_turnover\": 59.23913377361389,\n          \"receivables_turnover\": 6.944214894632135,\n          \"days_sales_outstanding\": 0.14400476010225405,\n          \"operating_cycle\": 67.01367448036645,\n          \"working_capital_turnover\": 10.734182147081333,\n          \"current_ratio\": 1.073,\n          \"quick_ratio\": 1.0239451232711068,\n          \"cash_ratio\": 0.30424040664910096,\n          \"operating_cash_flow_ratio\": 0.8690780978256812,\n          \"debt_to_equity\": 3.771,\n          \"debt_to_assets\": 0.30561731642876944,\n          \"interest_coverage\": null,\n          \"revenue_growth\": 0.0063164485956925,\n          \"earnings_growth\": 0.04039383473374916,\n          \"book_value_growth\": 0.19235349016831332,\n          \"earnings_per_share_growth\": 0.04584439998767372,\n          \"free_cash_flow_growth\": 0.07315432197943444,\n          \"operating_income_growth\": 0.04132376732081311,\n          \"ebitda_growth\": 0.03698055965829707,\n          \"payout_ratio\": 0.147,\n          \"earnings_per_share\": 6.4884336868484755,\n          \"book_value_per_share\": 4.778,\n          \"free_cash_flow_per_share\": 6.89\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"report_period\": \"2023-09-30\",\n          \"fiscal_period\": \"2023-Q4\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2676736860720.0,\n          \"enterprise_value\": 2757608860720.0,\n          \"price_to_earnings_ratio\": 27.597,\n          \"price_to_book_ratio\": 43.072,\n          \"price_to_sales_ratio\": 6.984,\n          \"enterprise_value_to_ebitda_ratio\": 22.016,\n          \"enterprise_value_to_revenue_ratio\": 7.195324264502916,\n          \"free_cash_flow_yield\": 0.03720350754732517,\n          \"peg_ratio\": 9.269662972777612,\n          \"gross_margin\": 0.441,\n          \"operating_margin\": 0.296740023742124,\n          \"net_margin\": 0.253,\n          \"return_on_equity\": 1.608,\n          \"return_on_assets\": 0.284,\n          \"return_on_invested_capital\": 0.387,\n          \"asset_turnover\": 1.122,\n          \"inventory_turnover\": 60.540988785341966,\n          \"receivables_turnover\": 7.652614030008685,\n          \"days_sales_outstanding\": 0.13067430241204325,\n          \"operating_cycle\": 63.680241569842046,\n          \"working_capital_turnover\": 13.096596733410784,\n          \"current_ratio\": 0.988,\n          \"quick_ratio\": 0.9444421504665951,\n          \"cash_ratio\": 0.20621713876730807,\n          \"operating_cash_flow_ratio\": 0.7607495802020535,\n          \"debt_to_equity\": 4.673,\n          \"debt_to_assets\": 0.3150690759338936,\n          \"interest_coverage\": null,\n          \"revenue_growth\": -0.0016877944849752432,\n          \"earnings_growth\": 0.02358590122414521,\n          \"book_value_growth\": 0.03105816770083286,\n          \"earnings_per_share_growth\": 0.02977092905849271,\n          \"free_cash_flow_growth\": -0.013892877301038747,\n          \"operating_income_growth\": 0.040671235508870814,\n          \"ebitda_growth\": 0.034977111599543885,\n          \"payout_ratio\": 0.153,\n          \"earnings_per_share\": 6.204014370517209,\n          \"book_value_per_share\": 3.947,\n          \"free_cash_flow_per_share\": 6.325\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"report_period\": \"2023-07-01\",\n          \"fiscal_period\": \"2023-Q3\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 3050896326940.0,\n          \"enterprise_value\": 3135824326940.0,\n          \"price_to_earnings_ratio\": 32.196,\n          \"price_to_book_ratio\": 50.617,\n          \"price_to_sales_ratio\": 7.946,\n          \"enterprise_value_to_ebitda_ratio\": 25.911,\n          \"enterprise_value_to_revenue_ratio\": 8.157069923502277,\n          \"free_cash_flow_yield\": 0.0331007642273077,\n          \"peg_ratio\": 30.34447250638662,\n          \"gross_margin\": 0.434,\n          \"operating_margin\": 0.2846616466935637,\n          \"net_margin\": 0.247,\n          \"return_on_equity\": 1.649,\n          \"return_on_assets\": 0.277,\n          \"return_on_invested_capital\": 0.369,\n          \"asset_turnover\": 1.124,\n          \"inventory_turnover\": 52.22867637056183,\n          \"receivables_turnover\": 10.226623160418193,\n          \"days_sales_outstanding\": 0.09778398835213435,\n          \"operating_cycle\": 61.99403366402502,\n          \"working_capital_turnover\": 12.82898386072777,\n          \"current_ratio\": 0.982,\n          \"quick_ratio\": 0.9227371301905364,\n          \"cash_ratio\": 0.22733129006185832,\n          \"operating_cash_flow_ratio\": 0.9048438337747974,\n          \"debt_to_equity\": 4.559,\n          \"debt_to_assets\": 0.32617195661387666,\n          \"interest_coverage\": 51.94439163498099,\n          \"revenue_growth\": -0.00301743725574209,\n          \"earnings_growth\": 0.004654318762523722,\n          \"book_value_growth\": -0.03030985552945719,\n          \"earnings_per_share_growth\": 0.010610181318676899,\n          \"free_cash_flow_growth\": 0.03587034567647964,\n          \"operating_income_growth\": -0.009533907905349682,\n          \"ebitda_growth\": -0.006607730634424224,\n          \"payout_ratio\": 0.156,\n          \"earnings_per_share\": 6.024654799868419,\n          \"book_value_per_share\": 3.84,\n          \"free_cash_flow_per_share\": 6.433\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"report_period\": \"2023-04-01\",\n          \"fiscal_period\": \"2023-Q2\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2609038895400.0,\n          \"enterprise_value\": 2699613895400.0,\n          \"price_to_earnings_ratio\": 27.661,\n          \"price_to_book_ratio\": 41.974,\n          \"price_to_sales_ratio\": 6.775,\n          \"enterprise_value_to_ebitda_ratio\": 22.159,\n          \"enterprise_value_to_revenue_ratio\": 6.995590426777808,\n          \"free_cash_flow_yield\": 0.03736625014363901,\n          \"peg_ratio\": -78.2812445047287,\n          \"gross_margin\": 0.432,\n          \"operating_margin\": 0.2865344914891131,\n          \"net_margin\": 0.245,\n          \"return_on_equity\": 1.657,\n          \"return_on_assets\": 0.276,\n          \"return_on_invested_capital\": 0.371,\n          \"asset_turnover\": 1.126,\n          \"inventory_turnover\": 51.46952686447474,\n          \"receivables_turnover\": 8.550161524883713,\n          \"days_sales_outstanding\": 0.11695685480206183,\n          \"operating_cycle\": 62.402070348824424,\n          \"working_capital_turnover\": 15.538989206093008,\n          \"current_ratio\": 0.94,\n          \"quick_ratio\": 0.8780428898605038,\n          \"cash_ratio\": 0.20559650218613368,\n          \"operating_cash_flow_ratio\": 0.91262960649594,\n          \"debt_to_equity\": 4.344,\n          \"debt_to_assets\": 0.3300066233140655,\n          \"interest_coverage\": 79.67003610108303,\n          \"revenue_growth\": -0.006301333808126708,\n          \"earnings_growth\": -0.008931292095281125,\n          \"book_value_growth\": 0.09573924233610097,\n          \"earnings_per_share_growth\": -0.003533575992750797,\n          \"free_cash_flow_growth\": -8.205296518902952e-05,\n          \"operating_income_growth\": -0.021703859350480092,\n          \"ebitda_growth\": -0.018426607796058463,\n          \"payout_ratio\": 0.156,\n          \"earnings_per_share\": 5.961403230677187,\n          \"book_value_per_share\": 3.937,\n          \"free_cash_flow_per_share\": 6.175\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"report_period\": \"2022-12-31\",\n          \"fiscal_period\": \"2023-Q1\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2066941771740.0,\n          \"enterprise_value\": 2163364771740.0,\n          \"price_to_earnings_ratio\": 21.718,\n          \"price_to_book_ratio\": 36.437,\n          \"price_to_sales_ratio\": 5.334,\n          \"enterprise_value_to_ebitda_ratio\": 17.43,\n          \"enterprise_value_to_revenue_ratio\": 5.56725363446587,\n          \"free_cash_flow_yield\": 0.0471701725384958,\n          \"peg_ratio\": -5.923803306963104,\n          \"gross_margin\": 0.431,\n          \"operating_margin\": 0.291045758211474,\n          \"net_margin\": 0.246,\n          \"return_on_equity\": 1.635,\n          \"return_on_assets\": 0.275,\n          \"return_on_invested_capital\": 0.373,\n          \"asset_turnover\": 1.118,\n          \"inventory_turnover\": 56.823607038123164,\n          \"receivables_turnover\": 6.733216345819724,\n          \"days_sales_outstanding\": 0.14851743188392333,\n          \"operating_cycle\": 72.60726020099565,\n          \"working_capital_turnover\": 18.382800085382918,\n          \"current_ratio\": 0.938,\n          \"quick_ratio\": 0.888342584094518,\n          \"cash_ratio\": 0.1495782526987457,\n          \"operating_cash_flow_ratio\": 0.7953469399647451,\n          \"debt_to_equity\": 5.113,\n          \"debt_to_assets\": 0.32043536065200273,\n          \"interest_coverage\": 162.52305475504323,\n          \"revenue_growth\": -0.017221703759306973,\n          \"earnings_growth\": -0.046411430518120696,\n          \"book_value_growth\": 0.11949400063151247,\n          \"earnings_per_share_growth\": -0.03666257629104514,\n          \"free_cash_flow_growth\": -0.12513123300700807,\n          \"operating_income_growth\": -0.05299614619279111,\n          \"ebitda_growth\": -0.04679471917792438,\n          \"payout_ratio\": 0.154,\n          \"earnings_per_share\": 5.982543001001124,\n          \"book_value_per_share\": 3.569,\n          \"free_cash_flow_per_share\": 6.135\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"report_period\": \"2022-09-24\",\n          \"fiscal_period\": \"2022-Q4\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2417523223360.0,\n          \"enterprise_value\": 2509712223360.0,\n          \"price_to_earnings_ratio\": 24.223,\n          \"price_to_book_ratio\": 47.709,\n          \"price_to_sales_ratio\": 6.131,\n          \"enterprise_value_to_ebitda_ratio\": 19.275,\n          \"enterprise_value_to_revenue_ratio\": 6.375266842222718,\n          \"free_cash_flow_yield\": 0.04609800597700596,\n          \"peg_ratio\": 27.405442618313604,\n          \"gross_margin\": 0.433,\n          \"operating_margin\": 0.30204043334482966,\n          \"net_margin\": 0.253,\n          \"return_on_equity\": 1.609,\n          \"return_on_assets\": 0.281,\n          \"return_on_invested_capital\": 0.388,\n          \"asset_turnover\": 1.11,\n          \"inventory_turnover\": 79.72664779619895,\n          \"receivables_turnover\": 7.643941303041464,\n          \"days_sales_outstanding\": 0.13082256395690897,\n          \"operating_cycle\": 83.62968174046317,\n          \"working_capital_turnover\": 19.95082216038452,\n          \"current_ratio\": 0.879,\n          \"quick_ratio\": 0.8472353911496149,\n          \"cash_ratio\": 0.15356340351469652,\n          \"operating_cash_flow_ratio\": 0.7932810328479952,\n          \"debt_to_equity\": 5.962,\n          \"debt_to_assets\": 0.3403750478377344,\n          \"interest_coverage\": null,\n          \"revenue_growth\": 0.017510360167414113,\n          \"earnings_growth\": 0.0017062619814720023,\n          \"book_value_growth\": -0.1279536028361471,\n          \"earnings_per_share_growth\": 0.008838737540318197,\n          \"free_cash_flow_growth\": 0.03588890334814374,\n          \"operating_income_growth\": -0.011347223375114136,\n          \"ebitda_growth\": -0.011321356436696078,\n          \"payout_ratio\": 0.146,\n          \"earnings_per_share\": 6.210225881153539,\n          \"book_value_per_share\": 3.125,\n          \"free_cash_flow_per_share\": 6.872\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"report_period\": \"2022-06-25\",\n          \"fiscal_period\": \"2022-Q3\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2292792740460.0,\n          \"enterprise_value\": 2384675740460.0,\n          \"price_to_earnings_ratio\": 23.012,\n          \"price_to_book_ratio\": 39.458,\n          \"price_to_sales_ratio\": 5.916,\n          \"enterprise_value_to_ebitda_ratio\": 18.107,\n          \"enterprise_value_to_revenue_ratio\": 6.154124560589562,\n          \"free_cash_flow_yield\": 0.04692181639515134,\n          \"peg_ratio\": -15.897900860101469,\n          \"gross_margin\": 0.433,\n          \"operating_margin\": 0.3108566297330354,\n          \"net_margin\": 0.257,\n          \"return_on_equity\": 1.53,\n          \"return_on_assets\": 0.281,\n          \"return_on_invested_capital\": 0.386,\n          \"asset_turnover\": 1.092,\n          \"inventory_turnover\": 71.3311246088717,\n          \"receivables_turnover\": 8.843750713128408,\n          \"days_sales_outstanding\": 0.11307419582909724,\n          \"operating_cycle\": 79.99807000074432,\n          \"working_capital_turnover\": 15.558312256614075,\n          \"current_ratio\": 0.865,\n          \"quick_ratio\": 0.8227961162058318,\n          \"cash_ratio\": 0.21176072008808605,\n          \"operating_cash_flow_ratio\": 0.910304682266522,\n          \"debt_to_equity\": 4.788,\n          \"debt_to_assets\": 0.35589591714762314,\n          \"interest_coverage\": 43.39697406340058,\n          \"revenue_growth\": 0.003950603211775647,\n          \"earnings_growth\": -0.022583018590278118,\n          \"book_value_growth\": -0.13786554696657222,\n          \"earnings_per_share_growth\": -0.01447510778445713,\n          \"free_cash_flow_growth\": 0.016910381594245367,\n          \"operating_income_growth\": -0.010261339642948102,\n          \"ebitda_growth\": -0.009595860844977214,\n          \"payout_ratio\": 0.146,\n          \"earnings_per_share\": 6.155816237087493,\n          \"book_value_per_share\": 3.595,\n          \"free_cash_flow_per_share\": 6.656\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"report_period\": \"2022-03-26\",\n          \"fiscal_period\": \"2022-Q2\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2851332731520.0,\n          \"enterprise_value\": 2937011731520.0,\n          \"price_to_earnings_ratio\": 27.972,\n          \"price_to_book_ratio\": 42.305,\n          \"price_to_sales_ratio\": 7.387,\n          \"enterprise_value_to_ebitda_ratio\": 22.087,\n          \"enterprise_value_to_revenue_ratio\": 7.624575424191162,\n          \"free_cash_flow_yield\": 0.03710300058303032,\n          \"peg_ratio\": 14.625667617232983,\n          \"gross_margin\": 0.433,\n          \"operating_margin\": 0.3153203097272918,\n          \"net_margin\": 0.264,\n          \"return_on_equity\": 1.529,\n          \"return_on_assets\": 0.289,\n          \"return_on_invested_capital\": 0.387,\n          \"asset_turnover\": 1.093,\n          \"inventory_turnover\": 70.69908424908425,\n          \"receivables_turnover\": 6.977072469792956,\n          \"days_sales_outstanding\": 0.1433265892434789,\n          \"operating_cycle\": 75.08169491157136,\n          \"working_capital_turnover\": 12.332023512874576,\n          \"current_ratio\": 0.927,\n          \"quick_ratio\": 0.88402296326505,\n          \"cash_ratio\": 0.22036264391253882,\n          \"operating_cash_flow_ratio\": 0.9130878062552937,\n          \"debt_to_equity\": 4.203,\n          \"debt_to_assets\": 0.34215569408718366,\n          \"interest_coverage\": 44.71675238795004,\n          \"revenue_growth\": 0.02033711933982338,\n          \"earnings_growth\": 0.013723832728357616,\n          \"book_value_growth\": -0.06301785019184786,\n          \"earnings_per_share_growth\": 0.01912532715446732,\n          \"free_cash_flow_growth\": 0.03868320029846936,\n          \"operating_income_growth\": 0.017972735636029104,\n          \"ebitda_growth\": 0.01596057607823662,\n          \"payout_ratio\": 0.142,\n          \"earnings_per_share\": 6.246231105587501,\n          \"book_value_per_share\": 4.14,\n          \"free_cash_flow_per_share\": 6.499\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"report_period\": \"2021-12-25\",\n          \"fiscal_period\": \"2022-Q1\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2892119663160.0,\n          \"enterprise_value\": 2981898663160.0,\n          \"price_to_earnings_ratio\": 28.762,\n          \"price_to_book_ratio\": 40.206,\n          \"price_to_sales_ratio\": 7.645,\n          \"enterprise_value_to_ebitda_ratio\": 22.783,\n          \"enterprise_value_to_revenue_ratio\": 7.871048451085448,\n          \"free_cash_flow_yield\": 0.03521742246609289,\n          \"peg_ratio\": 4.10509070568537,\n          \"gross_margin\": 0.43,\n          \"operating_margin\": 0.3160526851394179,\n          \"net_margin\": 0.266,\n          \"return_on_equity\": 1.498,\n          \"return_on_assets\": 0.287,\n          \"return_on_invested_capital\": 0.38,\n          \"asset_turnover\": 1.082,\n          \"inventory_turnover\": 64.38444520081688,\n          \"receivables_turnover\": 6.480408362524516,\n          \"days_sales_outstanding\": 0.15431126312701052,\n          \"operating_cycle\": 67.22591253721944,\n          \"working_capital_turnover\": 11.018581622251347,\n          \"current_ratio\": 1.038,\n          \"quick_ratio\": 0.9979942266252863,\n          \"cash_ratio\": 0.251528046945939,\n          \"operating_cash_flow_ratio\": 0.7605743559163538,\n          \"debt_to_equity\": 4.299,\n          \"debt_to_assets\": 0.3221429676986078,\n          \"interest_coverage\": 44.268789337282485,\n          \"revenue_growth\": 0.03418649215318042,\n          \"earnings_growth\": 0.06205111956062526,\n          \"book_value_growth\": 0.14014899350134727,\n          \"earnings_per_share_growth\": 0.07006317760218678,\n          \"free_cash_flow_growth\": 0.09574731315826278,\n          \"operating_income_growth\": 0.069001895361728,\n          \"ebitda_growth\": 0.0629304183991684,\n          \"payout_ratio\": 0.142,\n          \"earnings_per_share\": 6.12901175072138,\n          \"book_value_per_share\": 4.388,\n          \"free_cash_flow_per_share\": 6.214\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"report_period\": \"2021-09-25\",\n          \"fiscal_period\": \"2021-Q4\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2428611988720.0,\n          \"enterprise_value\": 2516352988720.0,\n          \"price_to_earnings_ratio\": 25.651,\n          \"price_to_book_ratio\": 38.494,\n          \"price_to_sales_ratio\": 6.639,\n          \"enterprise_value_to_ebitda_ratio\": 20.436,\n          \"enterprise_value_to_revenue_ratio\": 6.884291841877223,\n          \"free_cash_flow_yield\": 0.03827412548061697,\n          \"peg_ratio\": 2.5359422597128307,\n          \"gross_margin\": 0.418,\n          \"operating_margin\": 0.305759437095597,\n          \"net_margin\": 0.259,\n          \"return_on_equity\": 1.441,\n          \"return_on_assets\": 0.276,\n          \"return_on_invested_capital\": 0.362,\n          \"asset_turnover\": 1.066,\n          \"inventory_turnover\": 55.595288753799394,\n          \"receivables_turnover\": 8.565738637694055,\n          \"days_sales_outstanding\": 0.11674416443194274,\n          \"operating_cycle\": 70.79009652168793,\n          \"working_capital_turnover\": 9.935954369525362,\n          \"current_ratio\": 1.075,\n          \"quick_ratio\": 1.0221149018576519,\n          \"cash_ratio\": 0.2784485300563432,\n          \"operating_cash_flow_ratio\": 0.8291135709788733,\n          \"debt_to_equity\": 4.564,\n          \"debt_to_assets\": 0.35532276169366556,\n          \"interest_coverage\": 42.288090737240076,\n          \"revenue_growth\": 0.05375696734887874,\n          \"earnings_growth\": 0.09075827745904472,\n          \"book_value_growth\": -0.01851275668948351,\n          \"earnings_per_share_growth\": 0.1011487509824254,\n          \"free_cash_flow_growth\": -0.01915203444200574,\n          \"operating_income_growth\": 0.0810403316999623,\n          \"ebitda_growth\": 0.07576181157394464,\n          \"payout_ratio\": 0.15,\n          \"earnings_per_share\": 5.727710175445304,\n          \"book_value_per_share\": 3.778,\n          \"free_cash_flow_per_share\": 5.566\n      }\n  ]\n}"
  },
  {
    "path": "tests/fixtures/api/financial_metrics/MSFT_2024-03-01_2024-03-08.json",
    "content": "{\n  \"financial_metrics\": [\n      {\n          \"ticker\": \"MSFT\",\n          \"report_period\": \"2023-12-31\",\n          \"fiscal_period\": \"2024-Q2\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2794827926197.0,\n          \"enterprise_value\": 2735865926197.0,\n          \"price_to_earnings_ratio\": 33.86,\n          \"price_to_book_ratio\": 11.73,\n          \"price_to_sales_ratio\": 12.28,\n          \"enterprise_value_to_ebitda_ratio\": 22.624,\n          \"enterprise_value_to_revenue_ratio\": 12.31280423492528,\n          \"free_cash_flow_yield\": 0.02413207602794147,\n          \"peg_ratio\": 4.818792569679835,\n          \"gross_margin\": 0.697,\n          \"operating_margin\": 0.4553152036839307,\n          \"net_margin\": 0.363,\n          \"return_on_equity\": 0.384,\n          \"return_on_assets\": 0.193,\n          \"return_on_invested_capital\": 0.556,\n          \"asset_turnover\": 0.533,\n          \"inventory_turnover\": 140.91826625386997,\n          \"receivables_turnover\": 5.704978441792841,\n          \"days_sales_outstanding\": 0.17528550023507908,\n          \"operating_cycle\": 104.33249740170618,\n          \"working_capital_turnover\": 1.8420986600078513,\n          \"current_ratio\": 1.218,\n          \"quick_ratio\": 1.2046175712302505,\n          \"cash_ratio\": 0.6694734580551333,\n          \"operating_cash_flow_ratio\": 0.8482101540292193,\n          \"debt_to_equity\": 0.975,\n          \"debt_to_assets\": 0.18780681658796577,\n          \"interest_coverage\": 42.96102819237148,\n          \"revenue_growth\": 0.04247629517658376,\n          \"earnings_growth\": 0.07062623222994707,\n          \"book_value_growth\": 0.07953278903920911,\n          \"earnings_per_share_growth\": 0.07026630491847487,\n          \"free_cash_flow_growth\": 0.0667288773605795,\n          \"operating_income_growth\": 0.06809186113630741,\n          \"ebitda_growth\": 0.07961038497250196,\n          \"payout_ratio\": 0.251,\n          \"earnings_per_share\": 11.105770537448961,\n          \"book_value_per_share\": 32.06,\n          \"free_cash_flow_per_share\": 9.075\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"report_period\": \"2023-09-30\",\n          \"fiscal_period\": \"2024-Q1\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2345947895222.0,\n          \"enterprise_value\": 2294650895222.0,\n          \"price_to_earnings_ratio\": 30.429,\n          \"price_to_book_ratio\": 10.629,\n          \"price_to_sales_ratio\": 10.746,\n          \"enterprise_value_to_ebitda_ratio\": 20.486,\n          \"enterprise_value_to_revenue_ratio\": 10.47586411626586,\n          \"free_cash_flow_yield\": 0.026951152721154892,\n          \"peg_ratio\": 4.592586246243802,\n          \"gross_margin\": 0.694,\n          \"operating_margin\": 0.44439558426091336,\n          \"net_margin\": 0.353,\n          \"return_on_equity\": 0.383,\n          \"return_on_assets\": 0.192,\n          \"return_on_invested_capital\": 0.552,\n          \"asset_turnover\": 0.545,\n          \"inventory_turnover\": 72.77,\n          \"receivables_turnover\": 5.098259011454794,\n          \"days_sales_outstanding\": 0.19614538958361963,\n          \"operating_cycle\": 84.48371355690935,\n          \"working_capital_turnover\": 1.5107069826342394,\n          \"current_ratio\": 1.663,\n          \"quick_ratio\": 1.6394159882043722,\n          \"cash_ratio\": 1.1535274697095967,\n          \"operating_cash_flow_ratio\": 0.7610023078402461,\n          \"debt_to_equity\": 1.02,\n          \"debt_to_assets\": 0.19065020133023766,\n          \"interest_coverage\": 48.678374310085296,\n          \"revenue_growth\": 0.030177193686147748,\n          \"earnings_growth\": 0.06543580105305344,\n          \"book_value_growth\": 0.0702685927369886,\n          \"earnings_per_share_growth\": 0.06625660749244355,\n          \"free_cash_flow_growth\": 0.06306851618327028,\n          \"operating_income_growth\": 0.06285125823026107,\n          \"ebitda_growth\": 0.06532242723987065,\n          \"payout_ratio\": 0.263,\n          \"earnings_per_share\": 10.37664223045396,\n          \"book_value_per_share\": 29.71,\n          \"free_cash_flow_per_share\": 8.511\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"report_period\": \"2023-06-30\",\n          \"fiscal_period\": \"2023-Q4\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2532080938790.0,\n          \"enterprise_value\": 2488175938790.0,\n          \"price_to_earnings_ratio\": 34.992,\n          \"price_to_book_ratio\": 12.278,\n          \"price_to_sales_ratio\": 11.949,\n          \"enterprise_value_to_ebitda_ratio\": 23.665,\n          \"enterprise_value_to_revenue_ratio\": 11.706504677771749,\n          \"free_cash_flow_yield\": 0.023488585648617215,\n          \"peg_ratio\": 7.057922875895999,\n          \"gross_margin\": 0.689,\n          \"operating_margin\": 0.43073402071585304,\n          \"net_margin\": 0.341,\n          \"return_on_equity\": 0.382,\n          \"return_on_assets\": 0.191,\n          \"return_on_invested_capital\": 0.55,\n          \"asset_turnover\": 0.559,\n          \"inventory_turnover\": 84.766,\n          \"receivables_turnover\": 4.922074603985692,\n          \"days_sales_outstanding\": 0.2031663638723073,\n          \"operating_cycle\": 83.7448382268237,\n          \"working_capital_turnover\": 1.5423722028741844,\n          \"current_ratio\": 1.769,\n          \"quick_ratio\": 1.745163179675273,\n          \"cash_ratio\": 1.0682963830665682,\n          \"operating_cash_flow_ratio\": 0.84092982169776,\n          \"debt_to_equity\": 0.998,\n          \"debt_to_assets\": 0.14555459541332505,\n          \"interest_coverage\": 46.38160569105691,\n          \"revenue_growth\": 0.02082941938716033,\n          \"earnings_growth\": 0.048406259055346276,\n          \"book_value_growth\": 0.059275848430525524,\n          \"earnings_per_share_growth\": 0.04957881194166335,\n          \"free_cash_flow_growth\": 0.03604152876005992,\n          \"operating_income_growth\": 0.048545139168093,\n          \"ebitda_growth\": 0.04079430602163949,\n          \"payout_ratio\": 0.274,\n          \"earnings_per_share\": 9.731843308204304,\n          \"book_value_per_share\": 27.696,\n          \"free_cash_flow_per_share\": 7.988\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"report_period\": \"2023-03-31\",\n          \"fiscal_period\": \"2023-Q3\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2146048558564.0,\n          \"enterprise_value\": 2106654558564.0,\n          \"price_to_earnings_ratio\": 31.093,\n          \"price_to_book_ratio\": 11.023,\n          \"price_to_sales_ratio\": 10.338,\n          \"enterprise_value_to_ebitda_ratio\": 20.854,\n          \"enterprise_value_to_revenue_ratio\": 10.126371367564104,\n          \"free_cash_flow_yield\": 0.026749627715046888,\n          \"peg_ratio\": 12.558616556835966,\n          \"gross_margin\": 0.684,\n          \"operating_margin\": 0.41934862301352177,\n          \"net_margin\": 0.332,\n          \"return_on_equity\": 0.385,\n          \"return_on_assets\": 0.188,\n          \"return_on_invested_capital\": 0.551,\n          \"asset_turnover\": 0.565,\n          \"inventory_turnover\": 72.15537017726798,\n          \"receivables_turnover\": 5.6677815243061715,\n          \"days_sales_outstanding\": 0.17643587631448376,\n          \"operating_cycle\": 76.55424216968777,\n          \"working_capital_turnover\": 1.5887025798403576,\n          \"current_ratio\": 1.913,\n          \"quick_ratio\": 1.8789837905964453,\n          \"cash_ratio\": 1.2186460655144649,\n          \"operating_cash_flow_ratio\": 0.9737428668121506,\n          \"debt_to_equity\": 0.952,\n          \"debt_to_assets\": 0.15923154637873335,\n          \"interest_coverage\": 43.921796165489404,\n          \"revenue_growth\": 0.017134261663743176,\n          \"earnings_growth\": 0.02329167222642293,\n          \"book_value_growth\": 0.06305150270836973,\n          \"earnings_per_share_growth\": 0.024758412184536262,\n          \"free_cash_flow_growth\": -0.03710288838941259,\n          \"operating_income_growth\": 0.029275098431015525,\n          \"ebitda_growth\": 0.022801138031933742,\n          \"payout_ratio\": 0.281,\n          \"earnings_per_share\": 9.272141546189301,\n          \"book_value_per_share\": 26.164,\n          \"free_cash_flow_per_share\": 7.715\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"report_period\": \"2022-12-31\",\n          \"fiscal_period\": \"2023-Q2\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 1787731749394.0,\n          \"enterprise_value\": 1740751749394.0,\n          \"price_to_earnings_ratio\": 26.505,\n          \"price_to_book_ratio\": 9.762,\n          \"price_to_sales_ratio\": 8.759,\n          \"enterprise_value_to_ebitda_ratio\": 17.625,\n          \"enterprise_value_to_revenue_ratio\": 8.566335852077964,\n          \"free_cash_flow_yield\": 0.03334840365183934,\n          \"peg_ratio\": -8.010825895126935,\n          \"gross_margin\": 0.682,\n          \"operating_margin\": 0.41440218722745403,\n          \"net_margin\": 0.33,\n          \"return_on_equity\": 0.393,\n          \"return_on_assets\": 0.188,\n          \"return_on_invested_capital\": 0.559,\n          \"asset_turnover\": 0.569,\n          \"inventory_turnover\": 68.48791946308725,\n          \"receivables_turnover\": 6.082190964358088,\n          \"days_sales_outstanding\": 0.164414436485149,\n          \"operating_cycle\": 62.39951988268039,\n          \"working_capital_turnover\": 1.6349035330494932,\n          \"current_ratio\": 1.931,\n          \"quick_ratio\": 1.8948456888323257,\n          \"cash_ratio\": 1.2176998947600284,\n          \"operating_cash_flow_ratio\": 1.0326488656110038,\n          \"debt_to_equity\": 0.991,\n          \"debt_to_assets\": 0.164898286115561,\n          \"interest_coverage\": 42.52237305178482,\n          \"revenue_growth\": 0.005017850547827158,\n          \"earnings_growth\": -0.03352963934144349,\n          \"book_value_growth\": 0.05513752693499879,\n          \"earnings_per_share_growth\": -0.03308640221129005,\n          \"free_cash_flow_growth\": -0.058673066599298954,\n          \"operating_income_growth\": -0.0254758722404019,\n          \"ebitda_growth\": -0.020421319897645448,\n          \"payout_ratio\": 0.282,\n          \"earnings_per_share\": 9.048124353937574,\n          \"book_value_per_share\": 24.579,\n          \"free_cash_flow_per_share\": 8.001\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"report_period\": \"2022-09-30\",\n          \"fiscal_period\": \"2023-Q1\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 1736943016989.0,\n          \"enterprise_value\": 1693456016989.0,\n          \"price_to_earnings_ratio\": 24.888,\n          \"price_to_book_ratio\": 10.007,\n          \"price_to_sales_ratio\": 8.553,\n          \"enterprise_value_to_ebitda_ratio\": 16.796,\n          \"enterprise_value_to_revenue_ratio\": 8.321866389210882,\n          \"free_cash_flow_yield\": 0.036462911782673114,\n          \"peg_ratio\": -6.580262509337578,\n          \"gross_margin\": 0.683,\n          \"operating_margin\": 0.42736919857195615,\n          \"net_margin\": 0.344,\n          \"return_on_equity\": 0.421,\n          \"return_on_assets\": 0.198,\n          \"return_on_invested_capital\": 0.596,\n          \"asset_turnover\": 0.576,\n          \"inventory_turnover\": 47.58083411433927,\n          \"receivables_turnover\": 5.376621657400053,\n          \"days_sales_outstanding\": 0.18599039763634126,\n          \"operating_cycle\": 56.081989947038004,\n          \"working_capital_turnover\": 1.6925384951972162,\n          \"current_ratio\": 1.84,\n          \"quick_ratio\": 1.791346737003513,\n          \"cash_ratio\": 1.227408483905297,\n          \"operating_cash_flow_ratio\": 1.003478698692055,\n          \"debt_to_equity\": 1.073,\n          \"debt_to_assets\": 0.16755053031819092,\n          \"interest_coverage\": 42.8794466403162,\n          \"revenue_growth\": 0.024234629545569174,\n          \"earnings_growth\": -0.04054276994143364,\n          \"book_value_growth\": 0.04217554730938742,\n          \"earnings_per_share_growth\": -0.03782294804188655,\n          \"free_cash_flow_growth\": -0.02785921503016163,\n          \"operating_income_growth\": 0.011762785763415289,\n          \"ebitda_growth\": 0.005856004150081306,\n          \"payout_ratio\": 0.266,\n          \"earnings_per_share\": 9.357738245309868,\n          \"book_value_per_share\": 23.276,\n          \"free_cash_flow_per_share\": 8.493\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"report_period\": \"2022-06-30\",\n          \"fiscal_period\": \"2022-Q4\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 1920840080062.0,\n          \"enterprise_value\": 1877430080062.0,\n          \"price_to_earnings_ratio\": 26.408,\n          \"price_to_book_ratio\": 11.534,\n          \"price_to_sales_ratio\": 9.688,\n          \"enterprise_value_to_ebitda_ratio\": 18.73,\n          \"enterprise_value_to_revenue_ratio\": 9.468669390538155,\n          \"free_cash_flow_yield\": 0.03391693076182435,\n          \"peg_ratio\": 42.01203621305179,\n          \"gross_margin\": 0.684,\n          \"operating_margin\": 0.4326373127553336,\n          \"net_margin\": 0.367,\n          \"return_on_equity\": 0.454,\n          \"return_on_assets\": 0.21,\n          \"return_on_invested_capital\": 0.607,\n          \"asset_turnover\": 0.573,\n          \"inventory_turnover\": 52.98503474078033,\n          \"receivables_turnover\": 5.158311002419544,\n          \"days_sales_outstanding\": 0.19386190548242296,\n          \"operating_cycle\": 61.501021999862,\n          \"working_capital_turnover\": 1.6694593433981963,\n          \"current_ratio\": 1.785,\n          \"quick_ratio\": 1.7452514671546664,\n          \"cash_ratio\": 1.1017542752571465,\n          \"operating_cash_flow_ratio\": 0.9364022633095643,\n          \"debt_to_equity\": 1.191,\n          \"debt_to_assets\": 0.16793662975550927,\n          \"interest_coverage\": 41.57973824527387,\n          \"revenue_growth\": 0.029669136930882804,\n          \"earnings_growth\": 0.0038920172242464393,\n          \"book_value_growth\": 0.022206673050011047,\n          \"earnings_per_share_growth\": 0.006285735791923525,\n          \"free_cash_flow_growth\": 0.02356674888843501,\n          \"operating_income_growth\": 0.012093824480260518,\n          \"ebitda_growth\": 0.016839286257722234,\n          \"payout_ratio\": 0.249,\n          \"earnings_per_share\": 9.725588680655044,\n          \"book_value_per_share\": 22.217,\n          \"free_cash_flow_per_share\": 8.691\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"report_period\": \"2022-03-31\",\n          \"fiscal_period\": \"2022-Q3\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2311358888417.0,\n          \"enterprise_value\": 2250021888417.0,\n          \"price_to_earnings_ratio\": 31.9,\n          \"price_to_book_ratio\": 14.187,\n          \"price_to_sales_ratio\": 12.004,\n          \"enterprise_value_to_ebitda_ratio\": 22.825,\n          \"enterprise_value_to_revenue_ratio\": 11.778065136125926,\n          \"free_cash_flow_yield\": 0.027537480362295374,\n          \"peg_ratio\": 16.474091603454198,\n          \"gross_margin\": 0.687,\n          \"operating_margin\": 0.4401501892945985,\n          \"net_margin\": 0.376,\n          \"return_on_equity\": 0.47,\n          \"return_on_assets\": 0.214,\n          \"return_on_invested_capital\": 0.625,\n          \"asset_turnover\": 0.569,\n          \"inventory_turnover\": 58.421419902912625,\n          \"receivables_turnover\": 5.823325722407875,\n          \"days_sales_outstanding\": 0.17172317807194754,\n          \"operating_cycle\": 66.80733205653297,\n          \"working_capital_turnover\": 1.6284372053295446,\n          \"current_ratio\": 1.988,\n          \"quick_ratio\": 1.9450922661707926,\n          \"cash_ratio\": 1.35194152817056,\n          \"operating_cash_flow_ratio\": 1.1249628740040547,\n          \"debt_to_equity\": 1.115,\n          \"debt_to_assets\": 0.17783446070451267,\n          \"interest_coverage\": 39.97830188679245,\n          \"revenue_growth\": 0.04139467720913127,\n          \"earnings_growth\": 0.01785488515839011,\n          \"book_value_growth\": 0.018211361789888133,\n          \"earnings_per_share_growth\": 0.01936384301271142,\n          \"free_cash_flow_growth\": 0.048704133919891916,\n          \"operating_income_growth\": 0.034468448675698767,\n          \"ebitda_growth\": 0.03857013422111717,\n          \"payout_ratio\": 0.245,\n          \"earnings_per_share\": 9.664838062124801,\n          \"book_value_per_share\": 21.743,\n          \"free_cash_flow_per_share\": 8.494\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"report_period\": \"2021-12-31\",\n          \"fiscal_period\": \"2022-Q2\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2525083982926.0,\n          \"enterprise_value\": 2457806982926.0,\n          \"price_to_earnings_ratio\": 35.472,\n          \"price_to_book_ratio\": 15.781,\n          \"price_to_sales_ratio\": 13.656,\n          \"enterprise_value_to_ebitda_ratio\": 25.894,\n          \"enterprise_value_to_revenue_ratio\": 13.32453763825357,\n          \"free_cash_flow_yield\": 0.0240360322311619,\n          \"peg_ratio\": 7.150522724110661,\n          \"gross_margin\": 0.688,\n          \"operating_margin\": 0.4430971915004083,\n          \"net_margin\": 0.385,\n          \"return_on_equity\": 0.484,\n          \"return_on_assets\": 0.216,\n          \"return_on_invested_capital\": 0.632,\n          \"asset_turnover\": 0.561,\n          \"inventory_turnover\": 61.2464392182842,\n          \"receivables_turnover\": 6.07544070052079,\n          \"days_sales_outstanding\": 0.16459711308091268,\n          \"operating_cycle\": 63.58803790114287,\n          \"working_capital_turnover\": 1.487219290907921,\n          \"current_ratio\": 2.247,\n          \"quick_ratio\": 2.208347310024513,\n          \"cash_ratio\": 1.6174558121532705,\n          \"operating_cash_flow_ratio\": 1.0825570894078183,\n          \"debt_to_equity\": 1.127,\n          \"debt_to_assets\": 0.18811418700369284,\n          \"interest_coverage\": 36.413333333333334,\n          \"revenue_growth\": 0.049089083182506765,\n          \"earnings_growth\": 0.04864251727236569,\n          \"book_value_growth\": 0.05284975456974036,\n          \"earnings_per_share_growth\": 0.04960775192594377,\n          \"free_cash_flow_growth\": 0.00451837140019861,\n          \"operating_income_growth\": 0.053111905190364794,\n          \"ebitda_growth\": 0.05404715105884443,\n          \"payout_ratio\": 0.243,\n          \"earnings_per_share\": 9.481244727653422,\n          \"book_value_per_share\": 21.32,\n          \"free_cash_flow_per_share\": 8.087\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"report_period\": \"2021-09-30\",\n          \"fiscal_period\": \"2022-Q1\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 2118598140636.0,\n          \"enterprise_value\": 2056039140636.0,\n          \"price_to_earnings_ratio\": 31.21,\n          \"price_to_book_ratio\": 13.94,\n          \"price_to_sales_ratio\": 12.02,\n          \"enterprise_value_to_ebitda_ratio\": 22.832,\n          \"enterprise_value_to_revenue_ratio\": 11.638635472343418,\n          \"free_cash_flow_yield\": 0.02851885822096587,\n          \"peg_ratio\": 2.8276278306878324,\n          \"gross_margin\": 0.689,\n          \"operating_margin\": 0.4414045877753885,\n          \"net_margin\": 0.385,\n          \"return_on_equity\": 0.486,\n          \"return_on_assets\": 0.212,\n          \"return_on_invested_capital\": 0.624,\n          \"asset_turnover\": 0.55,\n          \"inventory_turnover\": 51.67135737320434,\n          \"receivables_turnover\": 5.390598238316613,\n          \"days_sales_outstanding\": 0.18550816732954706,\n          \"operating_cycle\": 63.684297593368704,\n          \"working_capital_turnover\": 1.4950081853884454,\n          \"current_ratio\": 2.165,\n          \"quick_ratio\": 2.122429465527518,\n          \"cash_ratio\": 1.6219824160540433,\n          \"operating_cash_flow_ratio\": 1.0175963639976158,\n          \"debt_to_equity\": 1.207,\n          \"debt_to_assets\": 0.18883303817922711,\n          \"interest_coverage\": 33.88414634146341,\n          \"revenue_growth\": 0.04856384750844796,\n          \"earnings_growth\": 0.10791402131514093,\n          \"book_value_growth\": 0.07035805842747274,\n          \"earnings_per_share_growth\": 0.11037362647848503,\n          \"free_cash_flow_growth\": 0.07665989522078477,\n          \"operating_income_growth\": 0.05922557455614857,\n          \"ebitda_growth\": 0.0577560081753471,\n          \"payout_ratio\": 0.248,\n          \"earnings_per_share\": 9.033131386707195,\n          \"book_value_per_share\": 20.229,\n          \"free_cash_flow_per_share\": 8.042\n      }\n  ]\n}"
  },
  {
    "path": "tests/fixtures/api/financial_metrics/TSLA_2024-03-01_2024-03-08.json",
    "content": "{\n  \"financial_metrics\": [\n      {\n          \"ticker\": \"TSLA\",\n          \"report_period\": \"2023-12-31\",\n          \"fiscal_period\": \"2023-Q4\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 789898387236.0,\n          \"enterprise_value\": 778359387236.0,\n          \"price_to_earnings_ratio\": 52.67,\n          \"price_to_book_ratio\": 12.611,\n          \"price_to_sales_ratio\": 8.162,\n          \"enterprise_value_to_ebitda_ratio\": 52.524,\n          \"enterprise_value_to_revenue_ratio\": 8.046979914190942,\n          \"free_cash_flow_yield\": 0.005515899349087097,\n          \"peg_ratio\": 1.3431858869794433,\n          \"gross_margin\": 0.182,\n          \"operating_margin\": 0.10490529383195726,\n          \"net_margin\": 0.155,\n          \"return_on_equity\": 0.279,\n          \"return_on_assets\": 0.159,\n          \"return_on_invested_capital\": 0.187,\n          \"asset_turnover\": 1.024,\n          \"inventory_turnover\": 7.102084250697197,\n          \"receivables_turnover\": 32.1078301260783,\n          \"days_sales_outstanding\": 0.03114505078895973,\n          \"operating_cycle\": 39.185242639333865,\n          \"working_capital_turnover\": 2.568183326035322,\n          \"current_ratio\": 1.726,\n          \"quick_ratio\": 1.2519131765688047,\n          \"cash_ratio\": 0.5704048977320162,\n          \"operating_cash_flow_ratio\": 0.46111033811047725,\n          \"debt_to_equity\": 0.687,\n          \"debt_to_assets\": 0.04905363071901555,\n          \"interest_coverage\": 65.07692307692308,\n          \"revenue_growth\": 0.008850756849172262,\n          \"earnings_growth\": 0.3942915582000744,\n          \"book_value_growth\": 0.17147345976882505,\n          \"earnings_per_share_growth\": 0.3921305835498228,\n          \"free_cash_flow_growth\": 0.17312870220786214,\n          \"operating_income_growth\": -0.14767861640500377,\n          \"ebitda_growth\": -0.09280685644322008,\n          \"payout_ratio\": 0.0,\n          \"earnings_per_share\": 4.717637888894875,\n          \"book_value_per_share\": 19.733,\n          \"free_cash_flow_per_share\": 1.373\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"report_period\": \"2023-09-30\",\n          \"fiscal_period\": \"2023-Q3\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 794196895533.0,\n          \"enterprise_value\": 781231895533.0,\n          \"price_to_earnings_ratio\": 73.838,\n          \"price_to_book_ratio\": 14.854,\n          \"price_to_sales_ratio\": 8.279,\n          \"enterprise_value_to_ebitda_ratio\": 47.826,\n          \"enterprise_value_to_revenue_ratio\": 8.159145735509362,\n          \"free_cash_flow_yield\": 0.004676422208257899,\n          \"peg_ratio\": -6.19198947201923,\n          \"gross_margin\": 0.198,\n          \"operating_margin\": 0.12417121888161461,\n          \"net_margin\": 0.112,\n          \"return_on_equity\": 0.218,\n          \"return_on_assets\": 0.122,\n          \"return_on_invested_capital\": 0.247,\n          \"asset_turnover\": 1.085,\n          \"inventory_turnover\": 6.991035638801836,\n          \"receivables_turnover\": 32.1514999162058,\n          \"days_sales_outstanding\": 0.03110274800884033,\n          \"operating_cycle\": 38.9844236616202,\n          \"working_capital_turnover\": 2.718626006121755,\n          \"current_ratio\": 1.69,\n          \"quick_ratio\": 1.1751126126126126,\n          \"cash_ratio\": 0.598048048048048,\n          \"operating_cash_flow_ratio\": 0.4566066066066066,\n          \"debt_to_equity\": 0.738,\n          \"debt_to_assets\": 0.04676339404519858,\n          \"interest_coverage\": 93.0546875,\n          \"revenue_growth\": 0.020164206406602287,\n          \"earnings_growth\": -0.11799917999179992,\n          \"book_value_growth\": 0.0456874633287698,\n          \"earnings_per_share_growth\": -0.1192469232172997,\n          \"free_cash_flow_growth\": -0.39727361246348586,\n          \"operating_income_growth\": -0.11789972598681775,\n          \"ebitda_growth\": -0.07439936536718042,\n          \"payout_ratio\": 0.0,\n          \"earnings_per_share\": 3.388789776362266,\n          \"book_value_per_share\": 16.834,\n          \"free_cash_flow_per_share\": 1.169\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"report_period\": \"2023-06-30\",\n          \"fiscal_period\": \"2023-Q2\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 829681140873.0,\n          \"enterprise_value\": 816309140873.0,\n          \"price_to_earnings_ratio\": 68.035,\n          \"price_to_book_ratio\": 16.227,\n          \"price_to_sales_ratio\": 8.824,\n          \"enterprise_value_to_ebitda_ratio\": 46.255,\n          \"enterprise_value_to_revenue_ratio\": 8.685882299666057,\n          \"free_cash_flow_yield\": 0.00742694957910731,\n          \"peg_ratio\": 18.890409115827957,\n          \"gross_margin\": 0.215,\n          \"operating_margin\": 0.14360615986727357,\n          \"net_margin\": 0.13,\n          \"return_on_equity\": 0.265,\n          \"return_on_assets\": 0.146,\n          \"return_on_invested_capital\": 0.316,\n          \"asset_turnover\": 1.125,\n          \"inventory_turnover\": 6.549735302312622,\n          \"receivables_turnover\": 29.201242236024846,\n          \"days_sales_outstanding\": 0.034245118475347766,\n          \"operating_cycle\": 35.74664615513661,\n          \"working_capital_turnover\": 2.87029518605574,\n          \"current_ratio\": 1.59,\n          \"quick_ratio\": 1.0698390837924037,\n          \"cash_ratio\": 0.554363583647434,\n          \"operating_cash_flow_ratio\": 0.5057987822557263,\n          \"debt_to_equity\": 0.751,\n          \"debt_to_assets\": 0.025731032884061332,\n          \"interest_coverage\": 94.42657342657343,\n          \"revenue_growth\": 0.09290405067705004,\n          \"earnings_growth\": 0.03778401838141435,\n          \"book_value_growth\": 0.06401132059766096,\n          \"earnings_per_share_growth\": 0.03601538403947004,\n          \"free_cash_flow_growth\": 0.06645898234683281,\n          \"operating_income_growth\": 0.04213938411669368,\n          \"ebitda_growth\": 0.04611736810906936,\n          \"payout_ratio\": 0.0,\n          \"earnings_per_share\": 3.8476048119424844,\n          \"book_value_per_share\": 16.124,\n          \"free_cash_flow_per_share\": 1.943\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"report_period\": \"2023-03-31\",\n          \"fiscal_period\": \"2023-Q1\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 656424746349.0,\n          \"enterprise_value\": 643270746349.0,\n          \"price_to_earnings_ratio\": 55.861,\n          \"price_to_book_ratio\": 13.66,\n          \"price_to_sales_ratio\": 7.63,\n          \"enterprise_value_to_ebitda_ratio\": 38.131,\n          \"enterprise_value_to_revenue_ratio\": 7.474315643040623,\n          \"free_cash_flow_yield\": 0.008802227570086188,\n          \"peg_ratio\": -8.46496071761789,\n          \"gross_margin\": 0.231,\n          \"operating_margin\": 0.15060149938978323,\n          \"net_margin\": 0.137,\n          \"return_on_equity\": 0.278,\n          \"return_on_assets\": 0.151,\n          \"return_on_invested_capital\": 0.338,\n          \"asset_turnover\": 1.103,\n          \"inventory_turnover\": 5.985043478260869,\n          \"receivables_turnover\": 28.943650126156435,\n          \"days_sales_outstanding\": 0.03454989248561632,\n          \"operating_cycle\": 35.26649865999931,\n          \"working_capital_turnover\": 2.7997526806488877,\n          \"current_ratio\": 1.567,\n          \"quick_ratio\": 1.043227875783642,\n          \"cash_ratio\": 0.5849249161685377,\n          \"operating_cash_flow_ratio\": 0.4826505321475434,\n          \"debt_to_equity\": 0.782,\n          \"debt_to_assets\": 0.030817776651733787,\n          \"interest_coverage\": 81.49056603773585,\n          \"revenue_growth\": 0.05613660356976259,\n          \"earnings_growth\": -0.06411277476903472,\n          \"book_value_growth\": 0.07493736578382247,\n          \"earnings_per_share_growth\": -0.06599107022446334,\n          \"free_cash_flow_growth\": -0.23581536833752148,\n          \"operating_income_growth\": -0.06643129908494848,\n          \"ebitda_growth\": -0.04289118347895155,\n          \"payout_ratio\": 0.0,\n          \"earnings_per_share\": 3.7138491099818443,\n          \"book_value_per_share\": 15.178,\n          \"free_cash_flow_per_share\": 1.825\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"report_period\": \"2022-12-31\",\n          \"fiscal_period\": \"2022-Q4\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 388971946668.0,\n          \"enterprise_value\": 372992946668.0,\n          \"price_to_earnings_ratio\": 30.979,\n          \"price_to_book_ratio\": 8.701,\n          \"price_to_sales_ratio\": 4.775,\n          \"enterprise_value_to_ebitda_ratio\": 21.162,\n          \"enterprise_value_to_revenue_ratio\": 4.613414189045199,\n          \"free_cash_flow_yield\": 0.019438419826336616,\n          \"peg_ratio\": 2.730753006438353,\n          \"gross_margin\": 0.256,\n          \"operating_margin\": 0.1703739166728045,\n          \"net_margin\": 0.154,\n          \"return_on_equity\": 0.324,\n          \"return_on_assets\": 0.172,\n          \"return_on_invested_capital\": 0.399,\n          \"asset_turnover\": 1.119,\n          \"inventory_turnover\": 6.344886673416933,\n          \"receivables_turnover\": 31.67262830482115,\n          \"days_sales_outstanding\": 0.031573003363531464,\n          \"operating_cycle\": 38.70552133771418,\n          \"working_capital_turnover\": 2.9444805898937325,\n          \"current_ratio\": 1.532,\n          \"quick_ratio\": 1.0512561308922086,\n          \"cash_ratio\": 0.6085214721629413,\n          \"operating_cash_flow_ratio\": 0.5512748511737616,\n          \"debt_to_equity\": 0.815,\n          \"debt_to_assets\": 0.03763754281133863,\n          \"interest_coverage\": 72.66492146596859,\n          \"revenue_growth\": 0.0881476831011314,\n          \"earnings_growth\": 0.12207327971403038,\n          \"book_value_growth\": 0.12177862537953878,\n          \"earnings_per_share_growth\": 0.11344478931281178,\n          \"free_cash_flow_growth\": -0.15159335727109516,\n          \"operating_income_growth\": 0.10440041378212779,\n          \"ebitda_growth\": 0.08984109317999134,\n          \"payout_ratio\": 0.0,\n          \"earnings_per_share\": 3.9762458276221895,\n          \"book_value_per_share\": 14.282,\n          \"free_cash_flow_per_share\": 2.416\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"report_period\": \"2022-09-30\",\n          \"fiscal_period\": \"2022-Q3\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 831152929436.0,\n          \"enterprise_value\": 817258929436.0,\n          \"price_to_earnings_ratio\": 74.276,\n          \"price_to_book_ratio\": 20.857,\n          \"price_to_sales_ratio\": 11.102,\n          \"enterprise_value_to_ebitda_ratio\": 50.532,\n          \"enterprise_value_to_revenue_ratio\": 10.888876072772932,\n          \"free_cash_flow_yield\": 0.010722455139570361,\n          \"peg_ratio\": 4.4646076332543805,\n          \"gross_margin\": 0.266,\n          \"operating_margin\": 0.16786663638913749,\n          \"net_margin\": 0.149,\n          \"return_on_equity\": 0.319,\n          \"return_on_assets\": 0.165,\n          \"return_on_invested_capital\": 0.391,\n          \"asset_turnover\": 1.105,\n          \"inventory_turnover\": 7.24924954004067,\n          \"receivables_turnover\": 35.04001872220922,\n          \"days_sales_outstanding\": 0.02853879753683395,\n          \"operating_cycle\": 43.16185219115416,\n          \"working_capital_turnover\": 3.053327079552175,\n          \"current_ratio\": 1.462,\n          \"quick_ratio\": 1.0427451139734265,\n          \"cash_ratio\": 0.7936288651416034,\n          \"operating_cash_flow_ratio\": 0.6513754012433465,\n          \"debt_to_equity\": 0.836,\n          \"debt_to_assets\": 0.04773869346733668,\n          \"interest_coverage\": 54.877729257641924,\n          \"revenue_growth\": 0.11459667093469911,\n          \"earnings_growth\": 0.17591424968474148,\n          \"book_value_growth\": 0.09553001979327029,\n          \"earnings_per_share_growth\": 0.16636714198011776,\n          \"free_cash_flow_growth\": 0.2847052039786651,\n          \"operating_income_growth\": 0.15463065049614114,\n          \"ebitda_growth\": 0.13137460650577124,\n          \"payout_ratio\": 0.0,\n          \"earnings_per_share\": 3.571120782806143,\n          \"book_value_per_share\": 12.667,\n          \"free_cash_flow_per_share\": 2.833\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"report_period\": \"2022-06-30\",\n          \"fiscal_period\": \"2022-Q2\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 697669803694.0,\n          \"enterprise_value\": 684976803694.0,\n          \"price_to_earnings_ratio\": 73.315,\n          \"price_to_book_ratio\": 19.179,\n          \"price_to_sales_ratio\": 10.387,\n          \"enterprise_value_to_ebitda_ratio\": 47.917,\n          \"enterprise_value_to_revenue_ratio\": 10.180385964535628,\n          \"free_cash_flow_yield\": 0.009943099104003343,\n          \"peg_ratio\": 5.62858905261612,\n          \"gross_margin\": 0.271,\n          \"operating_margin\": 0.16204627341214306,\n          \"net_margin\": 0.142,\n          \"return_on_equity\": 0.298,\n          \"return_on_assets\": 0.15,\n          \"return_on_invested_capital\": 0.344,\n          \"asset_turnover\": 1.056,\n          \"inventory_turnover\": 8.28391711889492,\n          \"receivables_turnover\": 30.5856102003643,\n          \"days_sales_outstanding\": 0.03269511359914242,\n          \"operating_cycle\": 39.66271000440511,\n          \"working_capital_turnover\": 2.9980137032160155,\n          \"current_ratio\": 1.431,\n          \"quick_ratio\": 1.059254846249026,\n          \"cash_ratio\": 0.839741533385271,\n          \"operating_cash_flow_ratio\": 0.6451583337152285,\n          \"debt_to_equity\": 0.848,\n          \"debt_to_assets\": 0.06465926174594602,\n          \"interest_coverage\": 36.03973509933775,\n          \"revenue_growth\": 0.08001286380447017,\n          \"earnings_growth\": 0.13299202285986428,\n          \"book_value_growth\": 0.06721431714830571,\n          \"earnings_per_share_growth\": 0.13025546432671067,\n          \"free_cash_flow_growth\": 0.0017328519855595668,\n          \"operating_income_growth\": 0.1211372064276885,\n          \"ebitda_growth\": 0.1100326137599006,\n          \"payout_ratio\": 0.0,\n          \"earnings_per_share\": 3.061746729887747,\n          \"book_value_per_share\": 11.693,\n          \"free_cash_flow_per_share\": 2.23\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"report_period\": \"2022-03-31\",\n          \"fiscal_period\": \"2022-Q1\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 1113707801614.0,\n          \"enterprise_value\": 1102965801614.0,\n          \"price_to_earnings_ratio\": 132.6,\n          \"price_to_book_ratio\": 32.674,\n          \"price_to_sales_ratio\": 17.908,\n          \"enterprise_value_to_ebitda_ratio\": 85.647,\n          \"enterprise_value_to_revenue_ratio\": 17.704048908409714,\n          \"free_cash_flow_yield\": 0.006217968474284008,\n          \"peg_ratio\": 2.7695756410966594,\n          \"gross_margin\": 0.271,\n          \"operating_margin\": 0.15610226724553786,\n          \"net_margin\": 0.135,\n          \"return_on_equity\": 0.289,\n          \"return_on_assets\": 0.139,\n          \"return_on_invested_capital\": 0.309,\n          \"asset_turnover\": 1.032,\n          \"inventory_turnover\": 9.2945748019728,\n          \"receivables_turnover\": 29.446022727272727,\n          \"days_sales_outstanding\": 0.03396044380125422,\n          \"operating_cycle\": 39.43798930824959,\n          \"working_capital_turnover\": 3.174740926029915,\n          \"current_ratio\": 1.354,\n          \"quick_ratio\": 1.0421347005360055,\n          \"cash_ratio\": 0.8158937310650198,\n          \"operating_cash_flow_ratio\": 0.6455837800046609,\n          \"debt_to_equity\": 0.899,\n          \"debt_to_assets\": 0.07286713710288016,\n          \"interest_coverage\": 29.153153153153152,\n          \"revenue_growth\": 0.15545398807201383,\n          \"earnings_growth\": 0.5218336655191158,\n          \"book_value_growth\": 0.12905362880519394,\n          \"earnings_per_share_growth\": 0.47877388091259276,\n          \"free_cash_flow_growth\": 0.389725065221754,\n          \"operating_income_growth\": 0.4733646987403248,\n          \"ebitda_growth\": 0.35557894736842105,\n          \"payout_ratio\": 0.0,\n          \"earnings_per_share\": 2.708897967338401,\n          \"book_value_per_share\": 10.985,\n          \"free_cash_flow_per_share\": 2.232\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"report_period\": \"2021-12-31\",\n          \"fiscal_period\": \"2021-Q4\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 1061287010297.0,\n          \"enterprise_value\": 1053376010297.0,\n          \"price_to_earnings_ratio\": 192.297,\n          \"price_to_book_ratio\": 35.155,\n          \"price_to_sales_ratio\": 19.718,\n          \"enterprise_value_to_ebitda_ratio\": 110.882,\n          \"enterprise_value_to_revenue_ratio\": 19.51851458107129,\n          \"free_cash_flow_yield\": 0.004695242617362774,\n          \"peg_ratio\": 3.380591678131682,\n          \"gross_margin\": 0.253,\n          \"operating_margin\": 0.12241978336398937,\n          \"net_margin\": 0.103,\n          \"return_on_equity\": 0.21,\n          \"return_on_assets\": 0.097,\n          \"return_on_invested_capital\": 0.21,\n          \"asset_turnover\": 0.944,\n          \"inventory_turnover\": 9.349140177175613,\n          \"receivables_turnover\": 27.779612903225807,\n          \"days_sales_outstanding\": 0.03599762183453171,\n          \"operating_cycle\": 37.60491410804509,\n          \"working_capital_turnover\": 2.999414862492686,\n          \"current_ratio\": 1.375,\n          \"quick_ratio\": 1.083126110124334,\n          \"cash_ratio\": 0.8919563562547577,\n          \"operating_cash_flow_ratio\": 0.5834559756407003,\n          \"debt_to_equity\": 1.012,\n          \"debt_to_assets\": 0.10999340103973862,\n          \"interest_coverage\": 17.76010781671159,\n          \"revenue_growth\": 0.1488857581967213,\n          \"earnings_growth\": 0.5914071510957324,\n          \"book_value_growth\": 0.11592060030310872,\n          \"earnings_per_share_growth\": 0.5688263897683152,\n          \"free_cash_flow_growth\": 0.22522744037373985,\n          \"operating_income_growth\": 0.46292184724689167,\n          \"ebitda_growth\": 0.3221990257480863,\n          \"payout_ratio\": 0.0,\n          \"earnings_per_share\": 1.831854080129319,\n          \"book_value_per_share\": 10.202,\n          \"free_cash_flow_per_share\": 1.684\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"report_period\": \"2021-09-30\",\n          \"fiscal_period\": \"2021-Q3\",\n          \"period\": \"ttm\",\n          \"currency\": \"USD\",\n          \"market_cap\": 767736954726.0,\n          \"enterprise_value\": 760908954726.0,\n          \"price_to_earnings_ratio\": 221.377,\n          \"price_to_book_ratio\": 28.379,\n          \"price_to_sales_ratio\": 16.388,\n          \"enterprise_value_to_ebitda_ratio\": 105.902,\n          \"enterprise_value_to_revenue_ratio\": 16.218962489882173,\n          \"free_cash_flow_yield\": 0.005297387308197877,\n          \"peg_ratio\": 4.045366145566246,\n          \"gross_margin\": 0.231,\n          \"operating_margin\": 0.09614071038251366,\n          \"net_margin\": 0.074,\n          \"return_on_equity\": 0.143,\n          \"return_on_assets\": 0.064,\n          \"return_on_invested_capital\": 0.145,\n          \"asset_turnover\": 0.859,\n          \"inventory_turnover\": 9.010963646855165,\n          \"receivables_turnover\": 22.902957712050842,\n          \"days_sales_outstanding\": 0.04366248292349727,\n          \"operating_cycle\": 32.336707208627566,\n          \"working_capital_turnover\": 2.822083672178549,\n          \"current_ratio\": 1.385,\n          \"quick_ratio\": 1.097058334718298,\n          \"cash_ratio\": 0.8899783945487785,\n          \"operating_cash_flow_ratio\": 0.5501634258489835,\n          \"debt_to_equity\": 1.085,\n          \"debt_to_assets\": 0.14098972922502334,\n          \"interest_coverage\": 8.249084249084248,\n          \"revenue_growth\": 0.11910563279346424,\n          \"earnings_growth\": 0.5900962861072903,\n          \"book_value_growth\": 0.09067085953878407,\n          \"earnings_per_share_growth\": 0.5472370854940709,\n          \"free_cash_flow_growth\": -0.013821532492725509,\n          \"operating_income_growth\": 0.40006216972334474,\n          \"ebitda_growth\": 0.2558993183009963,\n          \"payout_ratio\": 0.0,\n          \"earnings_per_share\": 1.1676588895217703,\n          \"book_value_per_share\": 9.039,\n          \"free_cash_flow_per_share\": 1.359\n      }\n  ]\n}"
  },
  {
    "path": "tests/fixtures/api/insider_trades/AAPL_2024-03-01_2024-03-08.json",
    "content": "{\n  \"insider_trades\": [\n      {\n          \"ticker\": \"AAPL\",\n          \"issuer\": \"Apple Inc\",\n          \"name\": \"Susan Wagner\",\n          \"title\": null,\n          \"is_board_director\": true,\n          \"transaction_date\": \"2024-02-28\",\n          \"transaction_shares\": 1516,\n          \"transaction_price_per_share\": null,\n          \"transaction_value\": null,\n          \"shares_owned_before_transaction\": 0,\n          \"shares_owned_after_transaction\": 1516,\n          \"security_title\": \"Restricted Stock Unit\",\n          \"filing_date\": \"2024-03-01\"\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"issuer\": \"Apple Inc\",\n          \"name\": \"Ronald D Sugar\",\n          \"title\": null,\n          \"is_board_director\": true,\n          \"transaction_date\": \"2024-02-28\",\n          \"transaction_shares\": 1516,\n          \"transaction_price_per_share\": null,\n          \"transaction_value\": null,\n          \"shares_owned_before_transaction\": 0,\n          \"shares_owned_after_transaction\": 1516,\n          \"security_title\": \"Restricted Stock Unit\",\n          \"filing_date\": \"2024-03-01\"\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"issuer\": \"Apple Inc\",\n          \"name\": \"Monica C Lozano\",\n          \"title\": null,\n          \"is_board_director\": true,\n          \"transaction_date\": \"2024-02-28\",\n          \"transaction_shares\": 1516,\n          \"transaction_price_per_share\": null,\n          \"transaction_value\": null,\n          \"shares_owned_before_transaction\": 0,\n          \"shares_owned_after_transaction\": 1516,\n          \"security_title\": \"Restricted Stock Unit\",\n          \"filing_date\": \"2024-03-01\"\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"issuer\": \"Apple Inc\",\n          \"name\": \"Arthur D Levinson\",\n          \"title\": null,\n          \"is_board_director\": true,\n          \"transaction_date\": \"2024-02-28\",\n          \"transaction_shares\": 1516,\n          \"transaction_price_per_share\": null,\n          \"transaction_value\": null,\n          \"shares_owned_before_transaction\": 0,\n          \"shares_owned_after_transaction\": 1516,\n          \"security_title\": \"Restricted Stock Unit\",\n          \"filing_date\": \"2024-03-01\"\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"issuer\": \"Apple Inc\",\n          \"name\": \"Arthur D Levinson\",\n          \"title\": null,\n          \"is_board_director\": true,\n          \"transaction_date\": null,\n          \"transaction_shares\": 0,\n          \"transaction_price_per_share\": null,\n          \"transaction_value\": null,\n          \"shares_owned_before_transaction\": 56000,\n          \"shares_owned_after_transaction\": 56000,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-01\"\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"issuer\": \"Apple Inc\",\n          \"name\": \"Arthur D Levinson\",\n          \"title\": null,\n          \"is_board_director\": true,\n          \"transaction_date\": \"2024-02-29\",\n          \"transaction_shares\": -100000,\n          \"transaction_price_per_share\": 180.94,\n          \"transaction_value\": 18094000,\n          \"shares_owned_before_transaction\": 4534576,\n          \"shares_owned_after_transaction\": 4434576,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-01\"\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"issuer\": \"Apple Inc\",\n          \"name\": \"Andrea Jung\",\n          \"title\": null,\n          \"is_board_director\": true,\n          \"transaction_date\": \"2024-02-28\",\n          \"transaction_shares\": 1516,\n          \"transaction_price_per_share\": null,\n          \"transaction_value\": null,\n          \"shares_owned_before_transaction\": 0,\n          \"shares_owned_after_transaction\": 1516,\n          \"security_title\": \"Restricted Stock Unit\",\n          \"filing_date\": \"2024-03-01\"\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"issuer\": \"Apple Inc\",\n          \"name\": \"Alex Gorsky\",\n          \"title\": null,\n          \"is_board_director\": true,\n          \"transaction_date\": \"2024-02-28\",\n          \"transaction_shares\": 1516,\n          \"transaction_price_per_share\": null,\n          \"transaction_value\": null,\n          \"shares_owned_before_transaction\": 0,\n          \"shares_owned_after_transaction\": 1516,\n          \"security_title\": \"Restricted Stock Unit\",\n          \"filing_date\": \"2024-03-01\"\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"issuer\": \"Apple Inc\",\n          \"name\": \"Wanda M Austin\",\n          \"title\": null,\n          \"is_board_director\": true,\n          \"transaction_date\": \"2024-02-28\",\n          \"transaction_shares\": 1516,\n          \"transaction_price_per_share\": null,\n          \"transaction_value\": null,\n          \"shares_owned_before_transaction\": 0,\n          \"shares_owned_after_transaction\": 1516,\n          \"security_title\": \"Restricted Stock Unit\",\n          \"filing_date\": \"2024-03-01\"\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"issuer\": \"Apple Inc\",\n          \"name\": \"Wanda M Austin\",\n          \"title\": null,\n          \"is_board_director\": true,\n          \"transaction_date\": null,\n          \"transaction_shares\": 0,\n          \"transaction_price_per_share\": null,\n          \"transaction_value\": null,\n          \"shares_owned_before_transaction\": 72,\n          \"shares_owned_after_transaction\": 72,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-01\"\n      }\n  ]\n}"
  },
  {
    "path": "tests/fixtures/api/insider_trades/MSFT_2024-03-01_2024-03-08.json",
    "content": "{\n  \"insider_trades\": [\n      {\n          \"ticker\": \"MSFT\",\n          \"issuer\": \"Microsoft Corp\",\n          \"name\": \"Christopher David Young\",\n          \"title\": \"EVP Business Development\",\n          \"is_board_director\": false,\n          \"transaction_date\": null,\n          \"transaction_shares\": 0,\n          \"transaction_price_per_share\": null,\n          \"transaction_value\": null,\n          \"shares_owned_before_transaction\": 1500,\n          \"shares_owned_after_transaction\": 1500,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-04\"\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"issuer\": \"Microsoft Corp\",\n          \"name\": \"Christopher David Young\",\n          \"title\": \"EVP Business Development\",\n          \"is_board_director\": false,\n          \"transaction_date\": \"2024-02-29\",\n          \"transaction_shares\": -2806,\n          \"transaction_price_per_share\": 407.72,\n          \"transaction_value\": 1144291,\n          \"shares_owned_before_transaction\": 109446,\n          \"shares_owned_after_transaction\": 106640,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-04\"\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"issuer\": \"Microsoft Corp\",\n          \"name\": \"Bradford L Smith\",\n          \"title\": \"Vice Chair and President\",\n          \"is_board_director\": false,\n          \"transaction_date\": \"2024-02-29\",\n          \"transaction_shares\": -4076,\n          \"transaction_price_per_share\": 407.72,\n          \"transaction_value\": 1662122,\n          \"shares_owned_before_transaction\": 570826,\n          \"shares_owned_after_transaction\": 566749,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-04\"\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"issuer\": \"Microsoft Corp\",\n          \"name\": \"Takeshi Numoto\",\n          \"title\": \"EVP Chief Marketing Officer\",\n          \"is_board_director\": false,\n          \"transaction_date\": \"2024-02-29\",\n          \"transaction_shares\": -1376,\n          \"transaction_price_per_share\": 407.72,\n          \"transaction_value\": 561346,\n          \"shares_owned_before_transaction\": 49293,\n          \"shares_owned_after_transaction\": 47916,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-04\"\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"issuer\": \"Microsoft Corp\",\n          \"name\": \"Satya Nadella\",\n          \"title\": \"Chief Executive Officer\",\n          \"is_board_director\": true,\n          \"transaction_date\": \"2024-03-01\",\n          \"transaction_shares\": -1276,\n          \"transaction_price_per_share\": 410.94,\n          \"transaction_value\": 524359,\n          \"shares_owned_before_transaction\": 802606,\n          \"shares_owned_after_transaction\": 801330,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-04\"\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"issuer\": \"Microsoft Corp\",\n          \"name\": \"Satya Nadella\",\n          \"title\": \"Chief Executive Officer\",\n          \"is_board_director\": true,\n          \"transaction_date\": \"2024-02-29\",\n          \"transaction_shares\": -1551,\n          \"transaction_price_per_share\": 407.72,\n          \"transaction_value\": 632442,\n          \"shares_owned_before_transaction\": 804157,\n          \"shares_owned_after_transaction\": 802606,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-04\"\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"issuer\": \"Microsoft Corp\",\n          \"name\": \"Alice L Jolla\",\n          \"title\": \"Chief Accounting Officer\",\n          \"is_board_director\": false,\n          \"transaction_date\": \"2024-02-29\",\n          \"transaction_shares\": -452,\n          \"transaction_price_per_share\": 407.72,\n          \"transaction_value\": 184542,\n          \"shares_owned_before_transaction\": 69048,\n          \"shares_owned_after_transaction\": 68596,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-04\"\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"issuer\": \"Microsoft Corp\",\n          \"name\": \"Amy Hood\",\n          \"title\": \"EVP Chief Financial Officer\",\n          \"is_board_director\": false,\n          \"transaction_date\": \"2024-02-29\",\n          \"transaction_shares\": -4663,\n          \"transaction_price_per_share\": 407.72,\n          \"transaction_value\": 1901581,\n          \"shares_owned_before_transaction\": 521114,\n          \"shares_owned_after_transaction\": 516450,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-04\"\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"issuer\": \"Microsoft Corp\",\n          \"name\": \"Kathleen T Hogan\",\n          \"title\": \"EVP Chief Human Resources Off\",\n          \"is_board_director\": false,\n          \"transaction_date\": \"2024-02-29\",\n          \"transaction_shares\": -1921,\n          \"transaction_price_per_share\": 407.72,\n          \"transaction_value\": 783510,\n          \"shares_owned_before_transaction\": 183640,\n          \"shares_owned_after_transaction\": 181719,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-04\"\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"issuer\": \"Microsoft Corp\",\n          \"name\": \"Judson Althoff\",\n          \"title\": \"EVP Chief Commercial Officer\",\n          \"is_board_director\": false,\n          \"transaction_date\": \"2024-02-29\",\n          \"transaction_shares\": -3037,\n          \"transaction_price_per_share\": 407.72,\n          \"transaction_value\": 1238477,\n          \"shares_owned_before_transaction\": 142986,\n          \"shares_owned_after_transaction\": 139948,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-04\"\n      }\n  ]\n}"
  },
  {
    "path": "tests/fixtures/api/insider_trades/TSLA_2024-03-01_2024-03-08.json",
    "content": "{\n  \"insider_trades\": [\n      {\n          \"ticker\": \"TSLA\",\n          \"issuer\": \"Tesla Inc\",\n          \"name\": \"Xiaotong Zhu\",\n          \"title\": \"SVP Automotive\",\n          \"is_board_director\": false,\n          \"transaction_date\": \"2024-03-05\",\n          \"transaction_shares\": -2633,\n          \"transaction_price_per_share\": 0.0,\n          \"transaction_value\": null,\n          \"shares_owned_before_transaction\": 7899,\n          \"shares_owned_after_transaction\": 5266,\n          \"security_title\": \"Restricted Stock Unit\",\n          \"filing_date\": \"2024-03-07\"\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"issuer\": \"Tesla Inc\",\n          \"name\": \"Xiaotong Zhu\",\n          \"title\": \"SVP Automotive\",\n          \"is_board_director\": false,\n          \"transaction_date\": \"2024-03-06\",\n          \"transaction_shares\": -687,\n          \"transaction_price_per_share\": 177.106,\n          \"transaction_value\": 121716,\n          \"shares_owned_before_transaction\": 63858,\n          \"shares_owned_after_transaction\": 63171,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-07\"\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"issuer\": \"Tesla Inc\",\n          \"name\": \"Xiaotong Zhu\",\n          \"title\": \"SVP Automotive\",\n          \"is_board_director\": false,\n          \"transaction_date\": \"2024-03-05\",\n          \"transaction_shares\": 2633,\n          \"transaction_price_per_share\": 0.0,\n          \"transaction_value\": null,\n          \"shares_owned_before_transaction\": 61225,\n          \"shares_owned_after_transaction\": 63858,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-07\"\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"issuer\": \"Tesla Inc\",\n          \"name\": \"Andrew D Baglino\",\n          \"title\": \"SVP Powertrain and Energy Eng.\",\n          \"is_board_director\": false,\n          \"transaction_date\": \"2024-02-29\",\n          \"transaction_shares\": -10500,\n          \"transaction_price_per_share\": 17.22,\n          \"transaction_value\": 180810,\n          \"shares_owned_before_transaction\": 509775,\n          \"shares_owned_after_transaction\": 499275,\n          \"security_title\": \"Non-Qualified Stock Option right to buy\",\n          \"filing_date\": \"2024-03-04\"\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"issuer\": \"Tesla Inc\",\n          \"name\": \"Andrew D Baglino\",\n          \"title\": \"SVP Powertrain and Energy Eng.\",\n          \"is_board_director\": false,\n          \"transaction_date\": \"2024-02-29\",\n          \"transaction_shares\": -10500,\n          \"transaction_price_per_share\": 204.17,\n          \"transaction_value\": 2143785,\n          \"shares_owned_before_transaction\": 41730,\n          \"shares_owned_after_transaction\": 31230,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-04\"\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"issuer\": \"Tesla Inc\",\n          \"name\": \"Andrew D Baglino\",\n          \"title\": \"SVP Powertrain and Energy Eng.\",\n          \"is_board_director\": false,\n          \"transaction_date\": \"2024-02-29\",\n          \"transaction_shares\": 10500,\n          \"transaction_price_per_share\": 17.22,\n          \"transaction_value\": 180810,\n          \"shares_owned_before_transaction\": 31230,\n          \"shares_owned_after_transaction\": 41730,\n          \"security_title\": \"Common Stock\",\n          \"filing_date\": \"2024-03-04\"\n      }\n  ]\n}"
  },
  {
    "path": "tests/fixtures/api/news/AAPL_2024-03-01_2024-03-08.json",
    "content": "{\n  \"news\": [\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Apple (AAPL) Ascends While Market Falls: Some Facts to Note\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-08T22:45:22Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2238271/apple-aapl-ascends-while-market-falls-some-facts-to-note\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default3.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Stock Market Hits Record Highs As Powell Hints At Rate Cuts, Bitcoin And Gold Soar: This Week In The Markets\",\n          \"author\": \"Piero Cingari\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-08T21:19:21Z\",\n          \"url\": \"https://www.benzinga.com/markets/cryptocurrency/24/03/37569176/stock-market-hit-record-highs-as-powell-hints-at-rate-cuts-bitcoin-and-gold-soar-this-week\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Bull-And-Bear-Are-On-A-Graphic-With-Mark.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Big US Stocks’ Q4’23 Fundamentals\",\n          \"author\": \"Adam Hamilton\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-08T20:46:00Z\",\n          \"url\": \"https://www.investing.com/analysis/big-us-stocks-q423-fundamentals-200646680\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Complaints about stock market’s ‘bad breadth’ are overblown. Here’s why.\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-08T20:36:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/complaints-about-stock-markets-bad-breadth-are-overblown-heres-why-ef4e052e\",\n          \"image_url\": \"https://images.mktw.net/im-16255885/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Is It Time to Sell These 5 Overvalued Stocks?\",\n          \"author\": \"MarketBeat.com\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-08T20:34:00Z\",\n          \"url\": \"https://www.investing.com/analysis/is-it-time-to-sell-these-5-overvalued-stocks-200646679\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Investor Update: Spirit Airlines, Macy's, Apple, Spotify\",\n          \"author\": \"newsfeedback@fool.com (Motley Fool Staff)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-08T18:44:08Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/08/investor-update-spirit-airlines-macys-apple-spotif/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768069/mfm_04-copy-2.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Mother Of All Reports Give Ammunition To Both Bull And Bears\",\n          \"author\": \"The Arora Report\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-08T15:47:21Z\",\n          \"url\": \"https://www.benzinga.com/markets/24/03/37561905/mother-of-all-reports-give-ammunition-to-both-bull-and-bears\",\n          \"image_url\": \"https://cdn.benzinga.com/files/chris-liverani-dbi_my696rk-unsplash_3_2.jpg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Investors have sold a record amount of tech stocks, says Bank of America\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-08T15:47:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/investors-have-drained-4-4-billion-from-tech-stocks-the-most-ever-says-bank-of-america-c3185d79\",\n          \"image_url\": \"https://images.mktw.net/im-77554719/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Nvidia Vs. Microsoft: 'Insane' Gains Aim Tech 'Beast' At No. 1 Market Cap Slot\",\n          \"author\": \"Neil Dennis\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-08T15:17:51Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37561048/nvidia-vs-microsoft-insane-gains-aim-tech-beast-at-no-1-market-cap-slot\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/nvidia-chip-shutter_2.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Rivian looks like it’s trying to get Apple’s attention\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-08T14:50:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/rivian-looks-like-its-trying-to-get-apples-attention-bad24bbb\",\n          \"image_url\": \"https://images.mktw.net/im-08242973/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Here’s what 100 years of history shows about periods of extreme market concentration, according to Goldman Sachs\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-08T13:40:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/heres-what-100-years-of-history-shows-about-periods-of-extreme-market-concentration-according-to-goldman-sachs-340ca243\",\n          \"image_url\": \"https://images.mktw.net/im-65097578/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"A Motley Fool Primer on Small-Cap Investing\",\n          \"author\": \"newsfeedback@fool.com (Motley Fool Staff)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-08T13:07:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/08/a-motley-fool-primer-on-small-cap-investing/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767970/mfm_02.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Taiwan Semiconductor shares climb after chipmaker posts early 2024 sales jump\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-08T11:23:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/taiwan-semiconductor-shares-climb-after-chipmaker-posts-early-2024-sales-jump-0aed549f\",\n          \"image_url\": \"https://images.mktw.net/im-10218739/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Should Goldman Sachs MarketBeta U.S. 1000 Equity ETF (GUSA) Be on Your Investing Radar?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-08T11:20:04Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237786/should-goldman-sachs-marketbeta-us-1000-equity-etf-gusa-be-on-your-investing-radar\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default24.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Zacks Investment Ideas feature highlights: Apple, Tesla, HCA Healthcare, DaVita and The Progressive\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-08T11:00:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237768/zacks-investment-ideas-feature-highlights-apple-tesla-hca-healthcare-davita-and-the-progressive\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/7c/572.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Zacks Investment Ideas feature highlights: Adobe, Apple, Nvidia and Block\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-08T11:00:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237764/zacks-investment-ideas-feature-highlights-adobe-apple-nvidia-and-block\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/ad/429.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"3 Attractive Tech Stocks Offering Nearly 40% Upside Potential\",\n          \"author\": \"Investing.com\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-08T10:31:00Z\",\n          \"url\": \"https://www.investing.com/analysis/3-attractive-tech-stocks-offering-nearly-40-upside-potential-200646668\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Apple Has Spent $175 Billion on R&D Since the Start of 2013 -- but It's the $651 Billion Spent on Something Else That's the Real Jaw-Dropper\",\n          \"author\": \"newsfeedback@fool.com (Sean Williams)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-08T10:21:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/08/apple-spent-175-billion-rd-since-2013-651-billion/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768388/balance-sheet-company-debt-assets-book-value-goodwill-investment-magnfiying-glass-accountant-getty.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"78% of Warren Buffett's $369 Billion Portfolio Is Invested in Just 6 Stocks\",\n          \"author\": \"newsfeedback@fool.com (Sean Williams)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-08T10:06:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/08/78-warren-buffett-portfolio-invested-in-6-stocks/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767796/buffett13-tmf.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Wedbush's Dan Ives Sees Apple's AI Push As A 'Golden Buying Opportunity': 'There's 2.2B Reasons They Are Not Too Late'\",\n          \"author\": \"Benzinga Neuro\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-08T07:10:16Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37553146/wedbushs-dan-ives-sees-apples-ai-push-as-a-golden-buying-opportunity-theres-2-2b-re\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Apple_21.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Do These 2 Stocks Belong in the Magnificent 7?\",\n          \"author\": \"Derek Lewis\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T20:28:00Z\",\n          \"url\": \"https://www.zacks.com/commentary/2237587/do-these-2-stocks-belong-in-the-magnificent-7\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/f8/14626.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Apple Vs. Nvidia: Only 1 Adds $1 Trillion In 67 Days In Battle For Mega-Cap Dominance\",\n          \"author\": \"Neil Dennis\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-07T20:20:02Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37544925/apple-vs-nvidia-only-one-added-1-trillion-in-67-days-as-battle-for-mega-cap-dominan\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/nvidia-shutter6_2.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"3 Standout Tech Stocks to Buy Down at Least 15%\",\n          \"author\": \"Benjamin Rains\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T19:19:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237586/3-standout-tech-stocks-to-buy-down-at-least-15\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/bc/51150.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Nvidia’s stock continues to hit new highs. Why that may be ‘a bit unhealthy.’\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-07T18:00:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/nvidias-stock-continues-to-hit-new-highs-why-that-may-be-a-bitunhealthy-ec604df2\",\n          \"image_url\": \"https://images.mktw.net/im-231865/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Market Leaders Rolling Over: Time to Rotate into Defensive Stocks?\",\n          \"author\": \"Ethan Feller\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T17:25:00Z\",\n          \"url\": \"https://www.zacks.com/commentary/2237542/market-leaders-rolling-over-time-to-rotate-into-defensive-stocks\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/b6/2622.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Wall Street is divided on whether bitcoin is ‘frothy,’ and  will undo Fed’s rate-cut plans\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-07T14:37:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/wall-street-is-divided-on-whether-bitcoin-froth-will-undo-feds-rate-cut-plans-6826c116\",\n          \"image_url\": \"https://images.mktw.net/im-02780123/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Surging Stocks: These Music Industry Picks See Boost After Exceeding Expectations\",\n          \"author\": \"Johnny Rice\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-07T13:29:49Z\",\n          \"url\": \"https://www.benzinga.com/trading-ideas/long-ideas/24/03/37533056/surging-stocks-these-music-industry-picks-see-boost-after-exceeding-expectations\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Screenshot-2024-03-07-at-6-50-53-PM_1.png?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Spotify Is Dominating Music and Now Has a $2 Billion Win Over Apple\",\n          \"author\": \"newsfeedback@fool.com (Travis Hoium)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-07T12:00:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/07/spotify-is-dominating-music-and-how-has-a-2-billio/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768049/person-listening-to-music.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Should iShares Russell 1000 ETF (IWB) Be on Your Investing Radar?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T11:20:06Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237070/should-ishares-russell-1000-etf-iwb-be-on-your-investing-radar\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default44.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Is iShares Paris-Aligned Climate MSCI USA ETF (PABU) a Strong ETF Right Now?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T11:20:05Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237079/is-ishares-paris-aligned-climate-msci-usa-etf-pabu-a-strong-etf-right-now\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default7.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"This 1 Chart Shows How Microsoft Became the Biggest \\\"Magnificent Seven\\\" Stock and World's Most Valuable Company\",\n          \"author\": \"newsfeedback@fool.com (Keith Noonan)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-07T10:40:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/07/chart-microsoft-biggest-magnificent-7-stock/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/766974/a-flaming-arrow-moving-up.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Apple: The Reasons For The Selloff And How It May Present An Opportunity (Rating Downgrade)\",\n          \"author\": \"Yuval Rotem\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-07T10:11:57Z\",\n          \"url\": \"https://seekingalpha.com/article/4676536-apple-stock-reasons-for-selloff-how-may-present-opportunity-downgrade-hold\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/1705316992/image_1705316992.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"The 2024 Ford Bronco is civilized in town but really shines off-road\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-07T10:03:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/the-2024-ford-bronco-is-civilized-in-town-but-really-shines-off-road-1b1a7c68\",\n          \"image_url\": \"https://images.mktw.net/im-68286650/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Zacks Earnings Trends Highlights: Apple, Amazon, Microsoft, Meta and Nvidia\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T08:37:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237031/zacks-earnings-trends-highlights-apple-amazon-microsoft-meta-and-nvidia\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/23/489.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Apple: How Does 2024 Look After Recent Setbacks?\",\n          \"author\": \"The Tokenist\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-07T07:01:00Z\",\n          \"url\": \"https://www.investing.com/analysis/apple-how-does-2024-look-after-recent-setbacks-200646625\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Tesla Investor Ross Gerber Says 'Vision Pro Is Awesome' After Apple's Recent Stock Rout: 'It's Gen 1. I'm Telling You'\",\n          \"author\": \"Benzinga Neuro\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-07T06:58:48Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37527657/tesla-investor-ross-gerber-says-vision-pro-is-awesome-after-apples-recent-stock-rou\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Apple-Vision-Pro_51.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Looking Ahead to Q1 Earnings: What's Next?\",\n          \"author\": \"Sheraz Mian\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T00:50:00Z\",\n          \"url\": \"https://www.zacks.com/commentary/2237011/looking-ahead-to-q1-earnings-whats-next\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/03/23269.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Looking Ahead to Q1 Earnings: What's Next?\",\n          \"author\": \"Sheraz Mian\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T00:46:00Z\",\n          \"url\": \"https://www.zacks.com/commentary/2237013/looking-ahead-to-q1-earnings-whats-next\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/03/23269.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"'They Are Not All Magnificent': Wall Street Veteran Points Out 2 Vulnerable Magnificent 7 Stocks\",\n          \"author\": \"Piero Cingari\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-06T22:26:07Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37523438/they-are-not-all-magnificent-wall-street-veteran-points-out-2-vulnerable-magnificen\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Magnificent7-Shutterstock_3.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Stock market bubble? One key ingredient is still missing.\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T20:25:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/stock-market-bubble-one-key-ingredient-is-still-missing-579247fd\",\n          \"image_url\": \"https://images.mktw.net/im-632304/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"If ‘Magnificent Seven’s’ stock swings are stressing you, think about their bonds\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T19:03:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/magnificent-sevens-wild-stock-swings-keeping-you-awake-at-night-consider-their-bonds-86349071\",\n          \"image_url\": \"https://images.mktw.net/im-886882/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Apple terminates Epic Games’ developer account in latest clash between the two companies\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T17:58:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/apple-terminates-epic-games-developer-account-in-latest-clash-between-the-two-companies-22b8bc15\",\n          \"image_url\": \"https://images.mktw.net/im-77780461/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Super Micro's Earnings Skyrocket with AI Demand: Analyst Highlights Growth Trajectory\",\n          \"author\": \"Anusuya Lahiri\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-06T17:45:10Z\",\n          \"url\": \"https://www.benzinga.com/news/24/03/37516106/super-micros-earnings-skyrocket-with-ai-demand-analyst-highlights-growth-trajectory\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/SMCI-Super-Micro-Computer_3.png?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Buying Ahead Of Powell On Hopium Key Apple Level, Bitcoin Whales Take Profits\",\n          \"author\": \"The Arora Report\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-06T17:03:36Z\",\n          \"url\": \"https://www.benzinga.com/24/03/37515021/buying-ahead-of-powell-on-hopium-key-apple-level-bitcoin-whales-take-profits\",\n          \"image_url\": \"https://cdn.benzinga.com/files/austin-distel-dfjjmvhwh_8-unsplash_3_6.jpg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Magnificent Seven no longer a monolith as performance continues to diverge\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T16:29:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/magnificent-seven-no-longer-a-monolith-as-performance-continues-to-diverge-ed03ebe6\",\n          \"image_url\": \"https://images.mktw.net/im-96498660/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"For the 14th year in a row, the S&P 500 did better than the majority of actively managed U.S. large-cap stock funds\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T15:23:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/majority-of-active-u-s-large-cap-stocks-funds-fail-to-beat-s-p-500-in-2023-a-worse-year-for-underperformance-than-2022-89fad8a1\",\n          \"image_url\": \"https://images.mktw.net/im-07151356/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Is Warren Buffet About To Sell His Stake In Apple?\",\n          \"author\": \"Johnny Rice\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-06T14:12:40Z\",\n          \"url\": \"https://www.benzinga.com/24/03/37509830/is-warren-buffet-about-to-sell-his-stake-in-apple\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Screenshot-2024-03-06-at-7-39-02-PM.png?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"There’s one big mystery in the everything rally: gold’s record-setting ascent\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T13:34:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/theres-one-big-mystery-in-the-everything-rally-golds-record-setting-ascent-13775d75\",\n          \"image_url\": \"https://images.mktw.net/im-65082598/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Apple Scraps EV Plans: Here’s What’s Next, Lower Prices\",\n          \"author\": \"MarketBeat.com\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-06T13:20:00Z\",\n          \"url\": \"https://www.investing.com/analysis/apple-scraps-ev-plans-heres-whats-next-lower-prices-200646613\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Jason and Travis Kelce’s ‘New Heights’ podcast could be in line for $100 million deal, expert says\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T13:05:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/why-jason-and-travis-kelce-could-be-the-next-100-million-podcasters-91400853\",\n          \"image_url\": \"https://images.mktw.net/im-23324785/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Taiwan Semiconductor: Japan Bet, AI Demand to Keep Countering Geopolitical Risks\",\n          \"author\": \"Alessandro Bergonzi\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-06T13:02:00Z\",\n          \"url\": \"https://www.investing.com/analysis/taiwan-semiconductor-japan-bet-ai-demand-to-keep-countering-geopolitical-risks-200646608\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Apple's Big Failure in Autonomous Driving\",\n          \"author\": \"newsfeedback@fool.com (Travis Hoium)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T13:00:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/06/apples-big-failure/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768048/autonomous-vehicles-on-road.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Bitcoin Has Been a Better Investment Over the Past 5 Years Than All but 1 of the \\\"Magnificient Seven\\\" Stocks\",\n          \"author\": \"newsfeedback@fool.com (David Jagielski)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T11:30:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/06/bitcoin-has-been-a-better-investment-to-own-over-t/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767723/stock-traders-looking-at-a-chart.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Stock Market News for Mar 6, 2024\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-06T11:29:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2236441/stock-market-news-for-mar-6-2024\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/7f/511.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Is FlexShares Morningstar U.S. Market Factor Tilt ETF (TILT) a Strong ETF Right Now?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-06T11:20:05Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2236437/is-flexshares-morningstar-us-market-factor-tilt-etf-tilt-a-strong-etf-right-now\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default9.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Should You Buy the Worst-Performing \\\"Magnificent Seven\\\" Stock of the Past Decade?\",\n          \"author\": \"newsfeedback@fool.com (Prosper Junior Bakiny)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T11:16:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/06/should-you-buy-the-worst-performing-magnificent-se/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767225/person-working-at-a-desk.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"1 \\\"Magnificent Seven\\\" Stock to Buy Hand Over Fist in March, and 1 to Avoid Like the Plague\",\n          \"author\": \"newsfeedback@fool.com (Sean Williams)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T10:06:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/06/1-magnificent-seven-stock-buy-in-march-1-to-avoid/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767782/buy-sell-dice-on-financial-graph-stock-market-getty.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Apple Analysts Dissecting iPhone Maker's 'Dark Chapter' Pin Hope On One Thing For Tide To Turn: 'If Humanity Needs Devices In The Future...'\",\n          \"author\": \"Shanthi Rexaline\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-06T09:51:01Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37503548/apple-analysts-dissecting-iphone-makers-dark-chapter-pin-hope-on-one-thing-for-tide\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/St-Petersburg--Russia---June-13-2022-Clo.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Jim Cramer Weighs In On Tuesday Market Decline, Says It Reflects A Market Top, Not A Bubble\",\n          \"author\": \"Benzinga Neuro\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-06T08:50:24Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37503243/jim-cramer-weighs-in-on-tuesday-market-decline-says-it-reflects-a-market-top-not-a-\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/jim-cramer-shutterstock_19.png?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Fear & Greed Index Moves To 'Greed' Zone; Dow Dips Over 400 Points\",\n          \"author\": \"Avi Kapoor\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-06T07:29:16Z\",\n          \"url\": \"https://www.benzinga.com/news/earnings/24/03/37502521/fear-greed-index-moves-to-greed-zone-dow-dips-over-400-points\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/03/06/image_3.jpg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Nvidia: Time To Admit My Big Mistake (Rating Upgrade)\",\n          \"author\": \"Dair Sansyzbayev\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-06T03:44:47Z\",\n          \"url\": \"https://seekingalpha.com/article/4676178-nvidia-stock-time-to-admit-big-mistake-upgrade-buy\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/1494623399/image_1494623399.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Markets Sell Off Ahead of Jobs & Powell News\",\n          \"author\": \"Mark Vickery\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T22:57:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2236308/markets-sell-off-ahead-of-jobs-powell-news\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/b1/5642.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"‘Magnificent Seven’ shed $233 billion in market cap, dragging down stock market\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T22:14:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/magnificent-seven-shed-233-billion-in-market-cap-dragging-down-the-stock-market-d71f4f68\",\n          \"image_url\": \"https://images.mktw.net/im-19977052/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Dow Jones ends down over 400 points and Nasdaq suffers worst day in 3 weeks as Apple shares slump\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T21:32:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/s-p-500-futures-dip-as-traders-wary-of-rally-exhaustion-and-apple-eyes-4-month-low-e11d85a8\",\n          \"image_url\": \"https://images.mktw.net/im-252190/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"TikTok faces fresh threat of U.S. ban under new House bill\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T19:20:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/tiktok-faces-fresh-threat-of-u-s-ban-under-new-house-bill-1bd21567\",\n          \"image_url\": \"https://images.mktw.net/im-76530103/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"The Magnificent 7 Are Falling Like Dominos; Only 3 Remain\",\n          \"author\": \"Beth Kindig\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-05T17:36:28Z\",\n          \"url\": \"https://seekingalpha.com/article/4675980-the-magnificent-7-are-falling-like-dominos-only-3-remain\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/2041838046/image_2041838046.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Goodbye, Project Titan\",\n          \"author\": \"newsfeedback@fool.com (Motley Fool Staff)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-05T17:10:16Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/05/goodbye-project-titan/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767734/mfm_28.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"'Apple At A Crossroads' Analyst Says: Company Must Step Up On AI Or Get Left Behind\",\n          \"author\": \"Neil Dennis\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-05T17:02:54Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37488304/apple-at-a-crossroads-analyst-says-company-must-step-up-on-ai-or-get-left-behind\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Apple-vision-pro_2.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Apple’s stock is in a ‘funk.’ Could Warren Buffett be selling more shares?\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T17:00:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/apples-stock-is-in-a-funk-could-warren-buffett-be-selling-more-shares-116a083e\",\n          \"image_url\": \"https://images.mktw.net/im-87117296/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Is PayPal Set for a Glow-Up?\",\n          \"author\": \"newsfeedback@fool.com (Motley Fool Staff)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-05T16:55:11Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/05/is-paypal-set-for-a-glow-up/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/766848/mfm_21-copy.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Alphabet (GOOGL) Enhances Google Maps With New Feature\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T16:46:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2236149/alphabet-googl-enhances-google-maps-with-new-feature\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/aa/397.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Apple's China, EV Headwinds Show It's Not All 'Roses And Rainbows' For Company, Analyst Says 'Next Phase Of Monetization Is On The Horizon'\",\n          \"author\": \"Chris Katje\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-05T15:49:33Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37486312/apples-china-ev-headwinds-show-its-not-all-roses-and-rainbows-for-company-analyst-s\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Apple-iPhone-7_0.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Earnings Data Deluge\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T15:38:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2236119/earnings-data-deluge\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/2f/502.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Target, NIO Beat; \\\"Jobs Week\\\" Starts Tomorrow\",\n          \"author\": \"Mark Vickery\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T15:14:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2236098/target-nio-beat-jobs-week-starts-tomorrow\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/2f/502.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Looking for Growth and Dividends? Try These 2 Top \\\"Magnificent Seven\\\" Stocks\",\n          \"author\": \"newsfeedback@fool.com (Adria Cimino)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-05T15:05:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/05/2-magnificent-seven-stocks-for-growth-and-dividend/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767907/gettyimages-investors-or-sports-betting_excited.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Jim Cramer Says Apple Stock Short-Term Prospects Is 'Not A Table Pounder'\",\n          \"author\": \"Benzinga Neuro\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-05T13:58:36Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37482108/jim-cramer-says-apple-stock-short-term-prospects-is-not-a-table-pounder\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Apple_18.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"5 Best ETF Areas of Last Week\",\n          \"author\": \"Sanghamitra Saha\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T13:42:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235891/5-best-etf-areas-of-last-week\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/93/1042.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"How Morgan Stanley’s bearish strategist may be right for the wrong reason\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T13:22:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/how-morgan-stanleys-bearish-strategist-may-be-right-for-the-wrong-reason-e731ff6e\",\n          \"image_url\": \"https://images.mktw.net/im-49457442/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"'I Sense Serious Apple Panic': Jim Cramer Says 'Nothing Good Is Going To Come Of China' For Cupertino After iPhone's 24% Plunge\",\n          \"author\": \"Rounak Jain\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-05T12:30:22Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37478583/i-sense-serious-apple-panic-jim-cramer-says-nothing-good-is-going-to-come-of-china-\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Apple-iPhone-China_0.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"1 Mind-Blowing Quote That Shows Why This Artificial Intelligence (AI) Stock is a No-Brainer Buy Right Now\",\n          \"author\": \"newsfeedback@fool.com (Jeremy Bowman)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-05T11:30:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/05/1-mind-blowing-quote-that-shows-why-this-ai-stock/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767691/artificial-intelligence-ai-robot-big-data-bull-market-stock-chart-getty.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Should TCW Transform 500 ETF (VOTE) Be on Your Investing Radar?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T11:20:07Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235756/should-tcw-transform-500-etf-vote-be-on-your-investing-radar\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default18.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Is SoFi Select 500 ETF (SFY) a Strong ETF Right Now?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T11:20:07Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235760/is-sofi-select-500-etf-sfy-a-strong-etf-right-now\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default22.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Should Schwab 1000 Index ETF (SCHK) Be on Your Investing Radar?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T11:20:06Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235762/should-schwab-1000-index-etf-schk-be-on-your-investing-radar\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default24.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Is Schwab Fundamental U.S. Large Company Index ETF (FNDX) a Strong ETF Right Now?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T11:20:06Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235765/is-schwab-fundamental-us-large-company-index-etf-fndx-a-strong-etf-right-now\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default27.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Gene Munster Predicts Apple AI $33B A Year Opportunity, Draws Comparisons To This Stock From 2022\",\n          \"author\": \"Rounak Jain\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-05T11:19:59Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37476887/gene-munster-predicts-apple-ai-33b-a-year-opportunity-draws-comparisons-to-this-sto\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Apple_17.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Apple iPhone sales slump 24% in China to start the year, report says\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T11:03:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/chinese-smartphone-sales-fall-customers-unimpressed-by-apples-new-iphone-model-959e6150\",\n          \"image_url\": \"https://images.mktw.net/im-215383/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"4 Warren Buffett Stocks to Hold Forever\",\n          \"author\": \"newsfeedback@fool.com (Justin Pope)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-05T10:04:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/05/4-warren-buffett-stocks-to-hold-forever/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767293/aapl.png\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"10 top used sedan models you can get for under $30,000\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T10:03:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/10-top-used-sedan-models-you-can-get-for-under-30-000-1b76ee0e\",\n          \"image_url\": \"https://images.mktw.net/im-58291454/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"The new 2024 McLaren 750S: What’s under the hood, and how fast does it go? \",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T10:01:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/the-new-2024-mclaren-750s-whats-under-the-hood-and-how-fast-does-it-go-0240de66\",\n          \"image_url\": \"https://images.mktw.net/im-88754923/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Why Market Bubble Fears Might Be Overblown\",\n          \"author\": \"Investing.com\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-05T09:47:00Z\",\n          \"url\": \"https://www.investing.com/analysis/why-market-bubble-fears-might-be-overblown-200646547\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Company News for Mar 5, 2024\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T09:20:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235731/company-news-for-mar-5-2024\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/01/36484.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Apple: Vision Pro Marks Start Of Innovative AI Opportunities\",\n          \"author\": \"Khaveen Investments\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-05T08:20:39Z\",\n          \"url\": \"https://seekingalpha.com/article/4675875-apple-stock-vision-pro-marks-start-of-innovative-ai-opportunities\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/1705316992/image_1705316992.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"3 Major Trends Driving the Markets in March 2024\",\n          \"author\": \"The Tokenist\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-05T07:15:00Z\",\n          \"url\": \"https://www.investing.com/analysis/3-major-trends-driving-the-markets-in-march-2024-200646563\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"S&P 500 Ripe for Pullback as Bearish Rising Wedge Forms: Support Levels to Watch\",\n          \"author\": \"Michael Kramer\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-05T05:43:00Z\",\n          \"url\": \"https://www.investing.com/analysis/sp-500-ripe-for-pullback-as-bearish-rising-wedge-forms-support-levels-to-watch-200646561\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Nasdaq has gone more than 300 days without a major pullback. Does that mean a shakeout is overdue?\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-04T21:54:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/nasdaq-has-gone-more-than-300-days-without-a-major-pullback-does-that-mean-a-shakeout-is-overdue-a8afb112\",\n          \"image_url\": \"https://images.mktw.net/im-56028536/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Apple’s stock has had a sour 2024. This bull sees ‘a host of tailwinds’ ahead.\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-04T19:33:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/apples-stock-has-had-a-sour-2024-this-bull-sees-a-host-of-tailwinds-ahead-1d14aee0\",\n          \"image_url\": \"https://images.mktw.net/im-795052/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Stocks Stall, Magnificent 7 Crack As Apple, Tesla And Google Face Headwinds; Bitcoin, Gold Eye Record Highs: What's Driving Markets Monday?\",\n          \"author\": \"Piero Cingari\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-04T18:36:04Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37464820/stocks-stall-magnificent-7-crack-as-apple-tesla-and-google-face-headwinds-bitcoin-g\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Bull-and-bear-stock-market-index_8.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Two more stock indexes are on track for records: What that means for the bull market\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-04T18:32:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/two-more-stock-indexes-are-on-track-for-records-what-that-means-for-the-bull-market-044b8b21\",\n          \"image_url\": \"https://images.mktw.net/im-30026221/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"S&P 500’s breadth ‘still narrow’ after record peak — with these four stocks driving February gains\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-04T18:26:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/s-p-500s-breadth-still-narrow-after-record-peak-with-these-four-stocks-driving-february-gains-6fd60def\",\n          \"image_url\": \"https://images.mktw.net/im-508862/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"title\": \"Markets Wait for Key Jobs Data and Earnings Reports\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-04T15:51:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235426/markets-wait-for-key-jobs-data-and-earnings-reports\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/2f/502.jpg\",\n          \"sentiment\": null\n      }\n  ]\n}"
  },
  {
    "path": "tests/fixtures/api/news/MSFT_2024-03-01_2024-03-08.json",
    "content": "{\n  \"news\": [\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Surging Towards $1,000 Per Share, Is Nvidia Stock Overvalued?\",\n          \"author\": \"Taylor Irwin\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-08T22:49:27Z\",\n          \"url\": \"https://seekingalpha.com/article/4677097-surging-towards-1000-per-share-is-nvidia-stock-overvalued\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/1494623399/image_1494623399.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Stock Market Hits Record Highs As Powell Hints At Rate Cuts, Bitcoin And Gold Soar: This Week In The Markets\",\n          \"author\": \"Piero Cingari\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-08T21:19:21Z\",\n          \"url\": \"https://www.benzinga.com/markets/cryptocurrency/24/03/37569176/stock-market-hit-record-highs-as-powell-hints-at-rate-cuts-bitcoin-and-gold-soar-this-week\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Bull-And-Bear-Are-On-A-Graphic-With-Mark.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Complaints about stock market’s ‘bad breadth’ are overblown. Here’s why.\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-08T20:36:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/complaints-about-stock-markets-bad-breadth-are-overblown-heres-why-ef4e052e\",\n          \"image_url\": \"https://images.mktw.net/im-16255885/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Is It Time to Sell These 5 Overvalued Stocks?\",\n          \"author\": \"MarketBeat.com\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-08T20:34:00Z\",\n          \"url\": \"https://www.investing.com/analysis/is-it-time-to-sell-these-5-overvalued-stocks-200646679\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Billionaire David Tepper Slashed His Position in Nvidia. Here Are the Artificial Intelligence (AI) Stocks He Bought Instead.\",\n          \"author\": \"newsfeedback@fool.com (Keith Speights)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-08T19:41:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/08/billionaire-david-tepper-nvidia-ai-stocks/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768159/artificial-intelligence-investing-algorithms.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Mother Of All Reports Give Ammunition To Both Bull And Bears\",\n          \"author\": \"The Arora Report\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-08T15:47:21Z\",\n          \"url\": \"https://www.benzinga.com/markets/24/03/37561905/mother-of-all-reports-give-ammunition-to-both-bull-and-bears\",\n          \"image_url\": \"https://cdn.benzinga.com/files/chris-liverani-dbi_my696rk-unsplash_3_2.jpg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Investors have sold a record amount of tech stocks, says Bank of America\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-08T15:47:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/investors-have-drained-4-4-billion-from-tech-stocks-the-most-ever-says-bank-of-america-c3185d79\",\n          \"image_url\": \"https://images.mktw.net/im-77554719/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Nvidia Vs. Microsoft: 'Insane' Gains Aim Tech 'Beast' At No. 1 Market Cap Slot\",\n          \"author\": \"Neil Dennis\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-08T15:17:51Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37561048/nvidia-vs-microsoft-insane-gains-aim-tech-beast-at-no-1-market-cap-slot\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/nvidia-chip-shutter_2.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Brokers Suggest Investing in Microsoft (MSFT): Read This Before Placing a Bet\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-08T14:30:10Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237972/brokers-suggest-investing-in-microsoft-msft-read-this-before-placing-a-bet\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default26.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Should Goldman Sachs MarketBeta U.S. 1000 Equity ETF (GUSA) Be on Your Investing Radar?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-08T11:20:04Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237786/should-goldman-sachs-marketbeta-us-1000-equity-etf-gusa-be-on-your-investing-radar\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default24.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"The Bull Market Is Official: 1 Superb Artificial Intelligence (AI) Growth Stock to Buy Before the Nasdaq Soars Higher in 2024\",\n          \"author\": \"newsfeedback@fool.com (Danny Vena)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-08T10:04:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/08/the-bull-market-is-official-1-superb-artificial-in/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768127/a-robotic-hand-interacting-with-a-visual-ai-touchscreen-display.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Tracking David Tepper's Appaloosa Management Portfolio - Q4 2023 Update\",\n          \"author\": \"John Vincent\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-08T05:28:52Z\",\n          \"url\": \"https://seekingalpha.com/article/4676894-tracking-david-teppers-appaloosa-management-portfolio-q4-2023-update\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/489944299/image_489944299.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"2 High Growth Stocks to Buy After Earnings This Week\",\n          \"author\": \"Shaun Pruitt\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T21:36:00Z\",\n          \"url\": \"https://www.zacks.com/commentary/2237588/2-high-growth-stocks-to-buy-after-earnings-this-week\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/74/55762.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Do These 2 Stocks Belong in the Magnificent 7?\",\n          \"author\": \"Derek Lewis\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T20:28:00Z\",\n          \"url\": \"https://www.zacks.com/commentary/2237587/do-these-2-stocks-belong-in-the-magnificent-7\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/f8/14626.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Cognizant (CTSH), Microsoft Team Up to Streamline Healthcare\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T17:30:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237560/cognizant-ctsh-microsoft-team-up-to-streamline-healthcare\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/f0/33677.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Should iShares Russell 1000 ETF (IWB) Be on Your Investing Radar?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T11:20:06Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237070/should-ishares-russell-1000-etf-iwb-be-on-your-investing-radar\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default44.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Is iShares Paris-Aligned Climate MSCI USA ETF (PABU) a Strong ETF Right Now?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T11:20:05Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237079/is-ishares-paris-aligned-climate-msci-usa-etf-pabu-a-strong-etf-right-now\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default7.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"This 1 Chart Shows How Microsoft Became the Biggest \\\"Magnificent Seven\\\" Stock and World's Most Valuable Company\",\n          \"author\": \"newsfeedback@fool.com (Keith Noonan)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-07T10:40:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/07/chart-microsoft-biggest-magnificent-7-stock/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/766974/a-flaming-arrow-moving-up.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Zacks Investment Ideas feature highlights: Microsoft, Alphabet, Nasdaq 100 ETF and CBIZ\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T08:50:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237034/zacks-investment-ideas-feature-highlights-microsoft-alphabet-nasdaq-100-etf-and-cbiz\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/f4/59928.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Zacks Earnings Trends Highlights: Apple, Amazon, Microsoft, Meta and Nvidia\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T08:37:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237031/zacks-earnings-trends-highlights-apple-amazon-microsoft-meta-and-nvidia\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/23/489.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Looking Ahead to Q1 Earnings: What's Next?\",\n          \"author\": \"Sheraz Mian\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T00:50:00Z\",\n          \"url\": \"https://www.zacks.com/commentary/2237011/looking-ahead-to-q1-earnings-whats-next\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/03/23269.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Looking Ahead to Q1 Earnings: What's Next?\",\n          \"author\": \"Sheraz Mian\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T00:46:00Z\",\n          \"url\": \"https://www.zacks.com/commentary/2237013/looking-ahead-to-q1-earnings-whats-next\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/03/23269.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Microsoft (MSFT) Stock Slides as Market Rises: Facts to Know Before You Trade\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-06T22:45:19Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2236950/microsoft-msft-stock-slides-as-market-rises-facts-to-know-before-you-trade\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default16.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Stock market bubble? One key ingredient is still missing.\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T20:25:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/stock-market-bubble-one-key-ingredient-is-still-missing-579247fd\",\n          \"image_url\": \"https://images.mktw.net/im-632304/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"If ‘Magnificent Seven’s’ stock swings are stressing you, think about their bonds\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T19:03:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/magnificent-sevens-wild-stock-swings-keeping-you-awake-at-night-consider-their-bonds-86349071\",\n          \"image_url\": \"https://images.mktw.net/im-886882/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"ASML: Potentially The Biggest Winner Of The AI Arms Race\",\n          \"author\": \"Simple Investment Ideas\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-06T17:50:16Z\",\n          \"url\": \"https://seekingalpha.com/article/4676339-asml-potentially-the-biggest-winner-of-the-ai-arms-race\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/1651834688/image_1651834688.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Navigating the AI Frontier: Clues From Private Markets\",\n          \"author\": \"Andrew Rocco\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-06T17:30:00Z\",\n          \"url\": \"https://www.zacks.com/commentary/2236780/navigating-the-ai-frontier-clues-from-private-markets\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/d3/848.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Buying Ahead Of Powell On Hopium Key Apple Level, Bitcoin Whales Take Profits\",\n          \"author\": \"The Arora Report\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-06T17:03:36Z\",\n          \"url\": \"https://www.benzinga.com/24/03/37515021/buying-ahead-of-powell-on-hopium-key-apple-level-bitcoin-whales-take-profits\",\n          \"image_url\": \"https://cdn.benzinga.com/files/austin-distel-dfjjmvhwh_8-unsplash_3_6.jpg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Magnificent Seven no longer a monolith as performance continues to diverge\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T16:29:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/magnificent-seven-no-longer-a-monolith-as-performance-continues-to-diverge-ed03ebe6\",\n          \"image_url\": \"https://images.mktw.net/im-96498660/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Billionaire Jeff Bezos and Cathie Wood Joined Microsoft, Nvidia, Amazon, and OpenAI in Funding 1 of 2024's Most Intriguing AI Start-Ups\",\n          \"author\": \"newsfeedback@fool.com (Danny Vena)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T15:32:29Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/06/billionaire-jeff-bezos-cathie-wood-ai-start-up/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767986/ai-artificial-intelligence-powered-robots-sitting-at-a-conference-room-table-typing-on-laptops.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Is Nvidia Doomed to be the Next Cisco? What Investors Should Know\",\n          \"author\": \"newsfeedback@fool.com (Geoffrey Seiler)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T15:31:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/06/is-nvidia-doomed-to-be-the-next-cisco-what-investo/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/766997/microchip-technology-computer-chip-data-processing.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"BOX Extends Microsoft Tie-Up, Boosts Generative AI Efforts\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-06T15:11:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2236759/box-extends-microsoft-tie-up-boosts-generative-ai-efforts\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/f9/1872.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Amazon (AMZN) to Boost Reach in APAC With Saudi Arabia Region\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-06T15:08:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2236757/amazon-amzn-to-boost-reach-in-apac-with-saudi-arabia-region\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/a4/1882.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"1 Brilliant Strategy That Could Supercharge Microsoft's Artificial Intelligence (AI) Revenue\",\n          \"author\": \"newsfeedback@fool.com (Danny Vena)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T14:15:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/06/1-brilliant-strategy-could-supercharge-microsoft/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767750/a-persons-hand-cupped-open-with-a-hologram-of-an-ai-chatbot-above-saying-hi-can-i-help-you.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Take-Two (TTWO) Introduces Exciting New Editions of WWE 2K24\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-06T12:43:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2236480/take-two-ttwo-introduces-exciting-new-editions-of-wwe-2k24\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/8e/2280.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Stock Market News for Mar 6, 2024\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-06T11:29:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2236441/stock-market-news-for-mar-6-2024\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/7f/511.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Is FlexShares Morningstar U.S. Market Factor Tilt ETF (TILT) a Strong ETF Right Now?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-06T11:20:05Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2236437/is-flexshares-morningstar-us-market-factor-tilt-etf-tilt-a-strong-etf-right-now\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default9.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Should You Buy the Worst-Performing \\\"Magnificent Seven\\\" Stock of the Past Decade?\",\n          \"author\": \"newsfeedback@fool.com (Prosper Junior Bakiny)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T11:16:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/06/should-you-buy-the-worst-performing-magnificent-se/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767225/person-working-at-a-desk.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Microsoft's Path to Becoming the Largest Company in the World, Explained in One Chart.\",\n          \"author\": \"newsfeedback@fool.com (Jake Lerch)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T10:25:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/06/microsofts-path-to-becoming-the-largest-company-in/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767814/msft-microsoft-stock-investing-money-market.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"1 \\\"Magnificent Seven\\\" Stock to Buy Hand Over Fist in March, and 1 to Avoid Like the Plague\",\n          \"author\": \"newsfeedback@fool.com (Sean Williams)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T10:06:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/06/1-magnificent-seven-stock-buy-in-march-1-to-avoid/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767782/buy-sell-dice-on-financial-graph-stock-market-getty.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Tech investor Jason Palmer vows to deliver ‘conscious capitalism’ in his presidential bid\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T08:03:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/tech-investor-jason-palmer-vows-to-deliver-conscious-capitalism-in-his-presidential-bid-0d95bae0\",\n          \"image_url\": \"https://images.mktw.net/im-02757020/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Campbell Soup, Box And 3 Stocks To Watch Heading Into Wednesday\",\n          \"author\": \"Avi Kapoor\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-06T07:28:17Z\",\n          \"url\": \"https://www.benzinga.com/news/earnings/24/03/37502472/campbell-soup-box-and-3-stocks-to-watch-heading-into-wednesday\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/03/06/campbell_soup_-_logo.jpg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"OpenAI says Elon Musk backed for-profit plans, and that it has emails proving it\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T04:40:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/openai-says-elon-musk-backed-for-profit-plans-and-that-it-has-emails-proving-it-25ca0b53\",\n          \"image_url\": \"https://images.mktw.net/im-51996667/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Nvidia: Time To Admit My Big Mistake (Rating Upgrade)\",\n          \"author\": \"Dair Sansyzbayev\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-06T03:44:47Z\",\n          \"url\": \"https://seekingalpha.com/article/4676178-nvidia-stock-time-to-admit-big-mistake-upgrade-buy\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/1494623399/image_1494623399.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Box posts first $1 billion fiscal year as AI sales pick up\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T01:16:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/software-firm-box-posts-first-1-billion-fiscal-year-as-ai-sales-pick-up-a18bd073\",\n          \"image_url\": \"https://images.mktw.net/im-20418518/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"‘Magnificent Seven’ shed $233 billion in market cap, dragging down stock market\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T22:14:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/magnificent-seven-shed-233-billion-in-market-cap-dragging-down-the-stock-market-d71f4f68\",\n          \"image_url\": \"https://images.mktw.net/im-19977052/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Box Q4 Earnings: Revenue Miss, EPS Beat, Expanded AI Partnership With Microsoft\",\n          \"author\": \"Adam Eckert\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-05T21:45:52Z\",\n          \"url\": \"https://www.benzinga.com/news/earnings/24/03/37495817/box-q4-earnings-revenue-miss-eps-beat-expanded-ai-partnership-with-microsoft\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/chart_4.png?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Dow Jones ends down over 400 points and Nasdaq suffers worst day in 3 weeks as Apple shares slump\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T21:32:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/s-p-500-futures-dip-as-traders-wary-of-rally-exhaustion-and-apple-eyes-4-month-low-e11d85a8\",\n          \"image_url\": \"https://images.mktw.net/im-252190/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"The Magnificent 7 Are Falling Like Dominos; Only 3 Remain\",\n          \"author\": \"Beth Kindig\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-05T17:36:28Z\",\n          \"url\": \"https://seekingalpha.com/article/4675980-the-magnificent-7-are-falling-like-dominos-only-3-remain\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/2041838046/image_2041838046.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"'Apple At A Crossroads' Analyst Says: Company Must Step Up On AI Or Get Left Behind\",\n          \"author\": \"Neil Dennis\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-05T17:02:54Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37488304/apple-at-a-crossroads-analyst-says-company-must-step-up-on-ai-or-get-left-behind\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Apple-vision-pro_2.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Apple’s stock is in a ‘funk.’ Could Warren Buffett be selling more shares?\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T17:00:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/apples-stock-is-in-a-funk-could-warren-buffett-be-selling-more-shares-116a083e\",\n          \"image_url\": \"https://images.mktw.net/im-87117296/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Is PayPal Set for a Glow-Up?\",\n          \"author\": \"newsfeedback@fool.com (Motley Fool Staff)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-05T16:55:11Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/05/is-paypal-set-for-a-glow-up/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/766848/mfm_21-copy.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Alphabet (GOOGL) Enhances Google Maps With New Feature\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T16:46:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2236149/alphabet-googl-enhances-google-maps-with-new-feature\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/aa/397.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Union Pacific And Alphabet On CNBC's 'Final Trades'\",\n          \"author\": \"Avi Kapoor\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-05T13:24:57Z\",\n          \"url\": \"https://www.benzinga.com/trading-ideas/long-ideas/24/03/37478507/union-pacific-and-alphabet-on-cnbcs-final-trades\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/03/05/stock_chart_up.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Oracle (ORCL) Makes its Autonomous Database Generally Available\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T12:52:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235836/oracle-orcl-makes-its-autonomous-database-generally-available\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/79/525.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"1 Mind-Blowing Quote That Shows Why This Artificial Intelligence (AI) Stock is a No-Brainer Buy Right Now\",\n          \"author\": \"newsfeedback@fool.com (Jeremy Bowman)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-05T11:30:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/05/1-mind-blowing-quote-that-shows-why-this-ai-stock/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767691/artificial-intelligence-ai-robot-big-data-bull-market-stock-chart-getty.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Should TCW Transform 500 ETF (VOTE) Be on Your Investing Radar?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T11:20:07Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235756/should-tcw-transform-500-etf-vote-be-on-your-investing-radar\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default18.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Is SoFi Select 500 ETF (SFY) a Strong ETF Right Now?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T11:20:07Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235760/is-sofi-select-500-etf-sfy-a-strong-etf-right-now\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default22.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Should Schwab 1000 Index ETF (SCHK) Be on Your Investing Radar?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T11:20:06Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235762/should-schwab-1000-index-etf-schk-be-on-your-investing-radar\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default24.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Is Schwab Fundamental U.S. Large Company Index ETF (FNDX) a Strong ETF Right Now?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T11:20:06Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235765/is-schwab-fundamental-us-large-company-index-etf-fndx-a-strong-etf-right-now\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default27.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Gene Munster Predicts Apple AI $33B A Year Opportunity, Draws Comparisons To This Stock From 2022\",\n          \"author\": \"Rounak Jain\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-05T11:19:59Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37476887/gene-munster-predicts-apple-ai-33b-a-year-opportunity-draws-comparisons-to-this-sto\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Apple_17.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Is AMD Stock a Buy?\",\n          \"author\": \"newsfeedback@fool.com (Dani Cook)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-05T10:45:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/05/is-amd-stock-a-buy/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767555/headquarters-with-amd-logo-on-building_amd_advance.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"1 No-Brainer Artificial Intelligence (AI) Stock to Buy With $40 and Hold for 10 Years\",\n          \"author\": \"newsfeedback@fool.com (Anthony Di Pizio)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-05T10:29:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/05/1-no-brainer-ai-stock-buy-with-40-hold-for-10-year/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767613/a-digital-render-of-a-computer-chip-with-ai-inscribed-in-the-center-on-a-blue-background.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Why Market Bubble Fears Might Be Overblown\",\n          \"author\": \"Investing.com\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-05T09:47:00Z\",\n          \"url\": \"https://www.investing.com/analysis/why-market-bubble-fears-might-be-overblown-200646547\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Dell Just Gave Nvidia and AMD Investors Something to Cheer -- and Amazon, Microsoft, and Google Investors Something to Perhaps Fear\",\n          \"author\": \"newsfeedback@fool.com (Keith Speights)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-05T09:05:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/05/dell-nvidia-amd-amazon-microsoft-google-investors/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767830/surprised-young-woman-laptop.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"3 Major Trends Driving the Markets in March 2024\",\n          \"author\": \"The Tokenist\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-05T07:15:00Z\",\n          \"url\": \"https://www.investing.com/analysis/3-major-trends-driving-the-markets-in-march-2024-200646563\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"AI boom in data centers has top tech companies spending more than major oil companies on capex\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T00:08:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/ai-boom-in-data-centers-has-top-tech-companies-spending-more-than-major-oil-companies-on-capex-7639ad2a\",\n          \"image_url\": \"https://images.mktw.net/im-93575517/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Nvidia and These Tech Giants Just Invested in This Artificial Intelligence (AI) and Robotics Start-Up\",\n          \"author\": \"newsfeedback@fool.com (Jose Najarro)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-04T22:10:04Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/04/nvidia-and-these-tech-giants-just-invested-in-this/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768008/nvidia-headquarters-with-nvidia-sign-in-front.png\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Two more stock indexes are on track for records: What that means for the bull market\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-04T18:32:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/two-more-stock-indexes-are-on-track-for-records-what-that-means-for-the-bull-market-044b8b21\",\n          \"image_url\": \"https://images.mktw.net/im-30026221/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"S&P 500’s breadth ‘still narrow’ after record peak — with these four stocks driving February gains\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-04T18:26:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/s-p-500s-breadth-still-narrow-after-record-peak-with-these-four-stocks-driving-february-gains-6fd60def\",\n          \"image_url\": \"https://images.mktw.net/im-508862/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"This Billionaire Is Selling AI Stocks, Including Nvidia\",\n          \"author\": \"newsfeedback@fool.com (Travis Hoium)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-04T16:30:19Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/04/this-billionaire-is-selling-ai-stocks-including-nv/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767758/ai-image.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Alphabet (GOOGL) to Boost Chromebook Features With App Mall\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-04T15:49:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235423/alphabet-googl-to-boost-chromebook-features-with-app-mall\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/aa/397.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"This Billionaire Investor Has 21% of His Portfolio Invested in These 2 Artificial Intelligence Growth Stocks\",\n          \"author\": \"newsfeedback@fool.com (Justin Pope)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-04T15:45:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/04/this-billionaire-investor-has-21-of-his-portfolio/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767275/msft-1.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Google Is 'Down, But Not Out': Alphabet Analyst On GenAI Gap With Microsoft And OpenAI, Potential Dividend Move\",\n          \"author\": \"Surbhi Jain\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-04T15:21:12Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37459165/google-is-down-but-not-out-closing-the-genai-gap-with-microsoft-and-openai-potentia\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Google_12.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Goldman Sachs says this tech stock rally is grounded in reality\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-04T14:24:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/goldman-sachs-says-this-tech-stock-rally-is-grounded-in-reality-9ee03ea6\",\n          \"image_url\": \"https://images.mktw.net/im-55738041/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Microsoft Corporation (MSFT) Is a Trending Stock: Facts to Know Before Betting on It\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-04T14:00:15Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235203/microsoft-corporation-msft-is-a-trending-stock-facts-to-know-before-betting-on-it\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default17.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"5 Solid Tech Stocks to Buy as Nasdaq Hits All-Time High\",\n          \"author\": \"Ritujay Ghosh\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-04T13:49:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235175/5-solid-tech-stocks-to-buy-as-nasdaq-hits-all-time-high\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/b3/738.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"S&P 500 to Keep Shining? These March Patterns Forecast More Positive Returns Ahead\",\n          \"author\": \"Investing.com\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-04T12:48:00Z\",\n          \"url\": \"https://www.investing.com/analysis/sp-500-to-keep-shining-these-march-patterns-forecast-more-positive-returns-ahead-200646532\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Meet the Best Dividend Stock Among the \\\"Magnificent Seven\\\"\",\n          \"author\": \"newsfeedback@fool.com (Prosper Junior Bakiny)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-04T11:47:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/04/meet-the-best-dividend-stock-among-the-magnificent/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767429/person-sitting-at-a-desk-looking-at-two-monitors.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"MongoDB (MDB) to Report Q4 Earnings: What's in the Offing?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-04T11:25:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235058/mongodb-mdb-to-report-q4-earnings-whats-in-the-offing\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/10/2241.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Should Invesco Dividend Achievers ETF (PFM) Be on Your Investing Radar?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-04T11:20:06Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235051/should-invesco-dividend-achievers-etf-pfm-be-on-your-investing-radar\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default3.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Is It Too Late to Buy Snowflake Stock?\",\n          \"author\": \"newsfeedback@fool.com (Leo Sun)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-04T11:05:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/04/is-it-too-late-to-buy-snowflake-stock/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767598/digital-snowflake-circuit.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Nvidia, Meta, Microsoft And Amazon Among Dan Niles' Top Picks For 2024 Amid AI Bubble Concerns: 'We Have A Lot More Room To Go'\",\n          \"author\": \"Benzinga Neuro\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-04T07:05:54Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37448284/nvidia-meta-microsoft-and-amazon-among-dan-niles-top-picks-for-2024-amid-ai-bubble-\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Stocks-Photo-by-Pixels-Hunter-on-Shutter_2.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"These Five Small-Cap Stocks Under The Radar Are Expected To Surge, Says Stock Researcher\",\n          \"author\": \"Bibhu Pattnaik\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-03T19:57:14Z\",\n          \"url\": \"https://www.benzinga.com/news/24/03/37445451/these-five-small-cap-stocks-under-the-radar-are-expected-to-surge-says-stock-researcher\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/03/03/shutterstock_131733137.jpg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Billionaire Dan Loeb Has 27% of His Third Point Portfolio in 3 Brilliant \\\"Magnificent Seven\\\" Stocks, but Completely Sold Out of Another\",\n          \"author\": \"newsfeedback@fool.com (Danny Vena)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-03T18:08:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/03/billionaire-dan-loeb-has-27-of-his-third-point-por/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767130/a-person-studying-a-see-through-display-of-various-charts-and-graphs.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"3 Top Dividend Stocks That Could Deliver Record Payouts In 2024\",\n          \"author\": \"newsfeedback@fool.com (Daniel Foelber, Scott Levine, and Lee Samaha)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-03T12:15:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/03/3-top-dividend-stocks-could-deliver-record-payouts/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767339/gettyimages-1386479288-1200x800-5b2df79.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Better Tech Stock: Alphabet vs. Microsoft\",\n          \"author\": \"newsfeedback@fool.com (Dani Cook)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-03T11:15:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/03/better-tech-stock-alphabet-vs-microsoft/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767446/consulting-on-finances-at-home.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Expect this ‘Roaring 2020s’ market to keep stocks up and bring inflation down\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-02T17:53:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/stocks-are-heading-up-and-inflation-is-coming-down-as-this-roaring-2020s-market-rolls-on-f9157a69\",\n          \"image_url\": \"https://images.mktw.net/im-91748496/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"How the U.S. economy could slide into a Japan-like ‘lost decade’\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-02T16:44:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/why-the-u-s-economy-could-repeat-japans-lost-decade-30e9227b\",\n          \"image_url\": \"https://images.mktw.net/im-690325/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Microsoft hasn’t been worth this much more than Apple since 2003\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-02T14:15:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/microsoft-hasnt-been-worth-this-much-more-than-apple-since-2003-8c2583e0\",\n          \"image_url\": \"https://images.mktw.net/im-97239857/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"How To Invest In A SWAN Portfolio: The Near Perfect Portfolio Strategy\",\n          \"author\": \"Financially Free Investor\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-02T14:00:00Z\",\n          \"url\": \"https://seekingalpha.com/article/4674395-how-to-invest-in-a-swan-portfolio-the-near-perfect-portfolio-strategy\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/1061700868/image_1061700868.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"‘GRANOLAS’ vs. the Magnificent Seven: Which should you dig into now?\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-02T13:41:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/granolas-vs-the-magnificent-seven-which-should-you-dig-into-now-79d2aaf5\",\n          \"image_url\": \"https://images.mktw.net/im-825291/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Microsoft Stock Has 17% Upside, According to 1 Wall Street Analyst\",\n          \"author\": \"newsfeedback@fool.com (John Ballard)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-02T13:10:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/02/microsoft-stock-17-upside-wall-street-analyst/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767481/microsoft-logo.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"AI, software and semiconductor stocks are too popular for their own good\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-02T12:36:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/most-ai-software-and-semiconductor-stocks-are-a-crowded-trade-right-now-79782d90\",\n          \"image_url\": \"https://images.mktw.net/im-872777/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Will Apple create a better AI? Questions abound as Tim Cook hints at what’s to come.\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-02T12:15:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/will-apple-create-a-better-ai-questions-abound-as-tim-cook-hints-at-whats-to-come-d20c69f6\",\n          \"image_url\": \"https://images.mktw.net/im-792683/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Is Microsoft a Top Artificial Intelligence (AI) Investment?\",\n          \"author\": \"newsfeedback@fool.com (Keithen Drury)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-02T12:15:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/02/is-microsoft-a-top-artificial-intelligence-ai-inve/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/766948/person-looking-at-data-on-computer-screens.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"My 4% Yielding Retirement Portfolio\",\n          \"author\": \"Dividend Sensei\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-02T12:00:00Z\",\n          \"url\": \"https://seekingalpha.com/article/4674992-4-percent-yielding-retirement-portfolio\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/1291620275/image_1291620275.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Billionaires David Tepper and George Soros Both Own These \\\"Magnificent Seven\\\" Stocks. Should You?\",\n          \"author\": \"newsfeedback@fool.com (Keith Speights)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-02T10:49:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/02/david-tepper-george-soros-magnificent-7-stocks/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767092/data-center-infrastructure-information-technology.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"AI is fueling a gold rush in new data centers, the hottest buildings in real estate\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-02T10:44:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/ai-is-fueling-a-gold-rush-in-new-data-centers-the-hottest-buildings-in-real-estate-e2cd13b3\",\n          \"image_url\": \"https://images.mktw.net/im-62406754/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"title\": \"Salesforce: Einstein Can Be Trusted, Thanks To Data Cloud\",\n          \"author\": \"Vinay Utham, CFA\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-02T10:41:26Z\",\n          \"url\": \"https://seekingalpha.com/article/4675454-salesforce-einstein-can-be-trusted-thanks-to-data-cloud\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/53177428/image_53177428.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      }\n  ]\n}"
  },
  {
    "path": "tests/fixtures/api/news/TSLA_2024-03-01_2024-03-08.json",
    "content": "{\n  \"news\": [\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Why Rivian Was the 1 EV Stock That Didn't Drop This Week\",\n          \"author\": \"newsfeedback@fool.com (Travis Hoium)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-08T22:55:43Z\",\n          \"url\": \"https://www.fool.com/how-to-invest/thirteen-steps/2024/03/08/why-rivian-was-the-1-ev-stock-that-didnt-drop-this/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768657/car-being-charged-outside.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Complaints about stock market’s ‘bad breadth’ are overblown. Here’s why.\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-08T20:36:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/complaints-about-stock-markets-bad-breadth-are-overblown-heres-why-ef4e052e\",\n          \"image_url\": \"https://images.mktw.net/im-16255885/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Why This Analyst Halves Lucid Group's Price Forecast Despite 12%-23% Higher Tech Efficiency Than Tesla\",\n          \"author\": \"Nabaparna Bhattacharya\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-08T18:08:50Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37565512/why-this-analyst-halves-lucid-groups-price-forecast-despite-12-23-higher-tech-effic\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Lucid_2.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Why Tesla Stock Fell This Week\",\n          \"author\": \"newsfeedback@fool.com (Brett Schafer)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-08T15:59:19Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/08/why-tesla-stock-fell-this-week/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768575/dissapointed-2.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Mother Of All Reports Give Ammunition To Both Bull And Bears\",\n          \"author\": \"The Arora Report\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-08T15:47:21Z\",\n          \"url\": \"https://www.benzinga.com/markets/24/03/37561905/mother-of-all-reports-give-ammunition-to-both-bull-and-bears\",\n          \"image_url\": \"https://cdn.benzinga.com/files/chris-liverani-dbi_my696rk-unsplash_3_2.jpg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Investors have sold a record amount of tech stocks, says Bank of America\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-08T15:47:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/investors-have-drained-4-4-billion-from-tech-stocks-the-most-ever-says-bank-of-america-c3185d79\",\n          \"image_url\": \"https://images.mktw.net/im-77554719/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Unfortunate News for Tesla Stock Investors\",\n          \"author\": \"newsfeedback@fool.com (Parkev Tatevosian, CFA)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-08T15:08:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/08/unfortunate-news-for-tesla-stock-investors/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768476/tesla-building-with-tesla-logo-and-two-teslas-in-front.png\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Rivian looks like it’s trying to get Apple’s attention\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-08T14:50:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/rivian-looks-like-its-trying-to-get-apples-attention-bad24bbb\",\n          \"image_url\": \"https://images.mktw.net/im-08242973/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Here’s what 100 years of history shows about periods of extreme market concentration, according to Goldman Sachs\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-08T13:40:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/heres-what-100-years-of-history-shows-about-periods-of-extreme-market-concentration-according-to-goldman-sachs-340ca243\",\n          \"image_url\": \"https://images.mktw.net/im-65097578/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Fund Manager Substantially Reduces Tesla Holding, Citing 2 Reasons That Undermine His Positive View\",\n          \"author\": \"Shanthi Rexaline\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-08T13:32:27Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37558029/fund-manager-substantially-reduces-tesla-holding-citing-2-reasons-that-undermine-hi\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Paris--France---November-29--2014-Tesla-_2.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Zacks Investment Ideas feature highlights: Apple, Tesla, HCA Healthcare, DaVita and The Progressive\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-08T11:00:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237768/zacks-investment-ideas-feature-highlights-apple-tesla-hca-healthcare-davita-and-the-progressive\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/7c/572.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"2 \\\"Magnificent Seven\\\" Stocks to Buy Hand Over Fist in March\",\n          \"author\": \"newsfeedback@fool.com (Jose Najarro)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-08T10:31:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/08/2-magnificent-seven-stocks-to-buy-hand-over-fist-i/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768450/tesla-gigafactory-in-shanghai-with-tesla-logo.png\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla Is Great. Here's Why You Shouldn't Buy It\",\n          \"author\": \"newsfeedback@fool.com (Neil Patel)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-08T10:26:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/08/tesla-is-great-heres-why-you-shouldnt-buy-it/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768027/tesla-car-at-super-charger-station.png\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Broadcom’s stock falls, with AI set for stronger showing as other areas sag\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-08T01:22:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/broadcoms-stock-has-ridden-the-ai-wave-but-its-cooling-after-earnings-91ff1878\",\n          \"image_url\": \"https://images.mktw.net/im-33452279/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla (TSLA) Laps the Stock Market: Here's Why\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T22:45:18Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2237624/tesla-tsla-laps-the-stock-market-heres-why\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default0.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Do These 2 Stocks Belong in the Magnificent 7?\",\n          \"author\": \"Derek Lewis\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T20:28:00Z\",\n          \"url\": \"https://www.zacks.com/commentary/2237587/do-these-2-stocks-belong-in-the-magnificent-7\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/f8/14626.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Apple Vs. Nvidia: Only 1 Adds $1 Trillion In 67 Days In Battle For Mega-Cap Dominance\",\n          \"author\": \"Neil Dennis\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-07T20:20:02Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37544925/apple-vs-nvidia-only-one-added-1-trillion-in-67-days-as-battle-for-mega-cap-dominan\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/nvidia-shutter6_2.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Rivian unveils its new R2 EV. Here’s what you need to know.\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-07T18:42:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/heres-what-the-rivian-r2-will-cost-750b389b\",\n          \"image_url\": \"https://images.mktw.net/im-39590092/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"S&P 500 Reclaims All-Time Highs As Powell Hints At Rate Cuts, Nvidia Breaks $900, Bitcoin Nears Record Levels: What's Driving Market Thursday?\",\n          \"author\": \"Piero Cingari\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-07T18:32:36Z\",\n          \"url\": \"https://www.benzinga.com/markets/cryptocurrency/24/03/37542707/s-p-500-reclaims-all-time-highs-as-powell-hints-at-rate-cuts-nvidia-breaks-900-bitcoin-nea\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Wall-Street-bull_4.png?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Market Leaders Rolling Over: Time to Rotate into Defensive Stocks?\",\n          \"author\": \"Ethan Feller\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T17:25:00Z\",\n          \"url\": \"https://www.zacks.com/commentary/2237542/market-leaders-rolling-over-time-to-rotate-into-defensive-stocks\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/b6/2622.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Wall Street is divided on whether bitcoin is ‘frothy,’ and  will undo Fed’s rate-cut plans\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-07T14:37:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/wall-street-is-divided-on-whether-bitcoin-froth-will-undo-feds-rate-cut-plans-6826c116\",\n          \"image_url\": \"https://images.mktw.net/im-02780123/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Rivian’s stock dubbed a buy as EV company comes closest to Tesla’s ‘spirit’\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-07T14:05:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/rivians-stock-dubbed-a-buy-as-ev-company-comes-closest-to-teslas-spirit-3e115ba1\",\n          \"image_url\": \"https://images.mktw.net/im-72885181/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla Vs. BYD: Is the Chinese Powerhouse the Better EV Bet Going Forward?\",\n          \"author\": \"Investing.com\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-07T13:52:00Z\",\n          \"url\": \"https://www.investing.com/analysis/tesla-vs-byd-is-the-chinese-powerhouse-the-better-ev-bet-going-forward-200646640\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla Stock Set To Lose For 4th Straight Day? Fund Manager Warns Wall Street May Slash Q1 Delivery Estimates Soon\",\n          \"author\": \"Shanthi Rexaline\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-07T12:14:10Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37531135/tesla-stock-set-to-lose-for-4th-straight-day-fund-manager-warns-wall-street-may-sla\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Tesla-stock_10.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Things Just Went From Bad to Worse for General Motors and Self-Driving Start-Up Cruise. Here's Why I Think Tesla Is the Big Winner\",\n          \"author\": \"newsfeedback@fool.com (Adam Spatacco)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-07T11:55:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/07/things-just-went-from-bad-to-worse-for-general-mot/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767675/gettyimages-1349375133.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Nasdaq 100: Today's Move to Decide If Correction Is Starting - Watch This Level\",\n          \"author\": \"Michael Kramer\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-07T07:22:00Z\",\n          \"url\": \"https://www.investing.com/analysis/nasdaq-100-todays-move-to-decide-if-correction-is-starting--watch-this-level-200646622\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"ChargePoint: It's Only Getting Worse\",\n          \"author\": \"Bill Maurer\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-07T05:44:43Z\",\n          \"url\": \"https://seekingalpha.com/article/4676517-chargepoint-stock-q4-earnings-getting-worse\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/104187060/image_104187060.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Cathie Wood's Ark Invest Sells Over $48M Worth Of Coinbase Shares Amid Steaming Bitcoin Rally — Seizes The Tesla Dip\",\n          \"author\": \"Benzinga Neuro\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-07T03:20:22Z\",\n          \"url\": \"https://www.benzinga.com/markets/equities/24/03/37526043/cathie-woods-ark-invest-sells-over-48m-worth-of-coinbase-shares-amid-steaming-bitcoin-rally-seiz\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/cathie-wood-bz-ark_7.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Looking Ahead to Q1 Earnings: What's Next?\",\n          \"author\": \"Sheraz Mian\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T00:50:00Z\",\n          \"url\": \"https://www.zacks.com/commentary/2237011/looking-ahead-to-q1-earnings-whats-next\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/03/23269.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Looking Ahead to Q1 Earnings: What's Next?\",\n          \"author\": \"Sheraz Mian\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-07T00:46:00Z\",\n          \"url\": \"https://www.zacks.com/commentary/2237013/looking-ahead-to-q1-earnings-whats-next\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/03/23269.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla is not one of the 10 largest U.S. companies for first time in 13 months\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T22:43:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/tesla-is-not-one-of-the-10-largest-u-s-companies-for-first-time-in-13-months-152f21cb\",\n          \"image_url\": \"https://images.mktw.net/im-807593/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"With NVDA in the Driver's Seat, What Will the Nasdaq 100 Do Next?\",\n          \"author\": \"MoneyShow\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-06T20:25:00Z\",\n          \"url\": \"https://www.investing.com/analysis/with-nvda-in-the-drivers-seat-what-will-the-nasdaq-100-do-next-200646619\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"If ‘Magnificent Seven’s’ stock swings are stressing you, think about their bonds\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T19:03:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/magnificent-sevens-wild-stock-swings-keeping-you-awake-at-night-consider-their-bonds-86349071\",\n          \"image_url\": \"https://images.mktw.net/im-886882/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"A Tesla bull turns bearish, and warns EV maker could lose money this year\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T17:07:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/a-tesla-bull-turns-bearish-and-warns-ev-maker-could-lose-money-this-year-2095776b\",\n          \"image_url\": \"https://images.mktw.net/im-77677160/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Buying Ahead Of Powell On Hopium Key Apple Level, Bitcoin Whales Take Profits\",\n          \"author\": \"The Arora Report\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-06T17:03:36Z\",\n          \"url\": \"https://www.benzinga.com/24/03/37515021/buying-ahead-of-powell-on-hopium-key-apple-level-bitcoin-whales-take-profits\",\n          \"image_url\": \"https://cdn.benzinga.com/files/austin-distel-dfjjmvhwh_8-unsplash_3_6.jpg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Magnificent Seven no longer a monolith as performance continues to diverge\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T16:29:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/magnificent-seven-no-longer-a-monolith-as-performance-continues-to-diverge-ed03ebe6\",\n          \"image_url\": \"https://images.mktw.net/im-96498660/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Elon Musk says he’s not donating to Trump after they reportedly met about potential contribution\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T15:43:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/donald-trump-met-with-elon-musk-seeking-campaign-donation-reports-24013090\",\n          \"image_url\": \"https://images.mktw.net/im-58077718/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"There’s one big mystery in the everything rally: gold’s record-setting ascent\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T13:34:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/theres-one-big-mystery-in-the-everything-rally-golds-record-setting-ascent-13775d75\",\n          \"image_url\": \"https://images.mktw.net/im-65082598/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Will the R2 Unveiling on March 7 Be the Saving Grace for Rivian Investors?\",\n          \"author\": \"newsfeedback@fool.com (Daniel Foelber)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T12:22:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/06/will-r2-unveiling-on-march-7-save-rivian-stock/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768052/ev-electric-vehicle-charging-charge-port-1201x800-5b2df79.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Should You Buy the Worst-Performing \\\"Magnificent Seven\\\" Stock of the Past Decade?\",\n          \"author\": \"newsfeedback@fool.com (Prosper Junior Bakiny)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T11:16:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/06/should-you-buy-the-worst-performing-magnificent-se/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767225/person-working-at-a-desk.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Nvidia, Microsoft, and Jeff Bezos Invested in a $2.6 Billion Robotics Start-Up. Here's Why That's Good News for Tesla.\",\n          \"author\": \"newsfeedback@fool.com (Adam Spatacco)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T11:15:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/06/nvidia-microsoft-and-jeff-bezos-invested-in-a/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767695/gettyimages-1349338733.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Zacks Investment Ideas feature highlights: Perion Networks, Honda, Tesla and KKR\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-06T10:36:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2236403/zacks-investment-ideas-feature-highlights-perion-networks-honda-tesla-and-kkr\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/b9/441.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Nvidia and 7 Other Semiconductor Stocks That Can Benefit From Artificial Intelligence (AI) Robots\",\n          \"author\": \"newsfeedback@fool.com (Jose Najarro)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T10:30:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/06/nvidia-and-7-other-semiconductor-stocks-that-can-b/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768136/microchip-technology-computer-chip-data-processing.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"1 \\\"Magnificent Seven\\\" Stock to Buy Hand Over Fist in March, and 1 to Avoid Like the Plague\",\n          \"author\": \"newsfeedback@fool.com (Sean Williams)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T10:06:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/06/1-magnificent-seven-stock-buy-in-march-1-to-avoid/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767782/buy-sell-dice-on-financial-graph-stock-market-getty.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"The 2024 GMC Hummer EV SUV: Reimagined, futuristic, luxurious and an off-road champ\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T10:02:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/the-2024-gmc-hummer-ev-suv-reimagined-futuristic-luxurious-and-an-off-road-champ-ea8335b7\",\n          \"image_url\": \"https://images.mktw.net/im-75147015/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Jim Cramer Weighs In On Tuesday Market Decline, Says It Reflects A Market Top, Not A Bubble\",\n          \"author\": \"Benzinga Neuro\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-06T08:50:24Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37503243/jim-cramer-weighs-in-on-tuesday-market-decline-says-it-reflects-a-market-top-not-a-\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/jim-cramer-shutterstock_19.png?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla Bull Says EV Giant Is Going Through 'Brutal Transition,' Blames This For 80% Of Stock Sell-Off\",\n          \"author\": \"Shanthi Rexaline\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-06T07:07:54Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37502605/tesla-bull-says-ev-giant-is-going-through-brutal-transition-blames-this-for-80-of-s\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Elon-Musk--Tesla-illustration_3.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"OpenAI says Elon Musk backed for-profit plans, and that it has emails proving it\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-06T04:40:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/openai-says-elon-musk-backed-for-profit-plans-and-that-it-has-emails-proving-it-25ca0b53\",\n          \"image_url\": \"https://images.mktw.net/im-51996667/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Why Investors Slammed the Brakes on Tesla Stock Today\",\n          \"author\": \"newsfeedback@fool.com (Eric Volkman)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-06T04:33:28Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/05/why-investors-slammed-the-brakes-on-tesla-stock-to/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768174/red-traffic-light.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla Faces Uphill Battle: Morgan Stanley Analyst Adam Jonas Cuts Target Price, Forecasts Lower FY24 Sales Volume\",\n          \"author\": \"Anan Ashraf\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-06T03:57:19Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37501204/tesla-faces-uphill-battle-morgan-stanley-analyst-adam-jonas-cuts-target-price-forec\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/San-Francisco--Ca---June-3--2019-Tesla-A.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Nvidia: Time To Admit My Big Mistake (Rating Upgrade)\",\n          \"author\": \"Dair Sansyzbayev\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-06T03:44:47Z\",\n          \"url\": \"https://seekingalpha.com/article/4676178-nvidia-stock-time-to-admit-big-mistake-upgrade-buy\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/1494623399/image_1494623399.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"‘Magnificent Seven’ shed $233 billion in market cap, dragging down stock market\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T22:14:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/magnificent-seven-shed-233-billion-in-market-cap-dragging-down-the-stock-market-d71f4f68\",\n          \"image_url\": \"https://images.mktw.net/im-19977052/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Cathie Wood Sells $31 Million In Coinbase Stock After Site Crashes Amidst Bitcoin Rally, Warren Buffet Warns Of 'Casino-Like' Behavior In Markets\",\n          \"author\": \"Johnny Rice\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-05T20:49:37Z\",\n          \"url\": \"https://www.benzinga.com/trading-ideas/long-ideas/24/03/37493009/cathie-wood-sells-31-million-in-coinbase-stock-after-site-crashes-amidst-bitcoin-rally-w\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Screenshot-2024-03-06-at-2-14-33-AM.png?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"3 Top Ranked Stocks with Bargain Valuations\",\n          \"author\": \"Ethan Feller\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T19:29:00Z\",\n          \"url\": \"https://www.zacks.com/commentary/2236253/3-top-ranked-stocks-with-bargain-valuations\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/75/2558.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Bears Prowl Wall Street As Magnificent 7 Tumble; Bitcoin Sinks After Record High: What's Driving Markets Tuesday?\",\n          \"author\": \"Piero Cingari\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-05T19:01:09Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37490827/bears-prowl-wall-street-as-magnificent-7-tumble-bitcoin-sinks-after-record-high-wha\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Wall-Street-Bear-AI.png?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"The Magnificent 7 Are Falling Like Dominos; Only 3 Remain\",\n          \"author\": \"Beth Kindig\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-05T17:36:28Z\",\n          \"url\": \"https://seekingalpha.com/article/4675980-the-magnificent-7-are-falling-like-dominos-only-3-remain\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/2041838046/image_2041838046.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla Stock Has Nearly 60% Upside, According to 1 Wall Street Analyst\",\n          \"author\": \"newsfeedback@fool.com (Howard Smith)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-05T15:50:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/05/tesla-stock-has-60-upside-according-to-1-analyst/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767487/4-teslas-in-a-parking-lot-at-a-charger-station.png\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"U.S. investors may be chasing the wrong target\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T15:19:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/u-s-investors-may-be-chasing-the-wrong-target-1013d677\",\n          \"image_url\": \"https://images.mktw.net/im-63037169/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla’s Berlin factory halted, extremist group claims responsibility for power outage\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T15:09:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/teslas-berlin-factory-halted-due-to-power-outage-4e2c1eca\",\n          \"image_url\": \"https://images.mktw.net/im-21732928/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Forget Tesla, Buy This Magnificent Auto Stock Instead\",\n          \"author\": \"newsfeedback@fool.com (Neil Patel)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-05T15:03:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/05/forget-tesla-buy-magnificent-auto-stock-instead/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767668/group-of-tesla-super-chargers-with-logo-in-view.png\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla: Here's Why It's Down 24%\",\n          \"author\": \"ValueAnalyst\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-05T14:53:49Z\",\n          \"url\": \"https://seekingalpha.com/article/4675933-tesla-here-why-down-24-percent\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/1448592472/image_1448592472.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"5 Best ETF Areas of Last Week\",\n          \"author\": \"Sanghamitra Saha\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T13:42:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235891/5-best-etf-areas-of-last-week\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/93/1042.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"How Morgan Stanley’s bearish strategist may be right for the wrong reason\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T13:22:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/how-morgan-stanleys-bearish-strategist-may-be-right-for-the-wrong-reason-e731ff6e\",\n          \"image_url\": \"https://images.mktw.net/im-49457442/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"This Is Disastrous News for EV Stocks\",\n          \"author\": \"newsfeedback@fool.com (Travis Hoium)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-05T12:58:24Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/05/this-is-disastrous-news-for-ev-stocks/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768006/car-being-charged-outside.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"These 4 \\\"Magnificent Seven\\\" Stocks Are Brilliant Buys in March\",\n          \"author\": \"newsfeedback@fool.com (Keithen Drury)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-05T12:45:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/05/these-4-magnificent-seven-stocks-are-brilliant-buy/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767391/advisor-explains-stock-investment-to-a-customer.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Wall Street Breakfast Podcast: Philip Morris Hit With Lawsuit Over Zyn\",\n          \"author\": \"Wall Street Breakfast\",\n          \"source\": \"Seeking Alpha\",\n          \"date\": \"2024-03-05T11:57:32Z\",\n          \"url\": \"https://seekingalpha.com/article/4675909-wall-street-breakfast-podcast-philip-morris-hit-with-lawsuit-over-zyn\",\n          \"image_url\": \"https://static.seekingalpha.com/cdn/s3/uploads/getty_images/1970323520/image_1970323520.jpg?io=getty-c-w1536\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Ross Gerber Says Tesla Should Be Seeing 'Rally Period' Now Because Cybertrucks Are Selling 'Left And Right' But Earnings Are Going Down: 'That Needs To Be Addressed'\",\n          \"author\": \"Anan Ashraf\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-05T10:59:52Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37476484/ross-gerber-says-tesla-should-be-seeing-rally-period-now-because-cybertrucks-are-se\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Tesla-showroom-in-Austin--Texas-_1.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Xos, Inc. (XOS) Stock Jumps 28.0%: Will It Continue to Soar?\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T10:41:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235745/xos-inc-xos-stock-jumps-280-will-it-continue-to-soar\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default225.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"10 top used sedan models you can get for under $30,000\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-05T10:03:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/10-top-used-sedan-models-you-can-get-for-under-30-000-1b76ee0e\",\n          \"image_url\": \"https://images.mktw.net/im-58291454/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Why Market Bubble Fears Might Be Overblown\",\n          \"author\": \"Investing.com\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-05T09:47:00Z\",\n          \"url\": \"https://www.investing.com/analysis/why-market-bubble-fears-might-be-overblown-200646547\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla Bear Wonders If Wall Street Is Looking For 'New Narrative' Of Energy Hype Because EV Giant's Stock Price Is 'Too High'\",\n          \"author\": \"Shanthi Rexaline\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-05T09:44:41Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37475847/tesla-bear-wonders-if-wall-street-is-looking-for-new-narrative-of-energy-hype-becau\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/0x0-Charging-05_0.png?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Company News for Mar 5, 2024\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-05T09:20:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235731/company-news-for-mar-5-2024\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/01/36484.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"S&P 500 Ripe for Pullback as Bearish Rising Wedge Forms: Support Levels to Watch\",\n          \"author\": \"Michael Kramer\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-05T05:43:00Z\",\n          \"url\": \"https://www.investing.com/analysis/sp-500-ripe-for-pullback-as-bearish-rising-wedge-forms-support-levels-to-watch-200646561\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Fund Manager Says He 'Would Avoid' Tesla, But Recommends These 'Phenomenal' Auto Stocks: 'Race To Develop EVs Is Going To Be Profitless...'\",\n          \"author\": \"Benzinga Neuro\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-05T02:04:08Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37473043/fund-manager-says-he-would-avoid-tesla-but-recommends-these-phenomenal-auto-stocks-\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/ev-6049719-1280_2.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Nvidia and These Tech Giants Just Invested in This Artificial Intelligence (AI) and Robotics Start-Up\",\n          \"author\": \"newsfeedback@fool.com (Jose Najarro)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-04T22:10:04Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/04/nvidia-and-these-tech-giants-just-invested-in-this/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/768008/nvidia-headquarters-with-nvidia-sign-in-front.png\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Nasdaq has gone more than 300 days without a major pullback. Does that mean a shakeout is overdue?\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-04T21:54:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/nasdaq-has-gone-more-than-300-days-without-a-major-pullback-does-that-mean-a-shakeout-is-overdue-a8afb112\",\n          \"image_url\": \"https://images.mktw.net/im-56028536/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla Cuts Prices and the Stock Falls, Dragging Rivian and Lucid With It\",\n          \"author\": \"newsfeedback@fool.com (Travis Hoium)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-04T20:09:18Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/04/tesla-cuts-prices-and-the-stock-falls-dragging-riv/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767992/sports-car.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Stocks Stall, Magnificent 7 Crack As Apple, Tesla And Google Face Headwinds; Bitcoin, Gold Eye Record Highs: What's Driving Markets Monday?\",\n          \"author\": \"Piero Cingari\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-04T18:36:04Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37464820/stocks-stall-magnificent-7-crack-as-apple-tesla-and-google-face-headwinds-bitcoin-g\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Bull-and-bear-stock-market-index_8.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Two more stock indexes are on track for records: What that means for the bull market\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-04T18:32:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/two-more-stock-indexes-are-on-track-for-records-what-that-means-for-the-bull-market-044b8b21\",\n          \"image_url\": \"https://images.mktw.net/im-30026221/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"S&P 500’s breadth ‘still narrow’ after record peak — with these four stocks driving February gains\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-04T18:26:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/s-p-500s-breadth-still-narrow-after-record-peak-with-these-four-stocks-driving-february-gains-6fd60def\",\n          \"image_url\": \"https://images.mktw.net/im-508862/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla’s stock slides 5% after fresh volley of price cuts and discounts from EV maker\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-04T16:53:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/teslas-stock-slides-5-after-fresh-volley-of-price-cuts-and-discounts-from-ev-maker-dc1655f2\",\n          \"image_url\": \"https://images.mktw.net/im-25944526/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Massive News for Tesla Stock Investors\",\n          \"author\": \"newsfeedback@fool.com (Neil Rozenbaum)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-04T16:52:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/04/massive-news-for-tesla-stock-investors/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767741/tesla-building-with-tesla-logo-and-two-teslas-in-front.png\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla, Spirit Airlines, New York Community Bancorp And Other Big Stocks Moving Lower On Monday\",\n          \"author\": \"Avi Kapoor\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-04T15:40:18Z\",\n          \"url\": \"https://www.benzinga.com/news/24/03/37459626/tesla-spirit-airlines-new-york-community-bancorp-and-other-big-stocks-moving-lower-on-monday\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/03/04/tesla_-_logo.jpg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Goldman Sachs says this tech stock rally is grounded in reality\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-04T14:24:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/goldman-sachs-says-this-tech-stock-rally-is-grounded-in-reality-9ee03ea6\",\n          \"image_url\": \"https://images.mktw.net/im-55738041/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla, Inc. (TSLA) Is a Trending Stock: Facts to Know Before Betting on It\",\n          \"author\": \"Zacks Equity Research\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-04T14:00:16Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235201/tesla-inc-tsla-is-a-trending-stock-facts-to-know-before-betting-on-it\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/default_article_images/default15.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"4 Best Inverse/Leveraged ETF Areas of Last Week\",\n          \"author\": \"Sanghamitra Saha\",\n          \"source\": \"Zacks Investment Research\",\n          \"date\": \"2024-03-04T13:26:00Z\",\n          \"url\": \"https://www.zacks.com/stock/news/2235149/4-best-inverseleveraged-etf-areas-of-last-week\",\n          \"image_url\": \"https://staticx-tuner.zacks.com/images/articles/main/08/371.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Bank of America boosts S&P 500 target to 5,400. Here’s how it got there.\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-04T13:08:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/bank-of-america-boosts-s-p-500-target-to-5-400-heres-how-it-got-there-21418f72\",\n          \"image_url\": \"https://images.mktw.net/im-88409229/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"5 Stock(s) That Artificial Intelligence (AI) Could Propel Into the $2 Trillion Club\",\n          \"author\": \"newsfeedback@fool.com (Justin Pope)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-04T10:10:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/04/stocks-artificial-intelligence-ai-2-trillion-club/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767269/tesla-dojo-for-artificial-intelligence-ai.png\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla has the most loyal buyers and the most ‘conquests’ in 2024 vehicle loyalty ​study\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-04T10:01:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/tesla-has-the-most-loyal-buyers-and-the-most-conquests-in-2024-vehicle-loyalty-study-923d8450\",\n          \"image_url\": \"https://images.mktw.net/im-60024986/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Tesla Bull Wants Musk's Company To Think About How Apple Convinced Dumb Phone Users To Switch To iPhones Amid EV Slowdown: 'Effective Communication'\",\n          \"author\": \"Shanthi Rexaline\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-04T07:51:36Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37448482/tesla-bull-wants-musks-company-to-think-about-how-apple-convinced-dumb-phone-users-\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Tesla-cars-.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Nvidia, Meta, Microsoft And Amazon Among Dan Niles' Top Picks For 2024 Amid AI Bubble Concerns: 'We Have A Lot More Room To Go'\",\n          \"author\": \"Benzinga Neuro\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-04T07:05:54Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37448284/nvidia-meta-microsoft-and-amazon-among-dan-niles-top-picks-for-2024-amid-ai-bubble-\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Stocks-Photo-by-Pixels-Hunter-on-Shutter_2.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Nvidia Bound For A Drop Like Tesla? Investors Draw Parallels Between AI And EV Frenzy\",\n          \"author\": \"Benzinga Neuro\",\n          \"source\": \"Benzinga\",\n          \"date\": \"2024-03-04T06:26:42Z\",\n          \"url\": \"https://www.benzinga.com/analyst-ratings/analyst-color/24/03/37448071/nvidia-bound-for-a-drop-like-tesla-investors-draw-parallels-between-ai-and-ev-frenz\",\n          \"image_url\": \"https://cdn.benzinga.com/files/images/story/2024/Nvidia-Stock.jpeg?width=1200&height=800&fit=crop\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Is Tesla Still a Phenomenal Growth Stock?\",\n          \"author\": \"newsfeedback@fool.com (Howard Smith)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-03T12:25:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/03/is-tesla-still-a-phenomenal-growth-stock/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/766865/tesla-car-at-super-charger-station.png\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"1 Stock to Buy, 1 Stock to Sell This Week: CrowdStrike, Nio\",\n          \"author\": \"Investing.com\",\n          \"source\": \"Investing.com\",\n          \"date\": \"2024-03-03T11:05:00Z\",\n          \"url\": \"https://www.investing.com/analysis/1-stock-to-buy-1-stock-to-sell-this-week-crowdstrike-nio-200646518\",\n          \"image_url\": \"https://i-invdn-com.investing.com/redesign/images/seo/investingcom_analysis_og.jpg\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Expect this ‘Roaring 2020s’ market to keep stocks up and bring inflation down\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-02T17:53:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/stocks-are-heading-up-and-inflation-is-coming-down-as-this-roaring-2020s-market-rolls-on-f9157a69\",\n          \"image_url\": \"https://images.mktw.net/im-91748496/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"How the U.S. economy could slide into a Japan-like ‘lost decade’\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-02T16:44:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/why-the-u-s-economy-could-repeat-japans-lost-decade-30e9227b\",\n          \"image_url\": \"https://images.mktw.net/im-690325/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"1 \\\"Magnificent Seven\\\" Stock Down 52% to Buy and Hold Forever\",\n          \"author\": \"newsfeedback@fool.com (Justin Pope)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-02T15:03:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/02/1-magnificent-seven-stock-down-52-to-buy-and-hold/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/766839/tesla-bot-hand-closeup-artificial-intelligence-ai-1.png\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"‘GRANOLAS’ vs. the Magnificent Seven: Which should you dig into now?\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-02T13:41:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/granolas-vs-the-magnificent-seven-which-should-you-dig-into-now-79d2aaf5\",\n          \"image_url\": \"https://images.mktw.net/im-825291/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"AI, software and semiconductor stocks are too popular for their own good\",\n          \"author\": \"MarketWatch\",\n          \"source\": \"MarketWatch\",\n          \"date\": \"2024-03-02T12:36:00Z\",\n          \"url\": \"https://www.marketwatch.com/story/most-ai-software-and-semiconductor-stocks-are-a-crowded-trade-right-now-79782d90\",\n          \"image_url\": \"https://images.mktw.net/im-872777/social\",\n          \"sentiment\": null\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"title\": \"Billionaires David Tepper and George Soros Both Own These \\\"Magnificent Seven\\\" Stocks. Should You?\",\n          \"author\": \"newsfeedback@fool.com (Keith Speights)\",\n          \"source\": \"The Motley Fool\",\n          \"date\": \"2024-03-02T10:49:00Z\",\n          \"url\": \"https://www.fool.com/investing/2024/03/02/david-tepper-george-soros-magnificent-7-stocks/\",\n          \"image_url\": \"https://g.foolcdn.com/editorial/images/767092/data-center-infrastructure-information-technology.jpg\",\n          \"sentiment\": null\n      }\n  ]\n}"
  },
  {
    "path": "tests/fixtures/api/prices/AAPL_2024-03-01_2024-03-08.json",
    "content": "{\n  \"ticker\": \"AAPL\",\n  \"prices\": [\n      {\n          \"ticker\": \"AAPL\",\n          \"open\": 179.55,\n          \"close\": 179.66,\n          \"high\": 180.53,\n          \"low\": 177.38,\n          \"volume\": 73450582,\n          \"time\": \"2024-03-01T05:00:00Z\",\n          \"time_milliseconds\": 1709269200000\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"open\": 176.15,\n          \"close\": 175.1,\n          \"high\": 176.9,\n          \"low\": 173.79,\n          \"volume\": 81505451,\n          \"time\": \"2024-03-04T05:00:00Z\",\n          \"time_milliseconds\": 1709528400000\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"open\": 170.76,\n          \"close\": 170.12,\n          \"high\": 172.04,\n          \"low\": 169.62,\n          \"volume\": 94702355,\n          \"time\": \"2024-03-05T05:00:00Z\",\n          \"time_milliseconds\": 1709614800000\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"open\": 171.06,\n          \"close\": 169.12,\n          \"high\": 171.24,\n          \"low\": 168.68,\n          \"volume\": 68568907,\n          \"time\": \"2024-03-06T05:00:00Z\",\n          \"time_milliseconds\": 1709701200000\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"open\": 169.15,\n          \"close\": 169,\n          \"high\": 170.73,\n          \"low\": 168.49,\n          \"volume\": 71763761,\n          \"time\": \"2024-03-07T05:00:00Z\",\n          \"time_milliseconds\": 1709787600000\n      },\n      {\n          \"ticker\": \"AAPL\",\n          \"open\": 169,\n          \"close\": 170.73,\n          \"high\": 173.7,\n          \"low\": 168.94,\n          \"volume\": 76267041,\n          \"time\": \"2024-03-08T05:00:00Z\",\n          \"time_milliseconds\": 1709874000000\n      }\n  ]\n}"
  },
  {
    "path": "tests/fixtures/api/prices/MSFT_2024-03-01_2024-03-08.json",
    "content": "{\n  \"ticker\": \"MSFT\",\n  \"prices\": [\n      {\n          \"ticker\": \"MSFT\",\n          \"open\": 411.27,\n          \"close\": 415.5,\n          \"high\": 415.87,\n          \"low\": 410.88,\n          \"volume\": 17805495,\n          \"time\": \"2024-03-01T05:00:00Z\",\n          \"time_milliseconds\": 1709269200000\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"open\": 413.44,\n          \"close\": 414.92,\n          \"high\": 417.35,\n          \"low\": 412.32,\n          \"volume\": 17595956,\n          \"time\": \"2024-03-04T05:00:00Z\",\n          \"time_milliseconds\": 1709528400000\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"open\": 413.96,\n          \"close\": 402.65,\n          \"high\": 414.25,\n          \"low\": 400.64,\n          \"volume\": 26886877,\n          \"time\": \"2024-03-05T05:00:00Z\",\n          \"time_milliseconds\": 1709614800000\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"open\": 402.97,\n          \"close\": 402.09,\n          \"high\": 405.16,\n          \"low\": 398.39,\n          \"volume\": 22320549,\n          \"time\": \"2024-03-06T05:00:00Z\",\n          \"time_milliseconds\": 1709701200000\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"open\": 406.12,\n          \"close\": 409.14,\n          \"high\": 409.78,\n          \"low\": 402.24,\n          \"volume\": 18695839,\n          \"time\": \"2024-03-07T05:00:00Z\",\n          \"time_milliseconds\": 1709787600000\n      },\n      {\n          \"ticker\": \"MSFT\",\n          \"open\": 407.96,\n          \"close\": 406.22,\n          \"high\": 410.42,\n          \"low\": 404.33,\n          \"volume\": 18002186,\n          \"time\": \"2024-03-08T05:00:00Z\",\n          \"time_milliseconds\": 1709874000000\n      }\n  ]\n}"
  },
  {
    "path": "tests/fixtures/api/prices/TSLA_2024-03-01_2024-03-08.json",
    "content": "{\n  \"ticker\": \"TSLA\",\n  \"prices\": [\n      {\n          \"ticker\": \"TSLA\",\n          \"open\": 200.52,\n          \"close\": 202.64,\n          \"high\": 204.52,\n          \"low\": 198.5,\n          \"volume\": 82243119,\n          \"time\": \"2024-03-01T05:00:00Z\",\n          \"time_milliseconds\": 1709269200000\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"open\": 198.73,\n          \"close\": 188.14,\n          \"high\": 199.75,\n          \"low\": 186.72,\n          \"volume\": 134301269,\n          \"time\": \"2024-03-04T05:00:00Z\",\n          \"time_milliseconds\": 1709528400000\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"open\": 183.05,\n          \"close\": 180.74,\n          \"high\": 184.59,\n          \"low\": 177.57,\n          \"volume\": 119597758,\n          \"time\": \"2024-03-05T05:00:00Z\",\n          \"time_milliseconds\": 1709614800000\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"open\": 179.99,\n          \"close\": 176.54,\n          \"high\": 181.58,\n          \"low\": 173.7,\n          \"volume\": 107869464,\n          \"time\": \"2024-03-06T05:00:00Z\",\n          \"time_milliseconds\": 1709701200000\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"open\": 174.35,\n          \"close\": 178.65,\n          \"high\": 180.04,\n          \"low\": 173.7,\n          \"volume\": 102129004,\n          \"time\": \"2024-03-07T05:00:00Z\",\n          \"time_milliseconds\": 1709787600000\n      },\n      {\n          \"ticker\": \"TSLA\",\n          \"open\": 181.5,\n          \"close\": 175.34,\n          \"high\": 182.73,\n          \"low\": 174.7,\n          \"volume\": 85544644,\n          \"time\": \"2024-03-08T05:00:00Z\",\n          \"time_milliseconds\": 1709874000000\n      }\n  ]\n}"
  },
  {
    "path": "tests/test_api_rate_limiting.py",
    "content": "import os\nimport pytest\nfrom unittest.mock import Mock, patch, call\n\nfrom src.tools.api import _make_api_request, get_prices\n\nclass TestRateLimiting:\n    \"\"\"Test suite for API rate limiting functionality.\"\"\"\n\n    @patch('src.tools.api.time.sleep')\n    @patch('src.tools.api.requests.get')\n    def test_handles_single_rate_limit(self, mock_get, mock_sleep):\n        \"\"\"Test that API retries once after a 429 and succeeds.\"\"\"\n        # Setup mock responses: first 429, then 200\n        mock_429_response = Mock()\n        mock_429_response.status_code = 429\n        \n        mock_200_response = Mock()\n        mock_200_response.status_code = 200\n        mock_200_response.text = \"Success\"\n        \n        mock_get.side_effect = [mock_429_response, mock_200_response]\n        \n        # Call the function\n        headers = {\"X-API-KEY\": \"test-key\"}\n        url = \"https://api.financialdatasets.ai/test\"\n        \n        result = _make_api_request(url, headers)\n        \n        # Verify behavior\n        assert result.status_code == 200\n        assert result.text == \"Success\"\n        \n        # Verify requests.get was called twice\n        assert mock_get.call_count == 2\n        mock_get.assert_has_calls([\n            call(url, headers=headers),\n            call(url, headers=headers)\n        ])\n        \n        # Verify sleep was called once with 60 seconds (first retry)\n        mock_sleep.assert_called_once_with(60)\n\n    @patch('src.tools.api.time.sleep')\n    @patch('src.tools.api.requests.get')\n    def test_handles_multiple_rate_limits(self, mock_get, mock_sleep):\n        \"\"\"Test that API retries multiple times after 429s.\"\"\"\n        # Setup mock responses: three 429s, then 200\n        mock_429_response = Mock()\n        mock_429_response.status_code = 429\n        \n        mock_200_response = Mock()\n        mock_200_response.status_code = 200\n        mock_200_response.text = \"Success\"\n        \n        mock_get.side_effect = [\n            mock_429_response, \n            mock_429_response, \n            mock_429_response, \n            mock_200_response\n        ]\n        \n        # Call the function\n        headers = {\"X-API-KEY\": \"test-key\"}\n        url = \"https://api.financialdatasets.ai/test\"\n        \n        result = _make_api_request(url, headers)\n        \n        # Verify behavior\n        assert result.status_code == 200\n        assert result.text == \"Success\"\n        \n        # Verify requests.get was called 4 times\n        assert mock_get.call_count == 4\n        \n        # Verify sleep was called 3 times with linear backoff: 60s, 90s, 120s\n        assert mock_sleep.call_count == 3\n        expected_calls = [call(60), call(90), call(120)]\n        mock_sleep.assert_has_calls(expected_calls)\n\n    @patch('src.tools.api.time.sleep')\n    @patch('src.tools.api.requests.post')\n    def test_handles_post_rate_limiting(self, mock_post, mock_sleep):\n        \"\"\"Test that POST requests handle rate limiting.\"\"\"\n        # Setup mock responses: first 429, then 200\n        mock_429_response = Mock()\n        mock_429_response.status_code = 429\n        \n        mock_200_response = Mock()\n        mock_200_response.status_code = 200\n        mock_200_response.text = \"Success\"\n        \n        mock_post.side_effect = [mock_429_response, mock_200_response]\n        \n        # Call the function with POST method\n        headers = {\"X-API-KEY\": \"test-key\"}\n        url = \"https://api.financialdatasets.ai/test\"\n        json_data = {\"test\": \"data\"}\n        \n        result = _make_api_request(url, headers, method=\"POST\", json_data=json_data)\n        \n        # Verify behavior\n        assert result.status_code == 200\n        assert result.text == \"Success\"\n        \n        # Verify requests.post was called twice\n        assert mock_post.call_count == 2\n        mock_post.assert_has_calls([\n            call(url, headers=headers, json=json_data),\n            call(url, headers=headers, json=json_data)\n        ])\n        \n        # Verify sleep was called once with 60 seconds (first retry)\n        mock_sleep.assert_called_once_with(60)\n\n    @patch('src.tools.api.time.sleep')\n    @patch('src.tools.api.requests.get')\n    def test_ignores_other_errors(self, mock_get, mock_sleep):\n        \"\"\"Test that non-429 errors are returned without retrying.\"\"\"\n        # Setup mock response: 500 error\n        mock_500_response = Mock()\n        mock_500_response.status_code = 500\n        mock_500_response.text = \"Internal Server Error\"\n        \n        mock_get.return_value = mock_500_response\n        \n        # Call the function\n        headers = {\"X-API-KEY\": \"test-key\"}\n        url = \"https://api.financialdatasets.ai/test\"\n        \n        result = _make_api_request(url, headers)\n        \n        # Verify behavior\n        assert result.status_code == 500\n        assert result.text == \"Internal Server Error\"\n        \n        # Verify requests.get was called only once\n        assert mock_get.call_count == 1\n        \n        # Verify sleep was never called\n        mock_sleep.assert_not_called()\n\n    @patch('src.tools.api.time.sleep')\n    @patch('src.tools.api.requests.get')\n    def test_normal_success_requests(self, mock_get, mock_sleep):\n        \"\"\"Test that successful requests return immediately without retry.\"\"\"\n        # Setup mock response: 200 success\n        mock_200_response = Mock()\n        mock_200_response.status_code = 200\n        mock_200_response.text = \"Success\"\n        \n        mock_get.return_value = mock_200_response\n        \n        # Call the function\n        headers = {\"X-API-KEY\": \"test-key\"}\n        url = \"https://api.financialdatasets.ai/test\"\n        \n        result = _make_api_request(url, headers)\n        \n        # Verify behavior\n        assert result.status_code == 200\n        assert result.text == \"Success\"\n        \n        # Verify requests.get was called only once\n        assert mock_get.call_count == 1\n        \n        # Verify sleep was never called\n        mock_sleep.assert_not_called()\n\n    @patch('src.tools.api._cache')\n    @patch('src.tools.api.time.sleep')\n    @patch('src.tools.api.requests.get')\n    def test_full_integration(self, mock_get, mock_sleep, mock_cache):\n        \"\"\"Test that get_prices function properly handles rate limiting.\"\"\"\n        # Mock cache to return None (cache miss)\n        mock_cache.get_prices.return_value = None\n        \n        # Setup mock responses: first 429, then 200 with valid data\n        mock_429_response = Mock()\n        mock_429_response.status_code = 429\n        \n        mock_200_response = Mock()\n        mock_200_response.status_code = 200\n        mock_200_response.json.return_value = {\n            \"ticker\": \"AAPL\",\n            \"prices\": [\n                {\n                    \"time\": \"2024-01-01T00:00:00Z\",\n                    \"open\": 100.0,\n                    \"close\": 101.0,\n                    \"high\": 102.0,\n                    \"low\": 99.0,\n                    \"volume\": 1000\n                }\n            ]\n        }\n        \n        mock_get.side_effect = [mock_429_response, mock_200_response]\n        \n        # Set environment variable for API key\n        with patch.dict(os.environ, {\"FINANCIAL_DATASETS_API_KEY\": \"test-key\"}):\n            # Call get_prices\n            result = get_prices(\"AAPL\", \"2024-01-01\", \"2024-01-02\")\n        \n        # Verify the function succeeded and returned data\n        assert len(result) == 1\n        assert result[0].open == 100.0\n        assert result[0].close == 101.0\n        \n        # Verify rate limiting behavior\n        assert mock_get.call_count == 2\n        mock_sleep.assert_called_once_with(60)\n        \n        # Verify cache operations\n        mock_cache.get_prices.assert_called_once()\n        mock_cache.set_prices.assert_called_once()\n\n    @patch('src.tools.api.time.sleep')\n    @patch('src.tools.api.requests.get')\n    def test_max_retries_exceeded(self, mock_get, mock_sleep):\n        \"\"\"Test that function stops retrying after max_retries and returns final 429.\"\"\"\n        # Setup mock responses: all 429s (exceeds max retries)\n        mock_429_response = Mock()\n        mock_429_response.status_code = 429\n        mock_429_response.text = \"Too Many Requests\"\n        \n        mock_get.return_value = mock_429_response\n        \n        # Call the function with max_retries=2\n        headers = {\"X-API-KEY\": \"test-key\"}\n        url = \"https://api.financialdatasets.ai/test\"\n        \n        result = _make_api_request(url, headers, max_retries=2)\n        \n        # Verify final 429 is returned\n        assert result.status_code == 429\n        assert result.text == \"Too Many Requests\"\n        \n        # Verify requests.get was called 3 times (1 initial + 2 retries)\n        assert mock_get.call_count == 3\n        \n        # Verify sleep was called 2 times with linear backoff: 60s, 90s\n        assert mock_sleep.call_count == 2\n        expected_calls = [call(60), call(90)]\n        mock_sleep.assert_has_calls(expected_calls)\n\n\nif __name__ == \"__main__\":\n    pytest.main([__file__]) "
  }
]