mt5api 1.0.0__tar.gz → 1.0.1__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.0 → mt5api-1.0.1}/.agents/skills/pr-feedback-triage/SKILL.md +25 -15
  2. {mt5api-1.0.0 → mt5api-1.0.1}/PKG-INFO +16 -9
  3. {mt5api-1.0.0 → mt5api-1.0.1}/README.md +14 -7
  4. {mt5api-1.0.0 → mt5api-1.0.1}/docs/api/rest-api.md +2 -2
  5. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/__main__.py +3 -19
  6. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/config.py +49 -6
  7. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/constants.py +1 -1
  8. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/dependencies.py +7 -14
  9. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/main.py +35 -2
  10. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/middleware.py +82 -2
  11. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/routers/connection.py +3 -3
  12. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/routers/health.py +4 -4
  13. {mt5api-1.0.0 → mt5api-1.0.1}/pyproject.toml +2 -2
  14. {mt5api-1.0.0 → mt5api-1.0.1}/tests/conftest.py +2 -2
  15. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_auth.py +5 -5
  16. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_cli.py +20 -23
  17. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_config.py +78 -0
  18. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_connection.py +60 -33
  19. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_health.py +63 -3
  20. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_main.py +102 -0
  21. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_market.py +9 -3
  22. mt5api-1.0.1/tests/test_middleware.py +221 -0
  23. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_trading.py +3 -1
  24. {mt5api-1.0.0 → mt5api-1.0.1}/uv.lock +6 -6
  25. mt5api-1.0.0/tests/test_middleware.py +0 -147
  26. {mt5api-1.0.0 → mt5api-1.0.1}/.agents/skills/local-qa/SKILL.md +0 -0
  27. {mt5api-1.0.0 → mt5api-1.0.1}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  28. {mt5api-1.0.0 → mt5api-1.0.1}/.agents/skills/mt5api/SKILL.md +0 -0
  29. {mt5api-1.0.0 → mt5api-1.0.1}/.github/FUNDING.yml +0 -0
  30. {mt5api-1.0.0 → mt5api-1.0.1}/.github/dependabot.yml +0 -0
  31. {mt5api-1.0.0 → mt5api-1.0.1}/.github/renovate.json +0 -0
  32. {mt5api-1.0.0 → mt5api-1.0.1}/.github/workflows/ci.yml +0 -0
  33. {mt5api-1.0.0 → mt5api-1.0.1}/.github/workflows/claude.yml +0 -0
  34. {mt5api-1.0.0 → mt5api-1.0.1}/.github/workflows/release.yml +0 -0
  35. {mt5api-1.0.0 → mt5api-1.0.1}/.gitignore +0 -0
  36. {mt5api-1.0.0 → mt5api-1.0.1}/AGENTS.md +0 -0
  37. {mt5api-1.0.0 → mt5api-1.0.1}/CLAUDE.md +0 -0
  38. {mt5api-1.0.0 → mt5api-1.0.1}/LICENSE +0 -0
  39. {mt5api-1.0.0 → mt5api-1.0.1}/docs/api/deployment.md +0 -0
  40. {mt5api-1.0.0 → mt5api-1.0.1}/docs/api/index.md +0 -0
  41. {mt5api-1.0.0 → mt5api-1.0.1}/docs/index.md +0 -0
  42. {mt5api-1.0.0 → mt5api-1.0.1}/mkdocs.yml +0 -0
  43. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/__init__.py +0 -0
  44. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/auth.py +0 -0
  45. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/formatters.py +0 -0
  46. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/models.py +0 -0
  47. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/routers/__init__.py +0 -0
  48. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/routers/account.py +0 -0
  49. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/routers/calc.py +0 -0
  50. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/routers/history.py +0 -0
  51. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/routers/market.py +0 -0
  52. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/routers/symbols.py +0 -0
  53. {mt5api-1.0.0 → mt5api-1.0.1}/mt5api/routers/trading.py +0 -0
  54. {mt5api-1.0.0 → mt5api-1.0.1}/tests/__init__.py +0 -0
  55. {mt5api-1.0.0 → mt5api-1.0.1}/tests/mt5_constants.py +0 -0
  56. {mt5api-1.0.0 → mt5api-1.0.1}/tests/openapi_mt5_constants.py +0 -0
  57. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_account.py +0 -0
  58. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_calc.py +0 -0
  59. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_dependencies.py +0 -0
  60. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_formatters.py +0 -0
  61. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_history.py +0 -0
  62. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_integration.py +0 -0
  63. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_lifespan.py +0 -0
  64. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_models.py +0 -0
  65. {mt5api-1.0.0 → mt5api-1.0.1}/tests/test_symbols.py +0 -0
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: pr-feedback-triage
3
3
  description: Triage pull request review comments into fixes, replies, clarification requests, or open follow-ups while respecting safe execution modes.
