mt5api 1.0.3__tar.gz → 1.0.5__tar.gz
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-1.0.3 → mt5api-1.0.5}/PKG-INFO +1 -2
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/constants.py +1 -3
- mt5api-1.0.5/mt5api/dependencies.py +210 -0
- mt5api-1.0.5/mt5api/formatters.py +163 -0
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/main.py +100 -58
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/middleware.py +5 -4
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/connection.py +9 -16
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/health.py +6 -3
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/trading.py +32 -63
- {mt5api-1.0.3 → mt5api-1.0.5}/pyproject.toml +6 -8
- mt5api-1.0.3/.agents/skills/local-qa/SKILL.md +0 -22
- mt5api-1.0.3/.agents/skills/local-qa/scripts/qa.sh +0 -31
- mt5api-1.0.3/.agents/skills/mt5api/SKILL.md +0 -396
- mt5api-1.0.3/.github/CODEOWNERS +0 -2
- mt5api-1.0.3/.github/FUNDING.yml +0 -3
- mt5api-1.0.3/.github/dependabot.yml +0 -17
- mt5api-1.0.3/.github/renovate.json +0 -13
- mt5api-1.0.3/.github/workflows/ci.yml +0 -92
- mt5api-1.0.3/.github/workflows/claude.yml +0 -57
- mt5api-1.0.3/.github/workflows/release.yml +0 -44
- mt5api-1.0.3/AGENTS.md +0 -38
- mt5api-1.0.3/CLAUDE.md +0 -1
- mt5api-1.0.3/docs/api/deployment.md +0 -62
- mt5api-1.0.3/docs/api/index.md +0 -69
- mt5api-1.0.3/docs/api/rest-api.md +0 -333
- mt5api-1.0.3/docs/index.md +0 -75
- mt5api-1.0.3/mkdocs.yml +0 -72
- mt5api-1.0.3/mt5api/dependencies.py +0 -229
- mt5api-1.0.3/mt5api/formatters.py +0 -159
- mt5api-1.0.3/tests/__init__.py +0 -1
- mt5api-1.0.3/tests/conftest.py +0 -295
- mt5api-1.0.3/tests/mt5_constants.py +0 -75
- mt5api-1.0.3/tests/openapi_mt5_constants.py +0 -34
- mt5api-1.0.3/tests/test_account.py +0 -78
- mt5api-1.0.3/tests/test_auth.py +0 -177
- mt5api-1.0.3/tests/test_calc.py +0 -312
- mt5api-1.0.3/tests/test_cli.py +0 -127
- mt5api-1.0.3/tests/test_config.py +0 -213
- mt5api-1.0.3/tests/test_connection.py +0 -494
- mt5api-1.0.3/tests/test_dependencies.py +0 -157
- mt5api-1.0.3/tests/test_formatters.py +0 -184
- mt5api-1.0.3/tests/test_health.py +0 -205
- mt5api-1.0.3/tests/test_history.py +0 -394
- mt5api-1.0.3/tests/test_lifespan.py +0 -30
- mt5api-1.0.3/tests/test_main.py +0 -179
- mt5api-1.0.3/tests/test_market.py +0 -493
- mt5api-1.0.3/tests/test_middleware.py +0 -221
- mt5api-1.0.3/tests/test_models.py +0 -295
- mt5api-1.0.3/tests/test_symbols.py +0 -160
- mt5api-1.0.3/tests/test_trading.py +0 -537
- mt5api-1.0.3/uv.lock +0 -1712
- {mt5api-1.0.3 → mt5api-1.0.5}/.gitignore +0 -0
- {mt5api-1.0.3 → mt5api-1.0.5}/LICENSE +0 -0
- {mt5api-1.0.3 → mt5api-1.0.5}/README.md +0 -0
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/__init__.py +0 -0
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/__main__.py +0 -0
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/auth.py +0 -0
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/config.py +0 -0
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/models.py +0 -0
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/__init__.py +0 -0
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/account.py +0 -0
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/calc.py +0 -0
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/history.py +0 -0
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/market.py +0 -0
- {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/symbols.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mt5api
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.5
|
|
4
4
|
Summary: MetaTrader 5 REST API
|
|
5
5
|
Project-URL: Repository, https://github.com/dceoy/mt5api.git
|
|
6
6
|
Author-email: dceoy <dceoy@users.noreply.github.com>
|
|
@@ -21,7 +21,6 @@ Requires-Dist: pandas>=3.0.3
|
|
|
21
21
|
Requires-Dist: pdmt5>=1.2.0
|
|
22
22
|
Requires-Dist: pyarrow>=18.0.0
|
|
23
23
|
Requires-Dist: pydantic>=2.13.4
|
|
24
|
-
Requires-Dist: python-multipart>=0.0.31
|
|
25
24
|
Requires-Dist: uvicorn[standard]>=0.32.0
|
|
26
25
|
Description-Content-Type: text/markdown
|
|
27
26
|
|
|
@@ -15,9 +15,7 @@ API_OPENAPI_URL = "/openapi.json"
|
|
|
15
15
|
API_APP_IMPORT = "mt5api.main:app"
|
|
16
16
|
API_KEY_HEADER_NAME = "X-API-Key"
|
|
17
17
|
API_KEY_SECURITY_SCHEME_NAME = "APIKeyHeader"
|
|
18
|
-
|
|
19
|
-
MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY = "market_book_cleanup_client"
|
|
20
|
-
MAX_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY = "max_market_book_subscriptions"
|
|
18
|
+
MT5_RUNTIME_STATE_KEY = "mt5_runtime"
|
|
21
19
|
|
|
22
20
|
ENV_MT5API_HOST = "MT5API_HOST"
|
|
23
21
|
ENV_MT5API_PORT = "MT5API_PORT"
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""FastAPI dependency injection for MT5 client and format negotiation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING, Annotated, Any, TypeVar
|
|
9
|
+
|
|
10
|
+
from fastapi import Header, Query, Request
|
|
11
|
+
from pdmt5.dataframe import Mt5Config, Mt5DataClient
|
|
12
|
+
|
|
13
|
+
from .config import get_configured_max_market_book_subscriptions
|
|
14
|
+
from .constants import MT5_RUNTIME_STATE_KEY
|
|
15
|
+
from .models import ResponseFormat
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
from fastapi import FastAPI
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class Mt5RuntimeState:
|
|
28
|
+
"""Application-scoped owner for MT5 connection and subscription state."""
|
|
29
|
+
|
|
30
|
+
client: Mt5DataClient | None = None
|
|
31
|
+
client_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
32
|
+
market_book_subscriptions: set[str] = field(default_factory=set[str])
|
|
33
|
+
market_book_cleanup_client: Mt5DataClient | None = None
|
|
34
|
+
max_market_book_subscriptions: int = 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def initialize_mt5_runtime_state(
|
|
38
|
+
app: FastAPI,
|
|
39
|
+
*,
|
|
40
|
+
max_market_book_subscriptions: int | None = None,
|
|
41
|
+
) -> Mt5RuntimeState:
|
|
42
|
+
"""Create and install the application-scoped MT5 runtime state.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
app: FastAPI application that owns the state.
|
|
46
|
+
max_market_book_subscriptions: Optional explicit subscription limit.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Newly installed runtime state.
|
|
50
|
+
"""
|
|
51
|
+
state = Mt5RuntimeState(
|
|
52
|
+
max_market_book_subscriptions=(
|
|
53
|
+
get_configured_max_market_book_subscriptions()
|
|
54
|
+
if max_market_book_subscriptions is None
|
|
55
|
+
else max_market_book_subscriptions
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
setattr(app.state, MT5_RUNTIME_STATE_KEY, state)
|
|
59
|
+
return state
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_mt5_runtime_state(request: Request) -> Mt5RuntimeState:
|
|
63
|
+
"""Return the request application's MT5 runtime state.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Existing state or a lazily installed state for direct ASGI usage.
|
|
67
|
+
"""
|
|
68
|
+
state = getattr(request.app.state, MT5_RUNTIME_STATE_KEY, None)
|
|
69
|
+
if isinstance(state, Mt5RuntimeState):
|
|
70
|
+
return state
|
|
71
|
+
return initialize_mt5_runtime_state(request.app)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_mt5_client_lock(request: Request) -> asyncio.Lock:
|
|
75
|
+
"""Return the application-scoped MT5 replacement lock.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Lock guarding client replacement and subscription cleanup.
|
|
79
|
+
"""
|
|
80
|
+
return get_mt5_runtime_state(request).client_lock
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_mt5_client(request: Request) -> Mt5DataClient:
|
|
84
|
+
"""Get or create the application-scoped MT5 client instance.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
request: Request whose application owns the MT5 runtime state.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Shared initialized MT5 data client.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
RuntimeError: If MT5 client initialization fails.
|
|
94
|
+
"""
|
|
95
|
+
state = get_mt5_runtime_state(request)
|
|
96
|
+
if state.client is None:
|
|
97
|
+
client = Mt5DataClient(config=Mt5Config())
|
|
98
|
+
try:
|
|
99
|
+
client.initialize_and_login_mt5()
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
error_message = f"Failed to initialize MT5 client: {exc!s}"
|
|
102
|
+
raise RuntimeError(error_message) from exc
|
|
103
|
+
state.client = client
|
|
104
|
+
return state.client
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def shutdown_mt5_client(state: Mt5RuntimeState) -> None:
|
|
108
|
+
"""Shutdown and clear the application-scoped MT5 client."""
|
|
109
|
+
client = state.client
|
|
110
|
+
if client is None:
|
|
111
|
+
return
|
|
112
|
+
state.client = None
|
|
113
|
+
client.shutdown()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def replace_mt5_client(
|
|
117
|
+
state: Mt5RuntimeState,
|
|
118
|
+
config: Mt5Config,
|
|
119
|
+
) -> Mt5DataClient:
|
|
120
|
+
"""Replace the application's client after successful initialization.
|
|
121
|
+
|
|
122
|
+
MetaTrader5 connection state is process-global, so the previous wrapper is
|
|
123
|
+
not shut down after success because doing so could close the new session.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Newly initialized and installed client.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
RuntimeError: If the replacement cannot be initialized.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
new_client = Mt5DataClient(config=config)
|
|
133
|
+
await asyncio.to_thread(new_client.initialize_and_login_mt5)
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
logger.exception("Failed to initialize MT5 client")
|
|
136
|
+
error_message = "Failed to initialize MT5 client"
|
|
137
|
+
raise RuntimeError(error_message) from exc
|
|
138
|
+
state.client = new_client
|
|
139
|
+
return new_client
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def release_market_book_subscriptions(state: Mt5RuntimeState) -> None:
|
|
143
|
+
"""Release and clear all market-book subscriptions owned by ``state``."""
|
|
144
|
+
subscriptions = state.market_book_subscriptions
|
|
145
|
+
if not subscriptions:
|
|
146
|
+
state.market_book_cleanup_client = None
|
|
147
|
+
return
|
|
148
|
+
cleanup_client = state.market_book_cleanup_client
|
|
149
|
+
if cleanup_client is None:
|
|
150
|
+
logger.warning("Active market-book subscriptions found without cleanup client")
|
|
151
|
+
subscriptions.clear()
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
def release_subscriptions() -> None:
|
|
155
|
+
for symbol in tuple(subscriptions):
|
|
156
|
+
try:
|
|
157
|
+
cleanup_client.market_book_release(symbol=symbol)
|
|
158
|
+
except Exception:
|
|
159
|
+
logger.exception("Failed to release market book for %s", symbol)
|
|
160
|
+
|
|
161
|
+
await asyncio.to_thread(release_subscriptions)
|
|
162
|
+
subscriptions.clear()
|
|
163
|
+
state.market_book_cleanup_client = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def run_in_threadpool(
|
|
167
|
+
func: Callable[..., T],
|
|
168
|
+
*args: Any, # noqa: ANN401
|
|
169
|
+
**kwargs: Any, # noqa: ANN401
|
|
170
|
+
) -> T:
|
|
171
|
+
"""Run a synchronous MT5 function without blocking the event loop.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Result returned by ``func``.
|
|
175
|
+
"""
|
|
176
|
+
return await asyncio.to_thread(func, *args, **kwargs)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get_response_format(
|
|
180
|
+
accept: Annotated[str | None, Header()] = None,
|
|
181
|
+
format_param: Annotated[ResponseFormat | None, Query(alias="format")] = None,
|
|
182
|
+
) -> ResponseFormat:
|
|
183
|
+
"""Determine response format from query parameter or Accept header.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Selected response format, defaulting to JSON.
|
|
187
|
+
"""
|
|
188
|
+
if format_param is not None:
|
|
189
|
+
return format_param
|
|
190
|
+
if accept:
|
|
191
|
+
accept_lower = accept.lower()
|
|
192
|
+
if "application/parquet" in accept_lower:
|
|
193
|
+
return ResponseFormat.PARQUET
|
|
194
|
+
if "application/json" in accept_lower:
|
|
195
|
+
return ResponseFormat.JSON
|
|
196
|
+
return ResponseFormat.JSON
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_request_info(request: Request) -> dict[str, Any]:
|
|
200
|
+
"""Extract request information for logging.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Request method, URL, client address, and user agent.
|
|
204
|
+
"""
|
|
205
|
+
return {
|
|
206
|
+
"method": request.method,
|
|
207
|
+
"url": str(request.url),
|
|
208
|
+
"client": request.client.host if request.client else None,
|
|
209
|
+
"user_agent": request.headers.get("user-agent"),
|
|
210
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
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, cast, 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
|
|
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
|
+
from typing import Protocol
|
|
20
|
+
|
|
21
|
+
class _PyArrowTable(Protocol):
|
|
22
|
+
"""Structural type for the PyArrow table passed to Parquet output."""
|
|
23
|
+
|
|
24
|
+
class _PyArrowTableFactory(Protocol):
|
|
25
|
+
"""Structural type for the supported PyArrow table factory API."""
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def from_pandas(
|
|
29
|
+
dataframe: pd.DataFrame,
|
|
30
|
+
*,
|
|
31
|
+
preserve_index: bool = True,
|
|
32
|
+
) -> _PyArrowTable: ...
|
|
33
|
+
|
|
34
|
+
class _PyArrowModule(Protocol):
|
|
35
|
+
"""Structural type for the supported PyArrow module API."""
|
|
36
|
+
|
|
37
|
+
Table: _PyArrowTableFactory
|
|
38
|
+
|
|
39
|
+
class _PyArrowParquetModule(Protocol):
|
|
40
|
+
"""Structural type for the supported PyArrow Parquet API."""
|
|
41
|
+
|
|
42
|
+
def write_table(
|
|
43
|
+
self,
|
|
44
|
+
table: _PyArrowTable,
|
|
45
|
+
where: io.BytesIO,
|
|
46
|
+
*,
|
|
47
|
+
compression: str = "snappy",
|
|
48
|
+
use_dictionary: bool = True,
|
|
49
|
+
write_statistics: bool = True,
|
|
50
|
+
) -> None: ...
|
|
51
|
+
|
|
52
|
+
@overload
|
|
53
|
+
def format_response(
|
|
54
|
+
data: pd.DataFrame,
|
|
55
|
+
response_format: ResponseFormat,
|
|
56
|
+
) -> DataResponse | Response: ...
|
|
57
|
+
|
|
58
|
+
@overload
|
|
59
|
+
def format_response(
|
|
60
|
+
data: dict[str, Any],
|
|
61
|
+
response_format: ResponseFormat,
|
|
62
|
+
) -> DataResponse | Response: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def format_dataframe_to_json(
|
|
66
|
+
dataframe: pd.DataFrame,
|
|
67
|
+
*,
|
|
68
|
+
orient: str = "records",
|
|
69
|
+
) -> DataResponse:
|
|
70
|
+
"""Format DataFrame as JSON response.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
JSON data response.
|
|
74
|
+
"""
|
|
75
|
+
if orient == "records":
|
|
76
|
+
data_value: list[dict[str, Any]] | dict[str, Any] = cast(
|
|
77
|
+
"list[dict[str, Any]] | dict[str, Any]",
|
|
78
|
+
dataframe.to_dict(orient=orient), # type: ignore[arg-type]
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
data_value = cast(
|
|
82
|
+
"list[dict[str, Any]] | dict[str, Any]",
|
|
83
|
+
dataframe.to_dict(orient=orient), # type: ignore[arg-type]
|
|
84
|
+
)
|
|
85
|
+
return DataResponse(
|
|
86
|
+
data=data_value, count=len(dataframe), format=ResponseFormat.JSON
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def format_dict_to_json(data: dict[str, Any]) -> DataResponse:
|
|
91
|
+
"""Format dictionary as JSON response.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Single-record JSON data response.
|
|
95
|
+
"""
|
|
96
|
+
return DataResponse(data=data, count=1, format=ResponseFormat.JSON)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def format_dataframe_to_parquet(dataframe: pd.DataFrame) -> Response:
|
|
100
|
+
"""Format DataFrame as a materialized Apache Parquet response.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Binary Parquet HTTP response.
|
|
104
|
+
"""
|
|
105
|
+
arrow_module = cast(
|
|
106
|
+
"_PyArrowModule",
|
|
107
|
+
pa, # pyright: ignore[reportUnknownArgumentType]
|
|
108
|
+
)
|
|
109
|
+
parquet_module = cast(
|
|
110
|
+
"_PyArrowParquetModule",
|
|
111
|
+
pq, # pyright: ignore[reportUnknownArgumentType]
|
|
112
|
+
)
|
|
113
|
+
table = arrow_module.Table.from_pandas(dataframe, preserve_index=False)
|
|
114
|
+
buffer = io.BytesIO()
|
|
115
|
+
parquet_module.write_table(
|
|
116
|
+
table,
|
|
117
|
+
buffer,
|
|
118
|
+
compression="snappy",
|
|
119
|
+
use_dictionary=True,
|
|
120
|
+
write_statistics=True,
|
|
121
|
+
)
|
|
122
|
+
return Response(
|
|
123
|
+
content=buffer.getvalue(),
|
|
124
|
+
media_type="application/parquet",
|
|
125
|
+
headers={"Content-Disposition": "attachment; filename=data.parquet"},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def format_dict_to_parquet(data: dict[str, Any]) -> Response:
|
|
130
|
+
"""Format a dictionary as a single-row Apache Parquet response.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Binary Parquet HTTP response.
|
|
134
|
+
"""
|
|
135
|
+
return format_dataframe_to_parquet(pd.DataFrame([data]))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def format_response(
|
|
139
|
+
data: object,
|
|
140
|
+
response_format: ResponseFormat,
|
|
141
|
+
) -> DataResponse | Response:
|
|
142
|
+
"""Format a DataFrame or mapping as JSON or Parquet.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
JSON model or binary Parquet response.
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
ValueError: If the response format is unsupported.
|
|
149
|
+
TypeError: If ``data`` is not a DataFrame or mapping.
|
|
150
|
+
"""
|
|
151
|
+
if response_format not in _SUPPORTED_RESPONSE_FORMATS:
|
|
152
|
+
message = f"Unsupported response format: {response_format}"
|
|
153
|
+
raise ValueError(message)
|
|
154
|
+
if isinstance(data, pd.DataFrame):
|
|
155
|
+
if response_format == ResponseFormat.PARQUET:
|
|
156
|
+
return format_dataframe_to_parquet(data)
|
|
157
|
+
return format_dataframe_to_json(data)
|
|
158
|
+
if isinstance(data, dict):
|
|
159
|
+
data = cast("dict[str, Any]", data)
|
|
160
|
+
if response_format == ResponseFormat.PARQUET:
|
|
161
|
+
return format_dict_to_parquet(data)
|
|
162
|
+
return format_dict_to_json(data)
|
|
163
|
+
raise TypeError(_INVALID_DATA_MESSAGE)
|
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import asyncio
|
|
6
5
|
import json
|
|
7
6
|
import logging
|
|
8
7
|
from contextlib import asynccontextmanager
|
|
9
|
-
from typing import TYPE_CHECKING, Any
|
|
8
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
10
9
|
|
|
11
10
|
from fastapi import FastAPI
|
|
12
11
|
from fastapi.openapi.utils import get_openapi
|
|
@@ -18,7 +17,6 @@ from .config import (
|
|
|
18
17
|
get_configured_python_log_level,
|
|
19
18
|
)
|
|
20
19
|
from .constants import (
|
|
21
|
-
ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY,
|
|
22
20
|
API_DESCRIPTION,
|
|
23
21
|
API_DOCS_URL,
|
|
24
22
|
API_KEY_SECURITY_SCHEME_NAME,
|
|
@@ -26,10 +24,12 @@ from .constants import (
|
|
|
26
24
|
API_REDOC_URL,
|
|
27
25
|
API_TITLE,
|
|
28
26
|
API_VERSION,
|
|
29
|
-
MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY,
|
|
30
|
-
MAX_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY,
|
|
31
27
|
)
|
|
32
|
-
from .dependencies import
|
|
28
|
+
from .dependencies import (
|
|
29
|
+
initialize_mt5_runtime_state,
|
|
30
|
+
release_market_book_subscriptions,
|
|
31
|
+
shutdown_mt5_client,
|
|
32
|
+
)
|
|
33
33
|
from .middleware import add_middleware
|
|
34
34
|
from .models import ErrorResponse
|
|
35
35
|
from .routers import (
|
|
@@ -44,7 +44,14 @@ from .routers import (
|
|
|
44
44
|
)
|
|
45
45
|
|
|
46
46
|
if TYPE_CHECKING:
|
|
47
|
-
from collections.abc import AsyncGenerator
|
|
47
|
+
from collections.abc import AsyncGenerator, Iterator
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _as_dict(value: object) -> dict[str, Any] | None:
|
|
51
|
+
"""Return a typed dictionary after checking a dynamic OpenAPI value."""
|
|
52
|
+
if not isinstance(value, dict):
|
|
53
|
+
return None
|
|
54
|
+
return cast("dict[str, Any]", value)
|
|
48
55
|
|
|
49
56
|
|
|
50
57
|
class _JsonFormatter(logging.Formatter):
|
|
@@ -57,52 +64,70 @@ class _JsonFormatter(logging.Formatter):
|
|
|
57
64
|
"logger": record.name,
|
|
58
65
|
"message": record.getMessage(),
|
|
59
66
|
}
|
|
60
|
-
|
|
61
67
|
if record.exc_info:
|
|
62
68
|
payload["exception"] = self.formatException(record.exc_info)
|
|
63
|
-
|
|
64
69
|
return json.dumps(payload)
|
|
65
70
|
|
|
66
71
|
|
|
67
72
|
def _configure_logging() -> None:
|
|
68
73
|
"""Configure structured logging for the API."""
|
|
69
74
|
log_level = get_configured_python_log_level()
|
|
70
|
-
|
|
71
75
|
handler = logging.StreamHandler()
|
|
72
76
|
handler.setFormatter(_JsonFormatter())
|
|
73
|
-
|
|
74
77
|
root_logger = logging.getLogger()
|
|
75
78
|
root_logger.handlers.clear()
|
|
76
79
|
root_logger.setLevel(log_level)
|
|
77
80
|
root_logger.addHandler(handler)
|
|
78
81
|
|
|
79
82
|
|
|
83
|
+
def _iter_openapi_operations(
|
|
84
|
+
openapi_schema: dict[str, Any],
|
|
85
|
+
) -> Iterator[dict[str, Any]]:
|
|
86
|
+
"""Yield operation mappings from an OpenAPI schema.
|
|
87
|
+
|
|
88
|
+
Yields:
|
|
89
|
+
Individual HTTP operation mappings.
|
|
90
|
+
"""
|
|
91
|
+
paths = _as_dict(openapi_schema.get("paths", {}))
|
|
92
|
+
if paths is None:
|
|
93
|
+
return
|
|
94
|
+
for methods_value in paths.values():
|
|
95
|
+
methods = _as_dict(methods_value)
|
|
96
|
+
if methods is None:
|
|
97
|
+
continue
|
|
98
|
+
for operation_value in methods.values():
|
|
99
|
+
operation = _as_dict(operation_value)
|
|
100
|
+
if operation is not None:
|
|
101
|
+
yield operation
|
|
102
|
+
|
|
103
|
+
|
|
80
104
|
def _strip_auth_from_openapi(openapi_schema: dict[str, Any]) -> None:
|
|
81
105
|
"""Remove API key requirements from OpenAPI when auth is disabled."""
|
|
82
106
|
openapi_schema.pop("security", None)
|
|
83
|
-
|
|
84
|
-
components
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
for
|
|
91
|
-
|
|
92
|
-
if isinstance(operation, dict):
|
|
93
|
-
operation.pop("security", None)
|
|
107
|
+
components = _as_dict(openapi_schema.get("components", {}))
|
|
108
|
+
if components is not None:
|
|
109
|
+
security_schemes = _as_dict(components.get("securitySchemes", {}))
|
|
110
|
+
if security_schemes is not None:
|
|
111
|
+
security_schemes.pop(API_KEY_SECURITY_SCHEME_NAME, None)
|
|
112
|
+
if not security_schemes:
|
|
113
|
+
components.pop("securitySchemes", None)
|
|
114
|
+
for operation in _iter_openapi_operations(openapi_schema):
|
|
115
|
+
operation.pop("security", None)
|
|
94
116
|
|
|
95
117
|
|
|
96
118
|
def _patch_validation_error_responses(openapi_schema: dict[str, Any]) -> None:
|
|
97
119
|
"""Advertise RFC 7807 Problem Details for request validation failures."""
|
|
98
|
-
components = openapi_schema.setdefault("components", {})
|
|
99
|
-
|
|
120
|
+
components = _as_dict(openapi_schema.setdefault("components", {}))
|
|
121
|
+
if components is None:
|
|
122
|
+
return
|
|
123
|
+
schemas = _as_dict(components.setdefault("schemas", {}))
|
|
124
|
+
if schemas is None:
|
|
125
|
+
return
|
|
100
126
|
schemas["ErrorResponse"] = ErrorResponse.model_json_schema(
|
|
101
127
|
ref_template="#/components/schemas/{model}",
|
|
102
128
|
)
|
|
103
129
|
schemas.pop("HTTPValidationError", None)
|
|
104
130
|
schemas.pop("ValidationError", None)
|
|
105
|
-
|
|
106
131
|
error_response = {
|
|
107
132
|
"description": "Validation Error",
|
|
108
133
|
"content": {
|
|
@@ -111,22 +136,57 @@ def _patch_validation_error_responses(openapi_schema: dict[str, Any]) -> None:
|
|
|
111
136
|
},
|
|
112
137
|
},
|
|
113
138
|
}
|
|
139
|
+
for operation in _iter_openapi_operations(openapi_schema):
|
|
140
|
+
responses = operation.get("responses")
|
|
141
|
+
responses = _as_dict(
|
|
142
|
+
cast("object", responses), # pyright: ignore[reportUnknownArgumentType]
|
|
143
|
+
)
|
|
144
|
+
if responses is not None and "422" in responses:
|
|
145
|
+
responses["422"] = error_response
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _supports_format_query(operation: dict[str, Any]) -> bool:
|
|
149
|
+
"""Return whether an OpenAPI operation exposes the shared format query."""
|
|
150
|
+
parameters = operation.get("parameters", [])
|
|
151
|
+
if not isinstance(parameters, list):
|
|
152
|
+
return False
|
|
153
|
+
parameters = cast("list[object]", parameters)
|
|
154
|
+
return any(
|
|
155
|
+
(parameter := _as_dict(parameter_value)) is not None
|
|
156
|
+
and parameter.get("in") == "query"
|
|
157
|
+
and parameter.get("name") == "format"
|
|
158
|
+
for parameter_value in parameters
|
|
159
|
+
)
|
|
160
|
+
|
|
114
161
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
162
|
+
def _patch_parquet_success_responses(openapi_schema: dict[str, Any]) -> None:
|
|
163
|
+
"""Advertise Parquet for operations using the shared format dependency."""
|
|
164
|
+
parquet_schema = {"schema": {"type": "string", "format": "binary"}}
|
|
165
|
+
for operation in _iter_openapi_operations(openapi_schema):
|
|
166
|
+
if not _supports_format_query(operation):
|
|
167
|
+
continue
|
|
168
|
+
responses = operation.get("responses")
|
|
169
|
+
responses = _as_dict(
|
|
170
|
+
cast("object", responses), # pyright: ignore[reportUnknownArgumentType]
|
|
171
|
+
)
|
|
172
|
+
if responses is None:
|
|
173
|
+
continue
|
|
174
|
+
success = _as_dict(responses.get("200"))
|
|
175
|
+
if success is None:
|
|
176
|
+
continue
|
|
177
|
+
content = _as_dict(success.setdefault("content", {}))
|
|
178
|
+
if content is not None:
|
|
179
|
+
content.setdefault("application/parquet", parquet_schema)
|
|
119
180
|
|
|
120
181
|
|
|
121
182
|
def _build_openapi_schema(app: FastAPI) -> dict[str, Any]:
|
|
122
183
|
"""Build OpenAPI schema for the current authentication mode.
|
|
123
184
|
|
|
124
185
|
Returns:
|
|
125
|
-
|
|
186
|
+
Cached or newly generated OpenAPI schema.
|
|
126
187
|
"""
|
|
127
188
|
if app.openapi_schema:
|
|
128
189
|
return app.openapi_schema
|
|
129
|
-
|
|
130
190
|
openapi_schema = get_openapi(
|
|
131
191
|
title=app.title,
|
|
132
192
|
version=app.version,
|
|
@@ -136,7 +196,7 @@ def _build_openapi_schema(app: FastAPI) -> dict[str, Any]:
|
|
|
136
196
|
if not is_auth_enabled():
|
|
137
197
|
_strip_auth_from_openapi(openapi_schema)
|
|
138
198
|
_patch_validation_error_responses(openapi_schema)
|
|
139
|
-
|
|
199
|
+
_patch_parquet_success_responses(openapi_schema)
|
|
140
200
|
app.openapi_schema = openapi_schema
|
|
141
201
|
return openapi_schema
|
|
142
202
|
|
|
@@ -145,7 +205,7 @@ def _custom_openapi() -> dict[str, Any]:
|
|
|
145
205
|
"""Build OpenAPI schema for the current application instance.
|
|
146
206
|
|
|
147
207
|
Returns:
|
|
148
|
-
|
|
208
|
+
Cached or newly generated OpenAPI schema.
|
|
149
209
|
"""
|
|
150
210
|
return _build_openapi_schema(app)
|
|
151
211
|
|
|
@@ -156,40 +216,25 @@ logger = logging.getLogger(__name__)
|
|
|
156
216
|
|
|
157
217
|
@asynccontextmanager
|
|
158
218
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
159
|
-
"""Manage application
|
|
160
|
-
|
|
161
|
-
Args:
|
|
162
|
-
app: FastAPI application instance (unused but required by FastAPI).
|
|
219
|
+
"""Manage application startup and shutdown.
|
|
163
220
|
|
|
164
221
|
Yields:
|
|
165
|
-
|
|
222
|
+
Control to FastAPI while the application is running.
|
|
166
223
|
"""
|
|
167
|
-
# Startup
|
|
168
224
|
logger.info("Starting MT5 REST API...")
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
app.state,
|
|
173
|
-
MAX_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY,
|
|
174
|
-
get_configured_max_market_book_subscriptions(),
|
|
225
|
+
state = initialize_mt5_runtime_state(
|
|
226
|
+
app,
|
|
227
|
+
max_market_book_subscriptions=get_configured_max_market_book_subscriptions(),
|
|
175
228
|
)
|
|
176
|
-
|
|
177
|
-
# Note: MT5 client is initialized lazily on first request via dependency
|
|
178
|
-
# This avoids blocking startup if MT5 is not available
|
|
179
|
-
await asyncio.sleep(0) # Make function truly async
|
|
180
|
-
|
|
181
229
|
yield
|
|
182
|
-
|
|
183
|
-
# Shutdown
|
|
184
230
|
logger.info("Shutting down MT5 REST API...")
|
|
185
231
|
try:
|
|
186
|
-
await release_market_book_subscriptions(
|
|
232
|
+
await release_market_book_subscriptions(state)
|
|
187
233
|
finally:
|
|
188
|
-
shutdown_mt5_client()
|
|
234
|
+
shutdown_mt5_client(state)
|
|
189
235
|
logger.info("MT5 connection closed")
|
|
190
236
|
|
|
191
237
|
|
|
192
|
-
# Create FastAPI application
|
|
193
238
|
app = FastAPI(
|
|
194
239
|
title=API_TITLE,
|
|
195
240
|
description=API_DESCRIPTION,
|
|
@@ -200,11 +245,8 @@ app = FastAPI(
|
|
|
200
245
|
openapi_url=API_OPENAPI_URL,
|
|
201
246
|
)
|
|
202
247
|
app.openapi = _custom_openapi
|
|
203
|
-
|
|
204
|
-
# Add middleware
|
|
205
248
|
add_middleware(app)
|
|
206
249
|
|
|
207
|
-
# Include routers
|
|
208
250
|
router_prefix = get_configured_api_router_prefix()
|
|
209
251
|
app.include_router(health.router, prefix=router_prefix)
|
|
210
252
|
app.include_router(symbols.router, prefix=router_prefix)
|