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.
Files changed (65) hide show
  1. {mt5api-1.0.3 → mt5api-1.0.5}/PKG-INFO +1 -2
  2. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/constants.py +1 -3
  3. mt5api-1.0.5/mt5api/dependencies.py +210 -0
  4. mt5api-1.0.5/mt5api/formatters.py +163 -0
  5. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/main.py +100 -58
  6. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/middleware.py +5 -4
  7. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/connection.py +9 -16
  8. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/health.py +6 -3
  9. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/trading.py +32 -63
  10. {mt5api-1.0.3 → mt5api-1.0.5}/pyproject.toml +6 -8
  11. mt5api-1.0.3/.agents/skills/local-qa/SKILL.md +0 -22
  12. mt5api-1.0.3/.agents/skills/local-qa/scripts/qa.sh +0 -31
  13. mt5api-1.0.3/.agents/skills/mt5api/SKILL.md +0 -396
  14. mt5api-1.0.3/.github/CODEOWNERS +0 -2
  15. mt5api-1.0.3/.github/FUNDING.yml +0 -3
  16. mt5api-1.0.3/.github/dependabot.yml +0 -17
  17. mt5api-1.0.3/.github/renovate.json +0 -13
  18. mt5api-1.0.3/.github/workflows/ci.yml +0 -92
  19. mt5api-1.0.3/.github/workflows/claude.yml +0 -57
  20. mt5api-1.0.3/.github/workflows/release.yml +0 -44
  21. mt5api-1.0.3/AGENTS.md +0 -38
  22. mt5api-1.0.3/CLAUDE.md +0 -1
  23. mt5api-1.0.3/docs/api/deployment.md +0 -62
  24. mt5api-1.0.3/docs/api/index.md +0 -69
  25. mt5api-1.0.3/docs/api/rest-api.md +0 -333
  26. mt5api-1.0.3/docs/index.md +0 -75
  27. mt5api-1.0.3/mkdocs.yml +0 -72
  28. mt5api-1.0.3/mt5api/dependencies.py +0 -229
  29. mt5api-1.0.3/mt5api/formatters.py +0 -159
  30. mt5api-1.0.3/tests/__init__.py +0 -1
  31. mt5api-1.0.3/tests/conftest.py +0 -295
  32. mt5api-1.0.3/tests/mt5_constants.py +0 -75
  33. mt5api-1.0.3/tests/openapi_mt5_constants.py +0 -34
  34. mt5api-1.0.3/tests/test_account.py +0 -78
  35. mt5api-1.0.3/tests/test_auth.py +0 -177
  36. mt5api-1.0.3/tests/test_calc.py +0 -312
  37. mt5api-1.0.3/tests/test_cli.py +0 -127
  38. mt5api-1.0.3/tests/test_config.py +0 -213
  39. mt5api-1.0.3/tests/test_connection.py +0 -494
  40. mt5api-1.0.3/tests/test_dependencies.py +0 -157
  41. mt5api-1.0.3/tests/test_formatters.py +0 -184
  42. mt5api-1.0.3/tests/test_health.py +0 -205
  43. mt5api-1.0.3/tests/test_history.py +0 -394
  44. mt5api-1.0.3/tests/test_lifespan.py +0 -30
  45. mt5api-1.0.3/tests/test_main.py +0 -179
  46. mt5api-1.0.3/tests/test_market.py +0 -493
  47. mt5api-1.0.3/tests/test_middleware.py +0 -221
  48. mt5api-1.0.3/tests/test_models.py +0 -295
  49. mt5api-1.0.3/tests/test_symbols.py +0 -160
  50. mt5api-1.0.3/tests/test_trading.py +0 -537
  51. mt5api-1.0.3/uv.lock +0 -1712
  52. {mt5api-1.0.3 → mt5api-1.0.5}/.gitignore +0 -0
  53. {mt5api-1.0.3 → mt5api-1.0.5}/LICENSE +0 -0
  54. {mt5api-1.0.3 → mt5api-1.0.5}/README.md +0 -0
  55. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/__init__.py +0 -0
  56. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/__main__.py +0 -0
  57. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/auth.py +0 -0
  58. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/config.py +0 -0
  59. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/models.py +0 -0
  60. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/__init__.py +0 -0
  61. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/account.py +0 -0
  62. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/calc.py +0 -0
  63. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/history.py +0 -0
  64. {mt5api-1.0.3 → mt5api-1.0.5}/mt5api/routers/market.py +0 -0
  65. {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
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
- ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY = "active_market_book_subscriptions"
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 release_market_book_subscriptions, shutdown_mt5_client
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 = openapi_schema.get("components", {})
85
- security_schemes = components.get("securitySchemes", {})
86
- security_schemes.pop(API_KEY_SECURITY_SCHEME_NAME, None)
87
- if not security_schemes:
88
- components.pop("securitySchemes", None)
89
-
90
- for methods in openapi_schema.get("paths", {}).values():
91
- for operation in methods.values():
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
- schemas = components.setdefault("schemas", {})
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
- for methods in openapi_schema.get("paths", {}).values():
116
- for operation in methods.values():
117
- if isinstance(operation, dict) and "422" in operation.get("responses", {}):
118
- operation["responses"]["422"] = error_response
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
- OpenAPI schema for the current auth configuration.
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
- OpenAPI schema for the application.
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 lifespan (startup and shutdown).
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
- None
222
+ Control to FastAPI while the application is running.
166
223
  """
167
- # Startup
168
224
  logger.info("Starting MT5 REST API...")
169
- setattr(app.state, ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY, set())
170
- setattr(app.state, MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY, None)
171
- setattr(
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(app)
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)