4
+ allowed-tools: Bash(git:*), Bash(gh:*), mcp__github__*, Read, Grep, Glob, Edit, MultiEdit, Write
4
5
  ---
5
6
 
6
7
  # PR Feedback Triage
@@ -43,9 +44,10 @@ When a mode disables an action, skip that destructive or externally visible acti
43
44
 
44
45
  Gather the complete feedback set before editing:
45
46
 
46
- - Fetch unresolved review threads, requested-change reviews, PR-level summary comments, and copied comments.
47
- - Use platform-native APIs/CLI when available. Paginate results; do not inspect only the first page of threads or comments.
48
- - For bot reviewers that post both summary comments and inline comments, collect both. Summary comments often contain severity, rationale, and fix instructions; inline comments contain the exact file and line context.
47
+ - Fetch unresolved review threads, requested-change reviews, inline comments, copied comments, and PR-level summary comments.
48
+ - Use whichever authenticated GitHub-capable interface is available and reliable. This skill explicitly permits both `gh` and GitHub MCP tools; paginate results and do not inspect only the first page of threads or comments.
49
+ - For bot reviewers that post both summary comments and inline comments, prefer inline comments for actionable triage. Incorporate summary findings only when they contain distinct severity, rationale, or fix instructions not already captured from inline comments.
50
+ - Summary comments may be excluded from triage when they do not add distinct actionable context.
49
51
  - Preserve every thread/comment identifier needed to reply or resolve later.
50
52
  - Compare each comment with the current diff and file contents because review lines can become outdated.
51
53
 
@@ -56,7 +58,7 @@ Build one triage record per distinct finding:
56
58
  - Prefer exact review-thread identity when available.
57
59
  - For duplicate bot findings appearing in both summary and inline comments, merge by exact issue title first, then by file path plus line range as a fallback.
58
60
  - Prefer inline comments for location and current code context.
59
- - Prefer summary comments for severity, category, rationale, and detailed agent prompts.
61
+ - Use summary comments only for distinct severity, category, rationale, or detailed agent prompts that are not already available from inline comments.
60
62
  - Preserve the reviewer’s exact issue title and original wording where practical. Do not rename findings in a way that would make replies hard to map back to comments.
61
63
  - Preserve the reviewer’s original ordering unless the user asks for priority reordering. Many review bots already order findings by severity.
62
64
 
@@ -70,13 +72,20 @@ Keep a thread open only when it still needs reviewer, maintainer, or product inp
70
72
 
71
73
  When resolving a thread, add a concise reply first only if it provides useful context, such as what changed, why no code change was needed, why a finding was intentionally deferred, or why the original comment is now outdated. Do not add noisy replies for self-evident fixes unless project norms require them.
72
74
 
75
+ ## Platform Comment Style
76
+
77
+ - Keep every posted reply or comment brief: one sentence by default, two short sentences only when necessary.
78
+ - Do not post PR-level summary or status comments by default. Omit them when they only restate completed fixes, resolved threads, or verification already visible in commits/checks.
79
+ - Avoid templates, long bullet lists, exhaustive status logs, and duplicated explanations in platform comments.
80
+ - For simple fixes, already-addressed findings, or outdated findings, prefer `resolve_only` over adding a reply.
81
+
73
82
  ## Platform Action Contract
74
83
 
75
- Do not treat triage as complete until every collected source ID reaches an explicit terminal state:
84
+ Do not treat triage as complete until every incorporated source ID reaches an explicit terminal state:
76
85
 
77
86
  - `resolved`: a platform resolve action succeeded, or a re-check shows the thread is already resolved.
78
87
  - `replied_left_open`: a reply or question was posted and the thread is intentionally left unresolved.
79
- - `not_resolvable`: the source is a PR-level summary comment or copied comment that has no platform-level resolve action; reply or post a PR summary when useful.
88
+ - `not_resolvable`: the source is a PR-level summary comment or copied comment that has no platform-level resolve action; post a brief reply only when useful.
80
89
  - `skipped_by_mode`: `dry_run`, `no_push`, or `no_reply` prevented the external action.
81
90
  - `failed_action`: a reply or resolve action was attempted and failed; include the attempted action and failure in the final summary.
82
91
 
@@ -85,13 +94,13 @@ In normal mode, build and execute a platform action queue after fixes are verifi
85
94
  - `reply_then_resolve`: use for handled threads where the reviewer needs context before resolution.
86
95
  - `resolve_only`: use for self-evident fixes and already-addressed or outdated threads where an extra reply would add noise.
87
96
  - `reply_leave_open`: use only for clarification requests, blocked work, or intentionally open follow-ups.
