mt5api 1.0.2__tar.gz → 1.0.4__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 (73) hide show
  1. {mt5api-1.0.2 → mt5api-1.0.4}/.agents/skills/local-qa/scripts/qa.sh +6 -0
  2. mt5api-1.0.4/.github/CODEOWNERS +2 -0
  3. {mt5api-1.0.2 → mt5api-1.0.4}/.github/workflows/ci.yml +5 -5
  4. {mt5api-1.0.2 → mt5api-1.0.4}/.github/workflows/claude.yml +2 -2
  5. {mt5api-1.0.2 → mt5api-1.0.4}/.github/workflows/release.yml +2 -2
  6. mt5api-1.0.4/AGENTS.md +38 -0
  7. {mt5api-1.0.2 → mt5api-1.0.4}/PKG-INFO +4 -4
  8. {mt5api-1.0.2 → mt5api-1.0.4}/README.md +0 -1
  9. {mt5api-1.0.2 → mt5api-1.0.4}/docs/api/deployment.md +0 -1
  10. {mt5api-1.0.2 → mt5api-1.0.4}/docs/api/rest-api.md +6 -4
  11. {mt5api-1.0.2 → mt5api-1.0.4}/docs/index.md +0 -1
  12. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/constants.py +4 -4
  13. mt5api-1.0.4/mt5api/dependencies.py +210 -0
  14. mt5api-1.0.4/mt5api/formatters.py +163 -0
  15. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/main.py +100 -68
  16. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/middleware.py +5 -4
  17. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/routers/connection.py +9 -16
  18. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/routers/health.py +6 -3
  19. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/routers/trading.py +32 -63
  20. {mt5api-1.0.2 → mt5api-1.0.4}/pyproject.toml +9 -13
  21. {mt5api-1.0.2 → mt5api-1.0.4}/tests/conftest.py +15 -79
  22. {mt5api-1.0.2 → mt5api-1.0.4}/tests/test_auth.py +0 -10
  23. mt5api-1.0.4/tests/test_connection.py +318 -0
  24. mt5api-1.0.4/tests/test_dependencies.py +205 -0
  25. {mt5api-1.0.2 → mt5api-1.0.4}/tests/test_formatters.py +2 -4
  26. {mt5api-1.0.2 → mt5api-1.0.4}/tests/test_health.py +66 -17
  27. {mt5api-1.0.2 → mt5api-1.0.4}/tests/test_lifespan.py +1 -1
  28. mt5api-1.0.4/tests/test_main.py +215 -0
  29. mt5api-1.0.4/tests/test_trading.py +308 -0
  30. {mt5api-1.0.2 → mt5api-1.0.4}/uv.lock +321 -34
  31. mt5api-1.0.2/.agents/skills/pr-feedback-triage/SKILL.md +0 -211
  32. mt5api-1.0.2/AGENTS.md +0 -70
  33. mt5api-1.0.2/mt5api/dependencies.py +0 -229
  34. mt5api-1.0.2/mt5api/formatters.py +0 -159
  35. mt5api-1.0.2/tests/test_connection.py +0 -494
  36. mt5api-1.0.2/tests/test_dependencies.py +0 -157
  37. mt5api-1.0.2/tests/test_integration.py +0 -80
  38. mt5api-1.0.2/tests/test_main.py +0 -255
  39. mt5api-1.0.2/tests/test_pdmt5_migration.py +0 -232
  40. mt5api-1.0.2/tests/test_trading.py +0 -537
  41. {mt5api-1.0.2 → mt5api-1.0.4}/.agents/skills/local-qa/SKILL.md +0 -0
  42. {mt5api-1.0.2 → mt5api-1.0.4}/.agents/skills/mt5api/SKILL.md +0 -0
  43. {mt5api-1.0.2 → mt5api-1.0.4}/.github/FUNDING.yml +0 -0
  44. {mt5api-1.0.2 → mt5api-1.0.4}/.github/dependabot.yml +0 -0
  45. {mt5api-1.0.2 → mt5api-1.0.4}/.github/renovate.json +0 -0
  46. {mt5api-1.0.2 → mt5api-1.0.4}/.gitignore +0 -0
  47. {mt5api-1.0.2 → mt5api-1.0.4}/CLAUDE.md +0 -0
  48. {mt5api-1.0.2 → mt5api-1.0.4}/LICENSE +0 -0
  49. {mt5api-1.0.2 → mt5api-1.0.4}/docs/api/index.md +0 -0
  50. {mt5api-1.0.2 → mt5api-1.0.4}/mkdocs.yml +0 -0
  51. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/__init__.py +0 -0
  52. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/__main__.py +0 -0
  53. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/auth.py +0 -0
  54. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/config.py +0 -0
  55. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/models.py +0 -0
  56. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/routers/__init__.py +0 -0
  57. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/routers/account.py +0 -0
  58. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/routers/calc.py +0 -0
  59. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/routers/history.py +0 -0
  60. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/routers/market.py +0 -0
  61. {mt5api-1.0.2 → mt5api-1.0.4}/mt5api/routers/symbols.py +0 -0
  62. {mt5api-1.0.2 → mt5api-1.0.4}/tests/__init__.py +0 -0
  63. {mt5api-1.0.2 → mt5api-1.0.4}/tests/mt5_constants.py +0 -0
  64. {mt5api-1.0.2 → mt5api-1.0.4}/tests/openapi_mt5_constants.py +0 -0
  65. {mt5api-1.0.2 → mt5api-1.0.4}/tests/test_account.py +0 -0
  66. {mt5api-1.0.2 → mt5api-1.0.4}/tests/test_calc.py +0 -0
  67. {mt5api-1.0.2 → mt5api-1.0.4}/tests/test_cli.py +0 -0
  68. {mt5api-1.0.2 → mt5api-1.0.4}/tests/test_config.py +0 -0
  69. {mt5api-1.0.2 → mt5api-1.0.4}/tests/test_history.py +0 -0
  70. {mt5api-1.0.2 → mt5api-1.0.4}/tests/test_market.py +0 -0
  71. {mt5api-1.0.2 → mt5api-1.0.4}/tests/test_middleware.py +0 -0
  72. {mt5api-1.0.2 → mt5api-1.0.4}/tests/test_models.py +0 -0
  73. {mt5api-1.0.2 → mt5api-1.0.4}/tests/test_symbols.py +0 -0
