mt4ctl 0.2.0__tar.gz → 0.3.0__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.
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/CHANGELOG.md +17 -1
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/PKG-INFO +4 -1
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/README.md +3 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/docs/tools.md +34 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/pyproject.toml +1 -1
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/__init__.py +1 -1
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/models.py +52 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/operations.py +58 -1
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/scripts.py +37 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/server.py +92 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_models.py +15 -1
- mt4ctl-0.3.0/tests/test_operations.py +167 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_scripts.py +20 -0
- mt4ctl-0.2.0/tests/test_operations.py +0 -80
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/.github/workflows/ci.yml +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/.github/workflows/release.yml +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/.gitignore +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/.pre-commit-config.yaml +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/CONTRIBUTING.md +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/LICENSE +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/docs/architecture.md +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/docs/configuration.md +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/docs/install-linux-ubuntu.md +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/docs/install-windows-wsl.md +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/examples/mcp.json.example +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/examples/terminals.example.yaml +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/__main__.py +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/auth.py +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/cli.py +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/config.py +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/diagnostics.py +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/errors.py +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/login.py +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/py.typed +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/ssh.py +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/conftest.py +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_auth.py +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_cli.py +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_config.py +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_diagnostics.py +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_login.py +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_server.py +0 -0
- {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_ssh.py +0 -0
|
@@ -6,6 +6,21 @@ to [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - 2026-05-23
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `mt4_ea_list` — inventory of experts (strategies) attached per terminal.
|
|
14
|
+
- `mt4_autotrading` — terminal master AutoTrading switch (from `terminal.ini`)
|
|
15
|
+
plus per-EA live-trading status (best-effort decode of the chart-expert flags).
|
|
16
|
+
- `mt4_info` — terminal build, broker server, and last broker ping (from logs).
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Fan-out tools (`mt4_ea_list`/`mt4_autotrading`/`mt4_info`) no longer blank the
|
|
21
|
+
whole `all` result when a single host is unreachable — each terminal renders
|
|
22
|
+
its own error row (matching `mt4_status`).
|
|
23
|
+
|
|
9
24
|
## [0.2.0] - 2026-05-23
|
|
10
25
|
|
|
11
26
|
### Added
|
|
@@ -56,6 +71,7 @@ Initial release.
|
|
|
56
71
|
fail-fast config resolution and actionable SSH-failure errors.
|
|
57
72
|
- Test suite, strict `mypy`, `ruff`, and a 3.11–3.13 CI matrix.
|
|
58
73
|
|
|
59
|
-
[Unreleased]: https://github.com/ak40u/mt4ctl/compare/v0.
|
|
74
|
+
[Unreleased]: https://github.com/ak40u/mt4ctl/compare/v0.3.0...HEAD
|
|
75
|
+
[0.3.0]: https://github.com/ak40u/mt4ctl/compare/v0.2.0...v0.3.0
|
|
60
76
|
[0.2.0]: https://github.com/ak40u/mt4ctl/compare/v0.1.0...v0.2.0
|
|
61
77
|
[0.1.0]: https://github.com/ak40u/mt4ctl/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mt4ctl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: MCP server for managing headless MetaTrader terminals over SSH (Wine + systemd)
|
|
5
5
|
Project-URL: Homepage, https://github.com/ak40u/mt4ctl
|
|
6
6
|
Project-URL: Repository, https://github.com/ak40u/mt4ctl
|
|
@@ -246,6 +246,9 @@ and an absolute `command` path if `uvx` is not on the GUI app's `PATH` (`which u
|
|
|
246
246
|
| `mt4_control` | ✓ | `start` / `stop` / `restart` a unit (live needs `confirm`). |
|
|
247
247
|
| `mt4_login` | ✓ | One-time headless login for auto-reconnect (live needs `confirm`). |
|
|
248
248
|
| `mt4_doctor` | – | Diagnose registry, SSH, remote tools, units, and data dirs. |
|
|
249
|
+
| `mt4_ea_list` | – | List the experts (strategies) attached per terminal. |
|
|
250
|
+
| `mt4_autotrading` | – | AutoTrading master switch + per-EA live-trading status. |
|
|
251
|
+
| `mt4_info` | – | Terminal build, broker server, and last broker ping. |
|
|
249
252
|
|
|
250
253
|
Full reference: [`docs/tools.md`](docs/tools.md).
|
|
251
254
|
|
|
@@ -214,6 +214,9 @@ and an absolute `command` path if `uvx` is not on the GUI app's `PATH` (`which u
|
|
|
214
214
|
| `mt4_control` | ✓ | `start` / `stop` / `restart` a unit (live needs `confirm`). |
|
|
215
215
|
| `mt4_login` | ✓ | One-time headless login for auto-reconnect (live needs `confirm`). |
|
|
216
216
|
| `mt4_doctor` | – | Diagnose registry, SSH, remote tools, units, and data dirs. |
|
|
217
|
+
| `mt4_ea_list` | – | List the experts (strategies) attached per terminal. |
|
|
218
|
+
| `mt4_autotrading` | – | AutoTrading master switch + per-EA live-trading status. |
|
|
219
|
+
| `mt4_info` | – | Terminal build, broker server, and last broker ping. |
|
|
217
220
|
|
|
218
221
|
Full reference: [`docs/tools.md`](docs/tools.md).
|
|
219
222
|
|
|
@@ -92,3 +92,37 @@ reachability, required remote tools, systemd units, data directories, and
|
|
|
92
92
|
connected, rather than reporting a misleading "all passed"). Returns a ✓/!/✗
|
|
93
93
|
checklist. Use it when a terminal is unexpectedly `unknown` or `mt4_status` looks
|
|
94
94
|
wrong. The same checks are available from the shell as `mt4ctl doctor`.
|
|
95
|
+
|
|
96
|
+
## `mt4_ea_list`
|
|
97
|
+
|
|
98
|
+
| Arg | Type | Default | Description |
|
|
99
|
+
| --- | --- | --- | --- |
|
|
100
|
+
| `terminal` | string | `"all"` | a terminal id, or "all" |
|
|
101
|
+
|
|
102
|
+
Read-only. Lists the expert advisors (strategies) attached to a terminal, parsed
|
|
103
|
+
from its chart files. For a single terminal it lists every EA; for `"all"` it
|
|
104
|
+
shows the count per terminal.
|
|
105
|
+
|
|
106
|
+
## `mt4_autotrading`
|
|
107
|
+
|
|
108
|
+
| Arg | Type | Default | Description |
|
|
109
|
+
| --- | --- | --- | --- |
|
|
110
|
+
| `terminal` | string | `"all"` | a terminal id, or "all" |
|
|
111
|
+
|
|
112
|
+
Read-only. Reports whether algo-trading is enabled, at two levels: the terminal
|
|
113
|
+
**master AutoTrading** switch (from `terminal.ini` `Experts=` — authoritative)
|
|
114
|
+
and how many attached experts have live-trading enabled. Flags terminals whose
|
|
115
|
+
master is off (nothing trades) or whose experts have live-trading disabled.
|
|
116
|
+
|
|
117
|
+
> The per-EA live-trading flag is a *best-effort* decode of the MT4 chart-expert
|
|
118
|
+
> `flags` bitmask (low bit); the terminal master switch is authoritative.
|
|
119
|
+
|
|
120
|
+
## `mt4_info`
|
|
121
|
+
|
|
122
|
+
| Arg | Type | Default | Description |
|
|
123
|
+
| --- | --- | --- | --- |
|
|
124
|
+
| `terminal` | string | `"all"` | a terminal id, or "all" |
|
|
125
|
+
|
|
126
|
+
Read-only. Reports each terminal's build, broker server, and last broker ping,
|
|
127
|
+
parsed from its log — useful to confirm what build/broker a terminal is on and
|
|
128
|
+
its connection latency.
|
|
@@ -104,6 +104,58 @@ class TerminalStatus:
|
|
|
104
104
|
return self.service_state == "active" and self.connected is True
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
# Bit in the MT4 chart-expert ``flags`` bitmask that corresponds to "Allow live
|
|
108
|
+
# trading". Derived (not officially documented); on a healthy SQX farm every
|
|
109
|
+
# trading expert reports flags=343 (odd → this bit set). Treated as best-effort.
|
|
110
|
+
EXPERT_LIVE_TRADING_BIT = 0x01
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class Expert:
|
|
115
|
+
"""An expert advisor attached to a chart."""
|
|
116
|
+
|
|
117
|
+
name: str
|
|
118
|
+
"""Expert name as stored in the .chr file (``folder\\EA name``)."""
|
|
119
|
+
flags: int
|
|
120
|
+
"""MT4 chart-expert flags bitmask, or -1 if unparsable."""
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def short_name(self) -> str:
|
|
124
|
+
"""The EA name without its folder prefix."""
|
|
125
|
+
return self.name.replace("\\", "/").rsplit("/", 1)[-1]
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def live_trading(self) -> bool | None:
|
|
129
|
+
"""Best-effort decode of the live-trading bit; ``None`` if flags unknown."""
|
|
130
|
+
if self.flags < 0:
|
|
131
|
+
return None
|
|
132
|
+
return bool(self.flags & EXPERT_LIVE_TRADING_BIT)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class ExpertsReport:
|
|
137
|
+
"""AutoTrading master switch + attached experts for one terminal."""
|
|
138
|
+
|
|
139
|
+
terminal: str
|
|
140
|
+
master: bool | None
|
|
141
|
+
"""Terminal-level AutoTrading (``Experts=`` in terminal.ini); None if unknown."""
|
|
142
|
+
experts: list[Expert]
|
|
143
|
+
error: str | None = None
|
|
144
|
+
"""Set when the host could not be reached, so one dead host never blanks a fan-out."""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True, slots=True)
|
|
148
|
+
class TerminalInfo:
|
|
149
|
+
"""Build / broker / latency facts parsed from a terminal's log."""
|
|
150
|
+
|
|
151
|
+
terminal: str
|
|
152
|
+
build: str | None
|
|
153
|
+
server: str | None
|
|
154
|
+
ping_ms: float | None
|
|
155
|
+
error: str | None = None
|
|
156
|
+
"""Set when the host could not be reached."""
|
|
157
|
+
|
|
158
|
+
|
|
107
159
|
@dataclass(frozen=True, slots=True)
|
|
108
160
|
class Registry:
|
|
109
161
|
"""The full set of configured hosts and terminals."""
|
|
@@ -14,7 +14,18 @@ from pathlib import Path
|
|
|
14
14
|
|
|
15
15
|
from . import scripts, ssh
|
|
16
16
|
from .errors import ConfirmationRequiredError, RemoteCommandError
|
|
17
|
-
from .models import
|
|
17
|
+
from .models import (
|
|
18
|
+
Env,
|
|
19
|
+
Expert,
|
|
20
|
+
ExpertsReport,
|
|
21
|
+
Host,
|
|
22
|
+
Registry,
|
|
23
|
+
Terminal,
|
|
24
|
+
TerminalInfo,
|
|
25
|
+
TerminalStatus,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_PING_RE = re.compile(r"login on (\S+) through[^(]*\(ping: ([0-9.]+) ms\)")
|
|
18
29
|
|
|
19
30
|
CONTROL_ACTIONS = ("start", "stop", "restart")
|
|
20
31
|
|
|
@@ -122,6 +133,52 @@ async def logs(
|
|
|
122
133
|
return result.stdout.strip() or "(no log output)"
|
|
123
134
|
|
|
124
135
|
|
|
136
|
+
async def experts(registry: Registry, terminal_id: str) -> ExpertsReport:
|
|
137
|
+
"""Report the AutoTrading master switch and attached experts for a terminal."""
|
|
138
|
+
term = registry.terminal(terminal_id)
|
|
139
|
+
host = registry.host_of(term)
|
|
140
|
+
try:
|
|
141
|
+
result = await ssh.run(host, scripts.build_experts_script(term.data_dir), check=False)
|
|
142
|
+
except RemoteCommandError as exc:
|
|
143
|
+
# One unreachable host must not blank a whole fan-out (matches status()).
|
|
144
|
+
return ExpertsReport(terminal=terminal_id, master=None, experts=[], error=str(exc))
|
|
145
|
+
master: bool | None = None
|
|
146
|
+
eas: list[Expert] = []
|
|
147
|
+
for line in result.stdout.splitlines():
|
|
148
|
+
parts = line.split(scripts.SEP)
|
|
149
|
+
if parts[0] == "MASTER" and len(parts) >= 2:
|
|
150
|
+
master = {"1": True, "0": False}.get(parts[1].strip())
|
|
151
|
+
elif parts[0] == "EA" and len(parts) >= 3:
|
|
152
|
+
try:
|
|
153
|
+
flags = int(parts[2])
|
|
154
|
+
except ValueError:
|
|
155
|
+
flags = -1
|
|
156
|
+
eas.append(Expert(name=parts[1], flags=flags))
|
|
157
|
+
return ExpertsReport(terminal=terminal_id, master=master, experts=eas)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def info(registry: Registry, terminal_id: str) -> TerminalInfo:
|
|
161
|
+
"""Report build / broker server / last ping for a terminal, parsed from logs."""
|
|
162
|
+
term = registry.terminal(terminal_id)
|
|
163
|
+
host = registry.host_of(term)
|
|
164
|
+
try:
|
|
165
|
+
result = await ssh.run(host, scripts.build_info_script(term.data_dir), check=False)
|
|
166
|
+
except RemoteCommandError as exc:
|
|
167
|
+
return TerminalInfo(
|
|
168
|
+
terminal=terminal_id, build=None, server=None, ping_ms=None, error=str(exc)
|
|
169
|
+
)
|
|
170
|
+
build = server = None
|
|
171
|
+
ping: float | None = None
|
|
172
|
+
for line in result.stdout.splitlines():
|
|
173
|
+
parts = line.split(scripts.SEP, 1)
|
|
174
|
+
if parts[0] == "BUILD" and len(parts) == 2:
|
|
175
|
+
build = parts[1].strip() or None
|
|
176
|
+
elif parts[0] == "LOGIN" and len(parts) == 2 and (m := _PING_RE.search(parts[1])):
|
|
177
|
+
server = m.group(1)
|
|
178
|
+
ping = float(m.group(2))
|
|
179
|
+
return TerminalInfo(terminal=terminal_id, build=build, server=server, ping_ms=ping)
|
|
180
|
+
|
|
181
|
+
|
|
125
182
|
async def control(
|
|
126
183
|
registry: Registry,
|
|
127
184
|
terminal_id: str,
|
|
@@ -106,6 +106,43 @@ emit() {{
|
|
|
106
106
|
"""
|
|
107
107
|
|
|
108
108
|
|
|
109
|
+
def build_experts_script(data_dir: str) -> str:
|
|
110
|
+
"""Build a script reporting the AutoTrading master switch + attached experts.
|
|
111
|
+
|
|
112
|
+
Emits ``MASTER|<0|1|?>`` (from ``config/terminal.ini`` ``Experts=``) and one
|
|
113
|
+
``EA|<name>|<flags>`` line per attached expert (parsed from the active
|
|
114
|
+
profile's ``.chr`` files). ``flags`` is the MT4 chart-expert bitmask.
|
|
115
|
+
"""
|
|
116
|
+
dir_q = sh_quote(data_dir)
|
|
117
|
+
return f"""\
|
|
118
|
+
set +e
|
|
119
|
+
DIR={dir_q}
|
|
120
|
+
m=$(grep -iE '^Experts=' "$DIR/config/terminal.ini" 2>/dev/null | head -1 | cut -d= -f2 | tr -d '\\r')
|
|
121
|
+
[ -z "$m" ] && m="?"
|
|
122
|
+
echo "MASTER{SEP}$m"
|
|
123
|
+
for f in "$DIR"/profiles/default/*.chr; do
|
|
124
|
+
[ -e "$f" ] || continue
|
|
125
|
+
# .chr files are CRLF — strip the trailing CR so it doesn't ride into the name
|
|
126
|
+
# field, where it would become a stray splitlines() boundary downstream (each
|
|
127
|
+
# EA record would split apart) and dirty the parsed name.
|
|
128
|
+
awk '{{sub(/\\r$/,"")}} /<expert>/{{e=1;n=""}} e&&/^name=/{{n=substr($0,6)}} e&&/^flags=/{{print "EA{SEP}"n"{SEP}"substr($0,7); e=0}}' "$f"
|
|
129
|
+
done
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_info_script(data_dir: str) -> str:
|
|
134
|
+
"""Build a script reporting build, broker, and last login+ping from the log."""
|
|
135
|
+
dir_q = sh_quote(data_dir)
|
|
136
|
+
return f"""\
|
|
137
|
+
set +e
|
|
138
|
+
DIR={dir_q}
|
|
139
|
+
if ! ls "$DIR"/logs/*.log >/dev/null 2>&1; then echo "INFO{SEP}nolog"; exit 0; fi
|
|
140
|
+
# Scan all logs (the last login/ping may be in an earlier file than the newest).
|
|
141
|
+
echo "BUILD{SEP}$(grep -ahoE '([A-Za-z0-9]+ MT[45] )?build [0-9]+' "$DIR"/logs/*.log | tail -1)"
|
|
142
|
+
echo "LOGIN{SEP}$(grep -ahoE 'login on [^ ]+ through[^(]*\\(ping: [0-9.]+ ms\\)' "$DIR"/logs/*.log | tail -1)"
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
|
|
109
146
|
def build_logs_script(data_dir: str, pattern: str | None, lines: int) -> str:
|
|
110
147
|
"""Build a script that returns the tail of a terminal's newest log file."""
|
|
111
148
|
grep = (
|
|
@@ -7,6 +7,7 @@ spawn locally.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import asyncio
|
|
10
11
|
import functools
|
|
11
12
|
from collections.abc import Awaitable, Callable
|
|
12
13
|
from typing import TypeVar
|
|
@@ -228,6 +229,97 @@ async def mt4_doctor() -> str:
|
|
|
228
229
|
return diagnostics.format_checks(checks)
|
|
229
230
|
|
|
230
231
|
|
|
232
|
+
@mcp.tool()
|
|
233
|
+
@_guard
|
|
234
|
+
async def mt4_ea_list(terminal: str = "all") -> str:
|
|
235
|
+
"""List the expert advisors (strategies) attached to terminals.
|
|
236
|
+
|
|
237
|
+
For a single terminal, lists every attached EA; for "all", shows the count
|
|
238
|
+
per terminal. Read-only (parses the terminal's chart files).
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
terminal: a terminal id, or "all" (default).
|
|
242
|
+
"""
|
|
243
|
+
reg = registry()
|
|
244
|
+
ids = list(reg.terminals) if terminal == "all" else [terminal]
|
|
245
|
+
reports = await asyncio.gather(*(operations.experts(reg, t) for t in ids))
|
|
246
|
+
if len(ids) == 1:
|
|
247
|
+
r = reports[0]
|
|
248
|
+
if r.error:
|
|
249
|
+
return f"{ids[0]}: unreachable — {r.error}"
|
|
250
|
+
names = "\n".join(f" {e.short_name}" for e in r.experts) or " (none)"
|
|
251
|
+
return f"{ids[0]}: {len(r.experts)} experts\n{names}"
|
|
252
|
+
return "\n".join(
|
|
253
|
+
f"{r.terminal:<12} unreachable — {r.error}"
|
|
254
|
+
if r.error
|
|
255
|
+
else f"{r.terminal:<12} {len(r.experts):>4} experts"
|
|
256
|
+
for r in reports
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@mcp.tool()
|
|
261
|
+
@_guard
|
|
262
|
+
async def mt4_autotrading(terminal: str = "all") -> str:
|
|
263
|
+
"""Report whether algo-trading is enabled — terminal master switch + per-EA.
|
|
264
|
+
|
|
265
|
+
Shows the terminal-level AutoTrading button (from terminal.ini) and how many
|
|
266
|
+
attached experts have live-trading enabled. Flags terminals whose master is
|
|
267
|
+
off (nothing trades) or whose experts have non-uniform/disabled flags.
|
|
268
|
+
|
|
269
|
+
Note: the per-EA live-trading flag is a best-effort decode of the MT4
|
|
270
|
+
chart-expert bitmask; the terminal master switch is authoritative.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
terminal: a terminal id, or "all" (default).
|
|
274
|
+
"""
|
|
275
|
+
reg = registry()
|
|
276
|
+
ids = list(reg.terminals) if terminal == "all" else [terminal]
|
|
277
|
+
reports = await asyncio.gather(*(operations.experts(reg, t) for t in ids))
|
|
278
|
+
lines = [f"{'TERMINAL':<12} {'AUTOTRADING':<12} EXPERTS"]
|
|
279
|
+
for r in reports:
|
|
280
|
+
if r.error:
|
|
281
|
+
lines.append(f"{r.terminal:<12} {'?':<12} unreachable — {r.error}")
|
|
282
|
+
continue
|
|
283
|
+
master = {True: "on", False: "OFF", None: "?"}[r.master]
|
|
284
|
+
total = len(r.experts)
|
|
285
|
+
live = sum(e.live_trading is True for e in r.experts)
|
|
286
|
+
off = [e.short_name for e in r.experts if e.live_trading is False]
|
|
287
|
+
unknown = sum(e.live_trading is None for e in r.experts)
|
|
288
|
+
note = ""
|
|
289
|
+
if r.master is False:
|
|
290
|
+
note = " <- master AutoTrading OFF; nothing trades"
|
|
291
|
+
elif off:
|
|
292
|
+
note = f" <- {len(off)} EA live-trading off: {', '.join(off[:5])}"
|
|
293
|
+
elif unknown:
|
|
294
|
+
note = f" <- {unknown} EA flags unreadable"
|
|
295
|
+
lines.append(f"{r.terminal:<12} {master:<12} {live}/{total} live{note}")
|
|
296
|
+
return "\n".join(lines)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@mcp.tool()
|
|
300
|
+
@_guard
|
|
301
|
+
async def mt4_info(terminal: str = "all") -> str:
|
|
302
|
+
"""Report each terminal's build, broker server, and last broker ping.
|
|
303
|
+
|
|
304
|
+
Read-only (parsed from the terminal's log). Useful to confirm what build and
|
|
305
|
+
broker a terminal is on and its connection latency.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
terminal: a terminal id, or "all" (default).
|
|
309
|
+
"""
|
|
310
|
+
reg = registry()
|
|
311
|
+
ids = list(reg.terminals) if terminal == "all" else [terminal]
|
|
312
|
+
infos = await asyncio.gather(*(operations.info(reg, t) for t in ids))
|
|
313
|
+
lines = [f"{'TERMINAL':<12} {'BUILD':<22} {'SERVER':<18} PING"]
|
|
314
|
+
for i in infos:
|
|
315
|
+
if i.error:
|
|
316
|
+
lines.append(f"{i.terminal:<12} unreachable — {i.error}")
|
|
317
|
+
continue
|
|
318
|
+
ping = "-" if i.ping_ms is None else f"{i.ping_ms:.0f}ms"
|
|
319
|
+
lines.append(f"{i.terminal:<12} {(i.build or '-'):<22} {(i.server or '-'):<18} {ping}")
|
|
320
|
+
return "\n".join(lines)
|
|
321
|
+
|
|
322
|
+
|
|
231
323
|
def serve() -> None:
|
|
232
324
|
"""Run the MCP server over stdio (the form MCP clients launch)."""
|
|
233
325
|
mcp.run()
|
|
@@ -5,7 +5,21 @@ from __future__ import annotations
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
7
|
from mt4ctl.errors import UnknownTargetError
|
|
8
|
-
from mt4ctl.models import Env, Host, HostKind, TerminalStatus
|
|
8
|
+
from mt4ctl.models import Env, Expert, Host, HostKind, TerminalStatus
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_expert_short_name_strips_folder():
|
|
12
|
+
assert Expert(name="SQ-29-03-2026\\SQ AUDUSD H4 0.157419", flags=343).short_name == (
|
|
13
|
+
"SQ AUDUSD H4 0.157419"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.parametrize(
|
|
18
|
+
("flags", "expected"),
|
|
19
|
+
[(343, True), (342, False), (0, False), (-1, None)],
|
|
20
|
+
)
|
|
21
|
+
def test_expert_live_trading_decode(flags, expected):
|
|
22
|
+
assert Expert(name="x", flags=flags).live_trading is expected
|
|
9
23
|
|
|
10
24
|
|
|
11
25
|
def test_wsl_host_requires_distro():
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Status parsing and the live-confirmation guard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from mt4ctl import operations, ssh
|
|
8
|
+
from mt4ctl.errors import ConfirmationRequiredError
|
|
9
|
+
from mt4ctl.operations import _parse_status_line
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _term(registry, tid):
|
|
13
|
+
return registry.terminal(tid)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_parse_connected_and_age(registry):
|
|
17
|
+
line = "TERM|demo1|active|12|2|login on Demo"
|
|
18
|
+
st = _parse_status_line(line, _term(registry, "demo1"))
|
|
19
|
+
assert st is not None
|
|
20
|
+
assert st.service_state == "active"
|
|
21
|
+
assert st.connected is True
|
|
22
|
+
assert st.log_age_seconds == 12
|
|
23
|
+
assert st.healthy is True
|
|
24
|
+
assert st.last_event == "login on Demo"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_parse_disconnected(registry):
|
|
28
|
+
st = _parse_status_line("TERM|demo1|active|5|0|", _term(registry, "demo1"))
|
|
29
|
+
assert st.connected is False
|
|
30
|
+
assert st.healthy is False
|
|
31
|
+
assert st.last_event is None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_parse_unknown_connection_when_estab_negative(registry):
|
|
35
|
+
st = _parse_status_line("TERM|demo1|active|-1|-1|", _term(registry, "demo1"))
|
|
36
|
+
assert st.connected is None
|
|
37
|
+
assert st.log_age_seconds is None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_parse_rejects_malformed(registry):
|
|
41
|
+
assert _parse_status_line("garbage", _term(registry, "demo1")) is None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def test_status_marks_unreachable_host(registry, monkeypatch):
|
|
45
|
+
async def boom(*a, **k):
|
|
46
|
+
from mt4ctl.errors import RemoteCommandError
|
|
47
|
+
|
|
48
|
+
raise RemoteCommandError("demo-box", 255, "unreachable")
|
|
49
|
+
|
|
50
|
+
monkeypatch.setattr(ssh, "run", boom)
|
|
51
|
+
rows = await operations.status(registry, ["demo1"])
|
|
52
|
+
assert rows[0].service_state == "unknown"
|
|
53
|
+
assert rows[0].last_event == "host unreachable"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def test_control_live_requires_confirm(registry, monkeypatch):
|
|
57
|
+
async def fail(*a, **k):
|
|
58
|
+
raise AssertionError("ssh.run must not be called without confirmation")
|
|
59
|
+
|
|
60
|
+
monkeypatch.setattr(ssh, "run", fail)
|
|
61
|
+
with pytest.raises(ConfirmationRequiredError):
|
|
62
|
+
await operations.control(registry, "live-main", "restart", confirm=False)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def test_control_rejects_bad_action(registry):
|
|
66
|
+
with pytest.raises(ValueError, match="action must be"):
|
|
67
|
+
await operations.control(registry, "demo1", "frobnicate")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def test_experts_parses_master_and_flags(registry, monkeypatch):
|
|
71
|
+
from mt4ctl.ssh import CommandResult
|
|
72
|
+
|
|
73
|
+
out = "MASTER|1\nEA|SQ-29-03-2026\\SQ AUDUSD H4 0.157419|343\nEA|Util\\Heartbeat|342\n"
|
|
74
|
+
|
|
75
|
+
async def fake_run(host, script, **kw):
|
|
76
|
+
return CommandResult(0, out, "")
|
|
77
|
+
|
|
78
|
+
monkeypatch.setattr(ssh, "run", fake_run)
|
|
79
|
+
r = await operations.experts(registry, "demo1")
|
|
80
|
+
assert r.master is True
|
|
81
|
+
assert [e.flags for e in r.experts] == [343, 342]
|
|
82
|
+
assert r.experts[0].short_name == "SQ AUDUSD H4 0.157419"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def test_experts_master_off_and_unknown(registry, monkeypatch):
|
|
86
|
+
from mt4ctl.ssh import CommandResult
|
|
87
|
+
|
|
88
|
+
async def fake_run(host, script, **kw):
|
|
89
|
+
return CommandResult(0, "MASTER|0\n", "")
|
|
90
|
+
|
|
91
|
+
monkeypatch.setattr(ssh, "run", fake_run)
|
|
92
|
+
r = await operations.experts(registry, "demo1")
|
|
93
|
+
assert r.master is False and r.experts == []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def test_experts_unparsable_flags_and_unknown_master(registry, monkeypatch):
|
|
97
|
+
from mt4ctl.ssh import CommandResult
|
|
98
|
+
|
|
99
|
+
async def fake_run(host, script, **kw):
|
|
100
|
+
return CommandResult(0, "MASTER|?\nEA|x\\y|notanumber\n", "")
|
|
101
|
+
|
|
102
|
+
monkeypatch.setattr(ssh, "run", fake_run)
|
|
103
|
+
r = await operations.experts(registry, "demo1")
|
|
104
|
+
assert r.master is None
|
|
105
|
+
assert r.experts[0].flags == -1
|
|
106
|
+
assert r.experts[0].live_trading is None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def test_experts_unreachable_host_does_not_raise(registry, monkeypatch):
|
|
110
|
+
from mt4ctl.errors import RemoteCommandError
|
|
111
|
+
|
|
112
|
+
async def boom(host, script, **kw):
|
|
113
|
+
raise RemoteCommandError(host.id, 124, "command timed out")
|
|
114
|
+
|
|
115
|
+
monkeypatch.setattr(ssh, "run", boom)
|
|
116
|
+
r = await operations.experts(registry, "demo1")
|
|
117
|
+
assert r.error is not None and r.experts == [] and r.master is None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def test_info_nolog_and_unreachable(registry, monkeypatch):
|
|
121
|
+
from mt4ctl.errors import RemoteCommandError
|
|
122
|
+
from mt4ctl.ssh import CommandResult
|
|
123
|
+
|
|
124
|
+
async def fake_run(host, script, **kw):
|
|
125
|
+
return CommandResult(0, "INFO|nolog\n", "")
|
|
126
|
+
|
|
127
|
+
monkeypatch.setattr(ssh, "run", fake_run)
|
|
128
|
+
i = await operations.info(registry, "demo1")
|
|
129
|
+
assert i.build is None and i.server is None and i.ping_ms is None and i.error is None
|
|
130
|
+
|
|
131
|
+
async def boom(host, script, **kw):
|
|
132
|
+
raise RemoteCommandError(host.id, 127, "could not start ssh")
|
|
133
|
+
|
|
134
|
+
monkeypatch.setattr(ssh, "run", boom)
|
|
135
|
+
i2 = await operations.info(registry, "demo1")
|
|
136
|
+
assert i2.error is not None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def test_info_parses_build_server_ping(registry, monkeypatch):
|
|
140
|
+
from mt4ctl.ssh import CommandResult
|
|
141
|
+
|
|
142
|
+
out = (
|
|
143
|
+
"BUILD|Forex4you MT4 build 1470\n"
|
|
144
|
+
"LOGIN|login on Darwinex-Demo through primary 2 (ping: 53.40 ms)\n"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
async def fake_run(host, script, **kw):
|
|
148
|
+
return CommandResult(0, out, "")
|
|
149
|
+
|
|
150
|
+
monkeypatch.setattr(ssh, "run", fake_run)
|
|
151
|
+
i = await operations.info(registry, "demo1")
|
|
152
|
+
assert i.build == "Forex4you MT4 build 1470"
|
|
153
|
+
assert i.server == "Darwinex-Demo"
|
|
154
|
+
assert i.ping_ms == 53.40
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def test_logs_surfaces_ssh_failure(registry, monkeypatch):
|
|
158
|
+
from mt4ctl.ssh import CommandResult
|
|
159
|
+
|
|
160
|
+
async def fake_run(host, script, **kw):
|
|
161
|
+
return CommandResult(255, "", "ssh: connect to host failed")
|
|
162
|
+
|
|
163
|
+
monkeypatch.setattr(ssh, "run", fake_run)
|
|
164
|
+
out = await operations.logs(registry, "demo1")
|
|
165
|
+
assert "cannot read logs for demo1" in out
|
|
166
|
+
assert "SSH to host" in out
|
|
167
|
+
assert "(no output)" not in out
|
|
@@ -54,6 +54,26 @@ def test_status_script_handles_broker_failure_and_visibility():
|
|
|
54
54
|
assert "svcuser=$(systemctl show -p User" in out
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def test_experts_script_reads_master_and_charts():
|
|
58
|
+
out = scripts.build_experts_script("/home/t/demo1")
|
|
59
|
+
assert "config/terminal.ini" in out
|
|
60
|
+
assert "^Experts=" in out
|
|
61
|
+
assert "profiles/default/*.chr" in out
|
|
62
|
+
assert "MASTER|" in out and "EA|" in out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_info_script_extracts_build_and_ping():
|
|
66
|
+
out = scripts.build_info_script("/home/t/demo1")
|
|
67
|
+
assert "build [0-9]+" in out
|
|
68
|
+
assert "ping:" in out
|
|
69
|
+
assert "BUILD|" in out and "LOGIN|" in out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_experts_script_strips_crlf():
|
|
73
|
+
out = scripts.build_experts_script("/home/t/demo1")
|
|
74
|
+
assert "sub(/\\r$/" in out # CRLF guard so flags parse and lines don't over-split
|
|
75
|
+
|
|
76
|
+
|
|
57
77
|
def test_doctor_script_probes_tools_and_terminals():
|
|
58
78
|
out = scripts.build_doctor_script([("t1", "mt4-t1", "/home/t/t1")])
|
|
59
79
|
assert "command -v" in out
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
"""Status parsing and the live-confirmation guard."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from mt4ctl import operations, ssh
|
|
8
|
-
from mt4ctl.errors import ConfirmationRequiredError
|
|
9
|
-
from mt4ctl.operations import _parse_status_line
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def _term(registry, tid):
|
|
13
|
-
return registry.terminal(tid)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def test_parse_connected_and_age(registry):
|
|
17
|
-
line = "TERM|demo1|active|12|2|login on Demo"
|
|
18
|
-
st = _parse_status_line(line, _term(registry, "demo1"))
|
|
19
|
-
assert st is not None
|
|
20
|
-
assert st.service_state == "active"
|
|
21
|
-
assert st.connected is True
|
|
22
|
-
assert st.log_age_seconds == 12
|
|
23
|
-
assert st.healthy is True
|
|
24
|
-
assert st.last_event == "login on Demo"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def test_parse_disconnected(registry):
|
|
28
|
-
st = _parse_status_line("TERM|demo1|active|5|0|", _term(registry, "demo1"))
|
|
29
|
-
assert st.connected is False
|
|
30
|
-
assert st.healthy is False
|
|
31
|
-
assert st.last_event is None
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_parse_unknown_connection_when_estab_negative(registry):
|
|
35
|
-
st = _parse_status_line("TERM|demo1|active|-1|-1|", _term(registry, "demo1"))
|
|
36
|
-
assert st.connected is None
|
|
37
|
-
assert st.log_age_seconds is None
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_parse_rejects_malformed(registry):
|
|
41
|
-
assert _parse_status_line("garbage", _term(registry, "demo1")) is None
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
async def test_status_marks_unreachable_host(registry, monkeypatch):
|
|
45
|
-
async def boom(*a, **k):
|
|
46
|
-
from mt4ctl.errors import RemoteCommandError
|
|
47
|
-
|
|
48
|
-
raise RemoteCommandError("demo-box", 255, "unreachable")
|
|
49
|
-
|
|
50
|
-
monkeypatch.setattr(ssh, "run", boom)
|
|
51
|
-
rows = await operations.status(registry, ["demo1"])
|
|
52
|
-
assert rows[0].service_state == "unknown"
|
|
53
|
-
assert rows[0].last_event == "host unreachable"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
async def test_control_live_requires_confirm(registry, monkeypatch):
|
|
57
|
-
async def fail(*a, **k):
|
|
58
|
-
raise AssertionError("ssh.run must not be called without confirmation")
|
|
59
|
-
|
|
60
|
-
monkeypatch.setattr(ssh, "run", fail)
|
|
61
|
-
with pytest.raises(ConfirmationRequiredError):
|
|
62
|
-
await operations.control(registry, "live-main", "restart", confirm=False)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
async def test_control_rejects_bad_action(registry):
|
|
66
|
-
with pytest.raises(ValueError, match="action must be"):
|
|
67
|
-
await operations.control(registry, "demo1", "frobnicate")
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
async def test_logs_surfaces_ssh_failure(registry, monkeypatch):
|
|
71
|
-
from mt4ctl.ssh import CommandResult
|
|
72
|
-
|
|
73
|
-
async def fake_run(host, script, **kw):
|
|
74
|
-
return CommandResult(255, "", "ssh: connect to host failed")
|
|
75
|
-
|
|
76
|
-
monkeypatch.setattr(ssh, "run", fake_run)
|
|
77
|
-
out = await operations.logs(registry, "demo1")
|
|
78
|
-
assert "cannot read logs for demo1" in out
|
|
79
|
-
assert "SSH to host" in out
|
|
80
|
-
assert "(no output)" not in out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|