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 +3 -0
- mt5api/__main__.py +71 -0
- mt5api/auth.py +74 -0
- mt5api/dependencies.py +132 -0
- mt5api/formatters.py +159 -0
- mt5api/main.py +183 -0
- mt5api/middleware.py +224 -0
- mt5api/models.py +408 -0
- mt5api/routers/__init__.py +7 -0
- mt5api/routers/account.py +72 -0
- mt5api/routers/health.py +87 -0
- mt5api/routers/history.py +139 -0
- mt5api/routers/market.py +188 -0
- mt5api/routers/symbols.py +101 -0
- mt5api-0.0.1.dist-info/METADATA +109 -0
- mt5api-0.0.1.dist-info/RECORD +18 -0
- mt5api-0.0.1.dist-info/WHEEL +4 -0
- mt5api-0.0.1.dist-info/licenses/LICENSE +21 -0
mt5api/__init__.py
ADDED
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")
|