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.
Files changed (43) hide show
  1. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/CHANGELOG.md +17 -1
  2. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/PKG-INFO +4 -1
  3. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/README.md +3 -0
  4. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/docs/tools.md +34 -0
  5. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/pyproject.toml +1 -1
  6. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/__init__.py +1 -1
  7. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/models.py +52 -0
  8. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/operations.py +58 -1
  9. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/scripts.py +37 -0
  10. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/server.py +92 -0
  11. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_models.py +15 -1
  12. mt4ctl-0.3.0/tests/test_operations.py +167 -0
  13. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_scripts.py +20 -0
  14. mt4ctl-0.2.0/tests/test_operations.py +0 -80
  15. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/.github/workflows/ci.yml +0 -0
  16. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/.github/workflows/release.yml +0 -0
  17. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/.gitignore +0 -0
  18. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/.pre-commit-config.yaml +0 -0
  19. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/CONTRIBUTING.md +0 -0
  20. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/LICENSE +0 -0
  21. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/docs/architecture.md +0 -0
  22. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/docs/configuration.md +0 -0
  23. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/docs/install-linux-ubuntu.md +0 -0
  24. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/docs/install-windows-wsl.md +0 -0
  25. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/examples/mcp.json.example +0 -0
  26. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/examples/terminals.example.yaml +0 -0
  27. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/__main__.py +0 -0
  28. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/auth.py +0 -0
  29. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/cli.py +0 -0
  30. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/config.py +0 -0
  31. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/diagnostics.py +0 -0
  32. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/errors.py +0 -0
  33. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/login.py +0 -0
  34. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/py.typed +0 -0
  35. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/src/mt4ctl/ssh.py +0 -0
  36. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/conftest.py +0 -0
  37. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_auth.py +0 -0
  38. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_cli.py +0 -0
  39. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_config.py +0 -0
  40. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_diagnostics.py +0 -0
  41. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_login.py +0 -0
  42. {mt4ctl-0.2.0 → mt4ctl-0.3.0}/tests/test_server.py +0 -0
  43. {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.2.0...HEAD
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.2.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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mt4ctl"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "MCP server for managing headless MetaTrader terminals over SSH (Wine + systemd)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -7,6 +7,6 @@ control, and headless first-login — exposed as Model Context Protocol tools.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- __version__ = "0.2.0"
10
+ __version__ = "0.3.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -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 Env, Host, Registry, Terminal, TerminalStatus
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