88
- - `reply_only`: use for PR-level comments or summaries that cannot be resolved as review threads.
97
+ - `reply_only`: use for PR-level comments or summaries that cannot be resolved, only when a short reply adds value.
89
98
 
90
99
  For duplicate findings, execute the terminal action for every source thread ID, not only the primary triage record. If one finding is represented by three unresolved inline threads, all three must be resolved or explicitly left open.
91
100
 
92
101
  ## GitHub Action Guidance
93
102
 
94
- Prefer platform-native APIs or `gh` commands that expose review-thread resolution state. For GitHub inline review threads, use the thread node ID and the GraphQL `resolveReviewThread` mutation rather than assuming that a reply resolves the conversation.
103
+ Use whichever authenticated GitHub-capable interface is available and reliable, preferably `gh` or GitHub MCP. Prefer interfaces that expose review-thread resolution state. For GitHub inline review threads, use the thread node ID and the GraphQL `resolveReviewThread` mutation, or an equivalent GitHub MCP resolve-thread tool, rather than assuming that a reply resolves the conversation.
95
104
 
96
105
  A reliable pattern is:
97
106
 
@@ -150,9 +159,9 @@ flowchart TD
150
159
  ## Compact Workflow
151
160
 
152
161
  1. **Collect all relevant feedback**
153
- - Identify the PR and gather unresolved review threads, requested-change reviews, PR-level summaries, inline comments, and copied comments.
162
+ - Identify the PR and gather unresolved review threads, requested-change reviews, inline comments, copied comments, and PR-level summaries.
154
163
  - Paginate all platform calls and keep comment/thread IDs for later replies and resolution.
155
- - For bot reviews, collect both summary and inline comments, then merge duplicates rather than fixing the same finding twice.
164
+ - For bot reviews, prioritize inline comments and incorporate summary findings only when they add distinct actionable context.
156
165
 
157
166
  2. **Classify each triage record**
158
167
  - **Fix**: Valid requested change; make the smallest focused edit when not in `dry_run`.
@@ -168,7 +177,7 @@ flowchart TD
168
177
  - In `dry_run`, stop at triage, proposed fixes, suggested replies, and verification plan.
169
178
  - In `no_push`, local edits are allowed, but do not push or resolve threads whose fix is only local. Reply or resolve non-code, already-addressed, or outdated threads only when the action does not depend on unpushed work and `no_reply` is not set.
170
179
  - In `no_reply`, do not post replies or resolve threads; report suggested replies/actions instead.
171
- - In normal mode, commit and push changed code when appropriate, then execute the platform action queue for every collected source ID.
180
+ - In normal mode, commit and push changed code when appropriate, then execute the platform action queue for every incorporated source ID.
172
181
 
173
182
  4. **Verify before claiming completion**
174
183
  - For fixes, run appropriate checks or explain why they could not run.
@@ -178,15 +187,16 @@ flowchart TD
178
187
  - If a resolve or reply operation fails, retry once when safe; then report `failed_action` with the affected source ID and reason.
179
188
 
180
189
  5. **Finish**
181
- - Normal mode: commit/push changes when appropriate, post useful replies or a summary, resolve all handled threads by default, and reconcile the final unresolved set.
190
+ - Normal mode: commit/push changes when appropriate, post only useful short replies/comments, omit PR-level summaries when they add no value, resolve all handled threads by default, and reconcile the final unresolved set.
182
191
  - Safe modes: report the local state and the exact replies/resolution actions a human could take.
183
192
 
184
193
  ## Reply Guidance
185
194
 
186
- - Keep inline replies short and tied to the original title or concern.
187
- - For fixed findings, mention the concrete change or commit if useful.
188
- - For already-addressed or outdated findings, cite the current code path or behavior that makes the finding no longer applicable.
195
+ - Keep inline replies short: one sentence by default, two short sentences only when needed.
196
+ - For fixed findings, mention the concrete change or commit only if it helps the reviewer.
197
+ - For already-addressed or outdated findings, cite the current code path or behavior only as briefly as needed.
189
198
  - For deferred or won't-fix findings, provide the reason and any follow-up issue or owner if known.
199
+ - Avoid posting PR-level summary comments unless they communicate a decision, blocker, or requested reviewer action.
190
200
  - If a reply or resolve operation fails, continue with the remaining threads and report the failure in the final summary.
191
201
 
192
202
  ## Final Summary Checklist
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5api
3
- Version: 1.0.0
3
+ Version: 1.0.1
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>
@@ -18,7 +18,7 @@ Classifier: Topic :: Office/Business :: Financial :: Investment
18
18
  Requires-Python: <3.14,>=3.11
19
19
  Requires-Dist: fastapi>=0.115.0
20
20
  Requires-Dist: httpx>=0.27.0
