diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..0c685ac --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,43 @@ +name: Docker Build & Push (hummingbot-mcp) + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: + - main + +jobs: + docker-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Determine Docker tag + id: vars + run: | + if [[ "${{ github.event_name }}" == "push" ]]; then + echo "TAG=latest" >> $GITHUB_ENV + else + echo "TAG=development" >> $GITHUB_ENV + fi + + - name: Build and push Docker image (multi-arch) + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: hummingbot/hummingbot-mcp:${{ env.TAG }} diff --git a/Dockerfile b/Dockerfile index 084e568..dd616ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,39 +1,30 @@ -# Use a Python image with uv pre-installed -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS uv - -# Install the project into `/app` +# Stage 1: Dependencies +FROM python:3.12-slim AS deps WORKDIR /app +RUN apt-get update && apt-get install -y git && \ + apt-get clean && rm -rf /var/lib/apt/lists/* && \ + pip install uv +COPY pyproject.toml uv.lock ./ +RUN uv venv && uv pip install . + +# Stage 2: Runtime +FROM python:3.12-slim +WORKDIR /app +RUN apt-get update && apt-get install -y git && \ + apt-get clean && rm -rf /var/lib/apt/lists/* && \ + pip install uv -# Enable bytecode compilation -ENV UV_COMPILE_BYTECODE=1 - -# Copy from the cache instead of linking since it's a mounted volume -ENV UV_LINK_MODE=copy - -# Install the project's dependencies using the lockfile and settings -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --frozen --no-install-project --no-dev --no-editable - -# Then, add the rest of the project source code and install it -# Installing separately from its dependencies allows optimal layer caching -ADD . /app -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen --no-dev --no-editable - -FROM python:3.12-slim-bookworm - -# Install system dependencies if needed -RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* +# Copy the virtual environment from the deps stage +COPY --from=deps /app/.venv /app/.venv -WORKDIR /app - -COPY --from=uv /root/.local /root/.local -COPY --from=uv --chown=app:app /app/.venv /app/.venv +# Copy source code +COPY hummingbot_mcp/ ./hummingbot_mcp/ +COPY README.md ./ +COPY main.py ./ +COPY pyproject.toml ./ -# Place executables in the environment at the front of the path -ENV PATH="/app/.venv/bin:$PATH" +# Set environment variable to indicate we're running in Docker +ENV DOCKER_CONTAINER=true -# The entrypoint should be the mcp-hummingbot command from pyproject.toml scripts -ENTRYPOINT ["mcp-hummingbot"] \ No newline at end of file +# Run the MCP server using the pre-built venv +ENTRYPOINT ["/app/.venv/bin/python", "main.py"] \ No newline at end of file diff --git a/README.md b/README.md index 8c43918..784b596 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MCP Hummingbot Server -An MCP (Model Context Protocol) server that enables Claude to interact with Hummingbot for automated cryptocurrency trading across multiple exchanges. +An MCP (Model Context Protocol) server that enables Claude and Gemini CLI to interact with Hummingbot for automated cryptocurrency trading across multiple exchanges. ## Installation & Configuration @@ -13,133 +13,249 @@ An MCP (Model Context Protocol) server that enables Claude to interact with Humm 2. **Clone and install dependencies**: ```bash - git clone + git clone https://github.com/hummingbot/mcp cd mcp uv sync ``` -3. **Set environment variables**: +3. **Create a .env file**: ```bash - export HUMMINGBOT_API_URL="http://localhost:15888" - export HUMMINGBOT_USERNAME="your-username" - export HUMMINGBOT_PASSWORD="your-password" - export DEFAULT_ACCOUNT="master_account" + cp .env.example .env ``` -4. **Configure in Claude Code**: - ```json - { - "mcpServers": { - "mcp-hummingbot": { - "type": "stdio", - "command": "uv", - "args": [ - "--directory", - "/path/to/mcp", - "run", - "mcp-hummingbot" - ], - "env": { - "HUMMINGBOT_API_URL": "http://localhost:15888", - "HUMMINGBOT_USERNAME": "your-username", - "HUMMINGBOT_PASSWORD": "your-password" - } - } - } - } +4. **Edit the .env file** with your Hummingbot API credentials: + ```env + HUMMINGBOT_API_URL=http://localhost:8000 + HUMMINGBOT_USERNAME=admin + HUMMINGBOT_PASSWORD=admin ``` - - Or run the server module directly: + +5. **Configure in Claude Code or Gemini CLI**: ```json { "mcpServers": { - "mcp-hummingbot": { + "hummingbot-mcp": { "type": "stdio", "command": "uv", "args": [ "--directory", "/path/to/mcp", "run", - "mcp_hummingbot/server.py" - ], - "env": { - "HUMMINGBOT_API_URL": "http://localhost:15888", - "HUMMINGBOT_USERNAME": "your-username", - "HUMMINGBOT_PASSWORD": "your-password" - } + "main.py" + ] } } } ``` + + **Note**: Make sure to replace `/path/to/mcp` with the actual path to your MCP directory. ### Option 2: Using Docker (Recommended for Production) -1. **Build the Docker image**: +1. **Create a .env file**: ```bash - docker build -t mcp-hummingbot . + touch .env + ``` + +2. **Edit the .env file** with your Hummingbot API credentials: + ```env + HUMMINGBOT_API_URL=http://localhost:8000 + HUMMINGBOT_USERNAME=admin + HUMMINGBOT_PASSWORD=admin ``` -2. **Configure in Claude Code**: +3. **Pull the Docker image**: + ```bash + docker pull hummingbot/hummingbot-mcp:latest + ``` + +4. **Configure in Claude Code or Gemini CLI**: ```json { "mcpServers": { - "hummingbot": { + "hummingbot-mcp": { + "type": "stdio", "command": "docker", "args": [ "run", "--rm", "-i", - "--network", "host", - "-e", "HUMMINGBOT_API_URL=http://localhost:15888", - "-e", "HUMMINGBOT_USERNAME=your-username", - "-e", "HUMMINGBOT_PASSWORD=your-password", - "mcp-hummingbot" + "--env-file", + "/path/to/mcp/.env", + "hummingbot/hummingbot-mcp:latest" ] } } } ``` + + **Note**: Make sure to replace `/path/to/mcp` with the actual path to your MCP directory. -### Cloud Deployment with Docker +### Cloud Deployment with Docker Compose For cloud deployment where both Hummingbot API and MCP server run on the same server: -1. **Create a docker-compose.yml**: +1. **Create a .env file**: + ```bash + touch .env + ``` + +2. **Edit the .env file** with your Hummingbot API credentials: + ```env + HUMMINGBOT_API_URL=http://localhost:8000 + HUMMINGBOT_USERNAME=admin + HUMMINGBOT_PASSWORD=admin + ``` + +3. **Create a docker-compose.yml**: ```yaml - version: '3.8' services: hummingbot-api: - image: hummingbot/backend-api:latest + container_name: hummingbot-api + image: hummingbot/hummingbot-api:latest ports: - - "15888:15888" - environment: - - CONFIG_FOLDER_PATH=/config + - "8000:8000" volumes: - - ./hummingbot_files:/app + - ./bots:/hummingbot-api/bots + - /var/run/docker.sock:/var/run/docker.sock + environment: + - USERNAME=admin + - PASSWORD=admin + - BROKER_HOST=emqx + - DATABASE_URL=postgresql+asyncpg://hbot:hummingbot-api@postgres:5432/hummingbot_api + networks: + - emqx-bridge + depends_on: + - postgres mcp-server: - build: . + container_name: hummingbot-mcp + image: hummingbot/hummingbot-mcp:latest stdin_open: true tty: true + env_file: + - .env environment: - - HUMMINGBOT_API_URL=http://hummingbot-api:15888 - - HUMMINGBOT_USERNAME=${HUMMINGBOT_USERNAME} - - HUMMINGBOT_PASSWORD=${HUMMINGBOT_PASSWORD} + - HUMMINGBOT_API_URL=http://hummingbot-api:8000 depends_on: - hummingbot-api + networks: + - emqx-bridge + + # Include other services from hummingbot-api docker-compose.yml as needed + emqx: + container_name: hummingbot-broker + image: emqx:5 + restart: unless-stopped + environment: + - EMQX_NAME=emqx + - EMQX_HOST=node1.emqx.local + - EMQX_CLUSTER__DISCOVERY_STRATEGY=static + - EMQX_CLUSTER__STATIC__SEEDS=[emqx@node1.emqx.local] + - EMQX_LOADED_PLUGINS="emqx_recon,emqx_retainer,emqx_management,emqx_dashboard" + volumes: + - emqx-data:/opt/emqx/data + - emqx-log:/opt/emqx/log + - emqx-etc:/opt/emqx/etc + ports: + - "1883:1883" + - "8883:8883" + - "8083:8083" + - "8084:8084" + - "8081:8081" + - "18083:18083" + - "61613:61613" + networks: + emqx-bridge: + aliases: + - node1.emqx.local + healthcheck: + test: [ "CMD", "/opt/emqx/bin/emqx_ctl", "status" ] + interval: 5s + timeout: 25s + retries: 5 + + postgres: + container_name: hummingbot-postgres + image: postgres:15 + restart: unless-stopped + environment: + - POSTGRES_DB=hummingbot_api + - POSTGRES_USER=hbot + - POSTGRES_PASSWORD=hummingbot-api + volumes: + - postgres-data:/var/lib/postgresql/data + ports: + - "5432:5432" + networks: + - emqx-bridge + healthcheck: + test: ["CMD-SHELL", "pg_isready -U hbot -d hummingbot_api"] + interval: 10s + timeout: 5s + retries: 5 + + networks: + emqx-bridge: + driver: bridge + + volumes: + emqx-data: { } + emqx-log: { } + emqx-etc: { } + postgres-data: { } ``` -2. **Deploy**: +4. **Deploy**: ```bash - docker-compose up -d + docker compose up -d ``` -3. **Configure Claude Code to connect remotely** (requires MCP to support remote connections - currently stdio only). +5. **Configure in Claude Code or Gemini CLI to connect to existing container**: + ```json + { + "mcpServers": { + "hummingbot-mcp": { + "type": "stdio", + "command": "docker", + "args": [ + "exec", + "-i", + "hummingbot-mcp", + "uv", + "run", + "main.py" + ] + } + } + } + ``` + + **Note**: Replace `hummingbot-mcp` with your actual container name. You can find the container name by running: + ```bash + docker ps + ``` + +## Environment Variables + +The following environment variables can be set in your `.env` file for the MCP server: + +| Variable | Default | Description | +|----------|---------|-------------| +| `HUMMINGBOT_API_URL` | `http://localhost:8000` | URL of the Hummingbot API server | +| `HUMMINGBOT_USERNAME` | `admin` | Username for API authentication | +| `HUMMINGBOT_PASSWORD` | `admin` | Password for API authentication | +| `HUMMINGBOT_TIMEOUT` | `30.0` | Connection timeout in seconds | +| `HUMMINGBOT_MAX_RETRIES` | `3` | Maximum number of retry attempts | +| `HUMMINGBOT_RETRY_DELAY` | `2.0` | Delay between retries in seconds | +| `HUMMINGBOT_LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) | + +**Note**: Hummingbot API server environment variables are configured directly in the `docker-compose.yml` file. ## Requirements - Python 3.11+ -- Running Hummingbot instance with API enabled +- Running Hummingbot API server - Valid Hummingbot API credentials ## Available Tools @@ -150,4 +266,26 @@ The MCP server provides tools for: - Order placement - Position management - Market data (prices, order books, candles) -- Funding rates \ No newline at end of file +- Funding rates + +## Development + +To run the server in development mode: + +```bash +uv run main.py +``` + +To run tests: + +```bash +uv run pytest +``` + +## Troubleshooting + +1. **Connection Issues**: Ensure the Hummingbot API server is running and accessible at the URL specified in your `.env` file. + +2. **Authentication Errors**: Verify your username and password in the `.env` file match your Hummingbot API credentials. + +3. **Docker Issues**: Make sure the `.env` file is in the same directory as your `docker-compose.yml` or specify the correct path in the Docker run command. diff --git a/config_examples/claude_code_config.json b/config_examples/claude_code_config.json new file mode 100644 index 0000000..c421814 --- /dev/null +++ b/config_examples/claude_code_config.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "mcp-hummingbot": { + "type": "stdio", + "command": "/Users/dman/.local/bin/uv", + "args": [ + "--directory", + "/Users/dman/Documents/code/mcp", + "run", + "main.py" + ], + "env": { + } + } + } +} \ No newline at end of file diff --git a/config_examples/claude_code_config_docker.json b/config_examples/claude_code_config_docker.json new file mode 100644 index 0000000..d9e5266 --- /dev/null +++ b/config_examples/claude_code_config_docker.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "hummingbot-mcp-docker": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "--network", "host", + "-e", "HUMMINGBOT_API_URL=http://localhost:8000", + "-e", "HUMMINGBOT_USERNAME=admin", + "-e", "HUMMINGBOT_PASSWORD=admin", + "dardonacci/hummingbot-mcp:latest" + ], + "description": "Hummingbot MCP server for interacting with Hummingbot API" + } + } +} \ No newline at end of file diff --git a/mcp_hummingbot/__init__.py b/hummingbot_mcp/__init__.py similarity index 93% rename from mcp_hummingbot/__init__.py rename to hummingbot_mcp/__init__.py index b29e98a..415f0cc 100644 --- a/mcp_hummingbot/__init__.py +++ b/hummingbot_mcp/__init__.py @@ -10,4 +10,4 @@ from .server import main -__all__ = ["main"] \ No newline at end of file +__all__ = ["main"] diff --git a/mcp_hummingbot/exceptions.py b/hummingbot_mcp/exceptions.py similarity index 97% rename from mcp_hummingbot/exceptions.py rename to hummingbot_mcp/exceptions.py index b03cb09..f52a3d7 100644 --- a/mcp_hummingbot/exceptions.py +++ b/hummingbot_mcp/exceptions.py @@ -5,24 +5,29 @@ class HummingbotMCPError(Exception): """Base exception for Hummingbot MCP server""" + pass class ToolError(HummingbotMCPError): """Exception raised when a tool execution fails""" + pass class MaxConnectionsAttemptError(HummingbotMCPError): """Exception raised when API connection fails""" + pass class ValidationError(HummingbotMCPError): """Exception raised when input validation fails""" + pass class ConfigurationError(HummingbotMCPError): """Exception raised when configuration is invalid""" - pass \ No newline at end of file + + pass diff --git a/mcp_hummingbot/hummingbot_client.py b/hummingbot_mcp/hummingbot_client.py similarity index 78% rename from mcp_hummingbot/hummingbot_client.py rename to hummingbot_mcp/hummingbot_client.py index 2efefc3..0115e6b 100644 --- a/mcp_hummingbot/hummingbot_client.py +++ b/hummingbot_mcp/hummingbot_client.py @@ -3,58 +3,60 @@ """ import asyncio -from typing import Optional +import logging + from hummingbot_api_client import HummingbotAPIClient -from mcp_hummingbot.settings import settings -import logging -from mcp_hummingbot.exceptions import MaxConnectionsAttemptError +from hummingbot_mcp.exceptions import MaxConnectionsAttemptError +from hummingbot_mcp.settings import settings logger = logging.getLogger("hummingbot-mcp") class HummingbotClient: """Wrapper for HummingbotAPIClient with connection management""" - + def __init__(self): - self._client: Optional[HummingbotAPIClient] = None + self._client: HummingbotAPIClient | None = None self._initialized = False - + async def initialize(self) -> HummingbotAPIClient: """Initialize API client with retry logic""" if self._client is not None and self._initialized: return self._client - + for attempt in range(settings.max_retries): try: self._client = HummingbotAPIClient( base_url=settings.api_url, username=settings.api_username, password=settings.api_password, - timeout=settings.client_timeout + timeout=settings.client_timeout, ) - + # Initialize and test connection await self._client.init() await self._client.accounts.list_accounts() - + self._initialized = True logger.info(f"Successfully connected to Hummingbot API at {settings.api_url}") return self._client - + except Exception as e: logger.warning(f"Connection attempt {attempt + 1} failed: {e}") if attempt < settings.max_retries - 1: await asyncio.sleep(settings.retry_delay) else: - raise MaxConnectionsAttemptError(f"Failed to connect to Hummingbot API after {settings.max_retries} attempts: {e}") - + raise MaxConnectionsAttemptError( + f"Failed to connect to Hummingbot API after {settings.max_retries} attempts: {e}" + ) + async def get_client(self) -> HummingbotAPIClient: """Get initialized client""" if not self._client or not self._initialized: return await self.initialize() return self._client - + async def close(self): """Close the client connection""" if self._client: @@ -64,4 +66,4 @@ async def close(self): # Global client instance -hummingbot_client = HummingbotClient() \ No newline at end of file +hummingbot_client = HummingbotClient() diff --git a/mcp_hummingbot/server.py b/hummingbot_mcp/server.py similarity index 58% rename from mcp_hummingbot/server.py rename to hummingbot_mcp/server.py index d0f2919..d708906 100644 --- a/mcp_hummingbot/server.py +++ b/hummingbot_mcp/server.py @@ -3,23 +3,22 @@ """ import asyncio -from typing import Dict, Any, Optional, List, Literal +import logging +import sys +from typing import Any, Literal, Optional from mcp.server.fastmcp import FastMCP -from mcp_hummingbot.settings import settings -from mcp_hummingbot.hummingbot_client import hummingbot_client -from mcp_hummingbot.logging import setup_logging -from mcp_hummingbot.exceptions import ToolError, MaxConnectionsAttemptError as HBConnectionError -from mcp_hummingbot.tools.account import SetupConnectorRequest -import logging + +from hummingbot_mcp.exceptions import MaxConnectionsAttemptError as HBConnectionError, ToolError +from hummingbot_mcp.hummingbot_client import hummingbot_client +from hummingbot_mcp.settings import settings +from hummingbot_mcp.tools.account import SetupConnectorRequest # Configure root logger logging.basicConfig( level="INFO", - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.StreamHandler(sys.stderr) - ] + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.StreamHandler(sys.stderr)], ) logger = logging.getLogger("hummingbot-mcp") @@ -29,21 +28,22 @@ # Account Management Tools + @mcp.tool() async def setup_connector( - connector: Optional[str] = None, - credentials: Optional[Dict[str, Any]] = None, - account: Optional[str] = None, - confirm_override: Optional[bool] = None + connector: str | None = None, + credentials: dict[str, Any] | None = None, + account: str | None = None, + confirm_override: bool | None = None, ) -> str: """Setup a new exchange connector for an account with credentials using progressive disclosure. - + This tool guides you through the entire process of connecting an exchange with a four-step flow: 1. No parameters → List available exchanges - 2. Connector only → Show required credential fields + 2. Connector only → Show required credential fields 3. Connector + credentials, no account → Select account from available accounts 4. All parameters → Connect the exchange (with override confirmation if needed) - + Args: connector: Exchange connector name (e.g., 'binance', 'binance_perpetual'). Leave empty to list available connectors. credentials: Credentials object with required fields for the connector. Leave empty to see required fields first. @@ -53,64 +53,21 @@ async def setup_connector( try: # Create and validate request using Pydantic model request = SetupConnectorRequest( - connector=connector, - credentials=credentials, - account=account, - confirm_override=confirm_override + connector=connector, credentials=credentials, account=account, confirm_override=confirm_override ) from .tools.account import setup_connector as setup_connector_impl + result = await setup_connector_impl(request) return f"Setup Connector Result: {result}" except Exception as e: logger.error(f"setup_connector failed: {str(e)}", exc_info=True) raise ToolError(f"Failed to setup connector: {str(e)}") - @mcp.tool() -async def create_delete_accounts( - action: str, - account_name: Optional[str] = None, - credential: Optional[str] = None, +async def get_portfolio_balances( + account_names: list[str] | None = None, connector_names: list[str] | None = None, as_distribution: bool = False ) -> str: - """ - Create or delete an account. Important: Deleting an account will remove all associated credentials and data, and - the master_account cannot be deleted. - If a credential is provided, only the credential for the account will be deleted - Args: - action: Action to perform ('create' or 'delete') - account_name: Name of the account to create or delete. Required for 'create' and optional for 'delete'. - """ - try: - client = await hummingbot_client.get_client() - if action == "create": - if not account_name: - raise ValueError("Account name is required for creating an account") - result = await client.accounts.add_account(account_name) - return f"Account '{account_name}' created successfully: {result}" - elif action == "delete": - if not account_name: - raise ValueError("Account name is required for deleting an account") - if account_name == settings.default_account: - raise ValueError("Cannot delete the master account") - if credential is not None: - # If credential is provided, delete only the credential for the account - result = await client.accounts.delete_credential(account_name, credential) - return f"Credential '{credential}' for account '{account_name}' deleted successfully: {result}" - result = await client.accounts.delete_account(account_name) - return f"Account '{account_name}' deleted successfully: {result}" - else: - raise ValueError("Invalid action. Must be 'create' or 'delete'.") - except HBConnectionError as e: - logger.error(f"Failed to connect to Hummingbot API: {e}") - raise ToolError( - "Failed to connect to Hummingbot API. Please ensure it is running and API credentials are correct.") - - -@mcp.tool() -async def get_portfolio_balances(account_names: Optional[List[str]] = None, - connector_names: Optional[List[str]] = None, - as_distribution: bool = False) -> str: """Get portfolio balances and holdings across all connected exchanges. Returns detailed token balances, values, and available units for each account. Use this to check your portfolio, @@ -130,8 +87,7 @@ async def get_portfolio_balances(account_names: Optional[List[str]] = None, client = await hummingbot_client.get_client() if as_distribution: # Get portfolio distribution - result = await client.portfolio.get_distribution(account_names=account_names, - connector_names=connector_names) + result = await client.portfolio.get_distribution(account_names=account_names, connector_names=connector_names) return f"Portfolio Distribution: {result}" account_info = await client.portfolio.get_state(account_names=account_names, connector_names=connector_names) return f"Account State: {account_info}" @@ -142,16 +98,17 @@ async def get_portfolio_balances(account_names: Optional[List[str]] = None, # Trading Tools + @mcp.tool() async def place_order( - connector_name: str, - trading_pair: str, - trade_type: str, - amount: str, - price: Optional[str] = None, - order_type: str = "MARKET", - position_action: Optional[str] = "OPEN", - account_name: Optional[str] = "master_account" + connector_name: str, + trading_pair: str, + trade_type: str, + amount: str, + price: str | None = None, + order_type: str = "MARKET", + position_action: str | None = "OPEN", + account_name: str | None = "master_account", ) -> str: """Place a buy or sell order (supports USD values by adding at the start of the amount $). @@ -182,7 +139,7 @@ async def place_order( amount=amount, order_type=order_type, price=price, - position_action=position_action + position_action=position_action, ) return f"Order Result: {result}" except Exception as e: @@ -192,11 +149,11 @@ async def place_order( @mcp.tool() async def set_account_position_mode_and_leverage( - account_name: str, - connector_name: str, - trading_pair: Optional[str] = None, - position_mode: Optional[str] = None, - leverage: Optional[int] = None + account_name: str, + connector_name: str, + trading_pair: str | None = None, + position_mode: str | None = None, + leverage: int | None = None, ) -> str: """Set position mode and leverage for an account on a specific exchange. If position mode is not specified, will only set the leverage. If leverage is not specified, will only set the position mode. @@ -219,9 +176,7 @@ async def set_account_position_mode_and_leverage( if position_mode not in ["HEDGE", "ONE-WAY"]: raise ValueError("Invalid position mode. Must be 'HEDGE' or 'ONE-WAY'") position_mode_result = await client.trading.set_position_mode( - account_name=account_name, - connector_name=connector_name, - position_mode=position_mode + account_name=account_name, connector_name=connector_name, position_mode=position_mode ) response += f"Position Mode Set: {position_mode_result}\n" if leverage is not None: @@ -230,10 +185,7 @@ async def set_account_position_mode_and_leverage( if trading_pair is None: raise ValueError("Trading_pair must be specified") leverage_result = await client.trading.set_leverage( - account_name=account_name, - connector_name=connector_name, - trading_pair=trading_pair, - leverage=leverage + account_name=account_name, connector_name=connector_name, trading_pair=trading_pair, leverage=leverage ) response += f"Leverage Set: {leverage_result}\n" return f"{response.strip()}" @@ -244,17 +196,17 @@ async def set_account_position_mode_and_leverage( @mcp.tool() async def get_orders( - account_names: Optional[List[str]] = None, - connector_names: Optional[List[str]] = None, - trading_pairs: Optional[List[str]] = None, - status: Optional[Literal["OPEN", "FILLED", "CANCELED", "FAILED"]] = None, - start_time: Optional[int] = None, - end_time: Optional[int] = None, - limit: Optional[int] = 500, - cursor: Optional[str] = None + account_names: list[str] | None = None, + connector_names: list[str] | None = None, + trading_pairs: list[str] | None = None, + status: Literal["OPEN", "FILLED", "CANCELED", "FAILED"] | None = None, + start_time: int | None = None, + end_time: int | None = None, + limit: int | None = 500, + cursor: str | None = None, ) -> str: """Get the orders manged by the connected accounts. - + Args: account_names: List of account names to filter by (optional). If empty, returns all accounts. connector_names: List of connector names to filter by (optional). If empty, returns all connectors. @@ -269,8 +221,14 @@ async def get_orders( try: client = await hummingbot_client.get_client() result = await client.trading.search_orders( - account_names=account_names, connector_names=connector_names, trading_pairs=trading_pairs, - status=status, start_time=start_time, end_time=end_time, limit=limit, cursor=cursor + account_names=account_names, + connector_names=connector_names, + trading_pairs=trading_pairs, + status=status, + start_time=start_time, + end_time=end_time, + limit=limit, + cursor=cursor, ) return f"Order Management Result: {result}" except Exception as e: @@ -280,9 +238,7 @@ async def get_orders( @mcp.tool() async def get_positions( - account_names: Optional[List[str]] = None, - connector_names: Optional[List[str]] = None, - limit: Optional[int] = 100 + account_names: list[str] | None = None, connector_names: list[str] | None = None, limit: int | None = 100 ) -> str: """Get the positions managed by the connected accounts. @@ -293,20 +249,18 @@ async def get_positions( """ try: client = await hummingbot_client.get_client() - result = await client.trading.get_positions( - account_names=account_names, connector_names=connector_names, limit=limit - ) + result = await client.trading.get_positions(account_names=account_names, connector_names=connector_names, limit=limit) return f"Position Management Result: {result}" except Exception as e: logger.error(f"manage_positions failed: {str(e)}", exc_info=True) raise ToolError(f"Failed to manage positions: {str(e)}") + # Market Data Tools + @mcp.tool() -async def get_prices( - connector_name: str, - trading_pairs: List[str]) -> str: +async def get_prices(connector_name: str, trading_pairs: list[str]) -> str: """Get the latest prices for the specified trading pairs on a specific exchange connector. Args: connector_name: Exchange connector name (e.g., 'binance', 'binance_perpetual') @@ -320,12 +274,9 @@ async def get_prices( logger.error(f"get_prices failed: {str(e)}", exc_info=True) raise ToolError(f"Failed to get prices: {str(e)}") + @mcp.tool() -async def get_candles( - connector_name: str, - trading_pair: str, - interval: str = "1h", - days: int = 30) -> str: +async def get_candles(connector_name: str, trading_pair: str, interval: str = "1h", days: int = 30) -> str: """Get the real-time candles for a trading pair on a specific exchange connector. Args: connector_name: Exchange connector name (e.g., 'binance', 'binance_perpetual') @@ -337,10 +288,12 @@ async def get_candles( client = await hummingbot_client.get_client() available_candles_connectors = await client.market_data.get_available_candle_connectors() if connector_name not in available_candles_connectors: - raise ValueError(f"Connector '{connector_name}' does not support candle data. Available connectors: {available_candles_connectors}") + raise ValueError( + f"Connector '{connector_name}' does not support candle data. Available connectors: {available_candles_connectors}" + ) # Determine max records based on interval "m" is minute, "s" is second, "h" is hour, "d" is day, "w" is week if interval.endswith("m"): - max_records = 1440 * days # 1440 minutes in a day + max_records = 1440 * days # 1440 minutes in a day elif interval.endswith("h"): max_records = 24 * days elif interval.endswith("d"): @@ -352,20 +305,16 @@ async def get_candles( max_records = int(max_records / int(interval[:-1])) if interval[:-1] else max_records candles = await client.market_data.get_candles( - connector_name=connector_name, - trading_pair=trading_pair, - interval=interval, - max_records=max_records + connector_name=connector_name, trading_pair=trading_pair, interval=interval, max_records=max_records ) return f"Candle results: {candles}" except Exception as e: logger.error(f"get_candles failed: {str(e)}", exc_info=True) raise ToolError(f"Failed to get candles: {str(e)}") + @mcp.tool() -async def get_funding_rate( - connector_name: str, - trading_pair: str) -> str: +async def get_funding_rate(connector_name: str, trading_pair: str) -> str: """Get the latest funding rate for a trading pair on a specific exchange connector. Only works for perpetual connectors so the connector name must have _perpetual in it. Args: @@ -375,27 +324,34 @@ async def get_funding_rate( try: client = await hummingbot_client.get_client() if "_perpetual" not in connector_name: - raise ValueError(f"Connector '{connector_name}' is not a perpetual connector. Funding rates are only available for perpetual connectors.") + raise ValueError( + f"Connector '{connector_name}' is not a perpetual connector. Funding rates are only available for" + f"perpetual connectors." + ) funding_rate = await client.market_data.get_funding_info(connector_name=connector_name, trading_pair=trading_pair) return f"Funding Rate: {funding_rate}" except Exception as e: logger.error(f"get_funding_rate failed: {str(e)}", exc_info=True) raise ToolError(f"Failed to get funding rate: {str(e)}") + @mcp.tool() async def get_order_book( - connector_name: str, - trading_pair: str, - query_type: Literal["snapshot", "volume_for_price", "price_for_volume", "quote_volume_for_price", "price_for_quote_volume"], - query_value: Optional[float] = None, + connector_name: str, + trading_pair: str, + query_type: Literal["snapshot", "volume_for_price", "price_for_volume", "quote_volume_for_price", "price_for_quote_volume"], + query_value: float | None = None, + is_buy: bool = True, ) -> str: - """Get order book data for a trading pair on a specific exchange connector, if the typ - + """Get order book data for a trading pair on a specific exchange connector, if the query type is different than + snapshot, you need to provide query_value and is_buy Args: connector_name: Connector name (e.g., 'binance', 'binance_perpetual') trading_pair: Trading pair (e.g., BTC-USDT) - query_type: Order book query type ('snapshot', 'volume_for_price', 'price_for_volume', 'quote_volume_for_price', 'price_for_quote_volume') + query_type: Order book query type ('snapshot', 'volume_for_price', 'price_for_volume', 'quote_volume_for_price', + 'price_for_quote_volume') query_value: Only required if query_type is not 'snapshot'. The value to query against the order book. + is_buy: Only required if query_type is not 'snapshot'. Is important to see what orders of the book analyze. """ try: client = await hummingbot_client.get_client() @@ -406,13 +362,21 @@ async def get_order_book( if query_value is None: raise ValueError(f"query_value must be provided for query_type '{query_type}'") if query_type == "volume_for_price": - result = await client.market_data.get_volume_for_price(connector_name=connector_name, trading_pair=trading_pair, price=query_value) + result = await client.market_data.get_volume_for_price( + connector_name=connector_name, trading_pair=trading_pair, price=query_value, is_buy=is_buy + ) elif query_type == "price_for_volume": - result = await client.market_data.get_price_for_volume(connector_name=connector_name, trading_pair=trading_pair, volume=query_value) + result = await client.market_data.get_price_for_volume( + connector_name=connector_name, trading_pair=trading_pair, volume=query_value, is_buy=is_buy + ) elif query_type == "quote_volume_for_price": - result = await client.market_data.get_quote_volume_for_price(connector_name=connector_name, trading_pair=trading_pair, price=query_value) + result = await client.market_data.get_quote_volume_for_price( + connector_name=connector_name, trading_pair=trading_pair, price=query_value, is_buy=is_buy + ) elif query_type == "price_for_quote_volume": - result = await client.market_data.get_price_for_quote_volume(connector_name=connector_name, trading_pair=trading_pair, quote_volume=query_value) + result = await client.market_data.get_price_for_quote_volume( + connector_name=connector_name, trading_pair=trading_pair, quote_volume=query_value, is_buy=is_buy + ) else: raise ValueError(f"Unsupported query type: {query_type}") return f"Order Book Query Result: {result}" @@ -421,6 +385,133 @@ async def get_order_book( raise ToolError(f"Failed to get market data: {str(e)}") +@mcp.tool() +async def manage_controller_configs( + action: Literal["list", "get", "upsert", "delete"], + config_name: str | None = None, + config_data: dict[str, Any] | None = None, +) -> str: + """ + Manage controller configurations for Hummingbot MCP. If action is + - 'list': will return all controller configs. + - 'get': will return the config for the given config_name. + - 'upsert': will create a controller config (if it doesn't exist) or update the config for the given config_name + with the provided config_data, is important to know that the config_name should be the same as the value of 'id' + in the config data. In order to create a config properly you can use the 'get' action to get the controller code + and understand how to configure it. + - 'delete': will delete the config for the given config_name. + Args: + action: Action to perform ('list', 'get', 'upsert', 'delete') + config_name: Name of the controller config to manage (required for 'get', 'upsert', 'delete') + config_data: Data for the controller config (required for 'upsert') + """ + try: + client = await hummingbot_client.get_client() + if action == "list": + configs = await client.controllers.list_controller_configs() + return f"Controller Configs: {configs}" + elif action == "get": + if not config_name: + raise ValueError("config_name is required for 'get' action") + config = await client.controllers.get_controller_config(config_name) + return f"Controller Config: {config}" + elif action == "upsert": + if not config_name or not config_data: + raise ValueError("config_name and config_data are required for 'upsert' action") + if "id" not in config_data or config_data["id"] != config_name: + config_data["id"] = config_name + result = await client.controllers.create_or_update_controller_config(config_name, config_data) + return f"Controller Config Upserted: {result}" + elif action == "delete": + if not config_name: + raise ValueError("config_name is required for 'delete' action") + result = await client.controllers.delete_controller_config(config_name) + await client.bot_orchestration.deploy_v2_controllers() + return f"Controller Config Deleted: {result}" + else: + raise ValueError("Invalid action. Must be 'list', 'get', 'upsert', or 'delete'.") + except HBConnectionError as e: + logger.error(f"Failed to connect to Hummingbot API: {e}") + raise ToolError("Failed to connect to Hummingbot API. Please ensure it is running and API credentials are correct.") + +@mcp.tool() +async def manage_controllers( + action: Literal["list", "get", "upsert", "delete"], + controller_type: Optional[Literal["directional_trading", "market_making", "generic"]] = None, + controller_name: Optional[str] = None, + controller_code: Optional[str] = None, +) -> str: + """ + Manage controller files (controllers are substrategies). + If action is: + - 'list': will show all the controllers available by type. + - 'get': will get the code of the controller, this will be really useful when trying to create a controller. + configuration since you can understand how each parameter is used. + - 'upsert': you can modify the code of a controller or add it if it doesn't exist. + - 'delete': delete a controller + + Args: + action: Action to perform ('list', 'get', 'upsert', 'delete') + controller_type: ("directional_trading", "market_making", "generic") is required for the actions 'get', 'upsert' and 'delete'. + controller_name: Name of the controller to manage (required for 'get', 'upsert', 'delete') + controller_code: Code to update, only required for the action 'upsert'. + """ + try: + client = await hummingbot_client.get_client() + if action == "list": + result = await client.controllers.list_controllers() + return f"Available controllers: {result}" + elif action == "get": + result = await client.controllers.get_controller(controller_type, controller_name) + return f"Controller code: {result}" + elif action == "upsert": + result = await client.controllers.create_or_update_controller(controller_type, controller_name, controller_code) + return f"Upsert operation: {result}" + elif action == "delete": + result = await client.controllers.delete_controller(controller_type, controller_name) + return f"Delete operation: {result}" + else: + raise ValueError("Invalid action. Must be 'list', 'get', 'upsert', or 'delete'.") + except HBConnectionError as e: + logger.error(f"Failed to connect to Hummingbot API: {e}") + raise ToolError("Failed to connect to Hummingbot API. Please ensure it is running and API credentials are correct.") + + +@mcp.tool() +async def deploy_bot_with_controllers( + bot_name: str, + controller_configs: list[str], + account_name: str | None = "master_account", + max_global_drawdown_quote: float | None = None, + max_controller_drawdown_quote: float | None = None, + image: str = "hummingbot/hummingbot:latest", +) -> str: + """Deploy a bot with specified controller configurations. + Args: + bot_name: Name of the bot to deploy + controller_configs: List of controller configs to use for the bot deployment. + account_name: Account name to use for the bot (default: master_account) + max_global_drawdown_quote: Maximum global drawdown in quote currency (optional) defaults to None. + max_controller_drawdown_quote: Maximum drawdown per controller in quote currency (optional) defaults to None. + image: Docker image to use for the bot (default: "hummingbot/hummingbot:latest") + """ + try: + client = await hummingbot_client.get_client() + # Validate controller configs + result = await client.bot_orchestration.deploy_v2_controllers( + instance_name=bot_name, + controller_configs=controller_configs, + credentials_profile=account_name, + max_global_drawdown_quote=max_global_drawdown_quote, + max_controller_drawdown_quote=max_controller_drawdown_quote, + image=image, + ) + return f"Bot Deployment Result: {result}" + except HBConnectionError as e: + logger.error(f"Failed to connect to Hummingbot API: {e}") + raise ToolError("Failed to connect to Hummingbot API. Please ensure it is running and API credentials are correct.") + + async def main(): """Run the MCP server""" # Setup logging once at application start diff --git a/mcp_hummingbot/settings.py b/hummingbot_mcp/settings.py similarity index 76% rename from mcp_hummingbot/settings.py rename to hummingbot_mcp/settings.py index 3e5cc32..d3d8b44 100644 --- a/mcp_hummingbot/settings.py +++ b/hummingbot_mcp/settings.py @@ -3,42 +3,43 @@ """ import os -from pydantic import BaseModel, Field, field_validator + import aiohttp +from pydantic import BaseModel, Field, field_validator -from mcp_hummingbot.exceptions import ConfigurationError +from hummingbot_mcp.exceptions import ConfigurationError class Settings(BaseModel): """Application settings""" - + # API Configuration api_url: str = Field(default="http://localhost:8000") api_username: str = Field(default="admin") - api_password: str = Field(default="admin") + api_password: str = Field(default="admin") default_account: str = Field(default="master_account") - + # Connection settings connection_timeout: float = Field(default=30.0) max_retries: int = Field(default=3) retry_delay: float = Field(default=2.0) - + # Logging log_level: str = Field(default="INFO") - - @field_validator('api_url', mode='before') + + @field_validator("api_url", mode="before") def validate_api_url(cls, v): - if not v.startswith(('http://', 'https://')): - raise ValueError('API URL must start with http:// or https://') + if not v.startswith(("http://", "https://")): + raise ValueError("API URL must start with http:// or https://") return v - - @field_validator('log_level', mode='before') + + @field_validator("log_level", mode="before") def validate_log_level(cls, v): - valid_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] + valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] if v.upper() not in valid_levels: - raise ValueError(f'Log level must be one of: {valid_levels}') + raise ValueError(f"Log level must be one of: {valid_levels}") return v.upper() - + @property def client_timeout(self) -> aiohttp.ClientTimeout: """Get aiohttp ClientTimeout object""" @@ -62,4 +63,4 @@ def get_settings() -> Settings: # Global settings instance -settings = get_settings() \ No newline at end of file +settings = get_settings() diff --git a/mcp_hummingbot/tools/__init__.py b/hummingbot_mcp/tools/__init__.py similarity index 79% rename from mcp_hummingbot/tools/__init__.py rename to hummingbot_mcp/tools/__init__.py index 5acee80..1a7ec3c 100644 --- a/mcp_hummingbot/tools/__init__.py +++ b/hummingbot_mcp/tools/__init__.py @@ -7,4 +7,4 @@ setup_connector, ] -__all__ = ["TOOLS", "setup_connector"] \ No newline at end of file +__all__ = ["TOOLS", "setup_connector"] diff --git a/mcp_hummingbot/tools/account.py b/hummingbot_mcp/tools/account.py similarity index 80% rename from mcp_hummingbot/tools/account.py rename to hummingbot_mcp/tools/account.py index f20bc1d..e004049 100644 --- a/mcp_hummingbot/tools/account.py +++ b/hummingbot_mcp/tools/account.py @@ -2,77 +2,81 @@ Account management tools for Hummingbot MCP Server """ -from typing import Dict, Any, Optional +import logging +from typing import Any from pydantic import BaseModel, Field, field_validator -from mcp_hummingbot.hummingbot_client import hummingbot_client -from mcp_hummingbot.settings import settings -from mcp_hummingbot.exceptions import ToolError -import logging + +from hummingbot_mcp.exceptions import ToolError +from hummingbot_mcp.hummingbot_client import hummingbot_client +from hummingbot_mcp.settings import settings logger = logging.getLogger("hummingbot-mcp") class SetupConnectorRequest(BaseModel): """Request model for setting up exchange connectors with progressive disclosure. - + This model supports a four-step flow: 1. No parameters → List available exchanges - 2. Connector only → Show required credential fields + 2. Connector only → Show required credential fields 3. Connector + credentials, no account → Select account from available accounts 4. All parameters → Connect the exchange (with override confirmation if needed) """ - - account: Optional[str] = Field( - default=None, - description="Account name to add credentials to. If not provided, uses the default account." + + account: str | None = Field( + default=None, description="Account name to add credentials to. If not provided, uses the default account." ) - - connector: Optional[str] = Field( + + connector: str | None = Field( default=None, description="Exchange connector name (e.g., 'binance', 'coinbase_pro'). Leave empty to list available connectors.", - examples=["binance", "coinbase_pro", "kraken", "gate_io"] + examples=["binance", "coinbase_pro", "kraken", "gate_io"], ) - - credentials: Optional[Dict[str, Any]] = Field( + + credentials: dict[str, Any] | None = Field( default=None, description="Credentials object with required fields for the connector. Leave empty to see required fields first.", examples=[ {"binance_api_key": "your_api_key", "binance_secret_key": "your_secret"}, - {"coinbase_pro_api_key": "your_key", "coinbase_pro_secret_key": "your_secret", "coinbase_pro_passphrase": "your_passphrase"} - ] + { + "coinbase_pro_api_key": "your_key", + "coinbase_pro_secret_key": "your_secret", + "coinbase_pro_passphrase": "your_passphrase", + }, + ], ) - - confirm_override: Optional[bool] = Field( + + confirm_override: bool | None = Field( default=None, - description="Explicit confirmation to override existing connector. Required when connector already exists." + description="Explicit confirmation to override existing connector. Required when connector already exists.", ) - - @field_validator('connector') + + @field_validator("connector") @classmethod - def validate_connector_name(cls, v: Optional[str]) -> Optional[str]: + def validate_connector_name(cls, v: str | None) -> str | None: """Validate connector name format if provided""" if v is not None: # Convert to lowercase and replace spaces/hyphens with underscores - v = v.lower().replace(' ', '_').replace('-', '_') - + v = v.lower().replace(" ", "_").replace("-", "_") + # Basic validation - should be alphanumeric with underscores - if not v.replace('_', '').isalnum(): + if not v.replace("_", "").isalnum(): raise ValueError("Connector name should contain only letters, numbers, and underscores") - + return v - - @field_validator('credentials') + + @field_validator("credentials") @classmethod - def validate_credentials(cls, v: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + def validate_credentials(cls, v: dict[str, Any] | None) -> dict[str, Any] | None: """Validate credentials format if provided""" if v is not None: if not isinstance(v, dict): raise ValueError("Credentials must be a dictionary/object") - + if not v: # Empty dict raise ValueError("Credentials cannot be empty. Omit the field to see required fields.") - + # Check that all values are strings (typical for API credentials) # except for force_override which can be boolean for key, value in v.items(): @@ -84,13 +88,13 @@ def validate_credentials(cls, v: Optional[Dict[str, Any]]) -> Optional[Dict[str, raise ValueError(f"Credential '{key}' must be a string") if not value.strip(): # Empty or whitespace-only raise ValueError(f"Credential '{key}' cannot be empty") - + return v - + def get_account_name(self) -> str: """Get account name with fallback to default""" return self.account or settings.default_account - + def get_flow_stage(self) -> str: """Determine which stage of the setup flow we're in""" @@ -102,11 +106,10 @@ def get_flow_stage(self) -> str: return "select_account" else: return "connect" - + def requires_override_confirmation(self) -> bool: """Check if this request needs override confirmation""" - return (self.credentials is not None and - self.confirm_override is None) + return self.credentials is not None and self.confirm_override is None async def _check_existing_connector(account_name: str, connector_name: str) -> bool: @@ -114,7 +117,7 @@ async def _check_existing_connector(account_name: str, connector_name: str) -> b try: client = await hummingbot_client.get_client() credentials = await client.accounts.list_account_credentials(account_name=account_name) - + # Check if the account exists and has the connector return connector_name in credentials except Exception as e: @@ -122,23 +125,23 @@ async def _check_existing_connector(account_name: str, connector_name: str) -> b return False -async def setup_connector(request: SetupConnectorRequest) -> Dict[str, Any]: +async def setup_connector(request: SetupConnectorRequest) -> dict[str, Any]: """Setup a new exchange connector with credentials using progressive disclosure. - + This function handles four different flows based on the provided parameters: 1. No connector → List available exchanges - 2. Connector only → Show required credential fields + 2. Connector only → Show required credential fields 3. Connector + credentials, no account → Select account from available accounts 4. All parameters → Connect the exchange (with override confirmation if needed) """ try: client = await hummingbot_client.get_client() flow_stage = request.get_flow_stage() - + if flow_stage == "select_account": # Step 2.5: List available accounts for selection (after connector and credentials are provided) accounts = await client.accounts.list_accounts() - + return { "action": "select_account", "message": f"Ready to connect {request.connector}. Please select an account:", @@ -146,57 +149,58 @@ async def setup_connector(request: SetupConnectorRequest) -> Dict[str, Any]: "accounts": accounts, "default_account": settings.default_account, "next_step": "Call again with 'account' parameter to specify which account to use", - "example": f"Use account='{settings.default_account}' to use the default account, or choose from the available accounts above" + "example": f"Use account='{settings.default_account}' to use the default account, or choose from " + f"the available accounts above", } - + elif flow_stage == "list_exchanges": # Step 1: List available connectors connectors = await client.connectors.list_connectors() - + # Handle both string and object responses from the API connector_names = [] for c in connectors: if isinstance(c, str): connector_names.append(c) - elif hasattr(c, 'name'): + elif hasattr(c, "name"): connector_names.append(c.name) else: connector_names.append(str(c)) - + return { "action": "list_connectors", "message": "Available exchange connectors:", "connectors": connector_names, "total_connectors": len(connector_names), "next_step": "Call again with 'connector' parameter to see required credentials for a specific exchange", - "example": "Use connector='binance' to see Binance setup requirements" + "example": "Use connector='binance' to see Binance setup requirements", } - + elif flow_stage == "show_config": # Step 2: Show required credential fields for the connector try: config_fields = await client.connectors.get_config_map(request.connector) - + # Build a dictionary from the list of field names credentials_dict = {field: f"your_{field}" for field in config_fields} - + return { "action": "show_config_map", "connector": request.connector, "required_fields": config_fields, "next_step": "Call again with 'credentials' parameter containing the required fields", - "example": f"Use credentials={credentials_dict} to connect" + "example": f"Use credentials={credentials_dict} to connect", } except Exception as e: raise ToolError(f"Failed to get configuration for connector '{request.connector}': {str(e)}") - + elif flow_stage == "connect": # Step 3: Actually connect the exchange with provided credentials account_name = request.get_account_name() - + # Check if connector already exists connector_exists = await _check_existing_connector(account_name, request.connector) - + if connector_exists and request.requires_override_confirmation(): return { "action": "requires_confirmation", @@ -205,48 +209,46 @@ async def setup_connector(request: SetupConnectorRequest) -> Dict[str, Any]: "connector": request.connector, "warning": "Adding credentials will override the existing connector configuration", "next_step": "To proceed with overriding, add 'confirm_override': true to your request", - "example": f"Use confirm_override=true along with your credentials to override the existing connector" + "example": "Use confirm_override=true along with your credentials to override the existing connector", } - + if connector_exists and not request.confirm_override: return { "action": "override_rejected", - "message": f"Cannot override existing connector '{request.connector}' without explicit confirmation", + "message": f"Cannot override existing connector {request.connector} without explicit confirmation", "account": account_name, "connector": request.connector, - "next_step": "Set confirm_override=true to override the existing connector" + "next_step": "Set confirm_override=true to override the existing connector", } - + # Remove force_override from credentials before sending to API credentials_to_send = dict(request.credentials) if "force_override" in credentials_to_send: del credentials_to_send["force_override"] - + try: await client.accounts.add_credential( - account_name=account_name, - connector_name=request.connector, - credentials=credentials_to_send + account_name=account_name, connector_name=request.connector, credentials=credentials_to_send ) - + action_type = "credentials_overridden" if connector_exists else "credentials_added" message_action = "overridden" if connector_exists else "connected" - + return { "action": action_type, - "message": f"Successfully {message_action} {request.connector} exchange to account '{account_name}'", + "message": f"Successfully {message_action} {request.connector} exchange to account {account_name}", "account": account_name, "connector": request.connector, "credentials_count": len(credentials_to_send), "was_existing": connector_exists, - "next_step": "Exchange is now ready for trading. Use get_account_state to verify the connection." + "next_step": "Exchange is now ready for trading. Use get_account_state to verify the connection.", } except Exception as e: raise ToolError(f"Failed to add credentials for {request.connector}: {str(e)}") - + else: raise ToolError(f"Unknown flow stage: {flow_stage}") - + except Exception as e: if isinstance(e, ToolError): raise diff --git a/main.py b/main.py index 1940b87..de781b3 100644 --- a/main.py +++ b/main.py @@ -4,12 +4,12 @@ """ import asyncio + from dotenv import load_dotenv -# Load environment variables before importing anything else -load_dotenv() +from hummingbot_mcp import main -from mcp_hummingbot import main +load_dotenv() if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 9b5d3b9..75a5e62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,21 +1,54 @@ [project] -name = "mcp_hummingbot" +name = "hummingbot_mcp" version = "0.1.0" description = "MCP server for Hummingbot API integration - manage crypto trading across multiple exchanges" readme = "README.md" requires-python = ">=3.11" +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] dependencies = [ - "aiohttp>=3.11.16", "hummingbot-api-client==1.1.2", "mcp[cli]>=1.6.0", - "pandas>=2.2.3", "pydantic>=2.11.2", "python-dotenv>=1.0.0", ] +[project.urls] +Homepage = "https://github.com/hummingbot/mcp" +Repository = "https://github.com/hummingbot/mcp" +Issues = "https://github.com/hummingbot/mcp/issues" + [project.scripts] -mcp-hummingbot = "mcp_hummingbot:main" +mcp-hummingbot = "hummingbot_mcp:main" [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" + +[tool.ruff] +line-length = 140 + +[tool.ruff.lint] +select = [ + "E", # https://docs.astral.sh/ruff/rules/#error-e + "F", # https://docs.astral.sh/ruff/rules/#pyflakes-f + "I", # https://docs.astral.sh/ruff/rules/#isort-i + "FA", # https://docs.astral.sh/ruff/rules/#flake8-future-annotations-fa + "UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up + "RUF100", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf +] +ignore = ["UP031"] # https://docs.astral.sh/ruff/rules/printf-string-formatting/ + +[tool.ruff.lint.isort] +combine-as-imports = true + +[dependency-groups] +dev = ["ruff"] diff --git a/uv.lock b/uv.lock index e4eaf93..1a9bbb5 100644 --- a/uv.lock +++ b/uv.lock @@ -265,6 +265,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/39/23ce2aa90a083d602ec4f4761649cd5bf35e6736f9d958e4dcb38110e473/hummingbot_api_client-1.1.2-py3-none-any.whl", hash = "sha256:46d1c0f57f9bcf4f6ac8688b5c17b0459c2e0429dc9bb41911836e43f0325460", size = 29212 }, ] +[[package]] +name = "hummingbot-mcp" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "hummingbot-api-client" }, + { name = "mcp", extra = ["cli"] }, + { name = "pydantic" }, + { name = "python-dotenv" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "hummingbot-api-client", specifier = "==1.1.2" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.6.0" }, + { name = "pydantic", specifier = ">=2.11.2" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "ruff" }] + [[package]] name = "idna" version = "3.10" @@ -311,29 +338,6 @@ cli = [ { name = "typer" }, ] -[[package]] -name = "mcp-hummingbot" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "aiohttp" }, - { name = "hummingbot-api-client" }, - { name = "mcp", extra = ["cli"] }, - { name = "pandas" }, - { name = "pydantic" }, - { name = "python-dotenv" }, -] - -[package.metadata] -requires-dist = [ - { name = "aiohttp", specifier = ">=3.11.16" }, - { name = "hummingbot-api-client", specifier = "==1.1.2" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.6.0" }, - { name = "pandas", specifier = ">=2.2.3" }, - { name = "pydantic", specifier = ">=2.11.2" }, - { name = "python-dotenv", specifier = ">=1.0.0" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -412,95 +416,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/c1/7832c95a50641148b567b5366dd3354489950dcfd01c8fc28472bec63b9a/multidict-6.3.2-py3-none-any.whl", hash = "sha256:71409d4579f716217f23be2f5e7afca5ca926aaeb398aa11b72d793bff637a1f", size = 10347 }, ] -[[package]] -name = "numpy" -version = "2.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/78/31103410a57bc2c2b93a3597340a8119588571f6a4539067546cb9a0bfac/numpy-2.2.4.tar.gz", hash = "sha256:9ba03692a45d3eef66559efe1d1096c4b9b75c0986b5dff5530c378fb8331d4f", size = 20270701 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/fb/09e778ee3a8ea0d4dc8329cca0a9c9e65fed847d08e37eba74cb7ed4b252/numpy-2.2.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e9e0a277bb2eb5d8a7407e14688b85fd8ad628ee4e0c7930415687b6564207a4", size = 21254989 }, - { url = "https://files.pythonhosted.org/packages/a2/0a/1212befdbecab5d80eca3cde47d304cad986ad4eec7d85a42e0b6d2cc2ef/numpy-2.2.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9eeea959168ea555e556b8188da5fa7831e21d91ce031e95ce23747b7609f8a4", size = 14425910 }, - { url = "https://files.pythonhosted.org/packages/2b/3e/e7247c1d4f15086bb106c8d43c925b0b2ea20270224f5186fa48d4fb5cbd/numpy-2.2.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bd3ad3b0a40e713fc68f99ecfd07124195333f1e689387c180813f0e94309d6f", size = 5426490 }, - { url = "https://files.pythonhosted.org/packages/5d/fa/aa7cd6be51419b894c5787a8a93c3302a1ed4f82d35beb0613ec15bdd0e2/numpy-2.2.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cf28633d64294969c019c6df4ff37f5698e8326db68cc2b66576a51fad634880", size = 6967754 }, - { url = "https://files.pythonhosted.org/packages/d5/ee/96457c943265de9fadeb3d2ffdbab003f7fba13d971084a9876affcda095/numpy-2.2.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fa8fa7697ad1646b5c93de1719965844e004fcad23c91228aca1cf0800044a1", size = 14373079 }, - { url = "https://files.pythonhosted.org/packages/c5/5c/ceefca458559f0ccc7a982319f37ed07b0d7b526964ae6cc61f8ad1b6119/numpy-2.2.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4162988a360a29af158aeb4a2f4f09ffed6a969c9776f8f3bdee9b06a8ab7e5", size = 16428819 }, - { url = "https://files.pythonhosted.org/packages/22/31/9b2ac8eee99e001eb6add9fa27514ef5e9faf176169057a12860af52704c/numpy-2.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:892c10d6a73e0f14935c31229e03325a7b3093fafd6ce0af704be7f894d95687", size = 15881470 }, - { url = "https://files.pythonhosted.org/packages/f0/dc/8569b5f25ff30484b555ad8a3f537e0225d091abec386c9420cf5f7a2976/numpy-2.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db1f1c22173ac1c58db249ae48aa7ead29f534b9a948bc56828337aa84a32ed6", size = 18218144 }, - { url = "https://files.pythonhosted.org/packages/5e/05/463c023a39bdeb9bb43a99e7dee2c664cb68d5bb87d14f92482b9f6011cc/numpy-2.2.4-cp311-cp311-win32.whl", hash = "sha256:ea2bb7e2ae9e37d96835b3576a4fa4b3a97592fbea8ef7c3587078b0068b8f09", size = 6606368 }, - { url = "https://files.pythonhosted.org/packages/8b/72/10c1d2d82101c468a28adc35de6c77b308f288cfd0b88e1070f15b98e00c/numpy-2.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:f7de08cbe5551911886d1ab60de58448c6df0f67d9feb7d1fb21e9875ef95e91", size = 12947526 }, - { url = "https://files.pythonhosted.org/packages/a2/30/182db21d4f2a95904cec1a6f779479ea1ac07c0647f064dea454ec650c42/numpy-2.2.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a7b9084668aa0f64e64bd00d27ba5146ef1c3a8835f3bd912e7a9e01326804c4", size = 20947156 }, - { url = "https://files.pythonhosted.org/packages/24/6d/9483566acfbda6c62c6bc74b6e981c777229d2af93c8eb2469b26ac1b7bc/numpy-2.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dbe512c511956b893d2dacd007d955a3f03d555ae05cfa3ff1c1ff6df8851854", size = 14133092 }, - { url = "https://files.pythonhosted.org/packages/27/f6/dba8a258acbf9d2bed2525cdcbb9493ef9bae5199d7a9cb92ee7e9b2aea6/numpy-2.2.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:bb649f8b207ab07caebba230d851b579a3c8711a851d29efe15008e31bb4de24", size = 5163515 }, - { url = "https://files.pythonhosted.org/packages/62/30/82116199d1c249446723c68f2c9da40d7f062551036f50b8c4caa42ae252/numpy-2.2.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:f34dc300df798742b3d06515aa2a0aee20941c13579d7a2f2e10af01ae4901ee", size = 6696558 }, - { url = "https://files.pythonhosted.org/packages/0e/b2/54122b3c6df5df3e87582b2e9430f1bdb63af4023c739ba300164c9ae503/numpy-2.2.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3f7ac96b16955634e223b579a3e5798df59007ca43e8d451a0e6a50f6bfdfba", size = 14084742 }, - { url = "https://files.pythonhosted.org/packages/02/e2/e2cbb8d634151aab9528ef7b8bab52ee4ab10e076509285602c2a3a686e0/numpy-2.2.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f92084defa704deadd4e0a5ab1dc52d8ac9e8a8ef617f3fbb853e79b0ea3592", size = 16134051 }, - { url = "https://files.pythonhosted.org/packages/8e/21/efd47800e4affc993e8be50c1b768de038363dd88865920439ef7b422c60/numpy-2.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4e84a6283b36632e2a5b56e121961f6542ab886bc9e12f8f9818b3c266bfbb", size = 15578972 }, - { url = "https://files.pythonhosted.org/packages/04/1e/f8bb88f6157045dd5d9b27ccf433d016981032690969aa5c19e332b138c0/numpy-2.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:11c43995255eb4127115956495f43e9343736edb7fcdb0d973defd9de14cd84f", size = 17898106 }, - { url = "https://files.pythonhosted.org/packages/2b/93/df59a5a3897c1f036ae8ff845e45f4081bb06943039ae28a3c1c7c780f22/numpy-2.2.4-cp312-cp312-win32.whl", hash = "sha256:65ef3468b53269eb5fdb3a5c09508c032b793da03251d5f8722b1194f1790c00", size = 6311190 }, - { url = "https://files.pythonhosted.org/packages/46/69/8c4f928741c2a8efa255fdc7e9097527c6dc4e4df147e3cadc5d9357ce85/numpy-2.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:2aad3c17ed2ff455b8eaafe06bcdae0062a1db77cb99f4b9cbb5f4ecb13c5146", size = 12644305 }, - { url = "https://files.pythonhosted.org/packages/2a/d0/bd5ad792e78017f5decfb2ecc947422a3669a34f775679a76317af671ffc/numpy-2.2.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cf4e5c6a278d620dee9ddeb487dc6a860f9b199eadeecc567f777daace1e9e7", size = 20933623 }, - { url = "https://files.pythonhosted.org/packages/c3/bc/2b3545766337b95409868f8e62053135bdc7fa2ce630aba983a2aa60b559/numpy-2.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1974afec0b479e50438fc3648974268f972e2d908ddb6d7fb634598cdb8260a0", size = 14148681 }, - { url = "https://files.pythonhosted.org/packages/6a/70/67b24d68a56551d43a6ec9fe8c5f91b526d4c1a46a6387b956bf2d64744e/numpy-2.2.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:79bd5f0a02aa16808fcbc79a9a376a147cc1045f7dfe44c6e7d53fa8b8a79392", size = 5148759 }, - { url = "https://files.pythonhosted.org/packages/1c/8b/e2fc8a75fcb7be12d90b31477c9356c0cbb44abce7ffb36be39a0017afad/numpy-2.2.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:3387dd7232804b341165cedcb90694565a6015433ee076c6754775e85d86f1fc", size = 6683092 }, - { url = "https://files.pythonhosted.org/packages/13/73/41b7b27f169ecf368b52533edb72e56a133f9e86256e809e169362553b49/numpy-2.2.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f527d8fdb0286fd2fd97a2a96c6be17ba4232da346931d967a0630050dfd298", size = 14081422 }, - { url = "https://files.pythonhosted.org/packages/4b/04/e208ff3ae3ddfbafc05910f89546382f15a3f10186b1f56bd99f159689c2/numpy-2.2.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce43e386c16898b91e162e5baaad90c4b06f9dcbe36282490032cec98dc8ae7", size = 16132202 }, - { url = "https://files.pythonhosted.org/packages/fe/bc/2218160574d862d5e55f803d88ddcad88beff94791f9c5f86d67bd8fbf1c/numpy-2.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31504f970f563d99f71a3512d0c01a645b692b12a63630d6aafa0939e52361e6", size = 15573131 }, - { url = "https://files.pythonhosted.org/packages/a5/78/97c775bc4f05abc8a8426436b7cb1be806a02a2994b195945600855e3a25/numpy-2.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:81413336ef121a6ba746892fad881a83351ee3e1e4011f52e97fba79233611fd", size = 17894270 }, - { url = "https://files.pythonhosted.org/packages/b9/eb/38c06217a5f6de27dcb41524ca95a44e395e6a1decdc0c99fec0832ce6ae/numpy-2.2.4-cp313-cp313-win32.whl", hash = "sha256:f486038e44caa08dbd97275a9a35a283a8f1d2f0ee60ac260a1790e76660833c", size = 6308141 }, - { url = "https://files.pythonhosted.org/packages/52/17/d0dd10ab6d125c6d11ffb6dfa3423c3571befab8358d4f85cd4471964fcd/numpy-2.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:207a2b8441cc8b6a2a78c9ddc64d00d20c303d79fba08c577752f080c4007ee3", size = 12636885 }, - { url = "https://files.pythonhosted.org/packages/fa/e2/793288ede17a0fdc921172916efb40f3cbc2aa97e76c5c84aba6dc7e8747/numpy-2.2.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8120575cb4882318c791f839a4fd66161a6fa46f3f0a5e613071aae35b5dd8f8", size = 20961829 }, - { url = "https://files.pythonhosted.org/packages/3a/75/bb4573f6c462afd1ea5cbedcc362fe3e9bdbcc57aefd37c681be1155fbaa/numpy-2.2.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a761ba0fa886a7bb33c6c8f6f20213735cb19642c580a931c625ee377ee8bd39", size = 14161419 }, - { url = "https://files.pythonhosted.org/packages/03/68/07b4cd01090ca46c7a336958b413cdbe75002286295f2addea767b7f16c9/numpy-2.2.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:ac0280f1ba4a4bfff363a99a6aceed4f8e123f8a9b234c89140f5e894e452ecd", size = 5196414 }, - { url = "https://files.pythonhosted.org/packages/a5/fd/d4a29478d622fedff5c4b4b4cedfc37a00691079623c0575978d2446db9e/numpy-2.2.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:879cf3a9a2b53a4672a168c21375166171bc3932b7e21f622201811c43cdd3b0", size = 6709379 }, - { url = "https://files.pythonhosted.org/packages/41/78/96dddb75bb9be730b87c72f30ffdd62611aba234e4e460576a068c98eff6/numpy-2.2.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f05d4198c1bacc9124018109c5fba2f3201dbe7ab6e92ff100494f236209c960", size = 14051725 }, - { url = "https://files.pythonhosted.org/packages/00/06/5306b8199bffac2a29d9119c11f457f6c7d41115a335b78d3f86fad4dbe8/numpy-2.2.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f085ce2e813a50dfd0e01fbfc0c12bbe5d2063d99f8b29da30e544fb6483b8", size = 16101638 }, - { url = "https://files.pythonhosted.org/packages/fa/03/74c5b631ee1ded596945c12027649e6344614144369fd3ec1aaced782882/numpy-2.2.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:92bda934a791c01d6d9d8e038363c50918ef7c40601552a58ac84c9613a665bc", size = 15571717 }, - { url = "https://files.pythonhosted.org/packages/cb/dc/4fc7c0283abe0981e3b89f9b332a134e237dd476b0c018e1e21083310c31/numpy-2.2.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ee4d528022f4c5ff67332469e10efe06a267e32f4067dc76bb7e2cddf3cd25ff", size = 17879998 }, - { url = "https://files.pythonhosted.org/packages/e5/2b/878576190c5cfa29ed896b518cc516aecc7c98a919e20706c12480465f43/numpy-2.2.4-cp313-cp313t-win32.whl", hash = "sha256:05c076d531e9998e7e694c36e8b349969c56eadd2cdcd07242958489d79a7286", size = 6366896 }, - { url = "https://files.pythonhosted.org/packages/3e/05/eb7eec66b95cf697f08c754ef26c3549d03ebd682819f794cb039574a0a6/numpy-2.2.4-cp313-cp313t-win_amd64.whl", hash = "sha256:188dcbca89834cc2e14eb2f106c96d6d46f200fe0200310fc29089657379c58d", size = 12739119 }, -] - -[[package]] -name = "pandas" -version = "2.2.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/44/d9502bf0ed197ba9bf1103c9867d5904ddcaf869e52329787fc54ed70cc8/pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039", size = 12602222 }, - { url = "https://files.pythonhosted.org/packages/52/11/9eac327a38834f162b8250aab32a6781339c69afe7574368fffe46387edf/pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd", size = 11321274 }, - { url = "https://files.pythonhosted.org/packages/45/fb/c4beeb084718598ba19aa9f5abbc8aed8b42f90930da861fcb1acdb54c3a/pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698", size = 15579836 }, - { url = "https://files.pythonhosted.org/packages/cd/5f/4dba1d39bb9c38d574a9a22548c540177f78ea47b32f99c0ff2ec499fac5/pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc", size = 13058505 }, - { url = "https://files.pythonhosted.org/packages/b9/57/708135b90391995361636634df1f1130d03ba456e95bcf576fada459115a/pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3", size = 16744420 }, - { url = "https://files.pythonhosted.org/packages/86/4a/03ed6b7ee323cf30404265c284cee9c65c56a212e0a08d9ee06984ba2240/pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32", size = 14440457 }, - { url = "https://files.pythonhosted.org/packages/ed/8c/87ddf1fcb55d11f9f847e3c69bb1c6f8e46e2f40ab1a2d2abadb2401b007/pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5", size = 11617166 }, - { url = "https://files.pythonhosted.org/packages/17/a3/fb2734118db0af37ea7433f57f722c0a56687e14b14690edff0cdb4b7e58/pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9", size = 12529893 }, - { url = "https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4", size = 11363475 }, - { url = "https://files.pythonhosted.org/packages/c6/2a/4bba3f03f7d07207481fed47f5b35f556c7441acddc368ec43d6643c5777/pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3", size = 15188645 }, - { url = "https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319", size = 12739445 }, - { url = "https://files.pythonhosted.org/packages/20/e8/45a05d9c39d2cea61ab175dbe6a2de1d05b679e8de2011da4ee190d7e748/pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8", size = 16359235 }, - { url = "https://files.pythonhosted.org/packages/1d/99/617d07a6a5e429ff90c90da64d428516605a1ec7d7bea494235e1c3882de/pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a", size = 14056756 }, - { url = "https://files.pythonhosted.org/packages/29/d4/1244ab8edf173a10fd601f7e13b9566c1b525c4f365d6bee918e68381889/pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13", size = 11504248 }, - { url = "https://files.pythonhosted.org/packages/64/22/3b8f4e0ed70644e85cfdcd57454686b9057c6c38d2f74fe4b8bc2527214a/pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015", size = 12477643 }, - { url = "https://files.pythonhosted.org/packages/e4/93/b3f5d1838500e22c8d793625da672f3eec046b1a99257666c94446969282/pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28", size = 11281573 }, - { url = "https://files.pythonhosted.org/packages/f5/94/6c79b07f0e5aab1dcfa35a75f4817f5c4f677931d4234afcd75f0e6a66ca/pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0", size = 15196085 }, - { url = "https://files.pythonhosted.org/packages/e8/31/aa8da88ca0eadbabd0a639788a6da13bb2ff6edbbb9f29aa786450a30a91/pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24", size = 12711809 }, - { url = "https://files.pythonhosted.org/packages/ee/7c/c6dbdb0cb2a4344cacfb8de1c5808ca885b2e4dcfde8008266608f9372af/pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659", size = 16356316 }, - { url = "https://files.pythonhosted.org/packages/57/b7/8b757e7d92023b832869fa8881a992696a0bfe2e26f72c9ae9f255988d42/pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb", size = 14022055 }, - { url = "https://files.pythonhosted.org/packages/3b/bc/4b18e2b8c002572c5a441a64826252ce5da2aa738855747247a971988043/pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d", size = 11481175 }, - { url = "https://files.pythonhosted.org/packages/76/a3/a5d88146815e972d40d19247b2c162e88213ef51c7c25993942c39dbf41d/pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468", size = 12615650 }, - { url = "https://files.pythonhosted.org/packages/9c/8c/f0fd18f6140ddafc0c24122c8a964e48294acc579d47def376fef12bcb4a/pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18", size = 11290177 }, - { url = "https://files.pythonhosted.org/packages/ed/f9/e995754eab9c0f14c6777401f7eece0943840b7a9fc932221c19d1abee9f/pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2", size = 14651526 }, - { url = "https://files.pythonhosted.org/packages/25/b0/98d6ae2e1abac4f35230aa756005e8654649d305df9a28b16b9ae4353bff/pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4", size = 11871013 }, - { url = "https://files.pythonhosted.org/packages/cc/57/0f72a10f9db6a4628744c8e8f0df4e6e21de01212c7c981d31e50ffc8328/pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d", size = 15711620 }, - { url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436 }, -] - [[package]] name = "propcache" version = "0.3.1" @@ -676,18 +591,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, -] - [[package]] name = "python-dotenv" version = "1.1.0" @@ -697,15 +600,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, ] -[[package]] -name = "pytz" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, -] - [[package]] name = "rich" version = "14.0.0" @@ -720,21 +614,37 @@ wheels = [ ] [[package]] -name = "shellingham" -version = "1.5.4" +name = "ruff" +version = "0.12.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/81/0bd3594fa0f690466e41bd033bdcdf86cba8288345ac77ad4afbe5ec743a/ruff-0.12.7.tar.gz", hash = "sha256:1fc3193f238bc2d7968772c82831a4ff69252f673be371fb49663f0068b7ec71", size = 5197814 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, + { url = "https://files.pythonhosted.org/packages/e1/d2/6cb35e9c85e7a91e8d22ab32ae07ac39cc34a71f1009a6f9e4a2a019e602/ruff-0.12.7-py3-none-linux_armv6l.whl", hash = "sha256:76e4f31529899b8c434c3c1dede98c4483b89590e15fb49f2d46183801565303", size = 11852189 }, + { url = "https://files.pythonhosted.org/packages/63/5b/a4136b9921aa84638f1a6be7fb086f8cad0fde538ba76bda3682f2599a2f/ruff-0.12.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:789b7a03e72507c54fb3ba6209e4bb36517b90f1a3569ea17084e3fd295500fb", size = 12519389 }, + { url = "https://files.pythonhosted.org/packages/a8/c9/3e24a8472484269b6b1821794141f879c54645a111ded4b6f58f9ab0705f/ruff-0.12.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e1c2a3b8626339bb6369116e7030a4cf194ea48f49b64bb505732a7fce4f4e3", size = 11743384 }, + { url = "https://files.pythonhosted.org/packages/26/7c/458dd25deeb3452c43eaee853c0b17a1e84169f8021a26d500ead77964fd/ruff-0.12.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32dec41817623d388e645612ec70d5757a6d9c035f3744a52c7b195a57e03860", size = 11943759 }, + { url = "https://files.pythonhosted.org/packages/7f/8b/658798472ef260ca050e400ab96ef7e85c366c39cf3dfbef4d0a46a528b6/ruff-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47ef751f722053a5df5fa48d412dbb54d41ab9b17875c6840a58ec63ff0c247c", size = 11654028 }, + { url = "https://files.pythonhosted.org/packages/a8/86/9c2336f13b2a3326d06d39178fd3448dcc7025f82514d1b15816fe42bfe8/ruff-0.12.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a828a5fc25a3efd3e1ff7b241fd392686c9386f20e5ac90aa9234a5faa12c423", size = 13225209 }, + { url = "https://files.pythonhosted.org/packages/76/69/df73f65f53d6c463b19b6b312fd2391dc36425d926ec237a7ed028a90fc1/ruff-0.12.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5726f59b171111fa6a69d82aef48f00b56598b03a22f0f4170664ff4d8298efb", size = 14182353 }, + { url = "https://files.pythonhosted.org/packages/58/1e/de6cda406d99fea84b66811c189b5ea139814b98125b052424b55d28a41c/ruff-0.12.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74e6f5c04c4dd4aba223f4fe6e7104f79e0eebf7d307e4f9b18c18362124bccd", size = 13631555 }, + { url = "https://files.pythonhosted.org/packages/6f/ae/625d46d5164a6cc9261945a5e89df24457dc8262539ace3ac36c40f0b51e/ruff-0.12.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d0bfe4e77fba61bf2ccadf8cf005d6133e3ce08793bbe870dd1c734f2699a3e", size = 12667556 }, + { url = "https://files.pythonhosted.org/packages/55/bf/9cb1ea5e3066779e42ade8d0cd3d3b0582a5720a814ae1586f85014656b6/ruff-0.12.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06bfb01e1623bf7f59ea749a841da56f8f653d641bfd046edee32ede7ff6c606", size = 12939784 }, + { url = "https://files.pythonhosted.org/packages/55/7f/7ead2663be5627c04be83754c4f3096603bf5e99ed856c7cd29618c691bd/ruff-0.12.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e41df94a957d50083fd09b916d6e89e497246698c3f3d5c681c8b3e7b9bb4ac8", size = 11771356 }, + { url = "https://files.pythonhosted.org/packages/17/40/a95352ea16edf78cd3a938085dccc55df692a4d8ba1b3af7accbe2c806b0/ruff-0.12.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4000623300563c709458d0ce170c3d0d788c23a058912f28bbadc6f905d67afa", size = 11612124 }, + { url = "https://files.pythonhosted.org/packages/4d/74/633b04871c669e23b8917877e812376827c06df866e1677f15abfadc95cb/ruff-0.12.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:69ffe0e5f9b2cf2b8e289a3f8945b402a1b19eff24ec389f45f23c42a3dd6fb5", size = 12479945 }, + { url = "https://files.pythonhosted.org/packages/be/34/c3ef2d7799c9778b835a76189c6f53c179d3bdebc8c65288c29032e03613/ruff-0.12.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a07a5c8ffa2611a52732bdc67bf88e243abd84fe2d7f6daef3826b59abbfeda4", size = 12998677 }, + { url = "https://files.pythonhosted.org/packages/77/ab/aca2e756ad7b09b3d662a41773f3edcbd262872a4fc81f920dc1ffa44541/ruff-0.12.7-py3-none-win32.whl", hash = "sha256:c928f1b2ec59fb77dfdf70e0419408898b63998789cc98197e15f560b9e77f77", size = 11756687 }, + { url = "https://files.pythonhosted.org/packages/b4/71/26d45a5042bc71db22ddd8252ca9d01e9ca454f230e2996bb04f16d72799/ruff-0.12.7-py3-none-win_amd64.whl", hash = "sha256:9c18f3d707ee9edf89da76131956aba1270c6348bfee8f6c647de841eac7194f", size = 12912365 }, + { url = "https://files.pythonhosted.org/packages/4c/9b/0b8aa09817b63e78d94b4977f18b1fcaead3165a5ee49251c5d5c245bb2d/ruff-0.12.7-py3-none-win_arm64.whl", hash = "sha256:dfce05101dbd11833a0776716d5d1578641b7fddb537fe7fa956ab85d1769b69", size = 11982083 }, ] [[package]] -name = "six" -version = "1.17.0" +name = "shellingham" +version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, ] [[package]] @@ -807,15 +717,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, ] -[[package]] -name = "tzdata" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, -] - [[package]] name = "uvicorn" version = "0.34.0"