@@ -3,7 +3,13 @@
3
3
  set -euox pipefail
4
4
  cd "$(git rev-parse --show-toplevel)"
5
5
 
6
+ COOLDOWN_DAYS=7
7
+ export UV_EXCLUDE_NEWER="${COOLDOWN_DAYS} days"
8
+ export NPM_CONFIG_MIN_RELEASE_AGE="${COOLDOWN_DAYS}"
9
+ export PNPM_CONFIG_MINIMUM_RELEASE_AGE=$((COOLDOWN_DAYS * 24 * 60))
10
+
6
11
  # Python
12
+ uv sync
7
13
  uv run ruff format .
8
14
  uv run ruff check --fix .
9
15
  uv run pyright .
@@ -0,0 +1,2 @@
1
+ # Default owner
2
+ * @dceoy
@@ -35,7 +35,7 @@ jobs:
35
35
  || (github.event_name == 'workflow_dispatch' && inputs.workflow == 'lint-and-test')
36
36
  permissions:
37
37
  contents: read
38
- uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-lint-and-scan.yml@main # zizmor: ignore[unpinned-uses]
38
+ uses: dceoy/gha-for-devops/.github/workflows/python-package-lint-and-scan.yml@main # zizmor: ignore[unpinned-uses]
39
39
  with:
40
40
  package-path: .
41
41
  runs-on: windows-latest
@@ -46,7 +46,7 @@ jobs:
46
46
  || (github.event_name == 'workflow_dispatch' && inputs.workflow == 'lint-and-test')
47
47
  permissions:
48
48
  contents: read
49
- uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-test.yml@main # zizmor: ignore[unpinned-uses]
49
+ uses: dceoy/gha-for-devops/.github/workflows/python-package-test.yml@main # zizmor: ignore[unpinned-uses]
50
50
  with:
51
51
  package-path: .
52
52
  runs-on: windows-latest
@@ -56,7 +56,7 @@ jobs:
56
56
  || (github.event_name == 'workflow_dispatch' && inputs.workflow == 'docs-deploy')
57
57
  permissions:
58
58
  contents: write
59
- uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-mkdocs-gh-deploy.yml@main # zizmor: ignore[unpinned-uses]
59
+ uses: dceoy/gha-for-devops/.github/workflows/python-package-mkdocs-gh-deploy.yml@main # zizmor: ignore[unpinned-uses]
60
60
  with:
61
61
  package-path: .
62
62
  runs-on: ubuntu-slim
@@ -71,7 +71,7 @@ jobs:
71
71
  contents: read
72
72
  security-events: write
73
73
  actions: read
74
- uses: dceoy/gh-actions-for-devops/.github/workflows/github-codeql-analysis.yml@main # zizmor: ignore[unpinned-uses]
74
+ uses: dceoy/gha-for-devops/.github/workflows/github-codeql-analysis.yml@main # zizmor: ignore[unpinned-uses]
75
75
  with:
76
76
  language: >
77
77
  ["python"]
