From 6d19165d9e0a42753aad56b3dee6ca60ba5e80a7 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Thu, 30 Apr 2026 18:58:11 +0200 Subject: [PATCH] chore(V2): rimuovi services/, gateway/, secrets/, docker/ (legacy V1) Co-Authored-By: Claude Sonnet 4.6 --- docker/base.Dockerfile | 12 - docker/mcp-alpaca.Dockerfile | 25 - docker/mcp-bybit.Dockerfile | 25 - docker/mcp-deribit.Dockerfile | 27 - docker/mcp-hyperliquid.Dockerfile | 25 - docker/mcp-macro.Dockerfile | 25 - docker/mcp-sentiment.Dockerfile | 25 - gateway/Caddyfile | 86 - gateway/Dockerfile | 6 - gateway/public/index.html | 97 - gateway/public/status.js | 23 - gateway/public/style.css | 101 - services/common/pyproject.toml | 23 - services/common/src/mcp_common/__init__.py | 1 - services/common/src/mcp_common/app_factory.py | 95 - services/common/src/mcp_common/audit.py | 121 -- services/common/src/mcp_common/auth.py | 98 - .../common/src/mcp_common/env_validation.py | 69 - services/common/src/mcp_common/environment.py | 138 -- services/common/src/mcp_common/http.py | 85 - services/common/src/mcp_common/indicators.py | 416 ---- services/common/src/mcp_common/logging.py | 81 - services/common/src/mcp_common/mcp_bridge.py | 239 --- .../common/src/mcp_common/microstructure.py | 74 - services/common/src/mcp_common/options.py | 201 -- services/common/src/mcp_common/server.py | 220 --- services/common/src/mcp_common/stats.py | 96 - services/common/tests/test_app_factory.py | 137 -- services/common/tests/test_audit.py | 155 -- services/common/tests/test_auth.py | 84 - services/common/tests/test_environment.py | 189 -- services/common/tests/test_http.py | 72 - services/common/tests/test_indicators.py | 260 --- services/common/tests/test_logging.py | 77 - services/common/tests/test_mcp_bridge.py | 112 -- services/common/tests/test_microstructure.py | 59 - services/common/tests/test_options.py | 144 -- services/common/tests/test_server_base.py | 90 - services/common/tests/test_stats.py | 51 - services/mcp-alpaca/pyproject.toml | 29 - .../mcp-alpaca/src/mcp_alpaca/__init__.py | 0 .../mcp-alpaca/src/mcp_alpaca/__main__.py | 30 - services/mcp-alpaca/src/mcp_alpaca/client.py | 385 ---- .../mcp-alpaca/src/mcp_alpaca/leverage_cap.py | 56 - services/mcp-alpaca/src/mcp_alpaca/server.py | 321 ---- services/mcp-alpaca/tests/conftest.py | 39 - services/mcp-alpaca/tests/test_client.py | 80 - .../mcp-alpaca/tests/test_environment_info.py | 50 - .../mcp-alpaca/tests/test_leverage_cap.py | 46 - services/mcp-alpaca/tests/test_server_acl.py | 110 -- services/mcp-bybit/pyproject.toml | 28 - services/mcp-bybit/src/mcp_bybit/__init__.py | 0 services/mcp-bybit/src/mcp_bybit/__main__.py | 30 - services/mcp-bybit/src/mcp_bybit/client.py | 672 ------- .../mcp-bybit/src/mcp_bybit/leverage_cap.py | 56 - services/mcp-bybit/src/mcp_bybit/server.py | 522 ----- services/mcp-bybit/tests/conftest.py | 21 - services/mcp-bybit/tests/test_client.py | 588 ------ .../mcp-bybit/tests/test_environment_info.py | 54 - services/mcp-bybit/tests/test_leverage_cap.py | 50 - services/mcp-bybit/tests/test_server_acl.py | 150 -- services/mcp-deribit/pyproject.toml | 27 - .../mcp-deribit/src/mcp_deribit/__init__.py | 0 .../mcp-deribit/src/mcp_deribit/__main__.py | 30 - .../mcp-deribit/src/mcp_deribit/client.py | 1673 ----------------- .../src/mcp_deribit/env_validation.py | 18 - .../src/mcp_deribit/leverage_cap.py | 56 - .../mcp-deribit/src/mcp_deribit/server.py | 695 ------- services/mcp-deribit/tests/test_client.py | 297 --- .../mcp-deribit/tests/test_env_validation.py | 71 - .../tests/test_environment_info.py | 77 - .../mcp-deribit/tests/test_leverage_cap.py | 50 - services/mcp-deribit/tests/test_server_acl.py | 269 --- services/mcp-hyperliquid/pyproject.toml | 29 - .../src/mcp_hyperliquid/__init__.py | 0 .../src/mcp_hyperliquid/__main__.py | 31 - .../src/mcp_hyperliquid/client.py | 577 ------ .../src/mcp_hyperliquid/leverage_cap.py | 56 - .../src/mcp_hyperliquid/server.py | 408 ---- services/mcp-hyperliquid/tests/test_client.py | 227 --- .../tests/test_environment_info.py | 50 - .../tests/test_leverage_cap.py | 50 - .../mcp-hyperliquid/tests/test_server_acl.py | 211 --- services/mcp-macro/pyproject.toml | 27 - services/mcp-macro/src/mcp_macro/__init__.py | 0 services/mcp-macro/src/mcp_macro/__main__.py | 37 - services/mcp-macro/src/mcp_macro/cot.py | 91 - .../mcp-macro/src/mcp_macro/cot_contracts.py | 36 - services/mcp-macro/src/mcp_macro/fetchers.py | 771 -------- services/mcp-macro/src/mcp_macro/server.py | 203 -- services/mcp-macro/tests/test_cot.py | 117 -- services/mcp-macro/tests/test_fetchers.py | 402 ---- services/mcp-macro/tests/test_server_acl.py | 202 -- services/mcp-sentiment/pyproject.toml | 27 - .../src/mcp_sentiment/__init__.py | 0 .../src/mcp_sentiment/__main__.py | 46 - .../src/mcp_sentiment/fetchers.py | 656 ------- .../mcp-sentiment/src/mcp_sentiment/server.py | 174 -- services/mcp-sentiment/tests/test_fetchers.py | 320 ---- .../mcp-sentiment/tests/test_server_acl.py | 216 --- 100 files changed, 15408 deletions(-) delete mode 100644 docker/base.Dockerfile delete mode 100644 docker/mcp-alpaca.Dockerfile delete mode 100644 docker/mcp-bybit.Dockerfile delete mode 100644 docker/mcp-deribit.Dockerfile delete mode 100644 docker/mcp-hyperliquid.Dockerfile delete mode 100644 docker/mcp-macro.Dockerfile delete mode 100644 docker/mcp-sentiment.Dockerfile delete mode 100644 gateway/Caddyfile delete mode 100644 gateway/Dockerfile delete mode 100644 gateway/public/index.html delete mode 100644 gateway/public/status.js delete mode 100644 gateway/public/style.css delete mode 100644 services/common/pyproject.toml delete mode 100644 services/common/src/mcp_common/__init__.py delete mode 100644 services/common/src/mcp_common/app_factory.py delete mode 100644 services/common/src/mcp_common/audit.py delete mode 100644 services/common/src/mcp_common/auth.py delete mode 100644 services/common/src/mcp_common/env_validation.py delete mode 100644 services/common/src/mcp_common/environment.py delete mode 100644 services/common/src/mcp_common/http.py delete mode 100644 services/common/src/mcp_common/indicators.py delete mode 100644 services/common/src/mcp_common/logging.py delete mode 100644 services/common/src/mcp_common/mcp_bridge.py delete mode 100644 services/common/src/mcp_common/microstructure.py delete mode 100644 services/common/src/mcp_common/options.py delete mode 100644 services/common/src/mcp_common/server.py delete mode 100644 services/common/src/mcp_common/stats.py delete mode 100644 services/common/tests/test_app_factory.py delete mode 100644 services/common/tests/test_audit.py delete mode 100644 services/common/tests/test_auth.py delete mode 100644 services/common/tests/test_environment.py delete mode 100644 services/common/tests/test_http.py delete mode 100644 services/common/tests/test_indicators.py delete mode 100644 services/common/tests/test_logging.py delete mode 100644 services/common/tests/test_mcp_bridge.py delete mode 100644 services/common/tests/test_microstructure.py delete mode 100644 services/common/tests/test_options.py delete mode 100644 services/common/tests/test_server_base.py delete mode 100644 services/common/tests/test_stats.py delete mode 100644 services/mcp-alpaca/pyproject.toml delete mode 100644 services/mcp-alpaca/src/mcp_alpaca/__init__.py delete mode 100644 services/mcp-alpaca/src/mcp_alpaca/__main__.py delete mode 100644 services/mcp-alpaca/src/mcp_alpaca/client.py delete mode 100644 services/mcp-alpaca/src/mcp_alpaca/leverage_cap.py delete mode 100644 services/mcp-alpaca/src/mcp_alpaca/server.py delete mode 100644 services/mcp-alpaca/tests/conftest.py delete mode 100644 services/mcp-alpaca/tests/test_client.py delete mode 100644 services/mcp-alpaca/tests/test_environment_info.py delete mode 100644 services/mcp-alpaca/tests/test_leverage_cap.py delete mode 100644 services/mcp-alpaca/tests/test_server_acl.py delete mode 100644 services/mcp-bybit/pyproject.toml delete mode 100644 services/mcp-bybit/src/mcp_bybit/__init__.py delete mode 100644 services/mcp-bybit/src/mcp_bybit/__main__.py delete mode 100644 services/mcp-bybit/src/mcp_bybit/client.py delete mode 100644 services/mcp-bybit/src/mcp_bybit/leverage_cap.py delete mode 100644 services/mcp-bybit/src/mcp_bybit/server.py delete mode 100644 services/mcp-bybit/tests/conftest.py delete mode 100644 services/mcp-bybit/tests/test_client.py delete mode 100644 services/mcp-bybit/tests/test_environment_info.py delete mode 100644 services/mcp-bybit/tests/test_leverage_cap.py delete mode 100644 services/mcp-bybit/tests/test_server_acl.py delete mode 100644 services/mcp-deribit/pyproject.toml delete mode 100644 services/mcp-deribit/src/mcp_deribit/__init__.py delete mode 100644 services/mcp-deribit/src/mcp_deribit/__main__.py delete mode 100644 services/mcp-deribit/src/mcp_deribit/client.py delete mode 100644 services/mcp-deribit/src/mcp_deribit/env_validation.py delete mode 100644 services/mcp-deribit/src/mcp_deribit/leverage_cap.py delete mode 100644 services/mcp-deribit/src/mcp_deribit/server.py delete mode 100644 services/mcp-deribit/tests/test_client.py delete mode 100644 services/mcp-deribit/tests/test_env_validation.py delete mode 100644 services/mcp-deribit/tests/test_environment_info.py delete mode 100644 services/mcp-deribit/tests/test_leverage_cap.py delete mode 100644 services/mcp-deribit/tests/test_server_acl.py delete mode 100644 services/mcp-hyperliquid/pyproject.toml delete mode 100644 services/mcp-hyperliquid/src/mcp_hyperliquid/__init__.py delete mode 100644 services/mcp-hyperliquid/src/mcp_hyperliquid/__main__.py delete mode 100644 services/mcp-hyperliquid/src/mcp_hyperliquid/client.py delete mode 100644 services/mcp-hyperliquid/src/mcp_hyperliquid/leverage_cap.py delete mode 100644 services/mcp-hyperliquid/src/mcp_hyperliquid/server.py delete mode 100644 services/mcp-hyperliquid/tests/test_client.py delete mode 100644 services/mcp-hyperliquid/tests/test_environment_info.py delete mode 100644 services/mcp-hyperliquid/tests/test_leverage_cap.py delete mode 100644 services/mcp-hyperliquid/tests/test_server_acl.py delete mode 100644 services/mcp-macro/pyproject.toml delete mode 100644 services/mcp-macro/src/mcp_macro/__init__.py delete mode 100644 services/mcp-macro/src/mcp_macro/__main__.py delete mode 100644 services/mcp-macro/src/mcp_macro/cot.py delete mode 100644 services/mcp-macro/src/mcp_macro/cot_contracts.py delete mode 100644 services/mcp-macro/src/mcp_macro/fetchers.py delete mode 100644 services/mcp-macro/src/mcp_macro/server.py delete mode 100644 services/mcp-macro/tests/test_cot.py delete mode 100644 services/mcp-macro/tests/test_fetchers.py delete mode 100644 services/mcp-macro/tests/test_server_acl.py delete mode 100644 services/mcp-sentiment/pyproject.toml delete mode 100644 services/mcp-sentiment/src/mcp_sentiment/__init__.py delete mode 100644 services/mcp-sentiment/src/mcp_sentiment/__main__.py delete mode 100644 services/mcp-sentiment/src/mcp_sentiment/fetchers.py delete mode 100644 services/mcp-sentiment/src/mcp_sentiment/server.py delete mode 100644 services/mcp-sentiment/tests/test_fetchers.py delete mode 100644 services/mcp-sentiment/tests/test_server_acl.py diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile deleted file mode 100644 index 67fc6b5..0000000 --- a/docker/base.Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM python:3.11-slim AS base -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential curl \ - && rm -rf /var/lib/apt/lists/* -RUN pip install --no-cache-dir "uv>=0.5,<0.7" -WORKDIR /app -COPY pyproject.toml uv.lock ./ -COPY services/common ./services/common -RUN uv sync --frozen --no-dev --package mcp-common -ENV PATH="/app/.venv/bin:$PATH" -FROM base AS dev -RUN uv sync --frozen --package mcp-common diff --git a/docker/mcp-alpaca.Dockerfile b/docker/mcp-alpaca.Dockerfile deleted file mode 100644 index e95fd3d..0000000 --- a/docker/mcp-alpaca.Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -ARG BASE_IMAGE=cerbero-base -ARG BASE_TAG=latest - -FROM ${BASE_IMAGE}:${BASE_TAG} AS builder -COPY services/mcp-alpaca ./services/mcp-alpaca -RUN uv sync --frozen --no-dev --package mcp-alpaca - -FROM python:3.11-slim AS runtime -LABEL org.opencontainers.image.source="https://github.com/AdrianoDev/cerbero" \ - cerbero.service="mcp-alpaca" - -WORKDIR /app -COPY --from=builder /app /app -ENV PATH="/app/.venv/bin:$PATH" - -RUN useradd -m -u 1000 app -USER app - -ENV HOST=0.0.0.0 PORT=9020 -EXPOSE 9020 - -HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=15s \ - CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"PORT\",\"9020\")}/health', timeout=3).close()" - -CMD ["mcp-alpaca"] diff --git a/docker/mcp-bybit.Dockerfile b/docker/mcp-bybit.Dockerfile deleted file mode 100644 index 611abf2..0000000 --- a/docker/mcp-bybit.Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -ARG BASE_IMAGE=cerbero-base -ARG BASE_TAG=latest - -FROM ${BASE_IMAGE}:${BASE_TAG} AS builder -COPY services/mcp-bybit ./services/mcp-bybit -RUN uv sync --frozen --no-dev --package mcp-bybit - -FROM python:3.11-slim AS runtime -LABEL org.opencontainers.image.source="https://github.com/AdrianoDev/cerbero" \ - cerbero.service="mcp-bybit" - -WORKDIR /app -COPY --from=builder /app /app -ENV PATH="/app/.venv/bin:$PATH" - -RUN useradd -m -u 1000 app -USER app - -ENV HOST=0.0.0.0 PORT=9019 -EXPOSE 9019 - -HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=15s \ - CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"PORT\",\"9019\")}/health', timeout=3).close()" - -CMD ["mcp-bybit"] diff --git a/docker/mcp-deribit.Dockerfile b/docker/mcp-deribit.Dockerfile deleted file mode 100644 index d2dcb81..0000000 --- a/docker/mcp-deribit.Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -# CER-P5-012 multi-stage slim: builder da cerbero-base (con uv + toolchain), -# runtime da python:3.11-slim (solo venv + source). -ARG BASE_IMAGE=cerbero-base -ARG BASE_TAG=latest - -FROM ${BASE_IMAGE}:${BASE_TAG} AS builder -COPY services/mcp-deribit ./services/mcp-deribit -RUN uv sync --frozen --no-dev --package mcp-deribit - -FROM python:3.11-slim AS runtime -LABEL org.opencontainers.image.source="https://github.com/AdrianoDev/cerbero" \ - cerbero.service="mcp-deribit" - -WORKDIR /app -COPY --from=builder /app /app -ENV PATH="/app/.venv/bin:$PATH" - -RUN useradd -m -u 1000 app -USER app - -ENV HOST=0.0.0.0 PORT=9011 -EXPOSE 9011 - -HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=15s \ - CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"PORT\",\"9011\")}/health', timeout=3).close()" - -CMD ["mcp-deribit"] diff --git a/docker/mcp-hyperliquid.Dockerfile b/docker/mcp-hyperliquid.Dockerfile deleted file mode 100644 index ce23442..0000000 --- a/docker/mcp-hyperliquid.Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -ARG BASE_IMAGE=cerbero-base -ARG BASE_TAG=latest - -FROM ${BASE_IMAGE}:${BASE_TAG} AS builder -COPY services/mcp-hyperliquid ./services/mcp-hyperliquid -RUN uv sync --frozen --no-dev --package mcp-hyperliquid - -FROM python:3.11-slim AS runtime -LABEL org.opencontainers.image.source="https://github.com/AdrianoDev/cerbero" \ - cerbero.service="mcp-hyperliquid" - -WORKDIR /app -COPY --from=builder /app /app -ENV PATH="/app/.venv/bin:$PATH" - -RUN useradd -m -u 1000 app -USER app - -ENV HOST=0.0.0.0 PORT=9012 -EXPOSE 9012 - -HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=15s \ - CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"PORT\",\"9012\")}/health', timeout=3).close()" - -CMD ["mcp-hyperliquid"] diff --git a/docker/mcp-macro.Dockerfile b/docker/mcp-macro.Dockerfile deleted file mode 100644 index cff87f2..0000000 --- a/docker/mcp-macro.Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -ARG BASE_IMAGE=cerbero-base -ARG BASE_TAG=latest - -FROM ${BASE_IMAGE}:${BASE_TAG} AS builder -COPY services/mcp-macro ./services/mcp-macro -RUN uv sync --frozen --no-dev --package mcp-macro - -FROM python:3.11-slim AS runtime -LABEL org.opencontainers.image.source="https://github.com/AdrianoDev/cerbero" \ - cerbero.service="mcp-macro" - -WORKDIR /app -COPY --from=builder /app /app -ENV PATH="/app/.venv/bin:$PATH" - -RUN useradd -m -u 1000 app -USER app - -ENV HOST=0.0.0.0 PORT=9013 -EXPOSE 9013 - -HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=15s \ - CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"PORT\",\"9013\")}/health', timeout=3).close()" - -CMD ["mcp-macro"] diff --git a/docker/mcp-sentiment.Dockerfile b/docker/mcp-sentiment.Dockerfile deleted file mode 100644 index b653d71..0000000 --- a/docker/mcp-sentiment.Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -ARG BASE_IMAGE=cerbero-base -ARG BASE_TAG=latest - -FROM ${BASE_IMAGE}:${BASE_TAG} AS builder -COPY services/mcp-sentiment ./services/mcp-sentiment -RUN uv sync --frozen --no-dev --package mcp-sentiment - -FROM python:3.11-slim AS runtime -LABEL org.opencontainers.image.source="https://github.com/AdrianoDev/cerbero" \ - cerbero.service="mcp-sentiment" - -WORKDIR /app -COPY --from=builder /app /app -ENV PATH="/app/.venv/bin:$PATH" - -RUN useradd -m -u 1000 app -USER app - -ENV HOST=0.0.0.0 PORT=9014 -EXPOSE 9014 - -HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=15s \ - CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"PORT\",\"9014\")}/health', timeout=3).close()" - -CMD ["mcp-sentiment"] diff --git a/gateway/Caddyfile b/gateway/Caddyfile deleted file mode 100644 index 39bae28..0000000 --- a/gateway/Caddyfile +++ /dev/null @@ -1,86 +0,0 @@ -{ - admin off - email {$ACME_EMAIL:adrianodalpastro@tielogic.com} - auto_https {$AUTO_HTTPS:on} - - # Plugin mholt/caddy-ratelimit - order rate_limit before basicauth - - # Trusted proxies: rispetta X-Forwarded-For quando dietro reverse proxy - # (es. Traefik). Default = solo private ranges. - servers { - trusted_proxies static {$TRUSTED_PROXIES:private_ranges} - } -} - -{$LISTEN:cerbero-mcp.tielogic.xyz} { - log { - output stdout - format json - } - - # ───── Security headers ───── - header { - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - X-Content-Type-Options "nosniff" - X-Frame-Options "DENY" - Referrer-Policy "no-referrer" - -Server - } - - # ───── IP allowlist su endpoint write ───── - # WRITE_ALLOWLIST: CIDR space-separated (es. "1.2.3.4/32 5.6.7.0/24"). - # Default 127.0.0.1/32 — fail-closed se non configurato. - @writes_blocked { - path_regexp ^/mcp-[a-z]+/tools/(place_|cancel_|set_|close_|transfer_|amend_|switch_) - not remote_ip {$WRITE_ALLOWLIST:127.0.0.1/32 ::1/128 172.16.0.0/12} - } - respond @writes_blocked "forbidden: source ip not in allowlist" 403 - - # ───── Rate limit ───── - # Reads: 60 req/min/IP, writes: 10 req/min/IP (sliding window). - rate_limit { - zone reads { - match { - not path_regexp ^/mcp-[a-z]+/tools/(place_|cancel_|set_|close_|transfer_|amend_|switch_) - } - key {remote_ip} - events 60 - window 1m - } - zone writes { - match { - path_regexp ^/mcp-[a-z]+/tools/(place_|cancel_|set_|close_|transfer_|amend_|switch_) - } - key {remote_ip} - events 10 - window 1m - } - } - - # ───── Reverse proxy ───── - handle_path /mcp-deribit/* { - reverse_proxy mcp-deribit:9011 - } - handle_path /mcp-bybit/* { - reverse_proxy mcp-bybit:9019 - } - handle_path /mcp-hyperliquid/* { - reverse_proxy mcp-hyperliquid:9012 - } - handle_path /mcp-alpaca/* { - reverse_proxy mcp-alpaca:9020 - } - handle_path /mcp-macro/* { - reverse_proxy mcp-macro:9013 - } - handle_path /mcp-sentiment/* { - reverse_proxy mcp-sentiment:9014 - } - - # Landing page statica - handle { - root * /srv - file_server - } -} diff --git a/gateway/Dockerfile b/gateway/Dockerfile deleted file mode 100644 index dd0015a..0000000 --- a/gateway/Dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM caddy:2.8-builder-alpine AS builder -RUN xcaddy build \ - --with github.com/mholt/caddy-ratelimit - -FROM caddy:2.8-alpine -COPY --from=builder /usr/bin/caddy /usr/bin/caddy diff --git a/gateway/public/index.html b/gateway/public/index.html deleted file mode 100644 index ee81341..0000000 --- a/gateway/public/index.html +++ /dev/null @@ -1,97 +0,0 @@ - - - - -Cerbero — MCP gateway - - - -
-

Cerbero

-

Sistema trading autonomo crypto, architettura MCP-only.

-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
StatoServizioPorta int.DescrizioneLink
mcp-memory9015Store L1/L2, system prompt base + dynhealth · docs
mcp-scheduler9016Recurring task + core agent runnerhealth · docs
mcp-deribit9011Options testnet order/markethealth · docs
mcp-hyperliquid9012Perp DEX testnethealth · docs
mcp-macro9013FRED indicators + Finnhub calendarhealth · docs
mcp-sentiment9014CryptoPanic news feedhealth · docs
mcp-telegram9017Bot commands + notifiche operatorehealth · docs
mcp-portfolio9018Holdings + yfinance + UI htmxhealth · gui · docs
- -
-

Console operativa

-

/console — run del core agent, eventi stdout/stderr, L1 live, trigger manuale.

-
-
- -
-

Status aggiornato ogni 5 s. Gateway Caddy su porta configurata via GATEWAY_PORT.

-
- - - - diff --git a/gateway/public/status.js b/gateway/public/status.js deleted file mode 100644 index 0d548b0..0000000 --- a/gateway/public/status.js +++ /dev/null @@ -1,23 +0,0 @@ -const rows = document.querySelectorAll("tr[data-path]"); - -async function poll() { - for (const row of rows) { - const dot = row.querySelector(".status"); - try { - const r = await fetch(`${row.dataset.path}/health`, { - method: "GET", - cache: "no-store", - }); - dot.classList.toggle("ok", r.ok); - dot.classList.toggle("err", !r.ok); - dot.setAttribute("aria-label", r.ok ? "ok" : "error"); - } catch { - dot.classList.remove("ok"); - dot.classList.add("err"); - dot.setAttribute("aria-label", "unreachable"); - } - } -} - -poll(); -setInterval(poll, 5000); diff --git a/gateway/public/style.css b/gateway/public/style.css deleted file mode 100644 index a650f40..0000000 --- a/gateway/public/style.css +++ /dev/null @@ -1,101 +0,0 @@ -:root { - --bg: #0f172a; - --fg: #e2e8f0; - --muted: #94a3b8; - --card: #1e293b; - --border: #334155; - --ok: #22c55e; - --err: #ef4444; - --unknown: #64748b; - --accent: #38bdf8; -} - -* { box-sizing: border-box; } - -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: var(--bg); - color: var(--fg); - line-height: 1.5; -} - -header, main, footer { - max-width: 960px; - margin: 0 auto; - padding: 1.5rem; -} - -header h1 { - margin: 0 0 0.25rem; - color: var(--accent); - font-size: 2rem; -} - -header p { - margin: 0; - color: var(--muted); -} - -table { - width: 100%; - border-collapse: collapse; - background: var(--card); - border-radius: 8px; - overflow: hidden; -} - -th, td { - padding: 0.75rem 1rem; - text-align: left; - border-bottom: 1px solid var(--border); -} - -th { - background: #0f172a; - color: var(--muted); - font-weight: 600; - font-size: 0.85rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -tr:last-child td { border-bottom: none; } - -td:nth-child(3) { - font-family: ui-monospace, "SF Mono", Menlo, monospace; - color: var(--muted); -} - -a { - color: var(--accent); - text-decoration: none; - margin-right: 0.5rem; -} - -a:hover { text-decoration: underline; } - -.status { - display: inline-block; - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--unknown); - transition: background 0.3s ease; -} - -.status.ok { background: var(--ok); box-shadow: 0 0 8px var(--ok); } -.status.err { background: var(--err); box-shadow: 0 0 8px var(--err); } - -footer { - color: var(--muted); - font-size: 0.85rem; - margin-top: 2rem; -} - -code { - background: var(--border); - padding: 0.1rem 0.3rem; - border-radius: 3px; - font-size: 0.9em; -} diff --git a/services/common/pyproject.toml b/services/common/pyproject.toml deleted file mode 100644 index b0f286f..0000000 --- a/services/common/pyproject.toml +++ /dev/null @@ -1,23 +0,0 @@ -[project] -name = "mcp-common" -version = "0.1.0" -requires-python = ">=3.11" -dependencies = [ - "fastapi>=0.115", - "uvicorn[standard]>=0.30", - "mcp>=1.0", - "httpx>=0.27", - "pydantic>=2.6", - "pydantic-settings>=2.3", - "python-json-logger>=2.0", -] - -[project.optional-dependencies] -dev = ["pytest>=8", "pytest-asyncio>=0.23", "pytest-httpx>=0.30", "ruff>=0.5"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/mcp_common"] diff --git a/services/common/src/mcp_common/__init__.py b/services/common/src/mcp_common/__init__.py deleted file mode 100644 index a9a2c5b..0000000 --- a/services/common/src/mcp_common/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__all__ = [] diff --git a/services/common/src/mcp_common/app_factory.py b/services/common/src/mcp_common/app_factory.py deleted file mode 100644 index 5ad0ab1..0000000 --- a/services/common/src/mcp_common/app_factory.py +++ /dev/null @@ -1,95 +0,0 @@ -"""App factory comune per i servizi mcp-{exchange}. - -Centralizza il boilerplate dei `__main__.py`: -- configure_root_logging (JSON) -- fail_fast_if_missing su env mandatory -- summarize env -- load creds JSON -- resolve_environment con default URLs -- load token store -- delega creazione client + app a callback per-servizio -- uvicorn.run - -Ogni servizio invoca `run_exchange_main(spec)` con uno spec dichiarativo. -""" -from __future__ import annotations - -import json -import os -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -import uvicorn - -from mcp_common.auth import load_token_store_from_files -from mcp_common.env_validation import fail_fast_if_missing, require_env, summarize -from mcp_common.environment import ( - EnvironmentInfo, - consistency_check, - resolve_environment, -) -from mcp_common.logging import configure_root_logging - - -@dataclass(frozen=True) -class ExchangeAppSpec: - exchange: str - creds_env_var: str - env_var: str # es. "BYBIT_TESTNET", "ALPACA_PAPER" - flag_key: str # campo nel secret JSON ("testnet" o "paper") - default_base_url_live: str - default_base_url_testnet: str - default_port: int - build_client: Callable[[dict, EnvironmentInfo], Any] - build_app: Callable[..., Any] - extra_summarize_envs: tuple[str, ...] = () - - -def run_exchange_main(spec: ExchangeAppSpec) -> None: - configure_root_logging() - - fail_fast_if_missing([spec.creds_env_var]) - summarize([ - spec.creds_env_var, - "CORE_TOKEN_FILE", - "OBSERVER_TOKEN_FILE", - "PORT", - "HOST", - spec.env_var, - *spec.extra_summarize_envs, - ]) - - creds_file = require_env(spec.creds_env_var, f"{spec.exchange} credentials JSON path") - with open(creds_file) as f: - creds = json.load(f) - - env_info = resolve_environment( - creds, - env_var=spec.env_var, - flag_key=spec.flag_key, - exchange=spec.exchange, - default_base_url_live=spec.default_base_url_live, - default_base_url_testnet=spec.default_base_url_testnet, - ) - - # Safety: previene switch accidentali a mainnet senza conferma esplicita - # nel secret. Solleva EnvironmentMismatchError → boot abort se mismatch. - strict_mainnet = os.environ.get("STRICT_MAINNET", "true").lower() not in ("0", "false", "no") - consistency_check(env_info, creds, strict_mainnet=strict_mainnet) - - client = spec.build_client(creds, env_info) - - token_store = load_token_store_from_files( - core_token_file=os.environ.get("CORE_TOKEN_FILE"), - observer_token_file=os.environ.get("OBSERVER_TOKEN_FILE"), - ) - - app = spec.build_app(client=client, token_store=token_store, creds=creds, env_info=env_info) - - uvicorn.run( - app, - log_config=None, - host=os.environ.get("HOST", "0.0.0.0"), - port=int(os.environ.get("PORT", str(spec.default_port))), - ) diff --git a/services/common/src/mcp_common/audit.py b/services/common/src/mcp_common/audit.py deleted file mode 100644 index a65c978..0000000 --- a/services/common/src/mcp_common/audit.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Audit log strutturato per write endpoint MCP (place_order, cancel, -set_*, close_*, transfer_*). Usa un logger dedicato `mcp.audit` su stream -JSON. - -Sink: -- stdout/stderr (sempre): tramite root JSON logger configurato da - `mcp_common.logging.configure_root_logging`. -- File JSONL persistente (opzionale): se env var `AUDIT_LOG_FILE` è - settata, aggiunge un `TimedRotatingFileHandler` che ruota a mezzanotte - con `AUDIT_LOG_BACKUP_DAYS` di retention (default 30). Una riga JSON - per record (formato `.jsonl`). - -Per VPS produzione: setta `AUDIT_LOG_FILE=/var/log/cerbero-mcp/.audit.jsonl` -con bind mount del volume `/var/log/cerbero-mcp` nel docker-compose. - -Payload sensibile (api_key, secret) già filtrato dal SecretsFilter -globale; qui non si include creds. -""" -from __future__ import annotations - -import logging -import os -from logging.handlers import TimedRotatingFileHandler -from typing import Any - -from mcp_common.auth import Principal -from mcp_common.logging import SecretsFilter, get_json_logger - -try: - from pythonjsonlogger.json import JsonFormatter as _JsonFormatter # noqa: N813 -except ImportError: - from pythonjsonlogger.jsonlogger import JsonFormatter as _JsonFormatter # noqa: N813 - -_logger = get_json_logger("mcp.audit", level=logging.INFO) -_file_handler_attached = False - - -def _configure_audit_sink() -> None: - """Aggiunge FileHandler al logger mcp.audit se AUDIT_LOG_FILE è settato. - Idempotente: chiamato la prima volta da audit_write_op, poi no-op. - """ - global _file_handler_attached - if _file_handler_attached: - return - - file_path = os.environ.get("AUDIT_LOG_FILE", "").strip() - if not file_path: - _file_handler_attached = True - return - - backup_days = int(os.environ.get("AUDIT_LOG_BACKUP_DAYS", "30")) - - os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True) - handler = TimedRotatingFileHandler( - file_path, - when="midnight", - interval=1, - backupCount=backup_days, - encoding="utf-8", - utc=True, - ) - handler.setFormatter(_JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s")) - handler.addFilter(SecretsFilter()) - _logger.addHandler(handler) - _file_handler_attached = True - - -def audit_write_op( - *, - principal: Principal | None, - action: str, - exchange: str, - target: str | None = None, - payload: dict[str, Any] | None = None, - result: dict[str, Any] | None = None, - error: str | None = None, -) -> None: - """Emit a structured audit log record per write operation. - - principal: chi ha invocato (None se anonimo, ma normalmente _check - impedisce di arrivare qui senza principal). - action: nome del tool (es. "place_order", "cancel_order"). - exchange: identificatore servizio (deribit, bybit, alpaca, hyperliquid). - target: instrument/symbol/order_id su cui si agisce. - payload: input non-sensibile (qty, side, leverage, ecc.). - result: output del client (order_id, status, ecc.). - error: stringa errore se l'operazione ha fallito. - """ - _configure_audit_sink() - record: dict[str, Any] = { - "audit_event": "write_op", - "action": action, - "exchange": exchange, - "principal": principal.name if principal else None, - "target": target, - "payload": payload or {}, - } - if result is not None: - record["result"] = _summarize_result(result) - if error is not None: - record["error"] = error - _logger.error("audit", extra=record) - else: - _logger.info("audit", extra=record) - - -def _summarize_result(result: dict[str, Any]) -> dict[str, Any]: - """Estrae i campi rilevanti dal result (order_id, state, error code) - per evitare di loggare payload enormi. - """ - keys = ( - "order_id", "order_link_id", "combo_instrument", "state", "status", - "code", "error", "stop_price", "tp_price", "transfer_id", - ) - out: dict[str, Any] = {} - for k in keys: - if k in result: - out[k] = result[k] - if "orders" in result: - out["orders_count"] = len(result["orders"]) - return out diff --git a/services/common/src/mcp_common/auth.py b/services/common/src/mcp_common/auth.py deleted file mode 100644 index d791527..0000000 --- a/services/common/src/mcp_common/auth.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass, field -from functools import wraps - -from fastapi import HTTPException, Request, status - - -@dataclass -class Principal: - name: str - capabilities: set[str] = field(default_factory=set) - - -@dataclass -class TokenStore: - tokens: dict[str, Principal] - - def get(self, token: str) -> Principal | None: - return self.tokens.get(token) - - -def require_principal(request: Request) -> Principal: - auth = request.headers.get("Authorization", "") - if not auth.startswith("Bearer "): - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing bearer token") - token = auth[len("Bearer "):].strip() - store: TokenStore = request.app.state.token_store - principal = store.get(token) - if principal is None: - raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid token") - return principal - - -def acl_requires(*, core: bool = False, observer: bool = False) -> Callable: - """Decorator: require at least one matching capability.""" - allowed: set[str] = set() - if core: - allowed.add("core") - if observer: - allowed.add("observer") - - def decorator(func: Callable) -> Callable: - @wraps(func) - async def async_wrapper(*args, **kwargs): - principal = kwargs.get("principal") - if principal is None: - for a in args: - if isinstance(a, Principal): - principal = a - break - if principal is None or not (principal.capabilities & allowed): - raise HTTPException( - status.HTTP_403_FORBIDDEN, - f"capability required: {allowed}", - ) - return await func(*args, **kwargs) if _is_coro(func) else func(*args, **kwargs) - - @wraps(func) - def sync_wrapper(*args, **kwargs): - principal = kwargs.get("principal") - if principal is None: - for a in args: - if isinstance(a, Principal): - principal = a - break - if principal is None or not (principal.capabilities & allowed): - raise HTTPException( - status.HTTP_403_FORBIDDEN, - f"capability required: {allowed}", - ) - return func(*args, **kwargs) - - return async_wrapper if _is_coro(func) else sync_wrapper - - return decorator - - -def _is_coro(func: Callable) -> bool: - import asyncio - return asyncio.iscoroutinefunction(func) - - -def load_token_store_from_files( - core_token_file: str | None, - observer_token_file: str | None, -) -> TokenStore: - tokens: dict[str, Principal] = {} - if core_token_file: - with open(core_token_file) as f: - tokens[f.read().strip()] = Principal(name="core", capabilities={"core"}) - if observer_token_file: - with open(observer_token_file) as f: - tokens[f.read().strip()] = Principal( - name="observer", capabilities={"observer"} - ) - return TokenStore(tokens=tokens) diff --git a/services/common/src/mcp_common/env_validation.py b/services/common/src/mcp_common/env_validation.py deleted file mode 100644 index f402315..0000000 --- a/services/common/src/mcp_common/env_validation.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Env validation policy: fail-fast per mandatory, soft per optional. - -Usage al boot di ogni mcp `__main__.py`: - - from mcp_common.env_validation import require_env, optional_env, summarize - - creds_file = require_env("CREDENTIALS_FILE", "deribit credentials JSON path") - host = optional_env("HOST", default="0.0.0.0") - summarize(["CREDENTIALS_FILE", "HOST", "PORT"]) -""" - -from __future__ import annotations - -import logging -import os -import sys - -logger = logging.getLogger(__name__) - - -class MissingEnvError(RuntimeError): - """Mandatory env var absent or empty.""" - - -def require_env(name: str, description: str = "") -> str: - val = (os.environ.get(name) or "").strip() - if not val: - msg = f"missing mandatory env var: {name}" - if description: - msg += f" ({description})" - logger.error(msg) - raise MissingEnvError(msg) - return val - - -def optional_env(name: str, *, default: str = "") -> str: - val = (os.environ.get(name) or "").strip() - if not val: - if default: - logger.info("env %s not set, using default=%r", name, default) - return default - return val - - -def summarize(names: list[str]) -> None: - sensitive_tokens = ("SECRET", "KEY", "TOKEN", "PASSWORD", "CREDENTIAL", "WALLET") - for n in names: - val = os.environ.get(n) - if val is None: - logger.info("env[%s]: ", n) - continue - if any(t in n.upper() for t in sensitive_tokens): - logger.info("env[%s]: ", n, len(val)) - else: - logger.info("env[%s]: %s", n, val) - - -def fail_fast_if_missing(names: list[str]) -> None: - missing: list[str] = [] - for n in names: - if not (os.environ.get(n) or "").strip(): - missing.append(n) - if missing: - logger.error("boot aborted: missing mandatory env vars: %s", missing) - print( - f"FATAL: missing mandatory env vars: {missing}", - file=sys.stderr, - ) - sys.exit(2) diff --git a/services/common/src/mcp_common/environment.py b/services/common/src/mcp_common/environment.py deleted file mode 100644 index 47b8c89..0000000 --- a/services/common/src/mcp_common/environment.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Resolver di ambiente (testnet/mainnet) per MCP exchange. - -Precedenza: env var > campo secret > default True (testnet). - -Safety: `consistency_check` previene switch accidentali a mainnet senza -conferma esplicita nel secret JSON. -""" -from __future__ import annotations - -import logging -import os -from dataclasses import dataclass -from typing import Literal - -logger = logging.getLogger(__name__) - -Environment = Literal["testnet", "mainnet"] -Source = Literal["env", "credentials", "default"] - -TRUTHY = {"1", "true", "yes", "on"} - -# Tokens nel base_url che indicano endpoint testnet (case-insensitive). -TESTNET_URL_HINTS = ("test", "testnet", "paper") - - -class EnvironmentMismatchError(RuntimeError): - """Boot abort: ambiente risolto non matcha conferma esplicita nel secret.""" - - -@dataclass(frozen=True) -class EnvironmentInfo: - exchange: str - environment: Environment - source: Source - env_value: str | None - base_url: str - - -def resolve_environment( - creds: dict, - *, - env_var: str, - flag_key: str, - exchange: str, - default_base_url_live: str | None = None, - default_base_url_testnet: str | None = None, -) -> EnvironmentInfo: - """Risolvi l'ambiente per un MCP exchange. - - creds: dict letto dal secret JSON. Può contenere base_url_live/base_url_testnet - per override; in assenza vengono usati i default kwargs. - env_var: nome della env var di override (es. DERIBIT_TESTNET). - flag_key: chiave booleana nel secret JSON (es. "testnet" o "paper" per alpaca). - exchange: nome exchange per logging/info. - default_base_url_live / default_base_url_testnet: URL canonici dell'exchange, - applicati se non presenti in creds. - """ - env_value = os.environ.get(env_var) - if env_value is not None: - is_test = env_value.strip().lower() in TRUTHY - environment: Environment = "testnet" if is_test else "mainnet" - source: Source = "env" - elif flag_key in creds: - environment = "testnet" if bool(creds[flag_key]) else "mainnet" - source = "credentials" - else: - environment = "testnet" - source = "default" - - if default_base_url_live is not None: - creds.setdefault("base_url_live", default_base_url_live) - if default_base_url_testnet is not None: - creds.setdefault("base_url_testnet", default_base_url_testnet) - - base_url = creds["base_url_testnet"] if environment == "testnet" else creds["base_url_live"] - return EnvironmentInfo( - exchange=exchange, - environment=environment, - source=source, - env_value=env_value, - base_url=base_url, - ) - - -def consistency_check( - env_info: EnvironmentInfo, - creds: dict, - *, - strict_mainnet: bool = True, -) -> list[str]: - """Verifica coerenza environment risolto vs secret JSON. Restituisce - lista di warning string. Solleva EnvironmentMismatchError per mismatch - bloccanti. - - Regole: - - Se `creds["environment"]` è presente e DIVERSO da `env_info.environment`: - → raise EnvironmentMismatchError (declared vs resolved mismatch). - - Se `env_info.environment == "mainnet"` e `creds.get("environment") != - "mainnet"`: con `strict_mainnet=True` → raise (richiede conferma - esplicita). Con `strict_mainnet=False` → warning. - - Se `env_info.base_url` contiene token testnet ("test", "testnet", - "paper") ma `env_info.environment == "mainnet"` (o viceversa): warning - (URL/environment incoerenti). - """ - warnings: list[str] = [] - - declared = creds.get("environment") - if declared and declared != env_info.environment: - raise EnvironmentMismatchError( - f"{env_info.exchange}: secret declared environment={declared!r} " - f"but resolver resolved environment={env_info.environment!r}" - ) - - if env_info.environment == "mainnet" and declared != "mainnet": - msg = ( - f"{env_info.exchange}: resolved mainnet without explicit confirmation " - "in secret. Add `\"environment\": \"mainnet\"` to the credentials JSON." - ) - if strict_mainnet: - raise EnvironmentMismatchError(msg) - warnings.append(msg) - - url_lower = (env_info.base_url or "").lower() - has_test_hint = any(token in url_lower for token in TESTNET_URL_HINTS) - if env_info.environment == "mainnet" and has_test_hint: - warnings.append( - f"{env_info.exchange}: environment=mainnet but base_url contains " - f"testnet hint ({env_info.base_url!r})" - ) - if env_info.environment == "testnet" and not has_test_hint and url_lower: - warnings.append( - f"{env_info.exchange}: environment=testnet but base_url does not " - f"appear to be a testnet endpoint ({env_info.base_url!r})" - ) - - for w in warnings: - logger.warning("environment consistency: %s", w) - return warnings diff --git a/services/common/src/mcp_common/http.py b/services/common/src/mcp_common/http.py deleted file mode 100644 index f357862..0000000 --- a/services/common/src/mcp_common/http.py +++ /dev/null @@ -1,85 +0,0 @@ -"""HTTP client factory con retry/backoff su errori transient. - -Wrap leggero attorno a httpx.AsyncClient: aggiunge AsyncHTTPTransport -con retries=N per gestire connection errors / DNS / refused. Per retry -su 5xx HTTP response usa `request_with_retry()` (decoratore separato). - -Usage standard: - - async with async_client(timeout=15) as http: - resp = await http.get(url) - -Equivalente a httpx.AsyncClient(timeout=15) ma con retry transport su -errori di livello connessione. -""" -from __future__ import annotations - -import asyncio -import logging -from collections.abc import Awaitable, Callable -from typing import Any, TypeVar - -import httpx - -logger = logging.getLogger(__name__) - -T = TypeVar("T") - -DEFAULT_RETRIES = 3 -DEFAULT_TIMEOUT = 15.0 - - -def async_client( - *, - timeout: float = DEFAULT_TIMEOUT, - retries: int = DEFAULT_RETRIES, - follow_redirects: bool = False, - **kwargs: Any, -) -> httpx.AsyncClient: - """httpx.AsyncClient con AsyncHTTPTransport(retries=N) di default. - retries gestisce connection errors / refused / DNS — non 5xx HTTP. - """ - transport = httpx.AsyncHTTPTransport(retries=retries) - return httpx.AsyncClient( - timeout=timeout, - transport=transport, - follow_redirects=follow_redirects, - **kwargs, - ) - - -async def call_with_retry( - fn: Callable[[], Awaitable[T]], - *, - max_attempts: int = 3, - base_delay: float = 0.5, - max_delay: float = 8.0, - retry_on: tuple[type[BaseException], ...] = (httpx.TransportError, httpx.TimeoutException), -) -> T: - """Retry generico async con exponential backoff. - - Ritenta `fn()` se solleva una delle exception in `retry_on`. Backoff - raddoppia (0.5, 1, 2, 4, ...) clipped a max_delay. Solleva l'ultima - exception se max_attempts raggiunto. - - Usabile su SDK sincroni avvolti in asyncio.to_thread (pybit, alpaca): - - result = await call_with_retry(lambda: client._run(self._http.get_tickers, ...)) - """ - delay = base_delay - last_exc: BaseException | None = None - for attempt in range(1, max_attempts + 1): - try: - return await fn() - except retry_on as e: - last_exc = e - if attempt == max_attempts: - break - logger.warning( - "transient error, retrying (%d/%d) in %.1fs: %s", - attempt, max_attempts, delay, type(e).__name__, - ) - await asyncio.sleep(delay) - delay = min(delay * 2, max_delay) - assert last_exc is not None - raise last_exc diff --git a/services/common/src/mcp_common/indicators.py b/services/common/src/mcp_common/indicators.py deleted file mode 100644 index e4775ce..0000000 --- a/services/common/src/mcp_common/indicators.py +++ /dev/null @@ -1,416 +0,0 @@ -from __future__ import annotations - -import math - - -def sma(values: list[float], period: int) -> float | None: - if len(values) < period: - return None - return sum(values[-period:]) / period - - -def rsi(closes: list[float], period: int = 14) -> float | None: - if len(closes) < period + 1: - return None - gains: list[float] = [] - losses: list[float] = [] - for i in range(1, len(closes)): - delta = closes[i] - closes[i - 1] - gains.append(max(delta, 0.0)) - losses.append(-min(delta, 0.0)) - avg_gain = sum(gains[:period]) / period - avg_loss = sum(losses[:period]) / period - for i in range(period, len(gains)): - avg_gain = (avg_gain * (period - 1) + gains[i]) / period - avg_loss = (avg_loss * (period - 1) + losses[i]) / period - if avg_loss == 0: - return 100.0 - rs = avg_gain / avg_loss - return 100.0 - (100.0 / (1.0 + rs)) - - -def _ema_series(values: list[float], period: int) -> list[float]: - if len(values) < period: - return [] - k = 2.0 / (period + 1) - seed = sum(values[:period]) / period - out = [seed] - for v in values[period:]: - out.append(out[-1] + k * (v - out[-1])) - return out - - -def macd( - closes: list[float], - fast: int = 12, - slow: int = 26, - signal: int = 9, -) -> dict[str, float | None]: - nothing: dict[str, float | None] = {"macd": None, "signal": None, "hist": None} - if len(closes) < slow + signal: - return nothing - ema_fast = _ema_series(closes, fast) - ema_slow = _ema_series(closes, slow) - offset = slow - fast - aligned_fast = ema_fast[offset:] - macd_line = [f - s for f, s in zip(aligned_fast, ema_slow, strict=False)] - if len(macd_line) < signal: - return nothing - signal_line = _ema_series(macd_line, signal) - if not signal_line: - return nothing - last_macd = macd_line[-1] - last_sig = signal_line[-1] - return { - "macd": last_macd, - "signal": last_sig, - "hist": last_macd - last_sig, - } - - -def atr( - highs: list[float], - lows: list[float], - closes: list[float], - period: int = 14, -) -> float | None: - if len(closes) < period + 1: - return None - trs: list[float] = [] - for i in range(1, len(closes)): - tr = max( - highs[i] - lows[i], - abs(highs[i] - closes[i - 1]), - abs(lows[i] - closes[i - 1]), - ) - trs.append(tr) - if len(trs) < period: - return None - avg = sum(trs[:period]) / period - for i in range(period, len(trs)): - avg = (avg * (period - 1) + trs[i]) / period - return avg - - -def adx( - highs: list[float], - lows: list[float], - closes: list[float], - period: int = 14, -) -> dict[str, float | None]: - nothing: dict[str, float | None] = {"adx": None, "+di": None, "-di": None} - if len(closes) < 2 * period + 1: - return nothing - trs: list[float] = [] - plus_dms: list[float] = [] - minus_dms: list[float] = [] - for i in range(1, len(closes)): - tr = max( - highs[i] - lows[i], - abs(highs[i] - closes[i - 1]), - abs(lows[i] - closes[i - 1]), - ) - up = highs[i] - highs[i - 1] - dn = lows[i - 1] - lows[i] - plus_dm = up if (up > dn and up > 0) else 0.0 - minus_dm = dn if (dn > up and dn > 0) else 0.0 - trs.append(tr) - plus_dms.append(plus_dm) - minus_dms.append(minus_dm) - - atr_s = sum(trs[:period]) - pdm_s = sum(plus_dms[:period]) - mdm_s = sum(minus_dms[:period]) - dxs: list[float] = [] - pdi = mdi = 0.0 - for i in range(period, len(trs)): - atr_s = atr_s - atr_s / period + trs[i] - pdm_s = pdm_s - pdm_s / period + plus_dms[i] - mdm_s = mdm_s - mdm_s / period + minus_dms[i] - pdi = 100.0 * pdm_s / atr_s if atr_s else 0.0 - mdi = 100.0 * mdm_s / atr_s if atr_s else 0.0 - s = pdi + mdi - dx = 100.0 * abs(pdi - mdi) / s if s else 0.0 - dxs.append(dx) - - if len(dxs) < period: - return nothing - adx_val = sum(dxs[:period]) / period - for i in range(period, len(dxs)): - adx_val = (adx_val * (period - 1) + dxs[i]) / period - return {"adx": adx_val, "+di": pdi, "-di": mdi} - - -# ───── Returns helper ───── - -def _log_returns(closes: list[float]) -> list[float]: - out: list[float] = [] - for i in range(1, len(closes)): - prev = closes[i - 1] - curr = closes[i] - if prev > 0 and curr > 0: - out.append(math.log(curr / prev)) - return out - - -def _percentile(sorted_values: list[float], q: float) -> float: - if not sorted_values: - return 0.0 - if len(sorted_values) == 1: - return sorted_values[0] - pos = q * (len(sorted_values) - 1) - lo = int(pos) - hi = min(lo + 1, len(sorted_values) - 1) - frac = pos - lo - return sorted_values[lo] + frac * (sorted_values[hi] - sorted_values[lo]) - - -def _stddev(xs: list[float]) -> float: - if len(xs) < 2: - return 0.0 - m = sum(xs) / len(xs) - var = sum((x - m) ** 2 for x in xs) / (len(xs) - 1) - return math.sqrt(var) - - -# ───── vol_cone ───── - -def vol_cone( - closes: list[float], - windows: list[int] | None = None, - annualization: int = 252, -) -> dict[int, dict[str, float | None]]: - """Realized vol cone: per ogni window restituisce vol corrente e percentili - storici (p10/p50/p90) di tutte le rolling windows del campione. - Annualizzata (default 252 trading days). - """ - windows = windows or [10, 20, 30, 60] - rets = _log_returns(closes) - out: dict[int, dict[str, float | None]] = {} - factor = math.sqrt(annualization) - for w in windows: - if len(rets) < w: - out[w] = {"current": None, "p10": None, "p50": None, "p90": None} - continue - rolling: list[float] = [] - for i in range(w, len(rets) + 1): - window_rets = rets[i - w:i] - rolling.append(_stddev(window_rets) * factor) - rolling_sorted = sorted(rolling) - out[w] = { - "current": rolling[-1], - "p10": _percentile(rolling_sorted, 0.10), - "p50": _percentile(rolling_sorted, 0.50), - "p90": _percentile(rolling_sorted, 0.90), - } - return out - - -# ───── hurst_exponent ───── - -def hurst_exponent(closes: list[float], min_lag: int = 2, max_lag: int = 100) -> float | None: - """Hurst via R/S analysis su log-prices. H≈0.5 random walk, >0.5 trending, - <0.5 mean-reverting. - """ - if len(closes) < max(20, max_lag): - return None - log_p = [math.log(c) for c in closes if c > 0] - if len(log_p) < max(20, max_lag): - return None - upper = min(max_lag, len(log_p) // 2) - if upper < min_lag + 1: - return None - lags = list(range(min_lag, upper)) - log_lags: list[float] = [] - log_rs: list[float] = [] - for lag in lags: - # Build N/lag non-overlapping segments; for each compute R/S - rs_vals: list[float] = [] - n_segs = len(log_p) // lag - if n_segs < 1: - continue - for seg in range(n_segs): - chunk = log_p[seg * lag:(seg + 1) * lag] - diffs = [chunk[i] - chunk[i - 1] for i in range(1, len(chunk))] - if len(diffs) < 2: - continue - mean = sum(diffs) / len(diffs) - dev = [d - mean for d in diffs] - cum = [] - acc = 0.0 - for d in dev: - acc += d - cum.append(acc) - r = max(cum) - min(cum) - s = _stddev(diffs) - if s > 0: - rs_vals.append(r / s) - if rs_vals: - avg_rs = sum(rs_vals) / len(rs_vals) - if avg_rs > 0: - log_lags.append(math.log(lag)) - log_rs.append(math.log(avg_rs)) - if len(log_lags) < 4: - return None - # Linear regression slope = Hurst - n = len(log_lags) - mx = sum(log_lags) / n - my = sum(log_rs) / n - num = sum((log_lags[i] - mx) * (log_rs[i] - my) for i in range(n)) - den = sum((log_lags[i] - mx) ** 2 for i in range(n)) - if den == 0: - return None - return num / den - - -# ───── half_life_mean_reversion ───── - -def half_life_mean_reversion(closes: list[float]) -> float | None: - """Half-life via OU AR(1) fit: y_t - y_{t-1} = a + b*y_{t-1} + eps. - Half-life = -ln(2)/ln(1+b). Se b>=0 → no mean reversion → None. - """ - if len(closes) < 30: - return None - y_lag = closes[:-1] - delta = [closes[i] - closes[i - 1] for i in range(1, len(closes))] - n = len(y_lag) - mx = sum(y_lag) / n - my = sum(delta) / n - num = sum((y_lag[i] - mx) * (delta[i] - my) for i in range(n)) - den = sum((y_lag[i] - mx) ** 2 for i in range(n)) - if den == 0: - return None - b = num / den - if b >= 0: - return None - one_plus_b = 1.0 + b - if one_plus_b <= 0: - return None - return -math.log(2.0) / math.log(one_plus_b) - - -# ───── garch11_forecast ───── - -def garch11_forecast( - closes: list[float], - max_iter: int = 50, -) -> dict[str, float] | None: - """Forecast GARCH(1,1) one-step-ahead sigma via metodo dei momenti - semplificato (no MLE). Pure-Python: stima omega, alpha, beta tramite - iterazione di punto fisso minimizzando MSE sul squared-return tracking. - Sufficiente per ranking volatility regimes; non production-grade. - """ - rets = _log_returns(closes) - if len(rets) < 50: - return None - mean = sum(rets) / len(rets) - centered = [r - mean for r in rets] - sq = [r * r for r in centered] - # Sample variance as long-run mean - var_lr = sum(sq) / len(sq) - if var_lr <= 0: - return None - # Simple grid for (alpha, beta) minimizing MSE of sigma2 vs realized sq - best = (1e18, 0.05, 0.90) - for a in [0.02, 0.05, 0.08, 0.10, 0.15]: - for b in [0.80, 0.85, 0.88, 0.90, 0.93]: - if a + b >= 0.999: - continue - omega = var_lr * (1 - a - b) - if omega <= 0: - continue - sigma2 = var_lr - mse = 0.0 - for s in sq[:-1]: - sigma2 = omega + a * s + b * sigma2 - mse += (sigma2 - s) ** 2 - if mse < best[0]: - best = (mse, a, b) - _, alpha, beta = best - omega = var_lr * (1 - alpha - beta) - sigma2 = var_lr - for s in sq: - sigma2 = omega + alpha * s + beta * sigma2 - sigma2_next = omega + alpha * sq[-1] + beta * sigma2 - return { - "sigma_next": math.sqrt(max(sigma2_next, 0.0)), - "alpha": alpha, - "beta": beta, - "omega": omega, - "long_run_sigma": math.sqrt(var_lr), - } - - -# ───── autocorrelation ───── - -def autocorrelation(values: list[float], max_lag: int = 10) -> dict[int, float]: - """Autocorrelation function (ACF) lag 1..max_lag. White noise → ≈ 0. - AR(1) phi → lag1 ≈ phi, lag-k ≈ phi^k. - """ - if len(values) < max_lag + 2: - return {} - n = len(values) - mean = sum(values) / n - dev = [v - mean for v in values] - var = sum(d * d for d in dev) / n - if var == 0: - return {lag: 0.0 for lag in range(1, max_lag + 1)} - out: dict[int, float] = {} - for lag in range(1, max_lag + 1): - cov = sum(dev[i] * dev[i + lag] for i in range(n - lag)) / n - out[lag] = cov / var - return out - - -# ───── rolling_sharpe ───── - -def rolling_sharpe( - closes: list[float], - window: int = 60, - annualization: int = 252, - risk_free: float = 0.0, -) -> dict[str, float] | None: - """Sharpe e Sortino rolling sull'ultimo `window` di log-returns. - Annualizzati. risk_free in tasso annualizzato. - """ - rets = _log_returns(closes) - if len(rets) < window: - return None - sample = rets[-window:] - daily_rf = risk_free / annualization - excess = [r - daily_rf for r in sample] - mean = sum(excess) / len(excess) - sd = _stddev(excess) - sharpe = (mean / sd) * math.sqrt(annualization) if sd > 0 else 0.0 - downside = [e for e in excess if e < 0] - if downside: - ds_var = sum(d * d for d in downside) / len(excess) - ds_sd = math.sqrt(ds_var) - sortino = (mean / ds_sd) * math.sqrt(annualization) if ds_sd > 0 else 0.0 - else: - sortino = sharpe * 2 # nessun downside → sortino "molto buono" - return {"sharpe": sharpe, "sortino": sortino, "mean_excess": mean, "stddev": sd} - - -# ───── var_cvar ───── - -def var_cvar(returns: list[float], confidences: list[float] | None = None) -> dict[str, float]: - """Historical VaR e CVaR (Expected Shortfall) ai livelli di confidenza. - returns: serie di rendimenti (qualsiasi periodicità). VaR/CVaR restituiti - come perdite positive (es. var_95=0.03 → -3% al 95%). - """ - confidences = confidences or [0.95, 0.99] - if len(returns) < 30: - return {} - sorted_rets = sorted(returns) - out: dict[str, float] = {} - for c in confidences: - tag = int(round(c * 100)) - q = 1.0 - c - var = -_percentile(sorted_rets, q) - cutoff = -var - tail = [r for r in sorted_rets if r <= cutoff] - cvar = -(sum(tail) / len(tail)) if tail else var - out[f"var_{tag}"] = var - out[f"cvar_{tag}"] = cvar - return out diff --git a/services/common/src/mcp_common/logging.py b/services/common/src/mcp_common/logging.py deleted file mode 100644 index 9e8007f..0000000 --- a/services/common/src/mcp_common/logging.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -import logging -import os -import re -import sys - -# pythonjsonlogger rinominato in .json; keep fallback per compat -try: - from pythonjsonlogger.json import JsonFormatter as _JsonFormatter # noqa: N813 -except ImportError: - from pythonjsonlogger.jsonlogger import JsonFormatter as _JsonFormatter # noqa: N813 - -SECRET_PATTERNS = [ - (re.compile(r"Bearer\s+[\w\-\._]+", re.IGNORECASE), "Bearer ***"), - (re.compile(r'("api_key"\s*:\s*")[^"]+(")'), r'\1***\2'), - (re.compile(r'("password"\s*:\s*")[^"]+(")'), r'\1***\2'), - (re.compile(r'("private_key"\s*:\s*")[^"]+(")'), r'\1***\2'), - (re.compile(r'("client_secret"\s*:\s*")[^"]+(")'), r'\1***\2'), - (re.compile(r"sk-[\w]{20,}"), "sk-***"), -] - - -class SecretsFilter(logging.Filter): - def filter(self, record: logging.LogRecord) -> bool: - msg = record.getMessage() - for pattern, replacement in SECRET_PATTERNS: - msg = pattern.sub(replacement, msg) - record.msg = msg - record.args = () # already formatted into msg - return True - - -def get_json_logger(name: str, level: int = logging.INFO) -> logging.Logger: - logger = logging.getLogger(name) - if logger.handlers: - return logger # already configured - logger.setLevel(level) - handler = logging.StreamHandler(sys.stderr) - formatter = _JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") - handler.setFormatter(formatter) - handler.addFilter(SecretsFilter()) - logger.addHandler(handler) - logger.propagate = False - return logger - - -def configure_root_logging( - *, - level: str | int | None = None, - format_type: str | None = None, -) -> None: - """CER-P5-009: configura il root logger con JSON o text formatter. - - Env overrides: - - LOG_LEVEL (default INFO) - - LOG_FORMAT=json|text (default json — production-ready structured log) - - Applica SecretsFilter su entrambi i format. - """ - lvl_raw = level if level is not None else os.environ.get("LOG_LEVEL", "INFO") - lvl = logging.getLevelName(lvl_raw.upper()) if isinstance(lvl_raw, str) else lvl_raw - fmt = (format_type or os.environ.get("LOG_FORMAT") or "json").lower() - - root = logging.getLogger() - # Rimuovi handler esistenti (basicConfig li avrebbe lasciati duplicati) - for h in list(root.handlers): - root.removeHandler(h) - - handler = logging.StreamHandler(sys.stderr) - if fmt == "json": - handler.setFormatter( - _JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") - ) - else: - handler.setFormatter( - logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s") - ) - handler.addFilter(SecretsFilter()) - root.addHandler(handler) - root.setLevel(lvl) diff --git a/services/common/src/mcp_common/mcp_bridge.py b/services/common/src/mcp_common/mcp_bridge.py deleted file mode 100644 index 5cec928..0000000 --- a/services/common/src/mcp_common/mcp_bridge.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Bridge MCP → endpoint REST esistenti. - -Implementa manualmente JSON-RPC 2.0 MCP su `POST /mcp` (no SSE, risposta -diretta in body JSON). Supporta: - - initialize - - notifications/initialized - - tools/list - - tools/call - -Claude Code config esempio: - - { - "mcpServers": { - "cerbero-memory": { - "type": "http", - "url": "http://localhost:8080/mcp-memory/mcp", - "headers": {"Authorization": "Bearer "} - } - } - } -""" -from __future__ import annotations - -import contextlib -from typing import Any - -import httpx -from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse - -from mcp_common.auth import TokenStore - -MCP_PROTOCOL_VERSION = "2024-11-05" - - -def _derive_input_schemas(app: FastAPI, tool_names: list[str]) -> dict[str, dict]: - """Estrae JSON schema del body Pydantic per ogni route POST /tools/. - - Risolve annotazioni lazy (PEP 563) via `typing.get_type_hints`. - Ritorna mapping {tool_name: json_schema}. Route senza body Pydantic o non - risolvibili vengono saltate: il chiamante userà un fallback. - """ - import typing - - from pydantic import BaseModel - - names_set = set(tool_names) - out: dict[str, dict] = {} - for route in app.routes: - path = getattr(route, "path", "") - if not path.startswith("/tools/"): - continue - name = path[len("/tools/"):] - if name not in names_set: - continue - endpoint = getattr(route, "endpoint", None) - if endpoint is None: - continue - try: - hints = typing.get_type_hints(endpoint) - except Exception: - continue - for pname, ann in hints.items(): - if pname == "return": - continue - if isinstance(ann, type) and issubclass(ann, BaseModel): - with contextlib.suppress(Exception): - out[name] = ann.model_json_schema() - break - return out - - -def _make_proxy_handler(internal_base_url: str, tool_name: str, token: str): - async def handler(args: dict | None) -> Any: - async with httpx.AsyncClient(timeout=30.0) as c: - r = await c.post( - f"{internal_base_url}/tools/{tool_name}", - headers={"Authorization": f"Bearer {token}"} if token else {}, - json=args or {}, - ) - if r.status_code >= 400: - raise RuntimeError( - f"tool {tool_name} failed: HTTP {r.status_code} — {r.text[:500]}" - ) - try: - return r.json() - except Exception: - return {"raw": r.text} - - return handler - - -def mount_mcp_endpoint( - app: FastAPI, - *, - name: str, - version: str, - token_store: TokenStore, - internal_base_url: str, - tools: list[dict], -) -> None: - """Registra un endpoint MCP JSON-RPC 2.0 su POST /mcp. - - Ogni tool è proxato verso POST {internal_base_url}/tools/ con il - Bearer token del client MCP (preservando le ACL REST esistenti). - - Args: - app: istanza FastAPI del service - name: nome server MCP - version: versione del service - token_store: lo stesso usato dai tool REST - internal_base_url: URL base interno (es. "http://localhost:9015") - tools: lista di {"name": str, "description": str, "input_schema"?: dict} - """ - tools_by_name = {t["name"]: t for t in tools} - - # Auto-derive input schemas from FastAPI routes (Pydantic body models). - # Permette al LLM di conoscere i nomi dei parametri obbligatori invece di - # indovinarli. Se il tool ha `input_schema` esplicito, vince sull'auto-derive. - derived_schemas = _derive_input_schemas(app, [t["name"] for t in tools]) - - def _tool_defs() -> list[dict]: - defs = [] - for t in tools: - schema = t.get("input_schema") or derived_schemas.get(t["name"]) or { - "type": "object", - "additionalProperties": True, - } - defs.append({ - "name": t["name"], - "description": t.get("description", t["name"]), - "inputSchema": schema, - }) - return defs - - async def _handle_rpc(body: dict, token: str) -> dict | None: - rpc_id = body.get("id") - method = body.get("method") - params = body.get("params") or {} - - # Notification (no id) → no response - if method == "notifications/initialized": - return None - - if method == "initialize": - return { - "jsonrpc": "2.0", - "id": rpc_id, - "result": { - "protocolVersion": MCP_PROTOCOL_VERSION, - "capabilities": {"tools": {"listChanged": False}}, - "serverInfo": {"name": name, "version": version}, - }, - } - - if method == "tools/list": - return { - "jsonrpc": "2.0", - "id": rpc_id, - "result": {"tools": _tool_defs()}, - } - - if method == "tools/call": - tool_name = params.get("name", "") - args = params.get("arguments") or {} - if tool_name not in tools_by_name: - return { - "jsonrpc": "2.0", - "id": rpc_id, - "error": {"code": -32601, "message": f"tool non trovato: {tool_name}"}, - } - handler = _make_proxy_handler(internal_base_url, tool_name, token) - try: - result = await handler(args) - return { - "jsonrpc": "2.0", - "id": rpc_id, - "result": { - "content": [ - { - "type": "text", - "text": _to_text(result), - } - ], - "isError": False, - }, - } - except Exception as e: - return { - "jsonrpc": "2.0", - "id": rpc_id, - "result": { - "content": [{"type": "text", "text": str(e)}], - "isError": True, - }, - } - - return { - "jsonrpc": "2.0", - "id": rpc_id, - "error": {"code": -32601, "message": f"metodo non supportato: {method}"}, - } - - @app.post("/mcp") - async def mcp_entry(request: Request): - auth = request.headers.get("Authorization", "") - if not auth.startswith("Bearer "): - return JSONResponse({"error": "missing bearer token"}, status_code=401) - token = auth[len("Bearer "):].strip() - principal = token_store.get(token) - if principal is None: - return JSONResponse({"error": "invalid token"}, status_code=403) - - body = await request.json() - - # Batch support - if isinstance(body, list): - results = [] - for item in body: - resp = await _handle_rpc(item, token) - if resp is not None: - results.append(resp) - return JSONResponse(results) - - resp = await _handle_rpc(body, token) - if resp is None: - # Notification (no id) → 204 no content - return JSONResponse(None, status_code=204) - return JSONResponse(resp) - - -def _to_text(value: Any) -> str: - import json - if isinstance(value, str): - return value - try: - return json.dumps(value, ensure_ascii=False, indent=2) - except Exception: - return str(value) diff --git a/services/common/src/mcp_common/microstructure.py b/services/common/src/mcp_common/microstructure.py deleted file mode 100644 index 056f442..0000000 --- a/services/common/src/mcp_common/microstructure.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Microstructure indicators: orderbook imbalance, slope, microprice. - -Tutte le funzioni accettano bids/asks come list[list[price, qty]] (formato -standard dei ticker exchange) e ritornano metriche aggregate exchange-agnostic. -""" -from __future__ import annotations - - -def orderbook_imbalance( - bids: list[list[float]], - asks: list[list[float]], - depth: int = 10, -) -> dict[str, float | None]: - """Imbalance ratio = (bid_vol - ask_vol) / (bid_vol + ask_vol) sui top-`depth` - livelli. Range [-1, +1]. Positivo = bid pressure, negativo = ask pressure. - - Microprice (Stoll-Bertsimas): mid pesato dalla size opposta - → P_micro = (P_bid * Q_ask + P_ask * Q_bid) / (Q_bid + Q_ask). Best level only. - - Slope: variazione cumulata di volume per unità di prezzo (proxy per - liquidità in profondità). - """ - if not bids and not asks: - return { - "imbalance_ratio": None, - "bid_volume": 0.0, - "ask_volume": 0.0, - "microprice": None, - "bid_slope": None, - "ask_slope": None, - } - - top_bids = bids[:depth] - top_asks = asks[:depth] - bid_vol = sum(q for _, q in top_bids) - ask_vol = sum(q for _, q in top_asks) - total = bid_vol + ask_vol - - ratio = None if total == 0 else (bid_vol - ask_vol) / total - - # Microprice: best bid, best ask. Weighted by opposite-side size. - microprice = None - if top_bids and top_asks: - bp, bq = top_bids[0] - ap, aq = top_asks[0] - denom = bq + aq - if denom > 0: - microprice = (bp * aq + ap * bq) / denom - - bid_slope = _depth_slope(top_bids, ascending_price=False) - ask_slope = _depth_slope(top_asks, ascending_price=True) - - return { - "imbalance_ratio": ratio, - "bid_volume": bid_vol, - "ask_volume": ask_vol, - "microprice": microprice, - "bid_slope": bid_slope, - "ask_slope": ask_slope, - } - - -def _depth_slope(levels: list[list[float]], ascending_price: bool) -> float | None: - """Calcola |Δq / Δp| sul primo vs penultimo livello. - Slope alto = liquidità che crolla rapidamente in profondità (book sottile). - """ - if len(levels) < 2: - return None - p_first, q_first = levels[0] - p_last, q_last = levels[-1] - dp = abs(p_last - p_first) - if dp == 0: - return None - return abs(q_first - q_last) / dp diff --git a/services/common/src/mcp_common/options.py b/services/common/src/mcp_common/options.py deleted file mode 100644 index 7e1b66b..0000000 --- a/services/common/src/mcp_common/options.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Logiche option-flow exchange-agnostiche. - -Ogni funzione accetta una lista di "legs" (dizionari) con i campi rilevanti -e ritorna metriche aggregate. La normalizzazione exchange-specific dei dati -spetta al chiamante (es. mcp-deribit costruisce le legs da chain + ticker). -""" -from __future__ import annotations - -from typing import Any - -# Convention dealer gamma: i dealer sono SHORT calls (le vendono al retail) e -# LONG puts. Quando spot sale e dealer sono short calls, comprano underlying -# (positive feedback → vol amplificata). Quando spot scende e dealer long puts, -# vendono underlying (positive feedback). Net dealer gamma negativo → mercato -# instabile (squeeze in entrambe le direzioni). - - -def oi_weighted_skew(legs: list[dict[str, Any]]) -> dict[str, float | int | None]: - """Skew aggregato pesato per OI: IV media puts - IV media calls. - Positivo = puts richer (paura), negativo = calls richer (greed). - """ - call_num = call_den = 0.0 - put_num = put_den = 0.0 - for leg in legs: - iv = leg.get("iv") - oi = leg.get("oi") or 0 - if iv is None or oi <= 0: - continue - if leg.get("option_type") == "call": - call_num += iv * oi - call_den += oi - elif leg.get("option_type") == "put": - put_num += iv * oi - put_den += oi - call_iv = call_num / call_den if call_den > 0 else None - put_iv = put_num / put_den if put_den > 0 else None - skew = (put_iv - call_iv) if (call_iv is not None and put_iv is not None) else None - return { - "skew": skew, - "call_iv_weighted": call_iv, - "put_iv_weighted": put_iv, - "total_oi": int(call_den + put_den), - } - - -def smile_asymmetry(legs: list[dict[str, Any]], spot: float) -> dict[str, float | None]: - """Smile asymmetry: differenza media IV otm puts vs otm calls a parità - di moneyness. Positivo = put-side richer (skew negativo classico equity). - """ - if spot <= 0 or not legs: - return {"atm_iv": None, "asymmetry": None, "otm_put_iv": None, "otm_call_iv": None} - - # ATM IV: media IV strike entro ±1% da spot - atm_ivs = [leg["iv"] for leg in legs if leg.get("iv") is not None and abs(leg.get("strike", 0) - spot) / spot < 0.01] - atm_iv = sum(atm_ivs) / len(atm_ivs) if atm_ivs else None - - otm_put_ivs = [ - leg["iv"] for leg in legs - if leg.get("iv") is not None and leg.get("option_type") == "put" and leg.get("strike", 0) < spot * 0.95 - ] - otm_call_ivs = [ - leg["iv"] for leg in legs - if leg.get("iv") is not None and leg.get("option_type") == "call" and leg.get("strike", 0) > spot * 1.05 - ] - otm_put = sum(otm_put_ivs) / len(otm_put_ivs) if otm_put_ivs else None - otm_call = sum(otm_call_ivs) / len(otm_call_ivs) if otm_call_ivs else None - asym = (otm_put - otm_call) if (otm_put is not None and otm_call is not None) else None - return { - "atm_iv": atm_iv, - "asymmetry": asym, - "otm_put_iv": otm_put, - "otm_call_iv": otm_call, - } - - -def atm_vs_wings_vol(legs: list[dict[str, Any]], spot: float) -> dict[str, float | None]: - """IV ATM vs IV alle ali 25-delta. Wing richness > 0 → smile (kurtosis vol). - """ - if not legs: - return {"atm_iv": None, "wing_25d_call_iv": None, "wing_25d_put_iv": None, "wing_richness": None} - - def _closest(target_delta: float, opt_type: str, tol: float = 0.1) -> float | None: - best = None - best_dist = float("inf") - for leg in legs: - d = leg.get("delta") - iv = leg.get("iv") - if d is None or iv is None or leg.get("option_type") != opt_type: - continue - dist = abs(abs(d) - abs(target_delta)) - if dist < best_dist: - best_dist = dist - best = iv - return best if best_dist <= tol else None - - # ATM IV: leg con delta più vicino a 0.5 (call) o -0.5 (put) - atm_call_iv = _closest(0.5, "call") - atm_put_iv = _closest(-0.5, "put") - atm_ivs = [v for v in (atm_call_iv, atm_put_iv) if v is not None] - atm_iv = sum(atm_ivs) / len(atm_ivs) if atm_ivs else None - - wing_call = _closest(0.25, "call") - wing_put = _closest(-0.25, "put") - wing_avg = None - if wing_call is not None and wing_put is not None: - wing_avg = (wing_call + wing_put) / 2 - - richness = (wing_avg - atm_iv) if (wing_avg is not None and atm_iv is not None) else None - return { - "atm_iv": atm_iv, - "wing_25d_call_iv": wing_call, - "wing_25d_put_iv": wing_put, - "wing_richness": richness, - } - - -def dealer_gamma_profile( - legs: list[dict[str, Any]], - spot: float, -) -> dict[str, Any]: - """Net dealer gamma per strike (assume dealer short calls + long puts). - Restituisce per strike: call_dealer_gamma (negativo), put_dealer_gamma - (positivo), net. Aggregato totale + zero-cross strike (gamma flip). - """ - by_strike: dict[float, dict[str, float]] = {} - for leg in legs: - strike = leg.get("strike") - gamma = leg.get("gamma") - oi = leg.get("oi") or 0 - if strike is None or gamma is None or oi <= 0 or spot <= 0: - continue - contrib = float(gamma) * oi * (spot ** 2) * 0.01 - entry = by_strike.setdefault( - float(strike), - {"strike": float(strike), "call_dealer_gamma": 0.0, "put_dealer_gamma": 0.0}, - ) - if leg.get("option_type") == "call": - entry["call_dealer_gamma"] -= contrib # dealer short calls - elif leg.get("option_type") == "put": - entry["put_dealer_gamma"] += contrib # dealer long puts - - rows: list[dict[str, float]] = [] - for s in sorted(by_strike.keys()): - e = by_strike[s] - e["net_dealer_gamma"] = e["call_dealer_gamma"] + e["put_dealer_gamma"] - rows.append(e) - - flip_level = None - for a, b in zip(rows, rows[1:], strict=False): - if (a["net_dealer_gamma"] < 0 <= b["net_dealer_gamma"]) or ( - a["net_dealer_gamma"] > 0 >= b["net_dealer_gamma"] - ): - denom = b["net_dealer_gamma"] - a["net_dealer_gamma"] - if denom != 0: - frac = -a["net_dealer_gamma"] / denom - flip_level = round(a["strike"] + frac * (b["strike"] - a["strike"]), 2) - break - - total = sum(r["net_dealer_gamma"] for r in rows) - return { - "by_strike": [ - { - "strike": r["strike"], - "call_dealer_gamma": round(r["call_dealer_gamma"], 2), - "put_dealer_gamma": round(r["put_dealer_gamma"], 2), - "net_dealer_gamma": round(r["net_dealer_gamma"], 2), - } - for r in rows - ], - "total_net_dealer_gamma": round(total, 2), - "gamma_flip_level": flip_level, - } - - -def vanna_charm_aggregate( - legs: list[dict[str, Any]], - spot: float, -) -> dict[str, Any]: - """Vanna (∂delta/∂IV) e Charm (∂delta/∂t) aggregati pesati per OI. - Vanna positiva → IV up, calls hedge buys; charm negativa → time decay - pushes delta down (calls only). - """ - total_vanna = 0.0 - total_charm = 0.0 - legs_used = 0 - for leg in legs: - vanna = leg.get("vanna") - charm = leg.get("charm") - oi = leg.get("oi") or 0 - if vanna is None or charm is None or oi <= 0: - continue - sign = 1 if leg.get("option_type") == "call" else -1 - total_vanna += float(vanna) * oi * sign - total_charm += float(charm) * oi * sign - legs_used += 1 - return { - "total_vanna": total_vanna, - "total_charm": total_charm, - "legs_analyzed": legs_used, - "spot": spot, - } diff --git a/services/common/src/mcp_common/server.py b/services/common/src/mcp_common/server.py deleted file mode 100644 index 7159ce2..0000000 --- a/services/common/src/mcp_common/server.py +++ /dev/null @@ -1,220 +0,0 @@ -from __future__ import annotations - -import json -import os -import time -import uuid -from collections.abc import Callable -from contextlib import AbstractAsyncContextManager -from datetime import UTC, datetime -from typing import Any - -from fastapi import FastAPI, HTTPException, Request -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse, Response -from starlette.middleware.base import BaseHTTPMiddleware - -from mcp_common.auth import TokenStore - -Lifespan = Callable[[FastAPI], AbstractAsyncContextManager[None]] - - -def _error_envelope( - *, - type_: str, - code: str, - message: str, - retryable: bool, - suggested_fix: str | None = None, - details: dict | None = None, - request_id: str | None = None, -) -> dict: - env: dict[str, Any] = { - "error": { - "type": type_, - "code": code, - "message": message, - "retryable": retryable, - }, - "request_id": request_id or uuid.uuid4().hex, - "data_timestamp": datetime.now(UTC).isoformat(), - } - if suggested_fix: - env["error"]["suggested_fix"] = suggested_fix - if details: - env["error"]["details"] = details - return env - - -class _TimestampInjectorMiddleware(BaseHTTPMiddleware): - """CER-P5-001: inietta data_timestamp nei response tool. - - - Dict response: body gains `data_timestamp` se mancante. - - List of dicts: ogni item gains `data_timestamp` se mancante. - - Header `X-Data-Timestamp` sempre presente (universale per list primitive). - Skips /health (già popolato) e /mcp (JSON-RPC bridge) e non-JSON responses. - """ - - async def dispatch(self, request: Request, call_next): - response = await call_next(request) - path = request.url.path - if not path.startswith("/tools/"): - return response - ctype = response.headers.get("content-type", "") - if "application/json" not in ctype: - return response - body = b"" - async for chunk in response.body_iterator: - body += chunk - ts = datetime.now(UTC).isoformat() - try: - data = json.loads(body) if body else None - except Exception: - headers = dict(response.headers) - headers["X-Data-Timestamp"] = ts - return Response( - content=body, - status_code=response.status_code, - headers=headers, - media_type=response.media_type, - ) - - modified = False - if isinstance(data, dict) and "data_timestamp" not in data: - data["data_timestamp"] = ts - modified = True - elif isinstance(data, list): - for item in data: - if isinstance(item, dict) and "data_timestamp" not in item: - item["data_timestamp"] = ts - modified = True - - headers = dict(response.headers) - headers["X-Data-Timestamp"] = ts - if modified: - new_body = json.dumps(data, default=str).encode() - headers.pop("content-length", None) - return Response( - content=new_body, - status_code=response.status_code, - headers=headers, - media_type="application/json", - ) - return Response( - content=body, - status_code=response.status_code, - headers=headers, - media_type=response.media_type, - ) - - -def build_app( - *, - name: str, - version: str, - token_store: TokenStore, - lifespan: Lifespan | None = None, -) -> FastAPI: - root_path = os.getenv("ROOT_PATH", "") - app = FastAPI(title=name, version=version, root_path=root_path, lifespan=lifespan) - app.state.token_store = token_store - app.state.boot_at = time.time() - - app.add_middleware(_TimestampInjectorMiddleware) - - @app.middleware("http") - async def _latency_header(request: Request, call_next): - t0 = time.perf_counter() - response = await call_next(request) - dur_ms = (time.perf_counter() - t0) * 1000 - response.headers["X-Duration-Ms"] = f"{dur_ms:.2f}" - return response - - # CER-P5-002 error envelope: exception handlers globali - @app.exception_handler(HTTPException) - async def _http_exc(request: Request, exc: HTTPException): - retryable = exc.status_code in (408, 429, 502, 503, 504) - code_map = { - 400: "BAD_REQUEST", 401: "UNAUTHORIZED", 403: "FORBIDDEN", - 404: "NOT_FOUND", 408: "TIMEOUT", 409: "CONFLICT", - 422: "VALIDATION_ERROR", 429: "RATE_LIMIT", - 500: "INTERNAL_ERROR", 502: "UPSTREAM_ERROR", - 503: "UNAVAILABLE", 504: "GATEWAY_TIMEOUT", - } - code = code_map.get(exc.status_code, f"HTTP_{exc.status_code}") - message = "HTTP error" - details: dict | None = None - detail = exc.detail - # Preserve rail-style detail {"error": "..", "message": ".."} as code - if isinstance(detail, dict): - if isinstance(detail.get("error"), str): - code = detail["error"].upper() - message = str(detail.get("message") or detail.get("error") or message) - details = detail - elif isinstance(detail, str): - message = detail - return JSONResponse( - status_code=exc.status_code, - content=_error_envelope( - type_="http_error", - code=code, - message=message, - retryable=retryable, - details=details, - ), - ) - - @app.exception_handler(RequestValidationError) - async def _validation_exc(request: Request, exc: RequestValidationError): - errs = exc.errors() - first_loc = ".".join(str(x) for x in errs[0]["loc"]) if errs else "body" - suggestion = ( - f"check field '{first_loc}': " - + (errs[0]["msg"] if errs else "invalid input") - ) - # Sanitize ctx values: pydantic v2 può mettere ValueError in ctx['error'], - # non serializzabile JSON. Riduci a stringhe. - safe_errs: list[dict] = [] - for e in errs[:5]: - ne: dict = {} - for k, v in e.items(): - if k == "ctx" and isinstance(v, dict): - ne[k] = {ck: str(cv) for ck, cv in v.items()} - else: - ne[k] = v - safe_errs.append(ne) - return JSONResponse( - status_code=422, - content=_error_envelope( - type_="validation_error", - code="INVALID_INPUT", - message=f"request body validation failed on {first_loc}", - retryable=False, - suggested_fix=suggestion, - details={"errors": safe_errs}, - ), - ) - - @app.exception_handler(Exception) - async def _unhandled(request: Request, exc: Exception): - return JSONResponse( - status_code=500, - content=_error_envelope( - type_="internal_error", - code="UNHANDLED_EXCEPTION", - message=f"{type(exc).__name__}: {str(exc)[:300]}", - retryable=True, - ), - ) - - @app.get("/health") - def health(): - return { - "status": "healthy", - "name": name, - "version": version, - "uptime_seconds": int(time.time() - app.state.boot_at), - "data_timestamp": datetime.now(UTC).isoformat(), - } - - return app diff --git a/services/common/src/mcp_common/stats.py b/services/common/src/mcp_common/stats.py deleted file mode 100644 index 79c1de3..0000000 --- a/services/common/src/mcp_common/stats.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Test statistici puri (cointegration, ADF, half-life già in indicators). -Nessuna dipendenza esterna: pure-Python. -""" -from __future__ import annotations - -import math - - -def _ols_slope_intercept(xs: list[float], ys: list[float]) -> tuple[float, float] | None: - if len(xs) != len(ys) or len(xs) < 3: - return None - n = len(xs) - mx = sum(xs) / n - my = sum(ys) / n - num = sum((xs[i] - mx) * (ys[i] - my) for i in range(n)) - den = sum((xs[i] - mx) ** 2 for i in range(n)) - if den == 0: - return None - slope = num / den - intercept = my - slope * mx - return slope, intercept - - -def _adf_t_stat(series: list[float]) -> float | None: - """Augmented Dickey-Fuller test stat semplificato (lag=0 → DF): - Δy_t = a + b*y_{t-1} + eps. t-stat di b vs zero. - Più negativo = più stazionario. Approssimazione: critical value ~ -2.86 al 5%. - """ - if len(series) < 30: - return None - y_lag = series[:-1] - delta = [series[i] - series[i - 1] for i in range(1, len(series))] - res = _ols_slope_intercept(y_lag, delta) - if res is None: - return None - b, a = res - n = len(y_lag) - mx = sum(y_lag) / n - den = sum((x - mx) ** 2 for x in y_lag) - if den == 0: - return None - fitted = [a + b * y_lag[i] for i in range(n)] - resid = [delta[i] - fitted[i] for i in range(n)] - rss = sum(r * r for r in resid) - if n - 2 <= 0: - return None - sigma2 = rss / (n - 2) - se_b = math.sqrt(sigma2 / den) - if se_b == 0: - return None - return b / se_b - - -def cointegration_test( - series_a: list[float], - series_b: list[float], - significance_t: float = -2.86, -) -> dict[str, float | bool | None]: - """Engle-Granger cointegration: - 1. OLS: y_t = alpha + beta * x_t + eps - 2. ADF su residui: se t-stat < critical (-2.86 @ 5%) → cointegrate. - """ - if len(series_a) != len(series_b) or len(series_a) < 50: - return { - "cointegrated": None, - "beta": None, - "alpha": None, - "adf_t_stat": None, - "spread_mean": None, - "spread_std": None, - } - res = _ols_slope_intercept(series_b, series_a) - if res is None: - return { - "cointegrated": None, - "beta": None, - "alpha": None, - "adf_t_stat": None, - "spread_mean": None, - "spread_std": None, - } - beta, alpha = res - spread = [series_a[i] - alpha - beta * series_b[i] for i in range(len(series_a))] - t_stat = _adf_t_stat(spread) - cointegrated = (t_stat is not None and t_stat < significance_t) - n = len(spread) - mean = sum(spread) / n - var = sum((s - mean) ** 2 for s in spread) / (n - 1) if n > 1 else 0.0 - return { - "cointegrated": cointegrated, - "beta": beta, - "alpha": alpha, - "adf_t_stat": t_stat, - "spread_mean": mean, - "spread_std": math.sqrt(var), - } diff --git a/services/common/tests/test_app_factory.py b/services/common/tests/test_app_factory.py deleted file mode 100644 index 67d347d..0000000 --- a/services/common/tests/test_app_factory.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations - -import json -from unittest.mock import MagicMock, patch - -import pytest -from mcp_common.app_factory import ExchangeAppSpec, run_exchange_main -from mcp_common.environment import EnvironmentInfo - - -def _make_spec(build_client=None, build_app=None) -> ExchangeAppSpec: - return ExchangeAppSpec( - exchange="testex", - creds_env_var="TESTEX_CREDENTIALS_FILE", - env_var="TESTEX_TESTNET", - flag_key="testnet", - default_base_url_live="https://api.testex.com", - default_base_url_testnet="https://test.testex.com", - default_port=9999, - build_client=build_client or (lambda creds, env_info: MagicMock(name="client")), - build_app=build_app or (lambda **kwargs: MagicMock(name="app")), - ) - - -def test_run_exchange_main_loads_creds_and_resolves_env(tmp_path, monkeypatch): - creds_file = tmp_path / "creds.json" - creds_file.write_text(json.dumps({"api_key": "k", "api_secret": "s"})) - monkeypatch.setenv("TESTEX_CREDENTIALS_FILE", str(creds_file)) - monkeypatch.setenv("PORT", "10000") - monkeypatch.delenv("TESTEX_TESTNET", raising=False) - - captured: dict = {} - - def build_client(creds, env_info): - captured["creds"] = creds - captured["env_info"] = env_info - return MagicMock() - - def build_app(**kwargs): - captured["app_kwargs"] = kwargs - return MagicMock() - - spec = _make_spec(build_client=build_client, build_app=build_app) - - with patch("mcp_common.app_factory.uvicorn.run") as mock_run: - run_exchange_main(spec) - - assert captured["creds"]["api_key"] == "k" - assert captured["creds"]["base_url_live"] == "https://api.testex.com" - assert captured["creds"]["base_url_testnet"] == "https://test.testex.com" - assert isinstance(captured["env_info"], EnvironmentInfo) - assert captured["env_info"].environment == "testnet" - assert captured["env_info"].exchange == "testex" - - assert "client" in captured["app_kwargs"] - assert "token_store" in captured["app_kwargs"] - assert "creds" in captured["app_kwargs"] - assert "env_info" in captured["app_kwargs"] - - call_kwargs = mock_run.call_args.kwargs - assert call_kwargs["port"] == 10000 # PORT override - - -def test_run_exchange_main_uses_default_port(tmp_path, monkeypatch): - creds_file = tmp_path / "creds.json" - creds_file.write_text(json.dumps({})) - monkeypatch.setenv("TESTEX_CREDENTIALS_FILE", str(creds_file)) - monkeypatch.delenv("PORT", raising=False) - - spec = _make_spec() - with patch("mcp_common.app_factory.uvicorn.run") as mock_run: - run_exchange_main(spec) - - assert mock_run.call_args.kwargs["port"] == 9999 - - -def test_run_exchange_main_env_var_overrides_creds(tmp_path, monkeypatch): - creds_file = tmp_path / "creds.json" - # `environment: mainnet` esplicito perché env var override → mainnet - # e consistency_check richiede conferma per evitare switch accidentale. - creds_file.write_text(json.dumps({"testnet": True, "environment": "mainnet"})) - monkeypatch.setenv("TESTEX_CREDENTIALS_FILE", str(creds_file)) - monkeypatch.setenv("TESTEX_TESTNET", "false") - - captured: dict = {} - - def build_client(creds, env_info): - captured["env_info"] = env_info - return MagicMock() - - spec = _make_spec(build_client=build_client) - - with patch("mcp_common.app_factory.uvicorn.run"): - run_exchange_main(spec) - - # env var "false" overrides creds.testnet=True → mainnet - assert captured["env_info"].environment == "mainnet" - assert captured["env_info"].source == "env" - - -def test_run_exchange_main_aborts_on_mainnet_without_confirmation(tmp_path, monkeypatch): - """Mainnet senza creds['environment']='mainnet' → boot abort fail-fast.""" - from mcp_common.environment import EnvironmentMismatchError - creds_file = tmp_path / "creds.json" - creds_file.write_text(json.dumps({"testnet": False})) - monkeypatch.setenv("TESTEX_CREDENTIALS_FILE", str(creds_file)) - monkeypatch.delenv("TESTEX_TESTNET", raising=False) - monkeypatch.delenv("STRICT_MAINNET", raising=False) - - spec = _make_spec() - with ( - pytest.raises(EnvironmentMismatchError), - patch("mcp_common.app_factory.uvicorn.run"), - ): - run_exchange_main(spec) - - -def test_run_exchange_main_strict_mainnet_disabled_via_env(tmp_path, monkeypatch): - """STRICT_MAINNET=false permette mainnet senza conferma (warning soltanto).""" - creds_file = tmp_path / "creds.json" - creds_file.write_text(json.dumps({"testnet": False})) - monkeypatch.setenv("TESTEX_CREDENTIALS_FILE", str(creds_file)) - monkeypatch.setenv("STRICT_MAINNET", "false") - - spec = _make_spec() - with patch("mcp_common.app_factory.uvicorn.run"): - run_exchange_main(spec) # non solleva - - -def test_run_exchange_main_missing_creds_file_exits(monkeypatch): - monkeypatch.delenv("TESTEX_CREDENTIALS_FILE", raising=False) - - spec = _make_spec() - import pytest - with pytest.raises(SystemExit) as exc_info: - run_exchange_main(spec) - assert exc_info.value.code == 2 diff --git a/services/common/tests/test_audit.py b/services/common/tests/test_audit.py deleted file mode 100644 index 2c4a631..0000000 --- a/services/common/tests/test_audit.py +++ /dev/null @@ -1,155 +0,0 @@ -from __future__ import annotations - -import logging - -import pytest -from mcp_common import audit as audit_mod -from mcp_common.audit import audit_write_op -from mcp_common.auth import Principal - - -@pytest.fixture -def captured_records(monkeypatch): - """Cattura i record emessi dal logger mcp.audit (propagate=False blocca caplog). - - Sostituisce il logger del modulo con uno che ha caplog attaccato. - """ - records: list[logging.LogRecord] = [] - - class ListHandler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - records.append(record) - - test_logger = logging.getLogger("mcp.audit.test") - test_logger.handlers.clear() - test_logger.addHandler(ListHandler()) - test_logger.setLevel(logging.DEBUG) - test_logger.propagate = False - monkeypatch.setattr(audit_mod, "_logger", test_logger) - return records - - -def test_audit_write_op_emits_structured_record(captured_records): - p = Principal("core", {"core"}) - audit_write_op( - principal=p, - action="place_order", - exchange="deribit", - target="BTC-PERPETUAL", - payload={"side": "buy", "amount": 10, "leverage": 3}, - result={"order_id": "abc", "state": "open"}, - ) - assert len(captured_records) == 1 - rec = captured_records[0] - assert rec.action == "place_order" - assert rec.exchange == "deribit" - assert rec.target == "BTC-PERPETUAL" - assert rec.principal == "core" - assert rec.payload == {"side": "buy", "amount": 10, "leverage": 3} - assert rec.result == {"order_id": "abc", "state": "open"} - - -def test_audit_write_op_error_uses_error_level(captured_records): - p = Principal("core", {"core"}) - audit_write_op( - principal=p, - action="cancel_order", - exchange="bybit", - target="ord-123", - payload={}, - error="not_found", - ) - assert len(captured_records) == 1 - rec = captured_records[0] - assert rec.levelname == "ERROR" - assert rec.error == "not_found" - - -def test_audit_write_op_summarizes_result_fields(captured_records): - p = Principal("core", {"core"}) - big_result = { - "order_id": "ord-1", - "state": "submitted", - "extra_huge_field": "x" * 10000, - "orders": [{"id": 1}, {"id": 2}, {"id": 3}], - } - audit_write_op( - principal=p, - action="place_combo_order", - exchange="bybit", - payload={}, - result=big_result, - ) - rec = captured_records[0] - assert "extra_huge_field" not in rec.result - assert rec.result["order_id"] == "ord-1" - assert rec.result["orders_count"] == 3 - - -def test_audit_write_op_no_principal(captured_records): - audit_write_op( - principal=None, - action="place_order", - exchange="alpaca", - payload={}, - ) - rec = captured_records[0] - assert rec.principal is None - - -def test_audit_write_op_writes_to_file_when_AUDIT_LOG_FILE_set(tmp_path, monkeypatch): - """Con env AUDIT_LOG_FILE settato, una riga JSON appare nel file.""" - import json - - from mcp_common import audit as audit_mod - - audit_file = tmp_path / "audit.jsonl" - monkeypatch.setenv("AUDIT_LOG_FILE", str(audit_file)) - # Reset state idempotency flag così il test riesegue setup - audit_mod._file_handler_attached = False - # Pulisci handlers preesistenti dal logger (potrebbe avere file vecchio) - for h in list(audit_mod._logger.handlers): - from logging.handlers import TimedRotatingFileHandler - if isinstance(h, TimedRotatingFileHandler): - audit_mod._logger.removeHandler(h) - - audit_write_op( - principal=Principal("core", {"core"}), - action="place_order", - exchange="bybit", - target="BTCUSDT", - payload={"side": "Buy", "qty": 0.01}, - result={"order_id": "abc123", "status": "submitted"}, - ) - - # Forza flush dei file handler - for h in audit_mod._logger.handlers: - h.flush() - - assert audit_file.exists() - content = audit_file.read_text().strip() - assert content, "audit file empty" - record = json.loads(content.splitlines()[-1]) - assert record["audit_event"] == "write_op" - assert record["action"] == "place_order" - assert record["exchange"] == "bybit" - assert record["target"] == "BTCUSDT" - assert record["principal"] == "core" - - -def test_audit_no_file_when_env_unset(tmp_path, monkeypatch): - """Senza AUDIT_LOG_FILE, nessun file viene creato.""" - from mcp_common import audit as audit_mod - monkeypatch.delenv("AUDIT_LOG_FILE", raising=False) - audit_mod._file_handler_attached = False - - audit_write_op( - principal=Principal("core", {"core"}), - action="cancel_order", - exchange="bybit", - target="ord-1", - payload={}, - ) - # Niente file creato in tmp_path - files = list(tmp_path.iterdir()) - assert files == [] diff --git a/services/common/tests/test_auth.py b/services/common/tests/test_auth.py deleted file mode 100644 index 3d2568e..0000000 --- a/services/common/tests/test_auth.py +++ /dev/null @@ -1,84 +0,0 @@ -import pytest -from fastapi import Depends, FastAPI -from fastapi.testclient import TestClient -from mcp_common.auth import ( - Principal, - TokenStore, - acl_requires, - require_principal, -) - - -@pytest.fixture -def token_store(): - return TokenStore(tokens={ - "token-core-123": Principal(name="core", capabilities={"core"}), - "token-obs-456": Principal(name="observer", capabilities={"observer"}), - }) - - -@pytest.fixture -def app(token_store): - app = FastAPI() - app.state.token_store = token_store - - @app.get("/public") - def public(): - return {"ok": True} - - @app.get("/private") - def private(principal: Principal = Depends(require_principal)): - return {"name": principal.name} - - @app.post("/core-only") - @acl_requires(core=True, observer=False) - def core_only(principal: Principal = Depends(require_principal)): - return {"who": principal.name} - - @app.post("/observer-only") - @acl_requires(core=False, observer=True) - def observer_only(principal: Principal = Depends(require_principal)): - return {"who": principal.name} - - return app - - -def test_public_endpoint_no_auth(app): - client = TestClient(app) - assert client.get("/public").status_code == 200 - - -def test_private_without_header_401(app): - client = TestClient(app) - assert client.get("/private").status_code == 401 - - -def test_private_bad_token_403(app): - client = TestClient(app) - r = client.get("/private", headers={"Authorization": "Bearer nope"}) - assert r.status_code == 403 - - -def test_private_good_token_200(app): - client = TestClient(app) - r = client.get("/private", headers={"Authorization": "Bearer token-core-123"}) - assert r.status_code == 200 - assert r.json() == {"name": "core"} - - -def test_acl_core_token_on_core_only_endpoint(app): - client = TestClient(app) - r = client.post("/core-only", headers={"Authorization": "Bearer token-core-123"}) - assert r.status_code == 200 - - -def test_acl_observer_on_core_only_rejected(app): - client = TestClient(app) - r = client.post("/core-only", headers={"Authorization": "Bearer token-obs-456"}) - assert r.status_code == 403 - - -def test_acl_observer_on_observer_only_ok(app): - client = TestClient(app) - r = client.post("/observer-only", headers={"Authorization": "Bearer token-obs-456"}) - assert r.status_code == 200 diff --git a/services/common/tests/test_environment.py b/services/common/tests/test_environment.py deleted file mode 100644 index 9840c2c..0000000 --- a/services/common/tests/test_environment.py +++ /dev/null @@ -1,189 +0,0 @@ -from __future__ import annotations - -import pytest -from mcp_common.environment import ( - EnvironmentInfo, - EnvironmentMismatchError, - consistency_check, - resolve_environment, -) - - -def test_env_var_overrides_secret(monkeypatch): - monkeypatch.setenv("DERIBIT_TESTNET", "false") - creds = {"testnet": True, "base_url_live": "L", "base_url_testnet": "T"} - info = resolve_environment( - creds, - env_var="DERIBIT_TESTNET", - flag_key="testnet", - exchange="deribit", - ) - assert info.environment == "mainnet" - assert info.source == "env" - assert info.env_value == "false" - assert info.base_url == "L" - - -def test_secret_used_when_env_missing(monkeypatch): - monkeypatch.delenv("DERIBIT_TESTNET", raising=False) - creds = {"testnet": True, "base_url_live": "L", "base_url_testnet": "T"} - info = resolve_environment( - creds, - env_var="DERIBIT_TESTNET", - flag_key="testnet", - exchange="deribit", - ) - assert info.environment == "testnet" - assert info.source == "credentials" - assert info.env_value is None - assert info.base_url == "T" - - -def test_default_when_both_missing(monkeypatch): - monkeypatch.delenv("FOO_TESTNET", raising=False) - creds = {"base_url_live": "L", "base_url_testnet": "T"} - info = resolve_environment( - creds, - env_var="FOO_TESTNET", - flag_key="testnet", - exchange="foo", - ) - assert info.environment == "testnet" - assert info.source == "default" - assert info.env_value is None - - -@pytest.mark.parametrize("raw,expected", [ - ("1", "testnet"), - ("true", "testnet"), - ("yes", "testnet"), - ("on", "testnet"), - ("TRUE", "testnet"), - ("0", "mainnet"), - ("false", "mainnet"), - ("no", "mainnet"), - ("off", "mainnet"), - ("garbage", "mainnet"), -]) -def test_env_value_truthy_parsing(monkeypatch, raw, expected): - monkeypatch.setenv("X_TESTNET", raw) - info = resolve_environment( - {"base_url_live": "L", "base_url_testnet": "T"}, - env_var="X_TESTNET", - flag_key="testnet", - exchange="x", - ) - assert info.environment == expected - - -def test_default_base_urls_applied_when_creds_missing(monkeypatch): - monkeypatch.delenv("X_TESTNET", raising=False) - creds: dict = {} - info = resolve_environment( - creds, - env_var="X_TESTNET", - flag_key="testnet", - exchange="x", - default_base_url_live="https://live.example", - default_base_url_testnet="https://test.example", - ) - assert info.base_url == "https://test.example" - assert creds["base_url_live"] == "https://live.example" - assert creds["base_url_testnet"] == "https://test.example" - - -def test_creds_base_urls_override_defaults(monkeypatch): - monkeypatch.delenv("X_TESTNET", raising=False) - creds = {"base_url_live": "L", "base_url_testnet": "T"} - info = resolve_environment( - creds, - env_var="X_TESTNET", - flag_key="testnet", - exchange="x", - default_base_url_live="https://live.example", - default_base_url_testnet="https://test.example", - ) - assert info.base_url == "T" - assert creds["base_url_live"] == "L" - - -def test_alpaca_paper_flag_key(monkeypatch): - """Alpaca usa 'paper' invece di 'testnet' nel secret.""" - monkeypatch.delenv("ALPACA_PAPER", raising=False) - creds = {"paper": False, "base_url_live": "L", "base_url_testnet": "T"} - info = resolve_environment( - creds, - env_var="ALPACA_PAPER", - flag_key="paper", - exchange="alpaca", - ) - assert info.environment == "mainnet" - assert info.source == "credentials" - - -# ───────── consistency_check ───────── - - -def _info(env: str, exchange: str = "deribit") -> EnvironmentInfo: - """Helper costruisce EnvironmentInfo per test.""" - return EnvironmentInfo( - exchange=exchange, - environment=env, - source="env", - env_value="false" if env == "mainnet" else "true", - base_url=f"https://api.{exchange}.com" if env == "mainnet" else f"https://test.{exchange}.com", - ) - - -def test_consistency_check_testnet_no_confirmation_ok(): - """Testnet senza conferma esplicita → ok, ritorna []. Default safe.""" - info = _info("testnet") - creds = {"api_key": "k", "api_secret": "s"} - warnings = consistency_check(info, creds) - assert warnings == [] - - -def test_consistency_check_mainnet_no_confirmation_raises(): - """Mainnet senza creds['environment']='mainnet' esplicito → fail-fast.""" - info = _info("mainnet") - creds = {"api_key": "k", "api_secret": "s"} - with pytest.raises(EnvironmentMismatchError, match="mainnet.*explicit confirmation"): - consistency_check(info, creds) - - -def test_consistency_check_mainnet_with_confirmation_ok(): - info = _info("mainnet") - creds = {"api_key": "k", "api_secret": "s", "environment": "mainnet"} - warnings = consistency_check(info, creds) - assert warnings == [] - - -def test_consistency_check_explicit_mismatch_raises(): - """Secret dichiara mainnet ma resolver risolve testnet → fail-fast.""" - info = _info("testnet") - creds = {"environment": "mainnet"} - with pytest.raises(EnvironmentMismatchError, match="declared.*resolved"): - consistency_check(info, creds) - - -def test_consistency_check_strict_mainnet_disabled(): - """Con strict_mainnet=False mainnet senza conferma logga warning ma non raise.""" - info = _info("mainnet") - creds = {"api_key": "k", "api_secret": "s"} - warnings = consistency_check(info, creds, strict_mainnet=False) - assert any("mainnet" in w for w in warnings) - - -def test_consistency_check_url_does_not_match_environment_warns(): - """Base URL contiene 'test' ma environment='mainnet' → warning.""" - from mcp_common.environment import EnvironmentInfo - info = EnvironmentInfo( - exchange="bybit", - environment="mainnet", - source="env", - env_value="false", - base_url="https://api-testnet.bybit.com", # url DICE testnet ma resolver MAINNET - ) - creds = {"environment": "mainnet"} - warnings = consistency_check(info, creds) - assert any("base_url" in w.lower() for w in warnings) diff --git a/services/common/tests/test_http.py b/services/common/tests/test_http.py deleted file mode 100644 index 6a3a950..0000000 --- a/services/common/tests/test_http.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import asyncio - -import httpx -import pytest -from mcp_common.http import async_client, call_with_retry - - -def test_async_client_uses_retry_transport(): - c = async_client(retries=5) - assert isinstance(c._transport, httpx.AsyncHTTPTransport) - # internal _retries on transport - assert c._transport._pool._retries == 5 - - -@pytest.mark.asyncio -async def test_call_with_retry_succeeds_first_try(): - calls = 0 - - async def fn(): - nonlocal calls - calls += 1 - return "ok" - - result = await call_with_retry(fn) - assert result == "ok" - assert calls == 1 - - -@pytest.mark.asyncio -async def test_call_with_retry_recovers_after_transient(monkeypatch): - monkeypatch.setattr(asyncio, "sleep", asyncio.coroutine(lambda *_: None) if False else _no_sleep) - calls = 0 - - async def fn(): - nonlocal calls - calls += 1 - if calls < 3: - raise httpx.ConnectError("boom") - return "ok" - - result = await call_with_retry(fn, max_attempts=5, base_delay=0.0) - assert result == "ok" - assert calls == 3 - - -async def _no_sleep(_): - return None - - -@pytest.mark.asyncio -async def test_call_with_retry_gives_up_after_max(): - calls = 0 - - async def fn(): - nonlocal calls - calls += 1 - raise httpx.TimeoutException("slow") - - with pytest.raises(httpx.TimeoutException): - await call_with_retry(fn, max_attempts=3, base_delay=0.0) - assert calls == 3 - - -@pytest.mark.asyncio -async def test_call_with_retry_does_not_catch_unexpected(): - async def fn(): - raise ValueError("not transient") - - with pytest.raises(ValueError): - await call_with_retry(fn, max_attempts=5, base_delay=0.0) diff --git a/services/common/tests/test_indicators.py b/services/common/tests/test_indicators.py deleted file mode 100644 index 293ddc0..0000000 --- a/services/common/tests/test_indicators.py +++ /dev/null @@ -1,260 +0,0 @@ - -import math - -from mcp_common.indicators import ( - adx, - atr, - autocorrelation, - garch11_forecast, - half_life_mean_reversion, - hurst_exponent, - macd, - rolling_sharpe, - rsi, - sma, - var_cvar, - vol_cone, -) - - -def test_rsi_simple(): - closes = [44, 44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, - 46.08, 45.89, 46.03, 45.61, 46.28] - r = rsi(closes, period=14) - assert r is not None - # Known textbook RSI value ballpark - assert 65.0 < r < 75.0 - - -def test_rsi_insufficient_data(): - assert rsi([1, 2, 3], period=14) is None - - -def test_sma_simple(): - assert sma([1, 2, 3, 4, 5], period=5) == 3.0 - assert sma([1, 2, 3], period=5) is None - - -def test_atr_simple(): - # highs, lows, closes - highs = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24] - lows = [ 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] - closes = [9.5,10.5,11.5,12.5,13.5,14.5,15.5,16.5,17.5,18.5,19.5,20.5,21.5,22.5,23.5] - a = atr(highs, lows, closes, period=14) - assert a is not None - assert 0.9 < a <= 1.5 - - -def test_macd_trend_up(): - # monotonic uptrend → MACD > 0, histogram > 0 - closes = [float(i) for i in range(1, 60)] - m = macd(closes) - assert m["macd"] is not None - assert m["signal"] is not None - assert m["hist"] is not None - assert m["macd"] > 0 - assert m["hist"] >= 0 - - -def test_macd_insufficient_data(): - m = macd([1.0, 2.0, 3.0]) - assert m == {"macd": None, "signal": None, "hist": None} - - -def test_macd_trend_down(): - closes = [float(i) for i in range(60, 1, -1)] - m = macd(closes) - assert m["macd"] < 0 - assert m["hist"] <= 0 - - -def test_adx_insufficient_data(): - a = adx([1.0] * 10, [0.5] * 10, [0.7] * 10, period=14) - assert a == {"adx": None, "+di": None, "-di": None} - - -def test_adx_strong_uptrend(): - highs = [float(i) + 1.0 for i in range(1, 40)] - lows = [float(i) for i in range(1, 40)] - closes = [float(i) + 0.5 for i in range(1, 40)] - a = adx(highs, lows, closes, period=14) - assert a["adx"] is not None - assert a["+di"] is not None and a["-di"] is not None - # strong uptrend → +DI >> -DI, ADX high - assert a["+di"] > a["-di"] - assert a["adx"] > 50.0 - - -def test_adx_flat_market(): - highs = [10.0] * 40 - lows = [9.0] * 40 - closes = [9.5] * 40 - a = adx(highs, lows, closes, period=14) - # no directional movement → ADX near 0 - assert a["adx"] is not None - assert a["adx"] < 5.0 - - -# ---------- vol_cone ---------- - -def _gbm_series(mu: float, sigma: float, n: int, seed: int = 42) -> list[float]: - """Mock GBM closes: deterministic for tests.""" - import random - r = random.Random(seed) - p = [100.0] - for _ in range(n): - z = r.gauss(0.0, 1.0) - p.append(p[-1] * math.exp(mu / 252 + sigma / math.sqrt(252) * z)) - return p - - -def test_vol_cone_returns_percentiles_per_window(): - closes = _gbm_series(mu=0.0, sigma=0.5, n=400) - out = vol_cone(closes, windows=[10, 30, 60]) - assert set(out.keys()) == {10, 30, 60} - for _w, stats in out.items(): - assert "current" in stats - assert "p10" in stats and "p50" in stats and "p90" in stats - assert stats["p10"] <= stats["p50"] <= stats["p90"] - # annualized — sensible range for sigma=0.5 - assert 0.1 < stats["p50"] < 1.5 - - -def test_vol_cone_insufficient_data(): - out = vol_cone([100.0, 101.0], windows=[10, 30]) - assert out[10]["current"] is None - assert out[30]["current"] is None - - -# ---------- hurst_exponent ---------- - -def test_hurst_random_walk_near_half(): - closes = _gbm_series(mu=0.0, sigma=0.3, n=500, seed=7) - h = hurst_exponent(closes) - assert h is not None - # Random walk → Hurst ≈ 0.5; R/S bias positivo ben noto su sample finiti. - # Bound largo: distinguere comunque random walk da trending forte (>0.85). - assert 0.35 < h < 0.85 - - -def test_hurst_persistent_trend(): - # Strong monotonic trend → H >> 0.5 - closes = [100.0 + i * 0.5 + math.sin(i / 10) * 0.1 for i in range(400)] - h = hurst_exponent(closes) - assert h is not None - assert h > 0.85 - - -def test_hurst_insufficient_data(): - assert hurst_exponent([1.0, 2.0, 3.0]) is None - - -# ---------- half_life_mean_reversion ---------- - -def test_half_life_mean_reverting_series(): - """OU process with theta=0.1 → half-life ≈ ln(2)/0.1 ≈ 6.93.""" - import random - r = random.Random(123) - theta = 0.1 - mu = 100.0 - sigma = 0.5 - s = [mu] - for _ in range(500): - s.append(s[-1] + theta * (mu - s[-1]) + sigma * r.gauss(0, 1)) - hl = half_life_mean_reversion(s) - assert hl is not None - # broad tolerance — finite-sample noise - assert 3.0 < hl < 20.0 - - -def test_half_life_trending_returns_none(): - closes = [100.0 + i for i in range(200)] - hl = half_life_mean_reversion(closes) - # No mean reversion → returns None or +inf - assert hl is None or hl > 1000 - - -# ---------- garch11_forecast ---------- - -def test_garch11_forecast_returns_positive_sigma(): - closes = _gbm_series(mu=0.0, sigma=0.4, n=500, seed=11) - out = garch11_forecast(closes) - assert out is not None - assert out["sigma_next"] > 0 - assert 0 < out["alpha"] < 1 - assert 0 < out["beta"] < 1 - assert out["alpha"] + out["beta"] < 1.0 # stationarity - - -def test_garch11_insufficient_data(): - assert garch11_forecast([100.0, 101.0]) is None - - -# ---------- autocorrelation ---------- - -def test_autocorrelation_white_noise_low(): - import random - r = random.Random(1) - rets = [r.gauss(0, 0.01) for _ in range(500)] - out = autocorrelation(rets, max_lag=5) - assert len(out) == 5 - # white noise → all autocorr ≈ 0 (within ±2/sqrt(N)) - bound = 2.0 / math.sqrt(len(rets)) - for _lag, val in out.items(): - assert abs(val) < bound * 2 # generous - - -def test_autocorrelation_lag1_strong_for_ar1(): - """AR(1) with phi=0.7 → autocorr lag-1 ≈ 0.7.""" - import random - r = random.Random(2) - s = [0.0] - for _ in range(500): - s.append(0.7 * s[-1] + r.gauss(0, 0.1)) - out = autocorrelation(s, max_lag=3) - assert out[1] > 0.5 - assert out[2] > 0.2 # geometric decay - - -def test_autocorrelation_insufficient_data(): - assert autocorrelation([1.0], max_lag=5) == {} - - -# ---------- rolling_sharpe ---------- - -def test_rolling_sharpe_positive_for_uptrend(): - closes = [100.0 * (1 + 0.001 * i) for i in range(252)] - s = rolling_sharpe(closes, window=60) - assert s is not None - assert s["sharpe"] > 0 - assert s["sortino"] >= s["sharpe"] / 2 # sortino can be high if no downside - - -def test_rolling_sharpe_zero_volatility(): - closes = [100.0] * 100 - s = rolling_sharpe(closes, window=60) - assert s is not None - assert s["sharpe"] == 0.0 # no variance → 0 by convention - - -def test_rolling_sharpe_insufficient_data(): - assert rolling_sharpe([100.0, 101.0], window=60) is None - - -# ---------- var_cvar ---------- - -def test_var_cvar_basic(): - import random - r = random.Random(3) - rets = [r.gauss(0.0005, 0.02) for _ in range(1000)] - out = var_cvar(rets, confidences=[0.95, 0.99]) - assert "var_95" in out and "cvar_95" in out - assert "var_99" in out and "cvar_99" in out - # VaR is loss → positive number representing percentile loss - assert out["var_95"] > 0 - assert out["cvar_95"] >= out["var_95"] # CVaR worse than VaR - assert out["var_99"] >= out["var_95"] - - -def test_var_cvar_insufficient_data(): - assert var_cvar([0.01], confidences=[0.95]) == {} diff --git a/services/common/tests/test_logging.py b/services/common/tests/test_logging.py deleted file mode 100644 index 24a69ba..0000000 --- a/services/common/tests/test_logging.py +++ /dev/null @@ -1,77 +0,0 @@ -import json -import logging - -from mcp_common.logging import ( - SecretsFilter, - configure_root_logging, - get_json_logger, -) - - -def test_secrets_filter_masks_bearer(): - f = SecretsFilter() - rec = logging.LogRecord( - name="t", level=logging.INFO, pathname="", lineno=0, - msg="Got Bearer abcdef123456 from client", - args=(), exc_info=None, - ) - f.filter(rec) - assert "abcdef" not in rec.msg - assert "***" in rec.msg - - -def test_secrets_filter_masks_api_key_json(): - f = SecretsFilter() - rec = logging.LogRecord( - name="t", level=logging.INFO, pathname="", lineno=0, - msg='{"api_key": "sk-live-abc123xyz"}', - args=(), exc_info=None, - ) - f.filter(rec) - assert "sk-live-abc123xyz" not in rec.msg - - -def test_json_logger_outputs_json(capsys): - logger = get_json_logger("test") - logger.info("hello", extra={"user_id": 42}) - captured = capsys.readouterr() - # output is on stderr by default for json logger - line = (captured.err or captured.out).strip().splitlines()[-1] - data = json.loads(line) - assert data["message"] == "hello" - assert data["user_id"] == 42 - - -def test_configure_root_json_format(monkeypatch, capsys): - monkeypatch.setenv("LOG_FORMAT", "json") - monkeypatch.setenv("LOG_LEVEL", "INFO") - configure_root_logging() - logging.info("root json test") - line = capsys.readouterr().err.strip().splitlines()[-1] - data = json.loads(line) - assert data["message"] == "root json test" - assert data["levelname"] == "INFO" - - -def test_configure_root_text_format(monkeypatch, capsys): - monkeypatch.setenv("LOG_FORMAT", "text") - configure_root_logging() - logging.info("root text test") - line = capsys.readouterr().err.strip().splitlines()[-1] - # text format non è JSON parseable - try: - json.loads(line) - raise AssertionError("expected text format, got JSON") - except json.JSONDecodeError: - pass - assert "root text test" in line - - -def test_configure_root_applies_secrets_filter(monkeypatch, capsys): - monkeypatch.setenv("LOG_FORMAT", "json") - configure_root_logging() - logging.info("calling with Bearer sk-live-leak123456 token") - line = capsys.readouterr().err.strip().splitlines()[-1] - data = json.loads(line) - assert "sk-live-leak123456" not in data["message"] - assert "***" in data["message"] diff --git a/services/common/tests/test_mcp_bridge.py b/services/common/tests/test_mcp_bridge.py deleted file mode 100644 index 357b639..0000000 --- a/services/common/tests/test_mcp_bridge.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -from fastapi import Depends, FastAPI -from fastapi.testclient import TestClient -from mcp_common.auth import Principal, TokenStore, require_principal -from mcp_common.mcp_bridge import _derive_input_schemas, mount_mcp_endpoint -from mcp_common.server import build_app -from pydantic import BaseModel - - -class EchoBody(BaseModel): - msg: str - n: int = 1 - - -def _make_app() -> tuple[FastAPI, TokenStore]: - store = TokenStore(tokens={"t": Principal("obs", {"observer"})}) - app = build_app(name="t", version="v", token_store=store) - - @app.post("/tools/echo") - def echo(body: EchoBody, principal: Principal = Depends(require_principal)): - return {"echo": body.msg, "n": body.n} - - @app.post("/tools/ping") - def ping(principal: Principal = Depends(require_principal)): - return {"pong": True} - - return app, store - - -def test_derive_input_schemas_resolves_lazy_annotations(): - app, _ = _make_app() - schemas = _derive_input_schemas(app, ["echo", "ping"]) - assert "echo" in schemas - echo_schema = schemas["echo"] - assert echo_schema["type"] == "object" - assert "msg" in echo_schema["properties"] - assert "n" in echo_schema["properties"] - assert "msg" in echo_schema["required"] - # ping has no Pydantic body → not in map (fallback applied by caller) - assert "ping" not in schemas - - -def test_mount_mcp_endpoint_exposes_derived_schemas(): - app, store = _make_app() - mount_mcp_endpoint( - app, - name="test", - version="1.0", - token_store=store, - internal_base_url="http://localhost:0", - tools=[ - {"name": "echo", "description": "Echo a message."}, - {"name": "ping", "description": "Ping."}, - ], - ) - c = TestClient(app) - r = c.post( - "/mcp", - headers={"Authorization": "Bearer t"}, - json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, - ) - assert r.status_code == 200 - tools = r.json()["result"]["tools"] - by_name = {t["name"]: t for t in tools} - assert set(by_name["echo"]["inputSchema"]["required"]) == {"msg"} - # ping fallback su schema generico - assert by_name["ping"]["inputSchema"] == { - "type": "object", - "additionalProperties": True, - } - - -def test_mount_mcp_endpoint_requires_auth(): - app, store = _make_app() - mount_mcp_endpoint( - app, - name="test", - version="1.0", - token_store=store, - internal_base_url="http://localhost:0", - tools=[{"name": "echo"}], - ) - c = TestClient(app) - r = c.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) - assert r.status_code == 401 - r = c.post( - "/mcp", - headers={"Authorization": "Bearer WRONG"}, - json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, - ) - assert r.status_code == 403 - - -def test_explicit_input_schema_overrides_derived(): - app, store = _make_app() - custom = {"type": "object", "properties": {"custom": {"type": "string"}}, "required": ["custom"]} - mount_mcp_endpoint( - app, - name="test", - version="1.0", - token_store=store, - internal_base_url="http://localhost:0", - tools=[{"name": "echo", "input_schema": custom}], - ) - c = TestClient(app) - r = c.post( - "/mcp", - headers={"Authorization": "Bearer t"}, - json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, - ) - assert r.json()["result"]["tools"][0]["inputSchema"] == custom diff --git a/services/common/tests/test_microstructure.py b/services/common/tests/test_microstructure.py deleted file mode 100644 index ef1da47..0000000 --- a/services/common/tests/test_microstructure.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -from mcp_common.microstructure import orderbook_imbalance - - -def test_orderbook_imbalance_balanced(): - bids = [[100.0, 1.0], [99.5, 1.0], [99.0, 1.0]] - asks = [[100.5, 1.0], [101.0, 1.0], [101.5, 1.0]] - out = orderbook_imbalance(bids, asks, depth=3) - assert abs(out["imbalance_ratio"]) < 0.01 # bilanciato - assert out["bid_volume"] == 3.0 - assert out["ask_volume"] == 3.0 - assert out["microprice"] is not None - - -def test_orderbook_imbalance_bid_heavy(): - bids = [[100.0, 5.0], [99.5, 5.0]] - asks = [[100.5, 1.0], [101.0, 1.0]] - out = orderbook_imbalance(bids, asks, depth=2) - assert out["imbalance_ratio"] > 0.5 # forte bid pressure - assert out["bid_volume"] == 10.0 - assert out["ask_volume"] == 2.0 - - -def test_orderbook_imbalance_ask_heavy(): - bids = [[100.0, 1.0], [99.5, 1.0]] - asks = [[100.5, 5.0], [101.0, 5.0]] - out = orderbook_imbalance(bids, asks, depth=2) - assert out["imbalance_ratio"] < -0.5 - - -def test_orderbook_imbalance_microprice_skew(): - """Microprice è weighted mid: pesato bid/ask depth opposto.""" - bids = [[100.0, 9.0]] - asks = [[101.0, 1.0]] - out = orderbook_imbalance(bids, asks, depth=1) - # large bid → microprice closer to ask (paradox: weighted by *opposite* size) - assert out["microprice"] > 100.5 - - -def test_orderbook_imbalance_empty(): - out = orderbook_imbalance([], [], depth=5) - assert out["imbalance_ratio"] is None - assert out["microprice"] is None - - -def test_orderbook_imbalance_one_sided(): - out = orderbook_imbalance([[100.0, 1.0]], [], depth=1) - assert out["imbalance_ratio"] == 1.0 # all bid - - -def test_orderbook_imbalance_slope(): - """Slope = velocity of liquidity dropoff: ripido = poca liquidità in profondità.""" - bids_steep = [[100.0, 10.0], [99.0, 1.0]] # depth crolla → slope alto - asks_steep = [[101.0, 10.0], [102.0, 1.0]] - out = orderbook_imbalance(bids_steep, asks_steep, depth=2) - assert out["bid_slope"] is not None - # bid liquidity drops by 9 per 1 price unit → slope ~9 - assert out["bid_slope"] > 5.0 diff --git a/services/common/tests/test_options.py b/services/common/tests/test_options.py deleted file mode 100644 index 97d15c7..0000000 --- a/services/common/tests/test_options.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Test puri per mcp_common.options (logiche option-flow indipendenti -dall'exchange). -""" -from __future__ import annotations - -import pytest -from mcp_common.options import ( - atm_vs_wings_vol, - dealer_gamma_profile, - oi_weighted_skew, - smile_asymmetry, - vanna_charm_aggregate, -) - -# ---------- oi_weighted_skew ---------- - -def test_oi_weighted_skew_balanced(): - """OI distribuito 50/50 calls/puts → skew vicino a 0.""" - legs = [ - {"iv": 0.5, "delta": 0.5, "oi": 100, "option_type": "call"}, - {"iv": 0.5, "delta": -0.5, "oi": 100, "option_type": "put"}, - ] - out = oi_weighted_skew(legs) - assert abs(out["skew"]) < 0.01 - - -def test_oi_weighted_skew_put_heavy(): - """Put heavy → IV media puts > IV media calls → skew positivo (put > call).""" - legs = [ - {"iv": 0.4, "delta": 0.5, "oi": 50, "option_type": "call"}, - {"iv": 0.7, "delta": -0.5, "oi": 500, "option_type": "put"}, - ] - out = oi_weighted_skew(legs) - assert out["skew"] > 0 - assert out["call_iv_weighted"] > 0 - assert out["put_iv_weighted"] > out["call_iv_weighted"] - - -def test_oi_weighted_skew_empty(): - out = oi_weighted_skew([]) - assert out == {"skew": None, "call_iv_weighted": None, "put_iv_weighted": None, "total_oi": 0} - - -# ---------- smile_asymmetry ---------- - -def test_smile_asymmetry_symmetric(): - """Smile simmetrico ATM → asymmetry ≈ 0.""" - legs = [ - {"strike": 80, "iv": 0.55, "option_type": "put"}, - {"strike": 90, "iv": 0.50, "option_type": "put"}, - {"strike": 100, "iv": 0.45, "option_type": "call"}, - {"strike": 110, "iv": 0.50, "option_type": "call"}, - {"strike": 120, "iv": 0.55, "option_type": "call"}, - ] - out = smile_asymmetry(legs, spot=100.0) - assert out["atm_iv"] is not None - assert abs(out["asymmetry"]) < 0.05 - - -def test_smile_asymmetry_put_skew(): - """OTM puts (low strike) IV >> OTM calls (high strike) IV → asymmetry > 0.""" - legs = [ - {"strike": 80, "iv": 0.80, "option_type": "put"}, - {"strike": 100, "iv": 0.50, "option_type": "call"}, - {"strike": 120, "iv": 0.45, "option_type": "call"}, - ] - out = smile_asymmetry(legs, spot=100.0) - assert out["asymmetry"] > 0.1 - - -def test_smile_asymmetry_no_atm(): - legs = [{"strike": 200, "iv": 0.5, "option_type": "call"}] - out = smile_asymmetry(legs, spot=100.0) - assert out["atm_iv"] is None - - -# ---------- atm_vs_wings_vol ---------- - -def test_atm_vs_wings_vol_basic(): - legs = [ - {"strike": 90, "iv": 0.55, "delta": -0.25, "option_type": "put"}, - {"strike": 100, "iv": 0.45, "delta": 0.5, "option_type": "call"}, - {"strike": 110, "iv": 0.50, "delta": 0.25, "option_type": "call"}, - ] - out = atm_vs_wings_vol(legs, spot=100.0) - assert out["atm_iv"] == pytest.approx(0.45, rel=1e-3) - assert out["wing_25d_call_iv"] == pytest.approx(0.50, rel=1e-3) - assert out["wing_25d_put_iv"] == pytest.approx(0.55, rel=1e-3) - # ATM 0 - - -def test_atm_vs_wings_vol_no_data(): - out = atm_vs_wings_vol([], spot=100.0) - assert out["atm_iv"] is None - - -# ---------- dealer_gamma_profile ---------- - -def test_dealer_gamma_profile_assumes_dealer_short_calls(): - """Convention: dealer SHORT calls (sells calls to retail), LONG puts. - Calls oi → negative dealer gamma, puts oi → positive dealer gamma. - """ - legs = [ - {"strike": 100, "gamma": 0.01, "oi": 1000, "option_type": "call"}, - {"strike": 100, "gamma": 0.01, "oi": 500, "option_type": "put"}, - ] - out = dealer_gamma_profile(legs, spot=100.0) - # call gamma greater than put gamma at same strike → net dealer short gamma - assert len(out["by_strike"]) == 1 - row = out["by_strike"][0] - assert row["call_dealer_gamma"] < 0 - assert row["put_dealer_gamma"] > 0 - assert row["net_dealer_gamma"] < 0 # calls dominate - assert out["total_net_dealer_gamma"] < 0 - - -def test_dealer_gamma_profile_empty(): - out = dealer_gamma_profile([], spot=100.0) - assert out["by_strike"] == [] - assert out["total_net_dealer_gamma"] == 0.0 - - -# ---------- vanna_charm_aggregate ---------- - -def test_vanna_charm_aggregate_basic(): - legs = [ - {"strike": 100, "vanna": 0.05, "charm": -0.001, "oi": 1000, "option_type": "call"}, - {"strike": 100, "vanna": -0.05, "charm": 0.001, "oi": 500, "option_type": "put"}, - ] - out = vanna_charm_aggregate(legs, spot=100.0) - assert out["total_vanna"] != 0 # some net exposure - assert "total_charm" in out - assert out["legs_analyzed"] == 2 - - -def test_vanna_charm_aggregate_skip_missing_greeks(): - legs = [ - {"strike": 100, "vanna": None, "charm": -0.001, "oi": 1000, "option_type": "call"}, - {"strike": 100, "vanna": 0.05, "charm": None, "oi": 500, "option_type": "put"}, - ] - out = vanna_charm_aggregate(legs, spot=100.0) - # entrambe le legs hanno almeno una greca None → skippate - assert out["legs_analyzed"] == 0 diff --git a/services/common/tests/test_server_base.py b/services/common/tests/test_server_base.py deleted file mode 100644 index 51db6e3..0000000 --- a/services/common/tests/test_server_base.py +++ /dev/null @@ -1,90 +0,0 @@ -from fastapi.testclient import TestClient -from mcp_common.auth import Principal, TokenStore -from mcp_common.server import build_app - - -def test_build_app_health(): - store = TokenStore(tokens={}) - app = build_app(name="test-mcp", version="0.0.1", token_store=store) - client = TestClient(app) - r = client.get("/health") - assert r.status_code == 200 - body = r.json() - assert body["status"] == "healthy" - assert body["name"] == "test-mcp" - assert body["version"] == "0.0.1" - assert "uptime_seconds" in body - assert "data_timestamp" in body - assert r.headers.get("X-Duration-Ms") is not None - - -def test_build_app_adds_token_store(): - store = TokenStore(tokens={"t1": Principal("x", {"core"})}) - app = build_app(name="t", version="v", token_store=store) - assert app.state.token_store is store - - -def test_timestamp_injector_dict_response(): - """CER-P5-001: dict response gets data_timestamp + X-Data-Timestamp header.""" - store = TokenStore(tokens={}) - app = build_app(name="t", version="v", token_store=store) - - @app.post("/tools/foo") - def foo(): - return {"ok": True} - - client = TestClient(app) - r = client.post("/tools/foo") - assert r.status_code == 200 - body = r.json() - assert body["ok"] is True - assert "data_timestamp" in body - assert r.headers.get("X-Data-Timestamp") is not None - - -def test_timestamp_injector_list_of_dicts(): - """CER-P5-001: list of dicts → each item gets data_timestamp.""" - store = TokenStore(tokens={}) - app = build_app(name="t", version="v", token_store=store) - - @app.post("/tools/list_items") - def list_items(): - return [{"x": 1}, {"x": 2}] - - client = TestClient(app) - r = client.post("/tools/list_items") - body = r.json() - assert isinstance(body, list) - assert len(body) == 2 - for item in body: - assert "data_timestamp" in item - assert r.headers.get("X-Data-Timestamp") is not None - - -def test_timestamp_injector_preserves_existing(): - """CER-P5-001: se già presente, non override.""" - store = TokenStore(tokens={}) - app = build_app(name="t", version="v", token_store=store) - - @app.post("/tools/already") - def already(): - return {"data_timestamp": "2020-01-01T00:00:00Z", "x": 1} - - client = TestClient(app) - body = client.post("/tools/already").json() - assert body["data_timestamp"] == "2020-01-01T00:00:00Z" - - -def test_timestamp_injector_empty_list_gets_header_only(): - """CER-P5-001: list vuota — no body modification, ma header presente.""" - store = TokenStore(tokens={}) - app = build_app(name="t", version="v", token_store=store) - - @app.post("/tools/empty_list") - def empty_list(): - return [] - - client = TestClient(app) - r = client.post("/tools/empty_list") - assert r.json() == [] - assert r.headers.get("X-Data-Timestamp") is not None diff --git a/services/common/tests/test_stats.py b/services/common/tests/test_stats.py deleted file mode 100644 index e14ab32..0000000 --- a/services/common/tests/test_stats.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -import random - -from mcp_common.stats import cointegration_test - - -def test_cointegrated_synthetic_pair(): - """Costruisco coppia cointegrata: B random walk, A = 2*B + noise stazionario.""" - r = random.Random(1) - b = [100.0] - for _ in range(300): - b.append(b[-1] + r.gauss(0, 1)) - a = [2 * b[i] + r.gauss(0, 0.5) for i in range(len(b))] - out = cointegration_test(a, b) - assert out["cointegrated"] is True - assert out["beta"] == pytest_approx(2.0, rel=0.05) - assert out["adf_t_stat"] is not None - assert out["adf_t_stat"] < -2.86 - - -def test_not_cointegrated_independent_walks(): - """Due random walk indipendenti → spread non stazionario → no cointegration.""" - r = random.Random(2) - a = [100.0] - b = [100.0] - for _ in range(300): - a.append(a[-1] + r.gauss(0, 1)) - b.append(b[-1] + r.gauss(0, 1)) - out = cointegration_test(a, b) - # Per due RW indipendenti, t-stat ADF è solitamente > -2.86 → non cointegrate - assert out["cointegrated"] is False or out["adf_t_stat"] > -3.0 - - -def test_cointegration_short_series(): - out = cointegration_test([1.0, 2.0], [3.0, 4.0]) - assert out["cointegrated"] is None - assert out["beta"] is None - - -def test_cointegration_mismatched_length(): - out = cointegration_test([1.0, 2.0, 3.0], [1.0, 2.0]) - assert out["cointegrated"] is None - - -def pytest_approx(value, rel): - """Tiny helper to avoid importing pytest just for approx.""" - class _Approx: - def __eq__(self, other): - return abs(other - value) <= abs(value) * rel - return _Approx() diff --git a/services/mcp-alpaca/pyproject.toml b/services/mcp-alpaca/pyproject.toml deleted file mode 100644 index 268999f..0000000 --- a/services/mcp-alpaca/pyproject.toml +++ /dev/null @@ -1,29 +0,0 @@ -[project] -name = "mcp-alpaca" -version = "0.1.0" -requires-python = ">=3.11" -dependencies = [ - "mcp-common", - "fastapi>=0.115", - "uvicorn[standard]>=0.30", - "httpx>=0.27", - "pydantic>=2.6", - "alpaca-py>=0.32", - "pytz>=2024.1", -] - -[project.optional-dependencies] -dev = ["pytest>=8", "pytest-asyncio>=0.23"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/mcp_alpaca"] - -[tool.uv.sources] -mcp-common = { workspace = true } - -[project.scripts] -mcp-alpaca = "mcp_alpaca.__main__:main" diff --git a/services/mcp-alpaca/src/mcp_alpaca/__init__.py b/services/mcp-alpaca/src/mcp_alpaca/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/mcp-alpaca/src/mcp_alpaca/__main__.py b/services/mcp-alpaca/src/mcp_alpaca/__main__.py deleted file mode 100644 index eca018b..0000000 --- a/services/mcp-alpaca/src/mcp_alpaca/__main__.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from mcp_common.app_factory import ExchangeAppSpec, run_exchange_main - -from mcp_alpaca.client import AlpacaClient -from mcp_alpaca.server import create_app - -SPEC = ExchangeAppSpec( - exchange="alpaca", - creds_env_var="ALPACA_CREDENTIALS_FILE", - env_var="ALPACA_PAPER", - flag_key="paper", - default_base_url_live="https://api.alpaca.markets", - default_base_url_testnet="https://paper-api.alpaca.markets", - default_port=9020, - build_client=lambda creds, env_info: AlpacaClient( - api_key=creds["api_key_id"], - secret_key=creds["secret_key"], - paper=(env_info.environment == "testnet"), - ), - build_app=create_app, -) - - -def main(): - run_exchange_main(SPEC) - - -if __name__ == "__main__": - main() diff --git a/services/mcp-alpaca/src/mcp_alpaca/client.py b/services/mcp-alpaca/src/mcp_alpaca/client.py deleted file mode 100644 index 001d5c6..0000000 --- a/services/mcp-alpaca/src/mcp_alpaca/client.py +++ /dev/null @@ -1,385 +0,0 @@ -from __future__ import annotations - -import asyncio -import datetime as _dt -from typing import Any - -from alpaca.data.historical import ( - CryptoHistoricalDataClient, - OptionHistoricalDataClient, - StockHistoricalDataClient, -) -from alpaca.data.requests import ( - CryptoBarsRequest, - CryptoLatestQuoteRequest, - CryptoLatestTradeRequest, - OptionBarsRequest, - OptionChainRequest, - OptionLatestQuoteRequest, - StockBarsRequest, - StockLatestQuoteRequest, - StockLatestTradeRequest, - StockSnapshotRequest, -) -from alpaca.data.timeframe import TimeFrame, TimeFrameUnit -from alpaca.trading.client import TradingClient -from alpaca.trading.enums import ( - AssetClass, - OrderSide, - QueryOrderStatus, - TimeInForce, -) -from alpaca.trading.requests import ( - ClosePositionRequest, - GetAssetsRequest, - GetOrdersRequest, - LimitOrderRequest, - MarketOrderRequest, - ReplaceOrderRequest, - StopOrderRequest, -) - -_TF_MAP = { - "1min": TimeFrame(1, TimeFrameUnit.Minute), - "5min": TimeFrame(5, TimeFrameUnit.Minute), - "15min": TimeFrame(15, TimeFrameUnit.Minute), - "30min": TimeFrame(30, TimeFrameUnit.Minute), - "1h": TimeFrame(1, TimeFrameUnit.Hour), - "1d": TimeFrame(1, TimeFrameUnit.Day), - "1w": TimeFrame(1, TimeFrameUnit.Week), -} - -_ASSET_CLASSES = {"stocks", "crypto", "options"} - - -def _tf(interval: str) -> TimeFrame: - if interval in _TF_MAP: - return _TF_MAP[interval] - raise ValueError(f"unsupported timeframe: {interval}") - - -def _asset_class_enum(ac: str) -> AssetClass: - ac = ac.lower() - if ac == "stocks": - return AssetClass.US_EQUITY - if ac == "crypto": - return AssetClass.CRYPTO - if ac == "options": - return AssetClass.US_OPTION - raise ValueError(f"invalid asset_class: {ac}") - - -def _serialize(obj: Any) -> Any: - """Recursively convert pydantic/datetime objects → json-safe.""" - if obj is None or isinstance(obj, str | int | float | bool): - return obj - if isinstance(obj, _dt.datetime | _dt.date): - return obj.isoformat() - if isinstance(obj, dict): - return {k: _serialize(v) for k, v in obj.items()} - if isinstance(obj, list | tuple): - return [_serialize(v) for v in obj] - if hasattr(obj, "model_dump"): - return _serialize(obj.model_dump()) - if hasattr(obj, "__dict__"): - return _serialize(vars(obj)) - return str(obj) - - -class AlpacaClient: - def __init__( - self, - api_key: str, - secret_key: str, - paper: bool = True, - trading: Any | None = None, - stock_data: Any | None = None, - crypto_data: Any | None = None, - option_data: Any | None = None, - ) -> None: - self.api_key = api_key - self.secret_key = secret_key - self.paper = paper - self._trading = trading or TradingClient( - api_key=api_key, secret_key=secret_key, paper=paper - ) - self._stock = stock_data or StockHistoricalDataClient( - api_key=api_key, secret_key=secret_key - ) - self._crypto = crypto_data or CryptoHistoricalDataClient( - api_key=api_key, secret_key=secret_key - ) - self._option = option_data or OptionHistoricalDataClient( - api_key=api_key, secret_key=secret_key - ) - - async def _run(self, fn, /, *args, **kwargs): - return await asyncio.to_thread(fn, *args, **kwargs) - - # ── Account / positions ────────────────────────────────────── - - async def get_account(self) -> dict: - acc = await self._run(self._trading.get_account) - return _serialize(acc) - - async def get_positions(self) -> list[dict]: - pos = await self._run(self._trading.get_all_positions) - return [_serialize(p) for p in pos] - - async def get_activities(self, limit: int = 50) -> list[dict]: - acts = await self._run(self._trading.get_account_activities) - data = [_serialize(a) for a in acts] - return data[:limit] - - # ── Assets ────────────────────────────────────────────────── - - async def get_assets( - self, asset_class: str = "stocks", status: str = "active" - ) -> list[dict]: - req = GetAssetsRequest( - asset_class=_asset_class_enum(asset_class), - status=status, - ) - assets = await self._run(self._trading.get_all_assets, req) - return [_serialize(a) for a in assets[:500]] - - # ── Market data ───────────────────────────────────────────── - - async def get_ticker(self, symbol: str, asset_class: str = "stocks") -> dict: - ac = asset_class.lower() - if ac == "stocks": - req = StockLatestTradeRequest(symbol_or_symbols=symbol) - data = await self._run(self._stock.get_stock_latest_trade, req) - trade = data.get(symbol) - q_req = StockLatestQuoteRequest(symbol_or_symbols=symbol) - qdata = await self._run(self._stock.get_stock_latest_quote, q_req) - quote = qdata.get(symbol) - return { - "symbol": symbol, - "asset_class": "stocks", - "last_price": getattr(trade, "price", None), - "bid": getattr(quote, "bid_price", None), - "ask": getattr(quote, "ask_price", None), - "bid_size": getattr(quote, "bid_size", None), - "ask_size": getattr(quote, "ask_size", None), - "timestamp": _serialize(getattr(trade, "timestamp", None)), - } - if ac == "crypto": - req = CryptoLatestTradeRequest(symbol_or_symbols=symbol) - data = await self._run(self._crypto.get_crypto_latest_trade, req) - trade = data.get(symbol) - q_req = CryptoLatestQuoteRequest(symbol_or_symbols=symbol) - qdata = await self._run(self._crypto.get_crypto_latest_quote, q_req) - quote = qdata.get(symbol) - return { - "symbol": symbol, - "asset_class": "crypto", - "last_price": getattr(trade, "price", None), - "bid": getattr(quote, "bid_price", None), - "ask": getattr(quote, "ask_price", None), - "timestamp": _serialize(getattr(trade, "timestamp", None)), - } - if ac == "options": - req = OptionLatestQuoteRequest(symbol_or_symbols=symbol) - data = await self._run(self._option.get_option_latest_quote, req) - quote = data.get(symbol) - return { - "symbol": symbol, - "asset_class": "options", - "bid": getattr(quote, "bid_price", None), - "ask": getattr(quote, "ask_price", None), - "timestamp": _serialize(getattr(quote, "timestamp", None)), - } - raise ValueError(f"invalid asset_class: {asset_class}") - - async def get_bars( - self, - symbol: str, - asset_class: str = "stocks", - interval: str = "1d", - start: str | None = None, - end: str | None = None, - limit: int = 1000, - ) -> dict: - tf = _tf(interval) - start_dt = _dt.datetime.fromisoformat(start) if start else ( - _dt.datetime.now(_dt.UTC) - _dt.timedelta(days=30) - ) - end_dt = _dt.datetime.fromisoformat(end) if end else _dt.datetime.now(_dt.UTC) - ac = asset_class.lower() - if ac == "stocks": - req = StockBarsRequest( - symbol_or_symbols=symbol, timeframe=tf, - start=start_dt, end=end_dt, limit=limit, - ) - data = await self._run(self._stock.get_stock_bars, req) - elif ac == "crypto": - req = CryptoBarsRequest( - symbol_or_symbols=symbol, timeframe=tf, - start=start_dt, end=end_dt, limit=limit, - ) - data = await self._run(self._crypto.get_crypto_bars, req) - elif ac == "options": - req = OptionBarsRequest( - symbol_or_symbols=symbol, timeframe=tf, - start=start_dt, end=end_dt, limit=limit, - ) - data = await self._run(self._option.get_option_bars, req) - else: - raise ValueError(f"invalid asset_class: {asset_class}") - bars_dict = getattr(data, "data", {}) or {} - rows = bars_dict.get(symbol, []) or [] - bars = [ - { - "timestamp": _serialize(getattr(b, "timestamp", None)), - "open": getattr(b, "open", None), - "high": getattr(b, "high", None), - "low": getattr(b, "low", None), - "close": getattr(b, "close", None), - "volume": getattr(b, "volume", None), - } - for b in rows - ] - return {"symbol": symbol, "asset_class": ac, "interval": interval, "bars": bars} - - async def get_snapshot(self, symbol: str) -> dict: - req = StockSnapshotRequest(symbol_or_symbols=symbol) - data = await self._run(self._stock.get_stock_snapshot, req) - return _serialize(data.get(symbol)) - - async def get_option_chain( - self, - underlying: str, - expiry: str | None = None, - ) -> dict: - kwargs: dict[str, Any] = {"underlying_symbol": underlying} - if expiry: - kwargs["expiration_date"] = _dt.date.fromisoformat(expiry) - req = OptionChainRequest(**kwargs) - data = await self._run(self._option.get_option_chain, req) - return { - "underlying": underlying, - "expiry": expiry, - "contracts": _serialize(data), - } - - # ── Orders ────────────────────────────────────────────────── - - async def get_open_orders(self, limit: int = 50) -> list[dict]: - req = GetOrdersRequest(status=QueryOrderStatus.OPEN, limit=limit) - orders = await self._run(self._trading.get_orders, filter=req) - return [_serialize(o) for o in orders] - - async def place_order( - self, - symbol: str, - side: str, - qty: float | None = None, - notional: float | None = None, - order_type: str = "market", - limit_price: float | None = None, - stop_price: float | None = None, - tif: str = "day", - asset_class: str = "stocks", - ) -> dict: - side_enum = OrderSide.BUY if side.lower() == "buy" else OrderSide.SELL - tif_enum = TimeInForce(tif.lower()) - ot = order_type.lower() - common = { - "symbol": symbol, - "side": side_enum, - "time_in_force": tif_enum, - } - if qty is not None: - common["qty"] = qty - if notional is not None: - common["notional"] = notional - if ot == "market": - req = MarketOrderRequest(**common) - elif ot == "limit": - if limit_price is None: - raise ValueError("limit_price required for limit order") - req = LimitOrderRequest(**common, limit_price=limit_price) - elif ot == "stop": - if stop_price is None: - raise ValueError("stop_price required for stop order") - req = StopOrderRequest(**common, stop_price=stop_price) - else: - raise ValueError(f"unsupported order_type: {order_type}") - order = await self._run(self._trading.submit_order, req) - return _serialize(order) - - async def amend_order( - self, - order_id: str, - qty: float | None = None, - limit_price: float | None = None, - stop_price: float | None = None, - tif: str | None = None, - ) -> dict: - kwargs: dict[str, Any] = {} - if qty is not None: - kwargs["qty"] = qty - if limit_price is not None: - kwargs["limit_price"] = limit_price - if stop_price is not None: - kwargs["stop_price"] = stop_price - if tif is not None: - kwargs["time_in_force"] = TimeInForce(tif.lower()) - req = ReplaceOrderRequest(**kwargs) - order = await self._run(self._trading.replace_order_by_id, order_id, req) - return _serialize(order) - - async def cancel_order(self, order_id: str) -> dict: - await self._run(self._trading.cancel_order_by_id, order_id) - return {"order_id": order_id, "canceled": True} - - async def cancel_all_orders(self) -> list[dict]: - resp = await self._run(self._trading.cancel_orders) - return [_serialize(r) for r in resp] - - # ── Position close ────────────────────────────────────────── - - async def close_position( - self, symbol: str, qty: float | None = None, percentage: float | None = None - ) -> dict: - req = None - if qty is not None or percentage is not None: - kwargs: dict[str, Any] = {} - if qty is not None: - kwargs["qty"] = str(qty) - if percentage is not None: - kwargs["percentage"] = str(percentage) - req = ClosePositionRequest(**kwargs) - order = await self._run( - self._trading.close_position, symbol, close_options=req - ) - return _serialize(order) - - async def close_all_positions(self, cancel_orders: bool = True) -> list[dict]: - resp = await self._run( - self._trading.close_all_positions, cancel_orders=cancel_orders - ) - return [_serialize(r) for r in resp] - - # ── Clock / calendar ──────────────────────────────────────── - - async def get_clock(self) -> dict: - clock = await self._run(self._trading.get_clock) - return _serialize(clock) - - async def get_calendar( - self, start: str | None = None, end: str | None = None - ) -> list[dict]: - from alpaca.trading.requests import GetCalendarRequest - - kwargs: dict[str, Any] = {} - if start: - kwargs["start"] = _dt.date.fromisoformat(start) - if end: - kwargs["end"] = _dt.date.fromisoformat(end) - req = GetCalendarRequest(**kwargs) if kwargs else None - cal = await self._run( - self._trading.get_calendar, filters=req - ) if req else await self._run(self._trading.get_calendar) - return [_serialize(c) for c in cal] diff --git a/services/mcp-alpaca/src/mcp_alpaca/leverage_cap.py b/services/mcp-alpaca/src/mcp_alpaca/leverage_cap.py deleted file mode 100644 index d04dd51..0000000 --- a/services/mcp-alpaca/src/mcp_alpaca/leverage_cap.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Leverage cap server-side per place_order. - -Cap letto dal secret JSON via campo `max_leverage`. Default 1 (cash) se assente. -""" -from __future__ import annotations - -from fastapi import HTTPException - - -def get_max_leverage(creds: dict) -> int: - """Legge max_leverage dal secret. Default 1 se mancante.""" - raw = creds.get("max_leverage", 1) - try: - value = int(raw) - except (TypeError, ValueError): - value = 1 - return max(1, value) - - -def enforce_leverage( - requested: int | float | None, - *, - creds: dict, - exchange: str, -) -> int: - """Verifica e applica leverage cap. Ritorna leverage applicabile. - - Solleva HTTPException(403, LEVERAGE_CAP_EXCEEDED) se requested > cap. - Se requested is None, applica il cap come default. - """ - cap = get_max_leverage(creds) - if requested is None: - return cap - lev = int(requested) - if lev < 1: - raise HTTPException( - status_code=403, - detail={ - "error": "LEVERAGE_CAP_EXCEEDED", - "exchange": exchange, - "requested": lev, - "max": cap, - "reason": "leverage must be >= 1", - }, - ) - if lev > cap: - raise HTTPException( - status_code=403, - detail={ - "error": "LEVERAGE_CAP_EXCEEDED", - "exchange": exchange, - "requested": lev, - "max": cap, - }, - ) - return lev diff --git a/services/mcp-alpaca/src/mcp_alpaca/server.py b/services/mcp-alpaca/src/mcp_alpaca/server.py deleted file mode 100644 index e5219a7..0000000 --- a/services/mcp-alpaca/src/mcp_alpaca/server.py +++ /dev/null @@ -1,321 +0,0 @@ -from __future__ import annotations - -import os - -from fastapi import Depends, HTTPException -from mcp_common.audit import audit_write_op -from mcp_common.auth import Principal, TokenStore, require_principal -from mcp_common.environment import EnvironmentInfo -from mcp_common.mcp_bridge import mount_mcp_endpoint -from mcp_common.server import build_app -from pydantic import BaseModel - -from mcp_alpaca.client import AlpacaClient -from mcp_alpaca.leverage_cap import get_max_leverage - -# --- Body models: reads --- - -class AccountReq(BaseModel): - pass - - -class PositionsReq(BaseModel): - pass - - -class ActivitiesReq(BaseModel): - limit: int = 50 - - -class AssetsReq(BaseModel): - asset_class: str = "stocks" - status: str = "active" - - -class TickerReq(BaseModel): - symbol: str - asset_class: str = "stocks" - - -class BarsReq(BaseModel): - symbol: str - asset_class: str = "stocks" - interval: str = "1d" - start: str | None = None - end: str | None = None - limit: int = 1000 - - -class SnapshotReq(BaseModel): - symbol: str - - -class OptionChainReq(BaseModel): - underlying: str - expiry: str | None = None - - -class OpenOrdersReq(BaseModel): - limit: int = 50 - - -class ClockReq(BaseModel): - pass - - -class CalendarReq(BaseModel): - start: str | None = None - end: str | None = None - - -# --- Body models: writes --- - -class PlaceOrderReq(BaseModel): - symbol: str - side: str - qty: float | None = None - notional: float | None = None - order_type: str = "market" - limit_price: float | None = None - stop_price: float | None = None - tif: str = "day" - asset_class: str = "stocks" - - -class AmendOrderReq(BaseModel): - order_id: str - qty: float | None = None - limit_price: float | None = None - stop_price: float | None = None - tif: str | None = None - - -class CancelOrderReq(BaseModel): - order_id: str - - -class CancelAllReq(BaseModel): - pass - - -class ClosePositionReq(BaseModel): - symbol: str - qty: float | None = None - percentage: float | None = None - - -class CloseAllPositionsReq(BaseModel): - cancel_orders: bool = True - - -# --- ACL helper --- - -def _check(principal: Principal, *, core: bool = False, observer: bool = False) -> None: - allowed: set[str] = set() - if core: - allowed.add("core") - if observer: - allowed.add("observer") - if not (principal.capabilities & allowed): - raise HTTPException(status_code=403, detail="forbidden") - - -def create_app( - *, - client: AlpacaClient, - token_store: TokenStore, - creds: dict | None = None, - env_info: EnvironmentInfo | None = None, -): - creds = creds or {} - app = build_app(name="mcp-alpaca", version="0.1.0", token_store=token_store) - - # ── Reads ────────────────────────────────────────────── - - @app.post("/tools/environment_info", tags=["reads"]) - async def t_environment_info(principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - if env_info is None: - return { - "exchange": "alpaca", - "environment": "testnet" if getattr(client, "paper", True) else "mainnet", - "source": "credentials", - "env_value": None, - "base_url": getattr(client, "base_url", None), - "max_leverage": get_max_leverage(creds), - } - return { - "exchange": env_info.exchange, - "environment": env_info.environment, - "source": env_info.source, - "env_value": env_info.env_value, - "base_url": env_info.base_url, - "max_leverage": get_max_leverage(creds), - } - - @app.post("/tools/get_account", tags=["reads"]) - async def t_get_account(body: AccountReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_account() - - @app.post("/tools/get_positions", tags=["reads"]) - async def t_get_positions(body: PositionsReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return {"positions": await client.get_positions()} - - @app.post("/tools/get_activities", tags=["reads"]) - async def t_get_activities(body: ActivitiesReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return {"activities": await client.get_activities(body.limit)} - - @app.post("/tools/get_assets", tags=["reads"]) - async def t_get_assets(body: AssetsReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return {"assets": await client.get_assets(body.asset_class, body.status)} - - @app.post("/tools/get_ticker", tags=["reads"]) - async def t_get_ticker(body: TickerReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_ticker(body.symbol, body.asset_class) - - @app.post("/tools/get_bars", tags=["reads"]) - async def t_get_bars(body: BarsReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_bars( - body.symbol, body.asset_class, body.interval, body.start, body.end, body.limit, - ) - - @app.post("/tools/get_snapshot", tags=["reads"]) - async def t_get_snapshot(body: SnapshotReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_snapshot(body.symbol) - - @app.post("/tools/get_option_chain", tags=["reads"]) - async def t_get_option_chain(body: OptionChainReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_option_chain(body.underlying, body.expiry) - - @app.post("/tools/get_open_orders", tags=["reads"]) - async def t_get_open_orders(body: OpenOrdersReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return {"orders": await client.get_open_orders(body.limit)} - - @app.post("/tools/get_clock", tags=["reads"]) - async def t_get_clock(body: ClockReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_clock() - - @app.post("/tools/get_calendar", tags=["reads"]) - async def t_get_calendar(body: CalendarReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return {"calendar": await client.get_calendar(body.start, body.end)} - - # ── Writes ───────────────────────────────────────────── - - @app.post("/tools/place_order", tags=["writes"]) - async def t_place_order(body: PlaceOrderReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = await client.place_order( - body.symbol, body.side, body.qty, body.notional, - body.order_type, body.limit_price, body.stop_price, body.tif, body.asset_class, - ) - audit_write_op( - principal=principal, action="place_order", exchange="alpaca", - target=body.symbol, - payload={"side": body.side, "qty": body.qty, "notional": body.notional, - "order_type": body.order_type, "limit_price": body.limit_price, - "stop_price": body.stop_price, "tif": body.tif, - "asset_class": body.asset_class}, - result=result, - ) - return result - - @app.post("/tools/amend_order", tags=["writes"]) - async def t_amend_order(body: AmendOrderReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = await client.amend_order( - body.order_id, body.qty, body.limit_price, body.stop_price, body.tif, - ) - audit_write_op( - principal=principal, action="amend_order", exchange="alpaca", - target=body.order_id, - payload={"qty": body.qty, "limit_price": body.limit_price, - "stop_price": body.stop_price, "tif": body.tif}, - result=result, - ) - return result - - @app.post("/tools/cancel_order", tags=["writes"]) - async def t_cancel_order(body: CancelOrderReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = await client.cancel_order(body.order_id) - audit_write_op( - principal=principal, action="cancel_order", exchange="alpaca", - target=body.order_id, payload={}, result=result, - ) - return result - - @app.post("/tools/cancel_all_orders", tags=["writes"]) - async def t_cancel_all(body: CancelAllReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = {"canceled": await client.cancel_all_orders()} - audit_write_op( - principal=principal, action="cancel_all_orders", exchange="alpaca", - payload={}, result=result, - ) - return result - - @app.post("/tools/close_position", tags=["writes"]) - async def t_close(body: ClosePositionReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = await client.close_position(body.symbol, body.qty, body.percentage) - audit_write_op( - principal=principal, action="close_position", exchange="alpaca", - target=body.symbol, - payload={"qty": body.qty, "percentage": body.percentage}, - result=result, - ) - return result - - @app.post("/tools/close_all_positions", tags=["writes"]) - async def t_close_all(body: CloseAllPositionsReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = {"closed": await client.close_all_positions(body.cancel_orders)} - audit_write_op( - principal=principal, action="close_all_positions", exchange="alpaca", - payload={"cancel_orders": body.cancel_orders}, result=result, - ) - return result - - # ── MCP mount ────────────────────────────────────────── - - port = int(os.environ.get("PORT", "9020")) - mount_mcp_endpoint( - app, - name="cerbero-alpaca", - version="0.1.0", - token_store=token_store, - internal_base_url=f"http://localhost:{port}", - tools=[ - {"name": "environment_info", "description": "Ambiente operativo (paper/live), source, base_url, max_leverage cap."}, - {"name": "get_account", "description": "Alpaca account summary (equity, cash, buying_power)."}, - {"name": "get_positions", "description": "Posizioni aperte (stocks/crypto/options)."}, - {"name": "get_activities", "description": "Activity log (fills, dividends, transfers)."}, - {"name": "get_assets", "description": "Universo asset per asset_class."}, - {"name": "get_ticker", "description": "Last trade + quote per simbolo (stocks/crypto/options)."}, - {"name": "get_bars", "description": "OHLCV candles (stocks/crypto/options)."}, - {"name": "get_snapshot", "description": "Snapshot completo stock (last trade+quote+bar)."}, - {"name": "get_option_chain", "description": "Option chain per underlying."}, - {"name": "get_open_orders", "description": "Ordini pending."}, - {"name": "get_clock", "description": "Market clock (open/close, next_open)."}, - {"name": "get_calendar", "description": "Calendar sessioni trading."}, - {"name": "place_order", "description": "Invia ordine (CORE only)."}, - {"name": "amend_order", "description": "Replace ordine esistente."}, - {"name": "cancel_order", "description": "Cancella ordine."}, - {"name": "cancel_all_orders", "description": "Cancella tutti ordini aperti."}, - {"name": "close_position", "description": "Chiude posizione (tutta o parziale)."}, - {"name": "close_all_positions", "description": "Liquida tutto il portafoglio."}, - ], - ) - - return app diff --git a/services/mcp-alpaca/tests/conftest.py b/services/mcp-alpaca/tests/conftest.py deleted file mode 100644 index de96baa..0000000 --- a/services/mcp-alpaca/tests/conftest.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from unittest.mock import MagicMock - -import pytest -from mcp_alpaca.client import AlpacaClient - - -@pytest.fixture -def mock_trading(): - return MagicMock(name="alpaca_TradingClient") - - -@pytest.fixture -def mock_stock(): - return MagicMock(name="alpaca_StockHistoricalDataClient") - - -@pytest.fixture -def mock_crypto(): - return MagicMock(name="alpaca_CryptoHistoricalDataClient") - - -@pytest.fixture -def mock_option(): - return MagicMock(name="alpaca_OptionHistoricalDataClient") - - -@pytest.fixture -def client(mock_trading, mock_stock, mock_crypto, mock_option): - return AlpacaClient( - api_key="test_key", - secret_key="test_secret", - paper=True, - trading=mock_trading, - stock_data=mock_stock, - crypto_data=mock_crypto, - option_data=mock_option, - ) diff --git a/services/mcp-alpaca/tests/test_client.py b/services/mcp-alpaca/tests/test_client.py deleted file mode 100644 index 24172ec..0000000 --- a/services/mcp-alpaca/tests/test_client.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from unittest.mock import MagicMock - -import pytest - - -@pytest.mark.asyncio -async def test_init_paper_mode(client, mock_trading): - assert client.paper is True - assert client._trading is mock_trading - - -@pytest.mark.asyncio -async def test_get_account_calls_trading(client, mock_trading): - mock_trading.get_account.return_value = MagicMock( - model_dump=lambda: {"equity": 100000, "cash": 50000} - ) - result = await client.get_account() - mock_trading.get_account.assert_called_once() - assert result["equity"] == 100000 - - -@pytest.mark.asyncio -async def test_get_positions_returns_list(client, mock_trading): - pos_mock = MagicMock(model_dump=lambda: {"symbol": "AAPL", "qty": 10}) - mock_trading.get_all_positions.return_value = [pos_mock] - result = await client.get_positions() - assert len(result) == 1 - assert result[0]["symbol"] == "AAPL" - - -@pytest.mark.asyncio -async def test_place_market_order_stocks(client, mock_trading): - order_mock = MagicMock(model_dump=lambda: {"id": "o123", "symbol": "AAPL"}) - mock_trading.submit_order.return_value = order_mock - result = await client.place_order( - symbol="AAPL", side="buy", qty=1, order_type="market", asset_class="stocks", - ) - assert result["id"] == "o123" - assert mock_trading.submit_order.called - - -@pytest.mark.asyncio -async def test_place_limit_order_requires_price(client): - with pytest.raises(ValueError, match="limit_price"): - await client.place_order( - symbol="AAPL", side="buy", qty=1, order_type="limit", - ) - - -@pytest.mark.asyncio -async def test_cancel_order(client, mock_trading): - mock_trading.cancel_order_by_id.return_value = None - result = await client.cancel_order("o1") - mock_trading.cancel_order_by_id.assert_called_once_with("o1") - assert result == {"order_id": "o1", "canceled": True} - - -@pytest.mark.asyncio -async def test_close_position_no_options(client, mock_trading): - order_mock = MagicMock(model_dump=lambda: {"id": "close-1"}) - mock_trading.close_position.return_value = order_mock - result = await client.close_position("AAPL") - assert mock_trading.close_position.called - assert result["id"] == "close-1" - - -@pytest.mark.asyncio -async def test_get_clock(client, mock_trading): - clock_mock = MagicMock(model_dump=lambda: {"is_open": True, "next_close": "2026-04-21T20:00:00Z"}) - mock_trading.get_clock.return_value = clock_mock - result = await client.get_clock() - assert result["is_open"] is True - - -@pytest.mark.asyncio -async def test_invalid_asset_class(client): - with pytest.raises(ValueError, match="invalid asset_class"): - await client.get_ticker("AAPL", asset_class="forex") diff --git a/services/mcp-alpaca/tests/test_environment_info.py b/services/mcp-alpaca/tests/test_environment_info.py deleted file mode 100644 index a6bfbff..0000000 --- a/services/mcp-alpaca/tests/test_environment_info.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -from unittest.mock import MagicMock - -from fastapi.testclient import TestClient -from mcp_alpaca.server import create_app -from mcp_common.auth import Principal, TokenStore -from mcp_common.environment import EnvironmentInfo - - -def _make_app(env_info, creds): - c = MagicMock() - c.paper = True - store = TokenStore(tokens={ - "ct": Principal("core", {"core"}), - "ot": Principal("observer", {"observer"}), - }) - return create_app(client=c, token_store=store, creds=creds, env_info=env_info) - - -def test_environment_info_paper_is_testnet(): - """Alpaca: 'paper' nel secret mappa a environment='testnet'.""" - env = EnvironmentInfo( - exchange="alpaca", - environment="testnet", - source="env", - env_value="true", - base_url="https://paper-api.alpaca.markets", - ) - app = _make_app(env, creds={"max_leverage": 1}) - c = TestClient(app) - r = c.post("/tools/environment_info", headers={"Authorization": "Bearer ot"}) - assert r.status_code == 200 - body = r.json() - assert body["exchange"] == "alpaca" - assert body["environment"] == "testnet" - assert body["source"] == "env" - assert body["base_url"] == "https://paper-api.alpaca.markets" - assert body["max_leverage"] == 1 - - -def test_environment_info_requires_auth(): - env = EnvironmentInfo( - exchange="alpaca", environment="testnet", source="default", - env_value=None, base_url="https://paper-api.alpaca.markets", - ) - app = _make_app(env, creds={"max_leverage": 1}) - c = TestClient(app) - r = c.post("/tools/environment_info") - assert r.status_code == 401 diff --git a/services/mcp-alpaca/tests/test_leverage_cap.py b/services/mcp-alpaca/tests/test_leverage_cap.py deleted file mode 100644 index c42d1b3..0000000 --- a/services/mcp-alpaca/tests/test_leverage_cap.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -import pytest -from fastapi import HTTPException -from mcp_alpaca.leverage_cap import enforce_leverage, get_max_leverage - - -def test_get_max_leverage_returns_creds_value(): - creds = {"max_leverage": 4} - assert get_max_leverage(creds) == 4 - - -def test_get_max_leverage_default_when_missing(): - """Default 1 (cash) se il secret non ha max_leverage.""" - assert get_max_leverage({}) == 1 - - -def test_enforce_leverage_pass_at_cap_one(): - """Alpaca cash account: cap 1, leverage 1 OK.""" - creds = {"max_leverage": 1} - enforce_leverage(1, creds=creds, exchange="alpaca") # no raise - - -def test_enforce_leverage_reject_over_cap_one(): - creds = {"max_leverage": 1} - with pytest.raises(HTTPException) as exc: - enforce_leverage(2, creds=creds, exchange="alpaca") - assert exc.value.status_code == 403 - assert exc.value.detail["error"] == "LEVERAGE_CAP_EXCEEDED" - assert exc.value.detail["exchange"] == "alpaca" - assert exc.value.detail["requested"] == 2 - assert exc.value.detail["max"] == 1 - - -def test_enforce_leverage_reject_when_below_one(): - creds = {"max_leverage": 1} - with pytest.raises(HTTPException) as exc: - enforce_leverage(0, creds=creds, exchange="alpaca") - assert exc.value.status_code == 403 - - -def test_enforce_leverage_default_when_none(): - """Se requested è None, applica il cap come default.""" - creds = {"max_leverage": 1} - result = enforce_leverage(None, creds=creds, exchange="alpaca") - assert result == 1 diff --git a/services/mcp-alpaca/tests/test_server_acl.py b/services/mcp-alpaca/tests/test_server_acl.py deleted file mode 100644 index 57911eb..0000000 --- a/services/mcp-alpaca/tests/test_server_acl.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest -from fastapi.testclient import TestClient -from mcp_alpaca.server import create_app -from mcp_common.auth import Principal, TokenStore - - -@pytest.fixture -def token_store(): - return TokenStore( - tokens={ - "core-tok": Principal("core", {"core"}), - "obs-tok": Principal("observer", {"observer"}), - } - ) - - -@pytest.fixture -def mock_client(): - c = MagicMock() - c.get_account = AsyncMock(return_value={"equity": 100000}) - c.get_positions = AsyncMock(return_value=[]) - c.get_activities = AsyncMock(return_value=[]) - c.get_assets = AsyncMock(return_value=[]) - c.get_ticker = AsyncMock(return_value={"symbol": "AAPL"}) - c.get_bars = AsyncMock(return_value={"bars": []}) - c.get_snapshot = AsyncMock(return_value={}) - c.get_option_chain = AsyncMock(return_value={"contracts": []}) - c.get_open_orders = AsyncMock(return_value=[]) - c.get_clock = AsyncMock(return_value={"is_open": True}) - c.get_calendar = AsyncMock(return_value=[]) - c.place_order = AsyncMock(return_value={"id": "o1"}) - c.amend_order = AsyncMock(return_value={"id": "o1"}) - c.cancel_order = AsyncMock(return_value={"canceled": True}) - c.cancel_all_orders = AsyncMock(return_value=[]) - c.close_position = AsyncMock(return_value={"id": "close1"}) - c.close_all_positions = AsyncMock(return_value=[]) - return c - - -@pytest.fixture -def http(mock_client, token_store): - app = create_app(client=mock_client, token_store=token_store, creds={"max_leverage": 1}) - return TestClient(app) - - -CORE = {"Authorization": "Bearer core-tok"} -OBS = {"Authorization": "Bearer obs-tok"} - -READ_ENDPOINTS = [ - ("/tools/get_account", {}), - ("/tools/get_positions", {}), - ("/tools/get_activities", {}), - ("/tools/get_assets", {}), - ("/tools/get_ticker", {"symbol": "AAPL"}), - ("/tools/get_bars", {"symbol": "AAPL"}), - ("/tools/get_snapshot", {"symbol": "AAPL"}), - ("/tools/get_option_chain", {"underlying": "AAPL"}), - ("/tools/get_open_orders", {}), - ("/tools/get_clock", {}), - ("/tools/get_calendar", {}), -] - -WRITE_ENDPOINTS = [ - ("/tools/place_order", {"symbol": "AAPL", "side": "buy", "qty": 1}), - ("/tools/amend_order", {"order_id": "o1", "qty": 2}), - ("/tools/cancel_order", {"order_id": "o1"}), - ("/tools/cancel_all_orders", {}), - ("/tools/close_position", {"symbol": "AAPL"}), - ("/tools/close_all_positions", {}), -] - - -@pytest.mark.parametrize("path,payload", READ_ENDPOINTS) -def test_read_core_ok(http, path, payload): - r = http.post(path, json=payload, headers=CORE) - assert r.status_code == 200, (path, r.text) - - -@pytest.mark.parametrize("path,payload", READ_ENDPOINTS) -def test_read_observer_ok(http, path, payload): - r = http.post(path, json=payload, headers=OBS) - assert r.status_code == 200, (path, r.text) - - -@pytest.mark.parametrize("path,payload", READ_ENDPOINTS) -def test_read_no_auth_401(http, path, payload): - r = http.post(path, json=payload) - assert r.status_code == 401, (path, r.text) - - -@pytest.mark.parametrize("path,payload", WRITE_ENDPOINTS) -def test_write_core_ok(http, path, payload): - r = http.post(path, json=payload, headers=CORE) - assert r.status_code == 200, (path, r.text) - - -@pytest.mark.parametrize("path,payload", WRITE_ENDPOINTS) -def test_write_observer_403(http, path, payload): - r = http.post(path, json=payload, headers=OBS) - assert r.status_code == 403, (path, r.text) - - -@pytest.mark.parametrize("path,payload", WRITE_ENDPOINTS) -def test_write_no_auth_401(http, path, payload): - r = http.post(path, json=payload) - assert r.status_code == 401, (path, r.text) diff --git a/services/mcp-bybit/pyproject.toml b/services/mcp-bybit/pyproject.toml deleted file mode 100644 index 3f28f62..0000000 --- a/services/mcp-bybit/pyproject.toml +++ /dev/null @@ -1,28 +0,0 @@ -[project] -name = "mcp-bybit" -version = "0.1.0" -requires-python = ">=3.11" -dependencies = [ - "mcp-common", - "fastapi>=0.115", - "uvicorn[standard]>=0.30", - "httpx>=0.27", - "pydantic>=2.6", - "pybit>=5.8", -] - -[project.optional-dependencies] -dev = ["pytest>=8", "pytest-asyncio>=0.23"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/mcp_bybit"] - -[tool.uv.sources] -mcp-common = { workspace = true } - -[project.scripts] -mcp-bybit = "mcp_bybit.__main__:main" diff --git a/services/mcp-bybit/src/mcp_bybit/__init__.py b/services/mcp-bybit/src/mcp_bybit/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/mcp-bybit/src/mcp_bybit/__main__.py b/services/mcp-bybit/src/mcp_bybit/__main__.py deleted file mode 100644 index bcc531a..0000000 --- a/services/mcp-bybit/src/mcp_bybit/__main__.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from mcp_common.app_factory import ExchangeAppSpec, run_exchange_main - -from mcp_bybit.client import BybitClient -from mcp_bybit.server import create_app - -SPEC = ExchangeAppSpec( - exchange="bybit", - creds_env_var="BYBIT_CREDENTIALS_FILE", - env_var="BYBIT_TESTNET", - flag_key="testnet", - default_base_url_live="https://api.bybit.com", - default_base_url_testnet="https://api-testnet.bybit.com", - default_port=9019, - build_client=lambda creds, env_info: BybitClient( - api_key=creds["api_key"], - api_secret=creds["api_secret"], - testnet=(env_info.environment == "testnet"), - ), - build_app=create_app, -) - - -def main(): - run_exchange_main(SPEC) - - -if __name__ == "__main__": - main() diff --git a/services/mcp-bybit/src/mcp_bybit/client.py b/services/mcp-bybit/src/mcp_bybit/client.py deleted file mode 100644 index 0effeec..0000000 --- a/services/mcp-bybit/src/mcp_bybit/client.py +++ /dev/null @@ -1,672 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import Any - -from mcp_common import indicators as ind -from mcp_common import microstructure as micro -from pybit.unified_trading import HTTP - - -def _f(v: Any) -> float | None: - try: - return float(v) - except (TypeError, ValueError): - return None - - -def _i(v: Any) -> int | None: - try: - return int(v) - except (TypeError, ValueError): - return None - - -class BybitClient: - def __init__( - self, - api_key: str, - api_secret: str, - testnet: bool = True, - http: Any | None = None, - ) -> None: - self.api_key = api_key - self.api_secret = api_secret - self.testnet = testnet - self._http = http or HTTP( - api_key=api_key, - api_secret=api_secret, - testnet=testnet, - ) - - async def _run(self, fn, /, **kwargs): - return await asyncio.to_thread(fn, **kwargs) - - @staticmethod - def _parse_ticker(row: dict) -> dict: - return { - "symbol": row.get("symbol"), - "last_price": _f(row.get("lastPrice")), - "mark_price": _f(row.get("markPrice")), - "bid": _f(row.get("bid1Price")), - "ask": _f(row.get("ask1Price")), - "volume_24h": _f(row.get("volume24h")), - "turnover_24h": _f(row.get("turnover24h")), - "funding_rate": _f(row.get("fundingRate")), - "open_interest": _f(row.get("openInterest")), - } - - async def get_ticker(self, symbol: str, category: str = "linear") -> dict: - resp = await self._run( - self._http.get_tickers, category=category, symbol=symbol - ) - rows = (resp.get("result") or {}).get("list") or [] - if not rows: - return {"symbol": symbol, "error": "not_found"} - return self._parse_ticker(rows[0]) - - async def get_ticker_batch( - self, symbols: list[str], category: str = "linear" - ) -> dict[str, dict]: - out: dict[str, dict] = {} - for sym in symbols: - out[sym] = await self.get_ticker(sym, category=category) - return out - - async def get_orderbook( - self, symbol: str, category: str = "linear", limit: int = 50 - ) -> dict: - resp = await self._run( - self._http.get_orderbook, category=category, symbol=symbol, limit=limit - ) - r = resp.get("result") or {} - return { - "symbol": r.get("s"), - "bids": [[float(p), float(q)] for p, q in (r.get("b") or [])], - "asks": [[float(p), float(q)] for p, q in (r.get("a") or [])], - "timestamp": r.get("ts"), - } - - async def get_historical( - self, - symbol: str, - category: str = "linear", - interval: str = "60", - start: int | None = None, - end: int | None = None, - limit: int = 1000, - ) -> dict: - kwargs = dict( - category=category, - symbol=symbol, - interval=interval, - limit=limit, - ) - if start is not None: - kwargs["start"] = start - if end is not None: - kwargs["end"] = end - resp = await self._run(self._http.get_kline, **kwargs) - rows = (resp.get("result") or {}).get("list") or [] - rows_sorted = sorted(rows, key=lambda r: int(r[0])) - candles = [ - { - "timestamp": int(r[0]), - "open": float(r[1]), - "high": float(r[2]), - "low": float(r[3]), - "close": float(r[4]), - "volume": float(r[5]), - } - for r in rows_sorted - ] - return {"symbol": symbol, "candles": candles} - - async def get_indicators( - self, - symbol: str, - category: str = "linear", - indicators: list[str] | None = None, - interval: str = "60", - start: int | None = None, - end: int | None = None, - ) -> dict: - indicators = indicators or ["rsi", "atr", "macd", "adx"] - historical = await self.get_historical( - symbol, category=category, interval=interval, start=start, end=end - ) - candles = historical.get("candles", []) - closes = [c["close"] for c in candles] - highs = [c["high"] for c in candles] - lows = [c["low"] for c in candles] - - out: dict[str, Any] = {"symbol": symbol, "category": category} - for name in indicators: - n = name.lower() - if n == "sma": - out["sma"] = ind.sma(closes, 20) - elif n == "rsi": - out["rsi"] = ind.rsi(closes) - elif n == "atr": - out["atr"] = ind.atr(highs, lows, closes) - elif n == "macd": - out["macd"] = ind.macd(closes) - elif n == "adx": - out["adx"] = ind.adx(highs, lows, closes) - else: - out[n] = None - return out - - async def get_funding_rate(self, symbol: str, category: str = "linear") -> dict: - resp = await self._run( - self._http.get_tickers, category=category, symbol=symbol - ) - rows = (resp.get("result") or {}).get("list") or [] - if not rows: - return {"symbol": symbol, "error": "not_found"} - row = rows[0] - return { - "symbol": row.get("symbol"), - "funding_rate": _f(row.get("fundingRate")), - "next_funding_time": _i(row.get("nextFundingTime")), - } - - async def get_funding_history( - self, symbol: str, category: str = "linear", limit: int = 100 - ) -> dict: - resp = await self._run( - self._http.get_funding_rate_history, - category=category, symbol=symbol, limit=limit, - ) - rows = (resp.get("result") or {}).get("list") or [] - hist = [ - { - "timestamp": int(r.get("fundingRateTimestamp", 0)), - "rate": float(r.get("fundingRate", 0)), - } - for r in rows - ] - return {"symbol": symbol, "history": hist} - - async def get_open_interest( - self, - symbol: str, - category: str = "linear", - interval: str = "5min", - limit: int = 288, - ) -> dict: - resp = await self._run( - self._http.get_open_interest, - category=category, symbol=symbol, intervalTime=interval, limit=limit, - ) - rows = (resp.get("result") or {}).get("list") or [] - points = [ - { - "timestamp": int(r.get("timestamp", 0)), - "oi": float(r.get("openInterest", 0)), - } - for r in rows - ] - current_oi = points[0]["oi"] if points else None - return { - "symbol": symbol, - "category": category, - "interval": interval, - "current_oi": current_oi, - "points": points, - } - - async def get_instruments(self, category: str = "linear", symbol: str | None = None) -> dict: - kwargs: dict[str, Any] = {"category": category} - if symbol: - kwargs["symbol"] = symbol - resp = await self._run(self._http.get_instruments_info, **kwargs) - rows = (resp.get("result") or {}).get("list") or [] - instruments = [] - for r in rows: - pf = r.get("priceFilter") or {} - lf = r.get("lotSizeFilter") or {} - instruments.append({ - "symbol": r.get("symbol"), - "status": r.get("status"), - "base_coin": r.get("baseCoin"), - "quote_coin": r.get("quoteCoin"), - "tick_size": _f(pf.get("tickSize")), - "qty_step": _f(lf.get("qtyStep")), - "min_qty": _f(lf.get("minOrderQty")), - }) - return {"category": category, "instruments": instruments} - - async def get_option_chain(self, base_coin: str, expiry: str | None = None) -> dict: - kwargs: dict[str, Any] = {"category": "option", "baseCoin": base_coin.upper()} - resp = await self._run(self._http.get_instruments_info, **kwargs) - rows = (resp.get("result") or {}).get("list") or [] - options = [] - for r in rows: - delivery = r.get("deliveryTime") - if expiry and expiry not in r.get("symbol", ""): - continue - options.append({ - "symbol": r.get("symbol"), - "base_coin": r.get("baseCoin"), - "settle_coin": r.get("settleCoin"), - "type": r.get("optionsType"), - "launch_time": int(r.get("launchTime", 0)), - "delivery_time": int(delivery) if delivery else None, - }) - return {"base_coin": base_coin.upper(), "options": options} - - async def get_positions( - self, category: str = "linear", settle_coin: str = "USDT" - ) -> list[dict]: - kwargs: dict[str, Any] = {"category": category} - if category in ("linear", "inverse"): - kwargs["settleCoin"] = settle_coin - resp = await self._run(self._http.get_positions, **kwargs) - rows = (resp.get("result") or {}).get("list") or [] - out = [] - for r in rows: - out.append({ - "symbol": r.get("symbol"), - "side": r.get("side"), - "size": _f(r.get("size")), - "entry_price": _f(r.get("avgPrice")), - "unrealized_pnl": _f(r.get("unrealisedPnl")), - "leverage": _f(r.get("leverage")), - "liquidation_price": _f(r.get("liqPrice")), - "position_value": _f(r.get("positionValue")), - }) - return out - - async def get_account_summary(self, account_type: str = "UNIFIED") -> dict: - resp = await self._run( - self._http.get_wallet_balance, accountType=account_type - ) - rows = (resp.get("result") or {}).get("list") or [] - if not rows: - return {"error": "no_account"} - a = rows[0] - coins = [] - for c in a.get("coin") or []: - coins.append({ - "coin": c.get("coin"), - "wallet_balance": _f(c.get("walletBalance")), - "equity": _f(c.get("equity")), - }) - return { - "account_type": a.get("accountType"), - "equity": _f(a.get("totalEquity")), - "wallet_balance": _f(a.get("totalWalletBalance")), - "margin_balance": _f(a.get("totalMarginBalance")), - "available_balance": _f(a.get("totalAvailableBalance")), - "unrealized_pnl": _f(a.get("totalPerpUPL")), - "coins": coins, - } - - async def get_trade_history( - self, category: str = "linear", limit: int = 50 - ) -> list[dict]: - resp = await self._run( - self._http.get_executions, category=category, limit=limit - ) - rows = (resp.get("result") or {}).get("list") or [] - return [ - { - "symbol": r.get("symbol"), - "side": r.get("side"), - "size": _f(r.get("execQty")), - "price": _f(r.get("execPrice")), - "fee": _f(r.get("execFee")), - "timestamp": _i(r.get("execTime")), - "order_id": r.get("orderId"), - } - for r in rows - ] - - async def get_open_orders( - self, - category: str = "linear", - symbol: str | None = None, - settle_coin: str = "USDT", - ) -> list[dict]: - kwargs: dict[str, Any] = {"category": category} - if category in ("linear", "inverse") and not symbol: - kwargs["settleCoin"] = settle_coin - if symbol: - kwargs["symbol"] = symbol - resp = await self._run(self._http.get_open_orders, **kwargs) - rows = (resp.get("result") or {}).get("list") or [] - return [ - { - "order_id": r.get("orderId"), - "symbol": r.get("symbol"), - "side": r.get("side"), - "qty": _f(r.get("qty")), - "price": _f(r.get("price")), - "type": r.get("orderType"), - "status": r.get("orderStatus"), - "reduce_only": bool(r.get("reduceOnly")), - } - for r in rows - ] - - async def get_orderbook_imbalance( - self, - symbol: str, - category: str = "linear", - depth: int = 10, - ) -> dict: - """Microstructure: bid/ask imbalance ratio + microprice + slope.""" - ob = await self.get_orderbook(symbol=symbol, category=category, limit=max(depth, 50)) - result = micro.orderbook_imbalance(ob.get("bids") or [], ob.get("asks") or [], depth=depth) - return { - "symbol": symbol, - "category": category, - "depth": depth, - **result, - "timestamp": ob.get("timestamp"), - } - - async def get_basis_term_structure(self, asset: str) -> dict: - """Basis curve futures (dated) vs perp + spot. Filtra contratti future - BTCUSDT / ETHUSDT con scadenza, calcola annualized basis per ognuno. - """ - import datetime as _dt - - asset = asset.upper() - spot = await self.get_ticker(f"{asset}USDT", category="spot") - perp = await self.get_ticker(f"{asset}USDT", category="linear") - sp = spot.get("last_price") - pp = perp.get("last_price") - - # Lista futures dated (linear/inverse) - instr = await self.get_instruments(category="linear") - items = (instr.get("instruments") or []) - futures = [ - x for x in items - if x.get("symbol", "").startswith(f"{asset}-") or x.get("symbol", "").startswith(f"{asset}USDT-") - ] - - rows: list[dict[str, Any]] = [] - if sp: - now_ms = int(_dt.datetime.now(_dt.UTC).timestamp() * 1000) - for f in futures[:10]: - tk = await self.get_ticker(f["symbol"], category="linear") - fp = tk.get("last_price") - expiry_ms = f.get("delivery_time") - if not fp or not expiry_ms: - continue - days = max((int(expiry_ms) - now_ms) / 86_400_000, 1) - basis_pct = 100.0 * (fp - sp) / sp - annualized = basis_pct * 365.0 / days - rows.append({ - "symbol": f["symbol"], - "expiry_ms": int(expiry_ms), - "days_to_expiry": round(days, 2), - "future_price": fp, - "basis_pct": round(basis_pct, 4), - "annualized_basis_pct": round(annualized, 4), - }) - - rows.sort(key=lambda r: r["days_to_expiry"]) - return { - "asset": asset, - "spot_price": sp, - "perp_price": pp, - "perp_basis_pct": round(100.0 * (pp - sp) / sp, 4) if (sp and pp) else None, - "term_structure": rows, - "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), - } - - async def get_basis_spot_perp(self, asset: str) -> dict: - asset = asset.upper() - symbol = f"{asset}USDT" - spot = await self.get_ticker(symbol, category="spot") - perp = await self.get_ticker(symbol, category="linear") - sp = spot.get("last_price") - pp = perp.get("last_price") - basis_abs = basis_pct = None - if sp and pp: - basis_abs = pp - sp - basis_pct = 100.0 * basis_abs / sp - return { - "asset": asset, - "symbol": symbol, - "spot_price": sp, - "perp_price": pp, - "basis_abs": basis_abs, - "basis_pct": basis_pct, - "funding_rate": perp.get("funding_rate"), - } - - def _envelope(self, resp: dict, payload: dict) -> dict: - code = resp.get("retCode", 0) - if code != 0: - return {"error": resp.get("retMsg", "bybit_error"), "code": code} - return payload - - async def place_order( - self, - category: str, - symbol: str, - side: str, - qty: float, - order_type: str = "Limit", - price: float | None = None, - tif: str = "GTC", - reduce_only: bool = False, - position_idx: int | None = None, - ) -> dict: - kwargs: dict[str, Any] = { - "category": category, - "symbol": symbol, - "side": side, - "qty": str(qty), - "orderType": order_type, - "timeInForce": tif, - "reduceOnly": reduce_only, - } - if price is not None: - kwargs["price"] = str(price) - if position_idx is not None: - kwargs["positionIdx"] = position_idx - if category == "option": - import uuid - kwargs["orderLinkId"] = f"cerbero-{uuid.uuid4().hex[:16]}" - resp = await self._run(self._http.place_order, **kwargs) - r = resp.get("result") or {} - return self._envelope(resp, { - "order_id": r.get("orderId"), - "order_link_id": r.get("orderLinkId"), - "status": "submitted", - }) - - async def place_combo_order( - self, - category: str, - legs: list[dict[str, Any]], - ) -> dict: - """Atomic multi-leg via /v5/order/create-batch (Bybit option only). - - Bybit supporta batch_order solo su category='option'. Per perp/linear - usare loop di place_order (non atomic). - - legs: [{symbol, side, qty, order_type, price?, tif?, reduce_only?}]. - """ - if category != "option": - raise ValueError("place_combo_order: Bybit batch_order è disponibile solo su category='option'") - if len(legs) < 2: - raise ValueError("combo requires at least 2 legs") - - import uuid - request: list[dict[str, Any]] = [] - for leg in legs: - entry: dict[str, Any] = { - "symbol": leg["symbol"], - "side": leg["side"], - "qty": str(leg["qty"]), - "orderType": leg.get("order_type", "Limit"), - "timeInForce": leg.get("tif", "GTC"), - "reduceOnly": leg.get("reduce_only", False), - "orderLinkId": f"cerbero-{uuid.uuid4().hex[:16]}", - } - if leg.get("price") is not None: - entry["price"] = str(leg["price"]) - request.append(entry) - - resp = await self._run(self._http.place_batch_order, category=category, request=request) - result_list = (resp.get("result") or {}).get("list") or [] - orders = [ - { - "order_id": r.get("orderId"), - "order_link_id": r.get("orderLinkId"), - "status": "submitted", - } - for r in result_list - ] - return self._envelope(resp, {"orders": orders}) - - async def amend_order( - self, - category: str, - symbol: str, - order_id: str, - new_qty: float | None = None, - new_price: float | None = None, - ) -> dict: - kwargs: dict[str, Any] = { - "category": category, - "symbol": symbol, - "orderId": order_id, - } - if new_qty is not None: - kwargs["qty"] = str(new_qty) - if new_price is not None: - kwargs["price"] = str(new_price) - resp = await self._run(self._http.amend_order, **kwargs) - r = resp.get("result") or {} - return self._envelope(resp, { - "order_id": r.get("orderId", order_id), - "status": "amended", - }) - - async def cancel_order( - self, category: str, symbol: str, order_id: str - ) -> dict: - resp = await self._run( - self._http.cancel_order, - category=category, symbol=symbol, orderId=order_id, - ) - r = resp.get("result") or {} - return self._envelope(resp, { - "order_id": r.get("orderId", order_id), - "status": "cancelled", - }) - - async def cancel_all_orders( - self, category: str, symbol: str | None = None - ) -> dict: - kwargs: dict[str, Any] = {"category": category} - if symbol: - kwargs["symbol"] = symbol - resp = await self._run(self._http.cancel_all_orders, **kwargs) - r = resp.get("result") or {} - ids = [x.get("orderId") for x in (r.get("list") or [])] - return self._envelope(resp, { - "cancelled_ids": ids, - "count": len(ids), - }) - - async def set_stop_loss( - self, category: str, symbol: str, stop_loss: float, - position_idx: int = 0, - ) -> dict: - resp = await self._run( - self._http.set_trading_stop, - category=category, symbol=symbol, - stopLoss=str(stop_loss), positionIdx=position_idx, - ) - return self._envelope(resp, { - "symbol": symbol, "stop_loss": stop_loss, - "status": "stop_loss_set", - }) - - async def set_take_profit( - self, category: str, symbol: str, take_profit: float, - position_idx: int = 0, - ) -> dict: - resp = await self._run( - self._http.set_trading_stop, - category=category, symbol=symbol, - takeProfit=str(take_profit), positionIdx=position_idx, - ) - return self._envelope(resp, { - "symbol": symbol, "take_profit": take_profit, - "status": "take_profit_set", - }) - - async def close_position(self, category: str, symbol: str) -> dict: - positions = await self.get_positions(category=category) - target = next((p for p in positions if p["symbol"] == symbol and (p["size"] or 0) > 0), None) - if not target: - return {"error": "no_open_position", "symbol": symbol} - close_side = "Sell" if target["side"] == "Buy" else "Buy" - return await self.place_order( - category=category, - symbol=symbol, - side=close_side, - qty=target["size"], - order_type="Market", - reduce_only=True, - tif="IOC", - ) - - async def set_leverage( - self, category: str, symbol: str, leverage: int - ) -> dict: - resp = await self._run( - self._http.set_leverage, - category=category, symbol=symbol, - buyLeverage=str(leverage), sellLeverage=str(leverage), - ) - return self._envelope(resp, { - "symbol": symbol, "leverage": leverage, - "status": "leverage_set", - }) - - async def switch_position_mode( - self, category: str, symbol: str, mode: str - ) -> dict: - mode_code = 3 if mode.lower() == "hedge" else 0 - resp = await self._run( - self._http.switch_position_mode, - category=category, symbol=symbol, mode=mode_code, - ) - return self._envelope(resp, { - "symbol": symbol, "mode": mode, - "status": "mode_switched", - }) - - async def transfer_asset( - self, - coin: str, - amount: float, - from_type: str, - to_type: str, - ) -> dict: - import uuid - resp = await self._run( - self._http.create_internal_transfer, - transferId=str(uuid.uuid4()), - coin=coin, - amount=str(amount), - fromAccountType=from_type, - toAccountType=to_type, - ) - r = resp.get("result") or {} - return self._envelope(resp, { - "transfer_id": r.get("transferId"), - "coin": coin, - "amount": amount, - "status": "submitted", - }) diff --git a/services/mcp-bybit/src/mcp_bybit/leverage_cap.py b/services/mcp-bybit/src/mcp_bybit/leverage_cap.py deleted file mode 100644 index d04dd51..0000000 --- a/services/mcp-bybit/src/mcp_bybit/leverage_cap.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Leverage cap server-side per place_order. - -Cap letto dal secret JSON via campo `max_leverage`. Default 1 (cash) se assente. -""" -from __future__ import annotations - -from fastapi import HTTPException - - -def get_max_leverage(creds: dict) -> int: - """Legge max_leverage dal secret. Default 1 se mancante.""" - raw = creds.get("max_leverage", 1) - try: - value = int(raw) - except (TypeError, ValueError): - value = 1 - return max(1, value) - - -def enforce_leverage( - requested: int | float | None, - *, - creds: dict, - exchange: str, -) -> int: - """Verifica e applica leverage cap. Ritorna leverage applicabile. - - Solleva HTTPException(403, LEVERAGE_CAP_EXCEEDED) se requested > cap. - Se requested is None, applica il cap come default. - """ - cap = get_max_leverage(creds) - if requested is None: - return cap - lev = int(requested) - if lev < 1: - raise HTTPException( - status_code=403, - detail={ - "error": "LEVERAGE_CAP_EXCEEDED", - "exchange": exchange, - "requested": lev, - "max": cap, - "reason": "leverage must be >= 1", - }, - ) - if lev > cap: - raise HTTPException( - status_code=403, - detail={ - "error": "LEVERAGE_CAP_EXCEEDED", - "exchange": exchange, - "requested": lev, - "max": cap, - }, - ) - return lev diff --git a/services/mcp-bybit/src/mcp_bybit/server.py b/services/mcp-bybit/src/mcp_bybit/server.py deleted file mode 100644 index 26328ad..0000000 --- a/services/mcp-bybit/src/mcp_bybit/server.py +++ /dev/null @@ -1,522 +0,0 @@ -from __future__ import annotations - -import os - -from fastapi import Depends, HTTPException -from mcp_common.audit import audit_write_op -from mcp_common.auth import Principal, TokenStore, require_principal -from mcp_common.environment import EnvironmentInfo -from mcp_common.mcp_bridge import mount_mcp_endpoint -from mcp_common.server import build_app -from pydantic import BaseModel, Field - -from mcp_bybit.client import BybitClient -from mcp_bybit.leverage_cap import enforce_leverage as _enforce_leverage -from mcp_bybit.leverage_cap import get_max_leverage - -# --- Body models: reads --- - -class TickerReq(BaseModel): - symbol: str - category: str = "linear" - - -class TickerBatchReq(BaseModel): - symbols: list[str] - category: str = "linear" - - -class OrderbookReq(BaseModel): - symbol: str - category: str = "linear" - limit: int = 50 - - -class HistoricalReq(BaseModel): - symbol: str - category: str = "linear" - interval: str = "60" - start: int | None = None - end: int | None = None - limit: int = 1000 - - -class IndicatorsReq(BaseModel): - symbol: str - category: str = "linear" - indicators: list[str] = ["rsi", "atr", "macd", "adx"] - interval: str = "60" - start: int | None = None - end: int | None = None - - -class FundingRateReq(BaseModel): - symbol: str - category: str = "linear" - - -class FundingHistoryReq(BaseModel): - symbol: str - category: str = "linear" - limit: int = 100 - - -class OpenInterestReq(BaseModel): - symbol: str - category: str = "linear" - interval: str = "5min" - limit: int = 288 - - -class InstrumentsReq(BaseModel): - category: str = "linear" - symbol: str | None = None - - -class OptionChainReq(BaseModel): - base_coin: str - expiry: str | None = None - - -class PositionsReq(BaseModel): - category: str = "linear" - - -class AccountSummaryReq(BaseModel): - pass - - -class TradeHistoryReq(BaseModel): - category: str = "linear" - limit: int = 50 - - -class OpenOrdersReq(BaseModel): - category: str = "linear" - symbol: str | None = None - - -class BasisSpotPerpReq(BaseModel): - asset: str - - -class OrderbookImbalanceReq(BaseModel): - symbol: str - category: str = "linear" - depth: int = 10 - - -class BasisTermStructureReq(BaseModel): - asset: str - - -# --- Body models: writes --- - -class PlaceOrderReq(BaseModel): - category: str - symbol: str - side: str - qty: float - order_type: str = "Limit" - price: float | None = None - tif: str = "GTC" - reduce_only: bool = False - position_idx: int | None = None - - -class ComboLegReq(BaseModel): - symbol: str - side: str - qty: float - order_type: str = "Limit" - price: float | None = None - tif: str = "GTC" - reduce_only: bool = False - - -class PlaceComboOrderReq(BaseModel): - category: str = "option" - legs: list[ComboLegReq] = Field(..., min_length=2) - - -class AmendOrderReq(BaseModel): - category: str - symbol: str - order_id: str - new_qty: float | None = None - new_price: float | None = None - - -class CancelOrderReq(BaseModel): - category: str - symbol: str - order_id: str - - -class CancelAllReq(BaseModel): - category: str - symbol: str | None = None - - -class SetStopLossReq(BaseModel): - category: str - symbol: str - stop_loss: float - position_idx: int = 0 - - -class SetTakeProfitReq(BaseModel): - category: str - symbol: str - take_profit: float - position_idx: int = 0 - - -class ClosePositionReq(BaseModel): - category: str - symbol: str - - -class SetLeverageReq(BaseModel): - category: str - symbol: str - leverage: int - - -class SwitchModeReq(BaseModel): - category: str - symbol: str - mode: str - - -class TransferReq(BaseModel): - coin: str - amount: float - from_type: str - to_type: str - - -# --- ACL helper --- - -def _check(principal: Principal, *, core: bool = False, observer: bool = False) -> None: - allowed: set[str] = set() - if core: - allowed.add("core") - if observer: - allowed.add("observer") - if not (principal.capabilities & allowed): - raise HTTPException(status_code=403, detail="forbidden") - - -def create_app( - *, - client: BybitClient, - token_store: TokenStore, - creds: dict | None = None, - env_info: EnvironmentInfo | None = None, -): - creds = creds or {} - app = build_app(name="mcp-bybit", version="0.1.0", token_store=token_store) - - # ── Reads ────────────────────────────────────────────── - - @app.post("/tools/environment_info", tags=["reads"]) - async def t_environment_info(principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - if env_info is None: - return { - "exchange": "bybit", - "environment": "testnet" if client.testnet else "mainnet", - "source": "credentials", - "env_value": None, - "base_url": getattr(client, "base_url", None), - "max_leverage": get_max_leverage(creds), - } - return { - "exchange": env_info.exchange, - "environment": env_info.environment, - "source": env_info.source, - "env_value": env_info.env_value, - "base_url": env_info.base_url, - "max_leverage": get_max_leverage(creds), - } - - @app.post("/tools/get_ticker", tags=["reads"]) - async def t_get_ticker(body: TickerReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_ticker(body.symbol, body.category) - - @app.post("/tools/get_ticker_batch", tags=["reads"]) - async def t_get_ticker_batch(body: TickerBatchReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_ticker_batch(body.symbols, body.category) - - @app.post("/tools/get_orderbook", tags=["reads"]) - async def t_get_orderbook(body: OrderbookReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_orderbook(body.symbol, body.category, body.limit) - - @app.post("/tools/get_historical", tags=["reads"]) - async def t_get_historical(body: HistoricalReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_historical( - body.symbol, body.category, body.interval, body.start, body.end, body.limit, - ) - - @app.post("/tools/get_indicators", tags=["reads"]) - async def t_get_indicators(body: IndicatorsReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_indicators( - body.symbol, body.category, body.indicators, - body.interval, body.start, body.end, - ) - - @app.post("/tools/get_funding_rate", tags=["reads"]) - async def t_get_funding_rate(body: FundingRateReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_funding_rate(body.symbol, body.category) - - @app.post("/tools/get_funding_history", tags=["reads"]) - async def t_get_funding_history(body: FundingHistoryReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_funding_history(body.symbol, body.category, body.limit) - - @app.post("/tools/get_open_interest", tags=["reads"]) - async def t_get_open_interest(body: OpenInterestReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_open_interest(body.symbol, body.category, body.interval, body.limit) - - @app.post("/tools/get_instruments", tags=["reads"]) - async def t_get_instruments(body: InstrumentsReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_instruments(body.category, body.symbol) - - @app.post("/tools/get_option_chain", tags=["reads"]) - async def t_get_option_chain(body: OptionChainReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_option_chain(body.base_coin, body.expiry) - - @app.post("/tools/get_positions", tags=["reads"]) - async def t_get_positions(body: PositionsReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return {"positions": await client.get_positions(body.category)} - - @app.post("/tools/get_account_summary", tags=["reads"]) - async def t_get_account_summary(body: AccountSummaryReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_account_summary() - - @app.post("/tools/get_trade_history", tags=["reads"]) - async def t_get_trade_history(body: TradeHistoryReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return {"trades": await client.get_trade_history(body.category, body.limit)} - - @app.post("/tools/get_open_orders", tags=["reads"]) - async def t_get_open_orders(body: OpenOrdersReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return {"orders": await client.get_open_orders(body.category, body.symbol)} - - @app.post("/tools/get_basis_spot_perp", tags=["reads"]) - async def t_get_basis_spot_perp(body: BasisSpotPerpReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_basis_spot_perp(body.asset) - - @app.post("/tools/get_orderbook_imbalance", tags=["reads"]) - async def t_get_ob_imbalance(body: OrderbookImbalanceReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_orderbook_imbalance(body.symbol, body.category, body.depth) - - @app.post("/tools/get_basis_term_structure", tags=["reads"]) - async def t_get_basis_term_structure(body: BasisTermStructureReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return await client.get_basis_term_structure(body.asset) - - # ── Writes ───────────────────────────────────────────── - - @app.post("/tools/place_order", tags=["writes"]) - async def t_place_order(body: PlaceOrderReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = await client.place_order( - body.category, body.symbol, body.side, body.qty, - body.order_type, body.price, body.tif, body.reduce_only, body.position_idx, - ) - audit_write_op( - principal=principal, action="place_order", exchange="bybit", - target=body.symbol, - payload={"category": body.category, "side": body.side, "qty": body.qty, - "order_type": body.order_type, "price": body.price, "tif": body.tif, - "reduce_only": body.reduce_only}, - result=result, - ) - return result - - @app.post("/tools/place_combo_order", tags=["writes"]) - async def t_place_combo_order(body: PlaceComboOrderReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = await client.place_combo_order( - category=body.category, - legs=[leg.model_dump() for leg in body.legs], - ) - audit_write_op( - principal=principal, action="place_combo_order", exchange="bybit", - payload={"category": body.category, - "legs": [leg.model_dump() for leg in body.legs]}, - result=result if isinstance(result, dict) else None, - ) - return result - - @app.post("/tools/amend_order", tags=["writes"]) - async def t_amend_order(body: AmendOrderReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = await client.amend_order( - body.category, body.symbol, body.order_id, body.new_qty, body.new_price, - ) - audit_write_op( - principal=principal, action="amend_order", exchange="bybit", - target=body.order_id, - payload={"category": body.category, "symbol": body.symbol, - "new_qty": body.new_qty, "new_price": body.new_price}, - result=result, - ) - return result - - @app.post("/tools/cancel_order", tags=["writes"]) - async def t_cancel_order(body: CancelOrderReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = await client.cancel_order(body.category, body.symbol, body.order_id) - audit_write_op( - principal=principal, action="cancel_order", exchange="bybit", - target=body.order_id, - payload={"category": body.category, "symbol": body.symbol}, - result=result, - ) - return result - - @app.post("/tools/cancel_all_orders", tags=["writes"]) - async def t_cancel_all(body: CancelAllReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = await client.cancel_all_orders(body.category, body.symbol) - audit_write_op( - principal=principal, action="cancel_all_orders", exchange="bybit", - target=body.symbol, - payload={"category": body.category}, - result=result, - ) - return result - - @app.post("/tools/set_stop_loss", tags=["writes"]) - async def t_set_sl(body: SetStopLossReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = await client.set_stop_loss(body.category, body.symbol, body.stop_loss, body.position_idx) - audit_write_op( - principal=principal, action="set_stop_loss", exchange="bybit", - target=body.symbol, - payload={"stop_loss": body.stop_loss, "position_idx": body.position_idx}, - result=result, - ) - return result - - @app.post("/tools/set_take_profit", tags=["writes"]) - async def t_set_tp(body: SetTakeProfitReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = await client.set_take_profit(body.category, body.symbol, body.take_profit, body.position_idx) - audit_write_op( - principal=principal, action="set_take_profit", exchange="bybit", - target=body.symbol, - payload={"take_profit": body.take_profit, "position_idx": body.position_idx}, - result=result, - ) - return result - - @app.post("/tools/close_position", tags=["writes"]) - async def t_close(body: ClosePositionReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = await client.close_position(body.category, body.symbol) - audit_write_op( - principal=principal, action="close_position", exchange="bybit", - target=body.symbol, - payload={"category": body.category}, - result=result, - ) - return result - - @app.post("/tools/set_leverage", tags=["writes"]) - async def t_set_leverage(body: SetLeverageReq, principal: Principal = Depends(require_principal)): - _enforce_leverage(body.leverage, creds=creds, exchange="bybit") - _check(principal, core=True) - result = await client.set_leverage(body.category, body.symbol, body.leverage) - audit_write_op( - principal=principal, action="set_leverage", exchange="bybit", - target=body.symbol, - payload={"category": body.category, "leverage": body.leverage}, - result=result, - ) - return result - - @app.post("/tools/switch_position_mode", tags=["writes"]) - async def t_switch_mode(body: SwitchModeReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = await client.switch_position_mode(body.category, body.symbol, body.mode) - audit_write_op( - principal=principal, action="switch_position_mode", exchange="bybit", - target=body.symbol, - payload={"category": body.category, "mode": body.mode}, - result=result, - ) - return result - - @app.post("/tools/transfer_asset", tags=["writes"]) - async def t_transfer(body: TransferReq, principal: Principal = Depends(require_principal)): - _check(principal, core=True) - result = await client.transfer_asset(body.coin, body.amount, body.from_type, body.to_type) - audit_write_op( - principal=principal, action="transfer_asset", exchange="bybit", - payload={"coin": body.coin, "amount": body.amount, - "from_type": body.from_type, "to_type": body.to_type}, - result=result, - ) - return result - - # ── MCP mount ────────────────────────────────────────── - - port = int(os.environ.get("PORT", "9019")) - mount_mcp_endpoint( - app, - name="cerbero-bybit", - version="0.1.0", - token_store=token_store, - internal_base_url=f"http://localhost:{port}", - tools=[ - {"name": "environment_info", "description": "Ambiente operativo (testnet/mainnet), source, base_url, max_leverage cap."}, - {"name": "get_ticker", "description": "Ticker Bybit (spot/linear/inverse/option)."}, - {"name": "get_ticker_batch", "description": "Ticker per più simboli."}, - {"name": "get_orderbook", "description": "Orderbook profondità N."}, - {"name": "get_historical", "description": "OHLCV candles Bybit."}, - {"name": "get_indicators", "description": "Indicatori tecnici (RSI, ATR, MACD, ADX)."}, - {"name": "get_funding_rate", "description": "Funding corrente perp."}, - {"name": "get_funding_history", "description": "Funding storico perp."}, - {"name": "get_open_interest", "description": "Open interest history perp."}, - {"name": "get_instruments", "description": "Specs contratti."}, - {"name": "get_option_chain", "description": "Option chain BTC/ETH/SOL."}, - {"name": "get_positions", "description": "Posizioni aperte."}, - {"name": "get_account_summary", "description": "Wallet balance e margine."}, - {"name": "get_trade_history", "description": "Fills recenti."}, - {"name": "get_open_orders", "description": "Ordini pending."}, - {"name": "get_basis_spot_perp", "description": "Basis spot vs linear perp."}, - {"name": "get_orderbook_imbalance", "description": "Microstructure: imbalance ratio + microprice + slope su top-N livelli book."}, - {"name": "get_basis_term_structure", "description": "Basis curve futures dated vs spot, annualizzato."}, - {"name": "place_order", "description": "Invia ordine (CORE only)."}, - {"name": "place_combo_order", "description": "Multi-leg atomico via place_batch_order (solo category=option)."}, - {"name": "amend_order", "description": "Modifica ordine esistente."}, - {"name": "cancel_order", "description": "Cancella ordine."}, - {"name": "cancel_all_orders", "description": "Cancella tutti ordini."}, - {"name": "set_stop_loss", "description": "Setta stop loss su posizione."}, - {"name": "set_take_profit", "description": "Setta take profit su posizione."}, - {"name": "close_position", "description": "Chiude posizione aperta."}, - {"name": "set_leverage", "description": "Leva buy+sell uniforme."}, - {"name": "switch_position_mode", "description": "Hedge vs one-way."}, - {"name": "transfer_asset", "description": "Trasferimento interno tra account types."}, - ], - ) - - return app diff --git a/services/mcp-bybit/tests/conftest.py b/services/mcp-bybit/tests/conftest.py deleted file mode 100644 index 7812045..0000000 --- a/services/mcp-bybit/tests/conftest.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -from unittest.mock import MagicMock - -import pytest -from mcp_bybit.client import BybitClient - - -@pytest.fixture -def mock_http(): - return MagicMock(name="pybit_HTTP") - - -@pytest.fixture -def client(mock_http): - return BybitClient( - api_key="test_key", - api_secret="test_secret", - testnet=True, - http=mock_http, - ) diff --git a/services/mcp-bybit/tests/test_client.py b/services/mcp-bybit/tests/test_client.py deleted file mode 100644 index c1d3434..0000000 --- a/services/mcp-bybit/tests/test_client.py +++ /dev/null @@ -1,588 +0,0 @@ -from __future__ import annotations - -import pytest -from mcp_bybit.client import BybitClient - - -def test_client_init_stores_attrs(client, mock_http): - assert client.testnet is True - assert client._http is mock_http - - -def test_client_init_default_http(monkeypatch): - created = {} - - class FakeHTTP: - def __init__(self, **kwargs): - created.update(kwargs) - - monkeypatch.setattr("mcp_bybit.client.HTTP", FakeHTTP) - BybitClient(api_key="k", api_secret="s", testnet=False) - assert created["api_key"] == "k" - assert created["api_secret"] == "s" - assert created["testnet"] is False - - -@pytest.mark.asyncio -async def test_get_ticker(client, mock_http): - mock_http.get_tickers.return_value = { - "retCode": 0, - "result": { - "list": [{ - "symbol": "BTCUSDT", - "lastPrice": "60000", - "markPrice": "60010", - "bid1Price": "59995", - "ask1Price": "60005", - "volume24h": "1500.5", - "turnover24h": "90000000", - "fundingRate": "0.0001", - "openInterest": "50000", - }] - }, - } - t = await client.get_ticker("BTCUSDT", category="linear") - mock_http.get_tickers.assert_called_once_with(category="linear", symbol="BTCUSDT") - assert t["symbol"] == "BTCUSDT" - assert t["last_price"] == 60000.0 - assert t["mark_price"] == 60010.0 - assert t["bid"] == 59995.0 - assert t["ask"] == 60005.0 - assert t["volume_24h"] == 1500.5 - assert t["funding_rate"] == 0.0001 - assert t["open_interest"] == 50000.0 - - -@pytest.mark.asyncio -async def test_get_ticker_batch(client, mock_http): - def side_effect(**kwargs): - symbol = kwargs["symbol"] - return {"retCode": 0, "result": {"list": [{ - "symbol": symbol, "lastPrice": "1", "markPrice": "1", - "bid1Price": "1", "ask1Price": "1", "volume24h": "0", - "turnover24h": "0", "fundingRate": "0", "openInterest": "0", - }]}} - mock_http.get_tickers.side_effect = side_effect - out = await client.get_ticker_batch(["BTCUSDT", "ETHUSDT"], category="linear") - assert set(out.keys()) == {"BTCUSDT", "ETHUSDT"} - assert mock_http.get_tickers.call_count == 2 - - -@pytest.mark.asyncio -async def test_get_ticker_not_found(client, mock_http): - mock_http.get_tickers.return_value = {"retCode": 0, "result": {"list": []}} - t = await client.get_ticker("UNKNOWNUSDT", category="linear") - assert t == {"symbol": "UNKNOWNUSDT", "error": "not_found"} - - -def test_parse_helpers(): - from mcp_bybit.client import _f, _i - assert _f("1.5") == 1.5 - assert _f("") is None - assert _f(None) is None - assert _i("42") == 42 - assert _i("") is None - assert _i(None) is None - - -@pytest.mark.asyncio -async def test_get_orderbook(client, mock_http): - mock_http.get_orderbook.return_value = { - "retCode": 0, - "result": { - "s": "BTCUSDT", - "b": [["59990", "0.5"], ["59980", "1.0"]], - "a": [["60010", "0.3"], ["60020", "0.7"]], - "ts": 1700000000000, - }, - } - ob = await client.get_orderbook("BTCUSDT", category="linear", limit=25) - mock_http.get_orderbook.assert_called_once_with( - category="linear", symbol="BTCUSDT", limit=25 - ) - assert ob["symbol"] == "BTCUSDT" - assert ob["bids"] == [[59990.0, 0.5], [59980.0, 1.0]] - assert ob["asks"] == [[60010.0, 0.3], [60020.0, 0.7]] - assert ob["timestamp"] == 1700000000000 - - -@pytest.mark.asyncio -async def test_get_historical(client, mock_http): - mock_http.get_kline.return_value = { - "retCode": 0, - "result": { - "list": [ - ["1700000000000", "60000", "60500", "59500", "60200", "100", "6020000"], - ["1700003600000", "60200", "60700", "60000", "60400", "80", "4832000"], - ] - }, - } - out = await client.get_historical( - "BTCUSDT", category="linear", interval="60", - start=1700000000000, end=1700003600000, - ) - mock_http.get_kline.assert_called_once_with( - category="linear", symbol="BTCUSDT", interval="60", - start=1700000000000, end=1700003600000, limit=1000, - ) - assert len(out["candles"]) == 2 - c0 = out["candles"][0] - assert c0["timestamp"] == 1700000000000 - assert c0["open"] == 60000.0 - assert c0["high"] == 60500.0 - assert c0["low"] == 59500.0 - assert c0["close"] == 60200.0 - assert c0["volume"] == 100.0 - - -@pytest.mark.asyncio -async def test_get_indicators(client, mock_http): - rows = [ - [str(1700000000000 + i * 3600_000), - str(60000 + i * 10), str(60000 + i * 10 + 5), - str(60000 + i * 10 - 5), str(60000 + i * 10 + 2), - "100", "6000000"] - for i in range(35) - ] - mock_http.get_kline.return_value = {"retCode": 0, "result": {"list": rows}} - out = await client.get_indicators( - "BTCUSDT", category="linear", - indicators=["rsi", "atr", "macd", "adx"], - interval="60", - ) - assert "rsi" in out and out["rsi"] is not None - assert "atr" in out and out["atr"] is not None - assert "macd" in out and out["macd"]["macd"] is not None - assert "adx" in out and out["adx"]["adx"] is not None - - -@pytest.mark.asyncio -async def test_get_funding_rate(client, mock_http): - mock_http.get_tickers.return_value = { - "retCode": 0, - "result": {"list": [{ - "symbol": "BTCUSDT", "fundingRate": "0.0001", - "nextFundingTime": "1700003600000", - "lastPrice": "60000", "markPrice": "60000", - "bid1Price": "0", "ask1Price": "0", - "volume24h": "0", "turnover24h": "0", "openInterest": "0", - }]}, - } - out = await client.get_funding_rate("BTCUSDT", category="linear") - assert out["symbol"] == "BTCUSDT" - assert out["funding_rate"] == 0.0001 - assert out["next_funding_time"] == 1700003600000 - - -@pytest.mark.asyncio -async def test_get_funding_history(client, mock_http): - mock_http.get_funding_rate_history.return_value = { - "retCode": 0, - "result": {"list": [ - {"symbol": "BTCUSDT", "fundingRate": "0.0001", "fundingRateTimestamp": "1700000000000"}, - {"symbol": "BTCUSDT", "fundingRate": "0.00008", "fundingRateTimestamp": "1699996400000"}, - ]}, - } - out = await client.get_funding_history("BTCUSDT", category="linear", limit=50) - mock_http.get_funding_rate_history.assert_called_once_with( - category="linear", symbol="BTCUSDT", limit=50 - ) - assert len(out["history"]) == 2 - assert out["history"][0]["rate"] == 0.0001 - - -@pytest.mark.asyncio -async def test_get_open_interest(client, mock_http): - mock_http.get_open_interest.return_value = { - "retCode": 0, - "result": {"list": [ - {"openInterest": "50000", "timestamp": "1700000000000"}, - {"openInterest": "49000", "timestamp": "1699996400000"}, - ]}, - } - out = await client.get_open_interest("BTCUSDT", category="linear", interval="5min", limit=100) - mock_http.get_open_interest.assert_called_once_with( - category="linear", symbol="BTCUSDT", intervalTime="5min", limit=100 - ) - assert len(out["points"]) == 2 - assert out["current_oi"] == 50000.0 - - -@pytest.mark.asyncio -async def test_get_instruments(client, mock_http): - mock_http.get_instruments_info.return_value = { - "retCode": 0, - "result": {"list": [ - {"symbol": "BTCUSDT", "status": "Trading", "baseCoin": "BTC", - "quoteCoin": "USDT", "priceFilter": {"tickSize": "0.1"}, - "lotSizeFilter": {"qtyStep": "0.001", "minOrderQty": "0.001"}}, - ]}, - } - out = await client.get_instruments(category="linear") - mock_http.get_instruments_info.assert_called_once_with(category="linear") - assert len(out["instruments"]) == 1 - inst = out["instruments"][0] - assert inst["symbol"] == "BTCUSDT" - assert inst["tick_size"] == 0.1 - assert inst["qty_step"] == 0.001 - - -@pytest.mark.asyncio -async def test_get_option_chain(client, mock_http): - mock_http.get_instruments_info.return_value = { - "retCode": 0, - "result": {"list": [ - {"symbol": "BTC-30JUN25-50000-C", "baseCoin": "BTC", - "settleCoin": "USDC", "optionsType": "Call", - "launchTime": "1700000000000", "deliveryTime": "1719734400000"}, - {"symbol": "BTC-30JUN25-50000-P", "baseCoin": "BTC", - "settleCoin": "USDC", "optionsType": "Put", - "launchTime": "1700000000000", "deliveryTime": "1719734400000"}, - ]}, - } - out = await client.get_option_chain(base_coin="BTC") - mock_http.get_instruments_info.assert_called_once_with(category="option", baseCoin="BTC") - assert len(out["options"]) == 2 - assert out["options"][0]["type"] == "Call" - - -@pytest.mark.asyncio -async def test_get_positions(client, mock_http): - mock_http.get_positions.return_value = { - "retCode": 0, - "result": {"list": [ - {"symbol": "BTCUSDT", "side": "Buy", "size": "0.1", - "avgPrice": "60000", "unrealisedPnl": "50", - "leverage": "10", "liqPrice": "50000", "positionValue": "6000"}, - ]}, - } - out = await client.get_positions(category="linear") - mock_http.get_positions.assert_called_once_with(category="linear", settleCoin="USDT") - assert len(out) == 1 - p = out[0] - assert p["symbol"] == "BTCUSDT" - assert p["side"] == "Buy" - assert p["size"] == 0.1 - assert p["entry_price"] == 60000.0 - assert p["liquidation_price"] == 50000.0 - - -@pytest.mark.asyncio -async def test_get_account_summary(client, mock_http): - mock_http.get_wallet_balance.return_value = { - "retCode": 0, - "result": {"list": [{ - "accountType": "UNIFIED", - "totalEquity": "10000", - "totalWalletBalance": "9500", - "totalMarginBalance": "9800", - "totalAvailableBalance": "9000", - "totalPerpUPL": "200", - "coin": [ - {"coin": "USDT", "walletBalance": "9500", "equity": "9700"} - ], - }]}, - } - out = await client.get_account_summary() - mock_http.get_wallet_balance.assert_called_once_with(accountType="UNIFIED") - assert out["equity"] == 10000.0 - assert out["available_balance"] == 9000.0 - assert out["unrealized_pnl"] == 200.0 - assert len(out["coins"]) == 1 - assert out["coins"][0]["coin"] == "USDT" - - -@pytest.mark.asyncio -async def test_get_trade_history(client, mock_http): - mock_http.get_executions.return_value = { - "retCode": 0, - "result": {"list": [ - {"symbol": "BTCUSDT", "side": "Buy", "execQty": "0.01", - "execPrice": "60000", "execFee": "0.1", - "execTime": "1700000000000", "orderId": "abc"}, - ]}, - } - out = await client.get_trade_history(category="linear", limit=50) - mock_http.get_executions.assert_called_once_with(category="linear", limit=50) - assert len(out) == 1 - assert out[0]["symbol"] == "BTCUSDT" - assert out[0]["size"] == 0.01 - assert out[0]["price"] == 60000.0 - - -@pytest.mark.asyncio -async def test_get_open_orders(client, mock_http): - mock_http.get_open_orders.return_value = { - "retCode": 0, - "result": {"list": [ - {"symbol": "BTCUSDT", "orderId": "o1", "side": "Buy", - "qty": "0.1", "price": "59000", "orderType": "Limit", - "orderStatus": "New", "reduceOnly": False}, - ]}, - } - out = await client.get_open_orders(category="linear") - mock_http.get_open_orders.assert_called_once_with(category="linear", settleCoin="USDT") - assert len(out) == 1 - assert out[0]["order_id"] == "o1" - assert out[0]["price"] == 59000.0 - - -@pytest.mark.asyncio -async def test_get_basis_spot_perp(client, mock_http): - def side(**kwargs): - if kwargs["category"] == "spot": - return {"retCode": 0, "result": {"list": [{ - "symbol": "BTCUSDT", "lastPrice": "60000", "markPrice": "60000", - "bid1Price": "59995", "ask1Price": "60005", - "volume24h": "0", "turnover24h": "0", - "fundingRate": "0", "openInterest": "0", - }]}} - else: - return {"retCode": 0, "result": {"list": [{ - "symbol": "BTCUSDT", "lastPrice": "60120", "markPrice": "60120", - "bid1Price": "60115", "ask1Price": "60125", - "volume24h": "0", "turnover24h": "0", - "fundingRate": "0.0001", "openInterest": "0", - }]}} - mock_http.get_tickers.side_effect = side - out = await client.get_basis_spot_perp("BTC") - assert out["asset"] == "BTC" - assert out["spot_price"] == 60000.0 - assert out["perp_price"] == 60120.0 - assert out["basis_abs"] == 120.0 - assert round(out["basis_pct"], 3) == 0.2 - - -@pytest.mark.asyncio -async def test_place_order_limit(client, mock_http): - mock_http.place_order.return_value = { - "retCode": 0, - "result": {"orderId": "ord123", "orderLinkId": ""}, - } - out = await client.place_order( - category="linear", symbol="BTCUSDT", side="Buy", - qty=0.01, order_type="Limit", price=60000.0, tif="GTC", - ) - assert out["order_id"] == "ord123" - kwargs = mock_http.place_order.call_args.kwargs - assert kwargs["category"] == "linear" - assert kwargs["symbol"] == "BTCUSDT" - assert kwargs["side"] == "Buy" - assert kwargs["qty"] == "0.01" - assert kwargs["orderType"] == "Limit" - assert kwargs["price"] == "60000.0" - assert kwargs["timeInForce"] == "GTC" - - -@pytest.mark.asyncio -async def test_place_order_error(client, mock_http): - mock_http.place_order.return_value = {"retCode": 10001, "retMsg": "insufficient balance"} - out = await client.place_order( - category="linear", symbol="BTCUSDT", side="Buy", qty=0.01, order_type="Market" - ) - assert out.get("error") == "insufficient balance" - assert out.get("code") == 10001 - - -@pytest.mark.asyncio -async def test_amend_order(client, mock_http): - mock_http.amend_order.return_value = {"retCode": 0, "result": {"orderId": "ord1"}} - out = await client.amend_order( - category="linear", symbol="BTCUSDT", order_id="ord1", new_qty=0.02 - ) - assert out["order_id"] == "ord1" - kwargs = mock_http.amend_order.call_args.kwargs - assert kwargs["orderId"] == "ord1" - assert kwargs["qty"] == "0.02" - assert "price" not in kwargs - - -@pytest.mark.asyncio -async def test_place_order_option_adds_link_id(client, mock_http): - mock_http.place_order.return_value = { - "retCode": 0, - "result": {"orderId": "opt1", "orderLinkId": "cerbero-abc"}, - } - await client.place_order( - category="option", symbol="BTC-24APR26-96000-C-USDT", - side="Buy", qty=0.01, order_type="Limit", price=5.0, - ) - kwargs = mock_http.place_order.call_args.kwargs - assert "orderLinkId" in kwargs - assert kwargs["orderLinkId"].startswith("cerbero-") - - -@pytest.mark.asyncio -async def test_place_order_linear_no_link_id(client, mock_http): - mock_http.place_order.return_value = {"retCode": 0, "result": {"orderId": "x"}} - await client.place_order( - category="linear", symbol="BTCUSDT", side="Buy", qty=0.01, order_type="Market" - ) - kwargs = mock_http.place_order.call_args.kwargs - assert "orderLinkId" not in kwargs - - -@pytest.mark.asyncio -async def test_place_combo_order_batch_option(client, mock_http): - """Combo order via place_batch_order su category=option (atomic, 1 round-trip).""" - mock_http.place_batch_order.return_value = { - "retCode": 0, - "result": { - "list": [ - {"orderId": "ord-1", "orderLinkId": "cerbero-leg1"}, - {"orderId": "ord-2", "orderLinkId": "cerbero-leg2"}, - ] - }, - } - legs = [ - {"symbol": "BTC-30APR26-75000-C-USDT", "side": "Buy", "qty": 0.01, "order_type": "Limit", "price": 5.0}, - {"symbol": "BTC-30APR26-80000-C-USDT", "side": "Sell", "qty": 0.01, "order_type": "Limit", "price": 3.0}, - ] - out = await client.place_combo_order(category="option", legs=legs) - assert len(out["orders"]) == 2 - assert out["orders"][0]["order_id"] == "ord-1" - kwargs = mock_http.place_batch_order.call_args.kwargs - assert kwargs["category"] == "option" - request = kwargs["request"] - assert len(request) == 2 - assert request[0]["symbol"] == "BTC-30APR26-75000-C-USDT" - assert request[0]["qty"] == "0.01" - assert request[0]["orderType"] == "Limit" - # CER: orderLinkId obbligatorio per option - assert "orderLinkId" in request[0] - - -@pytest.mark.asyncio -async def test_place_combo_order_error(client, mock_http): - mock_http.place_batch_order.return_value = {"retCode": 10001, "retMsg": "invalid leg"} - out = await client.place_combo_order( - category="option", - legs=[ - {"symbol": "X", "side": "Buy", "qty": 1, "order_type": "Limit", "price": 1.0}, - {"symbol": "Y", "side": "Sell", "qty": 1, "order_type": "Limit", "price": 1.0}, - ], - ) - assert out["error"] == "invalid leg" - assert out["code"] == 10001 - - -@pytest.mark.asyncio -async def test_place_combo_order_rejects_non_option(client, mock_http): - """Bybit batch_order è disponibile solo su option category.""" - import pytest as _pytest - with _pytest.raises(ValueError, match="option"): - await client.place_combo_order( - category="linear", - legs=[ - {"symbol": "BTCUSDT", "side": "Buy", "qty": 0.01, "order_type": "Market"}, - {"symbol": "ETHUSDT", "side": "Sell", "qty": 0.01, "order_type": "Market"}, - ], - ) - - -@pytest.mark.asyncio -async def test_cancel_order(client, mock_http): - mock_http.cancel_order.return_value = {"retCode": 0, "result": {"orderId": "ord1"}} - out = await client.cancel_order(category="linear", symbol="BTCUSDT", order_id="ord1") - mock_http.cancel_order.assert_called_once_with( - category="linear", symbol="BTCUSDT", orderId="ord1" - ) - assert out["order_id"] == "ord1" - assert out["status"] == "cancelled" - - -@pytest.mark.asyncio -async def test_cancel_all_orders(client, mock_http): - mock_http.cancel_all_orders.return_value = { - "retCode": 0, - "result": {"list": [{"orderId": "o1"}, {"orderId": "o2"}]}, - } - out = await client.cancel_all_orders(category="linear", symbol="BTCUSDT") - mock_http.cancel_all_orders.assert_called_once_with( - category="linear", symbol="BTCUSDT" - ) - assert out["cancelled_ids"] == ["o1", "o2"] - - -@pytest.mark.asyncio -async def test_set_stop_loss(client, mock_http): - mock_http.set_trading_stop.return_value = {"retCode": 0, "result": {}} - out = await client.set_stop_loss( - category="linear", symbol="BTCUSDT", stop_loss=55000.0 - ) - mock_http.set_trading_stop.assert_called_once() - kwargs = mock_http.set_trading_stop.call_args.kwargs - assert kwargs["category"] == "linear" - assert kwargs["symbol"] == "BTCUSDT" - assert kwargs["stopLoss"] == "55000.0" - assert kwargs.get("positionIdx", 0) == 0 - assert out["status"] == "stop_loss_set" - - -@pytest.mark.asyncio -async def test_set_take_profit(client, mock_http): - mock_http.set_trading_stop.return_value = {"retCode": 0, "result": {}} - out = await client.set_take_profit( - category="linear", symbol="BTCUSDT", take_profit=65000.0 - ) - kwargs = mock_http.set_trading_stop.call_args.kwargs - assert kwargs["takeProfit"] == "65000.0" - assert out["status"] == "take_profit_set" - - -@pytest.mark.asyncio -async def test_close_position(client, mock_http): - mock_http.get_positions.return_value = { - "retCode": 0, "result": {"list": [ - {"symbol": "BTCUSDT", "side": "Buy", "size": "0.1", - "avgPrice": "60000", "unrealisedPnl": "0", - "leverage": "10", "liqPrice": "0", "positionValue": "6000"}, - ]}, - } - mock_http.place_order.return_value = { - "retCode": 0, "result": {"orderId": "closeord", "orderLinkId": ""}, - } - out = await client.close_position(category="linear", symbol="BTCUSDT") - assert out["status"] == "submitted" - kwargs = mock_http.place_order.call_args.kwargs - assert kwargs["side"] == "Sell" - assert kwargs["qty"] == "0.1" - assert kwargs["reduceOnly"] is True - assert kwargs["orderType"] == "Market" - - -@pytest.mark.asyncio -async def test_set_leverage(client, mock_http): - mock_http.set_leverage.return_value = {"retCode": 0, "result": {}} - out = await client.set_leverage(category="linear", symbol="BTCUSDT", leverage=5) - mock_http.set_leverage.assert_called_once_with( - category="linear", symbol="BTCUSDT", buyLeverage="5", sellLeverage="5" - ) - assert out["status"] == "leverage_set" - - -@pytest.mark.asyncio -async def test_switch_position_mode(client, mock_http): - mock_http.switch_position_mode.return_value = {"retCode": 0, "result": {}} - out = await client.switch_position_mode( - category="linear", symbol="BTCUSDT", mode="hedge" - ) - kwargs = mock_http.switch_position_mode.call_args.kwargs - assert kwargs["mode"] == 3 - assert out["status"] == "mode_switched" - - -@pytest.mark.asyncio -async def test_transfer_asset(client, mock_http): - mock_http.create_internal_transfer.return_value = { - "retCode": 0, "result": {"transferId": "tx123"}, - } - out = await client.transfer_asset( - coin="USDT", amount=100.0, from_type="UNIFIED", to_type="FUND" - ) - kwargs = mock_http.create_internal_transfer.call_args.kwargs - assert kwargs["coin"] == "USDT" - assert kwargs["amount"] == "100.0" - assert kwargs["fromAccountType"] == "UNIFIED" - assert kwargs["toAccountType"] == "FUND" - assert out["transfer_id"] == "tx123" diff --git a/services/mcp-bybit/tests/test_environment_info.py b/services/mcp-bybit/tests/test_environment_info.py deleted file mode 100644 index a758998..0000000 --- a/services/mcp-bybit/tests/test_environment_info.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -from fastapi.testclient import TestClient -from mcp_bybit.server import create_app -from mcp_common.auth import Principal, TokenStore -from mcp_common.environment import EnvironmentInfo - - -def _make_app(env_info, creds): - c = MagicMock() - c.testnet = True - c.set_leverage = AsyncMock(return_value={"state": "ok"}) - store = TokenStore(tokens={ - "ct": Principal("core", {"core"}), - "ot": Principal("observer", {"observer"}), - }) - return create_app(client=c, token_store=store, creds=creds, env_info=env_info) - - -def test_environment_info_full_shape(): - env = EnvironmentInfo( - exchange="bybit", - environment="testnet", - source="env", - env_value="true", - base_url="https://api-testnet.bybit.com", - ) - app = _make_app(env, creds={"max_leverage": 3}) - c = TestClient(app) - r = c.post( - "/tools/environment_info", - headers={"Authorization": "Bearer ot"}, - ) - assert r.status_code == 200 - body = r.json() - assert body["exchange"] == "bybit" - assert body["environment"] == "testnet" - assert body["source"] == "env" - assert body["env_value"] == "true" - assert body["base_url"] == "https://api-testnet.bybit.com" - assert body["max_leverage"] == 3 - - -def test_environment_info_requires_auth(): - env = EnvironmentInfo( - exchange="bybit", environment="testnet", source="default", - env_value=None, base_url="https://api-testnet.bybit.com", - ) - app = _make_app(env, creds={"max_leverage": 3}) - c = TestClient(app) - r = c.post("/tools/environment_info") - assert r.status_code == 401 diff --git a/services/mcp-bybit/tests/test_leverage_cap.py b/services/mcp-bybit/tests/test_leverage_cap.py deleted file mode 100644 index 18c7715..0000000 --- a/services/mcp-bybit/tests/test_leverage_cap.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -import pytest -from fastapi import HTTPException -from mcp_bybit.leverage_cap import enforce_leverage, get_max_leverage - - -def test_get_max_leverage_returns_creds_value(): - creds = {"max_leverage": 5} - assert get_max_leverage(creds) == 5 - - -def test_get_max_leverage_default_when_missing(): - """Default 1 (cash) se il secret non ha max_leverage.""" - assert get_max_leverage({}) == 1 - - -def test_enforce_leverage_pass_under_cap(): - creds = {"max_leverage": 3} - enforce_leverage(2, creds=creds, exchange="bybit") # no raise - - -def test_enforce_leverage_pass_at_cap(): - creds = {"max_leverage": 3} - enforce_leverage(3, creds=creds, exchange="bybit") # no raise - - -def test_enforce_leverage_reject_over_cap(): - creds = {"max_leverage": 3} - with pytest.raises(HTTPException) as exc: - enforce_leverage(10, creds=creds, exchange="bybit") - assert exc.value.status_code == 403 - assert exc.value.detail["error"] == "LEVERAGE_CAP_EXCEEDED" - assert exc.value.detail["exchange"] == "bybit" - assert exc.value.detail["requested"] == 10 - assert exc.value.detail["max"] == 3 - - -def test_enforce_leverage_reject_when_below_one(): - creds = {"max_leverage": 3} - with pytest.raises(HTTPException) as exc: - enforce_leverage(0, creds=creds, exchange="bybit") - assert exc.value.status_code == 403 - - -def test_enforce_leverage_default_when_none(): - """Se requested è None, applica il cap come default.""" - creds = {"max_leverage": 3} - result = enforce_leverage(None, creds=creds, exchange="bybit") - assert result == 3 diff --git a/services/mcp-bybit/tests/test_server_acl.py b/services/mcp-bybit/tests/test_server_acl.py deleted file mode 100644 index 1edf42b..0000000 --- a/services/mcp-bybit/tests/test_server_acl.py +++ /dev/null @@ -1,150 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest -from fastapi.testclient import TestClient -from mcp_bybit.server import create_app -from mcp_common.auth import Principal, TokenStore - - -@pytest.fixture -def token_store(): - return TokenStore( - tokens={ - "core-tok": Principal("core", {"core"}), - "obs-tok": Principal("observer", {"observer"}), - } - ) - - -@pytest.fixture -def mock_client(): - c = MagicMock() - c.get_ticker = AsyncMock(return_value={"symbol": "BTCUSDT"}) - c.get_ticker_batch = AsyncMock(return_value={"BTCUSDT": {}}) - c.get_orderbook = AsyncMock(return_value={"bids": [], "asks": []}) - c.get_historical = AsyncMock(return_value={"candles": []}) - c.get_indicators = AsyncMock(return_value={"rsi": 50.0}) - c.get_funding_rate = AsyncMock(return_value={"funding_rate": 0.0001}) - c.get_funding_history = AsyncMock(return_value={"history": []}) - c.get_open_interest = AsyncMock(return_value={"points": []}) - c.get_instruments = AsyncMock(return_value={"instruments": []}) - c.get_option_chain = AsyncMock(return_value={"options": []}) - c.get_positions = AsyncMock(return_value=[]) - c.get_account_summary = AsyncMock(return_value={"equity": 0}) - c.get_trade_history = AsyncMock(return_value=[]) - c.get_open_orders = AsyncMock(return_value=[]) - c.get_basis_spot_perp = AsyncMock(return_value={"basis_pct": 0}) - c.place_order = AsyncMock(return_value={"order_id": "x"}) - c.amend_order = AsyncMock(return_value={"order_id": "x"}) - c.cancel_order = AsyncMock(return_value={"status": "cancelled"}) - c.cancel_all_orders = AsyncMock(return_value={"cancelled_ids": []}) - c.set_stop_loss = AsyncMock(return_value={"status": "stop_loss_set"}) - c.set_take_profit = AsyncMock(return_value={"status": "take_profit_set"}) - c.close_position = AsyncMock(return_value={"status": "submitted"}) - c.set_leverage = AsyncMock(return_value={"status": "leverage_set"}) - c.switch_position_mode = AsyncMock(return_value={"status": "mode_switched"}) - c.transfer_asset = AsyncMock(return_value={"transfer_id": "tx"}) - c.place_combo_order = AsyncMock(return_value={"orders": [{"order_id": "ord-1"}, {"order_id": "ord-2"}]}) - c.get_orderbook_imbalance = AsyncMock(return_value={"imbalance_ratio": 0.0, "microprice": 100.0}) - c.get_basis_term_structure = AsyncMock(return_value={"asset": "BTC", "term_structure": []}) - return c - - -@pytest.fixture -def http(mock_client, token_store): - app = create_app(client=mock_client, token_store=token_store, creds={"max_leverage": 5}) - return TestClient(app) - - -CORE = {"Authorization": "Bearer core-tok"} -OBS = {"Authorization": "Bearer obs-tok"} - -READ_ENDPOINTS = [ - ("/tools/get_ticker", {"symbol": "BTCUSDT"}), - ("/tools/get_ticker_batch", {"symbols": ["BTCUSDT"]}), - ("/tools/get_orderbook", {"symbol": "BTCUSDT"}), - ("/tools/get_historical", {"symbol": "BTCUSDT"}), - ("/tools/get_indicators", {"symbol": "BTCUSDT"}), - ("/tools/get_funding_rate", {"symbol": "BTCUSDT"}), - ("/tools/get_funding_history", {"symbol": "BTCUSDT"}), - ("/tools/get_open_interest", {"symbol": "BTCUSDT"}), - ("/tools/get_instruments", {}), - ("/tools/get_option_chain", {"base_coin": "BTC"}), - ("/tools/get_positions", {}), - ("/tools/get_account_summary", {}), - ("/tools/get_trade_history", {}), - ("/tools/get_open_orders", {}), - ("/tools/get_basis_spot_perp", {"asset": "BTC"}), - ("/tools/get_orderbook_imbalance", {"symbol": "BTCUSDT"}), - ("/tools/get_basis_term_structure", {"asset": "BTC"}), -] - -WRITE_ENDPOINTS = [ - ("/tools/place_order", {"category": "linear", "symbol": "BTCUSDT", "side": "Buy", "qty": 0.01}), - ("/tools/amend_order", {"category": "linear", "symbol": "BTCUSDT", "order_id": "o1"}), - ("/tools/cancel_order", {"category": "linear", "symbol": "BTCUSDT", "order_id": "o1"}), - ("/tools/cancel_all_orders", {"category": "linear"}), - ("/tools/set_stop_loss", {"category": "linear", "symbol": "BTCUSDT", "stop_loss": 55000}), - ("/tools/set_take_profit", {"category": "linear", "symbol": "BTCUSDT", "take_profit": 65000}), - ("/tools/close_position", {"category": "linear", "symbol": "BTCUSDT"}), - ("/tools/set_leverage", {"category": "linear", "symbol": "BTCUSDT", "leverage": 5}), - ("/tools/switch_position_mode", {"category": "linear", "symbol": "BTCUSDT", "mode": "hedge"}), - ("/tools/transfer_asset", {"coin": "USDT", "amount": 10.0, "from_type": "UNIFIED", "to_type": "FUND"}), - ("/tools/place_combo_order", { - "category": "option", - "legs": [ - {"symbol": "BTC-30APR26-75000-C-USDT", "side": "Buy", "qty": 0.01, "order_type": "Limit", "price": 5.0}, - {"symbol": "BTC-30APR26-80000-C-USDT", "side": "Sell", "qty": 0.01, "order_type": "Limit", "price": 3.0}, - ], - }), -] - - -def test_place_combo_order_min_legs(http): - r = http.post( - "/tools/place_combo_order", - json={ - "category": "option", - "legs": [{"symbol": "X", "side": "Buy", "qty": 1, "order_type": "Limit", "price": 1.0}], - }, - headers=CORE, - ) - assert r.status_code == 422 - - -@pytest.mark.parametrize("path,payload", READ_ENDPOINTS) -def test_read_core_ok(http, path, payload): - r = http.post(path, json=payload, headers=CORE) - assert r.status_code == 200, (path, r.text) - - -@pytest.mark.parametrize("path,payload", READ_ENDPOINTS) -def test_read_observer_ok(http, path, payload): - r = http.post(path, json=payload, headers=OBS) - assert r.status_code == 200, (path, r.text) - - -@pytest.mark.parametrize("path,payload", READ_ENDPOINTS) -def test_read_no_auth_401(http, path, payload): - r = http.post(path, json=payload) - assert r.status_code == 401, (path, r.text) - - -@pytest.mark.parametrize("path,payload", WRITE_ENDPOINTS) -def test_write_core_ok(http, path, payload): - r = http.post(path, json=payload, headers=CORE) - assert r.status_code == 200, (path, r.text) - - -@pytest.mark.parametrize("path,payload", WRITE_ENDPOINTS) -def test_write_observer_403(http, path, payload): - r = http.post(path, json=payload, headers=OBS) - assert r.status_code == 403, (path, r.text) - - -@pytest.mark.parametrize("path,payload", WRITE_ENDPOINTS) -def test_write_no_auth_401(http, path, payload): - r = http.post(path, json=payload) - assert r.status_code == 401, (path, r.text) diff --git a/services/mcp-deribit/pyproject.toml b/services/mcp-deribit/pyproject.toml deleted file mode 100644 index 7055a14..0000000 --- a/services/mcp-deribit/pyproject.toml +++ /dev/null @@ -1,27 +0,0 @@ -[project] -name = "mcp-deribit" -version = "0.1.0" -requires-python = ">=3.11" -dependencies = [ - "mcp-common", - "fastapi>=0.115", - "uvicorn[standard]>=0.30", - "httpx>=0.27", - "pydantic>=2.6", -] - -[project.optional-dependencies] -dev = ["pytest>=8", "pytest-asyncio>=0.23", "pytest-httpx>=0.30"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/mcp_deribit"] - -[tool.uv.sources] -mcp-common = { workspace = true } - -[project.scripts] -mcp-deribit = "mcp_deribit.__main__:main" diff --git a/services/mcp-deribit/src/mcp_deribit/__init__.py b/services/mcp-deribit/src/mcp_deribit/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/mcp-deribit/src/mcp_deribit/__main__.py b/services/mcp-deribit/src/mcp_deribit/__main__.py deleted file mode 100644 index 2f78db8..0000000 --- a/services/mcp-deribit/src/mcp_deribit/__main__.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from mcp_common.app_factory import ExchangeAppSpec, run_exchange_main - -from mcp_deribit.client import DeribitClient -from mcp_deribit.server import create_app - -SPEC = ExchangeAppSpec( - exchange="deribit", - creds_env_var="CREDENTIALS_FILE", - env_var="DERIBIT_TESTNET", - flag_key="testnet", - default_base_url_live="https://www.deribit.com/api/v2", - default_base_url_testnet="https://test.deribit.com/api/v2", - default_port=9011, - build_client=lambda creds, env_info: DeribitClient( - client_id=creds["client_id"], - client_secret=creds["client_secret"], - testnet=(env_info.environment == "testnet"), - ), - build_app=create_app, -) - - -def main(): - run_exchange_main(SPEC) - - -if __name__ == "__main__": - main() diff --git a/services/mcp-deribit/src/mcp_deribit/client.py b/services/mcp-deribit/src/mcp_deribit/client.py deleted file mode 100644 index cc231a1..0000000 --- a/services/mcp-deribit/src/mcp_deribit/client.py +++ /dev/null @@ -1,1673 +0,0 @@ -from __future__ import annotations - -import contextlib -import time -from dataclasses import dataclass, field -from typing import Any - -from mcp_common import indicators as ind -from mcp_common import microstructure as micro -from mcp_common import options as opt -from mcp_common.http import async_client - -BASE_LIVE = "https://www.deribit.com/api/v2" -BASE_TESTNET = "https://test.deribit.com/api/v2" - -RESOLUTION_MAP = { - "1m": "1", - "5m": "5", - "15m": "15", - "1h": "60", - "4h": "240", - "1d": "1D", -} - - -@dataclass -class DeribitClient: - client_id: str - client_secret: str - testnet: bool = True - _token: str | None = field(default=None, init=False, repr=False) - _token_expires_at: float = field(default=0.0, init=False, repr=False) - - @property - def base_url(self) -> str: - return BASE_TESTNET if self.testnet else BASE_LIVE - - # ── Auth ───────────────────────────────────────────────────── - - async def _authenticate(self) -> str: - url = f"{self.base_url}/public/auth" - params = { - "grant_type": "client_credentials", - "client_id": self.client_id, - "client_secret": self.client_secret, - } - async with async_client(timeout=15.0) as http: - resp = await http.get(url, params=params) - data = resp.json() - result = data["result"] - self._token = result["access_token"] - self._token_expires_at = time.monotonic() + result.get("expires_in", 900) - 30 - return self._token - - async def _get_token(self) -> str: - if self._token is None or time.monotonic() >= self._token_expires_at: - await self._authenticate() - return self._token # type: ignore[return-value] - - async def _request(self, method: str, params: dict[str, Any] | None = None) -> dict: - is_private = method.startswith("private/") - if is_private: - await self._get_token() - - url = f"{self.base_url}/{method}" - request_params = dict(params) if params else {} - headers: dict[str, str] = {} - if is_private and self._token: - headers["Authorization"] = f"Bearer {self._token}" - - async with async_client(timeout=15.0) as http: - resp = await http.get(url, params=request_params, headers=headers) - data = resp.json() - - if "result" not in data: - error = data.get("error", {}) - error_code = error.get("code", 0) if isinstance(error, dict) else 0 - error_msg = error.get("message", str(data)) if isinstance(error, dict) else str(error) - # Re-authenticate on auth errors and retry once - if is_private and error_code in (13004, 13009, 13015): - self._token = None - await self._authenticate() - headers["Authorization"] = f"Bearer {self._token}" - resp = await http.get(url, params=request_params, headers=headers) - data = resp.json() - if "result" in data: - return data - return {"result": None, "error": error_msg} - - return data - - # ── Read tools ─────────────────────────────────────────────── - - def is_testnet(self) -> dict: - return {"testnet": self.testnet, "base_url": self.base_url} - - async def get_ticker(self, instrument_name: str) -> dict: - import datetime as _dt - raw = await self._request("public/ticker", {"instrument_name": instrument_name}) - r = raw.get("result") or {} - is_perp = instrument_name.upper().endswith("-PERPETUAL") - greeks = r.get("greeks") - if is_perp and greeks is None: - greeks = {"delta": 1.0, "gamma": 0.0, "vega": 0.0, "theta": 0.0, "rho": 0.0} - return { - "instrument_name": instrument_name, - "last_price": r.get("last_price"), - "mark_price": r.get("mark_price"), - "bid": r.get("best_bid_price"), - "ask": r.get("best_ask_price"), - "volume_24h": r.get("stats", {}).get("volume"), - "open_interest": r.get("open_interest"), - "greeks": greeks, - "mark_iv": r.get("mark_iv"), - "testnet": self.testnet, - "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), - } - - async def get_ticker_batch(self, instrument_names: list[str]) -> dict: - """Fetch multiple tickers in parallel (max 20).""" - import asyncio - import datetime as _dt - - if not instrument_names: - return {"tickers": [], "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat()} - if len(instrument_names) > 20: - return {"error": "max 20 instruments per batch"} - - results = await asyncio.gather( - *[self.get_ticker(n) for n in instrument_names], - return_exceptions=True, - ) - tickers = [] - errors = [] - for name, res in zip(instrument_names, results, strict=True): - if isinstance(res, Exception): - errors.append({"instrument": name, "error": str(res)}) - else: - tickers.append(res) - return { - "tickers": tickers, - "errors": errors, - "count": len(tickers), - "testnet": self.testnet, - "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), - } - - async def get_instruments( - self, - currency: str, - kind: str | None = None, - expired: bool = False, - expiry_from: str | None = None, - expiry_to: str | None = None, - strike_min: float | None = None, - strike_max: float | None = None, - min_open_interest: float | None = None, - limit: int = 100, - offset: int = 0, - ) -> dict: - import asyncio - import datetime as _dt - - def _parse_iso(s: str | None) -> int | None: - if not s: - return None - try: - dt = _dt.datetime.fromisoformat(s) - except ValueError: - dt = _dt.datetime.strptime(s, "%Y-%m-%d") - return int(dt.timestamp() * 1000) - - expiry_from_ms = _parse_iso(expiry_from) - expiry_to_ms = _parse_iso(expiry_to) - - params: dict[str, Any] = {"currency": currency, "expired": expired} - if kind: - params["kind"] = kind - - # CER-008: fetch metadata + book_summary in parallelo per popolare OI. - # `public/get_instruments` non include open_interest; book_summary sì. - summary_params: dict[str, Any] = {"currency": currency} - if kind: - summary_params["kind"] = kind - instruments_raw, summary_raw = await asyncio.gather( - self._request("public/get_instruments", params), - self._request("public/get_book_summary_by_currency", summary_params), - return_exceptions=True, - ) - raw = instruments_raw if isinstance(instruments_raw, dict) else {} - summary_items = ( - summary_raw.get("result") if isinstance(summary_raw, dict) else None - ) or [] - oi_by_name: dict[str, float] = {} - for s in summary_items: - name = s.get("instrument_name") - oi = s.get("open_interest") - if name and oi is not None: - with contextlib.suppress(TypeError, ValueError): - oi_by_name[name] = float(oi) - - all_items = raw.get("result") or [] - filtered: list[dict] = [] - for i in all_items: - exp_ms = i.get("expiration_timestamp") - strike = i.get("strike") - name = i.get("instrument_name") - # CER-008: popola OI dal book_summary se mancante - oi = i.get("open_interest") - if oi is None and name in oi_by_name: - oi = oi_by_name[name] - i["open_interest"] = oi - if expiry_from_ms is not None and exp_ms is not None and exp_ms < expiry_from_ms: - continue - if expiry_to_ms is not None and exp_ms is not None and exp_ms > expiry_to_ms: - continue - if strike_min is not None and strike is not None and strike < strike_min: - continue - if strike_max is not None and strike is not None and strike > strike_max: - continue - if ( - min_open_interest is not None - and oi is not None - and oi < min_open_interest - ): - continue - filtered.append(i) - - total = len(filtered) - page = filtered[offset : offset + limit] - instruments = [ - { - "name": i.get("instrument_name"), - "strike": i.get("strike"), - "expiry": i.get("expiration_timestamp"), - "option_type": i.get("option_type"), - "tick_size": i.get("tick_size"), - "min_trade_amount": i.get("min_trade_amount"), - "open_interest": i.get("open_interest"), - } - for i in page - ] - return { - "instruments": instruments, - "total": total, - "offset": offset, - "limit": limit, - "has_more": offset + limit < total, - "testnet": self.testnet, - } - - async def get_orderbook(self, instrument_name: str, depth: int = 10) -> dict: - import datetime as _dt - raw = await self._request( - "public/get_order_book", {"instrument_name": instrument_name, "depth": depth} - ) - r = raw.get("result") or {} - return { - "bids": r.get("bids", []), - "asks": r.get("asks", []), - "timestamp": r.get("timestamp"), - "testnet": self.testnet, - "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), - } - - async def get_orderbook_imbalance(self, instrument_name: str, depth: int = 10) -> dict: - """Microstructure: bid/ask imbalance + microprice + slope su top-N livelli.""" - ob = await self.get_orderbook(instrument_name, depth=max(depth, 10)) - result = micro.orderbook_imbalance(ob.get("bids") or [], ob.get("asks") or [], depth=depth) - return { - "instrument_name": instrument_name, - "depth": depth, - **result, - "timestamp": ob.get("timestamp"), - "testnet": self.testnet, - } - - async def get_positions(self, currency: str = "USDC") -> list: - raw = await self._request("private/get_positions", {"currency": currency}) - result = raw.get("result") or [] - positions = [] - for p in result: - size = p.get("size", 0) - if size == 0: - continue - positions.append({ - "instrument": p.get("instrument_name"), - "size": abs(size), - "direction": "long" if size > 0 else "short", - "avg_price": p.get("average_price"), - "mark_price": p.get("mark_price"), - "unrealized_pnl": p.get("floating_profit_loss"), - "realized_pnl": p.get("realized_profit_loss"), - "leverage": p.get("leverage"), - }) - return positions - - async def get_account_summary(self, currency: str = "USDC") -> dict: - raw = await self._request("private/get_account_summary", {"currency": currency}) - r = raw.get("result") - if not r: - return { - "equity": 0, - "balance": 0, - "margin_balance": 0, - "available_funds": 0, - "unrealized_pnl": 0, - "total_pnl": 0, - "testnet": self.testnet, - "error": raw.get("error", "no result"), - } - return { - "equity": r.get("equity", 0), - "balance": r.get("balance", 0), - "margin_balance": r.get("margin_balance", 0), - "available_funds": r.get("available_funds", 0), - "unrealized_pnl": r.get("unrealized_pnl", 0), - "total_pnl": r.get("total_pnl", 0), - "testnet": self.testnet, - } - - async def get_trade_history( - self, limit: int = 100, instrument_name: str | None = None - ) -> list: - if instrument_name: - raw = await self._request( - "private/get_user_trades_by_instrument", - {"instrument_name": instrument_name, "count": limit}, - ) - else: - raw = await self._request( - "private/get_user_trades_by_currency", - {"currency": "BTC", "count": limit}, - ) - result = raw.get("result") or {} - if not isinstance(result, dict): - return [] - trades_raw = result.get("trades") or [] - trades = [] - for t in trades_raw: - if not isinstance(t, dict): - continue - trades.append({ - "instrument": t.get("instrument_name"), - "direction": t.get("direction"), - "price": t.get("price"), - "amount": t.get("amount"), - "fee": t.get("fee"), - "timestamp": t.get("timestamp"), - "order_id": t.get("order_id"), - }) - return trades - - async def get_historical( - self, instrument: str, start_date: str, end_date: str, resolution: str - ) -> dict: - # Convert ISO date strings to millisecond timestamps - import datetime as _dt - - def _to_ms(date_str: str) -> int: - try: - dt = _dt.datetime.fromisoformat(date_str) - except ValueError: - dt = _dt.datetime.strptime(date_str, "%Y-%m-%d") - return int(dt.timestamp() * 1000) - - start_ms = _to_ms(start_date) - end_ms = _to_ms(end_date) - res = RESOLUTION_MAP.get(resolution, resolution) - raw = await self._request( - "public/get_tradingview_chart_data", - { - "instrument_name": instrument, - "start_timestamp": start_ms, - "end_timestamp": end_ms, - "resolution": res, - }, - ) - r = raw.get("result") or {} - candles = [] - ticks = r.get("ticks", []) or [] - opens = r.get("open", []) or [] - highs = r.get("high", []) or [] - lows = r.get("low", []) or [] - closes = r.get("close", []) or [] - volumes = r.get("volume", []) or [] - for idx, ts in enumerate(ticks): - if idx >= min(len(opens), len(highs), len(lows), len(closes), len(volumes)): - break - candles.append({ - "timestamp": ts, - "open": opens[idx], - "high": highs[idx], - "low": lows[idx], - "close": closes[idx], - "volume": volumes[idx], - }) - return {"candles": candles} - - async def get_dvol( - self, - currency: str, - start_date: str, - end_date: str, - resolution: str = "1D", - ) -> dict: - import datetime as _dt - - def _to_ms(date_str: str) -> int: - try: - dt = _dt.datetime.fromisoformat(date_str) - except ValueError: - dt = _dt.datetime.strptime(date_str, "%Y-%m-%d") - return int(dt.timestamp() * 1000) - - start_ms = _to_ms(start_date) - end_ms = _to_ms(end_date) - res = RESOLUTION_MAP.get(resolution, resolution) - raw = await self._request( - "public/get_volatility_index_data", - { - "currency": currency.upper(), - "start_timestamp": start_ms, - "end_timestamp": end_ms, - "resolution": res, - }, - ) - r = raw.get("result") or {} - rows = r.get("data") or [] - candles = [ - { - "timestamp": row[0], - "open": row[1], - "high": row[2], - "low": row[3], - "close": row[4], - } - for row in rows - if len(row) >= 5 - ] - latest = candles[-1]["close"] if candles else None - return {"currency": currency.upper(), "latest": latest, "candles": candles} - - async def get_gex( - self, - currency: str, - expiry_from: str | None = None, - expiry_to: str | None = None, - top_n_strikes: int = 50, - ) -> dict: - import asyncio - import datetime as _dt - - currency = currency.upper() - try: - idx_tk = await self.get_ticker(f"{currency}-PERPETUAL") - spot = float(idx_tk.get("mark_price") or 0) - except Exception: - spot = 0.0 - - chain = await self.get_instruments( - currency=currency, - kind="option", - expiry_from=expiry_from, - expiry_to=expiry_to, - limit=2000, - ) - items = chain.get("instruments", []) - items.sort(key=lambda x: -(x.get("open_interest") or 0)) - top = items[:top_n_strikes] - - async def _ticker(name: str) -> dict: - try: - return await self.get_ticker(name) - except Exception: - return {} - - tickers = await asyncio.gather(*[_ticker(i["name"]) for i in top]) - - by_strike: dict[float, dict[str, float]] = {} - for meta, tk in zip(top, tickers, strict=True): - strike = meta.get("strike") - if strike is None: - continue - greeks = tk.get("greeks") or {} - gamma = greeks.get("gamma") - oi = meta.get("open_interest") or 0 - if gamma is None or spot <= 0: - continue - gex_contribution = float(gamma) * oi * (spot ** 2) * 0.01 - entry = by_strike.setdefault( - float(strike), {"strike": float(strike), "call_gex": 0.0, "put_gex": 0.0} - ) - if meta.get("option_type") == "call": - entry["call_gex"] += gex_contribution - else: - entry["put_gex"] -= gex_contribution - - rows = [] - for s in sorted(by_strike.keys()): - e = by_strike[s] - e["net_gex"] = e["call_gex"] + e["put_gex"] - rows.append(e) - - total_gex = sum(r["net_gex"] for r in rows) - zero_gamma = None - for a, b in zip(rows, rows[1:], strict=False): - if (a["net_gex"] < 0 <= b["net_gex"]) or (a["net_gex"] > 0 >= b["net_gex"]): - denom = b["net_gex"] - a["net_gex"] - if denom != 0: - frac = -a["net_gex"] / denom - zero_gamma = round(a["strike"] + frac * (b["strike"] - a["strike"]), 2) - break - - max_gex_level = max(rows, key=lambda r: r["net_gex"])["strike"] if rows else None - min_gex_level = min(rows, key=lambda r: r["net_gex"])["strike"] if rows else None - - return { - "currency": currency, - "expiry_from": expiry_from, - "expiry_to": expiry_to, - "spot_price": spot, - "total_gex_usd": round(total_gex, 2), - "zero_gamma_level": zero_gamma, - "gex_by_strike": [ - { - "strike": r["strike"], - "call_gex": round(r["call_gex"], 2), - "put_gex": round(r["put_gex"], 2), - "net_gex": round(r["net_gex"], 2), - } - for r in rows - ], - "max_gex_level": max_gex_level, - "min_gex_level": min_gex_level, - "strikes_analyzed": len(rows), - "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), - "testnet": self.testnet, - } - - async def _fetch_chain_legs( - self, - currency: str, - expiry_from: str | None = None, - expiry_to: str | None = None, - top_n_strikes: int = 50, - ) -> tuple[float, list[dict[str, Any]]]: - """Fetch chain options + ticker per top-N strikes per OI; restituisce - (spot, legs[]) con campi normalizzati per le funzioni in mcp_common.options. - """ - import asyncio - - currency = currency.upper() - try: - idx_tk = await self.get_ticker(f"{currency}-PERPETUAL") - spot = float(idx_tk.get("mark_price") or 0) - except Exception: - spot = 0.0 - - chain = await self.get_instruments( - currency=currency, - kind="option", - expiry_from=expiry_from, - expiry_to=expiry_to, - limit=2000, - ) - items = chain.get("instruments", []) - items.sort(key=lambda x: -(x.get("open_interest") or 0)) - top = items[:top_n_strikes] - - async def _ticker(name: str) -> dict: - try: - return await self.get_ticker(name) - except Exception: - return {} - - tickers = await asyncio.gather(*[_ticker(i["name"]) for i in top]) - legs: list[dict[str, Any]] = [] - for meta, tk in zip(top, tickers, strict=True): - greeks = tk.get("greeks") or {} - legs.append({ - "strike": meta.get("strike"), - "option_type": meta.get("option_type"), - "oi": meta.get("open_interest") or 0, - "iv": tk.get("mark_iv"), - "delta": greeks.get("delta"), - "gamma": greeks.get("gamma"), - "vanna": greeks.get("vanna"), - "charm": greeks.get("charm"), - "vega": greeks.get("vega"), - }) - return spot, legs - - async def get_dealer_gamma_profile( - self, - currency: str, - expiry_from: str | None = None, - expiry_to: str | None = None, - top_n_strikes: int = 50, - ) -> dict: - """Net dealer gamma per strike (assume dealer short calls/long puts). - Identifica il gamma flip level: sopra → mercato pinning, sotto → squeeze. - """ - import datetime as _dt - spot, legs = await self._fetch_chain_legs(currency, expiry_from, expiry_to, top_n_strikes) - result = opt.dealer_gamma_profile(legs, spot) - return { - "currency": currency.upper(), - "spot_price": spot, - **result, - "strikes_analyzed": len(result["by_strike"]), - "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), - "testnet": self.testnet, - } - - async def get_vanna_charm( - self, - currency: str, - expiry_from: str | None = None, - expiry_to: str | None = None, - top_n_strikes: int = 50, - ) -> dict: - """Vanna (∂delta/∂IV) e Charm (∂delta/∂t) aggregati pesati per OI. - Vanna positiva: dealer compra spot quando IV sale. - Charm negativa: time decay erode delta hedging. - """ - import datetime as _dt - spot, legs = await self._fetch_chain_legs(currency, expiry_from, expiry_to, top_n_strikes) - result = opt.vanna_charm_aggregate(legs, spot) - return { - "currency": currency.upper(), - **result, - "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), - "testnet": self.testnet, - } - - async def get_oi_weighted_skew( - self, - currency: str, - expiry_from: str | None = None, - expiry_to: str | None = None, - top_n_strikes: int = 100, - ) -> dict: - """Skew aggregato pesato OI: IV media puts - calls. Positivo = paura. - """ - import datetime as _dt - _, legs = await self._fetch_chain_legs(currency, expiry_from, expiry_to, top_n_strikes) - result = opt.oi_weighted_skew(legs) - return { - "currency": currency.upper(), - **result, - "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), - "testnet": self.testnet, - } - - async def get_smile_asymmetry( - self, - currency: str, - expiry_from: str | None = None, - expiry_to: str | None = None, - top_n_strikes: int = 100, - ) -> dict: - """Asymmetry IV otm-puts vs otm-calls. Positivo = put-side richer.""" - import datetime as _dt - spot, legs = await self._fetch_chain_legs(currency, expiry_from, expiry_to, top_n_strikes) - result = opt.smile_asymmetry(legs, spot) - return { - "currency": currency.upper(), - "spot_price": spot, - **result, - "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), - "testnet": self.testnet, - } - - async def get_atm_vs_wings_vol( - self, - currency: str, - expiry_from: str | None = None, - expiry_to: str | None = None, - top_n_strikes: int = 100, - ) -> dict: - """IV ATM vs IV alle ali 25-delta. wing_richness > 0 → smile (kurtosis).""" - import datetime as _dt - spot, legs = await self._fetch_chain_legs(currency, expiry_from, expiry_to, top_n_strikes) - result = opt.atm_vs_wings_vol(legs, spot) - return { - "currency": currency.upper(), - "spot_price": spot, - **result, - "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), - "testnet": self.testnet, - } - - async def get_pc_ratio(self, currency: str) -> dict: - import datetime as _dt - - currency = currency.upper() - raw = await self._request( - "public/get_book_summary_by_currency", - {"currency": currency, "kind": "option"}, - ) - rows = raw.get("result") or [] - call_oi = 0.0 - put_oi = 0.0 - call_vol = 0.0 - put_vol = 0.0 - for r in rows: - name = r.get("instrument_name", "") - oi = r.get("open_interest") or 0 - vol = r.get("volume") or 0 - if name.endswith("-C"): - call_oi += oi - call_vol += vol - elif name.endswith("-P"): - put_oi += oi - put_vol += vol - - def _ratio(put: float, call: float) -> float | None: - return round(put / call, 4) if call > 0 else None - - def _interp(ratio: float | None) -> str: - if ratio is None: - return "insufficient_data" - if ratio > 1.0: - return "puts_dominant" - if ratio < 0.7: - return "calls_dominant" - return "balanced" - - pc_oi = _ratio(put_oi, call_oi) - pc_vol = _ratio(put_vol, call_vol) - - return { - "currency": currency, - "pc_ratio_oi": pc_oi, - "pc_ratio_volume_24h": pc_vol, - "total_call_oi": call_oi, - "total_put_oi": put_oi, - "total_call_volume_24h": call_vol, - "total_put_volume_24h": put_vol, - "interpretation": { - "oi": _interp(pc_oi), - "volume_24h": _interp(pc_vol), - "percentile_90d": None, - }, - "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), - "testnet": self.testnet, - } - - async def get_skew_25d(self, currency: str, expiry: str) -> dict: - import asyncio - import datetime as _dt - - currency = currency.upper() - put_task = self.find_by_delta(currency, expiry, -0.25, "put", 1, 0, 0) - call_task = self.find_by_delta(currency, expiry, 0.25, "call", 1, 0, 0) - try: - idx_tk = await self.get_ticker(f"{currency}-PERPETUAL") - spot = float(idx_tk.get("mark_price") or 0) - except Exception: - spot = 0.0 - put_res, call_res = await asyncio.gather(put_task, call_task) - - put_best = put_res.get("best_match") or {} - call_best = call_res.get("best_match") or {} - put_iv = put_best.get("mark_iv") - call_iv = call_best.get("mark_iv") - - # ATM IV: find instrument closest to spot on this expiry - exp_dt = _dt.datetime.fromisoformat(expiry) if "T" in expiry or "-" in expiry else _dt.datetime.strptime(expiry, "%Y-%m-%d") - chain = await self.get_instruments( - currency=currency, - kind="option", - expiry_from=expiry, - expiry_to=(exp_dt + _dt.timedelta(days=1)).strftime("%Y-%m-%d"), - limit=500, - ) - items = chain.get("instruments", []) - atm_iv = None - if items and spot > 0: - call_items = [i for i in items if i.get("option_type") == "call"] - if call_items: - call_items.sort(key=lambda x: abs((x.get("strike") or 0) - spot)) - atm_tk = await self.get_ticker(call_items[0]["name"]) - atm_iv = atm_tk.get("mark_iv") - - skew = None - if put_iv is not None and call_iv is not None: - skew = round(put_iv - call_iv, 4) - - if skew is None: - skew_sign = "unknown" - elif skew > 1: - skew_sign = "puts_rich" - elif skew < -1: - skew_sign = "calls_rich" - else: - skew_sign = "neutral" - - butterfly = None - if put_iv is not None and call_iv is not None and atm_iv is not None: - butterfly = round((put_iv + call_iv) / 2 - atm_iv, 4) - - return { - "currency": currency, - "expiry": expiry, - "put_25d_iv": put_iv, - "call_25d_iv": call_iv, - "atm_iv": atm_iv, - "skew_25d": skew, - "skew_sign": skew_sign, - "risk_reversal_25d": round(-skew, 4) if skew is not None else None, - "butterfly_25d": butterfly, - "skew_percentile_90d": None, - "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), - "testnet": self.testnet, - } - - async def get_term_structure(self, currency: str) -> dict: - import asyncio - import datetime as _dt - - currency = currency.upper() - try: - idx_tk = await self.get_ticker(f"{currency}-PERPETUAL") - spot = float(idx_tk.get("mark_price") or 0) - except Exception: - spot = 0.0 - - chain = await self.get_instruments( - currency=currency, kind="option", limit=2000, offset=0 - ) - items = chain.get("instruments", []) - now_ms = int(_dt.datetime.now(_dt.UTC).timestamp() * 1000) - by_exp: dict[int, list[dict]] = {} - for i in items: - exp = i.get("expiry") - if exp is None or exp < now_ms: - continue - by_exp.setdefault(int(exp), []).append(i) - - atm_names: list[tuple[int, str]] = [] - for exp_ms, ins in sorted(by_exp.items()): - if spot > 0: - ins.sort(key=lambda x: abs((x.get("strike") or 0) - spot)) - atm_names.append((exp_ms, ins[0]["name"])) - - async def _ticker(name: str) -> dict: - try: - return await self.get_ticker(name) - except Exception: - return {} - - tickers = await asyncio.gather(*[_ticker(n) for _, n in atm_names]) - - ts = [] - for (exp_ms, name), tk in zip(atm_names, tickers, strict=True): - iv = tk.get("mark_iv") - if iv is None: - continue - exp_dt = _dt.datetime.fromtimestamp(exp_ms / 1000, _dt.UTC) - dte = max(0, (exp_dt - _dt.datetime.now(_dt.UTC)).days) - ts.append({ - "expiry": exp_dt.strftime("%Y-%m-%d"), - "dte": dte, - "atm_iv": iv, - "atm_instrument": name, - }) - - shape = "flat" - contango_steep = False - calendar_opp = False - if len(ts) >= 2: - diffs = [ts[i + 1]["atm_iv"] - ts[i]["atm_iv"] for i in range(len(ts) - 1)] - up = sum(1 for d in diffs if d > 0) - down = sum(1 for d in diffs if d < 0) - if up > len(diffs) / 2: - shape = "contango" - elif down > len(diffs) / 2: - shape = "backwardation" - short_term = next((x for x in ts if 8 <= x["dte"] <= 14), None) - mid_term = next((x for x in ts if 35 <= x["dte"] <= 45), None) - if short_term and mid_term and mid_term["atm_iv"] - short_term["atm_iv"] > 5: - contango_steep = True - calendar_opp = True - - return { - "currency": currency, - "spot": spot, - "term_structure": ts, - "shape": shape, - "contango_steep": contango_steep, - "calendar_spread_opportunity": calendar_opp, - "testnet": self.testnet, - } - - async def run_backtest( - self, - strategy_name: str, - underlying: str = "BTC", - lookback_days: int = 30, - resolution: str = "4h", - entry_rules: dict | None = None, - exit_rules: dict | None = None, - ) -> dict: - """Heuristic backtest: OHLCV lookback + simple rule-based entry/exit. - Approximate; does not simulate full options chain. - """ - import datetime as _dt - - end = _dt.datetime.now(_dt.UTC) - start = end - _dt.timedelta(days=lookback_days) - historical = await self.get_historical( - f"{underlying.upper()}-PERPETUAL", - start.strftime("%Y-%m-%d"), - end.strftime("%Y-%m-%d"), - resolution, - ) - candles = historical.get("candles", []) - if len(candles) < 20: - return { - "strategy_name": strategy_name, - "error": "insufficient history", - "trades_simulated": 0, - "recommendation": "reject", - } - - closes = [c["close"] for c in candles] - from mcp_common import indicators as _ind - - rsi_thr_low = (entry_rules or {}).get("rsi_below", 35) - rsi_thr_high = (entry_rules or {}).get("rsi_above", 65) - target_pct = (exit_rules or {}).get("target_pct", 2.0) - stop_pct = (exit_rules or {}).get("stop_pct", -1.5) - max_hold_bars = (exit_rules or {}).get("max_hold_bars", 20) - - trades: list[dict] = [] - i = 14 - while i < len(closes) - 1: - sub = closes[: i + 1] - rsi_val = _ind.rsi(sub) - if rsi_val is None: - i += 1 - continue - side: str | None = None - if rsi_val < rsi_thr_low: - side = "long" - elif rsi_val > rsi_thr_high: - side = "short" - if side is None: - i += 1 - continue - entry = closes[i] - exit_bar = None - exit_price = entry - for j in range(1, min(max_hold_bars, len(closes) - i)): - p = closes[i + j] - pct = (p - entry) / entry * 100 * (1 if side == "long" else -1) - if pct >= target_pct or pct <= stop_pct: - exit_bar = j - exit_price = p - break - if exit_bar is None: - exit_bar = min(max_hold_bars, len(closes) - i - 1) - exit_price = closes[i + exit_bar] - pnl_pct = (exit_price - entry) / entry * 100 * (1 if side == "long" else -1) - trades.append({ - "entry_idx": i, - "side": side, - "entry": entry, - "exit": exit_price, - "bars_held": exit_bar, - "pnl_pct": pnl_pct, - }) - i += exit_bar + 1 - - if not trades: - return { - "strategy_name": strategy_name, - "trades_simulated": 0, - "recommendation": "reject", - "notes": "no entries triggered", - } - - wins = [t for t in trades if t["pnl_pct"] > 0] - losses = [t for t in trades if t["pnl_pct"] <= 0] - win_rate = len(wins) / len(trades) - total_profit = sum(t["pnl_pct"] for t in wins) - total_loss = -sum(t["pnl_pct"] for t in losses) - profit_factor = (total_profit / total_loss) if total_loss > 0 else float("inf") - equity = 0.0 - peak = 0.0 - max_dd = 0.0 - for t in trades: - equity += t["pnl_pct"] - peak = max(peak, equity) - dd = peak - equity - max_dd = max(max_dd, dd) - mean_r = sum(t["pnl_pct"] for t in trades) / len(trades) - var_r = sum((t["pnl_pct"] - mean_r) ** 2 for t in trades) / len(trades) - std_r = var_r ** 0.5 - sharpe = round(mean_r / std_r, 2) if std_r > 0 else None - - recommendation = "reject" - if win_rate > 0.55 and max_dd < 4.0 and profit_factor > 1.5: - recommendation = "accept" - elif win_rate > 0.50 and max_dd < 5.0 and profit_factor > 1.35: - recommendation = "marginal" - - return { - "strategy_name": strategy_name, - "underlying": underlying.upper(), - "lookback_days": lookback_days, - "resolution": resolution, - "trades_simulated": len(trades), - "win_rate": round(win_rate, 3), - "max_drawdown_pct": round(max_dd, 2), - "profit_factor": round(profit_factor, 2) if profit_factor != float("inf") else None, - "sharpe": sharpe, - "recommendation": recommendation, - "notes": "heuristic RSI-based backtest — approximate, not full options simulation", - "testnet": self.testnet, - "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), - } - - async def calculate_spread_payoff( - self, - legs: list[dict], - quote_currency: str = "USD", - ) -> dict: - import asyncio - import re as _re - - if not legs or len(legs) > 4: - return {"error": "legs must be 1..4"} - - async def _fetch(name: str) -> dict: - try: - return await self.get_ticker(name) - except Exception as e: - return {"error": str(e)} - - tickers = await asyncio.gather(*[_fetch(l["instrument_name"]) for l in legs]) - - enriched: list[dict] = [] - spot_est: float | None = None - for leg, tk in zip(legs, tickers, strict=True): - name = leg["instrument_name"] - match = _re.match( - r"^([A-Z]+)-(\d{1,2}[A-Z]{3}\d{2})-(\d+(?:\.\d+)?)-(C|P)$", name - ) - if not match: - return {"error": f"invalid option name: {name}"} - coin, exp_str, strike_s, opt_type = match.groups() - strike = float(strike_s) - action = leg.get("action", "long").lower() - qty = float(leg.get("quantity", 1)) - sign = 1 if action == "long" else -1 - mark_price = tk.get("mark_price") or 0.0 - greeks = tk.get("greeks") or {} - if spot_est is None and mark_price: - spot_est = float(strike) - enriched.append({ - "name": name, - "coin": coin, - "expiry": exp_str, - "strike": strike, - "type": opt_type, - "action": action, - "quantity": qty, - "sign": sign, - "mark_price": float(mark_price), - "greeks": greeks, - }) - - strikes = [l["strike"] for l in enriched] - spot = spot_est or (sum(strikes) / len(strikes)) if strikes else 0.0 - if enriched and enriched[0]["coin"]: - try: - idx_name = f"{enriched[0]['coin']}-PERPETUAL" - idx_tk = await self.get_ticker(idx_name) - if idx_tk.get("mark_price"): - spot = float(idx_tk["mark_price"]) - except Exception: - pass - - net_premium = sum(l["sign"] * l["quantity"] * l["mark_price"] for l in enriched) - credit = net_premium < 0 - - greeks_net = {k: 0.0 for k in ("delta", "gamma", "vega", "theta", "rho")} - for l in enriched: - for k in greeks_net: - v = l["greeks"].get(k) - if v is not None: - greeks_net[k] += l["sign"] * l["quantity"] * float(v) - - def _payoff(s: float) -> float: - total = 0.0 - for l in enriched: - k = l["strike"] - intrinsic = max(0.0, s - k) if l["type"] == "C" else max(0.0, k - s) - total += l["sign"] * l["quantity"] * (intrinsic - l["mark_price"]) - return total - - lo = max(spot * 0.5, min(strikes) * 0.7) if strikes else spot * 0.5 - hi = max(spot * 1.5, max(strikes) * 1.3) if strikes else spot * 1.5 - steps = 14 - points = [] - for i in range(steps + 1): - s = lo + (hi - lo) * (i / steps) - points.append({"spot": round(s, 2), "pnl": round(_payoff(s), 4)}) - key_points = sorted({int(lo), int(spot), int(hi), *(int(k) for k in strikes)}) - for s in key_points: - points.append({"spot": float(s), "pnl": round(_payoff(float(s)), 4)}) - points.sort(key=lambda p: p["spot"]) - - pnls = [p["pnl"] for p in points] - max_profit = max(pnls) if pnls else 0.0 - max_loss = min(pnls) if pnls else 0.0 - - break_evens: list[float] = [] - for a, b in zip(points, points[1:], strict=False): - if a["pnl"] == 0: - break_evens.append(a["spot"]) - elif (a["pnl"] < 0 < b["pnl"]) or (a["pnl"] > 0 > b["pnl"]): - frac = -a["pnl"] / (b["pnl"] - a["pnl"]) - break_evens.append(round(a["spot"] + frac * (b["spot"] - a["spot"]), 2)) - - structure = self._guess_structure(enriched) - - sum(l["quantity"] * spot for l in enriched) if spot else 0.0 - fee_per_leg = min(0.0003 * (spot or 1) * sum(l["quantity"] for l in enriched), - 0.125 * abs(net_premium)) if spot else 0.0 - fees_open = round(fee_per_leg, 4) - fees_total = round(fees_open * 2, 4) - - warnings = [] - for l in enriched: - if l["action"] == "short": - any_long_same_type = any( - o["type"] == l["type"] and o["action"] == "long" and o["coin"] == l["coin"] - for o in enriched - ) - if not any_long_same_type: - warnings.append( - f"naked short {l['type']} {l['name']} — viola hard prohibition" - ) - - pl_ratio = ( - round(abs(max_profit / max_loss), 2) - if max_loss and max_loss != 0 else None - ) - - return { - "structure_name_guess": structure, - "net_premium": round(abs(net_premium), 4), - "net_premium_sign": "credit" if credit else "debit", - "max_profit": round(max_profit, 4), - "max_loss": round(max_loss, 4), - "break_even": break_evens, - "profit_loss_ratio": pl_ratio, - "greeks_net": {k: round(v, 6) for k, v in greeks_net.items()}, - "fees_estimate": { - "open": fees_open, - "close_estimate": fees_open, - "total": fees_total, - "cap_hit": fees_total >= 0.125 * abs(net_premium) if net_premium else False, - }, - "payoff_table": points, - "spot_assumed": round(spot, 2), - "warnings": warnings, - "testnet": self.testnet, - } - - @staticmethod - def _guess_structure(legs: list[dict]) -> str: - n = len(legs) - if n == 1: - l = legs[0] - return f"long {l['type']}" if l["action"] == "long" else f"short {l['type']}" - if n == 2: - types = {l["type"] for l in legs} - actions = {l["action"] for l in legs} - strikes = sorted(set(l["strike"] for l in legs)) - if types == {"P"} and actions == {"long", "short"}: - short = next(l for l in legs if l["action"] == "short") - long_ = next(l for l in legs if l["action"] == "long") - return "bull put spread" if short["strike"] > long_["strike"] else "bear put spread" - if types == {"C"} and actions == {"long", "short"}: - short = next(l for l in legs if l["action"] == "short") - long_ = next(l for l in legs if l["action"] == "long") - return "bear call spread" if short["strike"] < long_["strike"] else "bull call spread" - if types == {"C", "P"} and len(strikes) == 1 and len(actions) == 1: - return f"{list(actions)[0]} straddle" - if types == {"C", "P"} and len(strikes) == 2 and len(actions) == 1: - return f"{list(actions)[0]} strangle" - if len({l["expiry"] for l in legs}) == 2 and len(strikes) == 1: - return "calendar spread" - if n == 4: - types = [l["type"] for l in legs] - if types.count("P") == 2 and types.count("C") == 2: - return "iron condor" - return "custom" - - async def find_by_delta( - self, - currency: str, - expiry: str, - target_delta: float, - option_type: str, - max_results: int = 3, - min_open_interest: float = 100.0, - min_volume_24h: float = 20.0, - ) -> dict: - import asyncio - import datetime as _dt - - currency = currency.upper() - option_type = option_type.lower() - try: - exp_dt = _dt.datetime.fromisoformat(expiry) - except ValueError: - exp_dt = _dt.datetime.strptime(expiry, "%Y-%m-%d") - exp_ms = int(exp_dt.replace(tzinfo=_dt.UTC).timestamp() * 1000) - day_ms = 24 * 3600 * 1000 - - chain = await self.get_instruments( - currency=currency, - kind="option", - expiry_from=expiry, - expiry_to=(exp_dt + _dt.timedelta(days=1)).strftime("%Y-%m-%d"), - limit=500, - offset=0, - ) - items = [ - i for i in chain.get("instruments", []) - if i.get("option_type") == option_type - and i.get("expiry") is not None - and abs(i["expiry"] - exp_ms) < day_ms - ] - if not items: - return {"matches": [], "best_match": None, "note": "no instruments for expiry"} - - async def _ticker(inst: str) -> dict: - try: - return await self.get_ticker(inst) - except Exception: - return {} - - tickers = await asyncio.gather(*[_ticker(i["name"]) for i in items]) - - candidates = [] - for meta, tk in zip(items, tickers, strict=True): - greeks = tk.get("greeks") or {} - delta = greeks.get("delta") - oi = meta.get("open_interest") or 0 - vol = tk.get("volume_24h") or 0 - bid = tk.get("bid") or 0 - ask = tk.get("ask") or 0 - if delta is None: - continue - if oi < min_open_interest or vol < min_volume_24h: - continue - mid = (bid + ask) / 2 if (bid and ask) else None - spread_pct = round(100 * (ask - bid) / mid, 2) if mid and mid > 0 else None - candidates.append({ - "instrument_name": meta["name"], - "strike": meta["strike"], - "delta": delta, - "delta_distance": abs(delta - target_delta), - "mark_iv": tk.get("mark_iv"), - "mark_price_usd": tk.get("mark_price"), - "bid_ask_spread_pct": spread_pct, - "open_interest": oi, - "volume_24h": vol, - "greeks": greeks, - }) - candidates.sort(key=lambda x: x["delta_distance"]) - top = candidates[:max_results] - return { - "currency": currency, - "expiry": expiry, - "target_delta": target_delta, - "option_type": option_type, - "matches": top, - "best_match": top[0] if top else None, - "candidates_considered": len(candidates), - "testnet": self.testnet, - } - - async def get_iv_rank(self, instrument: str) -> dict: - currency = instrument.split("-", 1)[0].upper() - if currency not in ("BTC", "ETH"): - return { - "instrument": instrument, - "error": f"currency {currency} not supported for IV rank", - } - - ticker_raw = await self._request( - "public/ticker", {"instrument_name": instrument} - ) - tr = ticker_raw.get("result") or {} - current_iv = tr.get("mark_iv") - - import datetime as _dt - - now = _dt.datetime.now(_dt.UTC) - series_by_lookback: dict[int, list[float]] = {} - for lb in (30, 60, 90, 365): - start = now - _dt.timedelta(days=lb) - raw = await self._request( - "public/get_volatility_index_data", - { - "currency": currency, - "start_timestamp": int(start.timestamp() * 1000), - "end_timestamp": int(now.timestamp() * 1000), - "resolution": "1D", - }, - ) - rows = (raw.get("result") or {}).get("data") or [] - series_by_lookback[lb] = [ - float(r[4]) for r in rows if len(r) >= 5 - ] - - if current_iv is None: - latest = series_by_lookback.get(30) or series_by_lookback.get(90) or [] - current_iv = latest[-1] if latest else None - - def _percentile(values: list[float], target: float | None) -> float | None: - if target is None or not values: - return None - below = sum(1 for v in values if v <= target) - return round(100.0 * below / len(values), 2) - - def _rank(values: list[float], target: float | None) -> float | None: - if target is None or not values: - return None - lo, hi = min(values), max(values) - if hi == lo: - return None - return round(100.0 * (target - lo) / (hi - lo), 2) - - def _stats(values: list[float]) -> tuple[float | None, float | None]: - if not values: - return None, None - m = sum(values) / len(values) - var = sum((v - m) ** 2 for v in values) / len(values) - return m, var ** 0.5 - - mean_30, std_30 = _stats(series_by_lookback[30]) - warnings: list[str] = [] - if len(series_by_lookback[30]) < 10: - warnings.append("limited_history") - return { - "instrument": instrument, - "currency": currency, - "current_iv": current_iv, - "iv_percentile_30d": _percentile(series_by_lookback[30], current_iv), - "iv_percentile_60d": _percentile(series_by_lookback[60], current_iv), - "iv_percentile_90d": _percentile(series_by_lookback[90], current_iv), - "iv_percentile_365d": _percentile(series_by_lookback[365], current_iv), - "iv_rank_30d": _rank(series_by_lookback[30], current_iv), - "iv_rank_60d": _rank(series_by_lookback[60], current_iv), - "iv_rank_90d": _rank(series_by_lookback[90], current_iv), - "iv_rank_365d": _rank(series_by_lookback[365], current_iv), - "mean_30d": mean_30, - "stddev_30d": std_30, - "data_points_30d": len(series_by_lookback[30]), - "data_timestamp": now.isoformat(), - "warnings": warnings, - "testnet": self.testnet, - } - - async def get_realized_vol( - self, - currency: str, - windows: list[int] | None = None, - ) -> dict: - """Annualized realized volatility (log-return std) su daily closes di Deribit index.""" - import datetime as _dt - import math - - currency = currency.upper() - if currency not in ("BTC", "ETH"): - return {"currency": currency, "error": "currency not supported"} - windows = windows or [14, 30] - max_w = max(windows) - - now = _dt.datetime.now(_dt.UTC) - start = now - _dt.timedelta(days=max_w + 10) - raw = await self._request( - "public/get_tradingview_chart_data", - { - "instrument_name": f"{currency}-PERPETUAL", - "start_timestamp": int(start.timestamp() * 1000), - "end_timestamp": int(now.timestamp() * 1000), - "resolution": "1D", - }, - ) - result = raw.get("result") or {} - closes = [float(c) for c in (result.get("close") or [])] - rv_by_window: dict[str, float | None] = {} - for w in windows: - if len(closes) <= w: - rv_by_window[f"{w}d"] = None - continue - segment = closes[-(w + 1):] - rets = [ - math.log(segment[i] / segment[i - 1]) - for i in range(1, len(segment)) - if segment[i - 1] > 0 - ] - if len(rets) < 2: - rv_by_window[f"{w}d"] = None - continue - m = sum(rets) / len(rets) - var = sum((r - m) ** 2 for r in rets) / (len(rets) - 1) - rv_by_window[f"{w}d"] = round(math.sqrt(var * 365) * 100, 2) - - iv_current = None - try: - dvol_raw = await self._request( - "public/get_volatility_index_data", - { - "currency": currency, - "start_timestamp": int((now - _dt.timedelta(days=2)).timestamp() * 1000), - "end_timestamp": int(now.timestamp() * 1000), - "resolution": "1D", - }, - ) - dv = (dvol_raw.get("result") or {}).get("data") or [] - if dv: - iv_current = float(dv[-1][4]) - except Exception: - iv_current = None - - iv_rv_spread: dict[str, float | None] = {} - for w_key, rv in rv_by_window.items(): - iv_rv_spread[w_key] = ( - round(iv_current - rv, 2) if (iv_current is not None and rv is not None) else None - ) - - return { - "currency": currency, - "realized_vol_pct": rv_by_window, - "iv_current_pct": iv_current, - "iv_minus_rv_pct": iv_rv_spread, - "data_points": len(closes), - "data_timestamp": now.isoformat(), - "testnet": self.testnet, - } - - async def get_dvol_history( - self, - currency: str, - lookback_days: int = 90, - ) -> dict: - import datetime as _dt - - now = _dt.datetime.now(_dt.UTC) - start = now - _dt.timedelta(days=lookback_days) - raw = await self._request( - "public/get_volatility_index_data", - { - "currency": currency.upper(), - "start_timestamp": int(start.timestamp() * 1000), - "end_timestamp": int(now.timestamp() * 1000), - "resolution": "1D", - }, - ) - rows = (raw.get("result") or {}).get("data") or [] - series = [ - {"timestamp": r[0], "dvol": float(r[4])} - for r in rows - if len(r) >= 5 - ] - values = [s["dvol"] for s in series] - values_sorted = sorted(values) - - def _pct(p: float) -> float | None: - if not values_sorted: - return None - idx = int(round((len(values_sorted) - 1) * p)) - return values_sorted[idx] - - mean = sum(values) / len(values) if values else None - return { - "currency": currency.upper(), - "lookback_days": lookback_days, - "series": series, - "current": values[-1] if values else None, - "mean": mean, - "p25": _pct(0.25), - "p50": _pct(0.50), - "p75": _pct(0.75), - "p95": _pct(0.95), - "data_points": len(values), - "testnet": self.testnet, - } - - async def get_technical_indicators( - self, - instrument: str, - indicators: list[str], - start_date: str, - end_date: str, - resolution: str = "1h", - ) -> dict: - historical = await self.get_historical(instrument, start_date, end_date, resolution) - candles = historical.get("candles", []) - closes = [c["close"] for c in candles] - highs = [c["high"] for c in candles] - lows = [c["low"] for c in candles] - - result: dict[str, Any] = {} - for indicator in indicators: - name = indicator.lower() - if name == "sma": - result["sma"] = ind.sma(closes, 20) - elif name == "rsi": - result["rsi"] = ind.rsi(closes) - elif name == "atr": - result["atr"] = ind.atr(highs, lows, closes) - elif name == "macd": - result["macd"] = ind.macd(closes) - elif name == "adx": - result["adx"] = ind.adx(highs, lows, closes) - else: - result[name] = None - return result - - # ── Write tools ────────────────────────────────────────────── - - async def place_order( - self, - instrument_name: str, - side: str, - amount: float, - type: str = "limit", - price: float | None = None, - reduce_only: bool = False, - post_only: bool = False, - label: str | None = None, - ) -> dict: - endpoint = "private/buy" if side == "buy" else "private/sell" - params: dict[str, Any] = { - "instrument_name": instrument_name, - "amount": amount, - "type": type, - } - if price is not None: - if type in ("stop_market", "take_profit_market"): - params["trigger_price"] = price - params["trigger"] = "mark_price" - else: - params["price"] = price - if label: - params["label"] = label - if reduce_only: - params["reduce_only"] = True - if post_only: - params["post_only"] = True - raw = await self._request(endpoint, params) - r = raw.get("result") - if r is None: - return {"error": raw.get("error", "unknown"), "state": "error"} - return r - - async def place_combo_order( - self, - legs: list[dict[str, Any]], - side: str, - amount: float, - type: str = "limit", - price: float | None = None, - label: str | None = None, - ) -> dict: - """Crea un combo via private/create_combo poi piazza un singolo ordine - (buy/sell) sull'instrument_name del combo. Una sola crociata di spread - invece di N (uno per leg) → minor slippage su strutture liquide. - - legs: [{instrument_name, direction: 'buy'|'sell', ratio: int}]. - """ - combo_raw = await self._request("private/create_combo", {"trades": legs}) - combo = combo_raw.get("result") - if combo is None: - return {"state": "error", "error": combo_raw.get("error", "unknown")} - combo_instrument = combo.get("instrument_name") or combo.get("id") - order = await self.place_order( - instrument_name=combo_instrument, - side=side, - amount=amount, - type=type, - price=price, - label=label, - ) - if order.get("state") == "error": - return {"state": "error", "error": order.get("error"), "combo_instrument": combo_instrument} - return {"combo_instrument": combo_instrument, **order} - - async def set_leverage(self, instrument_name: str, leverage: int) -> dict: - """CER-016: pre-set account leverage per evitare default 50x testnet.""" - raw = await self._request( - "private/set_leverage", - {"instrument_name": instrument_name, "leverage": leverage}, - ) - if raw.get("result") is None: - return {"state": "error", "error": raw.get("error", "unknown")} - return {"state": "ok", "instrument": instrument_name, "leverage": leverage} - - async def cancel_order(self, order_id: str) -> dict: - raw = await self._request("private/cancel", {"order_id": order_id}) - if raw.get("result") is None: - return { - "order_id": order_id, - "state": "error", - "error": raw.get("error", "unknown"), - } - r = raw["result"] - return { - "order_id": r.get("order_id"), - "state": r.get("order_state"), - } - - async def set_stop_loss(self, order_id: str, stop_price: float) -> dict: - """ - Amend an existing order to add a stop-loss trigger via edit endpoint. - Deribit does not have a standalone set_stop_loss; we use private/edit - to update stop_price on the order. - """ - raw = await self._request( - "private/edit", - {"order_id": order_id, "stop_price": stop_price}, - ) - if raw.get("result") is None: - return {"order_id": order_id, "error": raw.get("error", "unknown")} - r = raw["result"].get("order", raw["result"]) - return { - "order_id": r.get("order_id"), - "state": r.get("order_state"), - "stop_price": r.get("stop_price"), - } - - async def set_take_profit(self, order_id: str, tp_price: float) -> dict: - """ - Amend an existing order to add a take-profit trigger via private/edit. - """ - raw = await self._request( - "private/edit", - {"order_id": order_id, "stop_price": tp_price}, - ) - if raw.get("result") is None: - return {"order_id": order_id, "error": raw.get("error", "unknown")} - r = raw["result"].get("order", raw["result"]) - return { - "order_id": r.get("order_id"), - "state": r.get("order_state"), - "tp_price": tp_price, - } - - async def close_position(self, instrument_name: str) -> dict: - raw = await self._request( - "private/close_position", - {"instrument_name": instrument_name, "type": "market"}, - ) - r = raw.get("result") - if r is None: - return {"instrument": instrument_name, "error": raw.get("error", "unknown"), "state": "error"} - order = r.get("order", r) - return { - "order_id": order.get("order_id"), - "state": order.get("order_state"), - } diff --git a/services/mcp-deribit/src/mcp_deribit/env_validation.py b/services/mcp-deribit/src/mcp_deribit/env_validation.py deleted file mode 100644 index 6a9cfe0..0000000 --- a/services/mcp-deribit/src/mcp_deribit/env_validation.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Re-export shim per backward-compat: la logica vive ora in -mcp_common.env_validation. Non aggiungere nuovo codice qui. -""" -from mcp_common.env_validation import ( - MissingEnvError, - fail_fast_if_missing, - optional_env, - require_env, - summarize, -) - -__all__ = [ - "MissingEnvError", - "fail_fast_if_missing", - "optional_env", - "require_env", - "summarize", -] diff --git a/services/mcp-deribit/src/mcp_deribit/leverage_cap.py b/services/mcp-deribit/src/mcp_deribit/leverage_cap.py deleted file mode 100644 index d04dd51..0000000 --- a/services/mcp-deribit/src/mcp_deribit/leverage_cap.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Leverage cap server-side per place_order. - -Cap letto dal secret JSON via campo `max_leverage`. Default 1 (cash) se assente. -""" -from __future__ import annotations - -from fastapi import HTTPException - - -def get_max_leverage(creds: dict) -> int: - """Legge max_leverage dal secret. Default 1 se mancante.""" - raw = creds.get("max_leverage", 1) - try: - value = int(raw) - except (TypeError, ValueError): - value = 1 - return max(1, value) - - -def enforce_leverage( - requested: int | float | None, - *, - creds: dict, - exchange: str, -) -> int: - """Verifica e applica leverage cap. Ritorna leverage applicabile. - - Solleva HTTPException(403, LEVERAGE_CAP_EXCEEDED) se requested > cap. - Se requested is None, applica il cap come default. - """ - cap = get_max_leverage(creds) - if requested is None: - return cap - lev = int(requested) - if lev < 1: - raise HTTPException( - status_code=403, - detail={ - "error": "LEVERAGE_CAP_EXCEEDED", - "exchange": exchange, - "requested": lev, - "max": cap, - "reason": "leverage must be >= 1", - }, - ) - if lev > cap: - raise HTTPException( - status_code=403, - detail={ - "error": "LEVERAGE_CAP_EXCEEDED", - "exchange": exchange, - "requested": lev, - "max": cap, - }, - ) - return lev diff --git a/services/mcp-deribit/src/mcp_deribit/server.py b/services/mcp-deribit/src/mcp_deribit/server.py deleted file mode 100644 index bfdc231..0000000 --- a/services/mcp-deribit/src/mcp_deribit/server.py +++ /dev/null @@ -1,695 +0,0 @@ -from __future__ import annotations - -import contextlib -import os - -from fastapi import Depends, FastAPI, HTTPException -from mcp_common.audit import audit_write_op -from mcp_common.auth import Principal, TokenStore, require_principal -from mcp_common.environment import EnvironmentInfo -from mcp_common.mcp_bridge import mount_mcp_endpoint -from mcp_common.server import build_app -from pydantic import BaseModel, field_validator, model_validator - -from mcp_deribit.client import DeribitClient -from mcp_deribit.leverage_cap import enforce_leverage as _enforce_leverage -from mcp_deribit.leverage_cap import get_max_leverage - -# --- Body models --- - -class GetTickerReq(BaseModel): - instrument_name: str | None = None - instrument: str | None = None - - model_config = {"extra": "allow"} - - @model_validator(mode="after") - def _normalize(self): - sym = self.instrument_name or self.instrument - if not sym: - raise ValueError("instrument_name (or instrument) is required") - self.instrument_name = sym - return self - - -class GetTickerBatchReq(BaseModel): - instrument_names: list[str] | None = None - instruments: list[str] | None = None - - model_config = {"extra": "allow"} - - @model_validator(mode="after") - def _normalize(self): - names = self.instrument_names or self.instruments - if not names: - raise ValueError("instrument_names (or instruments) is required") - self.instrument_names = names - return self - - -class GetInstrumentsReq(BaseModel): - currency: str - kind: str | None = None - expiry_from: str | None = None - expiry_to: str | None = None - strike_min: float | None = None - strike_max: float | None = None - min_open_interest: float | None = None - limit: int = 100 - offset: int = 0 - - -class GetOrderbookReq(BaseModel): - instrument_name: str - depth: int = 10 - - -class OrderbookImbalanceReq(BaseModel): - instrument_name: str - depth: int = 10 - - -class GetPositionsReq(BaseModel): - currency: str = "USDC" - - -class GetAccountSummaryReq(BaseModel): - currency: str = "USDC" - - -class GetTradeHistoryReq(BaseModel): - limit: int = 100 - instrument_name: str | None = None - - -class GetHistoricalReq(BaseModel): - instrument: str - start_date: str - end_date: str - resolution: str = "1h" - - -class GetDvolReq(BaseModel): - currency: str = "BTC" - start_date: str - end_date: str - resolution: str = "1D" - - -class GetDvolHistoryReq(BaseModel): - currency: str = "BTC" - lookback_days: int = 90 - - -class GetIvRankReq(BaseModel): - instrument: str - - -class GetRealizedVolReq(BaseModel): - currency: str = "BTC" - windows: list[int] = [14, 30] - - -class GetGexReq(BaseModel): - currency: str - expiry_from: str | None = None - expiry_to: str | None = None - top_n_strikes: int = 50 - - -class OptionFlowReq(BaseModel): - """Body comune per indicatori option-flow (dealer gamma, vanna/charm, - OI-weighted skew, smile asymmetry, ATM vs wings).""" - currency: str - expiry_from: str | None = None - expiry_to: str | None = None - top_n_strikes: int = 100 - - -class GetPcRatioReq(BaseModel): - currency: str - - -class GetSkew25dReq(BaseModel): - currency: str - expiry: str - - -class GetTermStructureReq(BaseModel): - currency: str - - -class CalculateSpreadPayoffReq(BaseModel): - legs: list[dict] - quote_currency: str = "USD" - - -class RunBacktestReq(BaseModel): - strategy_name: str - underlying: str = "BTC" - lookback_days: int = 30 - resolution: str = "4h" - entry_rules: dict | None = None - exit_rules: dict | None = None - - -class FindByDeltaReq(BaseModel): - currency: str - expiry: str - target_delta: float - option_type: str - max_results: int = 3 - min_open_interest: float = 100.0 - min_volume_24h: float = 20.0 - - -class GetIndicatorsReq(BaseModel): - instrument: str - indicators: list[str] - start_date: str - end_date: str - resolution: str = "1h" - - @field_validator("indicators", mode="before") - @classmethod - def _coerce_indicators(cls, v): - if isinstance(v, str): - import json - s = v.strip() - if s.startswith("["): - try: - parsed = json.loads(s) - if isinstance(parsed, list): - return [str(x).strip() for x in parsed if str(x).strip()] - except json.JSONDecodeError: - pass - return [x.strip() for x in s.split(",") if x.strip()] - if isinstance(v, list): - return v - raise ValueError( - "indicators must be a list like ['rsi','atr','macd'] " - "or a comma-separated string like 'rsi,atr,macd'" - ) - - -class PlaceOrderReq(BaseModel): - instrument_name: str - side: str # "buy" | "sell" - amount: float - type: str = "limit" - price: float | None = None - reduce_only: bool = False - post_only: bool = False - label: str | None = None - leverage: int | None = None # CER-016: None → default cap (3x) - - -class ComboLeg(BaseModel): - instrument_name: str - direction: str # "buy" | "sell" - ratio: int = 1 - - -class PlaceComboOrderReq(BaseModel): - legs: list[ComboLeg] - side: str # "buy" | "sell" - amount: float - type: str = "limit" - price: float | None = None - label: str | None = None - leverage: int | None = None - - @model_validator(mode="after") - def _at_least_two_legs(self): - if len(self.legs) < 2: - raise ValueError("combo requires at least 2 legs") - return self - - -class CancelOrderReq(BaseModel): - order_id: str - - -class SetStopLossReq(BaseModel): - order_id: str - stop_price: float - - -class SetTakeProfitReq(BaseModel): - order_id: str - tp_price: float - - -class ClosePositionReq(BaseModel): - instrument_name: str - - -# --- ACL helper --- - -def _check(principal: Principal, *, core: bool = False, observer: bool = False) -> None: - allowed: set[str] = set() - if core: - allowed.add("core") - if observer: - allowed.add("observer") - if not (principal.capabilities & allowed): - raise HTTPException(403, f"capability required: {allowed}") - - -# --- App factory --- - -def create_app( - *, - client: DeribitClient, - token_store: TokenStore, - creds: dict, - env_info: EnvironmentInfo | None = None, -) -> FastAPI: - from contextlib import asynccontextmanager - - cap_default = get_max_leverage(creds) - - # CER-016: pre-set leverage cap su perp principali al boot (best-effort). - @asynccontextmanager - async def _lifespan(_app: FastAPI): - for inst in ("BTC-PERPETUAL", "ETH-PERPETUAL"): - with contextlib.suppress(Exception): - await client.set_leverage(inst, cap_default) - yield - - app = build_app( - name="mcp-deribit", - version="0.1.0", - token_store=token_store, - lifespan=_lifespan, - ) - - # --- Read tools: core + observer --- - - @app.post("/tools/is_testnet", tags=["reads"]) - async def t_is_testnet(principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - return client.is_testnet() - - @app.post("/tools/environment_info", tags=["reads"]) - async def t_environment_info(principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - if env_info is None: - return { - "exchange": "deribit", - "environment": "testnet" if client.is_testnet().get("testnet") else "mainnet", - "source": "credentials", - "env_value": None, - "base_url": client.base_url, - "max_leverage": get_max_leverage(creds), - } - return { - "exchange": env_info.exchange, - "environment": env_info.environment, - "source": env_info.source, - "env_value": env_info.env_value, - "base_url": env_info.base_url, - "max_leverage": get_max_leverage(creds), - } - - @app.post("/tools/get_ticker", tags=["reads"]) - async def t_get_ticker( - body: GetTickerReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_ticker(body.instrument_name) - - @app.post("/tools/get_ticker_batch", tags=["reads"]) - async def t_get_ticker_batch( - body: GetTickerBatchReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_ticker_batch(body.instrument_names) - - @app.post("/tools/get_instruments", tags=["reads"]) - async def t_get_instruments( - body: GetInstrumentsReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_instruments( - currency=body.currency, - kind=body.kind, - expiry_from=body.expiry_from, - expiry_to=body.expiry_to, - strike_min=body.strike_min, - strike_max=body.strike_max, - min_open_interest=body.min_open_interest, - limit=body.limit, - offset=body.offset, - ) - - @app.post("/tools/get_orderbook", tags=["reads"]) - async def t_get_orderbook( - body: GetOrderbookReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_orderbook(body.instrument_name, body.depth) - - @app.post("/tools/get_orderbook_imbalance", tags=["reads"]) - async def t_get_ob_imbalance( - body: OrderbookImbalanceReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_orderbook_imbalance(body.instrument_name, body.depth) - - @app.post("/tools/get_positions", tags=["reads"]) - async def t_get_positions( - body: GetPositionsReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_positions(body.currency) - - @app.post("/tools/get_account_summary", tags=["reads"]) - async def t_get_account_summary( - body: GetAccountSummaryReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_account_summary(body.currency) - - @app.post("/tools/get_trade_history", tags=["reads"]) - async def t_get_trade_history( - body: GetTradeHistoryReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_trade_history(body.limit, body.instrument_name) - - @app.post("/tools/get_historical", tags=["reads"]) - async def t_get_historical( - body: GetHistoricalReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_historical( - body.instrument, body.start_date, body.end_date, body.resolution - ) - - @app.post("/tools/get_dvol", tags=["reads"]) - async def t_get_dvol( - body: GetDvolReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_dvol( - body.currency, body.start_date, body.end_date, body.resolution - ) - - @app.post("/tools/get_gex", tags=["reads"]) - async def t_get_gex( - body: GetGexReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_gex( - body.currency, body.expiry_from, body.expiry_to, body.top_n_strikes - ) - - @app.post("/tools/get_dealer_gamma_profile", tags=["reads"]) - async def t_get_dealer_gamma_profile( - body: OptionFlowReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_dealer_gamma_profile( - body.currency, body.expiry_from, body.expiry_to, body.top_n_strikes - ) - - @app.post("/tools/get_vanna_charm", tags=["reads"]) - async def t_get_vanna_charm( - body: OptionFlowReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_vanna_charm( - body.currency, body.expiry_from, body.expiry_to, body.top_n_strikes - ) - - @app.post("/tools/get_oi_weighted_skew", tags=["reads"]) - async def t_get_oi_weighted_skew( - body: OptionFlowReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_oi_weighted_skew( - body.currency, body.expiry_from, body.expiry_to, body.top_n_strikes - ) - - @app.post("/tools/get_smile_asymmetry", tags=["reads"]) - async def t_get_smile_asymmetry( - body: OptionFlowReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_smile_asymmetry( - body.currency, body.expiry_from, body.expiry_to, body.top_n_strikes - ) - - @app.post("/tools/get_atm_vs_wings_vol", tags=["reads"]) - async def t_get_atm_vs_wings_vol( - body: OptionFlowReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_atm_vs_wings_vol( - body.currency, body.expiry_from, body.expiry_to, body.top_n_strikes - ) - - @app.post("/tools/get_pc_ratio", tags=["reads"]) - async def t_get_pc_ratio( - body: GetPcRatioReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_pc_ratio(body.currency) - - @app.post("/tools/get_skew_25d", tags=["reads"]) - async def t_get_skew_25d( - body: GetSkew25dReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_skew_25d(body.currency, body.expiry) - - @app.post("/tools/get_term_structure", tags=["reads"]) - async def t_get_term_structure( - body: GetTermStructureReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_term_structure(body.currency) - - @app.post("/tools/run_backtest", tags=["writes"]) - async def t_run_backtest( - body: RunBacktestReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.run_backtest( - strategy_name=body.strategy_name, - underlying=body.underlying, - lookback_days=body.lookback_days, - resolution=body.resolution, - entry_rules=body.entry_rules, - exit_rules=body.exit_rules, - ) - - @app.post("/tools/calculate_spread_payoff", tags=["writes"]) - async def t_calculate_spread_payoff( - body: CalculateSpreadPayoffReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.calculate_spread_payoff(body.legs, body.quote_currency) - - @app.post("/tools/find_by_delta", tags=["writes"]) - async def t_find_by_delta( - body: FindByDeltaReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.find_by_delta( - currency=body.currency, - expiry=body.expiry, - target_delta=body.target_delta, - option_type=body.option_type, - max_results=body.max_results, - min_open_interest=body.min_open_interest, - min_volume_24h=body.min_volume_24h, - ) - - @app.post("/tools/get_iv_rank", tags=["reads"]) - async def t_get_iv_rank( - body: GetIvRankReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_iv_rank(body.instrument) - - @app.post("/tools/get_dvol_history", tags=["reads"]) - async def t_get_dvol_history( - body: GetDvolHistoryReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_dvol_history(body.currency, body.lookback_days) - - @app.post("/tools/get_realized_vol", tags=["reads"]) - async def t_get_realized_vol( - body: GetRealizedVolReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_realized_vol(body.currency, body.windows) - - @app.post("/tools/get_technical_indicators", tags=["reads"]) - async def t_get_indicators( - body: GetIndicatorsReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_technical_indicators( - body.instrument, - body.indicators, - body.start_date, - body.end_date, - body.resolution, - ) - - # --- Write tools: core only --- - - @app.post("/tools/place_order", tags=["writes"]) - async def t_place_order( - body: PlaceOrderReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True) - lev = _enforce_leverage(body.leverage, creds=creds, exchange="deribit") - if lev != cap_default: - with contextlib.suppress(Exception): - await client.set_leverage(body.instrument_name, lev) - result = await client.place_order( - instrument_name=body.instrument_name, - side=body.side, - amount=body.amount, - type=body.type, - price=body.price, - reduce_only=body.reduce_only, - post_only=body.post_only, - label=body.label, - ) - audit_write_op( - principal=principal, action="place_order", exchange="deribit", - target=body.instrument_name, - payload={"side": body.side, "amount": body.amount, "type": body.type, - "price": body.price, "leverage": lev, "label": body.label}, - result=result, - ) - return result - - @app.post("/tools/place_combo_order", tags=["writes"]) - async def t_place_combo_order( - body: PlaceComboOrderReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True) - lev = _enforce_leverage(body.leverage, creds=creds, exchange="deribit") - if lev != cap_default: - for leg in body.legs: - with contextlib.suppress(Exception): - await client.set_leverage(leg.instrument_name, lev) - result = await client.place_combo_order( - legs=[leg.model_dump() for leg in body.legs], - side=body.side, - amount=body.amount, - type=body.type, - price=body.price, - label=body.label, - ) - audit_write_op( - principal=principal, action="place_combo_order", exchange="deribit", - target=result.get("combo_instrument") if isinstance(result, dict) else None, - payload={"legs": [leg.model_dump() for leg in body.legs], - "side": body.side, "amount": body.amount, "leverage": lev}, - result=result if isinstance(result, dict) else None, - ) - return result - - @app.post("/tools/cancel_order", tags=["writes"]) - async def t_cancel_order( - body: CancelOrderReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True) - result = await client.cancel_order(body.order_id) - audit_write_op( - principal=principal, action="cancel_order", exchange="deribit", - target=body.order_id, payload={}, result=result, - ) - return result - - @app.post("/tools/set_stop_loss", tags=["writes"]) - async def t_set_sl( - body: SetStopLossReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True) - result = await client.set_stop_loss(body.order_id, body.stop_price) - audit_write_op( - principal=principal, action="set_stop_loss", exchange="deribit", - target=body.order_id, payload={"stop_price": body.stop_price}, result=result, - ) - return result - - @app.post("/tools/set_take_profit", tags=["writes"]) - async def t_set_tp( - body: SetTakeProfitReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True) - result = await client.set_take_profit(body.order_id, body.tp_price) - audit_write_op( - principal=principal, action="set_take_profit", exchange="deribit", - target=body.order_id, payload={"tp_price": body.tp_price}, result=result, - ) - return result - - @app.post("/tools/close_position", tags=["writes"]) - async def t_close_position( - body: ClosePositionReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True) - result = await client.close_position(body.instrument_name) - audit_write_op( - principal=principal, action="close_position", exchange="deribit", - target=body.instrument_name, payload={}, result=result, - ) - return result - - # ───── MCP endpoint (/mcp) — bridge verso /tools/* ───── - port = int(os.environ.get("PORT", "9011")) - mount_mcp_endpoint( - app, - name="cerbero-deribit", - version="0.1.0", - token_store=token_store, - internal_base_url=f"http://localhost:{port}", - tools=[ - {"name": "is_testnet", "description": "True se client Deribit è in modalità testnet."}, - {"name": "environment_info", "description": "Ambiente operativo (testnet/mainnet), source, base_url, max_leverage cap."}, - {"name": "get_ticker", "description": "Ticker di un instrument Deribit."}, - {"name": "get_ticker_batch", "description": "Ticker per N instruments in parallelo (max 20)."}, - {"name": "get_instruments", "description": "Lista instruments per currency."}, - {"name": "get_orderbook", "description": "Orderbook L1/L2 per instrument."}, - {"name": "get_orderbook_imbalance", "description": "Microstructure: imbalance ratio + microprice + slope."}, - {"name": "get_positions", "description": "Posizioni aperte."}, - {"name": "get_account_summary", "description": "Summary account (equity, balance)."}, - {"name": "get_trade_history", "description": "Storia trade recenti."}, - {"name": "get_historical", "description": "OHLCV storico."}, - {"name": "get_dvol", "description": "Deribit Volatility Index (DVOL) OHLC per currency (BTC/ETH)."}, - {"name": "get_dvol_history", "description": "DVOL time series + percentili su lookback_days."}, - {"name": "get_iv_rank", "description": "IV rank 30/90/365d di un instrument vs DVOL storico della currency."}, - {"name": "find_by_delta", "description": "Trova strike con delta più vicino a target, filtrato per liquidità (OI/vol)."}, - {"name": "calculate_spread_payoff", "description": "Payoff/greci/max P-L/break-even/fee per struttura multi-leg."}, - {"name": "run_backtest", "description": "Heuristic backtest RSI-based su storia OHLCV per threshold accept/marginal/reject."}, - {"name": "get_term_structure", "description": "IV ATM per ogni expiry disponibile, detect contango/backwardation."}, - {"name": "get_skew_25d", "description": "Skew 25-delta put/call IV + risk reversal + butterfly per expiry."}, - {"name": "get_pc_ratio", "description": "Put/Call ratio aggregato su OI e volume 24h."}, - {"name": "get_gex", "description": "Gamma exposure per strike + zero gamma level (top N strikes per OI)."}, - {"name": "get_dealer_gamma_profile", "description": "Net dealer gamma per strike (short calls/long puts) + gamma flip level."}, - {"name": "get_vanna_charm", "description": "Vanna (∂delta/∂IV) e Charm (∂delta/∂t) aggregati pesati OI."}, - {"name": "get_oi_weighted_skew", "description": "Skew aggregato pesato per OI: IV puts - IV calls. Positivo = paura."}, - {"name": "get_smile_asymmetry", "description": "Asymmetry IV otm-puts vs otm-calls + ATM IV reference."}, - {"name": "get_atm_vs_wings_vol", "description": "IV ATM vs IV ali 25-delta. wing_richness > 0 = smile/kurtosis."}, - {"name": "get_technical_indicators", "description": "Indicatori tecnici (RSI, MACD, ATR, ADX)."}, - {"name": "get_realized_vol", "description": "Volatilità realizzata annualizzata (log-return std) BTC/ETH + spread IV−RV."}, - {"name": "place_order", "description": "Invia ordine (CORE only, testnet)."}, - {"name": "place_combo_order", "description": "Crea combo via private/create_combo + piazza ordine sul combo (1 cross spread invece di N)."}, - {"name": "cancel_order", "description": "Cancella ordine."}, - {"name": "set_stop_loss", "description": "Setta stop loss su posizione."}, - {"name": "set_take_profit", "description": "Setta take profit su posizione."}, - {"name": "close_position", "description": "Chiude posizione aperta."}, - ], - ) - - return app diff --git a/services/mcp-deribit/tests/test_client.py b/services/mcp-deribit/tests/test_client.py deleted file mode 100644 index e2a5bbc..0000000 --- a/services/mcp-deribit/tests/test_client.py +++ /dev/null @@ -1,297 +0,0 @@ -from __future__ import annotations - -import re - -import pytest -from mcp_deribit.client import DeribitClient -from pytest_httpx import HTTPXMock - - -@pytest.fixture -def client(): - return DeribitClient(client_id="cid", client_secret="csec", testnet=True) - - -AUTH_RESP = {"result": {"access_token": "tok", "expires_in": 3600}} - - -@pytest.mark.asyncio -async def test_get_ticker(httpx_mock: HTTPXMock, client: DeribitClient): - # public endpoint — no auth needed - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/public/ticker"), - json={ - "result": { - "mark_price": 50000, - "last_price": 49900, - "best_bid_price": 49950, - "best_ask_price": 50050, - "instrument_name": "BTC-PERPETUAL", - "stats": {"volume": 1234.5}, - "open_interest": 9999, - "greeks": None, - "mark_iv": None, - } - }, - ) - result = await client.get_ticker("BTC-PERPETUAL") - assert result["mark_price"] == 50000 - assert result["bid"] == 49950 - assert result["ask"] == 50050 - # CER-003: perpetual returns conceptual greeks, not None - assert result["greeks"] == {"delta": 1.0, "gamma": 0.0, "vega": 0.0, "theta": 0.0, "rho": 0.0} - # CER-007: testnet flag present - assert result["testnet"] is True - - -@pytest.mark.asyncio -async def test_get_ticker_option_preserves_greeks(httpx_mock: HTTPXMock, client: DeribitClient): - real_greeks = {"delta": 0.42, "gamma": 0.001, "vega": 0.05, "theta": -0.02, "rho": 0.003} - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/public/ticker"), - json={ - "result": { - "mark_price": 2500, - "last_price": 2500, - "best_bid_price": 2490, - "best_ask_price": 2510, - "instrument_name": "BTC-30APR26-75000-C", - "stats": {"volume": 5.0}, - "open_interest": 100, - "greeks": real_greeks, - "mark_iv": 62.5, - } - }, - ) - result = await client.get_ticker("BTC-30APR26-75000-C") - assert result["greeks"] == real_greeks - assert result["mark_iv"] == 62.5 - - -def test_is_testnet(client: DeribitClient): - info = client.is_testnet() - assert info["testnet"] is True - assert "test.deribit.com" in info["base_url"] - - -@pytest.mark.asyncio -async def test_get_instruments_pagination_and_filter(httpx_mock: HTTPXMock, client: DeribitClient): - items = [] - for i, exp_ms in enumerate([1700000000000, 1776000000000, 1800000000000]): - items.append({ - "instrument_name": f"BTC-inst-{i}", - "strike": 50000 + i * 10000, - "expiration_timestamp": exp_ms, - "option_type": "call", - "tick_size": 0.5, - "min_trade_amount": 0.1, - # CER-008: public/get_instruments non include OI in produzione - }) - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_instruments"), - json={"result": items}, - ) - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_book_summary_by_currency"), - json={"result": [ - {"instrument_name": "BTC-inst-0", "open_interest": 100.0}, - {"instrument_name": "BTC-inst-1", "open_interest": 200.0}, - {"instrument_name": "BTC-inst-2", "open_interest": 300.0}, - ]}, - ) - result = await client.get_instruments( - "BTC", kind="option", strike_min=55000, limit=1, offset=0 - ) - assert result["total"] == 2 - assert len(result["instruments"]) == 1 - assert result["has_more"] is True - assert result["testnet"] is True - assert result["instruments"][0]["strike"] >= 55000 - # CER-008: OI merge da book_summary - assert result["instruments"][0]["open_interest"] in (200.0, 300.0) - - -@pytest.mark.asyncio -async def test_get_instruments_min_oi_filter(httpx_mock: HTTPXMock, client: DeribitClient): - """CER-008: min_open_interest filtra server-side usando book_summary.""" - items = [ - {"instrument_name": "BTC-low-OI", "strike": 60000, "expiration_timestamp": 1800000000000, - "option_type": "call", "tick_size": 0.5, "min_trade_amount": 0.1}, - {"instrument_name": "BTC-high-OI", "strike": 60000, "expiration_timestamp": 1800000000000, - "option_type": "call", "tick_size": 0.5, "min_trade_amount": 0.1}, - ] - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_instruments"), - json={"result": items}, - ) - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_book_summary_by_currency"), - json={"result": [ - {"instrument_name": "BTC-low-OI", "open_interest": 5.0}, - {"instrument_name": "BTC-high-OI", "open_interest": 500.0}, - ]}, - ) - result = await client.get_instruments("BTC", kind="option", min_open_interest=100) - assert result["total"] == 1 - assert result["instruments"][0]["name"] == "BTC-high-OI" - assert result["instruments"][0]["open_interest"] == 500.0 - - -@pytest.mark.asyncio -async def test_get_account_summary(httpx_mock: HTTPXMock, client: DeribitClient): - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"), - json=AUTH_RESP, - ) - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/private/get_account_summary"), - json={"result": {"equity": 1000.0, "balance": 900.0, "currency": "USDC", - "margin_balance": 800.0, "available_funds": 700.0, - "unrealized_pnl": 50.0, "total_pnl": 100.0}}, - ) - result = await client.get_account_summary("USDC") - assert result["equity"] == 1000.0 - assert result["balance"] == 900.0 - - -@pytest.mark.asyncio -async def test_place_order(httpx_mock: HTTPXMock, client: DeribitClient): - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"), - json=AUTH_RESP, - ) - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/private/buy"), - json={"result": {"order": {"order_id": "abc", "amount": 10, "order_state": "open"}, "trades": []}}, - ) - result = await client.place_order( - instrument_name="BTC-PERPETUAL", - side="buy", - amount=10, - type="limit", - price=50000, - ) - assert result["order"]["order_id"] == "abc" - - -@pytest.mark.asyncio -async def test_get_positions(httpx_mock: HTTPXMock, client: DeribitClient): - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"), - json=AUTH_RESP, - ) - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/private/get_positions"), - json={"result": [ - { - "instrument_name": "BTC-PERPETUAL", - "size": 100.0, - "average_price": 48000.0, - "mark_price": 50000.0, - "floating_profit_loss": 200.0, - "realized_profit_loss": 50.0, - "leverage": 10, - } - ]}, - ) - result = await client.get_positions("USDC") - assert len(result) == 1 - assert result[0]["instrument"] == "BTC-PERPETUAL" - assert result[0]["direction"] == "long" - - -@pytest.mark.asyncio -async def test_get_dvol(httpx_mock: HTTPXMock, client: DeribitClient): - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_volatility_index_data"), - json={ - "result": { - "data": [ - [1700000000000, 55.0, 58.0, 54.0, 57.0], - [1700086400000, 57.0, 60.0, 56.0, 59.5], - ], - "continuation": None, - } - }, - ) - result = await client.get_dvol("btc", "2024-01-01", "2024-01-02", "1D") - assert result["currency"] == "BTC" - assert result["latest"] == 59.5 - assert len(result["candles"]) == 2 - assert result["candles"][0]["close"] == 57.0 - - -@pytest.mark.asyncio -async def test_place_combo_order(httpx_mock: HTTPXMock, client: DeribitClient): - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"), - json=AUTH_RESP, - ) - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/private/create_combo"), - json={ - "result": { - "id": "BTC-COMBO-1", - "instrument_name": "BTC-COMBO-1", - "state": "active", - } - }, - ) - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/private/buy"), - json={ - "result": { - "order": {"order_id": "ord-1", "order_state": "open"}, - "trades": [], - } - }, - ) - legs = [ - {"instrument_name": "BTC-30APR26-75000-C", "direction": "buy", "ratio": 1}, - {"instrument_name": "BTC-30APR26-80000-C", "direction": "sell", "ratio": 1}, - ] - result = await client.place_combo_order( - legs=legs, - side="buy", - amount=1, - type="limit", - price=0.05, - label="vert-spread", - ) - assert result["combo_instrument"] == "BTC-COMBO-1" - assert result["order"]["order_id"] == "ord-1" - - -@pytest.mark.asyncio -async def test_place_combo_order_create_combo_error(httpx_mock: HTTPXMock, client: DeribitClient): - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"), - json=AUTH_RESP, - ) - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/private/create_combo"), - json={"error": {"code": -32602, "message": "Invalid leg"}}, - ) - result = await client.place_combo_order( - legs=[{"instrument_name": "BTC-X", "direction": "buy", "ratio": 1}], - side="buy", - amount=1, - type="market", - ) - assert result["state"] == "error" - assert "Invalid leg" in str(result.get("error")) - - -@pytest.mark.asyncio -async def test_cancel_order(httpx_mock: HTTPXMock, client: DeribitClient): - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"), - json=AUTH_RESP, - ) - httpx_mock.add_response( - url=re.compile(r"https://test\.deribit\.com/api/v2/private/cancel"), - json={"result": {"order_id": "abc123", "order_state": "cancelled"}}, - ) - result = await client.cancel_order("abc123") - assert result["order_id"] == "abc123" - assert result["state"] == "cancelled" diff --git a/services/mcp-deribit/tests/test_env_validation.py b/services/mcp-deribit/tests/test_env_validation.py deleted file mode 100644 index 592e1bf..0000000 --- a/services/mcp-deribit/tests/test_env_validation.py +++ /dev/null @@ -1,71 +0,0 @@ -"""CER-P5-010 env validation tests.""" - -from __future__ import annotations - -import pytest -from mcp_deribit.env_validation import ( - MissingEnvError, - fail_fast_if_missing, - optional_env, - require_env, - summarize, -) - - -def test_require_env_present(monkeypatch): - monkeypatch.setenv("FOO_KEY", "value1") - assert require_env("FOO_KEY") == "value1" - - -def test_require_env_missing_raises(monkeypatch): - monkeypatch.delenv("MISSING_REQ", raising=False) - with pytest.raises(MissingEnvError): - require_env("MISSING_REQ", "critical path") - - -def test_require_env_empty_raises(monkeypatch): - monkeypatch.setenv("EMPTY_REQ", "") - with pytest.raises(MissingEnvError): - require_env("EMPTY_REQ") - - -def test_require_env_whitespace_only_raises(monkeypatch): - monkeypatch.setenv("WS_REQ", " ") - with pytest.raises(MissingEnvError): - require_env("WS_REQ") - - -def test_optional_env_default(monkeypatch): - monkeypatch.delenv("OPT_A", raising=False) - assert optional_env("OPT_A", default="fallback") == "fallback" - - -def test_optional_env_set(monkeypatch): - monkeypatch.setenv("OPT_B", "xx") - assert optional_env("OPT_B", default="fallback") == "xx" - - -def test_fail_fast_all_present(monkeypatch): - monkeypatch.setenv("AA", "1") - monkeypatch.setenv("BB", "2") - fail_fast_if_missing(["AA", "BB"]) # no exit - - -def test_fail_fast_missing_exits(monkeypatch): - monkeypatch.setenv("HAVE_IT", "1") - monkeypatch.delenv("MISSING_X", raising=False) - with pytest.raises(SystemExit) as exc: - fail_fast_if_missing(["HAVE_IT", "MISSING_X"]) - assert exc.value.code == 2 - - -def test_summarize_does_not_leak_secrets(monkeypatch, caplog): - import logging - monkeypatch.setenv("API_KEY_FOO", "super-secret-token-123456") - monkeypatch.setenv("PORT", "9000") - with caplog.at_level(logging.INFO, logger="mcp_deribit.env_validation"): - summarize(["API_KEY_FOO", "PORT", "NOT_SET_XYZ"]) - log_text = "\n".join(caplog.messages) - assert "super-secret-token-123456" not in log_text - assert "9000" in log_text - assert "" in log_text diff --git a/services/mcp-deribit/tests/test_environment_info.py b/services/mcp-deribit/tests/test_environment_info.py deleted file mode 100644 index 080c958..0000000 --- a/services/mcp-deribit/tests/test_environment_info.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock - -from fastapi.testclient import TestClient -from mcp_common.auth import Principal, TokenStore -from mcp_common.environment import EnvironmentInfo -from mcp_deribit.server import create_app - - -def _make_app(env_info, creds): - c = AsyncMock() - c.set_leverage = AsyncMock(return_value={"state": "ok"}) - store = TokenStore(tokens={ - "ct": Principal("core", {"core"}), - "ot": Principal("observer", {"observer"}), - }) - return create_app(client=c, token_store=store, creds=creds, env_info=env_info) - - -def test_environment_info_full_shape(): - env = EnvironmentInfo( - exchange="deribit", - environment="testnet", - source="env", - env_value="true", - base_url="https://test.deribit.com/api/v2", - ) - app = _make_app(env, creds={"max_leverage": 3}) - c = TestClient(app) - r = c.post( - "/tools/environment_info", - headers={"Authorization": "Bearer ot"}, - ) - assert r.status_code == 200 - body = r.json() - assert body["exchange"] == "deribit" - assert body["environment"] == "testnet" - assert body["source"] == "env" - assert body["env_value"] == "true" - assert body["base_url"] == "https://test.deribit.com/api/v2" - assert body["max_leverage"] == 3 - - -def test_environment_info_default_source(): - env = EnvironmentInfo( - exchange="deribit", - environment="testnet", - source="default", - env_value=None, - base_url="https://test.deribit.com/api/v2", - ) - app = _make_app(env, creds={"max_leverage": 1}) - c = TestClient(app) - r = c.post( - "/tools/environment_info", - headers={"Authorization": "Bearer ct"}, - ) - assert r.status_code == 200 - body = r.json() - assert body["source"] == "default" - assert body["env_value"] is None - assert body["max_leverage"] == 1 - - -def test_environment_info_requires_auth(): - env = EnvironmentInfo( - exchange="deribit", - environment="testnet", - source="default", - env_value=None, - base_url="https://test.deribit.com/api/v2", - ) - app = _make_app(env, creds={"max_leverage": 3}) - c = TestClient(app) - r = c.post("/tools/environment_info") - assert r.status_code == 401 diff --git a/services/mcp-deribit/tests/test_leverage_cap.py b/services/mcp-deribit/tests/test_leverage_cap.py deleted file mode 100644 index f70bd86..0000000 --- a/services/mcp-deribit/tests/test_leverage_cap.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -import pytest -from fastapi import HTTPException -from mcp_deribit.leverage_cap import enforce_leverage, get_max_leverage - - -def test_get_max_leverage_returns_creds_value(): - creds = {"max_leverage": 5} - assert get_max_leverage(creds) == 5 - - -def test_get_max_leverage_default_when_missing(): - """Default 1 (cash) se il secret non ha max_leverage.""" - assert get_max_leverage({}) == 1 - - -def test_enforce_leverage_pass_under_cap(): - creds = {"max_leverage": 3} - enforce_leverage(2, creds=creds, exchange="deribit") # no raise - - -def test_enforce_leverage_pass_at_cap(): - creds = {"max_leverage": 3} - enforce_leverage(3, creds=creds, exchange="deribit") # no raise - - -def test_enforce_leverage_reject_over_cap(): - creds = {"max_leverage": 3} - with pytest.raises(HTTPException) as exc: - enforce_leverage(10, creds=creds, exchange="deribit") - assert exc.value.status_code == 403 - assert exc.value.detail["error"] == "LEVERAGE_CAP_EXCEEDED" - assert exc.value.detail["exchange"] == "deribit" - assert exc.value.detail["requested"] == 10 - assert exc.value.detail["max"] == 3 - - -def test_enforce_leverage_reject_when_below_one(): - creds = {"max_leverage": 3} - with pytest.raises(HTTPException) as exc: - enforce_leverage(0, creds=creds, exchange="deribit") - assert exc.value.status_code == 403 - - -def test_enforce_leverage_default_when_none(): - """Se requested è None, applica il cap come default.""" - creds = {"max_leverage": 3} - result = enforce_leverage(None, creds=creds, exchange="deribit") - assert result == 3 diff --git a/services/mcp-deribit/tests/test_server_acl.py b/services/mcp-deribit/tests/test_server_acl.py deleted file mode 100644 index 3777546..0000000 --- a/services/mcp-deribit/tests/test_server_acl.py +++ /dev/null @@ -1,269 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest -from fastapi.testclient import TestClient -from mcp_common.auth import Principal, TokenStore -from mcp_deribit.server import create_app - - -@pytest.fixture -def mock_client(): - c = MagicMock() - c.get_ticker = AsyncMock(return_value={"mark_price": 50000}) - c.get_instruments = AsyncMock(return_value=[]) - c.get_orderbook = AsyncMock(return_value={"bids": [], "asks": []}) - c.get_positions = AsyncMock(return_value=[]) - c.get_account_summary = AsyncMock(return_value={"equity": 1000}) - c.get_trade_history = AsyncMock(return_value=[]) - c.get_historical = AsyncMock(return_value={"candles": []}) - c.get_technical_indicators = AsyncMock(return_value={"rsi": 55.0}) - c.place_order = AsyncMock(return_value={"order_id": "x"}) - c.place_combo_order = AsyncMock(return_value={"combo_instrument": "BTC-COMBO-1", "order": {"order_id": "x"}}) - c.get_dealer_gamma_profile = AsyncMock(return_value={"by_strike": [], "total_net_dealer_gamma": 0}) - c.get_vanna_charm = AsyncMock(return_value={"total_vanna": 0, "total_charm": 0, "legs_analyzed": 0}) - c.get_oi_weighted_skew = AsyncMock(return_value={"skew": 0, "call_iv_weighted": None, "put_iv_weighted": None}) - c.get_smile_asymmetry = AsyncMock(return_value={"atm_iv": 0.5, "asymmetry": 0.0}) - c.get_atm_vs_wings_vol = AsyncMock(return_value={"atm_iv": 0.5, "wing_richness": 0.0}) - c.get_orderbook_imbalance = AsyncMock(return_value={"imbalance_ratio": 0.0, "microprice": 50000}) - c.cancel_order = AsyncMock(return_value={"order_id": "x", "state": "cancelled"}) - c.set_stop_loss = AsyncMock(return_value={"order_id": "x", "stop_price": 45000}) - c.set_take_profit = AsyncMock(return_value={"order_id": "x", "tp_price": 55000}) - c.close_position = AsyncMock(return_value={"closed": True}) - c.set_leverage = AsyncMock(return_value={"state": "ok"}) - return c - - -@pytest.fixture -def http(mock_client): - store = TokenStore(tokens={ - "ct": Principal("core", {"core"}), - "ot": Principal("observer", {"observer"}), - }) - app = create_app(client=mock_client, token_store=store, creds={"max_leverage": 3}) - return TestClient(app) - - -def test_health(http): - assert http.get("/health").status_code == 200 - - -def test_get_ticker_core_ok(http): - r = http.post( - "/tools/get_ticker", - headers={"Authorization": "Bearer ct"}, - json={"instrument_name": "BTC-PERPETUAL"}, - ) - assert r.status_code == 200 - assert r.json()["mark_price"] == 50000 - - -def test_get_ticker_observer_ok(http): - r = http.post( - "/tools/get_ticker", - headers={"Authorization": "Bearer ot"}, - json={"instrument_name": "BTC-PERPETUAL"}, - ) - assert r.status_code == 200 - - -def test_get_ticker_no_auth_401(http): - r = http.post("/tools/get_ticker", json={"instrument_name": "BTC-PERPETUAL"}) - assert r.status_code == 401 - - -def test_get_ticker_alias_instrument_ok(http, mock_client): - r = http.post( - "/tools/get_ticker", - headers={"Authorization": "Bearer ct"}, - json={"instrument": "ETH"}, - ) - assert r.status_code == 200 - mock_client.get_ticker.assert_awaited_with("ETH") - - -def test_place_order_core_ok(http): - r = http.post( - "/tools/place_order", - headers={"Authorization": "Bearer ct"}, - json={"instrument_name": "BTC-PERPETUAL", "side": "buy", "amount": 10}, - ) - assert r.status_code == 200 - - -def test_place_order_observer_forbidden(http): - r = http.post( - "/tools/place_order", - headers={"Authorization": "Bearer ot"}, - json={"instrument_name": "BTC-PERPETUAL", "side": "buy", "amount": 10}, - ) - assert r.status_code == 403 - - -def test_get_orderbook_imbalance_observer_ok(http): - r = http.post( - "/tools/get_orderbook_imbalance", - headers={"Authorization": "Bearer ot"}, - json={"instrument_name": "BTC-PERPETUAL"}, - ) - assert r.status_code == 200 - - -@pytest.mark.parametrize("path", [ - "/tools/get_dealer_gamma_profile", - "/tools/get_vanna_charm", - "/tools/get_oi_weighted_skew", - "/tools/get_smile_asymmetry", - "/tools/get_atm_vs_wings_vol", -]) -def test_option_flow_indicators_observer_ok(http, path): - r = http.post(path, headers={"Authorization": "Bearer ot"}, json={"currency": "BTC"}) - assert r.status_code == 200, (path, r.text) - - -@pytest.mark.parametrize("path", [ - "/tools/get_dealer_gamma_profile", - "/tools/get_vanna_charm", - "/tools/get_oi_weighted_skew", - "/tools/get_smile_asymmetry", - "/tools/get_atm_vs_wings_vol", -]) -def test_option_flow_indicators_no_auth_401(http, path): - r = http.post(path, json={"currency": "BTC"}) - assert r.status_code == 401, (path, r.text) - - -def test_place_combo_order_core_ok(http): - r = http.post( - "/tools/place_combo_order", - headers={"Authorization": "Bearer ct"}, - json={ - "legs": [ - {"instrument_name": "BTC-30APR26-75000-C", "direction": "buy", "ratio": 1}, - {"instrument_name": "BTC-30APR26-80000-C", "direction": "sell", "ratio": 1}, - ], - "side": "buy", - "amount": 1, - "type": "limit", - "price": 0.05, - }, - ) - assert r.status_code == 200 - assert r.json()["combo_instrument"] == "BTC-COMBO-1" - - -def test_place_combo_order_observer_forbidden(http): - r = http.post( - "/tools/place_combo_order", - headers={"Authorization": "Bearer ot"}, - json={ - "legs": [ - {"instrument_name": "BTC-X", "direction": "buy", "ratio": 1}, - {"instrument_name": "BTC-Y", "direction": "sell", "ratio": 1}, - ], - "side": "buy", - "amount": 1, - }, - ) - assert r.status_code == 403 - - -def test_place_combo_order_min_legs(http): - r = http.post( - "/tools/place_combo_order", - headers={"Authorization": "Bearer ct"}, - json={ - "legs": [{"instrument_name": "BTC-X", "direction": "buy", "ratio": 1}], - "side": "buy", - "amount": 1, - }, - ) - assert r.status_code == 422 - - -def test_place_combo_order_leverage_cap_enforced(http): - r = http.post( - "/tools/place_combo_order", - headers={"Authorization": "Bearer ct"}, - json={ - "legs": [ - {"instrument_name": "BTC-X", "direction": "buy", "ratio": 1}, - {"instrument_name": "BTC-Y", "direction": "sell", "ratio": 1}, - ], - "side": "buy", - "amount": 1, - "leverage": 50, - }, - ) - assert r.status_code == 403 - err = r.json()["error"] - assert err["code"] == "LEVERAGE_CAP_EXCEEDED" - - -def test_place_order_leverage_cap_enforced(http): - """Reject leverage > max_leverage (da secret, default 3).""" - r = http.post( - "/tools/place_order", - headers={"Authorization": "Bearer ct"}, - json={ - "instrument_name": "BTC-PERPETUAL", - "side": "buy", - "amount": 50, - "leverage": 50, - }, - ) - assert r.status_code == 403 - body = r.json() - err = body["error"] - assert err["code"] == "LEVERAGE_CAP_EXCEEDED" - details = err["details"] - assert details["exchange"] == "deribit" - assert details["requested"] == 50 - assert details["max"] == 3 - - -def test_close_position_core_ok(http): - r = http.post( - "/tools/close_position", - headers={"Authorization": "Bearer ct"}, - json={"instrument_name": "BTC-PERPETUAL"}, - ) - assert r.status_code == 200 - - -def test_close_position_observer_forbidden(http): - r = http.post( - "/tools/close_position", - headers={"Authorization": "Bearer ot"}, - json={"instrument_name": "BTC-PERPETUAL"}, - ) - assert r.status_code == 403 - - -def test_cancel_order_observer_forbidden(http): - r = http.post( - "/tools/cancel_order", - headers={"Authorization": "Bearer ot"}, - json={"order_id": "abc123"}, - ) - assert r.status_code == 403 - - -def test_set_stop_loss_observer_forbidden(http): - r = http.post( - "/tools/set_stop_loss", - headers={"Authorization": "Bearer ot"}, - json={"order_id": "abc123", "stop_price": 45000.0}, - ) - assert r.status_code == 403 - - -def test_get_account_summary_observer_ok(http): - r = http.post( - "/tools/get_account_summary", - headers={"Authorization": "Bearer ot"}, - json={"currency": "USDC"}, - ) - assert r.status_code == 200 - assert r.json()["equity"] == 1000 diff --git a/services/mcp-hyperliquid/pyproject.toml b/services/mcp-hyperliquid/pyproject.toml deleted file mode 100644 index c18e994..0000000 --- a/services/mcp-hyperliquid/pyproject.toml +++ /dev/null @@ -1,29 +0,0 @@ -[project] -name = "mcp-hyperliquid" -version = "0.1.0" -requires-python = ">=3.11" -dependencies = [ - "mcp-common", - "fastapi>=0.115", - "uvicorn[standard]>=0.30", - "httpx>=0.27", - "pydantic>=2.6", - "hyperliquid-python-sdk>=0.3", - "eth-account>=0.11", -] - -[project.optional-dependencies] -dev = ["pytest>=8", "pytest-asyncio>=0.23", "pytest-httpx>=0.30"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/mcp_hyperliquid"] - -[tool.uv.sources] -mcp-common = { workspace = true } - -[project.scripts] -mcp-hyperliquid = "mcp_hyperliquid.__main__:main" diff --git a/services/mcp-hyperliquid/src/mcp_hyperliquid/__init__.py b/services/mcp-hyperliquid/src/mcp_hyperliquid/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/mcp-hyperliquid/src/mcp_hyperliquid/__main__.py b/services/mcp-hyperliquid/src/mcp_hyperliquid/__main__.py deleted file mode 100644 index 397d3e5..0000000 --- a/services/mcp-hyperliquid/src/mcp_hyperliquid/__main__.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -from mcp_common.app_factory import ExchangeAppSpec, run_exchange_main - -from mcp_hyperliquid.client import HyperliquidClient -from mcp_hyperliquid.server import create_app - -SPEC = ExchangeAppSpec( - exchange="hyperliquid", - creds_env_var="HYPERLIQUID_WALLET_FILE", - env_var="HYPERLIQUID_TESTNET", - flag_key="testnet", - default_base_url_live="https://api.hyperliquid.xyz", - default_base_url_testnet="https://api.hyperliquid-testnet.xyz", - default_port=9012, - build_client=lambda creds, env_info: HyperliquidClient( - wallet_address=creds["wallet_address"], - private_key=creds["private_key"], - testnet=(env_info.environment == "testnet"), - api_wallet_address=creds.get("api_wallet_address"), - ), - build_app=create_app, -) - - -def main(): - run_exchange_main(SPEC) - - -if __name__ == "__main__": - main() diff --git a/services/mcp-hyperliquid/src/mcp_hyperliquid/client.py b/services/mcp-hyperliquid/src/mcp_hyperliquid/client.py deleted file mode 100644 index 662c911..0000000 --- a/services/mcp-hyperliquid/src/mcp_hyperliquid/client.py +++ /dev/null @@ -1,577 +0,0 @@ -"""Hyperliquid REST API client for perpetual futures trading.""" - -from __future__ import annotations - -import asyncio -import datetime as _dt -from typing import Any - -from mcp_common import indicators as ind -from mcp_common.http import async_client - -BASE_LIVE = "https://api.hyperliquid.xyz" -BASE_TESTNET = "https://api.hyperliquid-testnet.xyz" - -RESOLUTION_MAP = { - "1m": "1m", - "5m": "5m", - "15m": "15m", - "1h": "1h", - "4h": "4h", - "1d": "1d", -} - -try: - from eth_account import Account - from hyperliquid.exchange import Exchange - from hyperliquid.utils import constants as hl_constants - - _SDK_AVAILABLE = True -except ImportError: # pragma: no cover - _SDK_AVAILABLE = False - - -def _to_ms(date_str: str) -> int: - try: - dt = _dt.datetime.fromisoformat(date_str) - except ValueError: - dt = _dt.datetime.strptime(date_str, "%Y-%m-%d") - return int(dt.timestamp() * 1000) - - -class HyperliquidClient: - """Async client for the Hyperliquid API. - - Read operations use direct HTTP calls via httpx against /info. - Write operations delegate to hyperliquid-python-sdk for EIP-712 signing. - """ - - def __init__( - self, - wallet_address: str, - private_key: str, - testnet: bool = True, - api_wallet_address: str | None = None, - ): - self.wallet_address = wallet_address - self.private_key = private_key - self.testnet = testnet - self.api_wallet_address = api_wallet_address or wallet_address - self.base_url = BASE_TESTNET if testnet else BASE_LIVE - self._exchange: Any | None = None - - # ── SDK exchange (lazy) ──────────────────────────────────── - - def _get_exchange(self) -> Any: - """Return (and cache) an SDK Exchange instance for write ops.""" - if not _SDK_AVAILABLE: - raise RuntimeError( - "hyperliquid-python-sdk is not installed; write operations unavailable." - ) - if self._exchange is None: - account = Account.from_key(self.private_key) - base_url = ( - hl_constants.TESTNET_API_URL if self.testnet else hl_constants.MAINNET_API_URL - ) - empty_spot_meta: dict[str, Any] = {"universe": [], "tokens": []} - self._exchange = Exchange( - account, - base_url, - account_address=self.wallet_address, - spot_meta=empty_spot_meta, - ) - return self._exchange - - # ── Internal helpers ─────────────────────────────────────── - - async def _post(self, payload: dict[str, Any]) -> Any: - """POST JSON to the /info endpoint.""" - async with async_client(timeout=15.0) as http: - resp = await http.post(f"{self.base_url}/info", json=payload) - resp.raise_for_status() - return resp.json() - - @staticmethod - async def _run_sync(func: Any, *args: Any, **kwargs: Any) -> Any: - """Run a synchronous SDK call in the default executor.""" - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, lambda: func(*args, **kwargs)) - - # ── Read tools ───────────────────────────────────────────── - - async def get_markets(self) -> list[dict[str, Any]]: - """List all perp markets with metadata and current stats.""" - data = await self._post({"type": "metaAndAssetCtxs"}) - universe = data[0]["universe"] - ctx_list = data[1] - markets = [] - for meta, ctx in zip(universe, ctx_list, strict=True): - markets.append( - { - "asset": meta["name"], - "mark_price": float(ctx.get("markPx", 0)), - "funding_rate": float(ctx.get("funding", 0)), - "open_interest": float(ctx.get("openInterest", 0)), - "volume_24h": float(ctx.get("dayNtlVlm", 0)), - "max_leverage": int(meta.get("maxLeverage", 1)), - } - ) - return markets - - async def get_ticker(self, instrument: str) -> dict[str, Any]: - """Get ticker information for a specific asset.""" - markets = await self.get_markets() - for m in markets: - if m["asset"].upper() == instrument.upper(): - return { - "asset": m["asset"], - "mark_price": m["mark_price"], - "mid_price": m["mark_price"], - "funding_rate": m["funding_rate"], - "open_interest": m["open_interest"], - "volume_24h": m["volume_24h"], - "premium": 0.0, - } - return {"error": f"Asset {instrument} not found"} - - async def get_orderbook(self, instrument: str, depth: int = 10) -> dict[str, Any]: - """Get L2 order book for an asset.""" - data = await self._post({"type": "l2Book", "coin": instrument.upper()}) - levels = data.get("levels", [[], []]) - bids = [{"price": float(b["px"]), "size": float(b["sz"])} for b in levels[0][:depth]] - asks = [{"price": float(a["px"]), "size": float(a["sz"])} for a in levels[1][:depth]] - return {"asset": instrument, "bids": bids, "asks": asks} - - async def get_positions(self) -> list[dict[str, Any]]: - """Get open positions for the wallet.""" - data = await self._post( - {"type": "clearinghouseState", "user": self.wallet_address} - ) - positions = [] - for ap in data.get("assetPositions", []): - pos = ap.get("position", {}) - size = float(pos.get("szi", 0)) - if size == 0: - continue - leverage_data = pos.get("leverage", {}) - lev_value = ( - leverage_data.get("value", "1") - if isinstance(leverage_data, dict) - else str(leverage_data) - ) - positions.append( - { - "asset": pos.get("coin", ""), - "size": abs(size), - "direction": "long" if size > 0 else "short", - "entry_price": float(pos.get("entryPx", 0) or 0), - "unrealized_pnl": float(pos.get("unrealizedPnl", 0)), - "leverage": float(lev_value), - "liquidation_price": float(pos.get("liquidationPx", 0) or 0), - } - ) - return positions - - async def get_account_summary(self) -> dict[str, Any]: - """Get account summary (equity, balance, margin) including spot balances. - - Con Unified Account, spot USDC e perps condividono collaterale. - `spot_fetch_ok` / `perps_fetch_ok` indicano se i due lati sono stati - letti correttamente: se uno dei due è False il chiamante dovrebbe - considerare `equity`/`available_balance` un lower bound. - """ - perps_fetch_ok = True - perps_equity = 0.0 - perps_available = 0.0 - margin_used = 0.0 - unrealized_pnl = 0.0 - try: - data = await self._post( - {"type": "clearinghouseState", "user": self.wallet_address} - ) - margin = data.get("marginSummary") or {} - perps_equity = float(margin.get("accountValue", 0) or 0) - perps_available = float(margin.get("totalRawUsd", 0) or 0) - margin_used = float(margin.get("totalMarginUsed", 0) or 0) - unrealized_pnl = float(margin.get("totalNtlPos", 0) or 0) - except Exception: - perps_fetch_ok = False - - spot_fetch_ok = True - spot_usdc = 0.0 - try: - spot_data = await self._post( - {"type": "spotClearinghouseState", "user": self.wallet_address} - ) - for b in spot_data.get("balances", []) or []: - if b.get("coin") == "USDC": - spot_usdc = float(b.get("total", 0) or 0) - except Exception: - spot_fetch_ok = False - - total_equity = perps_equity + spot_usdc - total_available = perps_available + spot_usdc - return { - "equity": total_equity, - "perps_equity": perps_equity, - "perps_available": perps_available, - "spot_usdc": spot_usdc, - "available_balance": total_available, - "margin_used": margin_used, - "unrealized_pnl": unrealized_pnl, - "perps_fetch_ok": perps_fetch_ok, - "spot_fetch_ok": spot_fetch_ok, - } - - async def get_trade_history(self, limit: int = 100) -> list[dict[str, Any]]: - """Get recent trade fills.""" - data = await self._post({"type": "userFills", "user": self.wallet_address}) - trades = [] - for t in data[:limit]: - trades.append( - { - "asset": t.get("coin", ""), - "side": t.get("side", ""), - "size": float(t.get("sz", 0)), - "price": float(t.get("px", 0)), - "fee": float(t.get("fee", 0)), - "timestamp": t.get("time", ""), - } - ) - return trades - - async def get_historical( - self, instrument: str, start_date: str, end_date: str, resolution: str = "1h" - ) -> dict[str, Any]: - """Get OHLCV candles for an asset.""" - start_ms = _to_ms(start_date) - end_ms = _to_ms(end_date) - interval = RESOLUTION_MAP.get(resolution, resolution) - data = await self._post( - { - "type": "candleSnapshot", - "req": { - "coin": instrument.upper(), - "interval": interval, - "startTime": start_ms, - "endTime": end_ms, - }, - } - ) - candles = [] - for c in data: - candles.append( - { - "timestamp": c.get("t", 0), - "open": float(c.get("o", 0)), - "high": float(c.get("h", 0)), - "low": float(c.get("l", 0)), - "close": float(c.get("c", 0)), - "volume": float(c.get("v", 0)), - } - ) - return {"candles": candles} - - async def get_open_orders(self) -> list[dict[str, Any]]: - """Get all open orders for the wallet.""" - data = await self._post({"type": "openOrders", "user": self.wallet_address}) - orders = [] - for o in data: - orders.append( - { - "oid": o.get("oid"), - "asset": o.get("coin", ""), - "side": o.get("side", ""), - "size": float(o.get("sz", 0)), - "price": float(o.get("limitPx", 0)), - "order_type": o.get("orderType", ""), - } - ) - return orders - - async def basis_spot_perp(self, asset: str) -> dict[str, Any]: - asset = asset.upper() - # Spot reference price from Coinbase (mainnet reference, anche se HL è testnet) - spot_price: float | None = None - spot_source = "coinbase" - try: - async with async_client(timeout=8.0) as c: - resp = await c.get(f"https://api.coinbase.com/v2/prices/{asset}-USD/spot") - if resp.status_code == 200: - spot_price = float(resp.json().get("data", {}).get("amount")) - except Exception: - spot_price = None - if spot_price is None: - try: - async with async_client(timeout=8.0) as c: - resp = await c.get( - "https://api.kraken.com/0/public/Ticker", params={"pair": f"{asset}USD"} - ) - if resp.status_code == 200: - res = resp.json().get("result") or {} - first = next(iter(res.values()), {}) - price = (first.get("c") or [None])[0] - spot_price = float(price) if price else None - spot_source = "kraken" - except Exception: - pass - - # Perp price + funding from HL - try: - ctx = await self._post({"type": "metaAndAssetCtxs"}) - universe = ctx[0]["universe"] - ctx_list = ctx[1] - perp_price = None - funding = None - for meta, c in zip(universe, ctx_list, strict=True): - if meta["name"].upper() == asset: - perp_price = float(c.get("markPx", 0)) - funding = float(c.get("funding", 0)) - break - except Exception: - perp_price = None - funding = None - - if spot_price is None or perp_price is None: - return { - "asset": asset, - "spot_price": spot_price, - "perp_price": perp_price, - "error": "missing spot or perp price", - } - - basis_abs = perp_price - spot_price - basis_pct = round(basis_abs / spot_price * 100, 4) - basis_ann_funding = ( - round(funding * 24 * 365 * 100, 2) if funding is not None else None - ) - carry_opp = bool( - basis_ann_funding is not None - and basis_ann_funding > 5 - and (funding or 0) > 0.0001 - ) - - return { - "asset": asset, - "spot_price": spot_price, - "spot_source": spot_source, - "perp_price": perp_price, - "basis_absolute": round(basis_abs, 4), - "basis_pct": basis_pct, - "current_funding_hourly": funding, - "basis_annualized_funding_only": basis_ann_funding, - "carry_opportunity": carry_opp, - "testnet": self.testnet, - "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), - } - - async def get_funding_rate(self, instrument: str) -> dict[str, Any]: - """Get current and recent historical funding rates for an asset.""" - data = await self._post({"type": "metaAndAssetCtxs"}) - universe = data[0]["universe"] - ctx_list = data[1] - current_rate = None - for meta, ctx in zip(universe, ctx_list, strict=True): - if meta["name"].upper() == instrument.upper(): - current_rate = float(ctx.get("funding", 0)) - break - if current_rate is None: - return {"error": f"Asset {instrument} not found"} - - # Fetch funding history (last 7 days) - end_ms = int(_dt.datetime.utcnow().timestamp() * 1000) - start_ms = end_ms - 7 * 24 * 3600 * 1000 - history_data = await self._post( - { - "type": "fundingHistory", - "coin": instrument.upper(), - "startTime": start_ms, - "endTime": end_ms, - } - ) - history = [] - for entry in history_data: - history.append( - { - "timestamp": entry.get("time", 0), - "funding_rate": float(entry.get("fundingRate", 0)), - } - ) - return { - "asset": instrument, - "current_funding_rate": current_rate, - "history": history, - } - - async def get_indicators( - self, - instrument: str, - indicators: list[str], - start_date: str, - end_date: str, - resolution: str = "1h", - ) -> dict[str, Any]: - """Compute technical indicators over OHLCV data.""" - historical = await self.get_historical(instrument, start_date, end_date, resolution) - candles = historical.get("candles", []) - closes = [c["close"] for c in candles] - highs = [c["high"] for c in candles] - lows = [c["low"] for c in candles] - - result: dict[str, Any] = {} - for indicator in indicators: - name = indicator.lower() - if name == "sma": - result["sma"] = ind.sma(closes, 20) - elif name == "rsi": - result["rsi"] = ind.rsi(closes) - elif name == "atr": - result["atr"] = ind.atr(highs, lows, closes) - elif name == "macd": - result["macd"] = ind.macd(closes) - elif name == "adx": - result["adx"] = ind.adx(highs, lows, closes) - else: - result[name] = None - return result - - # ── Write tools (via SDK) ────────────────────────────────── - - async def place_order( - self, - instrument: str, - side: str, - amount: float, - type: str = "limit", - price: float | None = None, - reduce_only: bool = False, - ) -> dict[str, Any]: - """Place an order on Hyperliquid using the SDK for EIP-712 signing.""" - exchange = self._get_exchange() - is_buy = side.lower() in ("buy", "long") - coin = instrument.upper() - - if type == "market": - ot: dict[str, Any] = {"limit": {"tif": "Ioc"}} - if price is None: - ticker = await self.get_ticker(coin) - mark = ticker.get("mark_price", 0) - price = round(mark * 1.03, 1) if is_buy else round(mark * 0.97, 1) - elif type in ("stop_market", "stop_loss"): - ot = {"trigger": {"triggerPx": float(price), "isMarket": True, "tpsl": "sl"}} - elif type == "take_profit": - ot = {"trigger": {"triggerPx": float(price), "isMarket": True, "tpsl": "tp"}} - else: - ot = {"limit": {"tif": "Gtc"}} - - if price is None: - return {"error": "price is required for limit orders"} - - result = await self._run_sync( - exchange.order, coin, is_buy, amount, price, ot, reduce_only - ) - - status = result.get("status", "unknown") - response = result.get("response", {}) - if isinstance(response, str): - return { - "status": status, - "error": response, - "order_id": "", - "filled_size": 0, - "avg_fill_price": 0, - } - - statuses = response.get("data", {}).get("statuses", [{}]) - first = statuses[0] if statuses else {} - if isinstance(first, str): - return { - "status": status, - "error": first, - "order_id": "", - "filled_size": 0, - "avg_fill_price": 0, - } - return { - "order_id": first.get("resting", {}).get( - "oid", first.get("filled", {}).get("oid", "") - ), - "status": status, - "filled_size": float(first.get("filled", {}).get("totalSz", 0)), - "avg_fill_price": float(first.get("filled", {}).get("avgPx", 0)), - } - - async def cancel_order(self, order_id: str, instrument: str) -> dict[str, Any]: - """Cancel an existing order using the SDK.""" - exchange = self._get_exchange() - result = await self._run_sync( - exchange.cancel, instrument.upper(), int(order_id) - ) - status = result.get("status", "unknown") - response = result.get("response", "") - if isinstance(response, str) and status == "err": - return {"order_id": order_id, "status": status, "error": response} - return {"order_id": order_id, "status": status} - - async def set_stop_loss( - self, instrument: str, stop_price: float, size: float - ) -> dict[str, Any]: - """Set a stop-loss trigger order.""" - # Determine direction by checking open position - positions = await self.get_positions() - direction = "sell" # default: assume long - for pos in positions: - if pos["asset"].upper() == instrument.upper(): - direction = "sell" if pos["direction"] == "long" else "buy" - if size == 0: - size = pos["size"] - break - return await self.place_order( - instrument=instrument, - side=direction, - amount=size, - type="stop_loss", - price=stop_price, - reduce_only=True, - ) - - async def set_take_profit( - self, instrument: str, tp_price: float, size: float - ) -> dict[str, Any]: - """Set a take-profit trigger order.""" - positions = await self.get_positions() - direction = "sell" # default: assume long - for pos in positions: - if pos["asset"].upper() == instrument.upper(): - direction = "sell" if pos["direction"] == "long" else "buy" - if size == 0: - size = pos["size"] - break - return await self.place_order( - instrument=instrument, - side=direction, - amount=size, - type="take_profit", - price=tp_price, - reduce_only=True, - ) - - async def close_position(self, instrument: str) -> dict[str, Any]: - """Close an open position for the given asset using market_close.""" - exchange = self._get_exchange() - try: - result = await self._run_sync(exchange.market_close, instrument.upper()) - return { - "status": result.get("status", "unknown"), - "asset": instrument, - } - except Exception as exc: - return {"error": str(exc), "asset": instrument} - - async def health(self) -> dict[str, Any]: - """Health check — ping /info for server status.""" - try: - await self._post({"type": "meta"}) - return {"status": "ok", "testnet": self.testnet} - except Exception as exc: - return {"status": "error", "error": str(exc)} diff --git a/services/mcp-hyperliquid/src/mcp_hyperliquid/leverage_cap.py b/services/mcp-hyperliquid/src/mcp_hyperliquid/leverage_cap.py deleted file mode 100644 index d04dd51..0000000 --- a/services/mcp-hyperliquid/src/mcp_hyperliquid/leverage_cap.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Leverage cap server-side per place_order. - -Cap letto dal secret JSON via campo `max_leverage`. Default 1 (cash) se assente. -""" -from __future__ import annotations - -from fastapi import HTTPException - - -def get_max_leverage(creds: dict) -> int: - """Legge max_leverage dal secret. Default 1 se mancante.""" - raw = creds.get("max_leverage", 1) - try: - value = int(raw) - except (TypeError, ValueError): - value = 1 - return max(1, value) - - -def enforce_leverage( - requested: int | float | None, - *, - creds: dict, - exchange: str, -) -> int: - """Verifica e applica leverage cap. Ritorna leverage applicabile. - - Solleva HTTPException(403, LEVERAGE_CAP_EXCEEDED) se requested > cap. - Se requested is None, applica il cap come default. - """ - cap = get_max_leverage(creds) - if requested is None: - return cap - lev = int(requested) - if lev < 1: - raise HTTPException( - status_code=403, - detail={ - "error": "LEVERAGE_CAP_EXCEEDED", - "exchange": exchange, - "requested": lev, - "max": cap, - "reason": "leverage must be >= 1", - }, - ) - if lev > cap: - raise HTTPException( - status_code=403, - detail={ - "error": "LEVERAGE_CAP_EXCEEDED", - "exchange": exchange, - "requested": lev, - "max": cap, - }, - ) - return lev diff --git a/services/mcp-hyperliquid/src/mcp_hyperliquid/server.py b/services/mcp-hyperliquid/src/mcp_hyperliquid/server.py deleted file mode 100644 index ffb434f..0000000 --- a/services/mcp-hyperliquid/src/mcp_hyperliquid/server.py +++ /dev/null @@ -1,408 +0,0 @@ -from __future__ import annotations - -import os - -from fastapi import Depends, FastAPI, HTTPException -from mcp_common.audit import audit_write_op -from mcp_common.auth import Principal, TokenStore, require_principal -from mcp_common.environment import EnvironmentInfo -from mcp_common.mcp_bridge import mount_mcp_endpoint -from mcp_common.server import build_app -from pydantic import BaseModel, field_validator, model_validator - -from mcp_hyperliquid.client import HyperliquidClient -from mcp_hyperliquid.leverage_cap import enforce_leverage as _enforce_leverage -from mcp_hyperliquid.leverage_cap import get_max_leverage - -# --- Body models --- - -class GetMarketsReq(BaseModel): - pass - - -class GetTickerReq(BaseModel): - instrument: str - - -class GetOrderbookReq(BaseModel): - instrument: str - depth: int = 10 - - -class GetPositionsReq(BaseModel): - pass - - -class GetAccountSummaryReq(BaseModel): - pass - - -class GetTradeHistoryReq(BaseModel): - limit: int = 100 - - -class GetHistoricalReq(BaseModel): - instrument: str | None = None - asset: str | None = None - start_date: str | None = None - end_date: str | None = None - resolution: str = "1h" - interval: str | None = None - limit: int = 50 - - model_config = {"extra": "allow"} - - @model_validator(mode="after") - def _normalize(self): - from datetime import UTC, datetime, timedelta - sym = self.instrument or self.asset - if not sym: - raise ValueError("instrument (or asset) is required") - self.instrument = sym - if self.interval: - self.resolution = self.interval - if not self.end_date: - self.end_date = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") - if not self.start_date: - days = max(1, self.limit // 6) - self.start_date = ( - datetime.now(UTC) - timedelta(days=days) - ).strftime("%Y-%m-%dT%H:%M:%S") - return self - - -class GetOpenOrdersReq(BaseModel): - pass - - -class GetFundingRateReq(BaseModel): - instrument: str - - -class BasisSpotPerpReq(BaseModel): - asset: str - - -class GetIndicatorsReq(BaseModel): - instrument: str | None = None - asset: str | None = None - indicators: list[str] = ["rsi", "atr", "macd", "adx"] - start_date: str | None = None - end_date: str | None = None - resolution: str = "1h" - interval: str | None = None - limit: int = 50 - - model_config = {"extra": "allow"} - - @model_validator(mode="after") - def _normalize(self): - from datetime import UTC, datetime, timedelta - sym = self.instrument or self.asset - if not sym: - raise ValueError("instrument (or asset) is required") - self.instrument = sym - if self.interval: - self.resolution = self.interval - if not self.end_date: - self.end_date = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") - if not self.start_date: - days = max(2, self.limit // 6) - self.start_date = ( - datetime.now(UTC) - timedelta(days=days) - ).strftime("%Y-%m-%dT%H:%M:%S") - return self - - @field_validator("indicators", mode="before") - @classmethod - def _coerce_indicators(cls, v): - if isinstance(v, str): - import json - s = v.strip() - if s.startswith("["): - try: - parsed = json.loads(s) - if isinstance(parsed, list): - return [str(x).strip() for x in parsed if str(x).strip()] - except json.JSONDecodeError: - pass - return [x.strip() for x in s.split(",") if x.strip()] - if isinstance(v, list): - return v - raise ValueError( - "indicators must be a list like ['rsi','atr','macd'] " - "or a comma-separated string like 'rsi,atr,macd'" - ) - - -class PlaceOrderReq(BaseModel): - instrument: str - side: str # "buy" | "sell" - amount: float - type: str = "limit" - price: float | None = None - reduce_only: bool = False - leverage: int | None = None # CER-016: None → default cap (3x) - - -class CancelOrderReq(BaseModel): - order_id: str - instrument: str - - -class SetStopLossReq(BaseModel): - instrument: str - stop_price: float - size: float - - -class SetTakeProfitReq(BaseModel): - instrument: str - tp_price: float - size: float - - -class ClosePositionReq(BaseModel): - instrument: str - - -# --- ACL helper --- - -def _check(principal: Principal, *, core: bool = False, observer: bool = False) -> None: - allowed: set[str] = set() - if core: - allowed.add("core") - if observer: - allowed.add("observer") - if not (principal.capabilities & allowed): - raise HTTPException(403, f"capability required: {allowed}") - - -# --- App factory --- - -def create_app( - *, - client: HyperliquidClient, - token_store: TokenStore, - creds: dict | None = None, - env_info: EnvironmentInfo | None = None, -) -> FastAPI: - creds = creds or {} - app = build_app(name="mcp-hyperliquid", version="0.1.0", token_store=token_store) - - # --- Read tools: core + observer --- - - @app.post("/tools/environment_info", tags=["reads"]) - async def t_environment_info(principal: Principal = Depends(require_principal)): - _check(principal, core=True, observer=True) - if env_info is None: - return { - "exchange": "hyperliquid", - "environment": "testnet" if getattr(client, "testnet", True) else "mainnet", - "source": "credentials", - "env_value": None, - "base_url": getattr(client, "base_url", None), - "max_leverage": get_max_leverage(creds), - } - return { - "exchange": env_info.exchange, - "environment": env_info.environment, - "source": env_info.source, - "env_value": env_info.env_value, - "base_url": env_info.base_url, - "max_leverage": get_max_leverage(creds), - } - - @app.post("/tools/get_markets", tags=["reads"]) - async def t_get_markets( - body: GetMarketsReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_markets() - - @app.post("/tools/get_ticker", tags=["reads"]) - async def t_get_ticker( - body: GetTickerReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_ticker(body.instrument) - - @app.post("/tools/get_orderbook", tags=["reads"]) - async def t_get_orderbook( - body: GetOrderbookReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_orderbook(body.instrument, body.depth) - - @app.post("/tools/get_positions", tags=["reads"]) - async def t_get_positions( - body: GetPositionsReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_positions() - - @app.post("/tools/get_account_summary", tags=["reads"]) - async def t_get_account_summary( - body: GetAccountSummaryReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_account_summary() - - @app.post("/tools/get_trade_history", tags=["reads"]) - async def t_get_trade_history( - body: GetTradeHistoryReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_trade_history(body.limit) - - @app.post("/tools/get_historical", tags=["reads"]) - async def t_get_historical( - body: GetHistoricalReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_historical( - body.instrument, body.start_date, body.end_date, body.resolution - ) - - @app.post("/tools/get_open_orders", tags=["reads"]) - async def t_get_open_orders( - body: GetOpenOrdersReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_open_orders() - - @app.post("/tools/get_funding_rate", tags=["reads"]) - async def t_get_funding_rate( - body: GetFundingRateReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_funding_rate(body.instrument) - - @app.post("/tools/basis_spot_perp", tags=["writes"]) - async def t_basis_spot_perp( - body: BasisSpotPerpReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.basis_spot_perp(body.asset) - - @app.post("/tools/get_indicators", tags=["reads"]) - async def t_get_indicators( - body: GetIndicatorsReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await client.get_indicators( - body.instrument, - body.indicators, - body.start_date, - body.end_date, - body.resolution, - ) - - # --- Write tools: core only --- - - @app.post("/tools/place_order", tags=["writes"]) - async def t_place_order( - body: PlaceOrderReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True) - _enforce_leverage(body.leverage, creds=creds, exchange="hyperliquid") - result = await client.place_order( - instrument=body.instrument, - side=body.side, - amount=body.amount, - type=body.type, - price=body.price, - reduce_only=body.reduce_only, - ) - audit_write_op( - principal=principal, action="place_order", exchange="hyperliquid", - target=body.instrument, - payload={"side": body.side, "amount": body.amount, "type": body.type, - "price": body.price, "reduce_only": body.reduce_only, - "leverage": body.leverage}, - result=result, - ) - return result - - @app.post("/tools/cancel_order", tags=["writes"]) - async def t_cancel_order( - body: CancelOrderReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True) - result = await client.cancel_order(body.order_id, body.instrument) - audit_write_op( - principal=principal, action="cancel_order", exchange="hyperliquid", - target=body.order_id, payload={"instrument": body.instrument}, result=result, - ) - return result - - @app.post("/tools/set_stop_loss", tags=["writes"]) - async def t_set_sl( - body: SetStopLossReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True) - result = await client.set_stop_loss(body.instrument, body.stop_price, body.size) - audit_write_op( - principal=principal, action="set_stop_loss", exchange="hyperliquid", - target=body.instrument, - payload={"stop_price": body.stop_price, "size": body.size}, - result=result, - ) - return result - - @app.post("/tools/set_take_profit", tags=["writes"]) - async def t_set_tp( - body: SetTakeProfitReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True) - result = await client.set_take_profit(body.instrument, body.tp_price, body.size) - audit_write_op( - principal=principal, action="set_take_profit", exchange="hyperliquid", - target=body.instrument, - payload={"tp_price": body.tp_price, "size": body.size}, - result=result, - ) - return result - - @app.post("/tools/close_position", tags=["writes"]) - async def t_close_position( - body: ClosePositionReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True) - result = await client.close_position(body.instrument) - audit_write_op( - principal=principal, action="close_position", exchange="hyperliquid", - target=body.instrument, payload={}, result=result, - ) - return result - - # ───── MCP endpoint (/mcp) — bridge verso /tools/* ───── - port = int(os.environ.get("PORT", "9012")) - mount_mcp_endpoint( - app, - name="cerbero-hyperliquid", - version="0.1.0", - token_store=token_store, - internal_base_url=f"http://localhost:{port}", - tools=[ - {"name": "environment_info", "description": "Ambiente operativo (testnet/mainnet), source, base_url, max_leverage cap."}, - {"name": "get_markets", "description": "Lista mercati perp disponibili."}, - {"name": "get_ticker", "description": "Ticker di un perp."}, - {"name": "get_orderbook", "description": "Orderbook L2."}, - {"name": "get_positions", "description": "Posizioni aperte."}, - {"name": "get_account_summary", "description": "Account summary (spot + perp equity)."}, - {"name": "get_trade_history", "description": "Storia trade."}, - {"name": "get_historical", "description": "OHLCV storico."}, - {"name": "get_open_orders", "description": "Ordini aperti."}, - {"name": "get_funding_rate", "description": "Funding rate corrente per simbolo."}, - {"name": "basis_spot_perp", "description": "Basis spot-perp annualizzato + carry opportunity detection."}, - {"name": "get_indicators", "description": "Indicatori tecnici."}, - {"name": "place_order", "description": "Invia ordine (CORE only)."}, - {"name": "cancel_order", "description": "Cancella ordine."}, - {"name": "set_stop_loss", "description": "Stop loss su posizione."}, - {"name": "set_take_profit", "description": "Take profit su posizione."}, - {"name": "close_position", "description": "Chiude posizione."}, - ], - ) - - return app diff --git a/services/mcp-hyperliquid/tests/test_client.py b/services/mcp-hyperliquid/tests/test_client.py deleted file mode 100644 index 342c084..0000000 --- a/services/mcp-hyperliquid/tests/test_client.py +++ /dev/null @@ -1,227 +0,0 @@ -from __future__ import annotations - -import re - -import pytest -from mcp_hyperliquid.client import HyperliquidClient -from pytest_httpx import HTTPXMock - - -@pytest.fixture -def client(): - return HyperliquidClient( - wallet_address="0xDeadBeef", - private_key="0x" + "a" * 64, - testnet=True, - ) - - -# Shared mock responses - -META_AND_CTX = [ - { - "universe": [ - {"name": "BTC", "maxLeverage": 50}, - {"name": "ETH", "maxLeverage": 25}, - ] - }, - [ - { - "markPx": "50000.0", - "funding": "0.0001", - "openInterest": "1000.0", - "dayNtlVlm": "500000.0", - }, - { - "markPx": "3000.0", - "funding": "0.00005", - "openInterest": "500.0", - "dayNtlVlm": "200000.0", - }, - ], -] - -CLEARINGHOUSE_STATE = { - "marginSummary": { - "accountValue": "1500.0", - "totalRawUsd": "1200.0", - "totalMarginUsed": "300.0", - "totalNtlPos": "50.0", - }, - "assetPositions": [ - { - "position": { - "coin": "BTC", - "szi": "0.1", - "entryPx": "48000.0", - "unrealizedPnl": "200.0", - "leverage": {"value": "10"}, - "liquidationPx": "40000.0", - } - } - ], -} - -SPOT_STATE = {"balances": [{"coin": "USDC", "total": "500.0"}]} - - -@pytest.mark.asyncio -async def test_get_markets(httpx_mock: HTTPXMock, client: HyperliquidClient): - httpx_mock.add_response( - url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), - json=META_AND_CTX, - ) - markets = await client.get_markets() - assert len(markets) == 2 - assert markets[0]["asset"] == "BTC" - assert markets[0]["mark_price"] == 50000.0 - assert markets[0]["max_leverage"] == 50 - - -@pytest.mark.asyncio -async def test_get_ticker(httpx_mock: HTTPXMock, client: HyperliquidClient): - httpx_mock.add_response( - url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), - json=META_AND_CTX, - ) - result = await client.get_ticker("BTC") - assert result["asset"] == "BTC" - assert result["mark_price"] == 50000.0 - assert result["funding_rate"] == 0.0001 - - -@pytest.mark.asyncio -async def test_get_ticker_not_found(httpx_mock: HTTPXMock, client: HyperliquidClient): - httpx_mock.add_response( - url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), - json=META_AND_CTX, - ) - result = await client.get_ticker("SOL") - assert "error" in result - - -@pytest.mark.asyncio -async def test_get_orderbook(httpx_mock: HTTPXMock, client: HyperliquidClient): - httpx_mock.add_response( - url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), - json={ - "levels": [ - [{"px": "49990.0", "sz": "0.5"}, {"px": "49980.0", "sz": "1.0"}], - [{"px": "50010.0", "sz": "0.3"}, {"px": "50020.0", "sz": "0.8"}], - ] - }, - ) - result = await client.get_orderbook("BTC", depth=2) - assert result["asset"] == "BTC" - assert len(result["bids"]) == 2 - assert len(result["asks"]) == 2 - assert result["bids"][0]["price"] == 49990.0 - - -@pytest.mark.asyncio -async def test_get_positions(httpx_mock: HTTPXMock, client: HyperliquidClient): - httpx_mock.add_response( - url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), - json=CLEARINGHOUSE_STATE, - ) - positions = await client.get_positions() - assert len(positions) == 1 - assert positions[0]["asset"] == "BTC" - assert positions[0]["direction"] == "long" - assert positions[0]["size"] == 0.1 - assert positions[0]["leverage"] == 10.0 - - -@pytest.mark.asyncio -async def test_get_account_summary(httpx_mock: HTTPXMock, client: HyperliquidClient): - # get_account_summary calls /info twice (perp + spot) - httpx_mock.add_response( - url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), - json=CLEARINGHOUSE_STATE, - ) - httpx_mock.add_response( - url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), - json=SPOT_STATE, - ) - result = await client.get_account_summary() - assert result["perps_equity"] == 1500.0 - assert result["spot_usdc"] == 500.0 - assert result["equity"] == 2000.0 - - -@pytest.mark.asyncio -async def test_get_trade_history(httpx_mock: HTTPXMock, client: HyperliquidClient): - httpx_mock.add_response( - url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), - json=[ - {"coin": "BTC", "side": "B", "sz": "0.1", "px": "50000", "fee": "0.5", "time": 1000}, - {"coin": "ETH", "side": "A", "sz": "1.0", "px": "3000", "fee": "0.3", "time": 2000}, - ], - ) - trades = await client.get_trade_history(limit=10) - assert len(trades) == 2 - assert trades[0]["asset"] == "BTC" - assert trades[0]["price"] == 50000.0 - - -@pytest.mark.asyncio -async def test_get_open_orders(httpx_mock: HTTPXMock, client: HyperliquidClient): - httpx_mock.add_response( - url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), - json=[ - { - "oid": 12345, - "coin": "BTC", - "side": "B", - "sz": "0.05", - "limitPx": "49000", - "orderType": "Limit", - } - ], - ) - orders = await client.get_open_orders() - assert len(orders) == 1 - assert orders[0]["oid"] == 12345 - assert orders[0]["asset"] == "BTC" - - -@pytest.mark.asyncio -async def test_get_historical(httpx_mock: HTTPXMock, client: HyperliquidClient): - httpx_mock.add_response( - url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), - json=[ - {"t": 1000000, "o": "49000", "h": "51000", "l": "48500", "c": "50000", "v": "100"}, - ], - ) - result = await client.get_historical("BTC", "2024-01-01", "2024-01-02", "1h") - assert len(result["candles"]) == 1 - assert result["candles"][0]["close"] == 50000.0 - - -@pytest.mark.asyncio -async def test_health_ok(httpx_mock: HTTPXMock, client: HyperliquidClient): - httpx_mock.add_response( - url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), - json={"universe": []}, - ) - result = await client.health() - assert result["status"] in ("ok", "healthy") - assert result["testnet"] is True - - -@pytest.mark.asyncio -async def test_place_order_sdk_unavailable(client: HyperliquidClient): - """place_order raises RuntimeError when SDK is not available (mocked).""" - import mcp_hyperliquid.client as mod - - original = mod._SDK_AVAILABLE - mod._SDK_AVAILABLE = False - client._exchange = None - try: - result = await client.place_order("BTC", "buy", 0.1, price=50000.0) - # Should return error dict or raise RuntimeError - assert "error" in result or result.get("status") == "error" - except RuntimeError as exc: - assert "not installed" in str(exc).lower() or "sdk" in str(exc).lower() - finally: - mod._SDK_AVAILABLE = original diff --git a/services/mcp-hyperliquid/tests/test_environment_info.py b/services/mcp-hyperliquid/tests/test_environment_info.py deleted file mode 100644 index d9f7bf7..0000000 --- a/services/mcp-hyperliquid/tests/test_environment_info.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -from unittest.mock import MagicMock - -from fastapi.testclient import TestClient -from mcp_common.auth import Principal, TokenStore -from mcp_common.environment import EnvironmentInfo -from mcp_hyperliquid.server import create_app - - -def _make_app(env_info, creds): - c = MagicMock() - c.testnet = True - store = TokenStore(tokens={ - "ct": Principal("core", {"core"}), - "ot": Principal("observer", {"observer"}), - }) - return create_app(client=c, token_store=store, creds=creds, env_info=env_info) - - -def test_environment_info_full_shape(): - env = EnvironmentInfo( - exchange="hyperliquid", - environment="testnet", - source="env", - env_value="true", - base_url="https://api.hyperliquid-testnet.xyz", - ) - app = _make_app(env, creds={"max_leverage": 3}) - c = TestClient(app) - r = c.post("/tools/environment_info", headers={"Authorization": "Bearer ot"}) - assert r.status_code == 200 - body = r.json() - assert body["exchange"] == "hyperliquid" - assert body["environment"] == "testnet" - assert body["source"] == "env" - assert body["env_value"] == "true" - assert body["base_url"] == "https://api.hyperliquid-testnet.xyz" - assert body["max_leverage"] == 3 - - -def test_environment_info_requires_auth(): - env = EnvironmentInfo( - exchange="hyperliquid", environment="testnet", source="default", - env_value=None, base_url="https://api.hyperliquid-testnet.xyz", - ) - app = _make_app(env, creds={"max_leverage": 3}) - c = TestClient(app) - r = c.post("/tools/environment_info") - assert r.status_code == 401 diff --git a/services/mcp-hyperliquid/tests/test_leverage_cap.py b/services/mcp-hyperliquid/tests/test_leverage_cap.py deleted file mode 100644 index 51140b8..0000000 --- a/services/mcp-hyperliquid/tests/test_leverage_cap.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -import pytest -from fastapi import HTTPException -from mcp_hyperliquid.leverage_cap import enforce_leverage, get_max_leverage - - -def test_get_max_leverage_returns_creds_value(): - creds = {"max_leverage": 5} - assert get_max_leverage(creds) == 5 - - -def test_get_max_leverage_default_when_missing(): - """Default 1 (cash) se il secret non ha max_leverage.""" - assert get_max_leverage({}) == 1 - - -def test_enforce_leverage_pass_under_cap(): - creds = {"max_leverage": 3} - enforce_leverage(2, creds=creds, exchange="hyperliquid") # no raise - - -def test_enforce_leverage_pass_at_cap(): - creds = {"max_leverage": 3} - enforce_leverage(3, creds=creds, exchange="hyperliquid") # no raise - - -def test_enforce_leverage_reject_over_cap(): - creds = {"max_leverage": 3} - with pytest.raises(HTTPException) as exc: - enforce_leverage(10, creds=creds, exchange="hyperliquid") - assert exc.value.status_code == 403 - assert exc.value.detail["error"] == "LEVERAGE_CAP_EXCEEDED" - assert exc.value.detail["exchange"] == "hyperliquid" - assert exc.value.detail["requested"] == 10 - assert exc.value.detail["max"] == 3 - - -def test_enforce_leverage_reject_when_below_one(): - creds = {"max_leverage": 3} - with pytest.raises(HTTPException) as exc: - enforce_leverage(0, creds=creds, exchange="hyperliquid") - assert exc.value.status_code == 403 - - -def test_enforce_leverage_default_when_none(): - """Se requested è None, applica il cap come default.""" - creds = {"max_leverage": 3} - result = enforce_leverage(None, creds=creds, exchange="hyperliquid") - assert result == 3 diff --git a/services/mcp-hyperliquid/tests/test_server_acl.py b/services/mcp-hyperliquid/tests/test_server_acl.py deleted file mode 100644 index e1bf8f1..0000000 --- a/services/mcp-hyperliquid/tests/test_server_acl.py +++ /dev/null @@ -1,211 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest -from fastapi.testclient import TestClient -from mcp_common.auth import Principal, TokenStore -from mcp_hyperliquid.server import create_app - - -@pytest.fixture -def mock_client(): - c = MagicMock() - c.get_markets = AsyncMock(return_value=[{"asset": "BTC", "mark_price": 50000}]) - c.get_ticker = AsyncMock(return_value={"asset": "BTC", "mark_price": 50000}) - c.get_orderbook = AsyncMock(return_value={"bids": [], "asks": []}) - c.get_positions = AsyncMock(return_value=[]) - c.get_account_summary = AsyncMock(return_value={"equity": 1500, "perps_equity": 1000}) - c.get_trade_history = AsyncMock(return_value=[]) - c.get_historical = AsyncMock(return_value={"candles": []}) - c.get_open_orders = AsyncMock(return_value=[]) - c.get_funding_rate = AsyncMock(return_value={"asset": "BTC", "current_funding_rate": 0.0001}) - c.get_indicators = AsyncMock(return_value={"rsi": 55.0}) - c.place_order = AsyncMock(return_value={"order_id": "x", "status": "ok"}) - c.cancel_order = AsyncMock(return_value={"order_id": "x", "status": "ok"}) - c.set_stop_loss = AsyncMock(return_value={"order_id": "x", "status": "ok"}) - c.set_take_profit = AsyncMock(return_value={"order_id": "x", "status": "ok"}) - c.close_position = AsyncMock(return_value={"status": "ok", "asset": "BTC"}) - return c - - -@pytest.fixture -def http(mock_client): - store = TokenStore( - tokens={ - "ct": Principal("core", {"core"}), - "ot": Principal("observer", {"observer"}), - } - ) - app = create_app(client=mock_client, token_store=store, creds={"max_leverage": 3}) - return TestClient(app) - - -# --- Health --- - -def test_health(http): - assert http.get("/health").status_code == 200 - - -# --- Read tools: both core and observer allowed --- - -def test_get_markets_core_ok(http): - r = http.post("/tools/get_markets", headers={"Authorization": "Bearer ct"}, json={}) - assert r.status_code == 200 - - -def test_get_markets_observer_ok(http): - r = http.post("/tools/get_markets", headers={"Authorization": "Bearer ot"}, json={}) - assert r.status_code == 200 - - -def test_get_ticker_core_ok(http): - r = http.post( - "/tools/get_ticker", - headers={"Authorization": "Bearer ct"}, - json={"instrument": "BTC"}, - ) - assert r.status_code == 200 - assert r.json()["mark_price"] == 50000 - - -def test_get_ticker_observer_ok(http): - r = http.post( - "/tools/get_ticker", - headers={"Authorization": "Bearer ot"}, - json={"instrument": "BTC"}, - ) - assert r.status_code == 200 - - -def test_get_ticker_no_auth_401(http): - r = http.post("/tools/get_ticker", json={"instrument": "BTC"}) - assert r.status_code == 401 - - -def test_get_account_summary_observer_ok(http): - r = http.post( - "/tools/get_account_summary", - headers={"Authorization": "Bearer ot"}, - json={}, - ) - assert r.status_code == 200 - assert r.json()["equity"] == 1500 - - -def test_get_funding_rate_observer_ok(http): - r = http.post( - "/tools/get_funding_rate", - headers={"Authorization": "Bearer ot"}, - json={"instrument": "BTC"}, - ) - assert r.status_code == 200 - - -def test_get_positions_no_auth_401(http): - r = http.post("/tools/get_positions", json={}) - assert r.status_code == 401 - - -# --- Write tools: core only --- - -def test_place_order_core_ok(http): - # CER-016: amount * price = 150 < cap 200 - r = http.post( - "/tools/place_order", - headers={"Authorization": "Bearer ct"}, - json={"instrument": "BTC", "side": "buy", "amount": 0.003, "price": 50000}, - ) - assert r.status_code == 200 - - -def test_place_order_observer_forbidden(http): - r = http.post( - "/tools/place_order", - headers={"Authorization": "Bearer ot"}, - json={"instrument": "BTC", "side": "buy", "amount": 0.001, "price": 50000}, - ) - assert r.status_code == 403 - - -def test_place_order_leverage_cap_enforced_hl(http): - """Reject leverage > max_leverage (da secret, default 3).""" - r = http.post( - "/tools/place_order", - headers={"Authorization": "Bearer ct"}, - json={ - "instrument": "BTC", - "side": "buy", - "amount": 0.001, - "price": 50000, - "leverage": 10, - }, - ) - assert r.status_code == 403 - body = r.json() - err = body["error"] - assert err["code"] == "LEVERAGE_CAP_EXCEEDED" - assert err["details"]["exchange"] == "hyperliquid" - - -def test_cancel_order_core_ok(http): - r = http.post( - "/tools/cancel_order", - headers={"Authorization": "Bearer ct"}, - json={"order_id": "123", "instrument": "BTC"}, - ) - assert r.status_code == 200 - - -def test_cancel_order_observer_forbidden(http): - r = http.post( - "/tools/cancel_order", - headers={"Authorization": "Bearer ot"}, - json={"order_id": "123", "instrument": "BTC"}, - ) - assert r.status_code == 403 - - -def test_set_stop_loss_core_ok(http): - r = http.post( - "/tools/set_stop_loss", - headers={"Authorization": "Bearer ct"}, - json={"instrument": "BTC", "stop_price": 45000.0, "size": 0.1}, - ) - assert r.status_code == 200 - - -def test_set_stop_loss_observer_forbidden(http): - r = http.post( - "/tools/set_stop_loss", - headers={"Authorization": "Bearer ot"}, - json={"instrument": "BTC", "stop_price": 45000.0, "size": 0.1}, - ) - assert r.status_code == 403 - - -def test_set_take_profit_observer_forbidden(http): - r = http.post( - "/tools/set_take_profit", - headers={"Authorization": "Bearer ot"}, - json={"instrument": "BTC", "tp_price": 55000.0, "size": 0.1}, - ) - assert r.status_code == 403 - - -def test_close_position_core_ok(http): - r = http.post( - "/tools/close_position", - headers={"Authorization": "Bearer ct"}, - json={"instrument": "BTC"}, - ) - assert r.status_code == 200 - - -def test_close_position_observer_forbidden(http): - r = http.post( - "/tools/close_position", - headers={"Authorization": "Bearer ot"}, - json={"instrument": "BTC"}, - ) - assert r.status_code == 403 diff --git a/services/mcp-macro/pyproject.toml b/services/mcp-macro/pyproject.toml deleted file mode 100644 index 6462ba7..0000000 --- a/services/mcp-macro/pyproject.toml +++ /dev/null @@ -1,27 +0,0 @@ -[project] -name = "mcp-macro" -version = "0.1.0" -requires-python = ">=3.11" -dependencies = [ - "mcp-common", - "fastapi>=0.115", - "uvicorn[standard]>=0.30", - "httpx>=0.27", - "pydantic>=2.6", -] - -[project.optional-dependencies] -dev = ["pytest>=8", "pytest-asyncio>=0.23", "pytest-httpx>=0.30"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/mcp_macro"] - -[tool.uv.sources] -mcp-common = { workspace = true } - -[project.scripts] -mcp-macro = "mcp_macro.__main__:main" diff --git a/services/mcp-macro/src/mcp_macro/__init__.py b/services/mcp-macro/src/mcp_macro/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/mcp-macro/src/mcp_macro/__main__.py b/services/mcp-macro/src/mcp_macro/__main__.py deleted file mode 100644 index f985d79..0000000 --- a/services/mcp-macro/src/mcp_macro/__main__.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -import json -import os - -import uvicorn -from mcp_common.auth import load_token_store_from_files -from mcp_common.logging import configure_root_logging - -from mcp_macro.server import create_app - -configure_root_logging() # CER-P5-009 - -def main(): - creds_file = os.environ["MACRO_CREDENTIALS_FILE"] - with open(creds_file) as f: - creds = json.load(f) - - token_store = load_token_store_from_files( - core_token_file=os.environ.get("CORE_TOKEN_FILE"), - observer_token_file=os.environ.get("OBSERVER_TOKEN_FILE"), - ) - app = create_app( - fred_api_key=creds.get("fred_api_key", ""), - finnhub_api_key=creds.get("finnhub_api_key", ""), - token_store=token_store, - ) - uvicorn.run( - app, - log_config=None, # CER-P5-009: delega al root JSON logger - host=os.environ.get("HOST", "0.0.0.0"), - port=int(os.environ.get("PORT", "9013")), - ) - - -if __name__ == "__main__": - main() diff --git a/services/mcp-macro/src/mcp_macro/cot.py b/services/mcp-macro/src/mcp_macro/cot.py deleted file mode 100644 index a18645f..0000000 --- a/services/mcp-macro/src/mcp_macro/cot.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Pure-logic helpers per COT report parsing e analytics. - -Niente HTTP qui — orchestrazione fetch sta in fetchers.py. Tutto testabile -in isolamento. -""" -from __future__ import annotations - -from typing import Literal - -ExtremeSignal = Literal["extreme_short", "extreme_long", "neutral"] - - -def compute_percentile(value: float, history: list[float]) -> float | None: - """Percentile di `value` rispetto ad `history` (0-100, inclusive). - - Restituisce None se history vuoto. Clipped a [0, 100] se value fuori range. - """ - if not history: - return None - n = len(history) - below_or_eq = sum(1 for h in history if h <= value) - pct = 100.0 * below_or_eq / n - return max(0.0, min(100.0, pct)) - - -def classify_extreme(percentile: float | None, threshold: float = 5.0) -> ExtremeSignal: - """Classifica un percentile come estremo short/long o neutral. - - threshold default 5 → flagga ≤ 5 come short, ≥ 100-5=95 come long. - """ - if percentile is None: - return "neutral" - if percentile <= threshold: - return "extreme_short" - if percentile >= 100.0 - threshold: - return "extreme_long" - return "neutral" - - -def _to_int(v) -> int: - try: - return int(float(v)) - except (TypeError, ValueError): - return 0 - - -def _date_only(s: str) -> str: - """Estrae 'YYYY-MM-DD' da una data ISO con o senza timestamp.""" - if not s: - return "" - return s.split("T", 1)[0] - - -def parse_tff_row(raw: dict) -> dict: - """Mappa una row Socrata TFF al formato API output.""" - dl = _to_int(raw.get("dealer_positions_long_all")) - ds = _to_int(raw.get("dealer_positions_short_all")) - al = _to_int(raw.get("asset_mgr_positions_long")) - as_ = _to_int(raw.get("asset_mgr_positions_short")) - ll = _to_int(raw.get("lev_money_positions_long")) - ls = _to_int(raw.get("lev_money_positions_short")) - ol = _to_int(raw.get("other_rept_positions_long")) - os_ = _to_int(raw.get("other_rept_positions_short")) - return { - "report_date": _date_only(raw.get("report_date_as_yyyy_mm_dd", "")), - "dealer_long": dl, "dealer_short": ds, "dealer_net": dl - ds, - "asset_mgr_long": al, "asset_mgr_short": as_, "asset_mgr_net": al - as_, - "lev_funds_long": ll, "lev_funds_short": ls, "lev_funds_net": ll - ls, - "other_long": ol, "other_short": os_, "other_net": ol - os_, - "open_interest": _to_int(raw.get("open_interest_all")), - } - - -def parse_disagg_row(raw: dict) -> dict: - """Mappa una row Socrata Disaggregated F&O combined al formato API output.""" - pl = _to_int(raw.get("prod_merc_positions_long_all")) - ps = _to_int(raw.get("prod_merc_positions_short_all")) - sl = _to_int(raw.get("swap_positions_long_all")) - ss = _to_int(raw.get("swap_positions_short_all")) - ml = _to_int(raw.get("m_money_positions_long_all")) - ms = _to_int(raw.get("m_money_positions_short_all")) - ol = _to_int(raw.get("other_rept_positions_long_all")) - os_ = _to_int(raw.get("other_rept_positions_short_all")) - return { - "report_date": _date_only(raw.get("report_date_as_yyyy_mm_dd", "")), - "producer_long": pl, "producer_short": ps, "producer_net": pl - ps, - "swap_long": sl, "swap_short": ss, "swap_net": sl - ss, - "managed_money_long": ml, "managed_money_short": ms, "managed_money_net": ml - ms, - "other_long": ol, "other_short": os_, "other_net": ol - os_, - "open_interest": _to_int(raw.get("open_interest_all")), - } diff --git a/services/mcp-macro/src/mcp_macro/cot_contracts.py b/services/mcp-macro/src/mcp_macro/cot_contracts.py deleted file mode 100644 index 6e110af..0000000 --- a/services/mcp-macro/src/mcp_macro/cot_contracts.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Costanti CFTC: ticker → contract_market_code per TFF e Disaggregated. - -I codici CFTC (`cftc_contract_market_code`) sono pubblici e stabili nel tempo. -Riferimento: https://www.cftc.gov/MarketReports/CommitmentsofTraders/ -""" -from __future__ import annotations - -CFTC_BASE_URL = "https://publicreporting.cftc.gov/resource" -TFF_DATASET_ID = "gpe5-46if" -DISAGG_DATASET_ID = "72hh-3qpy" - -# TFF: equity/financial. Mapping ticker → cftc_contract_market_code. -SYMBOL_TO_CFTC_CODE_TFF: dict[str, str] = { - "ES": "13874A", # E-mini S&P 500 - "NQ": "209742", # E-mini Nasdaq-100 - "RTY": "239742", # E-mini Russell 2000 - "ZN": "043602", # 10-Year T-Note - "ZB": "020601", # 30-Year T-Bond - "6E": "099741", # Euro FX - "6J": "097741", # Japanese Yen - "DX": "098662", # US Dollar Index -} - -# Disaggregated: commodities. -SYMBOL_TO_CFTC_CODE_DISAGG: dict[str, str] = { - "CL": "067651", # Crude Oil WTI - "GC": "088691", # Gold - "SI": "084691", # Silver - "HG": "085692", # Copper - "ZW": "001602", # Wheat - "ZC": "002602", # Corn - "ZS": "005602", # Soybeans -} - -ALL_TFF_SYMBOLS: list[str] = list(SYMBOL_TO_CFTC_CODE_TFF.keys()) -ALL_DISAGG_SYMBOLS: list[str] = list(SYMBOL_TO_CFTC_CODE_DISAGG.keys()) diff --git a/services/mcp-macro/src/mcp_macro/fetchers.py b/services/mcp-macro/src/mcp_macro/fetchers.py deleted file mode 100644 index 448d877..0000000 --- a/services/mcp-macro/src/mcp_macro/fetchers.py +++ /dev/null @@ -1,771 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime, timedelta -from typing import Any - -import httpx -from mcp_common.http import async_client - -from mcp_macro.cot import classify_extreme, compute_percentile, parse_disagg_row, parse_tff_row -from mcp_macro.cot_contracts import ( - ALL_DISAGG_SYMBOLS, - ALL_TFF_SYMBOLS, - CFTC_BASE_URL, - DISAGG_DATASET_ID, - SYMBOL_TO_CFTC_CODE_DISAGG, - SYMBOL_TO_CFTC_CODE_TFF, - TFF_DATASET_ID, -) - -FRED_BASE = "https://api.stlouisfed.org/fred/series/observations" -FINNHUB_CALENDAR = "https://finnhub.io/api/v1/calendar/economic" -COINGECKO_GLOBAL = "https://api.coingecko.com/api/v3/global" -COINGECKO_SIMPLE = "https://api.coingecko.com/api/v3/simple/price" -DERIBIT_DVOL = "https://www.deribit.com/api/v2/public/get_volatility_index_data" -YAHOO_CHART = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}" - -ASSET_TICKER_MAP: dict[str, tuple[str, str]] = { - "WTI": ("CL=F", "WTI Crude Oil"), - "BRENT": ("BZ=F", "Brent Crude Oil"), - "GOLD": ("GC=F", "Gold Futures"), - "SILVER": ("SI=F", "Silver Futures"), - "COPPER": ("HG=F", "Copper Futures"), - "NATGAS": ("NG=F", "Natural Gas"), - "DXY": ("DX-Y.NYB", "US Dollar Index"), - "SPX": ("^GSPC", "S&P 500"), - "NDX": ("^NDX", "Nasdaq 100"), - "DJI": ("^DJI", "Dow Jones"), - "RUT": ("^RUT", "Russell 2000"), - "VIX": ("^VIX", "CBOE Volatility Index"), - "US5Y": ("^FVX", "US 5-Year Treasury"), - "US10Y": ("^TNX", "US 10-Year Treasury"), - "US30Y": ("^TYX", "US 30-Year Treasury"), - "US2Y": ("^UST2YR", "US 2-Year Treasury"), - "EURUSD": ("EURUSD=X", "EUR/USD"), - "USDJPY": ("JPY=X", "USD/JPY"), - "GBPUSD": ("GBPUSD=X", "GBP/USD"), - "BTCUSD": ("BTC-USD", "Bitcoin/USD"), - "ETHUSD": ("ETH-USD", "Ethereum/USD"), - "ES": ("ES=F", "E-mini S&P 500 Futures"), - "NQ": ("NQ=F", "E-mini Nasdaq 100 Futures"), - "YM": ("YM=F", "E-mini Dow Futures"), - "RTY": ("RTY=F", "E-mini Russell 2000 Futures"), -} - -_ASSET_CACHE: dict[str, dict] = {} -_ASSET_CACHE_TTL = 60.0 - - -async def _fetch_yahoo_meta(client: httpx.AsyncClient, symbol: str, range_: str = "10d") -> dict: - try: - resp = await client.get( - YAHOO_CHART.format(symbol=symbol), - params={"interval": "1d", "range": range_}, - headers={"User-Agent": "Mozilla/5.0"}, - ) - if resp.status_code != 200: - return {} - result = (resp.json().get("chart") or {}).get("result") or [] - if not result: - return {} - r0 = result[0] - meta = r0.get("meta") or {} - closes = ((r0.get("indicators") or {}).get("quote") or [{}])[0].get("close") or [] - closes = [c for c in closes if c is not None] - return {"meta": meta, "closes": closes} - except Exception: - return {} - - -async def fetch_asset_price(ticker: str) -> dict[str, Any]: - import time - - key = ticker.upper() - now = time.monotonic() - cached = _ASSET_CACHE.get(key) - if cached and (now - cached["ts"]) < _ASSET_CACHE_TTL: - return cached["data"] - - mapping = ASSET_TICKER_MAP.get(key) - if not mapping: - return {"ticker": ticker, "error": f"unknown ticker {ticker}"} - symbol, name = mapping - - async with async_client(timeout=10.0) as client: - info = await _fetch_yahoo_meta(client, symbol, "10d") - meta = info.get("meta") or {} - closes = info.get("closes") or [] - price = meta.get("regularMarketPrice") - prev_close = meta.get("previousClose") - change_24h_pct = None - if price is not None and prev_close: - try: - change_24h_pct = round((float(price) - float(prev_close)) / float(prev_close) * 100, 3) - except Exception: - change_24h_pct = None - change_7d_pct = None - if len(closes) >= 6 and price is not None: - try: - change_7d_pct = round((float(price) - float(closes[-6])) / float(closes[-6]) * 100, 3) - except Exception: - change_7d_pct = None - - out = { - "ticker": key, - "name": name, - "price": float(price) if price is not None else None, - "change_24h_pct": change_24h_pct, - "change_7d_pct": change_7d_pct, - "source": f"yfinance:{symbol}", - "data_timestamp": datetime.now(UTC).isoformat(), - } - _ASSET_CACHE[key] = {"data": out, "ts": now} - return out - - -_TREASURY_CACHE: dict[str, Any] = {"data": None, "ts": 0.0} -_TREASURY_TTL = 300.0 - - -async def fetch_treasury_yields() -> dict[str, Any]: - import time - - now = time.monotonic() - if _TREASURY_CACHE["data"] and (now - _TREASURY_CACHE["ts"]) < _TREASURY_TTL: - return _TREASURY_CACHE["data"] - - symbols = [ - ("us2y", "^UST2YR"), - ("us5y", "^FVX"), - ("us10y", "^TNX"), - ("us30y", "^TYX"), - ] - yields: dict[str, float | None] = {} - async with async_client(timeout=10.0) as client: - for key, sym in symbols: - info = await _fetch_yahoo_meta(client, sym, "5d") - meta = info.get("meta") or {} - price = meta.get("regularMarketPrice") - yields[key] = float(price) if price is not None else None - - spread = None - if yields.get("us10y") is not None and yields.get("us2y") is not None: - spread = round(yields["us10y"] - yields["us2y"], 3) - shape = "unknown" - if spread is not None: - if spread > 0.25: - shape = "normal" - elif spread < -0.1: - shape = "inverted" - else: - shape = "flat" - - out = { - "yields": yields, - "spread_2y10y": spread, - "yield_curve_shape": shape, - "data_timestamp": datetime.now(UTC).isoformat(), - } - _TREASURY_CACHE["data"] = out - _TREASURY_CACHE["ts"] = now - return out - - -def yield_curve_metrics(yields: dict[str, float | None]) -> dict[str, Any]: - """Slope + convexity da curva yields (us2y, us5y, us10y, us30y). - Convexity (butterfly): 2*us10y - us2y - us30y. >0 = curva concava. - """ - y2 = yields.get("us2y") - y5 = yields.get("us5y") - y10 = yields.get("us10y") - y30 = yields.get("us30y") - - slope_2y10y = (y10 - y2) if (y2 is not None and y10 is not None) else None - slope_5y30y = (y30 - y5) if (y5 is not None and y30 is not None) else None - butterfly_2_10_30 = (2 * y10 - y2 - y30) if (y2 is not None and y10 is not None and y30 is not None) else None - - regime = "unknown" - if slope_2y10y is not None: - if slope_2y10y >= 0.5: - regime = "steep" - elif slope_2y10y > 0.1: - regime = "normal" - elif slope_2y10y > -0.1: - regime = "flat" - else: - regime = "inverted" - - return { - "slope_2y10y": round(slope_2y10y, 3) if slope_2y10y is not None else None, - "slope_5y30y": round(slope_5y30y, 3) if slope_5y30y is not None else None, - "butterfly_2_10_30": round(butterfly_2_10_30, 3) if butterfly_2_10_30 is not None else None, - "regime": regime, - } - - -async def fetch_yield_curve_slope() -> dict[str, Any]: - """Curve slope/convexity metrics su treasury yields correnti.""" - base = await fetch_treasury_yields() - metrics = yield_curve_metrics(base.get("yields") or {}) - return { - "yields": base.get("yields"), - **metrics, - "data_timestamp": datetime.now(UTC).isoformat(), - } - - -async def fetch_breakeven_inflation(fred_api_key: str = "") -> dict[str, Any]: - """Breakeven inflation rate via FRED: - - T10YIE (10Y breakeven, market expectation 10Y inflation) - - T5YIE (5Y breakeven) - - T5YIFR (5Y forward 5Y forward inflation expectation) - """ - if not fred_api_key: - return {"error": "No FRED API key configured", "breakevens": {}} - - series_map = { - "be_5y": "T5YIE", - "be_10y": "T10YIE", - "be_5y5y_forward": "T5YIFR", - } - out: dict[str, float | None] = {} - async with async_client(timeout=10.0) as client: - for name, series_id in series_map.items(): - resp = await client.get( - FRED_BASE, - params={ - "series_id": series_id, - "api_key": fred_api_key, - "file_type": "json", - "sort_order": "desc", - "limit": 1, - }, - ) - data = resp.json() - obs = data.get("observations", []) - try: - out[name] = float(obs[0]["value"]) if obs and obs[0]["value"] != "." else None - except (ValueError, IndexError, KeyError): - out[name] = None - - interpretation = "unknown" - be10 = out.get("be_10y") - if be10 is not None: - if be10 > 3.0: - interpretation = "high_inflation_expected" - elif be10 < 1.5: - interpretation = "low_inflation_expected" - else: - interpretation = "anchored" - - return { - "breakevens": out, - "interpretation": interpretation, - "data_timestamp": datetime.now(UTC).isoformat(), - } - - -async def fetch_equity_futures() -> dict[str, Any]: - """Fetch ES/NQ/YM/RTY futures con session detection.""" - tickers = [("es", "ES=F"), ("nq", "NQ=F"), ("ym", "YM=F"), ("rty", "RTY=F")] - now = datetime.now(UTC) - weekday = now.weekday() # 0=Mon - hour_utc = now.hour - cash_open = (weekday < 5) and (13 <= hour_utc < 20) - if cash_open: - session = "regular" - elif weekday >= 5: - session = "weekend" - elif hour_utc < 13: - session = "pre-market" - else: - session = "after-hours" - - out: dict[str, Any] = {} - async with async_client(timeout=10.0) as client: - for key, sym in tickers: - info = await _fetch_yahoo_meta(client, sym, "5d") - meta = info.get("meta") or {} - price = meta.get("regularMarketPrice") - prev = meta.get("previousClose") or meta.get("chartPreviousClose") - change_pct = None - if price is not None and prev: - try: - change_pct = round((float(price) - float(prev)) / float(prev) * 100, 3) - except Exception: - change_pct = None - out[key] = { - "price": float(price) if price is not None else None, - "change_pct": change_pct, - "session": session, - } - - next_open = None - if weekday < 5 and hour_utc < 13: - next_open = now.replace(hour=13, minute=30, second=0, microsecond=0).isoformat() - else: - days_ahead = (7 - weekday) if weekday >= 5 else 1 - nd = (now.replace(hour=13, minute=30, second=0, microsecond=0) + timedelta(days=days_ahead)) - next_open = nd.isoformat() - - return { - "futures": out, - "session_status": { - "cash_open": cash_open, - "session": session, - "next_open_utc": next_open, - }, - "data_timestamp": datetime.now(UTC).isoformat(), - } - -_MARKET_CACHE: dict[str, Any] = {"data": None, "ts": 0.0} -_MARKET_CACHE_TTL = 120.0 - - -async def _fetch_yahoo_price(client: httpx.AsyncClient, symbol: str) -> float | None: - try: - resp = await client.get( - YAHOO_CHART.format(symbol=symbol), - params={"interval": "1d", "range": "5d"}, - headers={"User-Agent": "Mozilla/5.0"}, - ) - if resp.status_code != 200: - return None - result = (resp.json().get("chart") or {}).get("result") or [] - if not result: - return None - price = (result[0].get("meta") or {}).get("regularMarketPrice") - return float(price) if price is not None else None - except Exception: - return None - - -async def _fetch_dvol_latest(client: httpx.AsyncClient, currency: str) -> float | None: - now_ms = int(datetime.now(UTC).timestamp() * 1000) - start_ms = now_ms - 7 * 24 * 3600 * 1000 - try: - resp = await client.get( - DERIBIT_DVOL, - params={ - "currency": currency, - "start_timestamp": start_ms, - "end_timestamp": now_ms, - "resolution": "1D", - }, - ) - rows = (resp.json().get("result") or {}).get("data") or [] - if not rows: - return None - return float(rows[-1][4]) - except Exception: - return None - - -async def fetch_economic_indicators( - fred_api_key: str = "", - indicators: list[str] | None = None, -) -> dict[str, Any]: - series_map = { - "fed_rate": "FEDFUNDS", - "cpi": "CPIAUCSL", - "unemployment": "UNRATE", - "us10y_yield": "DGS10", - } - result: dict[str, Any] = {} - if not fred_api_key: - return {"indicators": result, "error": "No FRED API key configured"} - async with async_client(timeout=10.0) as client: - for name, series_id in series_map.items(): - if indicators and name not in indicators: - continue - resp = await client.get( - FRED_BASE, - params={ - "series_id": series_id, - "api_key": fred_api_key, - "file_type": "json", - "sort_order": "desc", - "limit": 1, - }, - ) - data = resp.json() - obs = data.get("observations", []) - result[name] = float(obs[0]["value"]) if obs else None - result["updated_at"] = datetime.now(UTC).isoformat() - return result - - -CURRENCY_TO_COUNTRY = { - "USD": ("US", "United States"), - "EUR": ("EU", "Euro Area"), - "JPY": ("JP", "Japan"), - "GBP": ("UK", "United Kingdom"), - "CAD": ("CA", "Canada"), - "AUD": ("AU", "Australia"), - "NZD": ("NZ", "New Zealand"), - "CHF": ("CH", "Switzerland"), - "CNY": ("CN", "China"), -} - -_HIGH_IMPACT_EVENTS = ( - "fomc", "fed", "cpi", "nfp", "non-farm", "nonfarm", "ppi", - "ecb", "boj", "boe", "gdp", "unemployment rate", -) - - -def _market_impact_historical(name: str) -> str: - n = (name or "").lower() - for kw in _HIGH_IMPACT_EVENTS: - if kw in n: - return "high_vol_spike" - return "normal" - - -async def fetch_macro_calendar( - finnhub_api_key: str = "", - days_ahead: int = 7, - country_filter: list[str] | None = None, - importance_min: str | None = None, - start: str | None = None, - end: str | None = None, -) -> dict[str, Any]: - """Fetch economic calendar con filtri country/importance/date range.""" - events: list[dict[str, Any]] = [] - - importance_order = {"low": 0, "medium": 1, "high": 2} - min_level = importance_order.get( - (importance_min or "").lower(), 0 - ) if importance_min else 0 - - start_dt: datetime | None = None - end_dt: datetime | None = None - if start: - try: - start_dt = datetime.fromisoformat(start).replace(tzinfo=UTC) - except ValueError: - start_dt = datetime.strptime(start, "%Y-%m-%d").replace(tzinfo=UTC) - if end: - try: - end_dt = datetime.fromisoformat(end).replace(tzinfo=UTC) - except ValueError: - end_dt = datetime.strptime(end, "%Y-%m-%d").replace(tzinfo=UTC) - - country_filter_set = ( - {c.upper() for c in country_filter} if country_filter else None - ) - - # Try Forex Factory free feed first - try: - async with async_client(timeout=10.0) as client: - resp = await client.get("https://nfs.faireconomy.media/ff_calendar_thisweek.json") - if resp.status_code == 200: - raw = resp.json() - now = datetime.now(UTC) - for e in raw: - date_str = e.get("date", "") - event_dt: datetime | None = None - try: - event_dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) - if event_dt < now: - continue - except (ValueError, TypeError): - pass - - currency = (e.get("country", "") or "").upper() - country_code, country_name = CURRENCY_TO_COUNTRY.get( - currency, (currency or "", e.get("country", "") or "") - ) - - if country_filter_set and country_code not in country_filter_set: - continue - - impact = (e.get("impact", "") or "").lower() - importance = ( - "high" if impact == "high" else "medium" if impact == "medium" else "low" - ) - if importance_order[importance] < min_level: - continue - - if start_dt and event_dt and event_dt < start_dt: - continue - if end_dt and event_dt and event_dt > end_dt: - continue - - name = e.get("title", "") - events.append( - { - "date": date_str, - "datetime_utc": event_dt.isoformat() if event_dt else date_str, - "name": name, - "event": name, - "country": country_name, - "country_code": country_code, - "importance": importance, - "forecast": e.get("forecast", ""), - "previous": e.get("previous", ""), - "actual": e.get("actual"), - "market_impact_historical": _market_impact_historical(name), - } - ) - except Exception: - pass - - # Fallback to Finnhub if we have a key and no events - if not events and finnhub_api_key: - try: - now = datetime.now(UTC) - end_default = now + timedelta(days=days_ahead) - async with async_client(timeout=10.0) as client: - resp = await client.get( - FINNHUB_CALENDAR, - params={ - "from": (start_dt or now).strftime("%Y-%m-%d"), - "to": (end_dt or end_default).strftime("%Y-%m-%d"), - "token": finnhub_api_key, - }, - ) - data = resp.json() - if isinstance(data, dict) and "error" in data: - return {"events": [], "error": data["error"]} - raw = data if isinstance(data, list) else data.get("economicCalendar", []) - for e in raw: - importance_raw = ( - e.get("importance") - or e.get("impact") - or "medium" - ) - if isinstance(importance_raw, int): - importance = ( - "high" if importance_raw >= 3 else - "medium" if importance_raw >= 2 else - "low" - ) - else: - importance = str(importance_raw).lower() - if importance not in ("low", "medium", "high"): - importance = "medium" - if importance_order[importance] < min_level: - continue - country_code = (e.get("country", "") or "").upper() - country_name = CURRENCY_TO_COUNTRY.get( - country_code, (country_code, country_code) - )[1] - if country_filter_set and country_code not in country_filter_set: - continue - name = e.get("event", "") - date_str = e.get("date", e.get("time", "")) - events.append({ - "date": date_str, - "datetime_utc": date_str, - "name": name, - "event": name, - "country": country_name, - "country_code": country_code, - "importance": importance, - "forecast": e.get("forecast", ""), - "previous": e.get("previous", e.get("prev", "")), - "actual": e.get("actual"), - "market_impact_historical": _market_impact_historical(name), - }) - except Exception: - pass - - if not events: - return {"events": [], "note": "No calendar source available"} - - return {"events": events} - - -async def fetch_market_overview() -> dict[str, Any]: - import time - - now = time.monotonic() - if _MARKET_CACHE["data"] is not None and (now - _MARKET_CACHE["ts"]) < _MARKET_CACHE_TTL: - return _MARKET_CACHE["data"] - - async with async_client(timeout=10.0) as client: - global_data: dict[str, Any] = {} - prices: dict[str, Any] = {} - try: - global_resp = await client.get(COINGECKO_GLOBAL) - global_data = global_resp.json().get("data", {}) or {} - except Exception: - global_data = {} - try: - price_resp = await client.get( - COINGECKO_SIMPLE, - params={"ids": "bitcoin,ethereum", "vs_currencies": "usd"}, - ) - prices = price_resp.json() or {} - except Exception: - prices = {} - dvol_btc = await _fetch_dvol_latest(client, "BTC") - dvol_eth = await _fetch_dvol_latest(client, "ETH") - sp500 = await _fetch_yahoo_price(client, "^GSPC") - gold = await _fetch_yahoo_price(client, "GC=F") - vix = await _fetch_yahoo_price(client, "^VIX") - - out = { - "btc_dominance": global_data.get("market_cap_percentage", {}).get("btc"), - "total_market_cap": global_data.get("total_market_cap", {}).get("usd"), - "btc_price": prices.get("bitcoin", {}).get("usd"), - "eth_price": prices.get("ethereum", {}).get("usd"), - "sp500": sp500, - "gold": gold, - "vix": vix, - "dvol_btc": dvol_btc, - "dvol_eth": dvol_eth, - "data_timestamp": datetime.now(UTC).isoformat(), - } - _MARKET_CACHE["data"] = out - _MARKET_CACHE["ts"] = now - return out - - -_COT_TTL = 3600.0 # 1h -_COT_CACHE: dict[tuple[str, str, int], dict[str, Any]] = {} -_COT_CACHE_TS: dict[tuple[str, str, int], float] = {} - - -async def fetch_cot_tff(symbol: str, lookback_weeks: int = 52) -> dict[str, Any]: - """Fetch COT TFF report per simbolo equity/financial. Returns ASC by date.""" - import time - - symbol = symbol.upper() - if symbol not in SYMBOL_TO_CFTC_CODE_TFF: - return {"error": "unknown_symbol", "available": ALL_TFF_SYMBOLS} - - key = (symbol, "tff", lookback_weeks) - now = time.monotonic() - if key in _COT_CACHE and (now - _COT_CACHE_TS[key]) < _COT_TTL: - return _COT_CACHE[key] - - code = SYMBOL_TO_CFTC_CODE_TFF[symbol] - url = f"{CFTC_BASE_URL}/{TFF_DATASET_ID}.json" - async with async_client(timeout=10.0) as client: - resp = await client.get( - url, - params={ - "cftc_contract_market_code": code, - "$order": "report_date_as_yyyy_mm_dd DESC", - "$limit": str(lookback_weeks), - }, - ) - if resp.status_code != 200: - return {"symbol": symbol, "report_type": "tff", "rows": [], "error": "cftc_unavailable"} - raw_rows = resp.json() or [] - parsed = [parse_tff_row(r) for r in raw_rows] - parsed.sort(key=lambda r: r["report_date"]) # ASC by date - out = { - "symbol": symbol, - "report_type": "tff", - "rows": parsed, - "data_timestamp": datetime.now(UTC).isoformat(), - } - _COT_CACHE[key] = out - _COT_CACHE_TS[key] = now - return out - - -async def fetch_cot_disaggregated(symbol: str, lookback_weeks: int = 52) -> dict[str, Any]: - """Fetch COT Disaggregated report per simbolo commodity. Returns ASC by date.""" - import time - - symbol = symbol.upper() - if symbol not in SYMBOL_TO_CFTC_CODE_DISAGG: - return {"error": "unknown_symbol", "available": ALL_DISAGG_SYMBOLS} - - key = (symbol, "disaggregated", lookback_weeks) - now = time.monotonic() - if key in _COT_CACHE and (now - _COT_CACHE_TS[key]) < _COT_TTL: - return _COT_CACHE[key] - - code = SYMBOL_TO_CFTC_CODE_DISAGG[symbol] - url = f"{CFTC_BASE_URL}/{DISAGG_DATASET_ID}.json" - async with async_client(timeout=10.0) as client: - resp = await client.get( - url, - params={ - "cftc_contract_market_code": code, - "$order": "report_date_as_yyyy_mm_dd DESC", - "$limit": str(lookback_weeks), - }, - ) - if resp.status_code != 200: - return {"symbol": symbol, "report_type": "disaggregated", "rows": [], "error": "cftc_unavailable"} - raw_rows = resp.json() or [] - parsed = [parse_disagg_row(r) for r in raw_rows] - parsed.sort(key=lambda r: r["report_date"]) - out = { - "symbol": symbol, - "report_type": "disaggregated", - "rows": parsed, - "data_timestamp": datetime.now(UTC).isoformat(), - } - _COT_CACHE[key] = out - _COT_CACHE_TS[key] = now - return out - - -async def fetch_cot_extreme_positioning(lookback_weeks: int = 156) -> dict[str, Any]: - """Scanner posizionamento estremo (percentile <=5 o >=95) sui simboli watchlist. - - TFF -> key_role = lev_funds (lev_funds_net). - Disaggregated -> key_role = managed_money (managed_money_net). - """ - import asyncio - - tff_tasks = [fetch_cot_tff(s, lookback_weeks) for s in ALL_TFF_SYMBOLS] - disagg_tasks = [fetch_cot_disaggregated(s, lookback_weeks) for s in ALL_DISAGG_SYMBOLS] - tff_results, disagg_results = await asyncio.gather( - asyncio.gather(*tff_tasks, return_exceptions=True), - asyncio.gather(*disagg_tasks, return_exceptions=True), - ) - - extremes: list[dict[str, Any]] = [] - - for res in tff_results: - if isinstance(res, BaseException) or not isinstance(res, dict): - continue - rows = res.get("rows") or [] - if len(rows) < 4: - continue - series = [r["lev_funds_net"] for r in rows] - current = series[-1] - history = series[:-1] - pct = compute_percentile(current, history) - extremes.append({ - "symbol": res["symbol"], - "report_type": "tff", - "key_role": "lev_funds", - "current_net": current, - "percentile": pct, - "signal": classify_extreme(pct), - "report_date": rows[-1]["report_date"], - }) - - for res in disagg_results: - if isinstance(res, BaseException) or not isinstance(res, dict): - continue - rows = res.get("rows") or [] - if len(rows) < 4: - continue - series = [r["managed_money_net"] for r in rows] - current = series[-1] - history = series[:-1] - pct = compute_percentile(current, history) - extremes.append({ - "symbol": res["symbol"], - "report_type": "disaggregated", - "key_role": "managed_money", - "current_net": current, - "percentile": pct, - "signal": classify_extreme(pct), - "report_date": rows[-1]["report_date"], - }) - - return { - "lookback_weeks": lookback_weeks, - "extremes": extremes, - "data_timestamp": datetime.now(UTC).isoformat(), - } diff --git a/services/mcp-macro/src/mcp_macro/server.py b/services/mcp-macro/src/mcp_macro/server.py deleted file mode 100644 index 18d84c6..0000000 --- a/services/mcp-macro/src/mcp_macro/server.py +++ /dev/null @@ -1,203 +0,0 @@ -from __future__ import annotations - -import os - -from fastapi import Depends, FastAPI, HTTPException -from mcp_common.auth import Principal, TokenStore, require_principal -from mcp_common.mcp_bridge import mount_mcp_endpoint -from mcp_common.server import build_app -from pydantic import BaseModel, Field - -from mcp_macro.fetchers import ( - fetch_asset_price, - fetch_breakeven_inflation, - fetch_cot_disaggregated, - fetch_cot_extreme_positioning, - fetch_cot_tff, - fetch_economic_indicators, - fetch_equity_futures, - fetch_macro_calendar, - fetch_market_overview, - fetch_treasury_yields, - fetch_yield_curve_slope, -) - -# --- Body models --- - -class GetEconomicIndicatorsReq(BaseModel): - indicators: list[str] | None = None - - -class GetMacroCalendarReq(BaseModel): - days: int = 7 - country_filter: list[str] | None = None - importance_min: str | None = None - start: str | None = None - end: str | None = None - - -class GetMarketOverviewReq(BaseModel): - pass - - -class GetAssetPriceReq(BaseModel): - ticker: str - - -class GetTreasuryYieldsReq(BaseModel): - pass - - -class GetEquityFuturesReq(BaseModel): - pass - - -class GetYieldCurveSlopeReq(BaseModel): - pass - - -class GetBreakevenInflationReq(BaseModel): - pass - - -class GetCotTffReq(BaseModel): - symbol: str - lookback_weeks: int = Field(default=52, ge=4, le=520) - - -class GetCotDisaggregatedReq(BaseModel): - symbol: str - lookback_weeks: int = Field(default=52, ge=4, le=520) - - -class GetCotExtremeReq(BaseModel): - lookback_weeks: int = Field(default=156, ge=4, le=520) - - -# --- ACL helper --- - -def _check(principal: Principal, *, core: bool = False, observer: bool = False) -> None: - allowed: set[str] = set() - if core: - allowed.add("core") - if observer: - allowed.add("observer") - if not (principal.capabilities & allowed): - raise HTTPException(403, f"capability required: {allowed}") - - -# --- App factory --- - -def create_app(*, fred_api_key: str = "", finnhub_api_key: str = "", token_store: TokenStore) -> FastAPI: - app = build_app(name="mcp-macro", version="0.1.0", token_store=token_store) - - @app.post("/tools/get_economic_indicators", tags=["reads"]) - async def t_get_economic_indicators( - body: GetEconomicIndicatorsReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_economic_indicators( - fred_api_key=fred_api_key, indicators=body.indicators - ) - - @app.post("/tools/get_macro_calendar", tags=["reads"]) - async def t_get_macro_calendar( - body: GetMacroCalendarReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_macro_calendar( - finnhub_api_key=finnhub_api_key, - days_ahead=body.days, - country_filter=body.country_filter, - importance_min=body.importance_min, - start=body.start, - end=body.end, - ) - - @app.post("/tools/get_market_overview", tags=["reads"]) - async def t_get_market_overview( - body: GetMarketOverviewReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_market_overview() - - @app.post("/tools/get_asset_price", tags=["reads"]) - async def t_get_asset_price( - body: GetAssetPriceReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_asset_price(body.ticker) - - @app.post("/tools/get_treasury_yields", tags=["reads"]) - async def t_get_treasury_yields( - body: GetTreasuryYieldsReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_treasury_yields() - - @app.post("/tools/get_equity_futures", tags=["reads"]) - async def t_get_equity_futures( - body: GetEquityFuturesReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_equity_futures() - - @app.post("/tools/get_yield_curve_slope", tags=["reads"]) - async def t_get_yield_curve_slope( - body: GetYieldCurveSlopeReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_yield_curve_slope() - - @app.post("/tools/get_breakeven_inflation", tags=["reads"]) - async def t_get_breakeven_inflation( - body: GetBreakevenInflationReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_breakeven_inflation(fred_api_key=fred_api_key) - - @app.post("/tools/get_cot_tff", tags=["reads"]) - async def t_get_cot_tff( - body: GetCotTffReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_cot_tff(body.symbol, body.lookback_weeks) - - @app.post("/tools/get_cot_disaggregated", tags=["reads"]) - async def t_get_cot_disaggregated( - body: GetCotDisaggregatedReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_cot_disaggregated(body.symbol, body.lookback_weeks) - - @app.post("/tools/get_cot_extreme_positioning", tags=["reads"]) - async def t_get_cot_extreme( - body: GetCotExtremeReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_cot_extreme_positioning(body.lookback_weeks) - - # ───── MCP endpoint (/mcp) — bridge verso /tools/* ───── - port = int(os.environ.get("PORT", "9013")) - mount_mcp_endpoint( - app, - name="cerbero-macro", - version="0.1.0", - token_store=token_store, - internal_base_url=f"http://localhost:{port}", - tools=[ - {"name": "get_economic_indicators", "description": "FRED economic indicators (Fed rate, CPI, ecc)."}, - {"name": "get_macro_calendar", "description": "Eventi macro con filtri country/importance/date range."}, - {"name": "get_market_overview", "description": "Snapshot overview mercato macro."}, - {"name": "get_asset_price", "description": "Prezzo cross-asset: WTI, DXY, SPX, VIX, yields, FX, ecc."}, - {"name": "get_treasury_yields", "description": "Curva US Treasury 2y/5y/10y/30y + shape detection."}, - {"name": "get_equity_futures", "description": "Futures ES/NQ/YM/RTY con session status."}, - {"name": "get_yield_curve_slope", "description": "Slope 2y10y/5y30y + butterfly + regime (steep/normal/flat/inverted)."}, - {"name": "get_breakeven_inflation", "description": "Breakeven inflation 5Y/10Y + 5y5y forward (FRED T5YIE/T10YIE/T5YIFR)."}, - {"name": "get_cot_tff", "description": "COT TFF report (CFTC) per equity/financial: ES/NQ/RTY/ZN/ZB/6E/6J/DX. Roles: dealer, asset manager, leveraged funds, other."}, - {"name": "get_cot_disaggregated", "description": "COT Disaggregated report (CFTC) per commodities: CL/GC/SI/HG/ZW/ZC/ZS. Roles: producer/merchant, swap dealer, managed money, other."}, - {"name": "get_cot_extreme_positioning", "description": "Scanner posizionamento estremo (percentile ≤5 o ≥95) sui simboli watchlist."}, - ], - ) - - return app diff --git a/services/mcp-macro/tests/test_cot.py b/services/mcp-macro/tests/test_cot.py deleted file mode 100644 index 1544e77..0000000 --- a/services/mcp-macro/tests/test_cot.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -from mcp_macro.cot import ( - classify_extreme, - compute_percentile, - parse_disagg_row, - parse_tff_row, -) - - -def test_compute_percentile_basic(): - history = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] - assert compute_percentile(50, history) == 50.0 - assert compute_percentile(10, history) == 10.0 - assert compute_percentile(100, history) == 100.0 - - -def test_compute_percentile_value_below_min(): - history = [10, 20, 30] - assert compute_percentile(5, history) == 0.0 - - -def test_compute_percentile_value_above_max(): - history = [10, 20, 30] - assert compute_percentile(40, history) == 100.0 - - -def test_compute_percentile_empty_history(): - assert compute_percentile(50, []) is None - - -def test_classify_extreme_below_threshold(): - assert classify_extreme(3.0) == "extreme_short" - assert classify_extreme(5.0) == "extreme_short" # boundary inclusive - - -def test_classify_extreme_above_threshold(): - assert classify_extreme(96.0) == "extreme_long" - assert classify_extreme(95.0) == "extreme_long" # boundary inclusive - - -def test_classify_extreme_neutral(): - assert classify_extreme(50.0) == "neutral" - assert classify_extreme(94.99) == "neutral" - assert classify_extreme(5.01) == "neutral" - - -def test_classify_extreme_none_input(): - assert classify_extreme(None) == "neutral" - - -# Payload Socrata reale (subset campi rilevanti, valori arbitrari per test) -TFF_SOCRATA_ROW = { - "report_date_as_yyyy_mm_dd": "2026-04-22T00:00:00.000", - "dealer_positions_long_all": "12345", - "dealer_positions_short_all": "23456", - "asset_mgr_positions_long": "654321", - "asset_mgr_positions_short": "200000", - "lev_money_positions_long": "100000", - "lev_money_positions_short": "350000", - "other_rept_positions_long": "50000", - "other_rept_positions_short": "50000", - "open_interest_all": "2500000", -} - -DISAGG_SOCRATA_ROW = { - "report_date_as_yyyy_mm_dd": "2026-04-22T00:00:00.000", - "prod_merc_positions_long_all": "100000", - "prod_merc_positions_short_all": "300000", - "swap_positions_long_all": "50000", - "swap_positions_short_all": "60000", - "m_money_positions_long_all": "200000", - "m_money_positions_short_all": "80000", - "other_rept_positions_long_all": "10000", - "other_rept_positions_short_all": "10000", - "open_interest_all": "1500000", -} - - -def test_parse_tff_row_extracts_all_fields(): - row = parse_tff_row(TFF_SOCRATA_ROW) - assert row["report_date"] == "2026-04-22" - assert row["dealer_long"] == 12345 - assert row["dealer_short"] == 23456 - assert row["dealer_net"] == 12345 - 23456 - assert row["asset_mgr_long"] == 654321 - assert row["asset_mgr_net"] == 654321 - 200000 - assert row["lev_funds_long"] == 100000 - assert row["lev_funds_short"] == 350000 - assert row["lev_funds_net"] == 100000 - 350000 - assert row["other_long"] == 50000 - assert row["other_net"] == 0 - assert row["open_interest"] == 2500000 - - -def test_parse_tff_row_handles_missing_field(): - payload = {"report_date_as_yyyy_mm_dd": "2026-04-22T00:00:00.000"} - row = parse_tff_row(payload) - assert row["report_date"] == "2026-04-22" - assert row["dealer_long"] == 0 - assert row["dealer_net"] == 0 - - -def test_parse_disagg_row_extracts_all_fields(): - row = parse_disagg_row(DISAGG_SOCRATA_ROW) - assert row["report_date"] == "2026-04-22" - assert row["producer_long"] == 100000 - assert row["producer_short"] == 300000 - assert row["producer_net"] == -200000 - assert row["swap_long"] == 50000 - assert row["swap_net"] == -10000 - assert row["managed_money_long"] == 200000 - assert row["managed_money_short"] == 80000 - assert row["managed_money_net"] == 120000 - assert row["other_long"] == 10000 - assert row["other_net"] == 0 - assert row["open_interest"] == 1500000 diff --git a/services/mcp-macro/tests/test_fetchers.py b/services/mcp-macro/tests/test_fetchers.py deleted file mode 100644 index f3e96f0..0000000 --- a/services/mcp-macro/tests/test_fetchers.py +++ /dev/null @@ -1,402 +0,0 @@ -from __future__ import annotations - -from datetime import UTC - -import httpx -import pytest -import pytest_httpx -from mcp_macro.fetchers import ( - fetch_breakeven_inflation, - fetch_economic_indicators, - fetch_macro_calendar, - fetch_market_overview, - yield_curve_metrics, -) - -# --- fetch_economic_indicators --- - -@pytest.mark.asyncio -async def test_economic_indicators_no_key(): - result = await fetch_economic_indicators(fred_api_key="") - assert "error" in result - assert result["error"] == "No FRED API key configured" - - -@pytest.mark.asyncio -async def test_economic_indicators_happy_path(httpx_mock: pytest_httpx.HTTPXMock): - for series_id in ("FEDFUNDS", "CPIAUCSL", "UNRATE", "DGS10"): - httpx_mock.add_response( - url=httpx.URL( - "https://api.stlouisfed.org/fred/series/observations", - params={ - "series_id": series_id, - "api_key": "testkey", - "file_type": "json", - "sort_order": "desc", - "limit": "1", - }, - ), - json={"observations": [{"value": "5.25"}]}, - ) - result = await fetch_economic_indicators(fred_api_key="testkey") - assert result["fed_rate"] == 5.25 - assert result["cpi"] == 5.25 - assert result["unemployment"] == 5.25 - assert result["us10y_yield"] == 5.25 - assert "updated_at" in result - - -@pytest.mark.asyncio -async def test_economic_indicators_filter(httpx_mock: pytest_httpx.HTTPXMock): - httpx_mock.add_response( - url=httpx.URL( - "https://api.stlouisfed.org/fred/series/observations", - params={ - "series_id": "FEDFUNDS", - "api_key": "k", - "file_type": "json", - "sort_order": "desc", - "limit": "1", - }, - ), - json={"observations": [{"value": "5.33"}]}, - ) - result = await fetch_economic_indicators(fred_api_key="k", indicators=["fed_rate"]) - assert "fed_rate" in result - assert "cpi" not in result - - -# --- fetch_macro_calendar --- - -@pytest.mark.asyncio -async def test_macro_calendar_forex_factory_happy(httpx_mock: pytest_httpx.HTTPXMock): - from datetime import datetime, timedelta - - future = (datetime.now(UTC) + timedelta(days=1)).isoformat() - httpx_mock.add_response( - url="https://nfs.faireconomy.media/ff_calendar_thisweek.json", - json=[ - { - "date": future, - "title": "CPI", - "country": "US", - "impact": "High", - "forecast": "3.0%", - "previous": "3.2%", - } - ], - ) - result = await fetch_macro_calendar() - assert "events" in result - assert len(result["events"]) >= 1 - assert result["events"][0]["name"] == "CPI" - - -@pytest.mark.asyncio -async def test_macro_calendar_no_source(httpx_mock: pytest_httpx.HTTPXMock): - httpx_mock.add_response( - url="https://nfs.faireconomy.media/ff_calendar_thisweek.json", - status_code=500, - ) - result = await fetch_macro_calendar(finnhub_api_key="") - assert result == {"events": [], "note": "No calendar source available"} - - -@pytest.mark.asyncio -@pytest.mark.httpx_mock(assert_all_responses_were_requested=False, assert_all_requests_were_expected=False) -async def test_macro_calendar_finnhub_fallback(httpx_mock: pytest_httpx.HTTPXMock): - httpx_mock.add_response( - url="https://nfs.faireconomy.media/ff_calendar_thisweek.json", - status_code=500, - ) - - def dispatch(request: httpx.Request) -> httpx.Response: - if "finnhub.io" in str(request.url): - return httpx.Response( - 200, - json=[{"date": "2024-01-15", "event": "FOMC", "importance": "high", "forecast": "", "prev": ""}], - ) - return httpx.Response(500) - - httpx_mock.add_callback(dispatch) - result = await fetch_macro_calendar(finnhub_api_key="fkey") - assert "events" in result - assert result["events"][0]["name"] == "FOMC" - - -# --- fetch_market_overview --- - -@pytest.mark.asyncio -async def test_market_overview_happy(httpx_mock: pytest_httpx.HTTPXMock): - httpx_mock.add_response( - url="https://api.coingecko.com/api/v3/global", - json={ - "data": { - "market_cap_percentage": {"btc": 52.3}, - "total_market_cap": {"usd": 2_000_000_000_000}, - } - }, - ) - httpx_mock.add_response( - url=httpx.URL( - "https://api.coingecko.com/api/v3/simple/price", - params={"ids": "bitcoin,ethereum", "vs_currencies": "usd"}, - ), - json={"bitcoin": {"usd": 65000}, "ethereum": {"usd": 3500}}, - ) - import re as _re - httpx_mock.add_response( - url=_re.compile( - r"https://www\.deribit\.com/api/v2/public/get_volatility_index_data\?currency=BTC.*" - ), - json={"result": {"data": [[1, 50, 52, 49, 51.5]], "continuation": None}}, - ) - httpx_mock.add_response( - url=_re.compile( - r"https://www\.deribit\.com/api/v2/public/get_volatility_index_data\?currency=ETH.*" - ), - json={"result": {"data": [[1, 60, 62, 59, 61.2]], "continuation": None}}, - ) - import re as _re - httpx_mock.add_response( - url=_re.compile(r"https://query1\.finance\.yahoo\.com/v8/finance/chart/\^GSPC.*"), - json={"chart": {"result": [{"meta": {"regularMarketPrice": 5830.12}}]}}, - ) - httpx_mock.add_response( - url=_re.compile(r"https://query1\.finance\.yahoo\.com/v8/finance/chart/GC[%=].*"), - json={"chart": {"result": [{"meta": {"regularMarketPrice": 2412.5}}]}}, - ) - httpx_mock.add_response( - url=_re.compile(r"https://query1\.finance\.yahoo\.com/v8/finance/chart/\^VIX.*"), - json={"chart": {"result": [{"meta": {"regularMarketPrice": 18.3}}]}}, - ) - # Clear module cache to force fresh fetch - from mcp_macro import fetchers as _f - _f._MARKET_CACHE["data"] = None - _f._MARKET_CACHE["ts"] = 0.0 - result = await fetch_market_overview() - assert result["btc_dominance"] == 52.3 - assert result["btc_price"] == 65000 - assert result["eth_price"] == 3500 - assert result["total_market_cap"] == 2_000_000_000_000 - assert result["dvol_btc"] == 51.5 - assert result["dvol_eth"] == 61.2 - assert result["sp500"] == 5830.12 - assert result["gold"] == 2412.5 - assert result["vix"] == 18.3 - assert "data_timestamp" in result - - -# --- yield_curve_metrics --- - -def test_yield_curve_metrics_normal_curve(): - out = yield_curve_metrics({"us2y": 4.0, "us5y": 4.2, "us10y": 4.5, "us30y": 4.8}) - assert out["slope_2y10y"] == 0.5 - assert out["slope_5y30y"] == 0.6 - assert out["regime"] == "steep" - # butterfly: 2*4.5 - 4.0 - 4.8 = 0.2 - assert out["butterfly_2_10_30"] == 0.2 - - -def test_yield_curve_metrics_inverted(): - out = yield_curve_metrics({"us2y": 5.5, "us5y": 5.0, "us10y": 4.5, "us30y": 4.3}) - assert out["slope_2y10y"] == -1.0 - assert out["regime"] == "inverted" - - -def test_yield_curve_metrics_partial_data(): - out = yield_curve_metrics({"us10y": 4.5}) - assert out["slope_2y10y"] is None - assert out["regime"] == "unknown" - - -# --- fetch_breakeven_inflation --- - -@pytest.mark.asyncio -async def test_breakeven_no_key(): - out = await fetch_breakeven_inflation(fred_api_key="") - assert "error" in out - - -@pytest.mark.asyncio -async def test_breakeven_happy_path(httpx_mock: pytest_httpx.HTTPXMock): - for series_id, val in [("T5YIE", "2.3"), ("T10YIE", "2.5"), ("T5YIFR", "2.7")]: - httpx_mock.add_response( - url=httpx.URL( - "https://api.stlouisfed.org/fred/series/observations", - params={ - "series_id": series_id, - "api_key": "k", - "file_type": "json", - "sort_order": "desc", - "limit": "1", - }, - ), - json={"observations": [{"value": val}]}, - ) - out = await fetch_breakeven_inflation(fred_api_key="k") - assert out["breakevens"]["be_5y"] == 2.3 - assert out["breakevens"]["be_10y"] == 2.5 - assert out["breakevens"]["be_5y5y_forward"] == 2.7 - assert out["interpretation"] == "anchored" - - -@pytest.mark.asyncio -async def test_breakeven_high_inflation(httpx_mock: pytest_httpx.HTTPXMock): - for series_id in ("T5YIE", "T10YIE", "T5YIFR"): - httpx_mock.add_response( - url=httpx.URL( - "https://api.stlouisfed.org/fred/series/observations", - params={ - "series_id": series_id, - "api_key": "k", - "file_type": "json", - "sort_order": "desc", - "limit": "1", - }, - ), - json={"observations": [{"value": "3.5"}]}, - ) - out = await fetch_breakeven_inflation(fred_api_key="k") - assert out["interpretation"] == "high_inflation_expected" - - -@pytest.mark.asyncio -async def test_fetch_cot_tff_happy_path(httpx_mock: pytest_httpx.HTTPXMock): - from mcp_macro.fetchers import fetch_cot_tff - httpx_mock.add_response( - url=httpx.URL( - "https://publicreporting.cftc.gov/resource/gpe5-46if.json", - params={ - "cftc_contract_market_code": "13874A", - "$order": "report_date_as_yyyy_mm_dd DESC", - "$limit": "52", - }, - ), - json=[ - { - "report_date_as_yyyy_mm_dd": "2026-04-22T00:00:00.000", - "dealer_positions_long_all": "12345", - "dealer_positions_short_all": "23456", - "asset_mgr_positions_long": "654321", - "asset_mgr_positions_short": "200000", - "lev_money_positions_long": "100000", - "lev_money_positions_short": "350000", - "other_rept_positions_long": "50000", - "other_rept_positions_short": "50000", - "open_interest_all": "2500000", - }, - { - "report_date_as_yyyy_mm_dd": "2026-04-15T00:00:00.000", - "dealer_positions_long_all": "11000", - "dealer_positions_short_all": "22000", - "asset_mgr_positions_long": "640000", - "asset_mgr_positions_short": "210000", - "lev_money_positions_long": "110000", - "lev_money_positions_short": "320000", - "other_rept_positions_long": "48000", - "other_rept_positions_short": "52000", - "open_interest_all": "2480000", - }, - ], - ) - out = await fetch_cot_tff("ES", lookback_weeks=52) - assert out["symbol"] == "ES" - assert out["report_type"] == "tff" - assert len(out["rows"]) == 2 - # Ordering ASC by date (oldest first) - assert out["rows"][0]["report_date"] == "2026-04-15" - assert out["rows"][1]["report_date"] == "2026-04-22" - assert out["rows"][1]["lev_funds_net"] == -250000 - assert "data_timestamp" in out - - -@pytest.mark.asyncio -async def test_fetch_cot_tff_unknown_symbol(): - from mcp_macro.fetchers import fetch_cot_tff - out = await fetch_cot_tff("INVALID", lookback_weeks=52) - assert out.get("error") == "unknown_symbol" - assert "ES" in out.get("available", []) - - -@pytest.mark.asyncio -async def test_fetch_cot_disagg_happy_path(httpx_mock: pytest_httpx.HTTPXMock): - from mcp_macro.fetchers import fetch_cot_disaggregated - httpx_mock.add_response( - url=httpx.URL( - "https://publicreporting.cftc.gov/resource/72hh-3qpy.json", - params={ - "cftc_contract_market_code": "067651", - "$order": "report_date_as_yyyy_mm_dd DESC", - "$limit": "52", - }, - ), - json=[ - { - "report_date_as_yyyy_mm_dd": "2026-04-22T00:00:00.000", - "prod_merc_positions_long_all": "100000", - "prod_merc_positions_short_all": "300000", - "swap_positions_long_all": "50000", - "swap_positions_short_all": "60000", - "m_money_positions_long_all": "200000", - "m_money_positions_short_all": "80000", - "other_rept_positions_long_all": "10000", - "other_rept_positions_short_all": "10000", - "open_interest_all": "1500000", - }, - ], - ) - out = await fetch_cot_disaggregated("CL", lookback_weeks=52) - assert out["symbol"] == "CL" - assert out["report_type"] == "disaggregated" - assert len(out["rows"]) == 1 - assert out["rows"][0]["managed_money_net"] == 120000 - assert out["rows"][0]["producer_net"] == -200000 - - -@pytest.mark.asyncio -async def test_fetch_cot_disagg_unknown_symbol(): - from mcp_macro.fetchers import fetch_cot_disaggregated - out = await fetch_cot_disaggregated("XYZ", lookback_weeks=52) - assert out.get("error") == "unknown_symbol" - assert "CL" in out.get("available", []) - - -@pytest.mark.asyncio -async def test_fetch_cot_extreme_positioning_flags_outliers(monkeypatch): - """Mock fetch_cot_tff e fetch_cot_disagg per simulare history e ultimo punto.""" - from unittest.mock import AsyncMock - - from mcp_macro import fetchers as f - - # Simula una serie ES dove ultimo lev_funds_net è in basso (extreme_short) - es_rows = [ - {"report_date": f"2026-{m:02d}-01", "lev_funds_net": v} - for m, v in [(1, 0), (2, 50), (3, 100), (4, -500)] - ] - cl_rows = [ - {"report_date": f"2026-{m:02d}-01", "managed_money_net": v} - for m, v in [(1, 100), (2, 200), (3, 300), (4, 1000)] - ] - - async def fake_tff(symbol, lookback_weeks): - if symbol == "ES": - return {"symbol": "ES", "report_type": "tff", "rows": es_rows} - return {"symbol": symbol, "report_type": "tff", "rows": []} - - async def fake_disagg(symbol, lookback_weeks): - if symbol == "CL": - return {"symbol": "CL", "report_type": "disaggregated", "rows": cl_rows} - return {"symbol": symbol, "report_type": "disaggregated", "rows": []} - - monkeypatch.setattr(f, "fetch_cot_tff", AsyncMock(side_effect=fake_tff)) - monkeypatch.setattr(f, "fetch_cot_disaggregated", AsyncMock(side_effect=fake_disagg)) - - out = await f.fetch_cot_extreme_positioning(lookback_weeks=4) - assert "extremes" in out - by_sym = {e["symbol"]: e for e in out["extremes"]} - assert by_sym["ES"]["signal"] == "extreme_short" - assert by_sym["ES"]["key_role"] == "lev_funds" - assert by_sym["CL"]["signal"] == "extreme_long" - assert by_sym["CL"]["key_role"] == "managed_money" - diff --git a/services/mcp-macro/tests/test_server_acl.py b/services/mcp-macro/tests/test_server_acl.py deleted file mode 100644 index 91bebd7..0000000 --- a/services/mcp-macro/tests/test_server_acl.py +++ /dev/null @@ -1,202 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, patch - -import pytest -from fastapi.testclient import TestClient -from mcp_common.auth import Principal, TokenStore -from mcp_macro.server import create_app - - -@pytest.fixture -def http(): - store = TokenStore( - tokens={ - "ct": Principal("core", {"core"}), - "ot": Principal("observer", {"observer"}), - } - ) - app = create_app(fred_api_key="testfred", finnhub_api_key="testfinn", token_store=store) - return TestClient(app) - - -# --- Health --- - -def test_health(http): - assert http.get("/health").status_code == 200 - - -# --- get_economic_indicators --- - -def test_get_economic_indicators_core_ok(http): - with patch( - "mcp_macro.server.fetch_economic_indicators", - new=AsyncMock(return_value={"fed_rate": 5.25, "updated_at": "2024-01-01T00:00:00+00:00"}), - ): - r = http.post( - "/tools/get_economic_indicators", - headers={"Authorization": "Bearer ct"}, - json={}, - ) - assert r.status_code == 200 - assert r.json()["fed_rate"] == 5.25 - - -def test_get_economic_indicators_observer_ok(http): - with patch( - "mcp_macro.server.fetch_economic_indicators", - new=AsyncMock(return_value={"fed_rate": 5.25}), - ): - r = http.post( - "/tools/get_economic_indicators", - headers={"Authorization": "Bearer ot"}, - json={}, - ) - assert r.status_code == 200 - - -def test_get_economic_indicators_no_auth_401(http): - r = http.post("/tools/get_economic_indicators", json={}) - assert r.status_code == 401 - - -# --- get_macro_calendar --- - -def test_get_macro_calendar_core_ok(http): - with patch( - "mcp_macro.server.fetch_macro_calendar", - new=AsyncMock(return_value={"events": []}), - ): - r = http.post( - "/tools/get_macro_calendar", - headers={"Authorization": "Bearer ct"}, - json={"days": 7}, - ) - assert r.status_code == 200 - - -def test_get_macro_calendar_observer_ok(http): - with patch( - "mcp_macro.server.fetch_macro_calendar", - new=AsyncMock(return_value={"events": []}), - ): - r = http.post( - "/tools/get_macro_calendar", - headers={"Authorization": "Bearer ot"}, - json={}, - ) - assert r.status_code == 200 - - -def test_get_macro_calendar_no_auth_401(http): - r = http.post("/tools/get_macro_calendar", json={}) - assert r.status_code == 401 - - -# --- get_market_overview --- - -def test_get_market_overview_core_ok(http): - with patch( - "mcp_macro.server.fetch_market_overview", - new=AsyncMock(return_value={"btc_dominance": 52.0, "btc_price": 65000}), - ): - r = http.post( - "/tools/get_market_overview", - headers={"Authorization": "Bearer ct"}, - json={}, - ) - assert r.status_code == 200 - assert r.json()["btc_price"] == 65000 - - -def test_get_market_overview_observer_ok(http): - with patch( - "mcp_macro.server.fetch_market_overview", - new=AsyncMock(return_value={"btc_dominance": 52.0}), - ): - r = http.post( - "/tools/get_market_overview", - headers={"Authorization": "Bearer ot"}, - json={}, - ) - assert r.status_code == 200 - - -def test_get_market_overview_no_auth_401(http): - r = http.post("/tools/get_market_overview", json={}) - assert r.status_code == 401 - - - - -def test_get_cot_tff_core_ok(http): - with patch( - "mcp_macro.server.fetch_cot_tff", - new=AsyncMock(return_value={"symbol": "ES", "rows": []}), - ): - r = http.post( - "/tools/get_cot_tff", - headers={"Authorization": "Bearer ct"}, - json={"symbol": "ES"}, - ) - assert r.status_code == 200 - assert r.json()["symbol"] == "ES" - - -def test_get_cot_tff_observer_ok(http): - with patch( - "mcp_macro.server.fetch_cot_tff", - new=AsyncMock(return_value={"symbol": "ES", "rows": []}), - ): - r = http.post( - "/tools/get_cot_tff", - headers={"Authorization": "Bearer ot"}, - json={"symbol": "ES"}, - ) - assert r.status_code == 200 - - -def test_get_cot_tff_no_auth_401(http): - r = http.post("/tools/get_cot_tff", json={"symbol": "ES"}) - assert r.status_code == 401 - - -def test_get_cot_disagg_observer_ok(http): - with patch( - "mcp_macro.server.fetch_cot_disaggregated", - new=AsyncMock(return_value={"symbol": "CL", "rows": []}), - ): - r = http.post( - "/tools/get_cot_disaggregated", - headers={"Authorization": "Bearer ot"}, - json={"symbol": "CL"}, - ) - assert r.status_code == 200 - - -def test_get_cot_disagg_no_auth_401(http): - r = http.post("/tools/get_cot_disaggregated", json={"symbol": "CL"}) - assert r.status_code == 401 - - -def test_get_cot_extreme_positioning_ok(http): - with patch( - "mcp_macro.server.fetch_cot_extreme_positioning", - new=AsyncMock(return_value={"extremes": []}), - ): - r = http.post( - "/tools/get_cot_extreme_positioning", - headers={"Authorization": "Bearer ot"}, - json={}, - ) - assert r.status_code == 200 - - -def test_get_cot_extreme_positioning_lookback_too_short(http): - """Pydantic validation: lookback_weeks < 4 → 422.""" - r = http.post( - "/tools/get_cot_extreme_positioning", - headers={"Authorization": "Bearer ct"}, - json={"lookback_weeks": 2}, - ) - assert r.status_code == 422 diff --git a/services/mcp-sentiment/pyproject.toml b/services/mcp-sentiment/pyproject.toml deleted file mode 100644 index 76b851a..0000000 --- a/services/mcp-sentiment/pyproject.toml +++ /dev/null @@ -1,27 +0,0 @@ -[project] -name = "mcp-sentiment" -version = "0.1.0" -requires-python = ">=3.11" -dependencies = [ - "mcp-common", - "fastapi>=0.115", - "uvicorn[standard]>=0.30", - "httpx>=0.27", - "pydantic>=2.6", -] - -[project.optional-dependencies] -dev = ["pytest>=8", "pytest-asyncio>=0.23", "pytest-httpx>=0.30"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/mcp_sentiment"] - -[tool.uv.sources] -mcp-common = { workspace = true } - -[project.scripts] -mcp-sentiment = "mcp_sentiment.__main__:main" diff --git a/services/mcp-sentiment/src/mcp_sentiment/__init__.py b/services/mcp-sentiment/src/mcp_sentiment/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/mcp-sentiment/src/mcp_sentiment/__main__.py b/services/mcp-sentiment/src/mcp_sentiment/__main__.py deleted file mode 100644 index 52aa248..0000000 --- a/services/mcp-sentiment/src/mcp_sentiment/__main__.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -import json -import os - -import uvicorn -from mcp_common.auth import load_token_store_from_files -from mcp_common.logging import configure_root_logging - -from mcp_sentiment.server import create_app - - -def _load_cryptopanic_key() -> str: - """CER-002: preferisci file secret, fallback a env CRYPTOPANIC_API_KEY.""" - creds_file = os.environ.get("SENTIMENT_CREDENTIALS_FILE") - if creds_file and os.path.exists(creds_file): - try: - with open(creds_file) as f: - creds = json.load(f) - key = (creds.get("cryptopanic_key") or "").strip() - if key and key.lower() not in ("placeholder", "changeme", "none"): - return key - except (OSError, json.JSONDecodeError): - pass - return (os.environ.get("CRYPTOPANIC_API_KEY") or "").strip() - - -configure_root_logging() # CER-P5-009 - -def main(): - key = _load_cryptopanic_key() - token_store = load_token_store_from_files( - core_token_file=os.environ.get("CORE_TOKEN_FILE"), - observer_token_file=os.environ.get("OBSERVER_TOKEN_FILE"), - ) - app = create_app(cryptopanic_key=key, token_store=token_store) - uvicorn.run( - app, - log_config=None, # CER-P5-009: delega al root JSON logger - host=os.environ.get("HOST", "0.0.0.0"), - port=int(os.environ.get("PORT", "9014")), - ) - - -if __name__ == "__main__": - main() diff --git a/services/mcp-sentiment/src/mcp_sentiment/fetchers.py b/services/mcp-sentiment/src/mcp_sentiment/fetchers.py deleted file mode 100644 index 7fc907f..0000000 --- a/services/mcp-sentiment/src/mcp_sentiment/fetchers.py +++ /dev/null @@ -1,656 +0,0 @@ -from __future__ import annotations - -import os -import re -import xml.etree.ElementTree as ET -from typing import Any - -from mcp_common.http import async_client - -CRYPTOPANIC_URL = "https://cryptopanic.com/api/v1/posts/" -ALTERNATIVE_ME_URL = "https://api.alternative.me/fng/" -COINDESK_RSS = "https://www.coindesk.com/arc/outboundfeeds/rss/" -LUNARCRUSH_COIN_URL = "https://lunarcrush.com/api4/public/coins/{symbol}/v1" -CRYPTOCOMPARE_NEWS_URL = "https://min-api.cryptocompare.com/data/v2/news/" -MESSARI_NEWS_URL = "https://data.messari.io/api/v1/news" - - -async def _fetch_coindesk_headlines(limit: int = 20) -> list[dict[str, Any]]: - items: list[dict[str, Any]] = [] - try: - async with async_client(timeout=10.0, follow_redirects=True) as client: - resp = await client.get(COINDESK_RSS) - if resp.status_code != 200: - return items - root = ET.fromstring(resp.text) - for node in root.findall(".//item")[:limit]: - items.append( - { - "title": node.findtext("title", ""), - "source": "CoinDesk", - "published_at": node.findtext("pubDate", ""), - "url": node.findtext("link", ""), - } - ) - except Exception: - pass - return items - -WORLD_NEWS_FEEDS = [ - ("Reuters Business", "https://feeds.reuters.com/reuters/businessNews"), - ("CNBC Top News", "https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=100003114"), - ("Bloomberg Markets", "https://feeds.bloomberg.com/markets/news.rss"), - ("CoinDesk", "https://www.coindesk.com/arc/outboundfeeds/rss/"), -] - -# Public funding rate endpoints (no auth required) -BINANCE_FUNDING_URL = "https://fapi.binance.com/fapi/v1/premiumIndex" -BYBIT_FUNDING_URL = "https://api.bybit.com/v5/market/tickers" -OKX_FUNDING_URL = "https://www.okx.com/api/v5/public/funding-rate" -BINANCE_OI_HIST_URL = "https://fapi.binance.com/futures/data/openInterestHist" - - -async def _fetch_cryptocompare_news(limit: int = 20) -> list[dict[str, Any]]: - """CER-017: CryptoCompare news free (no key needed).""" - items: list[dict[str, Any]] = [] - try: - async with async_client(timeout=10.0) as client: - resp = await client.get(CRYPTOCOMPARE_NEWS_URL, params={"lang": "EN"}) - if resp.status_code != 200: - return items - data = resp.json() - for r in (data.get("Data") or [])[:limit]: - ts = r.get("published_on") - try: - import datetime as _dt - pub = _dt.datetime.fromtimestamp(int(ts), _dt.UTC).isoformat() if ts else "" - except (TypeError, ValueError): - pub = "" - items.append({ - "title": r.get("title", ""), - "source": r.get("source", "CryptoCompare"), - "published_at": pub, - "url": r.get("url", ""), - "provider": "cryptocompare", - }) - except Exception: - pass - return items - - -async def _fetch_messari_news(limit: int = 20) -> list[dict[str, Any]]: - """CER-017: Messari news free (no key needed for basic feed).""" - items: list[dict[str, Any]] = [] - try: - async with async_client(timeout=10.0) as client: - resp = await client.get(MESSARI_NEWS_URL) - if resp.status_code != 200: - return items - data = resp.json() - for r in (data.get("data") or [])[:limit]: - items.append({ - "title": r.get("title", ""), - "source": (r.get("author") or {}).get("name") or "Messari", - "published_at": r.get("published_at", ""), - "url": r.get("url", ""), - "provider": "messari", - }) - except Exception: - pass - return items - - -def _normalize_title(t: str) -> str: - """Lowercase + strip non-alnum per dedup tra provider.""" - return "".join(ch for ch in t.lower() if ch.isalnum() or ch.isspace()).strip() - - -async def fetch_crypto_news(api_key: str = "", limit: int = 20) -> dict[str, Any]: - """CER-017: multi-source aggregator (CoinDesk + CryptoCompare + Messari) + dedup. - - Se `api_key` Cryptopanic presente, include anche quella come 4° source. - """ - import asyncio - - # CoinDesk + CryptoCompare + Messari sempre (free, no key) - tasks = [ - _fetch_coindesk_headlines(limit), - _fetch_cryptocompare_news(limit), - _fetch_messari_news(limit), - ] - include_cp = bool(api_key) and api_key.lower() not in ("placeholder", "none", "changeme") - if include_cp: - tasks.append(_fetch_cryptopanic_news(api_key, limit)) - - results = await asyncio.gather(*tasks, return_exceptions=True) - all_items: list[dict[str, Any]] = [] - providers_ok: list[str] = [] - providers_failed: list[str] = [] - provider_names = ["coindesk", "cryptocompare", "messari"] - if include_cp: - provider_names.append("cryptopanic") - for name, res in zip(provider_names, results, strict=True): - if isinstance(res, Exception) or not res: - providers_failed.append(name) - continue - providers_ok.append(name) - for item in res: - if "provider" not in item: - item["provider"] = name - all_items.append(item) - - # Dedup per normalized title — preserva primo match - seen: set[str] = set() - deduped: list[dict[str, Any]] = [] - for h in all_items: - key = _normalize_title(h.get("title", "")) - if not key or key in seen: - continue - seen.add(key) - deduped.append(h) - - # Sort per published_at DESC (stringhe ISO confrontabili; quelle vuote in fondo) - deduped.sort(key=lambda x: x.get("published_at") or "", reverse=True) - - return { - "headlines": deduped[:limit], - "sources": providers_ok, - "sources_failed": providers_failed, - "total_before_dedup": len(all_items), - "total_after_dedup": len(deduped), - } - - -async def _fetch_cryptopanic_news(api_key: str, limit: int) -> list[dict[str, Any]]: - """Cryptopanic as one of the sources. Failure → [].""" - try: - async with async_client(timeout=10.0) as client: - resp = await client.get( - CRYPTOPANIC_URL, - params={"auth_token": api_key, "public": "true"}, - ) - if resp.status_code >= 400: - return [] - data = resp.json() - except Exception: - return [] - return [ - { - "title": r.get("title", ""), - "source": (r.get("source") or {}).get("title", "Cryptopanic"), - "published_at": r.get("published_at", ""), - "url": r.get("url", ""), - "provider": "cryptopanic", - } - for r in (data.get("results") or [])[:limit] - ] - - -async def _fetch_lunarcrush(symbol: str, api_key: str) -> dict | None: - """CER-P2-005: LunarCrush v4 social metrics. Ritorna None se fail.""" - try: - async with async_client(timeout=10.0) as client: - resp = await client.get( - LUNARCRUSH_COIN_URL.format(symbol=symbol.upper()), - headers={"Authorization": f"Bearer {api_key}"}, - ) - if resp.status_code != 200: - return None - data = (resp.json() or {}).get("data") or {} - return { - "galaxy_score": data.get("galaxy_score"), - "alt_rank": data.get("alt_rank"), - "sentiment": data.get("sentiment"), # 0-100 scale - "social_volume_24h": data.get("social_volume_24h"), - "social_dominance": data.get("social_dominance"), - } - except Exception: - return None - - -def _fng_to_sentiment(value: int) -> float: - """Normalize fear&greed 0-100 to [-1, 1] proxy sentiment.""" - return round((value - 50) / 50.0, 3) - - -async def fetch_social_sentiment(symbol: str = "BTC") -> dict[str, Any]: - """CER-P2-005: provider chain LunarCrush + fear_greed proxy. - - Se LUNARCRUSH_API_KEY env è presente e risponde, usa valori reali. - Altrimenti deriva proxy da fear_greed per popolare twitter/reddit sentiment - (marcato come derived=True così l'agent sa che è proxy). - """ - async with async_client(timeout=10.0) as client: - fng_resp = await client.get(ALTERNATIVE_ME_URL, params={"limit": 1}) - fng_data = fng_resp.json() - fng_list = fng_data.get("data", []) - fng = fng_list[0] if fng_list else {} - fng_value = int(fng.get("value", 0)) - proxy = _fng_to_sentiment(fng_value) if fng_value else 0.0 - - result: dict[str, Any] = { - "fear_greed_index": fng_value, - "fear_greed_label": fng.get("value_classification", ""), - "symbol": symbol.upper(), - "social_volume": 0, - "twitter_sentiment": 0.0, - "reddit_sentiment": 0.0, - "source": "fear_greed_only", - "derived": True, - } - - lc_key = os.environ.get("LUNARCRUSH_API_KEY", "").strip() - if lc_key: - lc = await _fetch_lunarcrush(symbol, lc_key) - if lc is not None: - # LunarCrush sentiment 0-100 → normalize to [-1, 1] - lc_sent = lc.get("sentiment") - norm = round((float(lc_sent) - 50) / 50.0, 3) if lc_sent is not None else None - result.update({ - "twitter_sentiment": norm if norm is not None else proxy, - "reddit_sentiment": norm if norm is not None else proxy, - "social_volume": int(lc.get("social_volume_24h") or 0), - "galaxy_score": lc.get("galaxy_score"), - "alt_rank": lc.get("alt_rank"), - "social_dominance": lc.get("social_dominance"), - "source": "lunarcrush+fear_greed", - "derived": False, - }) - return result - - # Proxy-only path - result["twitter_sentiment"] = proxy - result["reddit_sentiment"] = proxy - result["note"] = ( - "twitter/reddit derived from fear_greed_index; configure LUNARCRUSH_API_KEY " - "for real social metrics" - ) - return result - - -async def fetch_funding_rates(asset: str = "BTC") -> dict[str, Any]: - """Fetch perpetual funding rates from Binance, Bybit and OKX public APIs.""" - asset = asset.upper() - usdt_symbol = f"{asset}USDT" - okx_inst = f"{asset}-USDT-SWAP" - rates: list[dict[str, Any]] = [] - - async with async_client(timeout=10.0) as client: - # Binance - try: - resp = await client.get(BINANCE_FUNDING_URL, params={"symbol": usdt_symbol}) - if resp.status_code == 200: - d = resp.json() - rates.append( - { - "exchange": "binance", - "asset": asset, - "rate": float(d.get("lastFundingRate", 0)), - "next_funding_time": d.get("nextFundingTime", ""), - } - ) - except Exception: - pass - - # Bybit - try: - resp = await client.get( - BYBIT_FUNDING_URL, - params={"category": "linear", "symbol": usdt_symbol}, - ) - if resp.status_code == 200: - items = resp.json().get("result", {}).get("list", []) - if items: - d = items[0] - rates.append( - { - "exchange": "bybit", - "asset": asset, - "rate": float(d.get("fundingRate", 0)), - "next_funding_time": d.get("nextFundingTime", ""), - } - ) - except Exception: - pass - - # OKX - try: - resp = await client.get(OKX_FUNDING_URL, params={"instId": okx_inst}) - if resp.status_code == 200: - items = resp.json().get("data", []) - if items: - d = items[0] - rates.append( - { - "exchange": "okx", - "asset": asset, - "rate": float(d.get("fundingRate", 0)), - "next_funding_time": d.get("nextFundingTime", ""), - } - ) - except Exception: - pass - - return {"asset": asset, "rates": rates} - - -async def fetch_cross_exchange_funding(assets: list[str] | None = None) -> dict[str, Any]: - """Snapshot multi-asset funding rates con spread e arbitrage detection.""" - from datetime import UTC - from datetime import datetime as _dt - - assets = [a.upper() for a in (assets or ["BTC", "ETH", "SOL"])] - snapshot: dict[str, dict[str, Any]] = {} - async with async_client(timeout=10.0) as client: - for asset in assets: - rates: dict[str, float | None] = { - "binance": None, - "bybit": None, - "okx": None, - "hyperliquid": None, - } - try: - resp = await client.get( - BINANCE_FUNDING_URL, params={"symbol": f"{asset}USDT"} - ) - if resp.status_code == 200: - rates["binance"] = float(resp.json().get("lastFundingRate", 0)) - except Exception: - pass - try: - resp = await client.get( - BYBIT_FUNDING_URL, - params={"category": "linear", "symbol": f"{asset}USDT"}, - ) - if resp.status_code == 200: - items = resp.json().get("result", {}).get("list", []) - if items: - rates["bybit"] = float(items[0].get("fundingRate", 0)) - except Exception: - pass - try: - resp = await client.get( - OKX_FUNDING_URL, params={"instId": f"{asset}-USDT-SWAP"} - ) - if resp.status_code == 200: - items = resp.json().get("data", []) - if items: - rates["okx"] = float(items[0].get("fundingRate", 0)) - except Exception: - pass - try: - resp = await client.post( - "https://api.hyperliquid.xyz/info", - json={"type": "metaAndAssetCtxs"}, - ) - if resp.status_code == 200: - data = resp.json() - universe = data[0].get("universe") or [] - ctx_list = data[1] if len(data) > 1 else [] - for meta, ctx in zip(universe, ctx_list, strict=False): - if meta.get("name", "").upper() == asset: - rates["hyperliquid"] = float(ctx.get("funding", 0)) - break - except Exception: - pass - - present = [v for v in rates.values() if v is not None] - spread_max_min = max(present) - min(present) if present else None - anomaly = None - if present and spread_max_min is not None: - mean_r = sum(present) / len(present) - for name, v in rates.items(): - if v is None: - continue - if abs(v - mean_r) > 2 * (spread_max_min / 2 or 1e-9): - anomaly = f"{name}_outlier" - break - - snapshot[asset] = { - **rates, - "spread_max_min": spread_max_min, - "anomaly": anomaly, - } - - # Arbitrage opportunities - arbs = [] - for asset, data in snapshot.items(): - values = [(k, v) for k, v in data.items() if k in ("binance", "bybit", "okx", "hyperliquid") and v is not None] - if len(values) < 2: - continue - values.sort(key=lambda x: x[1]) - low_ex, low_v = values[0] - high_ex, high_v = values[-1] - diff = high_v - low_v - ann_pct = diff * 24 * 365 * 100 # hourly funding → annual pct - if ann_pct > 50: - arbs.append({ - "asset": asset, - "pair": f"long_{low_ex}_short_{high_ex}", - "funding_differential_ann": round(ann_pct, 2), - "risk_adjusted": "acceptable" if ann_pct > 100 else "marginal", - }) - - return { - "assets": assets, - "snapshot": snapshot, - "arbitrage_opportunities": arbs, - "data_timestamp": _dt.now(UTC).isoformat(), - } - - -async def fetch_funding_arb_spread(assets: list[str] | None = None) -> dict[str, Any]: - """Riassume opportunità di arbitrage funding su cross-exchange in un - formato compatto: per ogni asset, rate min/max + spread + annualized %. - Wrapper su fetch_cross_exchange_funding focalizzato su action items. - """ - base = await fetch_cross_exchange_funding(assets) - snapshot = base.get("snapshot") or {} - rows: list[dict[str, Any]] = [] - for asset, data in snapshot.items(): - rates = {k: v for k, v in data.items() if k in ("binance", "bybit", "okx", "hyperliquid") and v is not None} - if len(rates) < 2: - continue - sorted_rates = sorted(rates.items(), key=lambda x: x[1]) - low_ex, low_v = sorted_rates[0] - high_ex, high_v = sorted_rates[-1] - spread = high_v - low_v - # Funding cycle: 8h on most, 1h on hyperliquid → assume 8h => 3x/day - ann_pct = spread * 3 * 365 * 100 - actionable = ann_pct > 50 - rows.append({ - "asset": asset, - "long_venue": low_ex, - "short_venue": high_ex, - "long_funding": low_v, - "short_funding": high_v, - "spread": spread, - "annualized_pct": round(ann_pct, 2), - "actionable": actionable, - }) - rows.sort(key=lambda r: -r["annualized_pct"]) - return { - "opportunities": rows, - "data_timestamp": base.get("data_timestamp"), - } - - -async def fetch_liquidation_heatmap(asset: str = "BTC") -> dict[str, Any]: - """Heuristic liquidation pressure: combina OI delta + funding extreme su - asset. NON usa feed liq paid (Coinglass): stima dove si concentra - leveraged exposure pronta a essere liquidata. - - long_squeeze_risk: high se OI cresce + funding positivo (long crowded). - short_squeeze_risk: high se OI cresce + funding negativo (short crowded). - """ - asset = asset.upper() - oi = await fetch_oi_history(asset=asset, period="5m", limit=288) - funding = await fetch_cross_exchange_funding(assets=[asset]) - snap = (funding.get("snapshot") or {}).get(asset) or {} - rates = [v for k, v in snap.items() if k in ("binance", "bybit", "okx", "hyperliquid") and v is not None] - avg_funding = sum(rates) / len(rates) if rates else None - - delta_4h = oi.get("delta_pct_4h") - delta_24h = oi.get("delta_pct_24h") - - long_risk = "low" - short_risk = "low" - if avg_funding is not None and delta_24h is not None: - if avg_funding > 0.0001 and delta_24h > 5: - long_risk = "high" - elif avg_funding > 0.00005 and delta_24h > 2: - long_risk = "medium" - if avg_funding < -0.0001 and delta_24h > 5: - short_risk = "high" - elif avg_funding < -0.00005 and delta_24h > 2: - short_risk = "medium" - - return { - "asset": asset, - "avg_funding_rate": avg_funding, - "oi_delta_pct_4h": delta_4h, - "oi_delta_pct_24h": delta_24h, - "long_squeeze_risk": long_risk, - "short_squeeze_risk": short_risk, - "note": "heuristic — non sostituisce feed liq dedicati (Coinglass).", - } - - -async def fetch_cointegration_pairs( - pairs: list[list[str]] | None = None, - lookback_hours: int = 24, -) -> dict[str, Any]: - """Test Engle-Granger su coppie crypto su Binance hourly. - pairs: lista di [base, quote] (es. [["BTC", "ETH"]]). Default top-3. - """ - from mcp_common.stats import cointegration_test - - pairs = pairs or [["BTC", "ETH"], ["BTC", "SOL"], ["ETH", "SOL"]] - out: list[dict[str, Any]] = [] - interval = "1h" - limit = max(50, lookback_hours) - - async with async_client(timeout=15.0) as client: - for pair in pairs: - if len(pair) != 2: - continue - a, b = pair[0].upper(), pair[1].upper() - sym_a = f"{a}USDT" - sym_b = f"{b}USDT" - try: - resp_a = await client.get( - "https://api.binance.com/api/v3/klines", - params={"symbol": sym_a, "interval": interval, "limit": limit}, - ) - resp_b = await client.get( - "https://api.binance.com/api/v3/klines", - params={"symbol": sym_b, "interval": interval, "limit": limit}, - ) - if resp_a.status_code != 200 or resp_b.status_code != 200: - continue - closes_a = [float(k[4]) for k in resp_a.json()] - closes_b = [float(k[4]) for k in resp_b.json()] - if len(closes_a) != len(closes_b): - n = min(len(closes_a), len(closes_b)) - closes_a = closes_a[-n:] - closes_b = closes_b[-n:] - result = cointegration_test(closes_a, closes_b) - out.append({ - "pair": [a, b], - "samples": len(closes_a), - **result, - }) - except Exception as e: - out.append({"pair": [a, b], "error": str(e)}) - - out.sort(key=lambda r: r.get("adf_t_stat") or 0) - return { - "results": out, - "lookback_hours": lookback_hours, - } - - -async def fetch_world_news() -> dict[str, Any]: - """Fetch world financial news from free RSS feeds.""" - articles: list[dict[str, Any]] = [] - - async with async_client(timeout=10.0, follow_redirects=True) as client: - for source_name, url in WORLD_NEWS_FEEDS: - try: - resp = await client.get(url) - if resp.status_code != 200: - continue - root = ET.fromstring(resp.text) - for item in root.findall(".//item")[:5]: - title = item.findtext("title", "") - link = item.findtext("link", "") - pub_date = item.findtext("pubDate", "") - description = item.findtext("description", "") - if "<" in description: - description = re.sub(r"<[^>]+>", "", description).strip() - articles.append( - { - "source": source_name, - "title": title, - "url": link, - "published": pub_date, - "summary": description[:200] if description else "", - } - ) - except Exception: - continue - - return {"articles": articles, "count": len(articles)} - - -async def fetch_oi_history(asset: str = "BTC", period: str = "5m", limit: int = 288) -> dict[str, Any]: - """Open interest history perpetual da Binance futures (public). - - period: 5m|15m|30m|1h|2h|4h|6h|12h|1d (Binance API). - limit: 1..500, default 288 = 24h a 5min. - """ - asset = asset.upper() - symbol = f"{asset}USDT" - limit = max(1, min(int(limit), 500)) - points: list[dict[str, Any]] = [] - try: - async with async_client(timeout=10.0) as client: - resp = await client.get( - BINANCE_OI_HIST_URL, - params={"symbol": symbol, "period": period, "limit": limit}, - ) - if resp.status_code == 200: - for row in resp.json() or []: - points.append( - { - "timestamp": int(row.get("timestamp", 0)), - "oi": float(row.get("sumOpenInterest", 0)), - "oi_value_usd": float(row.get("sumOpenInterestValue", 0)), - } - ) - except Exception: - pass - - def _delta_pct(points: list[dict[str, Any]], minutes_back: int) -> float | None: - if len(points) < 2: - return None - current = points[-1] - cutoff_ts = current["timestamp"] - minutes_back * 60 * 1000 - past = next((p for p in reversed(points) if p["timestamp"] <= cutoff_ts), None) - if past is None or past["oi"] == 0: - return None - return round(100.0 * (current["oi"] - past["oi"]) / past["oi"], 3) - - return { - "asset": asset, - "exchange": "binance", - "symbol": symbol, - "period": period, - "points": points, - "current_oi": points[-1]["oi"] if points else None, - "current_oi_value_usd": points[-1]["oi_value_usd"] if points else None, - "delta_pct_1h": _delta_pct(points, 60), - "delta_pct_4h": _delta_pct(points, 240), - "delta_pct_24h": _delta_pct(points, 1440), - "data_points": len(points), - } diff --git a/services/mcp-sentiment/src/mcp_sentiment/server.py b/services/mcp-sentiment/src/mcp_sentiment/server.py deleted file mode 100644 index 65f05b4..0000000 --- a/services/mcp-sentiment/src/mcp_sentiment/server.py +++ /dev/null @@ -1,174 +0,0 @@ -from __future__ import annotations - -import logging -import os - -from fastapi import Depends, FastAPI, HTTPException -from mcp_common.auth import Principal, TokenStore, require_principal -from mcp_common.mcp_bridge import mount_mcp_endpoint -from mcp_common.server import build_app -from pydantic import BaseModel - -from mcp_sentiment.fetchers import ( - fetch_cointegration_pairs, - fetch_cross_exchange_funding, - fetch_crypto_news, - fetch_funding_arb_spread, - fetch_funding_rates, - fetch_liquidation_heatmap, - fetch_oi_history, - fetch_social_sentiment, - fetch_world_news, -) - -logger = logging.getLogger(__name__) - -# --- Body models --- - -class GetCryptoNewsReq(BaseModel): - limit: int = 20 - - -class GetSocialSentimentReq(BaseModel): - symbol: str = "BTC" - - -class GetFundingRatesReq(BaseModel): - asset: str = "BTC" - - -class GetWorldNewsReq(BaseModel): - pass - - -class GetCrossExchangeFundingReq(BaseModel): - assets: list[str] | None = None - - -class GetFundingArbSpreadReq(BaseModel): - assets: list[str] | None = None - - -class GetLiquidationHeatmapReq(BaseModel): - asset: str = "BTC" - - -class GetCointegrationPairsReq(BaseModel): - pairs: list[list[str]] | None = None - lookback_hours: int = 24 - - -class GetOiHistoryReq(BaseModel): - asset: str = "BTC" - period: str = "5m" - limit: int = 288 - - -# --- ACL helper --- - -def _check(principal: Principal, *, core: bool = False, observer: bool = False) -> None: - allowed: set[str] = set() - if core: - allowed.add("core") - if observer: - allowed.add("observer") - if not (principal.capabilities & allowed): - raise HTTPException(403, f"capability required: {allowed}") - - -# --- App factory --- - -def create_app(*, cryptopanic_key: str = "", token_store: TokenStore) -> FastAPI: - app = build_app(name="mcp-sentiment", version="0.1.0", token_store=token_store) - - if not cryptopanic_key or cryptopanic_key.lower() in ("placeholder", "none", "changeme"): - logger.warning( - "mcp-sentiment: cryptopanic_key mancante o placeholder — get_crypto_news " - "ritornerà headlines=[] con note diagnostica" - ) - - @app.post("/tools/get_crypto_news", tags=["reads"]) - async def t_get_crypto_news( - body: GetCryptoNewsReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_crypto_news(api_key=cryptopanic_key, limit=body.limit) - - @app.post("/tools/get_social_sentiment", tags=["reads"]) - async def t_get_social_sentiment( - body: GetSocialSentimentReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_social_sentiment(body.symbol) - - @app.post("/tools/get_funding_rates", tags=["reads"]) - async def t_get_funding_rates( - body: GetFundingRatesReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_funding_rates(body.asset) - - @app.post("/tools/get_world_news", tags=["reads"]) - async def t_get_world_news( - body: GetWorldNewsReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_world_news() - - @app.post("/tools/get_cross_exchange_funding", tags=["reads"]) - async def t_get_cross_exchange_funding( - body: GetCrossExchangeFundingReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_cross_exchange_funding(body.assets) - - @app.post("/tools/get_funding_arb_spread", tags=["reads"]) - async def t_get_funding_arb_spread( - body: GetFundingArbSpreadReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_funding_arb_spread(body.assets) - - @app.post("/tools/get_liquidation_heatmap", tags=["reads"]) - async def t_get_liquidation_heatmap( - body: GetLiquidationHeatmapReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_liquidation_heatmap(body.asset) - - @app.post("/tools/get_cointegration_pairs", tags=["reads"]) - async def t_get_cointegration_pairs( - body: GetCointegrationPairsReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_cointegration_pairs(body.pairs, body.lookback_hours) - - @app.post("/tools/get_oi_history", tags=["reads"]) - async def t_get_oi_history( - body: GetOiHistoryReq, principal: Principal = Depends(require_principal) - ): - _check(principal, core=True, observer=True) - return await fetch_oi_history(body.asset, body.period, body.limit) - - # ───── MCP endpoint (/mcp) — bridge verso /tools/* ───── - port = int(os.environ.get("PORT", "9014")) - mount_mcp_endpoint( - app, - name="cerbero-sentiment", - version="0.1.0", - token_store=token_store, - internal_base_url=f"http://localhost:{port}", - tools=[ - {"name": "get_crypto_news", "description": "News crypto da CryptoPanic."}, - {"name": "get_social_sentiment", "description": "Sentiment aggregato social."}, - {"name": "get_funding_rates", "description": "Funding rates aggregati."}, - {"name": "get_world_news", "description": "News macro/world."}, - {"name": "get_cross_exchange_funding", "description": "Funding multi-asset multi-exchange + arbitrage opportunities."}, - {"name": "get_oi_history", "description": "Open interest history perp (Binance) + delta_pct 1h/4h/24h."}, - {"name": "get_funding_arb_spread", "description": "Opportunità arbitrage funding cross-exchange in formato compatto + annualized %."}, - {"name": "get_liquidation_heatmap", "description": "Pressione liquidazioni heuristica da OI delta + funding (long/short squeeze risk)."}, - {"name": "get_cointegration_pairs", "description": "Engle-Granger cointegration test su coppie crypto Binance hourly."}, - ], - ) - - return app diff --git a/services/mcp-sentiment/tests/test_fetchers.py b/services/mcp-sentiment/tests/test_fetchers.py deleted file mode 100644 index da787f5..0000000 --- a/services/mcp-sentiment/tests/test_fetchers.py +++ /dev/null @@ -1,320 +0,0 @@ -from __future__ import annotations - -import httpx -import pytest -import pytest_httpx -from mcp_sentiment.fetchers import ( - fetch_crypto_news, - fetch_funding_rates, - fetch_social_sentiment, - fetch_world_news, -) - -# --- CER-017 multi-source news aggregator --- - -_COINDESK_RSS = ( - '' - "ETH rallyhttps://coindesk.com/eth" - "2026-04-19" - "Common headlinehttps://coindesk.com/x" - "2026-04-18" - "" -) - - -def _mock_three_providers(httpx_mock: pytest_httpx.HTTPXMock, *, cc_items=None, messari_items=None): - httpx_mock.add_response(url="https://www.coindesk.com/arc/outboundfeeds/rss/", text=_COINDESK_RSS) - httpx_mock.add_response( - url="https://min-api.cryptocompare.com/data/v2/news/?lang=EN", - json={"Data": cc_items if cc_items is not None else [ - {"title": "BTC ATH", "source": "CryptoCompare", "published_on": 1761868800, "url": "https://x/1"}, - {"title": "Common headline", "source": "Reuters", "published_on": 1761782400, "url": "https://x/2"}, - ]}, - ) - httpx_mock.add_response( - url="https://data.messari.io/api/v1/news", - json={"data": messari_items if messari_items is not None else [ - {"title": "SOL rally", "author": {"name": "Messari"}, "published_at": "2026-04-19T10:00:00Z", "url": "https://x/3"}, - ]}, - ) - - -@pytest.mark.asyncio -async def test_crypto_news_aggregates_three_sources(httpx_mock: pytest_httpx.HTTPXMock): - """CER-017: CoinDesk + CryptoCompare + Messari in parallelo.""" - _mock_three_providers(httpx_mock) - result = await fetch_crypto_news(limit=20) - titles = {h["title"] for h in result["headlines"]} - assert "ETH rally" in titles - assert "BTC ATH" in titles - assert "SOL rally" in titles - assert set(result["sources"]) == {"coindesk", "cryptocompare", "messari"} - assert result["sources_failed"] == [] - - -@pytest.mark.asyncio -async def test_crypto_news_dedup_by_title(httpx_mock: pytest_httpx.HTTPXMock): - """CER-017: stesso titolo su 2 provider → 1 sola entry.""" - _mock_three_providers(httpx_mock) - result = await fetch_crypto_news(limit=20) - common_count = sum(1 for h in result["headlines"] if h["title"].lower() == "common headline") - assert common_count == 1 - assert result["total_before_dedup"] > result["total_after_dedup"] - - -@pytest.mark.asyncio -async def test_crypto_news_partial_failure(httpx_mock: pytest_httpx.HTTPXMock): - """CER-017: 1 provider 500 → altri proseguono, sources_failed riporta.""" - httpx_mock.add_response(url="https://www.coindesk.com/arc/outboundfeeds/rss/", text=_COINDESK_RSS) - httpx_mock.add_response( - url="https://min-api.cryptocompare.com/data/v2/news/?lang=EN", - status_code=500, - ) - httpx_mock.add_response( - url="https://data.messari.io/api/v1/news", - json={"data": [{"title": "OK Messari", "author": {"name": "M"}, "published_at": "2026-04-19T10:00:00Z", "url": "https://x"}]}, - ) - result = await fetch_crypto_news(limit=20) - assert "cryptocompare" in result["sources_failed"] - assert "coindesk" in result["sources"] - assert "messari" in result["sources"] - - -@pytest.mark.asyncio -async def test_crypto_news_sorted_desc_by_date(httpx_mock: pytest_httpx.HTTPXMock): - """CER-017: ordine published_at DESC.""" - _mock_three_providers(httpx_mock) - result = await fetch_crypto_news(limit=20) - dates = [h.get("published_at") or "" for h in result["headlines"] if h.get("published_at")] - assert dates == sorted(dates, reverse=True) - - -@pytest.mark.asyncio -async def test_crypto_news_with_cryptopanic_key(httpx_mock: pytest_httpx.HTTPXMock): - """CER-017: se api_key presente, include Cryptopanic come 4° source.""" - _mock_three_providers(httpx_mock) - httpx_mock.add_response( - url=httpx.URL("https://cryptopanic.com/api/v1/posts/", params={"auth_token": "k", "public": "true"}), - json={"results": [{ - "title": "Cryptopanic exclusive", - "source": {"title": "CP"}, - "published_at": "2026-04-20T00:00:00Z", - "url": "https://x/cp", - }]}, - ) - result = await fetch_crypto_news(api_key="k", limit=20) - titles = {h["title"] for h in result["headlines"]} - assert "Cryptopanic exclusive" in titles - assert "cryptopanic" in result["sources"] - - -@pytest.mark.asyncio -async def test_crypto_news_placeholder_key_skips_cryptopanic(httpx_mock: pytest_httpx.HTTPXMock): - """CER-017: api_key placeholder → no Cryptopanic call.""" - _mock_three_providers(httpx_mock) - result = await fetch_crypto_news(api_key="placeholder", limit=20) - assert "cryptopanic" not in result["sources"] - assert "cryptopanic" not in result["sources_failed"] - - -@pytest.mark.asyncio -async def test_crypto_news_provider_tracing(httpx_mock: pytest_httpx.HTTPXMock): - """CER-017: ogni headline ha campo provider.""" - _mock_three_providers(httpx_mock) - result = await fetch_crypto_news(limit=20) - for h in result["headlines"]: - assert h.get("provider") in {"coindesk", "cryptocompare", "messari"} - - -# --- fetch_social_sentiment --- - -@pytest.mark.asyncio -async def test_social_sentiment_happy(httpx_mock: pytest_httpx.HTTPXMock): - httpx_mock.add_response( - url=httpx.URL( - "https://api.alternative.me/fng/", - params={"limit": "1"}, - ), - json={"data": [{"value": "72", "value_classification": "Greed"}]}, - ) - result = await fetch_social_sentiment() - assert result["fear_greed_index"] == 72 - assert result["fear_greed_label"] == "Greed" - assert "social_volume" in result - - -@pytest.mark.asyncio -async def test_social_sentiment_empty_data(httpx_mock: pytest_httpx.HTTPXMock): - httpx_mock.add_response( - url=httpx.URL( - "https://api.alternative.me/fng/", - params={"limit": "1"}, - ), - json={"data": []}, - ) - result = await fetch_social_sentiment() - assert result["fear_greed_index"] == 0 - assert result["fear_greed_label"] == "" - - -@pytest.mark.asyncio -async def test_social_sentiment_derives_proxy_from_fng( - httpx_mock: pytest_httpx.HTTPXMock, monkeypatch -): - """CER-P2-005: senza LUNARCRUSH_API_KEY, twitter/reddit derivano da F&G.""" - monkeypatch.delenv("LUNARCRUSH_API_KEY", raising=False) - httpx_mock.add_response( - url=httpx.URL("https://api.alternative.me/fng/", params={"limit": "1"}), - json={"data": [{"value": "25", "value_classification": "Extreme Fear"}]}, - ) - result = await fetch_social_sentiment() - assert result["twitter_sentiment"] == pytest.approx(-0.5) - assert result["reddit_sentiment"] == pytest.approx(-0.5) - assert result["derived"] is True - assert result["source"] == "fear_greed_only" - - -@pytest.mark.asyncio -async def test_social_sentiment_uses_lunarcrush_when_key_present( - httpx_mock: pytest_httpx.HTTPXMock, monkeypatch -): - """CER-P2-005: con LUNARCRUSH_API_KEY, valori reali.""" - monkeypatch.setenv("LUNARCRUSH_API_KEY", "test-key") - httpx_mock.add_response( - url=httpx.URL("https://api.alternative.me/fng/", params={"limit": "1"}), - json={"data": [{"value": "50", "value_classification": "Neutral"}]}, - ) - httpx_mock.add_response( - url="https://lunarcrush.com/api4/public/coins/BTC/v1", - json={"data": { - "sentiment": 80, - "galaxy_score": 75, - "alt_rank": 3, - "social_volume_24h": 12345, - "social_dominance": 25.5, - }}, - ) - result = await fetch_social_sentiment("BTC") - assert result["twitter_sentiment"] == pytest.approx(0.6) - assert result["reddit_sentiment"] == pytest.approx(0.6) - assert result["social_volume"] == 12345 - assert result["galaxy_score"] == 75 - assert result["derived"] is False - assert "lunarcrush" in result["source"] - - -@pytest.mark.asyncio -async def test_social_sentiment_lunarcrush_failure_fallback_to_proxy( - httpx_mock: pytest_httpx.HTTPXMock, monkeypatch -): - """CER-P2-005: se LC fallisce, fallback a proxy F&G — no crash.""" - monkeypatch.setenv("LUNARCRUSH_API_KEY", "broken-key") - httpx_mock.add_response( - url=httpx.URL("https://api.alternative.me/fng/", params={"limit": "1"}), - json={"data": [{"value": "75", "value_classification": "Greed"}]}, - ) - httpx_mock.add_response( - url="https://lunarcrush.com/api4/public/coins/BTC/v1", - status_code=401, - json={"error": "unauthorized"}, - ) - result = await fetch_social_sentiment("BTC") - assert result["twitter_sentiment"] == pytest.approx(0.5) - assert result["derived"] is True - assert result["source"] == "fear_greed_only" - - -# --- fetch_funding_rates --- - -@pytest.mark.asyncio -async def test_funding_rates_all_exchanges(httpx_mock: pytest_httpx.HTTPXMock): - httpx_mock.add_response( - url=httpx.URL( - "https://fapi.binance.com/fapi/v1/premiumIndex", - params={"symbol": "BTCUSDT"}, - ), - json={"lastFundingRate": "0.0001", "nextFundingTime": 1700000000000}, - ) - httpx_mock.add_response( - url=httpx.URL( - "https://api.bybit.com/v5/market/tickers", - params={"category": "linear", "symbol": "BTCUSDT"}, - ), - json={"result": {"list": [{"fundingRate": "0.0002", "nextFundingTime": "1700000000000"}]}}, - ) - httpx_mock.add_response( - url=httpx.URL( - "https://www.okx.com/api/v5/public/funding-rate", - params={"instId": "BTC-USDT-SWAP"}, - ), - json={"data": [{"fundingRate": "0.00015", "nextFundingTime": "1700000000000"}]}, - ) - result = await fetch_funding_rates() - assert "rates" in result - exchanges = {r["exchange"] for r in result["rates"]} - assert "binance" in exchanges - assert "bybit" in exchanges - assert "okx" in exchanges - - -@pytest.mark.asyncio -async def test_funding_rates_partial_failure(httpx_mock: pytest_httpx.HTTPXMock): - """If some exchanges fail, we still get results from others.""" - httpx_mock.add_response( - url=httpx.URL( - "https://fapi.binance.com/fapi/v1/premiumIndex", - params={"symbol": "BTCUSDT"}, - ), - json={"lastFundingRate": "0.0001", "nextFundingTime": 1700000000000}, - ) - httpx_mock.add_response( - url=httpx.URL( - "https://api.bybit.com/v5/market/tickers", - params={"category": "linear", "symbol": "BTCUSDT"}, - ), - status_code=500, - ) - httpx_mock.add_response( - url=httpx.URL( - "https://www.okx.com/api/v5/public/funding-rate", - params={"instId": "BTC-USDT-SWAP"}, - ), - status_code=500, - ) - result = await fetch_funding_rates() - assert len(result["rates"]) == 1 - assert result["rates"][0]["exchange"] == "binance" - - -# --- fetch_world_news --- - -@pytest.mark.asyncio -async def test_world_news_happy(httpx_mock: pytest_httpx.HTTPXMock): - rss_xml = """ - -Markets rallyhttp://example.com/1Mon, 15 Jan 2024 10:00:00 +0000Stocks up -""" - for _, url in [ - ("Reuters Business", "https://feeds.reuters.com/reuters/businessNews"), - ("CNBC Top News", "https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=100003114"), - ("Bloomberg Markets", "https://feeds.bloomberg.com/markets/news.rss"), - ("CoinDesk", "https://www.coindesk.com/arc/outboundfeeds/rss/"), - ]: - httpx_mock.add_response(url=url, text=rss_xml) - result = await fetch_world_news() - assert result["count"] == 4 - assert result["articles"][0]["title"] == "Markets rally" - - -@pytest.mark.asyncio -async def test_world_news_all_fail(httpx_mock: pytest_httpx.HTTPXMock): - for _, url in [ - ("Reuters Business", "https://feeds.reuters.com/reuters/businessNews"), - ("CNBC Top News", "https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=100003114"), - ("Bloomberg Markets", "https://feeds.bloomberg.com/markets/news.rss"), - ("CoinDesk", "https://www.coindesk.com/arc/outboundfeeds/rss/"), - ]: - httpx_mock.add_response(url=url, status_code=503) - result = await fetch_world_news() - assert result["articles"] == [] - assert result["count"] == 0 diff --git a/services/mcp-sentiment/tests/test_server_acl.py b/services/mcp-sentiment/tests/test_server_acl.py deleted file mode 100644 index b5fd28a..0000000 --- a/services/mcp-sentiment/tests/test_server_acl.py +++ /dev/null @@ -1,216 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, patch - -import pytest -from fastapi.testclient import TestClient -from mcp_common.auth import Principal, TokenStore -from mcp_sentiment.server import create_app - - -@pytest.fixture -def http(): - store = TokenStore( - tokens={ - "ct": Principal("core", {"core"}), - "ot": Principal("observer", {"observer"}), - } - ) - app = create_app(cryptopanic_key="testkey", token_store=store) - return TestClient(app) - - -# --- Health --- - -def test_health(http): - assert http.get("/health").status_code == 200 - - -# --- get_crypto_news --- - -def test_get_crypto_news_core_ok(http): - with patch( - "mcp_sentiment.server.fetch_crypto_news", - new=AsyncMock(return_value={"headlines": []}), - ): - r = http.post( - "/tools/get_crypto_news", - headers={"Authorization": "Bearer ct"}, - json={"limit": 5}, - ) - assert r.status_code == 200 - - -def test_get_crypto_news_observer_ok(http): - with patch( - "mcp_sentiment.server.fetch_crypto_news", - new=AsyncMock(return_value={"headlines": []}), - ): - r = http.post( - "/tools/get_crypto_news", - headers={"Authorization": "Bearer ot"}, - json={}, - ) - assert r.status_code == 200 - - -def test_get_crypto_news_no_auth_401(http): - r = http.post("/tools/get_crypto_news", json={}) - assert r.status_code == 401 - - -# --- get_social_sentiment --- - -def test_get_social_sentiment_core_ok(http): - with patch( - "mcp_sentiment.server.fetch_social_sentiment", - new=AsyncMock(return_value={"fear_greed_index": 65, "fear_greed_label": "Greed"}), - ): - r = http.post( - "/tools/get_social_sentiment", - headers={"Authorization": "Bearer ct"}, - json={}, - ) - assert r.status_code == 200 - assert r.json()["fear_greed_index"] == 65 - - -def test_get_social_sentiment_observer_ok(http): - with patch( - "mcp_sentiment.server.fetch_social_sentiment", - new=AsyncMock(return_value={"fear_greed_index": 65}), - ): - r = http.post( - "/tools/get_social_sentiment", - headers={"Authorization": "Bearer ot"}, - json={}, - ) - assert r.status_code == 200 - - -def test_get_social_sentiment_no_auth_401(http): - r = http.post("/tools/get_social_sentiment", json={}) - assert r.status_code == 401 - - -# --- get_funding_rates --- - -def test_get_funding_rates_core_ok(http): - with patch( - "mcp_sentiment.server.fetch_funding_rates", - new=AsyncMock(return_value={"rates": []}), - ): - r = http.post( - "/tools/get_funding_rates", - headers={"Authorization": "Bearer ct"}, - json={}, - ) - assert r.status_code == 200 - - -def test_get_funding_rates_observer_ok(http): - with patch( - "mcp_sentiment.server.fetch_funding_rates", - new=AsyncMock(return_value={"rates": []}), - ): - r = http.post( - "/tools/get_funding_rates", - headers={"Authorization": "Bearer ot"}, - json={}, - ) - assert r.status_code == 200 - - -def test_get_funding_rates_no_auth_401(http): - r = http.post("/tools/get_funding_rates", json={}) - assert r.status_code == 401 - - -# --- get_world_news --- - -def test_get_world_news_core_ok(http): - with patch( - "mcp_sentiment.server.fetch_world_news", - new=AsyncMock(return_value={"articles": [], "count": 0}), - ): - r = http.post( - "/tools/get_world_news", - headers={"Authorization": "Bearer ct"}, - json={}, - ) - assert r.status_code == 200 - - -def test_get_world_news_observer_ok(http): - with patch( - "mcp_sentiment.server.fetch_world_news", - new=AsyncMock(return_value={"articles": [], "count": 0}), - ): - r = http.post( - "/tools/get_world_news", - headers={"Authorization": "Bearer ot"}, - json={}, - ) - assert r.status_code == 200 - - -def test_get_world_news_no_auth_401(http): - r = http.post("/tools/get_world_news", json={}) - assert r.status_code == 401 - - -# --- New indicators: funding_arb_spread, liquidation_heatmap, cointegration_pairs --- - -def test_get_funding_arb_spread_ok(http): - with patch( - "mcp_sentiment.server.fetch_funding_arb_spread", - new=AsyncMock(return_value={"opportunities": []}), - ): - r = http.post( - "/tools/get_funding_arb_spread", - headers={"Authorization": "Bearer ot"}, - json={}, - ) - assert r.status_code == 200 - - -def test_get_funding_arb_spread_no_auth_401(http): - r = http.post("/tools/get_funding_arb_spread", json={}) - assert r.status_code == 401 - - -def test_get_liquidation_heatmap_ok(http): - with patch( - "mcp_sentiment.server.fetch_liquidation_heatmap", - new=AsyncMock(return_value={"asset": "BTC", "long_squeeze_risk": "low"}), - ): - r = http.post( - "/tools/get_liquidation_heatmap", - headers={"Authorization": "Bearer ct"}, - json={"asset": "BTC"}, - ) - assert r.status_code == 200 - - -def test_get_liquidation_heatmap_no_auth_401(http): - r = http.post("/tools/get_liquidation_heatmap", json={"asset": "BTC"}) - assert r.status_code == 401 - - -def test_get_cointegration_pairs_ok(http): - with patch( - "mcp_sentiment.server.fetch_cointegration_pairs", - new=AsyncMock(return_value={"results": []}), - ): - r = http.post( - "/tools/get_cointegration_pairs", - headers={"Authorization": "Bearer ot"}, - json={"pairs": [["BTC", "ETH"]]}, - ) - assert r.status_code == 200 - - -def test_get_cointegration_pairs_no_auth_401(http): - r = http.post("/tools/get_cointegration_pairs", json={}) - assert r.status_code == 401 -