mt5api 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mt5api/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """FastAPI-based REST API for MetaTrader 5 data access."""
2
+
3
+ from __future__ import annotations
mt5api/__main__.py ADDED
@@ -0,0 +1,71 @@
1
+ """Module entrypoint for running the MT5 REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+
8
+ import uvicorn
9
+
10
+ _DEFAULT_HOST = "0.0.0.0" # noqa: S104
11
+ _DEFAULT_PORT = 8000
12
+ _DEFAULT_LOG_LEVEL = "INFO"
13
+ _MAX_PORT = 65535
14
+
15
+
16
+ def _get_host() -> str:
17
+ """Get API host from environment.
18
+
19
+ Returns:
20
+ Host address to bind the API server to.
21
+ """
22
+ return os.getenv("API_HOST", _DEFAULT_HOST)
23
+
24
+
25
+ def _get_port() -> int:
26
+ """Get API port from environment.
27
+
28
+ Returns:
29
+ Port number for the API server.
30
+ """
31
+ raw_port = os.getenv("API_PORT")
32
+ if raw_port is None:
33
+ return _DEFAULT_PORT
34
+
35
+ try:
36
+ port_value = int(raw_port)
37
+ except ValueError:
38
+ return _DEFAULT_PORT
39
+
40
+ if not 1 <= port_value <= _MAX_PORT:
41
+ return _DEFAULT_PORT
42
+
43
+ return port_value
44
+
45
+
46
+ def _get_log_level() -> str:
47
+ """Get log level from environment.
48
+
49
+ Returns:
50
+ Log level string for uvicorn.
51
+ """
52
+ return os.getenv("API_LOG_LEVEL", _DEFAULT_LOG_LEVEL).lower()
53
+
54
+
55
+ def main() -> None:
56
+ """Run the MT5 REST API with uvicorn."""
57
+ host = _get_host()
58
+ port = _get_port()
59
+ log_level = _get_log_level()
60
+
61
+ logging.getLogger(__name__).info("Starting MT5 REST API on %s:%s", host, port)
62
+ uvicorn.run(
63
+ "mt5api.main:app",
64
+ host=host,
65
+ port=port,
66
+ log_level=log_level,
67
+ )
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
mt5api/auth.py ADDED
@@ -0,0 +1,74 @@
1
+ """API key authentication for REST API endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Annotated
7
+
8
+ from fastapi import HTTPException, Security, status
9
+ from fastapi.security import APIKeyHeader
10
+
11
+ # API key security scheme
12
+ api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
13
+ # Auth mode is fixed for the lifetime of the process.
14
+ _API_KEY: str | None = os.getenv("MT5_API_KEY") or None # treat empty string as unset
15
+
16
+
17
+ def get_api_key() -> str | None:
18
+ """Get the API key configured at startup, if any.
19
+
20
+ Returns:
21
+ API key string from ``MT5_API_KEY``, or ``None`` if authentication is
22
+ disabled.
23
+ """
24
+ return _API_KEY
25
+
26
+
27
+ def is_auth_enabled() -> bool:
28
+ """Return whether API key authentication is enabled."""
29
+ return _API_KEY is not None
30
+
31
+
32
+ def verify_api_key(
33
+ api_key_header_value: Annotated[str | None, Security(api_key_header)],
34
+ ) -> str | None:
35
+ """Verify API key from request header.
36
+
37
+ Args:
38
+ api_key_header_value: API key from X-API-Key header.
39
+
40
+ Returns:
41
+ Verified API key when authentication is enabled, otherwise ``None``.
42
+
43
+ Raises:
44
+ HTTPException: 401 if API key is missing or invalid.
45
+ """
46
+ expected_key = get_api_key()
47
+ if expected_key is None:
48
+ return None
49
+
50
+ if not api_key_header_value:
51
+ raise HTTPException(
52
+ status_code=status.HTTP_401_UNAUTHORIZED,
53
+ detail={
54
+ "type": "/errors/unauthorized",
55
+ "title": "Authentication Required",
56
+ "status": 401,
57
+ "detail": "Missing API key. Provide X-API-Key header.",
58
+ "instance": None,
59
+ },
60
+ )
61
+
62
+ if api_key_header_value != expected_key:
63
+ raise HTTPException(
64
+ status_code=status.HTTP_401_UNAUTHORIZED,
65
+ detail={
66
+ "type": "/errors/unauthorized",
67
+ "title": "Authentication Failed",
68
+ "status": 401,
69
+ "detail": "Invalid API key.",
70
+ "instance": None,
71
+ },
72
+ )
73
+
74
+ return api_key_header_value
mt5api/dependencies.py ADDED
@@ -0,0 +1,132 @@
1
+ """FastAPI dependency injection for MT5 client and format negotiation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import TYPE_CHECKING, Annotated, Any, TypeVar
7
+
8
+ from fastapi import Header, Query, Request
9
+ from pdmt5.dataframe import Mt5Config, Mt5DataClient
10
+
11
+ from .models import ResponseFormat
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable
15
+
16
+ # Type variable for return types
17
+ T = TypeVar("T")
18
+
19
+ # Global singleton instance
20
+ _mt5_client: Mt5DataClient | None = None
21
+
22
+
23
+ def get_mt5_client() -> Mt5DataClient:
24
+ """Get or create Mt5DataClient singleton instance.
25
+
26
+ This dependency provides a single shared MT5 client instance across all
27
+ requests to avoid multiple terminal connections.
28
+
29
+ Returns:
30
+ Mt5DataClient: Singleton MT5 client instance.
31
+
32
+ Raises:
33
+ RuntimeError: If MT5 client cannot be initialized.
34
+ """
35
+ global _mt5_client # noqa: PLW0603
36
+
37
+ if _mt5_client is None:
38
+ config = Mt5Config()
39
+ _mt5_client = Mt5DataClient(config=config)
40
+ try:
41
+ _mt5_client.initialize_and_login_mt5()
42
+ except Exception as e:
43
+ _mt5_client = None
44
+ error_message = f"Failed to initialize MT5 client: {e!s}"
45
+ raise RuntimeError(error_message) from e
46
+
47
+ return _mt5_client
48
+
49
+
50
+ def shutdown_mt5_client() -> None:
51
+ """Shutdown and cleanup MT5 client singleton.
52
+
53
+ This should be called during application shutdown to properly
54
+ close the MT5 connection.
55
+ """
56
+ global _mt5_client # noqa: PLW0603
57
+
58
+ if _mt5_client is not None:
59
+ _mt5_client.shutdown()
60
+ _mt5_client = None
61
+
62
+
63
+ async def run_in_threadpool(
64
+ func: Callable[..., T],
65
+ *args: Any, # noqa: ANN401
66
+ **kwargs: Any, # noqa: ANN401
67
+ ) -> T:
68
+ """Run synchronous MT5 function in thread pool.
69
+
70
+ MT5 API calls are synchronous and blocking. This wrapper runs them
71
+ in a thread pool to avoid blocking the async event loop.
72
+
73
+ Args:
74
+ func: Synchronous function to run.
75
+ *args: Positional arguments for the function.
76
+ **kwargs: Keyword arguments for the function.
77
+
78
+ Returns:
79
+ The function's return value.
80
+ """
81
+ return await asyncio.to_thread(func, *args, **kwargs)
82
+
83
+
84
+ def get_response_format(
85
+ accept: Annotated[str | None, Header()] = None,
86
+ format_param: Annotated[ResponseFormat | None, Query(alias="format")] = None,
87
+ ) -> ResponseFormat:
88
+ """Determine response format from Accept header or query parameter.
89
+
90
+ Priority:
91
+ 1. Query parameter (?format=json or ?format=parquet)
92
+ 2. Accept header (application/json or application/parquet)
93
+ 3. Default to JSON
94
+
95
+ Args:
96
+ accept: Accept header from request.
97
+ format_param: Format query parameter.
98
+
99
+ Returns:
100
+ ResponseFormat: Negotiated response format.
101
+ """
102
+ # Query parameter takes priority
103
+ if format_param is not None:
104
+ return format_param
105
+
106
+ # Check Accept header
107
+ if accept:
108
+ accept_lower = accept.lower()
109
+ if "application/parquet" in accept_lower:
110
+ return ResponseFormat.PARQUET
111
+ if "application/json" in accept_lower:
112
+ return ResponseFormat.JSON
113
+
114
+ # Default to JSON
115
+ return ResponseFormat.JSON
116
+
117
+
118
+ def get_request_info(request: Request) -> dict[str, Any]:
119
+ """Extract request information for logging.
120
+
121
+ Args:
122
+ request: FastAPI request object.
123
+
124
+ Returns:
125
+ Dictionary with request details.
126
+ """
127
+ return {
128
+ "method": request.method,
129
+ "url": str(request.url),
130
+ "client": request.client.host if request.client else None,
131
+ "user_agent": request.headers.get("user-agent"),
132
+ }
mt5api/formatters.py ADDED
@@ -0,0 +1,159 @@
1
+ """Response formatters for JSON and Apache Parquet formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ from typing import TYPE_CHECKING, Any, overload
7
+
8
+ import pandas as pd
9
+ import pyarrow as pa # type: ignore[import-untyped]
10
+ import pyarrow.parquet as pq # type: ignore[import-untyped]
11
+ from fastapi.responses import Response, StreamingResponse
12
+
13
+ from .models import DataResponse, ResponseFormat
14
+
15
+ _SUPPORTED_RESPONSE_FORMATS = {ResponseFormat.JSON, ResponseFormat.PARQUET}
16
+ _INVALID_DATA_MESSAGE = "data must be a pandas DataFrame or dict[str, Any]"
17
+
18
+ if TYPE_CHECKING: # pragma: no cover
19
+
20
+ @overload
21
+ def format_response(
22
+ data: pd.DataFrame,
23
+ response_format: ResponseFormat,
24
+ ) -> DataResponse | Response: ...
25
+
26
+ @overload
27
+ def format_response(
28
+ data: dict[str, Any],
29
+ response_format: ResponseFormat,
30
+ ) -> DataResponse | Response: ...
31
+
32
+
33
+ def format_dataframe_to_json(
34
+ dataframe: pd.DataFrame,
35
+ *,
36
+ orient: str = "records",
37
+ ) -> DataResponse:
38
+ """Format DataFrame as JSON response.
39
+
40
+ Args:
41
+ dataframe: DataFrame to format.
42
+ orient: JSON orientation (records, index, columns, etc.).
43
+
44
+ Returns:
45
+ DataResponse model with JSON data.
46
+ """
47
+ # Convert DataFrame to dict/list based on orientation
48
+ if orient == "records":
49
+ data_value: list[dict[str, Any]] | dict[str, Any] = dataframe.to_dict(
50
+ orient=orient # type: ignore[arg-type]
51
+ )
52
+ else:
53
+ data_value = dataframe.to_dict(orient=orient) # type: ignore[arg-type]
54
+
55
+ return DataResponse(
56
+ data=data_value,
57
+ count=len(dataframe),
58
+ format=ResponseFormat.JSON,
59
+ )
60
+
61
+
62
+ def format_dict_to_json(data: dict[str, Any]) -> DataResponse:
63
+ """Format dictionary as JSON response.
64
+
65
+ Args:
66
+ data: Dictionary to format.
67
+
68
+ Returns:
69
+ DataResponse model with JSON data.
70
+ """
71
+ return DataResponse(
72
+ data=data,
73
+ count=1,
74
+ format=ResponseFormat.JSON,
75
+ )
76
+
77
+
78
+ def format_dataframe_to_parquet(dataframe: pd.DataFrame) -> Response:
79
+ """Format DataFrame as Apache Parquet response.
80
+
81
+ Args:
82
+ dataframe: DataFrame to format.
83
+
84
+ Returns:
85
+ StreamingResponse with Parquet binary data.
86
+ """
87
+ # Convert DataFrame to Arrow Table
88
+ table = pa.Table.from_pandas(dataframe, preserve_index=False)
89
+
90
+ # Write to in-memory buffer
91
+ buffer = io.BytesIO()
92
+ pq.write_table(
93
+ table,
94
+ buffer,
95
+ compression="snappy",
96
+ use_dictionary=True,
97
+ write_statistics=True,
98
+ )
99
+ buffer.seek(0)
100
+
101
+ return StreamingResponse(
102
+ content=iter([buffer.getvalue()]),
103
+ media_type="application/parquet",
104
+ headers={
105
+ "Content-Disposition": "attachment; filename=data.parquet",
106
+ },
107
+ )
108
+
109
+
110
+ def format_dict_to_parquet(data: dict[str, Any]) -> Response:
111
+ """Format dictionary as Apache Parquet response.
112
+
113
+ Args:
114
+ data: Dictionary to format (will be converted to single-row DataFrame).
115
+
116
+ Returns:
117
+ StreamingResponse with Parquet binary data.
118
+ """
119
+ # Convert dict to single-row DataFrame
120
+ dataframe = pd.DataFrame([data])
121
+ return format_dataframe_to_parquet(dataframe)
122
+
123
+
124
+ def format_response(
125
+ data: object,
126
+ response_format: ResponseFormat,
127
+ ) -> DataResponse | Response:
128
+ """Format data based on requested response format.
129
+
130
+ Unified formatter that handles both DataFrame and dict data types,
131
+ selecting the appropriate output format (JSON or Parquet).
132
+
133
+ Args:
134
+ data: Data to format. Must be a pandas DataFrame or dict with string
135
+ keys.
136
+ response_format: Requested response format (JSON or Parquet).
137
+
138
+ Returns:
139
+ DataResponse for JSON format, StreamingResponse for Parquet format.
140
+
141
+ Raises:
142
+ TypeError: If the data type is neither DataFrame nor dict.
143
+ ValueError: If the response format is not supported.
144
+ """
145
+ if response_format not in _SUPPORTED_RESPONSE_FORMATS:
146
+ message = f"Unsupported response format: {response_format}"
147
+ raise ValueError(message)
148
+
149
+ if isinstance(data, pd.DataFrame):
150
+ if response_format == ResponseFormat.PARQUET:
151
+ return format_dataframe_to_parquet(data)
152
+ return format_dataframe_to_json(data)
153
+
154
+ if isinstance(data, dict):
155
+ if response_format == ResponseFormat.PARQUET:
156
+ return format_dict_to_parquet(data)
157
+ return format_dict_to_json(data)
158
+
159
+ raise TypeError(_INVALID_DATA_MESSAGE)
mt5api/main.py ADDED
@@ -0,0 +1,183 @@
1
+ """FastAPI application instance and lifecycle management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ from contextlib import asynccontextmanager
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from fastapi import FastAPI
13
+ from fastapi.openapi.utils import get_openapi
14
+ from starlette.middleware.cors import CORSMiddleware
15
+
16
+ from .auth import is_auth_enabled
17
+ from .dependencies import shutdown_mt5_client
18
+ from .middleware import add_middleware
19
+ from .routers import account, health, history, market, symbols
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import AsyncGenerator
23
+
24
+
25
+ class _JsonFormatter(logging.Formatter):
26
+ """JSON formatter for structured logs."""
27
+
28
+ def format(self, record: logging.LogRecord) -> str:
29
+ payload = {
30
+ "timestamp": self.formatTime(record, self.datefmt),
31
+ "level": record.levelname,
32
+ "logger": record.name,
33
+ "message": record.getMessage(),
34
+ }
35
+
36
+ if record.exc_info:
37
+ payload["exception"] = self.formatException(record.exc_info)
38
+
39
+ return json.dumps(payload)
40
+
41
+
42
+ def _configure_logging() -> None:
43
+ """Configure structured logging for the API."""
44
+ log_level = os.getenv("API_LOG_LEVEL", "INFO").upper()
45
+
46
+ handler = logging.StreamHandler()
47
+ handler.setFormatter(_JsonFormatter())
48
+
49
+ root_logger = logging.getLogger()
50
+ root_logger.handlers.clear()
51
+ root_logger.setLevel(log_level)
52
+ root_logger.addHandler(handler)
53
+
54
+
55
+ def _get_cors_origins() -> list[str]:
56
+ """Get CORS origins from environment.
57
+
58
+ Returns:
59
+ List of allowed origins.
60
+ """
61
+ raw_origins = os.getenv("API_CORS_ORIGINS", "*")
62
+ if raw_origins.strip() == "*":
63
+ return ["*"]
64
+
65
+ return [origin.strip() for origin in raw_origins.split(",") if origin.strip()]
66
+
67
+
68
+ def _strip_auth_from_openapi(openapi_schema: dict[str, Any]) -> None:
69
+ """Remove API key requirements from OpenAPI when auth is disabled."""
70
+ openapi_schema.pop("security", None)
71
+
72
+ components = openapi_schema.get("components")
73
+ if isinstance(components, dict):
74
+ security_schemes = components.get("securitySchemes")
75
+ if isinstance(security_schemes, dict):
76
+ security_schemes.pop("APIKeyHeader", None)
77
+ if not security_schemes:
78
+ components.pop("securitySchemes", None)
79
+
80
+ for methods in openapi_schema.get("paths", {}).values():
81
+ if not isinstance(methods, dict):
82
+ continue
83
+ for operation in methods.values():
84
+ if isinstance(operation, dict):
85
+ operation.pop("security", None)
86
+
87
+
88
+ def _build_openapi_schema(app: FastAPI) -> dict[str, Any]:
89
+ """Build OpenAPI schema for the current authentication mode.
90
+
91
+ Returns:
92
+ OpenAPI schema for the current auth configuration.
93
+ """
94
+ if app.openapi_schema:
95
+ return app.openapi_schema
96
+
97
+ openapi_schema = get_openapi(
98
+ title=app.title,
99
+ version=app.version,
100
+ description=app.description,
101
+ routes=app.routes,
102
+ )
103
+ if not is_auth_enabled():
104
+ _strip_auth_from_openapi(openapi_schema)
105
+
106
+ app.openapi_schema = openapi_schema
107
+ return openapi_schema
108
+
109
+
110
+ def _custom_openapi() -> dict[str, Any]:
111
+ """Build OpenAPI schema for the current application instance.
112
+
113
+ Returns:
114
+ OpenAPI schema for the application.
115
+ """
116
+ return _build_openapi_schema(app)
117
+
118
+
119
+ _configure_logging()
120
+ logger = logging.getLogger(__name__)
121
+
122
+
123
+ @asynccontextmanager
124
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # noqa: ARG001
125
+ """Manage application lifespan (startup and shutdown).
126
+
127
+ Args:
128
+ app: FastAPI application instance (unused but required by FastAPI).
129
+
130
+ Yields:
131
+ None
132
+ """
133
+ # Startup
134
+ logger.info("Starting MT5 REST API...")
135
+
136
+ # Note: MT5 client is initialized lazily on first request via dependency
137
+ # This avoids blocking startup if MT5 is not available
138
+ await asyncio.sleep(0) # Make function truly async
139
+
140
+ yield
141
+
142
+ # Shutdown
143
+ logger.info("Shutting down MT5 REST API...")
144
+ shutdown_mt5_client()
145
+ logger.info("MT5 connection closed")
146
+
147
+
148
+ # Create FastAPI application
149
+ app = FastAPI(
150
+ title="MT5 REST API",
151
+ description=(
152
+ "REST API for MetaTrader 5 data access. "
153
+ "Provides read-only access to market data, "
154
+ "account information, and trading history via HTTP endpoints."
155
+ ),
156
+ version="1.0.0",
157
+ lifespan=lifespan,
158
+ docs_url="/docs",
159
+ redoc_url="/redoc",
160
+ openapi_url="/openapi.json",
161
+ )
162
+ app.openapi = _custom_openapi
163
+
164
+ # Add CORS middleware
165
+ app.add_middleware(
166
+ CORSMiddleware,
167
+ allow_origins=_get_cors_origins(),
168
+ allow_credentials=True,
169
+ allow_methods=["*"],
170
+ allow_headers=["*"],
171
+ )
172
+
173
+ # Add middleware
174
+ add_middleware(app)
175
+
176
+ # Include routers
177
+ app.include_router(health.router)
178
+ app.include_router(symbols.router)
179
+ app.include_router(market.router)
180
+ app.include_router(account.router)
181
+ app.include_router(history.router)
182
+
183
+ logger.info("MT5 REST API initialized")