21
- Requires-Dist: pdmt5>=1.0.1
21
+ Requires-Dist: pdmt5>=1.1.1
22
22
  Requires-Dist: pyarrow>=18.0.0
23
23
  Requires-Dist: python-multipart>=0.0.31
24
24
  Requires-Dist: uvicorn[standard]>=0.32.0
@@ -30,11 +30,18 @@ MetaTrader 5 REST API
30
30
 
31
31
  [![CI/CD](https://github.com/dceoy/mt5api/actions/workflows/ci.yml/badge.svg)](https://github.com/dceoy/mt5api/actions/workflows/ci.yml)
32
32
 
33
- mt5api exposes MT5 market data, account info, trading history, and trading
34
- operations over HTTP. It is a FastAPI/HTTP adapter over
35
- [`pdmt5`](https://github.com/dceoy/pdmt5), which provides the core MT5 client,
36
- dataframe/trading primitives, and canonical MT5 constant parsing. mt5api adds
37
- optional API-key auth and JSON/Parquet response formatting.
33
+ mt5api exposes MT5 market data, account info, trading history, trading state,
34
+ order validation, and operational terminal endpoints over HTTP. It is a
35
+ FastAPI/HTTP adapter over [`pdmt5`](https://github.com/dceoy/pdmt5), which
36
+ provides the core MT5 client, dataframe/order primitives, and canonical MT5
37
+ constant parsing. mt5api adds optional API-key auth and JSON/Parquet response
38
+ formatting.
39
+
40
+ The exposed operational surface is deliberately narrow: mt5api validates trade
41
+ requests, manages terminal-side subscriptions and MarketWatch visibility, and
42
+ supports reconnecting the terminal to a different MT5 account via
43
+ `POST /connection/login`, but it does not expose live order-send endpoints or
44
+ implement strategy orchestration.
38
45
 
39
46
  The API server must run on Windows. pdmt5 connects through the MetaTrader 5
40
47
  Python API, which is supported only on Windows, so you must host `mt5api` on a
@@ -69,8 +76,8 @@ graph TB
69
76
 
70
77
  ## Features
71
78
 
72
- - REST endpoints for symbols, market data, account info, orders, history,
73
- calculations, and trading operations
79
+ - REST endpoints for symbols, market data, account info, orders, positions,
80
+ history, margin/profit calculations, order validation, and terminal operations
74
81
  - JSON and Apache Parquet responses (content negotiation)
75
82
  - Optional API key authentication
76
83
  - Structured JSON logging
@@ -4,11 +4,18 @@ MetaTrader 5 REST API
4
4
 
5
5
  [![CI/CD](https://github.com/dceoy/mt5api/actions/workflows/ci.yml/badge.svg)](https://github.com/dceoy/mt5api/actions/workflows/ci.yml)
6
6
 
7
- mt5api exposes MT5 market data, account info, trading history, and trading
8
- operations over HTTP. It is a FastAPI/HTTP adapter over
9
- [`pdmt5`](https://github.com/dceoy/pdmt5), which provides the core MT5 client,
10
- dataframe/trading primitives, and canonical MT5 constant parsing. mt5api adds
11
- optional API-key auth and JSON/Parquet response formatting.
7
+ mt5api exposes MT5 market data, account info, trading history, trading state,
8
+ order validation, and operational terminal endpoints over HTTP. It is a
9
+ FastAPI/HTTP adapter over [`pdmt5`](https://github.com/dceoy/pdmt5), which
10
+ provides the core MT5 client, dataframe/order primitives, and canonical MT5
11
+ constant parsing. mt5api adds optional API-key auth and JSON/Parquet response
12
+ formatting.
13
+
14
+ The exposed operational surface is deliberately narrow: mt5api validates trade
15
+ requests, manages terminal-side subscriptions and MarketWatch visibility, and
16
+ supports reconnecting the terminal to a different MT5 account via
17
+ `POST /connection/login`, but it does not expose live order-send endpoints or
18
+ implement strategy orchestration.
12
19
 
13
20
  The API server must run on Windows. pdmt5 connects through the MetaTrader 5
14
21
  Python API, which is supported only on Windows, so you must host `mt5api` on a
@@ -43,8 +50,8 @@ graph TB
43
50
 
44
51
  ## Features
45
52
 
46
- - REST endpoints for symbols, market data, account info, orders, history,
47
- calculations, and trading operations
53
+ - REST endpoints for symbols, market data, account info, orders, positions,
54
+ history, margin/profit calculations, order validation, and terminal operations
48
55
  - JSON and Apache Parquet responses (content negotiation)
49
56
  - Optional API key authentication
50
57
  - Structured JSON logging
@@ -316,8 +316,8 @@ Errors follow RFC 7807 Problem Details:
316
316
  {
317
317
  "type": "/errors/validation-error",
318
318
  "title": "Request Validation Failed",
319
- "status": 400,
320
- "detail": "count must be positive (got: -10)",
319
+ "status": 422,
320
+ "detail": "Request validation error details",
321
321
  "instance": "/rates/from"
322
322
  }
323
323
  ```
@@ -11,11 +11,7 @@ from .config import (
11
11
  get_configured_api_log_level,
12
12
  get_configured_api_port,
13
13
  )
14
- from .constants import (
15
- API_APP_IMPORT,
16
- DEFAULT_API_PORT,
17
- MAX_API_PORT,
18
- )
14
+ from .constants import API_APP_IMPORT
19
15
 
20
16
 
21
17
  def _get_host() -> str:
@@ -33,19 +29,7 @@ def _get_port() -> int:
33
29
  Returns:
34
30
  Port number for the API server.
35
31
  """
36
- raw_port = get_configured_api_port()
37
- if raw_port is None:
38
- return DEFAULT_API_PORT
39
-
40
- try:
41
- port_value = int(raw_port)
42
- except ValueError:
43
- return DEFAULT_API_PORT
44
-
45
- if not 1 <= port_value <= MAX_API_PORT:
46
- return DEFAULT_API_PORT
47
-
48
- return port_value
32
+ return get_configured_api_port()
49
33
 
50
34
 
51
35
  def _get_log_level() -> str:
@@ -54,7 +38,7 @@ def _get_log_level() -> str:
54
38
  Returns:
55
39
  Log level string for uvicorn.
56
40
  """
57
- return get_configured_api_log_level().lower()
41
+ return get_configured_api_log_level()
58
42
 
59
43
 
60
44
  def main() -> None:
@@ -8,6 +8,7 @@ import re
8
8
  from .constants import (
9
9
  DEFAULT_API_HOST,
10
10
  DEFAULT_API_LOG_LEVEL,
11
+ DEFAULT_API_PORT,
11
12
  DEFAULT_API_ROUTER_PREFIX,
12
13
  DEFAULT_MAX_MARKET_BOOK_SUBSCRIPTIONS,
13
14
  ENV_MT5API_HOST,
@@ -16,9 +17,21 @@ from .constants import (
16
17
  ENV_MT5API_PORT,
17
18
  ENV_MT5API_ROUTER_PREFIX,
18
19
  ENV_MT5API_SECRET_KEY,
20
+ MAX_API_PORT,
19
21
  )
20
22
 
21
23
  _VALID_API_ROUTER_PREFIX_PATTERN = re.compile(r"^[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*$")
24
+ _VALID_UVICORN_LOG_LEVELS = {"critical", "error", "warning", "info", "debug", "trace"}
25
+ _LOGGING_LOG_LEVEL_BY_API_LEVEL = {
26
+ "critical": "CRITICAL",
27
+ "error": "ERROR",
28
+ "warning": "WARNING",
29
+ "info": "INFO",
30
+ "debug": "DEBUG",
31
+ "trace": "DEBUG",
32
+ }
33
+ _INVALID_MT5API_PORT_ERROR = "Invalid MT5API_PORT"
34
+ _INVALID_MT5API_LOG_LEVEL_ERROR = "Invalid MT5API_LOG_LEVEL"
22
35
  _INVALID_MT5API_ROUTER_PREFIX_ERROR = "Invalid MT5API_ROUTER_PREFIX"
23
36
  _INVALID_MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS_ERROR = (
24
37
  "Invalid MT5API_MAX_MARKET_BOOK_SUBSCRIPTIONS"
@@ -59,22 +72,52 @@ def get_configured_api_host() -> str:
59
72
  return os.getenv(ENV_MT5API_HOST, DEFAULT_API_HOST)
60
73
 
61
74
 
62
- def get_configured_api_port() -> str | None:
63
- """Get the configured API port string.
75
+ def get_configured_api_port() -> int:
76
+ """Get the configured API port.
64
77
 
65
78
  Returns:
66
- Raw port string from the environment, or ``None`` if unset.
79
+ Port number for the API server.
80
+
81
+ Raises:
82
+ ValueError: If the configured port is not an integer in the TCP port range.
67
83
  """
68
- return os.getenv(ENV_MT5API_PORT)
84
+ raw_port = os.getenv(ENV_MT5API_PORT)
85
+ if raw_port is None:
86
+ return DEFAULT_API_PORT
87
+
88
+ try:
89
+ port = int(raw_port)
90
+ except ValueError as error:
91
+ raise ValueError(_INVALID_MT5API_PORT_ERROR) from error
92
+
93
+ if not 1 <= port <= MAX_API_PORT:
94
+ raise ValueError(_INVALID_MT5API_PORT_ERROR)
95
+
96
+ return port
69
97
 
70
98
 
71
99
  def get_configured_api_log_level() -> str:
72
100
  """Get the configured API log level.
73
101
 
74
102
  Returns:
75
- Log level string from configuration.
103
+ Valid uvicorn log level string from configuration.
104
+
105
+ Raises:
106
+ ValueError: If the configured log level is unsupported.
107
+ """
108
+ log_level = os.getenv(ENV_MT5API_LOG_LEVEL, DEFAULT_API_LOG_LEVEL).strip().lower()
109
+ if log_level not in _VALID_UVICORN_LOG_LEVELS:
110
+ raise ValueError(_INVALID_MT5API_LOG_LEVEL_ERROR)
111
+ return log_level
112
+
113
+
114
+ def get_configured_python_log_level() -> str:
115
+ """Get a stdlib logging-compatible log level for the API logger.
116
+
117
+ Returns:
118
+ Python logging level name derived from the configured API log level.
76
119
  """
77
- return os.getenv(ENV_MT5API_LOG_LEVEL, DEFAULT_API_LOG_LEVEL)
120
+ return _LOGGING_LOG_LEVEL_BY_API_LEVEL[get_configured_api_log_level()]
78
121
 
79
122
 
80
123
  def get_configured_api_router_prefix() -> str:
@@ -6,7 +6,7 @@ API_DESCRIPTION = (
6
6
  "Provides market data, account information, trading history, calculations, "
7
7
  "and safe operational endpoints via HTTP."
8
8
  )
9
- API_VERSION = "1.0.0"
9
+ API_VERSION = "1.0.1"
10
10
  API_DOCS_URL = "/docs"
11
11
  API_REDOC_URL = "/redoc"
12
12
  API_OPENAPI_URL = "/openapi.json"
@@ -80,12 +80,12 @@ def shutdown_mt5_client() -> None:
80
80
  async def replace_mt5_client(config: Mt5Config) -> Mt5DataClient:
81
81
  """Swap the MT5 client singleton with a new connection.
82
82
 
83
- Constructs and initializes the new ``Mt5DataClient`` BEFORE touching the
84
- singleton so a failed reconnect leaves the existing client in place
85
- instead of disconnecting the operator from a working terminal. The new
86
- client is installed atomically; the old client (if any) is shut down on
87
- a best-effort basis after the swap. Callers MUST hold
88
- ``get_mt5_client_lock`` so concurrent reconnect requests do not race.
83
+ Constructs and initializes the new ``Mt5DataClient`` before replacing the
84
+ singleton reference. The MetaTrader5 Python module has process-global
85
+ connection state, so the previous client object must not be shut down after
86
+ successful reconnect because that would close the connection just opened by
87
+ the new client. Callers MUST hold ``get_mt5_client_lock`` so concurrent
88
+ reconnect requests do not race.
89
89
 
90
90
  The raised ``RuntimeError`` deliberately omits the underlying exception
91
91
  text from its message because third-party ``pdmt5``/MetaTrader5
@@ -101,7 +101,7 @@ async def replace_mt5_client(config: Mt5Config) -> Mt5DataClient:
101
101
 
102
102
  Raises:
103
103
  RuntimeError: If the new MT5 client cannot be constructed or
104
- initialized. The previously installed client is preserved.
104
+ initialized.
105
105
  """
106
106
  global _mt5_client # noqa: PLW0603
107
107
 
@@ -113,15 +113,8 @@ async def replace_mt5_client(config: Mt5Config) -> Mt5DataClient:
113
113
  error_message = "Failed to initialize MT5 client"
114
114
  raise RuntimeError(error_message) from e
115
115
 
116
- old_client = _mt5_client
117
116
  _mt5_client = new_client
118
117
 
119
- if old_client is not None:
120
- try:
121
- await asyncio.to_thread(old_client.shutdown)
122
- except Exception:
123
- logger.exception("Failed to shutdown previous MT5 client")
124
-
125
118
  return new_client
126
119
 
127
120
 
@@ -13,9 +13,9 @@ from fastapi.openapi.utils import get_openapi
13
13
 
14
14
  from .auth import is_auth_enabled
15
15
  from .config import (
16
- get_configured_api_log_level,
17
16
  get_configured_api_router_prefix,
18
17
  get_configured_max_market_book_subscriptions,
18
+ get_configured_python_log_level,
19
19
  )
20
20
  from .constants import (
21
21
  ACTIVE_MARKET_BOOK_SUBSCRIPTIONS_STATE_KEY,
@@ -31,6 +31,7 @@ from .constants import (
31
31
  )
32
32
  from .dependencies import release_market_book_subscriptions, shutdown_mt5_client
33
33
  from .middleware import add_middleware
34
+ from .models import ErrorResponse
34
35
  from .routers import (
35
36
  account,
36
37
  calc,
@@ -65,7 +66,7 @@ class _JsonFormatter(logging.Formatter):
65
66
 
66
67
  def _configure_logging() -> None:
67
68
  """Configure structured logging for the API."""
68
- log_level = get_configured_api_log_level().upper()
69
+ log_level = get_configured_python_log_level()
69
70
 
70
71
  handler = logging.StreamHandler()
71
72
  handler.setFormatter(_JsonFormatter())
@@ -96,6 +97,37 @@ def _strip_auth_from_openapi(openapi_schema: dict[str, Any]) -> None:
96
97
  operation.pop("security", None)
97
98
 
98
99
 
100
+ def _patch_validation_error_responses(openapi_schema: dict[str, Any]) -> None:
101
+ """Advertise RFC 7807 Problem Details for request validation failures."""
102
+ components = openapi_schema.setdefault("components", {})
103
+ if not isinstance(components, dict):
104
+ return
105
+
106
+ schemas = components.setdefault("schemas", {})
107
+ if isinstance(schemas, dict):
108
+ schemas["ErrorResponse"] = ErrorResponse.model_json_schema(
109
+ ref_template="#/components/schemas/{model}",
110
+ )
111
+ schemas.pop("HTTPValidationError", None)
112
+ schemas.pop("ValidationError", None)
113
+
114
+ error_response = {
115
+ "description": "Validation Error",
116
+ "content": {
117
+ "application/json": {
118
+ "schema": {"$ref": "#/components/schemas/ErrorResponse"},
119
+ },
120
+ },
121
+ }
122
+
123
+ for methods in openapi_schema.get("paths", {}).values():
124
+ if not isinstance(methods, dict):
125
+ continue
126
+ for operation in methods.values():
127
+ if isinstance(operation, dict) and "422" in operation.get("responses", {}):
128
+ operation["responses"]["422"] = error_response
129
+
130
+
99
131
  def _build_openapi_schema(app: FastAPI) -> dict[str, Any]:
100
132
  """Build OpenAPI schema for the current authentication mode.
101
133
 
@@ -113,6 +145,7 @@ def _build_openapi_schema(app: FastAPI) -> dict[str, Any]:
113
145
  )
114
146
  if not is_auth_enabled():
115
147
  _strip_auth_from_openapi(openapi_schema)
148
+ _patch_validation_error_responses(openapi_schema)
116
149
 
117
150
  app.openapi_schema = openapi_schema
118
151
  return openapi_schema
@@ -4,9 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
  import time
7
- from typing import TYPE_CHECKING
7
+ from typing import TYPE_CHECKING, Any
8
8
 
9
- from fastapi import Request, status
9
+ from fastapi import HTTPException, Request, status
10
+ from fastapi.exceptions import RequestValidationError
10
11
  from fastapi.responses import JSONResponse
11
12
  from pdmt5.mt5 import Mt5RuntimeError
12
13
  from pydantic import ValidationError
@@ -14,6 +15,8 @@ from pydantic import ValidationError
14
15
  from .models import ErrorResponse
15
16
 
16
17
  if TYPE_CHECKING:
18
+ from collections.abc import Sequence
19
+
17
20
  from fastapi import FastAPI
18
21
  from starlette.middleware.base import RequestResponseEndpoint
19
22
  from starlette.responses import Response
@@ -21,6 +24,36 @@ if TYPE_CHECKING:
21
24
  logger = logging.getLogger(__name__)
22
25
 
23
26
 
27
+ def _format_validation_error_location(location: Sequence[Any]) -> str:
28
+ """Format a Pydantic error location for display.
29
+
30
+ Args:
31
+ location: Pydantic validation error location tuple.
32
+
33
+ Returns:
34
+ Human-readable field path without the request-body prefix.
35
+ """
36
+ parts = [str(part) for part in location if str(part) != "body"]
37
+ return ".".join(parts) if parts else "request"
38
+
39
+
40
+ def _format_request_validation_detail(exc: RequestValidationError) -> str:
41
+ """Build a sanitized validation detail string without echoing request input.
42
+
43
+ Args:
44
+ exc: FastAPI request validation error.
45
+
46
+ Returns:
47
+ Validation summary containing field paths and messages only.
48
+ """
49
+ details: list[str] = []
50
+ for error in exc.errors():
51
+ location = _format_validation_error_location(error.get("loc", ()))
52
+ message = str(error.get("msg", "Invalid value"))
53
+ details.append(f"{location}: {message}")
54
+ return "; ".join(details) if details else "Request validation failed"
55
+
56
+
24
57
  def _create_error_response(
25
58
  error_type: str,
26
59
  title: str,
@@ -162,11 +195,58 @@ async def logging_middleware(
162
195
  return response
163
196
 
164
197
 
198
+ def http_exception_handler(
199
+ request: Request,
200
+ exc: HTTPException,
201
+ ) -> JSONResponse:
202
+ """Convert FastAPI HTTP exceptions to flat RFC 7807 responses.
203
+
204
+ Returns:
205
+ Problem Details JSON response.
206
+ """
207
+ if isinstance(exc.detail, dict):
208
+ detail = exc.detail.get("detail", str(exc.detail))
209
+ error_type = str(exc.detail.get("type", "/errors/http-error"))
210
+ title = str(exc.detail.get("title", "HTTP Error"))
211
+ else:
212
+ detail = str(exc.detail)
213
+ error_type = "/errors/http-error"
214
+ title = "HTTP Error"
215
+
216
+ return _create_error_response(
217
+ error_type,
218
+ title,
219
+ exc.status_code,
220
+ detail,
221
+ str(request.url),
222
+ )
223
+
224
+
225
+ def request_validation_exception_handler(
226
+ request: Request,
227
+ exc: RequestValidationError,
228
+ ) -> JSONResponse:
229
+ """Convert FastAPI request validation failures to RFC 7807 responses.
230
+
231
+ Returns:
232
+ Problem Details JSON response.
233
+ """
234
+ return _create_error_response(
235
+ "/errors/validation-error",
236
+ "Request Validation Failed",
237
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
238
+ _format_request_validation_detail(exc),
239
+ str(request.url),
240
+ )
241
+
242
+
165
243
  def add_middleware(app: FastAPI) -> None:
166
244
  """Add middleware and error handlers to the FastAPI application.
167
245
 
168
246
  Args:
169
247
  app: FastAPI application instance.
170
248
  """
249
+ app.exception_handler(HTTPException)(http_exception_handler)
250
+ app.exception_handler(RequestValidationError)(request_validation_exception_handler)
171
251
  app.middleware("http")(error_handler_middleware)
172
252
  app.middleware("http")(logging_middleware)
@@ -29,8 +29,8 @@ router = APIRouter(
29
29
  summary="Login to MT5 terminal",
30
30
  description=(
31
31
  "Reconnect the MT5 terminal using the supplied credentials. The current "
32
- "MT5 client (if any) is shut down and any active market-book "
33
- "subscriptions are released before the new connection is established."
32
+ "MT5 singleton is replaced after the new connection is established, then "
33
+ "any active market-book subscription tracking is cleared."
34
34
  ),
35
35
  )
36
36
  async def post_connection_login(
@@ -56,8 +56,8 @@ async def post_connection_login(
56
56
  )
57
57
 
58
58
  async with get_mt5_client_lock():
59
- await release_market_book_subscriptions(app_request.app)
60
59
  await replace_mt5_client(config)
60
+ await release_market_book_subscriptions(app_request.app)
61
61
 
62
62
  logger.info(
63
63
  "MT5 client reconnected (login=%d, server=%s)",
@@ -46,12 +46,12 @@ async def get_health() -> HealthResponse:
46
46
  mt5_version = None
47
47
 
48
48
  try:
49
- client = get_mt5_client()
50
- mt5_connected = True
49
+ client = await run_in_threadpool(get_mt5_client)
51
50
  version_dict = await run_in_threadpool(client.version_as_dict)
52
51
  if version_dict:
53
- mt5_version = f"{version_dict.get('version', 'unknown')}"
54
- except RuntimeError as e:
52
+ mt5_version = f"{version_dict.get('mt5_terminal_version', 'unknown')}"
53
+ mt5_connected = True
54
+ except Exception as e: # noqa: BLE001
55
55
  logger.debug("MT5 not available for health check: %s", e)
56
56
 
57
57
  return HealthResponse(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mt5api"
3
- version = "1.0.0"
3
+ version = "1.0.1"
4
4
  description = "MetaTrader 5 REST API"
5
5
  authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
6
6
  maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
@@ -9,7 +9,7 @@ license-files = ["LICENSE"]
9
9
  readme = "README.md"
10
10
  requires-python = ">= 3.11, < 3.14"
11
11
  dependencies = [
12
- "pdmt5 >= 1.0.1",
12
+ "pdmt5 >= 1.1.1",
13
13
  "fastapi >= 0.115.0",
14
14
  "uvicorn[standard] >= 0.32.0",
15
15
  "pyarrow >= 18.0.0",
@@ -44,9 +44,9 @@ def mock_mt5_client() -> Mock:
44
44
 
45
45
  # Mock version method
46
46
  client.version_as_dict.return_value = {
47
- "version": "5.0.4321",
47
+ "mt5_terminal_version": "5.0.4321",
48
48
  "build": 4321,
49
- "release_date": "2024-01-01",
49
+ "build_release_date": "2024-01-01",
50
50
  }
51
51
 
52
52
  # Mock account info