@@ -81,7 +81,7 @@ jobs:
81
81
  needs:
82
82
  - python-lint-and-scan
83
83
  - python-test
84
- uses: dceoy/gh-actions-for-devops/.github/workflows/dependabot-auto-merge.yml@main # zizmor: ignore[unpinned-uses]
84
+ uses: dceoy/gha-for-devops/.github/workflows/dependabot-auto-merge.yml@main # zizmor: ignore[unpinned-uses]
85
85
  permissions:
86
86
  contents: write
87
87
  pull-requests: write
@@ -35,7 +35,7 @@ jobs:
35
35
  issues: write
36
36
  id-token: write
37
37
  actions: read
38
- uses: dceoy/gh-actions-for-devops/.github/workflows/claude-code-review.yml@main # zizmor: ignore[unpinned-uses]
38
+ uses: dceoy/gha-for-devops/.github/workflows/claude-code-review.yml@main # zizmor: ignore[unpinned-uses]
39
39
  secrets:
40
40
  CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # zizmor: ignore[secrets-outside-env]
41
41
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -51,7 +51,7 @@ jobs:
51
51
  issues: write
52
52
  id-token: write
53
53
  actions: read
54
- uses: dceoy/gh-actions-for-devops/.github/workflows/claude-code-bot.yml@main # zizmor: ignore[unpinned-uses]
54
+ uses: dceoy/gha-for-devops/.github/workflows/claude-code-bot.yml@main # zizmor: ignore[unpinned-uses]
55
55
  secrets:
56
56
  CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # zizmor: ignore[secrets-outside-env]
57
57
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -13,7 +13,7 @@ jobs:
13
13
  permissions:
14
14
  contents: write
15
15
  id-token: write
16
- uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-release-on-pypi-and-github.yml@main # zizmor: ignore[unpinned-uses]
16
+ uses: dceoy/gha-for-devops/.github/workflows/python-package-release-on-pypi-and-github.yml@main # zizmor: ignore[unpinned-uses]
17
17
  with:
18
18
  package-path: .
19
19
  create-github-release: true
@@ -39,6 +39,6 @@ jobs:
39
39
  name: ${{ needs.build-and-release.outputs.distribution-artifact-name }}
40
40
  path: dist/
41
41
  - name: Publish distribution 📦 to PyPI
42
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
42
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
43
43
  with:
44
44
  verbose: true
mt5api-1.0.4/AGENTS.md ADDED
@@ -0,0 +1,38 @@
1
+ # Repository Guidelines
2
+
3
+ ## Build, Test, and Development Commands
4
+
5
+ - `.agents/skills/local-qa/scripts/qa.sh`: run the repository QA workflow after
6
+ any file update. This wraps formatting, linting, type checks, tests, Markdown
7
+ formatting, and workflow/security checks.
8
+
9
+ The API server runtime requires Windows with MetaTrader 5 installed and logged
10
+ in. Non-Windows systems are suitable for editing docs and most mocked tests.
11
+
12
+ ## Testing Guidelines
13
+
14
+ Parametrize unit tests with `pytest.mark.parametrize` when the same behavior
15
+ should be verified across multiple input and expected-output cases. Prefer
16
+ mocked MT5/pdmt5 interactions unless a test explicitly requires a Windows MT5
17
+ terminal.
18
+
19
+ ## Design Principles
20
+
21
+ Apply KISS, DRY, and YAGNI when changing code. Prefer the simplest direct
22
+ implementation that satisfies the current requirement. Remove duplication when
23
+ it improves clarity or prevents drift, but avoid abstractions that do not serve
24
+ an immediate need. Keep changes small, focused, and easy to review.
25
+
26
+ ## Commit & Pull Request Guidelines
27
+
28
+ Keep commits scoped to one logical change. Before committing or opening a PR,
29
+ run `.agents/skills/local-qa/scripts/qa.sh`. Pull requests should describe the
30
+ behavior change, list verification commands, link related issues when
31
+ applicable, and call out any runtime impact for Windows/MT5 users.
32
+
33
+ ## Security & Configuration Tips
34
+
35
+ Do not commit credentials, terminal account details, or real API keys. Configure
36
+ runtime values through environment variables such as `MT5API_SECRET_KEY`,
37
+ `MT5API_ROUTER_PREFIX`, `MT5API_HOST`, `MT5API_PORT`, and
38
+ `MT5API_LOG_LEVEL`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5api
3
- Version: 1.0.2
3
+ Version: 1.0.4
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>
@@ -15,11 +15,12 @@ Classifier: Operating System :: Microsoft :: Windows
15
15
  Classifier: Programming Language :: Python
16
16
  Classifier: Programming Language :: Python :: 3
