From 403faef2116efa896fa0fe146e4734c1301e5cf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 15:10:52 +0000 Subject: [PATCH 1/5] Add missing pdmt5 functions as API endpoints Expose all remaining read-only pdmt5.Mt5DataClient and Mt5Client functions, plus write operations (order_send, order_check, symbol_select, market_book_add/release). New read-only endpoints: - GET /last-error: last MT5 error info - GET /symbols/total: total symbol count - GET /orders/total: active orders count - GET /positions/total: open positions count - GET /history/orders/total: historical orders count by date range - GET /history/deals/total: historical deals count by date range - GET /calc/margin: margin calculation for a trading operation - GET /calc/profit: profit calculation for a trading operation New write endpoints: - POST /order/check: validate funds sufficiency for a trade - POST /order/send: execute a trade request - POST /symbols/{symbol}/select: show/hide symbol in MarketWatch - POST /market-book/{symbol}/subscribe: subscribe to market depth - POST /market-book/{symbol}/unsubscribe: unsubscribe from market depth https://claude.ai/code/session_01SJQJzQNfkFizmofhvFEByB --- mt5api/main.py | 4 +- mt5api/models.py | 179 +++++++++++++++++++++++++++++++++++++ mt5api/routers/__init__.py | 4 +- mt5api/routers/calc.py | 87 ++++++++++++++++++ mt5api/routers/health.py | 24 +++++ mt5api/routers/history.py | 87 ++++++++++++++++++ mt5api/routers/symbols.py | 19 ++++ mt5api/routers/trading.py | 158 ++++++++++++++++++++++++++++++++ tests/conftest.py | 48 ++++++++++ tests/mt5_constants.py | 8 ++ tests/test_calc.py | 155 ++++++++++++++++++++++++++++++++ tests/test_health.py | 34 +++++++ tests/test_history.py | 102 +++++++++++++++++++++ tests/test_models.py | 34 ++++++- tests/test_symbols.py | 31 +++++++ tests/test_trading.py | 165 ++++++++++++++++++++++++++++++++++ 16 files changed, 1135 insertions(+), 4 deletions(-) create mode 100644 mt5api/routers/calc.py create mode 100644 mt5api/routers/trading.py create mode 100644 tests/test_calc.py create mode 100644 tests/test_trading.py diff --git a/mt5api/main.py b/mt5api/main.py index eb0bb3f..8c12bdb 100644 --- a/mt5api/main.py +++ b/mt5api/main.py @@ -30,7 +30,7 @@ ) from .dependencies import shutdown_mt5_client from .middleware import add_middleware -from .routers import account, health, history, market, symbols +from .routers import account, calc, health, history, market, symbols, trading if TYPE_CHECKING: from collections.abc import AsyncGenerator @@ -190,5 +190,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # noqa: ARG001 app.include_router(market.router, prefix=router_prefix) app.include_router(account.router, prefix=router_prefix) app.include_router(history.router, prefix=router_prefix) +app.include_router(calc.router, prefix=router_prefix) +app.include_router(trading.router, prefix=router_prefix) logger.info("MT5 REST API initialized") diff --git a/mt5api/models.py b/mt5api/models.py index bd3a463..30453da 100644 --- a/mt5api/models.py +++ b/mt5api/models.py @@ -48,6 +48,15 @@ class ResponseFormat(StrEnum): "COPY_TICKS_TRADE", "COPY_TICKS_ALL", ) +_ORDER_TYPE_DESCRIPTION = ( + "MetaTrader5 ORDER_TYPE constant. Accepts a constant name such as " + "ORDER_TYPE_BUY or the corresponding integer value." +) +_ORDER_TYPE_EXAMPLE_NAMES = ( + "ORDER_TYPE_BUY", + "ORDER_TYPE_SELL", +) +_ORDER_TYPE_VALIDATION_DESCRIPTION = "MetaTrader5 ORDER_TYPE constant" _TIMEFRAME_VALIDATION_DESCRIPTION = "MetaTrader5 TIMEFRAME constant" _COPY_TICKS_VALIDATION_DESCRIPTION = "MetaTrader5 COPY_TICKS constant" @@ -116,6 +125,12 @@ def _get_mt5_module() -> ModuleType: names=_COPY_TICKS_NAMES, example_names=_COPY_TICKS_NAMES, ) +_ORDER_TYPE_SPEC = Mt5ConstantSpec( + validation_description=_ORDER_TYPE_VALIDATION_DESCRIPTION, + schema_description=_ORDER_TYPE_DESCRIPTION, + prefix="ORDER_TYPE_", + example_names=_ORDER_TYPE_EXAMPLE_NAMES, +) def _parse_mt5_constant( @@ -244,6 +259,35 @@ def _validate_mt5_copy_ticks(value: object) -> int: return _parse_mt5_constant(value, spec=_COPY_TICKS_SPEC) +def get_mt5_order_type_values() -> tuple[int, ...]: + """Return all available MT5 ORDER_TYPE values from the pdmt5 MT5 module copy.""" + return _ORDER_TYPE_SPEC.sorted_values + + +def get_mt5_order_type_names() -> tuple[str, ...]: + """Return all available MT5 ORDER_TYPE names from the pdmt5 MT5 module copy.""" + return _ORDER_TYPE_SPEC.sorted_names + + +def get_mt5_order_type_examples() -> list[int]: + """Return common MT5 ORDER_TYPE integer examples.""" + return _ORDER_TYPE_SPEC.example_values + + +def get_mt5_order_type_example_names() -> list[str]: + """Return common MT5 ORDER_TYPE example names.""" + return _ORDER_TYPE_SPEC.example_name_list + + +def _validate_mt5_order_type(value: object) -> int: + """Validate MT5 ORDER_TYPE input. + + Returns: + The validated integer ORDER_TYPE value. + """ + return _parse_mt5_constant(value, spec=_ORDER_TYPE_SPEC) + + Mt5Timeframe: TypeAlias = Annotated[ int, BeforeValidator(_validate_mt5_timeframe), @@ -274,6 +318,21 @@ def _validate_mt5_copy_ticks(value: object) -> int: ] +Mt5OrderType: TypeAlias = Annotated[ + int, + BeforeValidator(_validate_mt5_order_type), + WithJsonSchema( + _build_mt5_constant_json_schema( + description=_ORDER_TYPE_SPEC.schema_description, + names=get_mt5_order_type_names(), + values=get_mt5_order_type_values(), + examples=get_mt5_order_type_example_names(), + ), + mode="validation", + ), +] + + class ErrorResponse(BaseModel): """RFC 7807 Problem Details error response model.""" @@ -578,3 +637,123 @@ class HistoryDealsRequest(HistoryRequestBase): group: str | None = Field(default=None) symbol: str | None = Field(default=None) format: ResponseFormat | None = Field(default=None) + + +class HistoryTotalRequest(BaseModel): + """Request parameters for history total count endpoints.""" + + date_from: datetime = Field( + ..., + description="Start date (ISO 8601 format)", + examples=["2024-01-01T00:00:00Z"], + ) + date_to: datetime = Field( + ..., + description="End date (ISO 8601 format)", + examples=["2024-01-02T00:00:00Z"], + ) + + @model_validator(mode="after") + def validate_date_range(self) -> Self: + """Ensure date_from is before date_to. + + Returns: + The validated model instance. + + Raises: + ValueError: If date_from is not before date_to. + """ + if self.date_from >= self.date_to: + range_error = "date_from must be before date_to" + raise ValueError(range_error) + return self + + +class CalcMarginRequest(BaseModel): + """Request parameters for margin calculation endpoint.""" + + action: Mt5OrderType = Field( + ..., + description=_ORDER_TYPE_DESCRIPTION, + ) + symbol: str = Field( + ..., + description="Symbol name", + examples=["EURUSD"], + ) + volume: float = Field( + ..., + description="Trade volume in lots", + gt=0, + examples=[0.1, 1.0], + ) + price: float = Field( + ..., + description="Open price", + gt=0, + examples=[1.08500], + ) + + +class CalcProfitRequest(BaseModel): + """Request parameters for profit calculation endpoint.""" + + action: Mt5OrderType = Field( + ..., + description=_ORDER_TYPE_DESCRIPTION, + ) + symbol: str = Field( + ..., + description="Symbol name", + examples=["EURUSD"], + ) + volume: float = Field( + ..., + description="Trade volume in lots", + gt=0, + examples=[0.1, 1.0], + ) + price_open: float = Field( + ..., + description="Open price", + gt=0, + examples=[1.08500], + ) + price_close: float = Field( + ..., + description="Close price", + gt=0, + examples=[1.09000], + ) + + +class OrderCheckRequest(BaseModel): + """Request parameters for order check endpoint.""" + + request: dict[str, Any] = Field( + ..., + description="Trade request dictionary for order validation", + ) + + +class OrderSendRequest(BaseModel): + """Request parameters for order send endpoint.""" + + request: dict[str, Any] = Field( + ..., + description="Trade request dictionary for order execution", + ) + + +class SymbolSelectRequest(BaseModel): + """Request parameters for symbol select endpoint.""" + + symbol: str = Field( + ..., + description="Symbol name", + examples=["EURUSD"], + ) + enable: bool = Field( + default=True, + description="True to show symbol in MarketWatch, False to hide", + ) diff --git a/mt5api/routers/__init__.py b/mt5api/routers/__init__.py index 7134d60..e314ff9 100644 --- a/mt5api/routers/__init__.py +++ b/mt5api/routers/__init__.py @@ -2,6 +2,6 @@ from __future__ import annotations -from . import account, health, history, market, symbols +from . import account, calc, health, history, market, symbols, trading -__all__ = ["account", "health", "history", "market", "symbols"] +__all__ = ["account", "calc", "health", "history", "market", "symbols", "trading"] diff --git a/mt5api/routers/calc.py b/mt5api/routers/calc.py new file mode 100644 index 0000000..94d268a --- /dev/null +++ b/mt5api/routers/calc.py @@ -0,0 +1,87 @@ +"""Trading calculation endpoints (margin and profit).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated + +from fastapi import APIRouter, Depends +from pdmt5.dataframe import Mt5DataClient # noqa: TC002 + +from mt5api.auth import verify_api_key +from mt5api.dependencies import ( + get_mt5_client, + get_response_format, + run_in_threadpool, +) +from mt5api.formatters import format_response +from mt5api.models import ( + CalcMarginRequest, + CalcProfitRequest, + DataResponse, + ResponseFormat, +) + +if TYPE_CHECKING: + from fastapi.responses import Response + +router = APIRouter( + tags=["calc"], + dependencies=[Depends(verify_api_key)], +) + + +@router.get( + "/calc/margin", + response_model=DataResponse, + summary="Calculate margin", + description=( + "Calculate the margin required for a trading operation in the account currency" + ), +) +async def get_calc_margin( + mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], + response_format: Annotated[ResponseFormat, Depends(get_response_format)], + request: Annotated[CalcMarginRequest, Depends()], +) -> DataResponse | Response: + """Calculate margin for a trading operation. + + Returns: + JSON or Parquet response with calculated margin. + """ + margin = await run_in_threadpool( + mt5_client.order_calc_margin, + action=request.action, + symbol=request.symbol, + volume=request.volume, + price=request.price, + ) + return format_response({"margin": margin}, response_format) + + +@router.get( + "/calc/profit", + response_model=DataResponse, + summary="Calculate profit", + description=( + "Calculate the profit for a trading operation in the account currency" + ), +) +async def get_calc_profit( + mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], + response_format: Annotated[ResponseFormat, Depends(get_response_format)], + request: Annotated[CalcProfitRequest, Depends()], +) -> DataResponse | Response: + """Calculate profit for a trading operation. + + Returns: + JSON or Parquet response with calculated profit. + """ + profit = await run_in_threadpool( + mt5_client.order_calc_profit, + action=request.action, + symbol=request.symbol, + volume=request.volume, + price_open=request.price_open, + price_close=request.price_close, + ) + return format_response({"profit": profit}, response_format) diff --git a/mt5api/routers/health.py b/mt5api/routers/health.py index 7100c36..d8c512b 100644 --- a/mt5api/routers/health.py +++ b/mt5api/routers/health.py @@ -84,3 +84,27 @@ async def get_version( """ version_dict = await run_in_threadpool(mt5_client.version_as_dict) return format_response(version_dict, response_format) + + +@router.get( + "/last-error", + response_model=DataResponse, + summary="Get last MT5 error", + description="Get the last error information from the MetaTrader 5 terminal", + dependencies=[Depends(verify_api_key)], +) +async def get_last_error( + mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], + response_format: Annotated[ResponseFormat, Depends(get_response_format)], +) -> DataResponse | Response: + """Get the last MT5 error information. + + Args: + mt5_client: MT5 data client dependency. + response_format: Negotiated response format (JSON or Parquet). + + Returns: + JSON or Parquet response with last error data. + """ + error_dict = await run_in_threadpool(mt5_client.last_error_as_dict) + return format_response(error_dict, response_format) diff --git a/mt5api/routers/history.py b/mt5api/routers/history.py index 5304432..101c199 100644 --- a/mt5api/routers/history.py +++ b/mt5api/routers/history.py @@ -18,6 +18,7 @@ DataResponse, HistoryDealsRequest, HistoryOrdersRequest, + HistoryTotalRequest, OrdersRequest, PositionsRequest, ResponseFormat, @@ -136,3 +137,89 @@ async def get_orders( ticket=request.ticket, ) return format_response(dataframe, response_format) + + +@router.get( + "/orders/total", + response_model=DataResponse, + summary="Get active orders count", + description="Get the total number of active orders", +) +async def get_orders_total( + mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], + response_format: Annotated[ResponseFormat, Depends(get_response_format)], +) -> DataResponse | Response: + """Get the total number of active orders. + + Returns: + JSON or Parquet response with total count. + """ + total = await run_in_threadpool(mt5_client.orders_total) + return format_response({"total": total}, response_format) + + +@router.get( + "/positions/total", + response_model=DataResponse, + summary="Get open positions count", + description="Get the total number of open positions", +) +async def get_positions_total( + mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], + response_format: Annotated[ResponseFormat, Depends(get_response_format)], +) -> DataResponse | Response: + """Get the total number of open positions. + + Returns: + JSON or Parquet response with total count. + """ + total = await run_in_threadpool(mt5_client.positions_total) + return format_response({"total": total}, response_format) + + +@router.get( + "/history/orders/total", + response_model=DataResponse, + summary="Get historical orders count", + description="Get the total number of historical orders in a date range", +) +async def get_history_orders_total( + mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], + response_format: Annotated[ResponseFormat, Depends(get_response_format)], + request: Annotated[HistoryTotalRequest, Depends()], +) -> DataResponse | Response: + """Get the total number of historical orders. + + Returns: + JSON or Parquet response with total count. + """ + total = await run_in_threadpool( + mt5_client.history_orders_total, + date_from=request.date_from, + date_to=request.date_to, + ) + return format_response({"total": total}, response_format) + + +@router.get( + "/history/deals/total", + response_model=DataResponse, + summary="Get historical deals count", + description="Get the total number of historical deals in a date range", +) +async def get_history_deals_total( + mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], + response_format: Annotated[ResponseFormat, Depends(get_response_format)], + request: Annotated[HistoryTotalRequest, Depends()], +) -> DataResponse | Response: + """Get the total number of historical deals. + + Returns: + JSON or Parquet response with total count. + """ + total = await run_in_threadpool( + mt5_client.history_deals_total, + date_from=request.date_from, + date_to=request.date_to, + ) + return format_response({"total": total}, response_format) diff --git a/mt5api/routers/symbols.py b/mt5api/routers/symbols.py index 6895477..b3d59b0 100644 --- a/mt5api/routers/symbols.py +++ b/mt5api/routers/symbols.py @@ -54,6 +54,25 @@ async def get_symbols( return format_response(dataframe, response_format) +@router.get( + "/symbols/total", + response_model=DataResponse, + summary="Get total symbol count", + description="Get the total number of available trading symbols", +) +async def get_symbols_total( + mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], + response_format: Annotated[ResponseFormat, Depends(get_response_format)], +) -> DataResponse | Response: + """Get total number of symbols. + + Returns: + JSON or Parquet response with total symbol count. + """ + total = await run_in_threadpool(mt5_client.symbols_total) + return format_response({"total": total}, response_format) + + @router.get( "/symbols/{symbol}", response_model=DataResponse, diff --git a/mt5api/routers/trading.py b/mt5api/routers/trading.py new file mode 100644 index 0000000..9750c90 --- /dev/null +++ b/mt5api/routers/trading.py @@ -0,0 +1,158 @@ +"""Trading operation endpoints (order check, order send, symbol select).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated, Any + +from fastapi import APIRouter, Depends +from pdmt5.dataframe import Mt5DataClient # noqa: TC002 + +from mt5api.auth import verify_api_key +from mt5api.dependencies import ( + get_mt5_client, + get_response_format, + run_in_threadpool, +) +from mt5api.formatters import format_response +from mt5api.models import ( + DataResponse, + OrderCheckRequest, + OrderSendRequest, + ResponseFormat, + SymbolSelectRequest, +) + +if TYPE_CHECKING: + from fastapi.responses import Response + +router = APIRouter( + tags=["trading"], + dependencies=[Depends(verify_api_key)], +) + + +@router.post( + "/order/check", + response_model=DataResponse, + summary="Check order", + description="Check funds sufficiency for performing a trading operation", +) +async def post_order_check( + mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], + response_format: Annotated[ResponseFormat, Depends(get_response_format)], + request: OrderCheckRequest, +) -> DataResponse | Response: + """Check funds sufficiency for a trading operation. + + Returns: + JSON or Parquet response with order check result. + """ + result: dict[str, Any] = await run_in_threadpool( + mt5_client.order_check_as_dict, + request=request.request, + ) + return format_response(result, response_format) + + +@router.post( + "/order/send", + response_model=DataResponse, + summary="Send order", + description="Send a trading operation request to the trade server", +) +async def post_order_send( + mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], + response_format: Annotated[ResponseFormat, Depends(get_response_format)], + request: OrderSendRequest, +) -> DataResponse | Response: + """Send a trade request to the trade server. + + Returns: + JSON or Parquet response with order send result. + """ + result: dict[str, Any] = await run_in_threadpool( + mt5_client.order_send_as_dict, + request=request.request, + ) + return format_response(result, response_format) + + +@router.post( + "/symbols/{symbol}/select", + response_model=DataResponse, + summary="Select symbol", + description=( + "Select a symbol in the MarketWatch window or remove it from the window" + ), +) +async def post_symbol_select( + mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], + response_format: Annotated[ResponseFormat, Depends(get_response_format)], + request: Annotated[SymbolSelectRequest, Depends()], +) -> DataResponse | Response: + """Select or deselect a symbol in the MarketWatch window. + + Returns: + JSON or Parquet response with selection result. + """ + success = await run_in_threadpool( + mt5_client.symbol_select, + symbol=request.symbol, + enable=request.enable, + ) + return format_response( + {"symbol": request.symbol, "enable": request.enable, "success": success}, + response_format, + ) + + +@router.post( + "/market-book/{symbol}/subscribe", + response_model=DataResponse, + summary="Subscribe to market depth", + description="Subscribe to Market Depth change events for a symbol", +) +async def post_market_book_subscribe( + mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], + response_format: Annotated[ResponseFormat, Depends(get_response_format)], + symbol: str, +) -> DataResponse | Response: + """Subscribe to market depth for a symbol. + + Returns: + JSON or Parquet response with subscription result. + """ + success = await run_in_threadpool( + mt5_client.market_book_add, + symbol=symbol, + ) + return format_response( + {"symbol": symbol, "subscribed": success}, + response_format, + ) + + +@router.post( + "/market-book/{symbol}/unsubscribe", + response_model=DataResponse, + summary="Unsubscribe from market depth", + description="Cancel Market Depth subscription for a symbol", +) +async def post_market_book_unsubscribe( + mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], + response_format: Annotated[ResponseFormat, Depends(get_response_format)], + symbol: str, +) -> DataResponse | Response: + """Unsubscribe from market depth for a symbol. + + Returns: + JSON or Parquet response with unsubscription result. + """ + success = await run_in_threadpool( + mt5_client.market_book_release, + symbol=symbol, + ) + return format_response( + {"symbol": symbol, "unsubscribed": success}, + response_format, + ) diff --git a/tests/conftest.py b/tests/conftest.py index 1747c48..afc5a6a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -186,6 +186,54 @@ def mock_mt5_client() -> Mock: client.history_orders_get_as_df.return_value = pd.DataFrame([]) client.history_deals_get_as_df.return_value = pd.DataFrame([]) + # Mock last error + client.last_error_as_dict.return_value = { + "error_code": 1, + "error_description": "Success", + } + + # Mock totals + client.symbols_total.return_value = 100 + client.orders_total.return_value = 3 + client.positions_total.return_value = 5 + client.history_orders_total.return_value = 42 + client.history_deals_total.return_value = 37 + + # Mock calculation methods + client.order_calc_margin.return_value = 108.50 + client.order_calc_profit.return_value = 50.0 + + # Mock trading write methods + client.order_check_as_dict.return_value = { + "retcode": 0, + "balance": 10000.0, + "equity": 10500.0, + "profit": 0.0, + "margin": 108.50, + "margin_free": 10391.50, + "margin_level": 9677.42, + "comment": "Done", + "request_action": 1, + "request_symbol": "EURUSD", + "request_volume": 0.1, + "request_type": 0, + } + client.order_send_as_dict.return_value = { + "retcode": 10009, + "deal": 123456789, + "order": 987654321, + "volume": 0.1, + "price": 1.08500, + "comment": "Request executed", + "request_action": 1, + "request_symbol": "EURUSD", + "request_volume": 0.1, + "request_type": 0, + } + client.symbol_select.return_value = True + client.market_book_add.return_value = True + client.market_book_release.return_value = True + return client diff --git a/tests/mt5_constants.py b/tests/mt5_constants.py index 1e294df..b500f42 100644 --- a/tests/mt5_constants.py +++ b/tests/mt5_constants.py @@ -49,7 +49,15 @@ class Mt5BookType(IntEnum): class Mt5OrderType(IntEnum): """Test copy of MetaTrader5 order type constants.""" + ORDER_TYPE_BUY = 0 + ORDER_TYPE_SELL = 1 ORDER_TYPE_BUY_LIMIT = 2 + ORDER_TYPE_SELL_LIMIT = 3 + ORDER_TYPE_BUY_STOP = 4 + ORDER_TYPE_SELL_STOP = 5 + ORDER_TYPE_BUY_STOP_LIMIT = 6 + ORDER_TYPE_SELL_STOP_LIMIT = 7 + ORDER_TYPE_CLOSE_BY = 8 MT5_CONSTANT_ENUMS: tuple[type[IntEnum], ...] = ( diff --git a/tests/test_calc.py b/tests/test_calc.py new file mode 100644 index 0000000..f53e7d4 --- /dev/null +++ b/tests/test_calc.py @@ -0,0 +1,155 @@ +"""Contract tests for trading calculation endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from tests.mt5_constants import Mt5OrderType + +if TYPE_CHECKING: + from unittest.mock import Mock + + from fastapi.testclient import TestClient + + +def test_get_calc_margin_returns_json( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /calc/margin returns calculated margin.""" + response = client.get( + "/calc/margin", + params={ + "action": "ORDER_TYPE_BUY", + "symbol": "EURUSD", + "volume": 0.1, + "price": 1.08500, + }, + headers=api_headers, + ) + + assert response.status_code == 200 + + payload = response.json() + assert payload["count"] == 1 + assert payload["data"]["margin"] == 108.50 + + mock_mt5_client.order_calc_margin.assert_called_with( + action=int(Mt5OrderType.ORDER_TYPE_BUY), + symbol="EURUSD", + volume=0.1, + price=1.085, + ) + + +def test_get_calc_margin_accepts_integer_action( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /calc/margin accepts integer action value.""" + response = client.get( + "/calc/margin", + params={ + "action": int(Mt5OrderType.ORDER_TYPE_SELL), + "symbol": "EURUSD", + "volume": 1.0, + "price": 1.08500, + }, + headers=api_headers, + ) + + assert response.status_code == 200 + + mock_mt5_client.order_calc_margin.assert_called_with( + action=int(Mt5OrderType.ORDER_TYPE_SELL), + symbol="EURUSD", + volume=1.0, + price=1.085, + ) + + +def test_get_calc_margin_returns_parquet( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /calc/margin supports Parquet output.""" + response = client.get( + "/calc/margin", + params={ + "action": "ORDER_TYPE_BUY", + "symbol": "EURUSD", + "volume": 0.1, + "price": 1.08500, + "format": "parquet", + }, + headers=api_headers, + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/parquet") + + mock_mt5_client.order_calc_margin.assert_called_with( + action=int(Mt5OrderType.ORDER_TYPE_BUY), + symbol="EURUSD", + volume=0.1, + price=1.085, + ) + + +def test_get_calc_profit_returns_json( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /calc/profit returns calculated profit.""" + response = client.get( + "/calc/profit", + params={ + "action": "ORDER_TYPE_BUY", + "symbol": "EURUSD", + "volume": 0.1, + "price_open": 1.08500, + "price_close": 1.09000, + }, + headers=api_headers, + ) + + assert response.status_code == 200 + + payload = response.json() + assert payload["count"] == 1 + assert payload["data"]["profit"] == 50.0 + + mock_mt5_client.order_calc_profit.assert_called_with( + action=int(Mt5OrderType.ORDER_TYPE_BUY), + symbol="EURUSD", + volume=0.1, + price_open=1.085, + price_close=1.09, + ) + + +def test_get_calc_profit_returns_parquet( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, # noqa: ARG001 +) -> None: + """GET /calc/profit supports Parquet output.""" + response = client.get( + "/calc/profit", + params={ + "action": "ORDER_TYPE_SELL", + "symbol": "EURUSD", + "volume": 1.0, + "price_open": 1.09000, + "price_close": 1.08500, + "format": "parquet", + }, + headers=api_headers, + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/parquet") diff --git a/tests/test_health.py b/tests/test_health.py index d8c204f..0c7b60e 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -9,6 +9,8 @@ from mt5api.constants import API_VERSION if TYPE_CHECKING: + from unittest.mock import Mock + from fastapi.testclient import TestClient @@ -74,6 +76,38 @@ def test_docs_and_openapi_available(client: TestClient) -> None: assert "paths" in openapi_response.json() +def test_last_error_returns_json( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /last-error returns last MT5 error info.""" + response = client.get("/last-error", headers=api_headers) + + assert response.status_code == 200 + + payload = response.json() + assert payload["count"] == 1 + assert payload["data"]["error_code"] == 1 + assert payload["data"]["error_description"] == "Success" + + mock_mt5_client.last_error_as_dict.assert_called_with() + + +def test_last_error_returns_parquet( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /last-error supports Parquet output.""" + response = client.get("/last-error?format=parquet", headers=api_headers) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/parquet") + + mock_mt5_client.last_error_as_dict.assert_called_with() + + @pytest.mark.asyncio async def test_get_health_handles_runtime_error( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_history.py b/tests/test_history.py index 40572a6..d472dc6 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -193,3 +193,105 @@ def test_get_orders_returns_parquet( group=None, ticket=None, ) + + +def test_get_orders_total_returns_json( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /orders/total returns active orders count.""" + response = client.get("/orders/total", headers=api_headers) + + assert response.status_code == 200 + + payload = response.json() + assert payload["count"] == 1 + assert payload["data"]["total"] == 3 + + mock_mt5_client.orders_total.assert_called_with() + + +def test_get_positions_total_returns_json( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /positions/total returns open positions count.""" + response = client.get("/positions/total", headers=api_headers) + + assert response.status_code == 200 + + payload = response.json() + assert payload["count"] == 1 + assert payload["data"]["total"] == 5 + + mock_mt5_client.positions_total.assert_called_with() + + +def test_get_history_orders_total_returns_json( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /history/orders/total returns historical orders count.""" + response = client.get( + "/history/orders/total", + params={ + "date_from": "2024-01-01T00:00:00Z", + "date_to": "2024-01-02T00:00:00Z", + }, + headers=api_headers, + ) + + assert response.status_code == 200 + + payload = response.json() + assert payload["count"] == 1 + assert payload["data"]["total"] == 42 + + mock_mt5_client.history_orders_total.assert_called_with( + date_from=ANY, + date_to=ANY, + ) + + +def test_get_history_deals_total_returns_json( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /history/deals/total returns historical deals count.""" + response = client.get( + "/history/deals/total", + params={ + "date_from": "2024-01-01T00:00:00Z", + "date_to": "2024-01-02T00:00:00Z", + }, + headers=api_headers, + ) + + assert response.status_code == 200 + + payload = response.json() + assert payload["count"] == 1 + assert payload["data"]["total"] == 37 + + mock_mt5_client.history_deals_total.assert_called_with( + date_from=ANY, + date_to=ANY, + ) + + +def test_get_history_orders_total_requires_date_range( + client: TestClient, + api_headers: dict[str, str], +) -> None: + """GET /history/orders/total requires both date_from and date_to.""" + response = client.get( + "/history/orders/total", + params={"date_from": "2024-01-01T00:00:00Z"}, + headers=api_headers, + ) + + assert response.status_code == 422 diff --git a/tests/test_models.py b/tests/test_models.py index 6622c77..fd1b564 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -8,13 +8,16 @@ from pydantic import ValidationError from mt5api.models import ( + CalcMarginRequest, HistoryOrdersRequest, + HistoryTotalRequest, RatesFromRequest, TicksFromRequest, get_mt5_copy_ticks_examples, + get_mt5_order_type_examples, get_mt5_timeframe_examples, ) -from tests.mt5_constants import Mt5CopyTicks, Mt5Timeframe +from tests.mt5_constants import Mt5CopyTicks, Mt5OrderType, Mt5Timeframe def test_history_request_requires_filters() -> None: @@ -111,3 +114,32 @@ def test_ticks_from_request_rejects_invalid_mt5_copy_ticks_value() -> None: "count": 10, "flags": 99, }) + + +def test_mt5_order_type_example_helpers_return_integer_examples() -> None: + """MT5 ORDER_TYPE helpers expose integer example values.""" + assert get_mt5_order_type_examples() == [ + int(Mt5OrderType.ORDER_TYPE_BUY), + int(Mt5OrderType.ORDER_TYPE_SELL), + ] + + +def test_calc_margin_request_accepts_order_type_name() -> None: + """Calc margin request accepts ORDER_TYPE constant names.""" + request = CalcMarginRequest.model_validate({ + "action": "ORDER_TYPE_BUY", + "symbol": "EURUSD", + "volume": 0.1, + "price": 1.085, + }) + + assert request.action == int(Mt5OrderType.ORDER_TYPE_BUY) + + +def test_history_total_request_rejects_invalid_date_range() -> None: + """History total request rejects date ranges where start >= end.""" + with pytest.raises(ValueError, match="date_from must be before date_to"): + HistoryTotalRequest( + date_from=datetime(2024, 1, 2, tzinfo=UTC), + date_to=datetime(2024, 1, 1, tzinfo=UTC), + ) diff --git a/tests/test_symbols.py b/tests/test_symbols.py index 48bdf73..160a3e4 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -127,3 +127,34 @@ def test_get_symbol_tick_accepts_parquet( assert response.headers["content-type"].startswith("application/parquet") mock_mt5_client.symbol_info_tick_as_df.assert_called_with(symbol="EURUSD") + + +def test_get_symbols_total_returns_json( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /symbols/total returns symbol count.""" + response = client.get("/symbols/total", headers=api_headers) + + assert response.status_code == 200 + + payload = response.json() + assert payload["count"] == 1 + assert payload["data"]["total"] == 100 + + mock_mt5_client.symbols_total.assert_called_with() + + +def test_get_symbols_total_returns_parquet( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /symbols/total supports Parquet output.""" + response = client.get("/symbols/total?format=parquet", headers=api_headers) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/parquet") + + mock_mt5_client.symbols_total.assert_called_with() diff --git a/tests/test_trading.py b/tests/test_trading.py new file mode 100644 index 0000000..4a17dc2 --- /dev/null +++ b/tests/test_trading.py @@ -0,0 +1,165 @@ +"""Contract tests for trading operation endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from unittest.mock import Mock + + from fastapi.testclient import TestClient + + +def test_post_order_check_returns_json( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """POST /order/check returns order check result.""" + request_body = { + "request": { + "action": 1, + "symbol": "EURUSD", + "volume": 0.1, + "type": 0, + "price": 1.08500, + }, + } + response = client.post( + "/order/check", + json=request_body, + headers=api_headers, + ) + + assert response.status_code == 200 + + payload = response.json() + assert payload["count"] == 1 + assert payload["data"]["retcode"] == 0 + assert payload["data"]["comment"] == "Done" + + mock_mt5_client.order_check_as_dict.assert_called_with( + request=request_body["request"], + ) + + +def test_post_order_send_returns_json( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """POST /order/send returns order send result.""" + request_body = { + "request": { + "action": 1, + "symbol": "EURUSD", + "volume": 0.1, + "type": 0, + "price": 1.08500, + }, + } + response = client.post( + "/order/send", + json=request_body, + headers=api_headers, + ) + + assert response.status_code == 200 + + payload = response.json() + assert payload["count"] == 1 + assert payload["data"]["retcode"] == 10009 + assert payload["data"]["deal"] == 123456789 + + mock_mt5_client.order_send_as_dict.assert_called_with( + request=request_body["request"], + ) + + +def test_post_symbol_select_returns_json( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """POST /symbols/{symbol}/select returns selection result.""" + response = client.post( + "/symbols/EURUSD/select", + headers=api_headers, + ) + + assert response.status_code == 200 + + payload = response.json() + assert payload["count"] == 1 + assert payload["data"]["symbol"] == "EURUSD" + assert payload["data"]["enable"] is True + assert payload["data"]["success"] is True + + mock_mt5_client.symbol_select.assert_called_with( + symbol="EURUSD", + enable=True, + ) + + +def test_post_symbol_select_with_disable( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """POST /symbols/{symbol}/select supports enable=false.""" + response = client.post( + "/symbols/EURUSD/select?enable=false", + headers=api_headers, + ) + + assert response.status_code == 200 + + payload = response.json() + assert payload["data"]["enable"] is False + + mock_mt5_client.symbol_select.assert_called_with( + symbol="EURUSD", + enable=False, + ) + + +def test_post_market_book_subscribe_returns_json( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """POST /market-book/{symbol}/subscribe returns result.""" + response = client.post( + "/market-book/EURUSD/subscribe", + headers=api_headers, + ) + + assert response.status_code == 200 + + payload = response.json() + assert payload["count"] == 1 + assert payload["data"]["symbol"] == "EURUSD" + assert payload["data"]["subscribed"] is True + + mock_mt5_client.market_book_add.assert_called_with(symbol="EURUSD") + + +def test_post_market_book_unsubscribe_returns_json( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """POST /market-book/{symbol}/unsubscribe returns result.""" + response = client.post( + "/market-book/EURUSD/unsubscribe", + headers=api_headers, + ) + + assert response.status_code == 200 + + payload = response.json() + assert payload["count"] == 1 + assert payload["data"]["symbol"] == "EURUSD" + assert payload["data"]["unsubscribed"] is True + + mock_mt5_client.market_book_release.assert_called_with(symbol="EURUSD") From c728814d71892366b87ef0c2a11629bae3805df3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 15:18:53 +0000 Subject: [PATCH 2/5] docs: update documentation for new endpoints Update README, docs/index.md, docs/api/index.md, and docs/api/rest-api.md to cover all new endpoints added in the previous commit: read-only endpoints (last-error, symbols/total, orders/total, positions/total, history totals, calc/margin, calc/profit) and write endpoints (order/check, order/send, symbol select, market-book subscribe/unsubscribe). Also revise the "read-only" framing to reflect that the API now includes trading write operations. https://claude.ai/code/session_01SJQJzQNfkFizmofhvFEByB --- README.md | 55 +++++++++++------ docs/api/index.md | 24 ++++++-- docs/api/rest-api.md | 142 ++++++++++++++++++++++++++++++++++++++----- docs/index.md | 15 +++-- 4 files changed, 190 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 5d6d813..600aa65 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ MetaTrader 5 REST API [![CI/CD](https://github.com/dceoy/mt5api/actions/workflows/ci.yml/badge.svg)](https://github.com/dceoy/mt5api/actions/workflows/ci.yml) -mt5api exposes read-only MT5 market data, account info, and trading history -over HTTP. It uses the [`pdmt5`](https://github.com/dceoy/pdmt5) client internally and adds optional API-key -auth, rate limiting, and JSON/Parquet response formatting. +mt5api exposes MT5 market data, account info, trading history, and trading +operations over HTTP. It uses the [`pdmt5`](https://github.com/dceoy/pdmt5) +client internally and adds optional API-key auth, rate limiting, and +JSON/Parquet response formatting. The API server must run on Windows. The `MetaTrader5` Python package used by `pdmt5` is supported only on Windows, so you must host `mt5api` on a Windows @@ -15,7 +16,8 @@ any operating system. ## Features -- Read-only REST endpoints for symbols, market data, account info, orders, and history +- REST endpoints for symbols, market data, account info, orders, history, + calculations, and trading operations - JSON and Apache Parquet responses (content negotiation) - Optional API key authentication with per-minute rate limiting - Structured JSON logging and configurable CORS @@ -51,10 +53,10 @@ Docs: - Swagger UI: `http://localhost:8000/docs` - OpenAPI JSON: `http://localhost:8000/openapi.json` -Set `MT5API_ROUTER_PREFIX` to mount the read-only API endpoints under a shared -path such as `/api/v1`. The default is `""`, which keeps routes like `/health` -and `/symbols` at the root. `"/api/v1"`, `"api/v1"`, and `"/api/v1/"` are -treated the same. +Set `MT5API_ROUTER_PREFIX` to mount the API endpoints under a shared path such +as `/api/v1`. The default is `""`, which keeps routes like `/health` and +`/symbols` at the root. `"/api/v1"`, `"api/v1"`, and `"/api/v1/"` are treated +the same. ## Example Requests with curl @@ -76,20 +78,35 @@ curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/symbols?group curl -H "X-API-Key: your-secret-api-key" -H "Accept: application/parquet" "http://windows-host:8000/rates/from?symbol=EURUSD&timeframe=TIMEFRAME_M1&date_from=2024-01-01T00:00:00Z&count=100" ``` -Market-data endpoints accept MetaTrader 5 constants either by official name -(`TIMEFRAME_M1`, `COPY_TICKS_ALL`) or by their integer value. +Market-data and calculation endpoints accept MetaTrader 5 constants either by +official name (`TIMEFRAME_M1`, `COPY_TICKS_ALL`, `ORDER_TYPE_BUY`) or by their +integer value. -## Endpoints (Read-Only) +## Endpoints -- Health: `/health`, `/version` -- Symbols: `/symbols`, `/symbols/{symbol}`, `/symbols/{symbol}/tick` -- Market data: `/rates/from`, `/rates/from-pos`, `/rates/range`, - `/ticks/from`, `/ticks/range`, `/market-book/{symbol}` -- Account: `/account`, `/terminal` -- Trading state: `/positions`, `/orders` -- History: `/history/orders`, `/history/deals` +If `MT5API_ROUTER_PREFIX` is set, prepend that value to every API route below. -If `MT5API_ROUTER_PREFIX` is set, prepend that value to every API route above. +### Read-Only Endpoints + +- Health: `GET /health`, `GET /version`, `GET /last-error` +- Symbols: `GET /symbols`, `GET /symbols/total`, `GET /symbols/{symbol}`, + `GET /symbols/{symbol}/tick` +- Market data: `GET /rates/from`, `GET /rates/from-pos`, `GET /rates/range`, + `GET /ticks/from`, `GET /ticks/range`, `GET /market-book/{symbol}` +- Calculations: `GET /calc/margin`, `GET /calc/profit` +- Account: `GET /account`, `GET /terminal` +- Trading state: `GET /positions`, `GET /positions/total`, + `GET /orders`, `GET /orders/total` +- History: `GET /history/orders`, `GET /history/orders/total`, + `GET /history/deals`, `GET /history/deals/total` + +### Write Endpoints + +- `POST /symbols/{symbol}/select` — Show or hide symbol in MarketWatch +- `POST /market-book/{symbol}/subscribe` — Subscribe to DOM events +- `POST /market-book/{symbol}/unsubscribe` — Unsubscribe from DOM events +- `POST /order/check` — Validate funds for a trade (no execution) +- `POST /order/send` — Execute a trade request ## License diff --git a/docs/api/index.md b/docs/api/index.md index 929747a..0c0609c 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -10,8 +10,9 @@ logged in. Clients can call the HTTP API from any operating system. ### [REST API](rest-api.md) -FastAPI-based REST API that exposes read-only MT5 data over HTTP with JSON and -Parquet support. +FastAPI-based REST API that exposes MT5 market data, account info, trading +history, calculations, and trading operations over HTTP with JSON and Parquet +support. ### [Deployment](deployment.md) @@ -21,10 +22,21 @@ Windows service deployment guide for hosting the REST API alongside MetaTrader 5 mt5api provides a FastAPI layer on top of the MetaTrader 5 terminal runtime: -1. **API Layer** (`mt5api.main`, `mt5api.routers`): FastAPI app, routers, and response formatting -2. **Dependency Layer** (`mt5api.dependencies`): MT5 client lifecycle and format negotiation -3. **Model Layer** (`mt5api.models`): Response schemas and MT5 constant metadata helpers -4. **Formatter Layer** (`mt5api.formatters`): JSON and Parquet serialization helpers +1. **API Layer** (`mt5api.main`, `mt5api.routers`): FastAPI app, routers, and + response formatting. Routers are grouped by domain: + - `health.py`: health check, version, last error + - `symbols.py`: symbol listing, details, tick, total, and selection + - `market.py`: OHLCV rates, tick data, and market depth + - `calc.py`: margin and profit calculations + - `account.py`: account and terminal info + - `history.py`: positions, orders, history with totals + - `trading.py`: order check, order send, market book subscriptions +2. **Dependency Layer** (`mt5api.dependencies`): MT5 client lifecycle and + format negotiation +3. **Model Layer** (`mt5api.models`): Response schemas and MT5 constant + metadata helpers (TIMEFRAME, COPY_TICKS, ORDER_TYPE) +4. **Formatter Layer** (`mt5api.formatters`): JSON and Parquet serialization + helpers ## Usage Guidelines diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index d8677ef..10ee991 100644 --- a/docs/api/rest-api.md +++ b/docs/api/rest-api.md @@ -1,7 +1,7 @@ # REST API -The mt5api REST API exposes read-only MetaTrader 5 data via FastAPI. It supports -JSON and Apache Parquet responses for analytics workflows. +The mt5api REST API exposes MetaTrader 5 data and trading operations via +FastAPI. It supports JSON and Apache Parquet responses for analytics workflows. The API server must run on Windows because the `MetaTrader5` Python package is supported only on Windows. Host `mt5api` on a Windows machine with MetaTrader 5 @@ -88,20 +88,22 @@ curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/symbols?forma ## Endpoints -All endpoints are read-only. - If `MT5API_ROUTER_PREFIX` is configured, prepend it to each API route below. ### Health -- `GET /health` (no auth) -- `GET /version` +- `GET /health` (no auth) — API and MT5 connection status +- `GET /version` — MT5 terminal version +- `GET /last-error` — Last MT5 error code and description ### Symbols -- `GET /symbols` (`group`, `format`) -- `GET /symbols/{symbol}` (`format`) -- `GET /symbols/{symbol}/tick` (`format`) +- `GET /symbols` (`group`, `format`) — List available symbols +- `GET /symbols/total` (`format`) — Total number of symbols +- `GET /symbols/{symbol}` (`format`) — Symbol details +- `GET /symbols/{symbol}/tick` (`format`) — Latest tick for a symbol +- `POST /symbols/{symbol}/select` (`enable`) — Show or hide a symbol in + MarketWatch ### Market Data @@ -113,19 +115,51 @@ If `MT5API_ROUTER_PREFIX` is configured, prepend it to each API route below. - `GET /rates/range` (`symbol`, `timeframe`, `date_from`, `date_to`, `format`) - `GET /ticks/from` (`symbol`, `date_from`, `count`, `flags`, `format`) - `GET /ticks/range` (`symbol`, `date_from`, `date_to`, `flags`, `format`) -- `GET /market-book/{symbol}` (`format`) +- `GET /market-book/{symbol}` (`format`) — Market depth (DOM) +- `POST /market-book/{symbol}/subscribe` — Subscribe to DOM events +- `POST /market-book/{symbol}/unsubscribe` — Unsubscribe from DOM events + +### Calculations + +- `action` accepts either the official MetaTrader 5 constant name (for example + `ORDER_TYPE_BUY`) or the equivalent integer value. +- `GET /calc/margin` (`action`, `symbol`, `volume`, `price`, `format`) — + Calculate required margin in account currency +- `GET /calc/profit` (`action`, `symbol`, `volume`, `price_open`, + `price_close`, `format`) — Calculate expected profit in account currency + +### Account & Terminal -### Account & Trading State +- `GET /account` (`format`) — Account balance, equity, margin +- `GET /terminal` (`format`) — Terminal status and settings -- `GET /account` (`format`) -- `GET /terminal` (`format`) -- `GET /positions` (`symbol`, `group`, `ticket`, `format`) -- `GET /orders` (`symbol`, `group`, `ticket`, `format`) +### Positions & Orders + +- `GET /positions` (`symbol`, `group`, `ticket`, `format`) — Open positions +- `GET /positions/total` (`format`) — Count of open positions +- `GET /orders` (`symbol`, `group`, `ticket`, `format`) — Active pending orders +- `GET /orders/total` (`format`) — Count of active orders ### History - `GET /history/orders` (`date_from`, `date_to`, `ticket`, `position`, `group`, `symbol`, `format`) +- `GET /history/orders/total` (`date_from`, `date_to`, `format`) — Count of + historical orders in date range - `GET /history/deals` (`date_from`, `date_to`, `ticket`, `position`, `group`, `symbol`, `format`) +- `GET /history/deals/total` (`date_from`, `date_to`, `format`) — Count of + historical deals in date range + +### Trading Operations + +These endpoints send requests to the MetaTrader 5 trade server and modify +state. Use with care. + +- `POST /order/check` (body: `{"request": {...}}`) — Validate funds + sufficiency for a trade without executing it +- `POST /order/send` (body: `{"request": {...}}`) — Execute a trade request + +The `request` body follows the [MetaTrader 5 trade request +structure](https://www.mql5.com/en/docs/trading/ordersend). ## Response Formatter Utilities @@ -154,6 +188,12 @@ curl "http://windows-host:8000/health" curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/version" ``` +### Last Error + +```console +curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/last-error" +``` + ### Symbols ```console @@ -164,30 +204,100 @@ curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/symbols" curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/symbols?group=*USD*" ``` +```console +curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/symbols/total" +``` + ### Symbol Details ```console curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/symbols/EURUSD" ``` +### Select Symbol in MarketWatch + +```console +curl -X POST -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/symbols/EURUSD/select?enable=true" +``` + ### Rates (OHLCV) ```console curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/rates/from?symbol=EURUSD&timeframe=TIMEFRAME_M1&date_from=2024-01-01T00:00:00Z&count=100" ``` +### Market Depth + +```console +curl -X POST -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/market-book/EURUSD/subscribe" +``` + +```console +curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/market-book/EURUSD" +``` + +```console +curl -X POST -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/market-book/EURUSD/unsubscribe" +``` + +### Calculate Margin + +```console +curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/calc/margin?action=ORDER_TYPE_BUY&symbol=EURUSD&volume=0.1&price=1.08500" +``` + +### Calculate Profit + +```console +curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/calc/profit?action=ORDER_TYPE_BUY&symbol=EURUSD&volume=0.1&price_open=1.08500&price_close=1.09000" +``` + ### Account Info ```console curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/account" ``` +### Positions & Orders + +```console +curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/positions" +``` + +```console +curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/positions/total" +``` + +```console +curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/orders/total" +``` + ### History Orders ```console curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/history/orders?date_from=2024-01-01T00:00:00Z&date_to=2024-01-02T00:00:00Z" ``` +```console +curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/history/orders/total?date_from=2024-01-01T00:00:00Z&date_to=2024-01-02T00:00:00Z" +``` + +### Check Order Funds + +```console +curl -X POST -H "X-API-Key: your-secret-api-key" -H "Content-Type: application/json" \ + -d '{"request": {"action": 1, "symbol": "EURUSD", "volume": 0.1, "type": 0, "price": 1.085}}' \ + "http://windows-host:8000/order/check" +``` + +### Send Order + +```console +curl -X POST -H "X-API-Key: your-secret-api-key" -H "Content-Type: application/json" \ + -d '{"request": {"action": 1, "symbol": "EURUSD", "volume": 0.1, "type": 0, "price": 1.085}}' \ + "http://windows-host:8000/order/send" +``` + ## Error Responses Errors follow RFC 7807 Problem Details: @@ -210,3 +320,5 @@ Minimum security posture for deployments: - Rate limiting enabled (`MT5API_RATE_LIMIT`) - Run behind HTTPS in production - Restrict CORS origins (`MT5API_CORS_ORIGINS`) for public deployments +- Restrict access to trading endpoints (`/order/send`, `/order/check`) to + trusted clients only diff --git a/docs/index.md b/docs/index.md index 370a2aa..4c1530e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,12 +1,14 @@ # mt5api Documentation -FastAPI-based REST API for MetaTrader 5 market data and account information. +FastAPI-based REST API for MetaTrader 5 market data, account information, and +trading operations. ## Overview -mt5api exposes read-only MT5 data over HTTP using FastAPI. It relies on the -underlying MT5 client library for connectivity and adds optional authentication, rate -limiting, and response formatting suitable for analytics workflows. +mt5api exposes MT5 data and trading operations over HTTP using FastAPI. It +relies on the underlying MT5 client library for connectivity and adds optional +authentication, rate limiting, and response formatting suitable for analytics +workflows. The API server must run on Windows because the `MetaTrader5` Python package is Windows-only. Run `mt5api` on a Windows host with MetaTrader 5 installed and @@ -14,7 +16,8 @@ logged in. API clients can connect from any operating system. ## Features -- Read-only REST endpoints for symbols, market data, account info, orders, and history +- REST endpoints for symbols, market data, account info, orders, history, + calculations, and trading operations - JSON and Apache Parquet responses - Optional API key authentication and rate limiting - Structured JSON logging and configurable CORS @@ -61,7 +64,7 @@ curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/symbols?group ## API Reference -- [REST API](api/rest-api.md) - Endpoint overview, auth, and formats +- [REST API](api/rest-api.md) - Endpoint overview, auth, formats, and examples - [Deployment](api/deployment.md) - Windows service setup ## License From 5e056f48f38f1aafd36141c6f7b124aea6dc6802 Mon Sep 17 00:00:00 2001 From: dceoy <1938249+dceoy@users.noreply.github.com> Date: Tue, 17 Mar 2026 01:00:01 +0900 Subject: [PATCH 3/5] Restore read-only trading API boundaries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 5 +- docs/api/rest-api.md | 23 ++--- mt5api/main.py | 37 +++++++- mt5api/models.py | 54 +++++++++-- mt5api/routers/trading.py | 58 ++++++------ tests/conftest.py | 29 +++--- tests/test_main.py | 40 ++++++++ tests/test_trading.py | 189 +++++++++++++++++++++++++++++++++++--- 8 files changed, 345 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 600aa65..f05df06 100644 --- a/README.md +++ b/README.md @@ -100,13 +100,12 @@ If `MT5API_ROUTER_PREFIX` is set, prepend that value to every API route below. - History: `GET /history/orders`, `GET /history/orders/total`, `GET /history/deals`, `GET /history/deals/total` -### Write Endpoints +### Operational Endpoints - `POST /symbols/{symbol}/select` — Show or hide symbol in MarketWatch - `POST /market-book/{symbol}/subscribe` — Subscribe to DOM events - `POST /market-book/{symbol}/unsubscribe` — Unsubscribe from DOM events -- `POST /order/check` — Validate funds for a trade (no execution) -- `POST /order/send` — Execute a trade request +- `POST /order/check` — Validate a trade request without execution ## License diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index 10ee991..afb1876 100644 --- a/docs/api/rest-api.md +++ b/docs/api/rest-api.md @@ -149,17 +149,17 @@ If `MT5API_ROUTER_PREFIX` is configured, prepend it to each API route below. - `GET /history/deals/total` (`date_from`, `date_to`, `format`) — Count of historical deals in date range -### Trading Operations +### Trade Validation -These endpoints send requests to the MetaTrader 5 trade server and modify -state. Use with care. +This endpoint validates an MT5 trade request without executing it. - `POST /order/check` (body: `{"request": {...}}`) — Validate funds sufficiency for a trade without executing it -- `POST /order/send` (body: `{"request": {...}}`) — Execute a trade request The `request` body follows the [MetaTrader 5 trade request -structure](https://www.mql5.com/en/docs/trading/ordersend). +structure](https://www.mql5.com/en/docs/constants/structures/mqltraderequest), +with typed validation for core fields such as `action`, `symbol`, `volume`, +`type`, and `price`. ## Response Formatter Utilities @@ -290,14 +290,6 @@ curl -X POST -H "X-API-Key: your-secret-api-key" -H "Content-Type: application/j "http://windows-host:8000/order/check" ``` -### Send Order - -```console -curl -X POST -H "X-API-Key: your-secret-api-key" -H "Content-Type: application/json" \ - -d '{"request": {"action": 1, "symbol": "EURUSD", "volume": 0.1, "type": 0, "price": 1.085}}' \ - "http://windows-host:8000/order/send" -``` - ## Error Responses Errors follow RFC 7807 Problem Details: @@ -320,5 +312,6 @@ Minimum security posture for deployments: - Rate limiting enabled (`MT5API_RATE_LIMIT`) - Run behind HTTPS in production - Restrict CORS origins (`MT5API_CORS_ORIGINS`) for public deployments -- Restrict access to trading endpoints (`/order/send`, `/order/check`) to - trusted clients only +- Restrict access to operational endpoints (`/order/check`, + `/symbols/{symbol}/select`, `/market-book/{symbol}/subscribe`, + `/market-book/{symbol}/unsubscribe`) to trusted clients only diff --git a/mt5api/main.py b/mt5api/main.py index 8c12bdb..d8e2409 100644 --- a/mt5api/main.py +++ b/mt5api/main.py @@ -28,7 +28,7 @@ API_VERSION, DEFAULT_API_CORS_ORIGINS, ) -from .dependencies import shutdown_mt5_client +from .dependencies import run_in_threadpool, shutdown_mt5_client from .middleware import add_middleware from .routers import account, calc, health, history, market, symbols, trading @@ -132,10 +132,36 @@ def _custom_openapi() -> dict[str, Any]: _configure_logging() logger = logging.getLogger(__name__) +_ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY = "active_market_book_subscriptions" +_MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY = "market_book_cleanup_client" + + +async def _release_market_book_subscriptions(app: FastAPI) -> None: + """Release active market-book subscriptions before shutting down MT5.""" + subscriptions = getattr( + app.state, + _ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY, + None, + ) + if not isinstance(subscriptions, set) or not subscriptions: + return + + mt5_client = getattr(app.state, _MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, None) + if mt5_client is None: + logger.warning("Active market-book subscriptions found without cleanup client") + subscriptions.clear() + setattr(app.state, _MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, None) + return + + for symbol in tuple(sorted(subscriptions)): + await run_in_threadpool(mt5_client.market_book_release, symbol=symbol) + + subscriptions.clear() + setattr(app.state, _MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, None) @asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # noqa: ARG001 +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Manage application lifespan (startup and shutdown). Args: @@ -146,6 +172,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # noqa: ARG001 """ # Startup logger.info("Starting MT5 REST API...") + app.state.active_market_book_subscriptions = set() + app.state.market_book_cleanup_client = None # Note: MT5 client is initialized lazily on first request via dependency # This avoids blocking startup if MT5 is not available @@ -155,7 +183,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # noqa: ARG001 # Shutdown logger.info("Shutting down MT5 REST API...") - shutdown_mt5_client() + try: + await _release_market_book_subscriptions(app) + finally: + shutdown_mt5_client() logger.info("MT5 connection closed") diff --git a/mt5api/models.py b/mt5api/models.py index 30453da..10d5ed2 100644 --- a/mt5api/models.py +++ b/mt5api/models.py @@ -9,7 +9,14 @@ from typing import TYPE_CHECKING, Annotated, Any, LiteralString, Self, TypeAlias, cast from pdmt5.mt5 import Mt5Client -from pydantic import BaseModel, BeforeValidator, Field, WithJsonSchema, model_validator +from pydantic import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + WithJsonSchema, + model_validator, +) from pydantic_core import PydanticCustomError if TYPE_CHECKING: @@ -727,21 +734,48 @@ class CalcProfitRequest(BaseModel): ) -class OrderCheckRequest(BaseModel): - """Request parameters for order check endpoint.""" +class TradeRequest(BaseModel): + """Typed MT5 trade request payload used for order validation.""" + + model_config = ConfigDict(extra="allow") - request: dict[str, Any] = Field( + action: int = Field( ..., - description="Trade request dictionary for order validation", + description="MetaTrader5 TRADE_ACTION constant value", + ge=0, + examples=[1], + ) + symbol: str = Field( + ..., + description="Symbol name", + min_length=1, + max_length=32, + examples=["EURUSD"], + ) + volume: float = Field( + ..., + description="Trade volume in lots", + gt=0, + examples=[0.1, 1.0], + ) + type: Mt5OrderType = Field( + ..., + description=_ORDER_TYPE_DESCRIPTION, + ) + price: float = Field( + ..., + description="Requested price", + ge=0, + examples=[1.08500], ) -class OrderSendRequest(BaseModel): - """Request parameters for order send endpoint.""" +class OrderCheckRequest(BaseModel): + """Request parameters for order check endpoint.""" - request: dict[str, Any] = Field( + request: TradeRequest = Field( ..., - description="Trade request dictionary for order execution", + description="Trade request dictionary for order validation", ) @@ -751,6 +785,8 @@ class SymbolSelectRequest(BaseModel): symbol: str = Field( ..., description="Symbol name", + min_length=1, + max_length=32, examples=["EURUSD"], ) enable: bool = Field( diff --git a/mt5api/routers/trading.py b/mt5api/routers/trading.py index 9750c90..5452e01 100644 --- a/mt5api/routers/trading.py +++ b/mt5api/routers/trading.py @@ -1,10 +1,10 @@ -"""Trading operation endpoints (order check, order send, symbol select).""" +"""Operational endpoints for order checks and terminal subscriptions.""" from __future__ import annotations -from typing import TYPE_CHECKING, Annotated, Any +from typing import TYPE_CHECKING, Annotated, Any, cast -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Path, Request from pdmt5.dataframe import Mt5DataClient # noqa: TC002 from mt5api.auth import verify_api_key @@ -17,7 +17,6 @@ from mt5api.models import ( DataResponse, OrderCheckRequest, - OrderSendRequest, ResponseFormat, SymbolSelectRequest, ) @@ -30,6 +29,17 @@ dependencies=[Depends(verify_api_key)], ) +_ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY = "active_market_book_subscriptions" +_MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY = "market_book_cleanup_client" + + +def _get_active_market_book_subscriptions(request: Request) -> set[str]: + """Return the active market-book subscription set for the application.""" + return cast( + "set[str]", + getattr(request.app.state, _ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY), + ) + @router.post( "/order/check", @@ -49,30 +59,7 @@ async def post_order_check( """ result: dict[str, Any] = await run_in_threadpool( mt5_client.order_check_as_dict, - request=request.request, - ) - return format_response(result, response_format) - - -@router.post( - "/order/send", - response_model=DataResponse, - summary="Send order", - description="Send a trading operation request to the trade server", -) -async def post_order_send( - mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], - response_format: Annotated[ResponseFormat, Depends(get_response_format)], - request: OrderSendRequest, -) -> DataResponse | Response: - """Send a trade request to the trade server. - - Returns: - JSON or Parquet response with order send result. - """ - result: dict[str, Any] = await run_in_threadpool( - mt5_client.order_send_as_dict, - request=request.request, + request=request.request.model_dump(mode="python", exclude_none=True), ) return format_response(result, response_format) @@ -113,9 +100,10 @@ async def post_symbol_select( description="Subscribe to Market Depth change events for a symbol", ) async def post_market_book_subscribe( + request: Request, mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], response_format: Annotated[ResponseFormat, Depends(get_response_format)], - symbol: str, + symbol: Annotated[str, Path(min_length=1, max_length=32)], ) -> DataResponse | Response: """Subscribe to market depth for a symbol. @@ -126,6 +114,10 @@ async def post_market_book_subscribe( mt5_client.market_book_add, symbol=symbol, ) + if success: + subscriptions = _get_active_market_book_subscriptions(request) + subscriptions.add(symbol) + setattr(request.app.state, _MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, mt5_client) return format_response( {"symbol": symbol, "subscribed": success}, response_format, @@ -139,9 +131,10 @@ async def post_market_book_subscribe( description="Cancel Market Depth subscription for a symbol", ) async def post_market_book_unsubscribe( + request: Request, mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], response_format: Annotated[ResponseFormat, Depends(get_response_format)], - symbol: str, + symbol: Annotated[str, Path(min_length=1, max_length=32)], ) -> DataResponse | Response: """Unsubscribe from market depth for a symbol. @@ -152,6 +145,11 @@ async def post_market_book_unsubscribe( mt5_client.market_book_release, symbol=symbol, ) + if success: + subscriptions = _get_active_market_book_subscriptions(request) + subscriptions.discard(symbol) + if not subscriptions: + setattr(request.app.state, _MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, None) return format_response( {"symbol": symbol, "unsubscribed": success}, response_format, diff --git a/tests/conftest.py b/tests/conftest.py index afc5a6a..f39466f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -218,18 +218,6 @@ def mock_mt5_client() -> Mock: "request_volume": 0.1, "request_type": 0, } - client.order_send_as_dict.return_value = { - "retcode": 10009, - "deal": 123456789, - "order": 987654321, - "volume": 0.1, - "price": 1.08500, - "comment": "Request executed", - "request_action": 1, - "request_symbol": "EURUSD", - "request_volume": 0.1, - "request_type": 0, - } client.symbol_select.return_value = True client.market_book_add.return_value = True client.market_book_release.return_value = True @@ -238,14 +226,17 @@ def mock_mt5_client() -> Mock: @pytest.fixture -def client(mock_mt5_client: Mock, monkeypatch: pytest.MonkeyPatch) -> TestClient: +def client( + mock_mt5_client: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[TestClient, None, None]: """Create FastAPI test client with mocked MT5 client. Args: mock_mt5_client: Mocked Mt5DataClient fixture. monkeypatch: Pytest monkeypatch fixture for patching. - Returns: + Yields: FastAPI test client. """ # Import after setting environment variable and mocking MetaTrader5 @@ -263,10 +254,14 @@ def client(mock_mt5_client: Mock, monkeypatch: pytest.MonkeyPatch) -> TestClient app.dependency_overrides[dependencies.get_mt5_client] = lambda: mock_mt5_client # Don't override verify_api_key - let it work normally for auth tests - # Create test client - test_client = TestClient(app) + app.state.active_market_book_subscriptions = set() + app.state.market_book_cleanup_client = None + + with TestClient(app) as test_client: + yield test_client - return test_client + app.state.active_market_book_subscriptions = set() + app.state.market_book_cleanup_client = None @pytest.fixture diff --git a/tests/test_main.py b/tests/test_main.py index 7a8e504..eb8babb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,7 +2,11 @@ from __future__ import annotations +import asyncio from typing import Any +from unittest.mock import Mock + +from fastapi import FastAPI from mt5api.constants import API_KEY_SECURITY_SCHEME_NAME @@ -57,3 +61,39 @@ def test_strip_auth_from_openapi_preserves_other_security_schemes() -> None: main._strip_auth_from_openapi(openapi_schema) # pyright: ignore[reportPrivateUsage] assert openapi_schema["components"]["securitySchemes"] == {"Other": {}} + + +def test_release_market_book_subscriptions_clears_state() -> None: + """Shutdown cleanup should release tracked market-book subscriptions.""" + from mt5api import main # noqa: PLC0415 + + test_app = FastAPI() + test_app.state.active_market_book_subscriptions = {"GBPUSD", "EURUSD"} + test_client = Mock() + test_app.state.market_book_cleanup_client = test_client + + asyncio.run( + main._release_market_book_subscriptions(test_app) # pyright: ignore[reportPrivateUsage] + ) + + assert test_client.market_book_release.call_count == 2 + test_client.market_book_release.assert_any_call(symbol="EURUSD") + test_client.market_book_release.assert_any_call(symbol="GBPUSD") + assert test_app.state.active_market_book_subscriptions == set() + assert test_app.state.market_book_cleanup_client is None + + +def test_release_market_book_subscriptions_handles_missing_client() -> None: + """Shutdown cleanup should clear tracked state when no client is available.""" + from mt5api import main # noqa: PLC0415 + + test_app = FastAPI() + test_app.state.active_market_book_subscriptions = {"EURUSD"} + test_app.state.market_book_cleanup_client = None + + asyncio.run( + main._release_market_book_subscriptions(test_app) # pyright: ignore[reportPrivateUsage] + ) + + assert test_app.state.active_market_book_subscriptions == set() + assert test_app.state.market_book_cleanup_client is None diff --git a/tests/test_trading.py b/tests/test_trading.py index 4a17dc2..c964b91 100644 --- a/tests/test_trading.py +++ b/tests/test_trading.py @@ -2,12 +2,20 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast +from unittest.mock import call + +from fastapi.testclient import TestClient + +from mt5api import dependencies +from mt5api.main import app +from mt5api.routers import health if TYPE_CHECKING: from unittest.mock import Mock - from fastapi.testclient import TestClient + import pytest + from fastapi import FastAPI def test_post_order_check_returns_json( @@ -43,36 +51,66 @@ def test_post_order_check_returns_json( ) -def test_post_order_send_returns_json( +def test_post_order_check_validates_payload( client: TestClient, api_headers: dict[str, str], mock_mt5_client: Mock, ) -> None: - """POST /order/send returns order send result.""" + """POST /order/check rejects invalid request bodies.""" request_body = { "request": { "action": 1, - "symbol": "EURUSD", "volume": 0.1, "type": 0, "price": 1.08500, }, } response = client.post( - "/order/send", + "/order/check", json=request_body, headers=api_headers, ) - assert response.status_code == 200 + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"][-1] == "symbol" + mock_mt5_client.order_check_as_dict.assert_not_called() - payload = response.json() - assert payload["count"] == 1 - assert payload["data"]["retcode"] == 10009 - assert payload["data"]["deal"] == 123456789 - mock_mt5_client.order_send_as_dict.assert_called_with( - request=request_body["request"], +def test_post_order_check_allows_extra_mt5_fields( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """POST /order/check preserves additional MT5 trade-request fields.""" + request_body = { + "request": { + "action": 1, + "symbol": "EURUSD", + "volume": 0.1, + "type": "ORDER_TYPE_BUY", + "price": 1.08500, + "deviation": 10, + "type_filling": 1, + }, + } + response = client.post( + "/order/check", + json=request_body, + headers=api_headers, + ) + + assert response.status_code == 200 + + mock_mt5_client.order_check_as_dict.assert_called_with( + request={ + "action": 1, + "symbol": "EURUSD", + "volume": 0.1, + "type": 0, + "price": 1.08500, + "deviation": 10, + "type_filling": 1, + }, ) @@ -135,21 +173,60 @@ def test_post_market_book_subscribe_returns_json( ) assert response.status_code == 200 + test_app = cast("FastAPI", client.app) payload = response.json() assert payload["count"] == 1 assert payload["data"]["symbol"] == "EURUSD" assert payload["data"]["subscribed"] is True + assert test_app.state.active_market_book_subscriptions == {"EURUSD"} + assert test_app.state.market_book_cleanup_client is mock_mt5_client mock_mt5_client.market_book_add.assert_called_with(symbol="EURUSD") +def test_post_market_book_subscribe_validates_symbol_length( + client: TestClient, + api_headers: dict[str, str], +) -> None: + """POST /market-book/{symbol}/subscribe rejects oversized symbols.""" + response = client.post( + f"/market-book/{'X' * 33}/subscribe", + headers=api_headers, + ) + + assert response.status_code == 422 + + +def test_post_market_book_subscribe_does_not_track_failed_subscription( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """Failed subscriptions should not update tracked market-book state.""" + test_app = cast("FastAPI", client.app) + mock_mt5_client.market_book_add.return_value = False + + response = client.post( + "/market-book/EURUSD/subscribe", + headers=api_headers, + ) + + assert response.status_code == 200 + assert test_app.state.active_market_book_subscriptions == set() + assert test_app.state.market_book_cleanup_client is None + + def test_post_market_book_unsubscribe_returns_json( client: TestClient, api_headers: dict[str, str], mock_mt5_client: Mock, ) -> None: """POST /market-book/{symbol}/unsubscribe returns result.""" + test_app = cast("FastAPI", client.app) + test_app.state.active_market_book_subscriptions = {"EURUSD"} + test_app.state.market_book_cleanup_client = mock_mt5_client + response = client.post( "/market-book/EURUSD/unsubscribe", headers=api_headers, @@ -161,5 +238,91 @@ def test_post_market_book_unsubscribe_returns_json( assert payload["count"] == 1 assert payload["data"]["symbol"] == "EURUSD" assert payload["data"]["unsubscribed"] is True + assert test_app.state.active_market_book_subscriptions == set() + assert test_app.state.market_book_cleanup_client is None mock_mt5_client.market_book_release.assert_called_with(symbol="EURUSD") + + +def test_post_market_book_unsubscribe_validates_symbol_length( + client: TestClient, + api_headers: dict[str, str], +) -> None: + """POST /market-book/{symbol}/unsubscribe rejects oversized symbols.""" + response = client.post( + f"/market-book/{'X' * 33}/unsubscribe", + headers=api_headers, + ) + + assert response.status_code == 422 + + +def test_post_market_book_unsubscribe_keeps_cleanup_client_when_needed( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """Unsubscribing one symbol keeps cleanup state for remaining symbols.""" + test_app = cast("FastAPI", client.app) + test_app.state.active_market_book_subscriptions = {"EURUSD", "USDJPY"} + test_app.state.market_book_cleanup_client = mock_mt5_client + + response = client.post( + "/market-book/EURUSD/unsubscribe", + headers=api_headers, + ) + + assert response.status_code == 200 + assert test_app.state.active_market_book_subscriptions == {"USDJPY"} + assert test_app.state.market_book_cleanup_client is mock_mt5_client + + +def test_post_market_book_unsubscribe_does_not_mutate_tracking_on_failure( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """Failed unsubscriptions should preserve tracked market-book state.""" + test_app = cast("FastAPI", client.app) + test_app.state.active_market_book_subscriptions = {"EURUSD"} + test_app.state.market_book_cleanup_client = mock_mt5_client + mock_mt5_client.market_book_release.return_value = False + + response = client.post( + "/market-book/EURUSD/unsubscribe", + headers=api_headers, + ) + + assert response.status_code == 200 + assert test_app.state.active_market_book_subscriptions == {"EURUSD"} + assert test_app.state.market_book_cleanup_client is mock_mt5_client + + +def test_market_book_subscriptions_are_released_on_shutdown( + mock_mt5_client: Mock, + api_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tracked market-book subscriptions should be released during shutdown.""" + monkeypatch.setattr(health, "get_mt5_client", lambda: mock_mt5_client) + app.dependency_overrides.clear() + app.dependency_overrides[dependencies.get_mt5_client] = lambda: mock_mt5_client + + with TestClient(app) as test_client: + response = test_client.post( + "/market-book/EURUSD/subscribe", + headers=api_headers, + ) + assert response.status_code == 200 + + assert mock_mt5_client.market_book_add.call_args_list == [call(symbol="EURUSD")] + assert mock_mt5_client.market_book_release.call_args_list == [call(symbol="EURUSD")] + + +def test_openapi_excludes_order_send_endpoint( + client: TestClient, +) -> None: + """The removed order-send endpoint should not appear in OpenAPI.""" + openapi = client.get("/openapi.json").json() + + assert "/order/send" not in openapi["paths"] From 3b022850c7d504447b104f2886ce728dfd693593 Mon Sep 17 00:00:00 2001 From: dceoy <1938249+dceoy@users.noreply.github.com> Date: Tue, 17 Mar 2026 01:33:03 +0900 Subject: [PATCH 4/5] Update AGENTS.md --- AGENTS.md | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ea0af1e..0d2f1f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,15 +22,11 @@ - The API server must run on Windows with a logged-in MetaTrader 5 terminal. - Linux and macOS are for HTTP clients, documentation work, and local non-runtime development only. -- The service is intentionally read-only: expose account, terminal, symbol, market, order, and history data without adding trading actions. ### Key Dependencies - `pdmt5`: Underlying MetaTrader 5 client integration used by the API layer. - `fastapi`: HTTP application framework and OpenAPI generation. -- `pyarrow`: Apache Parquet response support. -- `slowapi`: Request rate limiting. -- `httpx`: HTTP client support used in testing and API interaction. ### Package Structure @@ -44,19 +40,16 @@ - `models.py`: Pydantic API models. - `middleware.py`: Request logging and error handling. - `constants.py`: Shared constants and environment variable names. - - `routers/`: Read-only endpoint groups: `health.py`, `symbols.py`, `market.py`, `account.py`, and `history.py`. + - `routers/`: Endpoint groups for operations: `health.py`, `symbols.py`, `market.py`, `account.py`, `history.py`, `calc.py`, and `trading.py`. - `tests/`: Pytest suite covering API behavior, configuration, middleware, and CLI entry points. - `docs/`: MkDocs documentation, including REST API and deployment guidance. ## Quality Standards -- Target Python 3.11+ with 4-space indentation and an 88-character line limit. -- Keep modules and functions in `snake_case`, classes in `PascalCase`, and constants in `UPPER_SNAKE_CASE`. -- Type annotations are expected; Pyright runs in strict mode. -- Ruff is configured with broad lint coverage; docstrings should follow the Google convention. -- Test coverage is enforced at 100%; update or add tests with every behavior change. -- Keep endpoint code grouped by domain under `mt5api/routers/`. -- Use `pytest.mark.parametrize` for input/result matrices. +- Type hints required (pyright strict mode) +- Comprehensive linting with 35+ rule categories (ruff) +- Test coverage tracking with 100% (pytest-cov) +- Parametrized tests for input/result matrices using `pytest.mark.parametrize` (pytest) ## Documentation Workflow @@ -67,8 +60,6 @@ ## Commit & Pull Request Guidelines - Run QA using `local-qa` skill before committing or creating a PR. -- Include request/response examples or screenshots when docs or OpenAPI-visible behavior changes. -- Keep PRs focused and include: concise summary, affected workflow paths, linked issue/context, and regenerated `README.md` when workflow inventory changes. - Branch names use appropriate prefixes on creation (e.g., `feature/...`, `bugfix/...`, `refactor/...`, `docs/...`, `chore/...`). - When instructed to create a PR, create it as a draft with appropriate labels by default. From ad83231a600df39a4142280c48a2eb5bfdd1e8fb Mon Sep 17 00:00:00 2001 From: dceoy <1938249+dceoy@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:05:35 +0900 Subject: [PATCH 5/5] Address PR review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 3 + docs/api/deployment.md | 1 + docs/api/index.md | 8 +- docs/api/rest-api.md | 6 ++ docs/index.md | 3 + mt5api/config.py | 30 ++++++ mt5api/constants.py | 11 ++- mt5api/main.py | 28 ++++-- mt5api/models.py | 93 +++++++++++++++++- mt5api/routers/trading.py | 90 ++++++++++++++--- tests/test_auth.py | 46 ++++++++- tests/test_calc.py | 141 +++++++++++++++++++++++++++ tests/test_config.py | 43 ++++++++ tests/test_history.py | 97 +++++++++++++++++++ tests/test_main.py | 61 +++++++++++- tests/test_models.py | 98 +++++++++++++++++++ tests/test_trading.py | 199 +++++++++++++++++++++++++++++++++++++- 17 files changed, 922 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index f05df06..5c35760 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ as `/api/v1`. The default is `""`, which keeps routes like `/health` and `/symbols` at the root. `"/api/v1"`, `"api/v1"`, and `"/api/v1/"` are treated the same. +Set `MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS` to cap active market-book +subscriptions. The default limit is `100`. + ## Example Requests with curl Replace `windows-host` with the DNS name or IP address of the Windows machine diff --git a/docs/api/deployment.md b/docs/api/deployment.md index 6ce662e..f45b10b 100644 --- a/docs/api/deployment.md +++ b/docs/api/deployment.md @@ -37,6 +37,7 @@ nssm install mt5api MT5API_SECRET_KEY=your-secret-api-key MT5API_LOG_LEVEL=INFO MT5API_RATE_LIMIT=100 +MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS=100 MT5API_CORS_ORIGINS=* MT5API_ROUTER_PREFIX=/api/v1 ``` diff --git a/docs/api/index.md b/docs/api/index.md index 0c0609c..1c21b26 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -11,8 +11,8 @@ logged in. Clients can call the HTTP API from any operating system. ### [REST API](rest-api.md) FastAPI-based REST API that exposes MT5 market data, account info, trading -history, calculations, and trading operations over HTTP with JSON and Parquet -support. +history, calculations, and non-executing operational endpoints over HTTP with +JSON and Parquet support. ### [Deployment](deployment.md) @@ -25,12 +25,12 @@ mt5api provides a FastAPI layer on top of the MetaTrader 5 terminal runtime: 1. **API Layer** (`mt5api.main`, `mt5api.routers`): FastAPI app, routers, and response formatting. Routers are grouped by domain: - `health.py`: health check, version, last error - - `symbols.py`: symbol listing, details, tick, total, and selection + - `symbols.py`: symbol listing, details, tick, and total - `market.py`: OHLCV rates, tick data, and market depth - `calc.py`: margin and profit calculations - `account.py`: account and terminal info - `history.py`: positions, orders, history with totals - - `trading.py`: order check, order send, market book subscriptions + - `trading.py`: order check, symbol selection, market book subscriptions 2. **Dependency Layer** (`mt5api.dependencies`): MT5 client lifecycle and format negotiation 3. **Model Layer** (`mt5api.models`): Response schemas and MT5 constant diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index afb1876..256973e 100644 --- a/docs/api/rest-api.md +++ b/docs/api/rest-api.md @@ -31,6 +31,7 @@ Set the optional API key and other limits via environment variables: $env:MT5API_SECRET_KEY = "your-secret-api-key" # Optional: omit to disable auth $env:MT5API_LOG_LEVEL = "INFO" $env:MT5API_RATE_LIMIT = "100" +$env:MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS = "100" $env:MT5API_CORS_ORIGINS = "*" $env:MT5API_ROUTER_PREFIX = "/api/v1" # Optional: omit for root-level routes ``` @@ -74,6 +75,11 @@ curl -H "X-API-Key: your-secret-api-key" "http://windows-host:8000/symbols" Rate limiting uses `slowapi` with a default limit of `100/minute`. Set `MT5API_RATE_LIMIT` to an integer for a different per-minute cap. +Active market-book subscriptions are capped at `100` symbols by default. Set +`MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS` to a positive integer to adjust that +limit. When the cap is reached, new `POST /market-book/{symbol}/subscribe` +requests return HTTP `429` until an existing subscription is released. + ## Format Negotiation Use `Accept` header or `format` query parameter: diff --git a/docs/index.md b/docs/index.md index 4c1530e..158f4c3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,6 +48,9 @@ uv run uvicorn mt5api.main:app --host 0.0.0.0 --port 8000 Once the API is running, use these `curl` examples from any client machine. +Set `MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS` to cap active market-book +subscriptions. The default limit is `100`. + Replace `windows-host` with the DNS name or IP address of the Windows machine running `mt5api`. If you run the request on that Windows host, `localhost` also works. In PowerShell, use `curl.exe` if `curl` resolves to diff --git a/mt5api/config.py b/mt5api/config.py index f8911c4..6b117fa 100644 --- a/mt5api/config.py +++ b/mt5api/config.py @@ -11,9 +11,11 @@ DEFAULT_API_LOG_LEVEL, DEFAULT_API_RATE_LIMIT, DEFAULT_API_ROUTER_PREFIX, + DEFAULT_MAX_MARKET_BOOK_SUBSCRIPTIONS, ENV_MT5API_CORS_ORIGINS, ENV_MT5API_HOST, ENV_MT5API_LOG_LEVEL, + ENV_MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS, ENV_MT5API_PORT, ENV_MT5API_RATE_LIMIT, ENV_MT5API_ROUTER_PREFIX, @@ -22,6 +24,9 @@ _VALID_API_ROUTER_PREFIX_PATTERN = re.compile(r"^[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*$") _INVALID_MT5API_ROUTER_PREFIX_ERROR = "Invalid MT5API_ROUTER_PREFIX" +_INVALID_MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS_ERROR = ( + "Invalid MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS" +) def normalize_api_router_prefix(raw_prefix: str | None) -> str: @@ -105,6 +110,31 @@ def get_configured_api_router_prefix() -> str: ) +def get_configured_max_market_book_subscriptions() -> int: + """Get the configured market-book subscription limit. + + Returns: + Positive maximum number of tracked market-book subscriptions. + + Raises: + ValueError: If the configured limit is not a positive integer. + """ + raw_limit = os.getenv( + ENV_MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS, + str(DEFAULT_MAX_MARKET_BOOK_SUBSCRIPTIONS), + ) + + try: + limit = int(raw_limit) + except ValueError as error: + raise ValueError(_INVALID_MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS_ERROR) from error + + if limit < 1: + raise ValueError(_INVALID_MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS_ERROR) + + return limit + + def get_configured_mt5api_secret_key() -> str | None: """Get the configured MT5 API secret key, if any. diff --git a/mt5api/constants.py b/mt5api/constants.py index dd5c92d..3a82768 100644 --- a/mt5api/constants.py +++ b/mt5api/constants.py @@ -2,9 +2,9 @@ API_TITLE = "MT5 REST API" API_DESCRIPTION = ( - "REST API for MetaTrader 5 data access. " - "Provides read-only access to market data, " - "account information, and trading history via HTTP endpoints." + "REST API for MetaTrader 5 data access and non-executing terminal utilities. " + "Provides market data, account information, trading history, calculations, " + "and safe operational endpoints via HTTP." ) API_VERSION = "1.0.0" API_DOCS_URL = "/docs" @@ -13,6 +13,9 @@ API_APP_IMPORT = "mt5api.main:app" API_KEY_HEADER_NAME = "X-API-Key" API_KEY_SECURITY_SCHEME_NAME = "APIKeyHeader" +ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY = "active_market_book_subscriptions" +MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY = "market_book_cleanup_client" +MAX_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY = "max_market_book_subscriptions" ENV_MT5API_HOST = "MT5API_HOST" ENV_MT5API_PORT = "MT5API_PORT" @@ -20,6 +23,7 @@ ENV_MT5API_RATE_LIMIT = "MT5API_RATE_LIMIT" ENV_MT5API_CORS_ORIGINS = "MT5API_CORS_ORIGINS" ENV_MT5API_ROUTER_PREFIX = "MT5API_ROUTER_PREFIX" +ENV_MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS = "MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS" ENV_MT5API_SECRET_KEY = "MT5API_SECRET_KEY" # noqa: S105 DEFAULT_API_HOST = "0.0.0.0" # noqa: S104 @@ -28,4 +32,5 @@ DEFAULT_API_RATE_LIMIT = 100 DEFAULT_API_CORS_ORIGINS = "*" DEFAULT_API_ROUTER_PREFIX = "" +DEFAULT_MAX_MARKET_BOOK_SUBSCRIPTIONS = 100 MAX_API_PORT = 65535 diff --git a/mt5api/main.py b/mt5api/main.py index d8e2409..a900987 100644 --- a/mt5api/main.py +++ b/mt5api/main.py @@ -17,8 +17,10 @@ get_configured_api_cors_origins, get_configured_api_log_level, get_configured_api_router_prefix, + get_configured_max_market_book_subscriptions, ) from .constants import ( + ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY, API_DESCRIPTION, API_DOCS_URL, API_KEY_SECURITY_SCHEME_NAME, @@ -27,6 +29,8 @@ API_TITLE, API_VERSION, DEFAULT_API_CORS_ORIGINS, + MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, + MAX_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY, ) from .dependencies import run_in_threadpool, shutdown_mt5_client from .middleware import add_middleware @@ -132,32 +136,33 @@ def _custom_openapi() -> dict[str, Any]: _configure_logging() logger = logging.getLogger(__name__) -_ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY = "active_market_book_subscriptions" -_MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY = "market_book_cleanup_client" async def _release_market_book_subscriptions(app: FastAPI) -> None: """Release active market-book subscriptions before shutting down MT5.""" subscriptions = getattr( app.state, - _ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY, + ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY, None, ) if not isinstance(subscriptions, set) or not subscriptions: return - mt5_client = getattr(app.state, _MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, None) + mt5_client = getattr(app.state, MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, None) if mt5_client is None: logger.warning("Active market-book subscriptions found without cleanup client") subscriptions.clear() - setattr(app.state, _MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, None) + setattr(app.state, MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, None) return for symbol in tuple(sorted(subscriptions)): - await run_in_threadpool(mt5_client.market_book_release, symbol=symbol) + try: + await run_in_threadpool(mt5_client.market_book_release, symbol=symbol) + except Exception: + logger.exception("Failed to release market book for %s", symbol) subscriptions.clear() - setattr(app.state, _MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, None) + setattr(app.state, MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, None) @asynccontextmanager @@ -172,8 +177,13 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """ # Startup logger.info("Starting MT5 REST API...") - app.state.active_market_book_subscriptions = set() - app.state.market_book_cleanup_client = None + setattr(app.state, ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY, set()) + setattr(app.state, MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, None) + setattr( + app.state, + MAX_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY, + get_configured_max_market_book_subscriptions(), + ) # Note: MT5 client is initialized lazily on first request via dependency # This avoids blocking startup if MT5 is not available diff --git a/mt5api/models.py b/mt5api/models.py index 10d5ed2..b15a6b4 100644 --- a/mt5api/models.py +++ b/mt5api/models.py @@ -553,10 +553,28 @@ class TicksRangeRequest(BaseModel): class MarketBookRequest(BaseModel): """Request parameters for market book endpoint.""" - symbol: str = Field(..., description="Symbol name") + symbol: str = Field( + ..., + description="Symbol name", + min_length=1, + max_length=32, + examples=["EURUSD"], + ) format: ResponseFormat | None = Field(default=None) +class MarketBookSubscriptionRequest(BaseModel): + """Request parameters for market-book subscription endpoints.""" + + symbol: str = Field( + ..., + description="Symbol name", + min_length=1, + max_length=32, + examples=["EURUSD"], + ) + + class PositionsRequest(BaseModel): """Request parameters for positions endpoint.""" @@ -737,7 +755,7 @@ class CalcProfitRequest(BaseModel): class TradeRequest(BaseModel): """Typed MT5 trade request payload used for order validation.""" - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra="forbid") action: int = Field( ..., @@ -768,6 +786,77 @@ class TradeRequest(BaseModel): ge=0, examples=[1.08500], ) + stoplimit: float | None = Field( + default=None, + description="Stop-limit price for stop-limit pending orders", + ge=0, + examples=[1.08450], + ) + sl: float | None = Field( + default=None, + description="Stop-loss price", + ge=0, + examples=[1.08000], + ) + tp: float | None = Field( + default=None, + description="Take-profit price", + ge=0, + examples=[1.09000], + ) + deviation: int | None = Field( + default=None, + description="Maximum allowed price deviation in points", + ge=0, + examples=[10], + ) + magic: int | None = Field( + default=None, + description="Expert Advisor identifier", + ge=0, + examples=[123456], + ) + comment: str | None = Field( + default=None, + description="Trade request comment", + min_length=1, + examples=["validation-check"], + ) + type_filling: int | None = Field( + default=None, + description="MetaTrader5 ORDER_FILLING constant value", + ge=0, + examples=[1], + ) + type_time: int | None = Field( + default=None, + description="MetaTrader5 ORDER_TIME constant value", + ge=0, + examples=[0], + ) + expiration: datetime | None = Field( + default=None, + description="Expiration time for timed pending orders", + examples=["2024-01-02T00:00:00Z"], + ) + order: int | None = Field( + default=None, + description="Pending order ticket", + ge=0, + examples=[123456], + ) + position: int | None = Field( + default=None, + description="Open position ticket", + ge=0, + examples=[123456], + ) + position_by: int | None = Field( + default=None, + description="Opposite position ticket", + ge=0, + examples=[654321], + ) class OrderCheckRequest(BaseModel): diff --git a/mt5api/routers/trading.py b/mt5api/routers/trading.py index 5452e01..83dcaf0 100644 --- a/mt5api/routers/trading.py +++ b/mt5api/routers/trading.py @@ -2,12 +2,20 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING, Annotated, Any, cast -from fastapi import APIRouter, Depends, Path, Request +from fastapi import APIRouter, Depends, Request, status +from fastapi.responses import JSONResponse from pdmt5.dataframe import Mt5DataClient # noqa: TC002 from mt5api.auth import verify_api_key +from mt5api.constants import ( + ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY, + ENV_MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS, + MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, + MAX_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY, +) from mt5api.dependencies import ( get_mt5_client, get_response_format, @@ -16,6 +24,8 @@ from mt5api.formatters import format_response from mt5api.models import ( DataResponse, + ErrorResponse, + MarketBookSubscriptionRequest, OrderCheckRequest, ResponseFormat, SymbolSelectRequest, @@ -28,16 +38,49 @@ tags=["trading"], dependencies=[Depends(verify_api_key)], ) - -_ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY = "active_market_book_subscriptions" -_MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY = "market_book_cleanup_client" +logger = logging.getLogger(__name__) -def _get_active_market_book_subscriptions(request: Request) -> set[str]: +def _get_active_market_book_subscriptions(app_request: Request) -> set[str]: """Return the active market-book subscription set for the application.""" return cast( "set[str]", - getattr(request.app.state, _ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY), + getattr(app_request.app.state, ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY), + ) + + +def _get_max_market_book_subscriptions(app_request: Request) -> int: + """Return the configured market-book subscription limit for the application.""" + return cast( + "int", + getattr(app_request.app.state, MAX_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY), + ) + + +def _build_market_book_subscription_limit_response( + app_request: Request, + *, + limit: int, +) -> JSONResponse: + """Create a problem-details response for market-book subscription exhaustion. + + Returns: + JSON response describing the subscription-cap violation. + """ + error = ErrorResponse( + type="/errors/subscription-limit", + title="Subscription Limit Exceeded", + status=status.HTTP_429_TOO_MANY_REQUESTS, + detail=( + "Active market-book subscriptions are limited to " + f"{limit}. Unsubscribe from an existing symbol or increase " + f"{ENV_MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS}." + ), + instance=str(app_request.url), + ) + return JSONResponse( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + content=error.model_dump(), ) @@ -100,24 +143,42 @@ async def post_symbol_select( description="Subscribe to Market Depth change events for a symbol", ) async def post_market_book_subscribe( - request: Request, + app_request: Request, mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], response_format: Annotated[ResponseFormat, Depends(get_response_format)], - symbol: Annotated[str, Path(min_length=1, max_length=32)], + request: Annotated[MarketBookSubscriptionRequest, Depends()], ) -> DataResponse | Response: """Subscribe to market depth for a symbol. Returns: JSON or Parquet response with subscription result. """ + subscriptions = _get_active_market_book_subscriptions(app_request) + symbol = request.symbol + if symbol not in subscriptions: + max_subscriptions = _get_max_market_book_subscriptions(app_request) + if len(subscriptions) >= max_subscriptions: + logger.warning( + "Market-book subscription limit reached for %s (%d active)", + symbol, + len(subscriptions), + ) + return _build_market_book_subscription_limit_response( + app_request, + limit=max_subscriptions, + ) + success = await run_in_threadpool( mt5_client.market_book_add, symbol=symbol, ) if success: - subscriptions = _get_active_market_book_subscriptions(request) subscriptions.add(symbol) - setattr(request.app.state, _MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, mt5_client) + setattr( + app_request.app.state, + MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, + mt5_client, + ) return format_response( {"symbol": symbol, "subscribed": success}, response_format, @@ -131,25 +192,26 @@ async def post_market_book_subscribe( description="Cancel Market Depth subscription for a symbol", ) async def post_market_book_unsubscribe( - request: Request, + app_request: Request, mt5_client: Annotated[Mt5DataClient, Depends(get_mt5_client)], response_format: Annotated[ResponseFormat, Depends(get_response_format)], - symbol: Annotated[str, Path(min_length=1, max_length=32)], + request: Annotated[MarketBookSubscriptionRequest, Depends()], ) -> DataResponse | Response: """Unsubscribe from market depth for a symbol. Returns: JSON or Parquet response with unsubscription result. """ + symbol = request.symbol success = await run_in_threadpool( mt5_client.market_book_release, symbol=symbol, ) if success: - subscriptions = _get_active_market_book_subscriptions(request) + subscriptions = _get_active_market_book_subscriptions(app_request) subscriptions.discard(symbol) if not subscriptions: - setattr(request.app.state, _MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, None) + setattr(app_request.app.state, MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, None) return format_response( {"symbol": symbol, "unsubscribed": success}, response_format, diff --git a/tests/test_auth.py b/tests/test_auth.py index 0f44488..176536a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any + +import pytest from mt5api.constants import API_KEY_HEADER_NAME @@ -82,6 +84,48 @@ def test_symbols_endpoint_rejects_invalid_api_key( assert "Invalid API key" in data["detail"] +@pytest.mark.parametrize( + ("path", "request_kwargs", "mock_attr"), + [ + ( + "/order/check", + { + "json": { + "request": { + "action": 1, + "symbol": "EURUSD", + "volume": 0.1, + "type": 0, + "price": 1.08500, + } + } + }, + "order_check_as_dict", + ), + ("/symbols/EURUSD/select", {}, "symbol_select"), + ("/market-book/EURUSD/subscribe", {}, "market_book_add"), + ("/market-book/EURUSD/unsubscribe", {}, "market_book_release"), + ], +) +def test_trading_endpoints_require_authentication( + client: TestClient, + mock_mt5_client: Mock, + path: str, + request_kwargs: dict[str, Any], + mock_attr: str, +) -> None: + """Trading endpoints should reject unauthenticated requests.""" + response = client.post(path, **request_kwargs) + + assert response.status_code == 401 + getattr(mock_mt5_client, mock_attr).assert_not_called() + + data = response.json()["detail"] + assert data["type"] == "/errors/unauthorized" + assert data["title"] == "Authentication Required" + assert "Missing API key" in data["detail"] + + def test_version_endpoint_allows_requests_when_auth_disabled( client: TestClient, monkeypatch: MonkeyPatch, diff --git a/tests/test_calc.py b/tests/test_calc.py index f53e7d4..5e9d6ca 100644 --- a/tests/test_calc.py +++ b/tests/test_calc.py @@ -4,6 +4,9 @@ from typing import TYPE_CHECKING +import pytest +from pdmt5.mt5 import Mt5RuntimeError + from tests.mt5_constants import Mt5OrderType if TYPE_CHECKING: @@ -153,3 +156,141 @@ def test_get_calc_profit_returns_parquet( assert response.status_code == 200 assert response.headers["content-type"].startswith("application/parquet") + + +@pytest.mark.parametrize( + "params", + [ + { + "action": 99, + "symbol": "EURUSD", + "volume": 0.1, + "price": 1.08500, + }, + { + "action": "ORDER_TYPE_BUY", + "symbol": "EURUSD", + "volume": 0, + "price": 1.08500, + }, + { + "action": "ORDER_TYPE_BUY", + "symbol": "EURUSD", + "volume": 0.1, + "price": 0, + }, + ], +) +def test_get_calc_margin_validates_query_params( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, + params: dict[str, str | float | int], +) -> None: + """GET /calc/margin rejects invalid query parameter combinations.""" + response = client.get( + "/calc/margin", + params=params, + headers=api_headers, + ) + + assert response.status_code == 422 + mock_mt5_client.order_calc_margin.assert_not_called() + + +@pytest.mark.parametrize( + "params", + [ + { + "action": 99, + "symbol": "EURUSD", + "volume": 0.1, + "price_open": 1.08500, + "price_close": 1.09000, + }, + { + "action": "ORDER_TYPE_BUY", + "symbol": "EURUSD", + "volume": 0, + "price_open": 1.08500, + "price_close": 1.09000, + }, + { + "action": "ORDER_TYPE_BUY", + "symbol": "EURUSD", + "volume": 0.1, + "price_open": 1.08500, + "price_close": 0, + }, + ], +) +def test_get_calc_profit_validates_query_params( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, + params: dict[str, str | float | int], +) -> None: + """GET /calc/profit rejects invalid query parameter combinations.""" + response = client.get( + "/calc/profit", + params=params, + headers=api_headers, + ) + + assert response.status_code == 422 + mock_mt5_client.order_calc_profit.assert_not_called() + + +def test_get_calc_margin_returns_service_unavailable_on_mt5_error( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /calc/margin surfaces MT5 runtime failures as 503 responses.""" + mock_mt5_client.order_calc_margin.side_effect = Mt5RuntimeError( + "order_calc_margin returned None" + ) + + response = client.get( + "/calc/margin", + params={ + "action": "ORDER_TYPE_BUY", + "symbol": "EURUSD", + "volume": 0.1, + "price": 1.08500, + }, + headers=api_headers, + ) + + assert response.status_code == 503 + payload = response.json() + assert payload["title"] == "MT5 Terminal Error" + assert "order_calc_margin returned None" in payload["detail"] + + +def test_get_calc_profit_returns_service_unavailable_on_mt5_error( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /calc/profit surfaces MT5 runtime failures as 503 responses.""" + mock_mt5_client.order_calc_profit.side_effect = Mt5RuntimeError( + "order_calc_profit returned None" + ) + + response = client.get( + "/calc/profit", + params={ + "action": "ORDER_TYPE_BUY", + "symbol": "EURUSD", + "volume": 0.1, + "price_open": 1.08500, + "price_close": 1.09000, + }, + headers=api_headers, + ) + + assert response.status_code == 503 + payload = response.json() + assert payload["title"] == "MT5 Terminal Error" + assert "order_calc_profit returned None" in payload["detail"] diff --git a/tests/test_config.py b/tests/test_config.py index 308aa09..e579c52 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,7 +7,9 @@ import pytest from mt5api.constants import ( + DEFAULT_MAX_MARKET_BOOK_SUBSCRIPTIONS, ENV_MT5API_CORS_ORIGINS, + ENV_MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS, ENV_MT5API_RATE_LIMIT, ENV_MT5API_ROUTER_PREFIX, ENV_MT5API_SECRET_KEY, @@ -98,6 +100,47 @@ def test_get_configured_mt5api_secret_key( assert config.get_configured_mt5api_secret_key() == expected_secret_key +@pytest.mark.parametrize( + ("raw_limit", "expected_limit"), + [ + (None, DEFAULT_MAX_MARKET_BOOK_SUBSCRIPTIONS), + ("1", 1), + ("250", 250), + ], +) +def test_get_configured_max_market_book_subscriptions( + monkeypatch: pytest.MonkeyPatch, + raw_limit: str | None, + expected_limit: int, +) -> None: + """Market-book subscription limit should read a positive configured integer.""" + from mt5api import config # noqa: PLC0415 + + if raw_limit is None: + monkeypatch.delenv(ENV_MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS, raising=False) + else: + monkeypatch.setenv(ENV_MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS, raw_limit) + + assert config.get_configured_max_market_book_subscriptions() == expected_limit + + +@pytest.mark.parametrize("raw_limit", ["0", "-1", "not-a-number"]) +def test_get_configured_max_market_book_subscriptions_rejects_invalid_values( + monkeypatch: pytest.MonkeyPatch, + raw_limit: str, +) -> None: + """Market-book subscription limit should reject invalid values.""" + from mt5api import config # noqa: PLC0415 + + monkeypatch.setenv(ENV_MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS, raw_limit) + + with pytest.raises( + ValueError, + match="Invalid MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS", + ): + config.get_configured_max_market_book_subscriptions() + + def test_app_uses_api_router_prefix_from_environment( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_history.py b/tests/test_history.py index d472dc6..ae3baef 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -212,6 +212,20 @@ def test_get_orders_total_returns_json( mock_mt5_client.orders_total.assert_called_with() +def test_get_orders_total_returns_parquet( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /orders/total supports Parquet output.""" + response = client.get("/orders/total?format=parquet", headers=api_headers) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/parquet") + + mock_mt5_client.orders_total.assert_called_with() + + def test_get_positions_total_returns_json( client: TestClient, api_headers: dict[str, str], @@ -229,6 +243,20 @@ def test_get_positions_total_returns_json( mock_mt5_client.positions_total.assert_called_with() +def test_get_positions_total_returns_parquet( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /positions/total supports Parquet output.""" + response = client.get("/positions/total?format=parquet", headers=api_headers) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/parquet") + + mock_mt5_client.positions_total.assert_called_with() + + def test_get_history_orders_total_returns_json( client: TestClient, api_headers: dict[str, str], @@ -256,6 +284,31 @@ def test_get_history_orders_total_returns_json( ) +def test_get_history_orders_total_returns_parquet( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /history/orders/total supports Parquet output.""" + response = client.get( + "/history/orders/total", + params={ + "date_from": "2024-01-01T00:00:00Z", + "date_to": "2024-01-02T00:00:00Z", + "format": "parquet", + }, + headers=api_headers, + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/parquet") + + mock_mt5_client.history_orders_total.assert_called_with( + date_from=ANY, + date_to=ANY, + ) + + def test_get_history_deals_total_returns_json( client: TestClient, api_headers: dict[str, str], @@ -283,6 +336,31 @@ def test_get_history_deals_total_returns_json( ) +def test_get_history_deals_total_returns_parquet( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /history/deals/total supports Parquet output.""" + response = client.get( + "/history/deals/total", + params={ + "date_from": "2024-01-01T00:00:00Z", + "date_to": "2024-01-02T00:00:00Z", + "format": "parquet", + }, + headers=api_headers, + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/parquet") + + mock_mt5_client.history_deals_total.assert_called_with( + date_from=ANY, + date_to=ANY, + ) + + def test_get_history_orders_total_requires_date_range( client: TestClient, api_headers: dict[str, str], @@ -295,3 +373,22 @@ def test_get_history_orders_total_requires_date_range( ) assert response.status_code == 422 + + +def test_get_history_deals_total_rejects_invalid_date_range( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """GET /history/deals/total rejects reversed date ranges.""" + response = client.get( + "/history/deals/total", + params={ + "date_from": "2024-01-02T00:00:00Z", + "date_to": "2024-01-01T00:00:00Z", + }, + headers=api_headers, + ) + + assert response.status_code == 400 + mock_mt5_client.history_deals_total.assert_not_called() diff --git a/tests/test_main.py b/tests/test_main.py index eb8babb..6357b79 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,13 +3,16 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import TYPE_CHECKING, Any from unittest.mock import Mock from fastapi import FastAPI from mt5api.constants import API_KEY_SECURITY_SCHEME_NAME +if TYPE_CHECKING: + import pytest + def test_strip_auth_from_openapi_handles_non_dict_components() -> None: """OpenAPI auth stripping should tolerate missing component mappings.""" @@ -97,3 +100,59 @@ def test_release_market_book_subscriptions_handles_missing_client() -> None: assert test_app.state.active_market_book_subscriptions == set() assert test_app.state.market_book_cleanup_client is None + + +def test_release_market_book_subscriptions_skips_when_state_is_missing() -> None: + """Shutdown cleanup should no-op when no subscriptions were ever tracked.""" + from mt5api import main # noqa: PLC0415 + + test_app = FastAPI() + + asyncio.run( + main._release_market_book_subscriptions(test_app) # pyright: ignore[reportPrivateUsage] + ) + + assert not hasattr(test_app.state, "active_market_book_subscriptions") + assert not hasattr(test_app.state, "market_book_cleanup_client") + + +def test_release_market_book_subscriptions_skips_when_state_is_empty() -> None: + """Shutdown cleanup should no-op when the tracked subscription set is empty.""" + from mt5api import main # noqa: PLC0415 + + test_app = FastAPI() + test_app.state.active_market_book_subscriptions = set() + test_client = Mock() + test_app.state.market_book_cleanup_client = test_client + + asyncio.run( + main._release_market_book_subscriptions(test_app) # pyright: ignore[reportPrivateUsage] + ) + + test_client.market_book_release.assert_not_called() + assert test_app.state.market_book_cleanup_client is test_client + + +def test_release_market_book_subscriptions_continues_after_failure( + caplog: pytest.LogCaptureFixture, +) -> None: + """Shutdown cleanup should continue releasing symbols after one failure.""" + from mt5api import main # noqa: PLC0415 + + test_app = FastAPI() + test_app.state.active_market_book_subscriptions = {"GBPUSD", "EURUSD"} + test_client = Mock() + test_client.market_book_release.side_effect = [RuntimeError("boom"), None] + test_app.state.market_book_cleanup_client = test_client + + with caplog.at_level("ERROR"): + asyncio.run( + main._release_market_book_subscriptions(test_app) # pyright: ignore[reportPrivateUsage] + ) + + assert test_client.market_book_release.call_count == 2 + test_client.market_book_release.assert_any_call(symbol="EURUSD") + test_client.market_book_release.assert_any_call(symbol="GBPUSD") + assert "Failed to release market book for EURUSD" in caplog.text + assert test_app.state.active_market_book_subscriptions == set() + assert test_app.state.market_book_cleanup_client is None diff --git a/tests/test_models.py b/tests/test_models.py index fd1b564..4451cef 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -13,6 +13,7 @@ HistoryTotalRequest, RatesFromRequest, TicksFromRequest, + TradeRequest, get_mt5_copy_ticks_examples, get_mt5_order_type_examples, get_mt5_timeframe_examples, @@ -143,3 +144,100 @@ def test_history_total_request_rejects_invalid_date_range() -> None: date_from=datetime(2024, 1, 2, tzinfo=UTC), date_to=datetime(2024, 1, 1, tzinfo=UTC), ) + + +def test_history_total_request_rejects_equal_dates() -> None: + """History total request rejects equal start and end timestamps.""" + timestamp = datetime(2024, 1, 1, tzinfo=UTC) + + with pytest.raises(ValueError, match="date_from must be before date_to"): + HistoryTotalRequest(date_from=timestamp, date_to=timestamp) + + +def test_trade_request_accepts_supported_optional_fields() -> None: + """Trade requests should accept explicitly whitelisted optional MT5 fields.""" + request = TradeRequest.model_validate({ + "action": 1, + "symbol": "EURUSD", + "volume": 0.1, + "type": "ORDER_TYPE_BUY", + "price": 1.085, + "deviation": 10, + "type_filling": 1, + "type_time": 0, + "position": 123456, + "comment": "validation-check", + }) + + assert request.deviation == 10 + assert request.type_filling == 1 + assert request.type_time == 0 + assert request.position == 123456 + assert request.comment == "validation-check" + + +def test_trade_request_rejects_unknown_field() -> None: + """Trade requests should reject unknown extra fields.""" + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + TradeRequest.model_validate({ + "action": 1, + "symbol": "EURUSD", + "volume": 0.1, + "type": 0, + "price": 1.085, + "unsupported": True, + }) + + +@pytest.mark.parametrize( + ("payload", "match"), + [ + ( + { + "action": -1, + "symbol": "EURUSD", + "volume": 0.1, + "type": 0, + "price": 1.085, + }, + "greater than or equal to 0", + ), + ( + { + "action": 1, + "symbol": "", + "volume": 0.1, + "type": 0, + "price": 1.085, + }, + "at least 1 character", + ), + ( + { + "action": 1, + "symbol": "EURUSD", + "volume": 0, + "type": 0, + "price": 1.085, + }, + "greater than 0", + ), + ( + { + "action": 1, + "symbol": "EURUSD", + "volume": 0.1, + "type": 99, + "price": 1.085, + }, + "Unsupported metatrader5 order_type constant value: 99", + ), + ], +) +def test_trade_request_rejects_invalid_core_fields( + payload: dict[str, object], + match: str, +) -> None: + """Trade requests should validate core field constraints.""" + with pytest.raises(ValidationError, match=match): + TradeRequest.model_validate(payload) diff --git a/tests/test_trading.py b/tests/test_trading.py index c964b91..7eb12b3 100644 --- a/tests/test_trading.py +++ b/tests/test_trading.py @@ -51,6 +51,31 @@ def test_post_order_check_returns_json( ) +def test_post_order_check_returns_parquet( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """POST /order/check supports Parquet output.""" + response = client.post( + "/order/check?format=parquet", + json={ + "request": { + "action": 1, + "symbol": "EURUSD", + "volume": 0.1, + "type": 0, + "price": 1.08500, + } + }, + headers=api_headers, + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/parquet") + mock_mt5_client.order_check_as_dict.assert_called_once() + + def test_post_order_check_validates_payload( client: TestClient, api_headers: dict[str, str], @@ -76,12 +101,12 @@ def test_post_order_check_validates_payload( mock_mt5_client.order_check_as_dict.assert_not_called() -def test_post_order_check_allows_extra_mt5_fields( +def test_post_order_check_allows_supported_optional_mt5_fields( client: TestClient, api_headers: dict[str, str], mock_mt5_client: Mock, ) -> None: - """POST /order/check preserves additional MT5 trade-request fields.""" + """POST /order/check preserves whitelisted optional MT5 trade-request fields.""" request_body = { "request": { "action": 1, @@ -90,7 +115,9 @@ def test_post_order_check_allows_extra_mt5_fields( "type": "ORDER_TYPE_BUY", "price": 1.08500, "deviation": 10, + "comment": "validation-check", "type_filling": 1, + "position": 123456, }, } response = client.post( @@ -109,11 +136,63 @@ def test_post_order_check_allows_extra_mt5_fields( "type": 0, "price": 1.08500, "deviation": 10, + "comment": "validation-check", "type_filling": 1, + "position": 123456, }, ) +def test_post_order_check_rejects_unknown_trade_request_field( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """POST /order/check rejects unrecognized trade-request fields.""" + response = client.post( + "/order/check", + json={ + "request": { + "action": 1, + "symbol": "EURUSD", + "volume": 0.1, + "type": 0, + "price": 1.08500, + "unsupported": True, + } + }, + headers=api_headers, + ) + + assert response.status_code == 422 + mock_mt5_client.order_check_as_dict.assert_not_called() + + +def test_post_order_check_rejects_invalid_optional_mt5_field_type( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """POST /order/check validates the types of optional MT5 trade-request fields.""" + response = client.post( + "/order/check", + json={ + "request": { + "action": 1, + "symbol": "EURUSD", + "volume": 0.1, + "type": 0, + "price": 1.08500, + "magic": [1, 2, 3], + } + }, + headers=api_headers, + ) + + assert response.status_code == 422 + mock_mt5_client.order_check_as_dict.assert_not_called() + + def test_post_symbol_select_returns_json( client: TestClient, api_headers: dict[str, str], @@ -139,6 +218,25 @@ def test_post_symbol_select_returns_json( ) +def test_post_symbol_select_returns_parquet( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """POST /symbols/{symbol}/select supports Parquet output.""" + response = client.post( + "/symbols/EURUSD/select?format=parquet", + headers=api_headers, + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/parquet") + mock_mt5_client.symbol_select.assert_called_with( + symbol="EURUSD", + enable=True, + ) + + def test_post_symbol_select_with_disable( client: TestClient, api_headers: dict[str, str], @@ -161,6 +259,27 @@ def test_post_symbol_select_with_disable( ) +def test_post_symbol_select_returns_false_when_mt5_rejects( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """POST /symbols/{symbol}/select exposes MT5 selection failures.""" + mock_mt5_client.symbol_select.return_value = False + + response = client.post( + "/symbols/EURUSD/select", + headers=api_headers, + ) + + assert response.status_code == 200 + assert response.json()["data"]["success"] is False + mock_mt5_client.symbol_select.assert_called_with( + symbol="EURUSD", + enable=True, + ) + + def test_post_market_book_subscribe_returns_json( client: TestClient, api_headers: dict[str, str], @@ -185,6 +304,22 @@ def test_post_market_book_subscribe_returns_json( mock_mt5_client.market_book_add.assert_called_with(symbol="EURUSD") +def test_post_market_book_subscribe_returns_parquet( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """POST /market-book/{symbol}/subscribe supports Parquet output.""" + response = client.post( + "/market-book/EURUSD/subscribe?format=parquet", + headers=api_headers, + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/parquet") + mock_mt5_client.market_book_add.assert_called_with(symbol="EURUSD") + + def test_post_market_book_subscribe_validates_symbol_length( client: TestClient, api_headers: dict[str, str], @@ -198,6 +333,46 @@ def test_post_market_book_subscribe_validates_symbol_length( assert response.status_code == 422 +def test_post_market_book_subscribe_rejects_when_subscription_limit_exceeded( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """New subscriptions should be rejected once the tracked limit is reached.""" + test_app = cast("FastAPI", client.app) + test_app.state.active_market_book_subscriptions = {"EURUSD"} + test_app.state.max_market_book_subscriptions = 1 + + response = client.post( + "/market-book/USDJPY/subscribe", + headers=api_headers, + ) + + assert response.status_code == 429 + assert response.json()["title"] == "Subscription Limit Exceeded" + assert test_app.state.active_market_book_subscriptions == {"EURUSD"} + mock_mt5_client.market_book_add.assert_not_called() + + +def test_post_market_book_subscribe_allows_existing_symbol_at_limit( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """Re-subscribing an already tracked symbol should not be rejected by the cap.""" + test_app = cast("FastAPI", client.app) + test_app.state.active_market_book_subscriptions = {"EURUSD"} + test_app.state.max_market_book_subscriptions = 1 + + response = client.post( + "/market-book/EURUSD/subscribe", + headers=api_headers, + ) + + assert response.status_code == 200 + mock_mt5_client.market_book_add.assert_called_with(symbol="EURUSD") + + def test_post_market_book_subscribe_does_not_track_failed_subscription( client: TestClient, api_headers: dict[str, str], @@ -244,6 +419,26 @@ def test_post_market_book_unsubscribe_returns_json( mock_mt5_client.market_book_release.assert_called_with(symbol="EURUSD") +def test_post_market_book_unsubscribe_returns_parquet( + client: TestClient, + api_headers: dict[str, str], + mock_mt5_client: Mock, +) -> None: + """POST /market-book/{symbol}/unsubscribe supports Parquet output.""" + test_app = cast("FastAPI", client.app) + test_app.state.active_market_book_subscriptions = {"EURUSD"} + test_app.state.market_book_cleanup_client = mock_mt5_client + + response = client.post( + "/market-book/EURUSD/unsubscribe?format=parquet", + headers=api_headers, + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/parquet") + mock_mt5_client.market_book_release.assert_called_with(symbol="EURUSD") + + def test_post_market_book_unsubscribe_validates_symbol_length( client: TestClient, api_headers: dict[str, str],