17
17
  Classifier: Topic :: Office/Business :: Financial :: Investment
18
- Requires-Python: <3.14,>=3.11
18
+ Requires-Python: <3.15,>=3.11
19
19
  Requires-Dist: fastapi>=0.115.0
20
- Requires-Dist: httpx>=0.27.0
20
+ Requires-Dist: pandas>=3.0.3
21
21
  Requires-Dist: pdmt5>=1.2.0
22
22
  Requires-Dist: pyarrow>=18.0.0
23
+ Requires-Dist: pydantic>=2.13.4
23
24
  Requires-Dist: python-multipart>=0.0.31
24
25
  Requires-Dist: uvicorn[standard]>=0.32.0
25
26
  Description-Content-Type: text/markdown
@@ -85,7 +86,6 @@ graph TB
85
86
 
86
87
  ## Requirements
87
88
 
88
- - Python 3.11+
89
89
  - Windows host with MetaTrader 5 terminal installed and logged in
90
90
  - Linux and macOS are not supported for the API server runtime, but they work
91
91
  for HTTP clients
@@ -59,7 +59,6 @@ graph TB
59
59
 
60
60
  ## Requirements
61
61
 
62
- - Python 3.11+
63
62
  - Windows host with MetaTrader 5 terminal installed and logged in
64
63
  - Linux and macOS are not supported for the API server runtime, but they work
65
64
  for HTTP clients
@@ -10,7 +10,6 @@ macOS because the `MetaTrader5` Python package is supported only on Windows.
10
10
 
11
11
  - Windows 10/11
12
12
  - MetaTrader 5 installed and logged in
13
- - Python 3.11+
14
13
  - `mt5api` installed
15
14
  - PowerShell access with permission to install and manage services
16
15
 
@@ -10,7 +10,6 @@ system.
10
10
 
11
11
  ## Runtime Requirements
12
12
 
13
- - Python 3.11+
14
13
  - Windows host with MetaTrader 5 terminal installed and logged in
15
14
  - Linux and macOS are not supported for the API server runtime, but they work
16
15
  for HTTP clients
@@ -167,9 +166,12 @@ with typed validation for core fields such as `action`, `symbol`, `volume`,
167
166
 
168
167
  - `POST /connection/login` (body: `{"login": ..., "password": "...",
169
168
  "server": "...", "timeout": ...}`) — Reconnect the MT5 terminal with the
170
- supplied credentials. Any active market-book subscriptions are released and
171
- the previous MT5 client is shut down before the new connection is
172
- established. The supplied password is never echoed in responses or logs.
169
+ supplied credentials. The replacement client is initialized and installed
170
+ before active market-book subscriptions are released and cleared. The old
171
+ wrapper is not shut down after a successful replacement because MetaTrader5
172
+ connection state is process-global; shutting it down could close the newly
173
+ established session. If replacement fails, existing subscription tracking is
174
+ preserved. The supplied password is never echoed in responses or logs.
173
175
 
174
176
  ## Response Formatter Utilities
175
177
 
@@ -26,7 +26,6 @@ system.
26
26
 
27
27
  ## Requirements
28
28
 
29
- - Python 3.11+
30
29
  - Windows OS with MetaTrader 5 terminal installed and logged in
31
30
  - Linux and macOS are not supported for the API server runtime, but they work
32
31
  for HTTP clients
@@ -1,21 +1,21 @@
1
1
  """Application-wide constants."""
2
2
 
3
+ from importlib.metadata import version
4
+
3
5
  API_TITLE = "MT5 REST API"
4
6
  API_DESCRIPTION = (
5
7
  "REST API for MetaTrader 5 data access and non-executing terminal utilities. "
6
8
  "Provides market data, account information, trading history, calculations, "
7
9
  "and safe operational endpoints via HTTP."
8
10
  )
9
- API_VERSION = "1.0.2"
11
+ API_VERSION = version("mt5api")
10
12
  API_DOCS_URL = "/docs"
11
13
  API_REDOC_URL = "/redoc"
12
14
  API_OPENAPI_URL = "/openapi.json"
13
15
  API_APP_IMPORT = "mt5api.main:app"
14
16
  API_KEY_HEADER_NAME = "X-API-Key"
15
17
  API_KEY_SECURITY_SCHEME_NAME = "APIKeyHeader"
16
- ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY = "active_market_book_subscriptions"
17
- MARKET_BOOK_CLEANUP_CLIENT_STATE_KEY = "market_book_cleanup_client"
18
- MAX_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY = "max_market_book_subscriptions"
18
+ MT5_RUNTIME_STATE_KEY = "mt5_runtime"
19
19
 
20
20
  ENV_MT5API_HOST = "MT5API_HOST"
21
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)