diff --git a/.agents/skills/local-qa/scripts/qa.sh b/.agents/skills/local-qa/scripts/qa.sh
index de65cff..bfca6eb 100755
--- a/.agents/skills/local-qa/scripts/qa.sh
+++ b/.agents/skills/local-qa/scripts/qa.sh
@@ -10,7 +10,7 @@ uv run pyright .
uv run pytest
# Markdown
-npx -y prettier --write './**/*.md'
+npx -y prettier --write './**/*.{md,json}'
# GitHub Actions
case "${OSTYPE}" in
diff --git a/examples/grafana/README.md b/examples/grafana/README.md
new file mode 100644
index 0000000..0276798
--- /dev/null
+++ b/examples/grafana/README.md
@@ -0,0 +1,95 @@
+# Grafana Integration for mt5cli
+
+This directory contains example configuration and dashboard files for visualising
+mt5cli SQLite data in [Grafana](https://grafana.com/) using the
+[Grafana SQLite datasource plugin](https://grafana.com/grafana/plugins/frser-sqlite-datasource/).
+
+## Prerequisites
+
+- mt5cli installed and able to connect to MetaTrader 5
+- Grafana 10+ with the `frser-sqlite-datasource` plugin installed
+- (Optional) Docker and Docker Compose for the containerised setup
+
+## Generating the SQLite database
+
+Collect historical data and snapshot current account state:
+
+```sh
+# Collect OHLCV history
+mt5cli -o history.db collect-history --symbol EURUSD --date-from 2024-01-01 --date-to 2024-12-31
+
+# Create Grafana-ready views and indexes
+mt5cli -o history.db grafana-schema
+
+# Snapshot current account, positions, and orders
+mt5cli -o history.db snapshot --with-grafana-schema
+```
+
+## Publishing a Grafana-readable copy
+
+Grafana reads the SQLite file directly. To avoid read/write conflicts, publish
+a consistent copy after each update:
+
+```sh
+mt5cli -o history.db grafana-schema --publish-copy history.mt5cli.db
+mt5cli -o history.db snapshot --publish-copy history.mt5cli.db
+```
+
+The `--publish-copy` option uses the SQLite online backup API, which is safe
+even when the source database uses WAL journal mode.
+
+## Configuring the datasource path
+
+Edit `provisioning/datasources/mt5cli-sqlite.yml` and set the `path` field
+to the absolute path of your published `.db` file:
+
+```yaml
+jsonData:
+ path: /absolute/path/to/history.mt5cli.db
+```
+
+## Running Grafana on Windows (native)
+
+1. Download and install Grafana from .
+2. Install the SQLite plugin: `grafana-cli plugins install frser-sqlite-datasource`.
+3. Copy `provisioning/datasources/mt5cli-sqlite.yml` into
+ `%ProgramFiles%\GrafanaLabs\grafana\conf\provisioning\datasources\`.
+ Do not copy `provisioning/dashboards/mt5cli.yml` — it contains a
+ Docker-specific dashboard path that is not valid on Windows.
+4. Import the dashboards from `dashboards/` via the Grafana UI
+ (Dashboards → Import → Upload JSON file).
+
+## Running with Docker Compose
+
+Set `MT5CLI_DB_PATH` to the absolute path of your published `.db` file, then
+start the stack:
+
+```sh
+# From the examples/grafana directory
+MT5CLI_DB_PATH=/absolute/path/to/history.mt5cli.db docker compose up -d
+```
+
+Alternatively, create a `.env` file in `examples/grafana/` containing
+`MT5CLI_DB_PATH=/absolute/path/to/history.mt5cli.db` and run
+`docker compose up -d`. Compose refuses to start if the variable is unset or
+empty.
+
+Then open (default credentials: admin / admin).
+
+## Dashboard overview
+
+| Dashboard | Description |
+| ---------------------- | ------------------------------------------------------- |
+| `mt5cli-overview.json` | Account balance, equity, margin, and snapshot freshness |
+| `mt5cli-trades.json` | Trade P/L, win rate, symbol breakdown |
+| `mt5cli-market.json` | OHLCV rates, spreads, and tick volume |
+
+All panel queries use the `grafana_*` views; they do not read internal storage
+tables directly.
+
+## Importing dashboards
+
+1. Open Grafana and navigate to **Dashboards → Import**.
+2. Click **Upload JSON file** and select one of the files in `dashboards/`.
+3. Select the `mt5cli-SQLite` datasource when prompted.
+4. Click **Import**.
diff --git a/examples/grafana/compose.yml b/examples/grafana/compose.yml
new file mode 100644
index 0000000..be2f94a
--- /dev/null
+++ b/examples/grafana/compose.yml
@@ -0,0 +1,26 @@
+# Docker Compose for Grafana with mt5cli SQLite datasource.
+#
+# MT5CLI_DB_PATH must be set to the absolute host path of your published .db
+# file before running `docker compose up -d`. Compose will refuse to start if
+# the variable is missing or empty.
+#
+# Example:
+# MT5CLI_DB_PATH=/home/user/history.mt5cli.db docker compose up -d
+
+services:
+ grafana:
+ image: grafana/grafana:latest
+ ports:
+ - "3000:3000"
+ environment:
+ GF_PATHS_PROVISIONING: /etc/grafana/provisioning
+ GF_INSTALL_PLUGINS: frser-sqlite-datasource
+ volumes:
+ - ./provisioning:/etc/grafana/provisioning:ro
+ - ./dashboards:/var/lib/grafana/dashboards:ro
+ - grafana-storage:/var/lib/grafana
+ - ${MT5CLI_DB_PATH:?Set MT5CLI_DB_PATH to the path of your published mt5cli SQLite DB}:/data/mt5cli.db:ro
+ user: "472"
+
+volumes:
+ grafana-storage:
diff --git a/examples/grafana/dashboards/mt5cli-market.json b/examples/grafana/dashboards/mt5cli-market.json
new file mode 100644
index 0000000..f049c52
--- /dev/null
+++ b/examples/grafana/dashboards/mt5cli-market.json
@@ -0,0 +1,98 @@
+{
+ "__inputs": [
+ {
+ "name": "DS_MT5CLI_SQLITE",
+ "label": "mt5cli-SQLite",
+ "description": "",
+ "type": "datasource",
+ "pluginId": "frser-sqlite-datasource",
+ "pluginName": "SQLite"
+ }
+ ],
+ "__requires": [
+ {
+ "type": "datasource",
+ "id": "frser-sqlite-datasource",
+ "name": "SQLite",
+ "version": "1.0.0"
+ }
+ ],
+ "annotations": { "list": [] },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "id": null,
+ "links": [],
+ "panels": [
+ {
+ "datasource": "${DS_MT5CLI_SQLITE}",
+ "fieldConfig": { "defaults": {}, "overrides": [] },
+ "gridPos": { "h": 8, "w": 24, "x": 0, "y": 0 },
+ "id": 1,
+ "title": "Close Price Over Time",
+ "type": "timeseries",
+ "targets": [
+ {
+ "rawSql": "SELECT \"time\" AS time, \"symbol\", \"close\" FROM grafana_rates WHERE \"time\" >= $__from / 1000 AND \"time\" < $__to / 1000 ORDER BY time",
+ "format": "time_series",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "datasource": "${DS_MT5CLI_SQLITE}",
+ "fieldConfig": { "defaults": {}, "overrides": [] },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
+ "id": 2,
+ "title": "Spread Over Time",
+ "type": "timeseries",
+ "targets": [
+ {
+ "rawSql": "SELECT \"time\" AS time, \"symbol\", \"spread\" FROM grafana_rates WHERE \"time\" >= $__from / 1000 AND \"time\" < $__to / 1000 ORDER BY time",
+ "format": "time_series",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "datasource": "${DS_MT5CLI_SQLITE}",
+ "fieldConfig": { "defaults": {}, "overrides": [] },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
+ "id": 3,
+ "title": "Tick Volume Over Time",
+ "type": "timeseries",
+ "targets": [
+ {
+ "rawSql": "SELECT \"time\" AS time, \"symbol\", \"tick_volume\" FROM grafana_rates WHERE \"time\" >= $__from / 1000 AND \"time\" < $__to / 1000 ORDER BY time",
+ "format": "time_series",
+ "refId": "A"
+ }
+ ]
+ }
+ ],
+ "refresh": "1m",
+ "schemaVersion": 36,
+ "tags": ["mt5cli", "market"],
+ "templating": {
+ "list": [
+ {
+ "current": {},
+ "hide": 0,
+ "includeAll": false,
+ "label": "Data Source",
+ "multi": false,
+ "name": "DS_MT5CLI_SQLITE",
+ "options": [],
+ "query": "frser-sqlite-datasource",
+ "refresh": 1,
+ "type": "datasource"
+ }
+ ]
+ },
+ "time": { "from": "now-24h", "to": "now" },
+ "timepicker": {},
+ "timezone": "browser",
+ "title": "MT5CLI - Market Data",
+ "uid": "mt5cli-market",
+ "version": 1
+}
diff --git a/examples/grafana/dashboards/mt5cli-overview.json b/examples/grafana/dashboards/mt5cli-overview.json
new file mode 100644
index 0000000..ab6a35f
--- /dev/null
+++ b/examples/grafana/dashboards/mt5cli-overview.json
@@ -0,0 +1,254 @@
+{
+ "__inputs": [
+ {
+ "name": "DS_MT5CLI_SQLITE",
+ "label": "mt5cli-SQLite",
+ "description": "",
+ "type": "datasource",
+ "pluginId": "frser-sqlite-datasource",
+ "pluginName": "SQLite"
+ }
+ ],
+ "__requires": [
+ {
+ "type": "datasource",
+ "id": "frser-sqlite-datasource",
+ "name": "SQLite",
+ "version": "1.0.0"
+ }
+ ],
+ "annotations": {
+ "list": []
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "id": null,
+ "links": [],
+ "panels": [
+ {
+ "datasource": "${DS_MT5CLI_SQLITE}",
+ "fieldConfig": {
+ "defaults": {
+ "unit": "currencyUSD"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {
+ "reduceOptions": {
+ "calcs": ["lastNotNull"]
+ }
+ },
+ "title": "Balance",
+ "type": "stat",
+ "targets": [
+ {
+ "rawSql": "SELECT \"time\" AS time, \"balance\" FROM grafana_account_snapshots ORDER BY time DESC LIMIT 1",
+ "format": "table",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "datasource": "${DS_MT5CLI_SQLITE}",
+ "fieldConfig": {
+ "defaults": {
+ "unit": "currencyUSD"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 6,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "reduceOptions": {
+ "calcs": ["lastNotNull"]
+ }
+ },
+ "title": "Equity",
+ "type": "stat",
+ "targets": [
+ {
+ "rawSql": "SELECT \"time\" AS time, \"equity\" FROM grafana_account_snapshots ORDER BY time DESC LIMIT 1",
+ "format": "table",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "datasource": "${DS_MT5CLI_SQLITE}",
+ "fieldConfig": {
+ "defaults": {
+ "unit": "currencyUSD"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 12,
+ "y": 0
+ },
+ "id": 3,
+ "options": {
+ "reduceOptions": {
+ "calcs": ["lastNotNull"]
+ }
+ },
+ "title": "Free Margin",
+ "type": "stat",
+ "targets": [
+ {
+ "rawSql": "SELECT \"time\" AS time, \"margin_free\" FROM grafana_account_snapshots ORDER BY time DESC LIMIT 1",
+ "format": "table",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "datasource": "${DS_MT5CLI_SQLITE}",
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percent"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 18,
+ "y": 0
+ },
+ "id": 4,
+ "options": {
+ "reduceOptions": {
+ "calcs": ["lastNotNull"]
+ }
+ },
+ "title": "Margin Level",
+ "type": "stat",
+ "targets": [
+ {
+ "rawSql": "SELECT \"time\" AS time, \"margin_level\" FROM grafana_account_snapshots ORDER BY time DESC LIMIT 1",
+ "format": "table",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "datasource": "${DS_MT5CLI_SQLITE}",
+ "fieldConfig": {
+ "defaults": {
+ "unit": "dateTimeFromNow"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 24,
+ "x": 0,
+ "y": 4
+ },
+ "id": 7,
+ "options": {
+ "reduceOptions": {
+ "calcs": ["lastNotNull"]
+ }
+ },
+ "title": "Last Snapshot",
+ "type": "stat",
+ "targets": [
+ {
+ "rawSql": "SELECT MAX(\"time\") * 1000 AS \"Last Snapshot\" FROM grafana_account_snapshots",
+ "format": "table",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "datasource": "${DS_MT5CLI_SQLITE}",
+ "fieldConfig": {
+ "defaults": {},
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 8
+ },
+ "id": 5,
+ "title": "Account Balance Over Time",
+ "type": "timeseries",
+ "targets": [
+ {
+ "rawSql": "SELECT \"time\" AS time, \"balance\" FROM grafana_account_snapshots ORDER BY time",
+ "format": "time_series",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "datasource": "${DS_MT5CLI_SQLITE}",
+ "fieldConfig": {
+ "defaults": {},
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 16
+ },
+ "id": 6,
+ "title": "Equity Over Time",
+ "type": "timeseries",
+ "targets": [
+ {
+ "rawSql": "SELECT \"time\" AS time, \"equity\" FROM grafana_account_snapshots ORDER BY time",
+ "format": "time_series",
+ "refId": "A"
+ }
+ ]
+ }
+ ],
+ "refresh": "1m",
+ "schemaVersion": 36,
+ "tags": ["mt5cli", "account"],
+ "templating": {
+ "list": [
+ {
+ "current": {},
+ "hide": 0,
+ "includeAll": false,
+ "label": "Data Source",
+ "multi": false,
+ "name": "DS_MT5CLI_SQLITE",
+ "options": [],
+ "query": "frser-sqlite-datasource",
+ "refresh": 1,
+ "type": "datasource"
+ }
+ ]
+ },
+ "time": {
+ "from": "now-7d",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "browser",
+ "title": "MT5CLI - Account Overview",
+ "uid": "mt5cli-overview",
+ "version": 1
+}
diff --git a/examples/grafana/dashboards/mt5cli-trades.json b/examples/grafana/dashboards/mt5cli-trades.json
new file mode 100644
index 0000000..eec71bd
--- /dev/null
+++ b/examples/grafana/dashboards/mt5cli-trades.json
@@ -0,0 +1,167 @@
+{
+ "__inputs": [
+ {
+ "name": "DS_MT5CLI_SQLITE",
+ "label": "mt5cli-SQLite",
+ "description": "",
+ "type": "datasource",
+ "pluginId": "frser-sqlite-datasource",
+ "pluginName": "SQLite"
+ }
+ ],
+ "__requires": [
+ {
+ "type": "datasource",
+ "id": "frser-sqlite-datasource",
+ "name": "SQLite",
+ "version": "1.0.0"
+ }
+ ],
+ "annotations": {
+ "list": []
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "id": null,
+ "links": [],
+ "panels": [
+ {
+ "datasource": "${DS_MT5CLI_SQLITE}",
+ "fieldConfig": {
+ "defaults": {},
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "title": "Realized P/L by Symbol",
+ "type": "table",
+ "targets": [
+ {
+ "rawSql": "SELECT \"symbol\", \"cumulative_pnl\", \"deal_count\" FROM grafana_realized_pnl ORDER BY cumulative_pnl DESC",
+ "format": "table",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "datasource": "${DS_MT5CLI_SQLITE}",
+ "fieldConfig": {
+ "defaults": {},
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "win_rate_pct"
+ },
+ "properties": [
+ {
+ "id": "unit",
+ "value": "percent"
+ },
+ {
+ "id": "displayName",
+ "value": "Win Rate (%)"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 0
+ },
+ "id": 2,
+ "title": "Trade Statistics by Symbol",
+ "type": "table",
+ "targets": [
+ {
+ "rawSql": "SELECT \"symbol\", \"total_deals\", \"winning_deals\", \"losing_deals\", \"total_profit\", \"avg_profit\", 100.0 * \"winning_deals\" / NULLIF(\"total_deals\", 0) AS \"win_rate_pct\" FROM grafana_trade_stats ORDER BY total_profit DESC",
+ "format": "table",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "datasource": "${DS_MT5CLI_SQLITE}",
+ "fieldConfig": {
+ "defaults": {},
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 8
+ },
+ "id": 3,
+ "title": "Open Position Profit Over Time",
+ "type": "timeseries",
+ "targets": [
+ {
+ "rawSql": "SELECT \"time\" AS time, \"symbol\", SUM(\"profit\") AS profit FROM grafana_position_snapshots GROUP BY time, symbol ORDER BY time",
+ "format": "time_series",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "datasource": "${DS_MT5CLI_SQLITE}",
+ "fieldConfig": {
+ "defaults": {},
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 16
+ },
+ "id": 4,
+ "title": "Cash Events Over Time",
+ "type": "timeseries",
+ "targets": [
+ {
+ "rawSql": "SELECT \"time\" AS time, \"profit\" FROM grafana_cash_events ORDER BY time",
+ "format": "time_series",
+ "refId": "A"
+ }
+ ]
+ }
+ ],
+ "refresh": "5m",
+ "schemaVersion": 36,
+ "tags": ["mt5cli", "trades"],
+ "templating": {
+ "list": [
+ {
+ "current": {},
+ "hide": 0,
+ "includeAll": false,
+ "label": "Data Source",
+ "multi": false,
+ "name": "DS_MT5CLI_SQLITE",
+ "options": [],
+ "query": "frser-sqlite-datasource",
+ "refresh": 1,
+ "type": "datasource"
+ }
+ ]
+ },
+ "time": {
+ "from": "now-30d",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "browser",
+ "title": "MT5CLI - Trade Analytics",
+ "uid": "mt5cli-trades",
+ "version": 1
+}
diff --git a/examples/grafana/provisioning/dashboards/mt5cli.yml b/examples/grafana/provisioning/dashboards/mt5cli.yml
new file mode 100644
index 0000000..e837aab
--- /dev/null
+++ b/examples/grafana/provisioning/dashboards/mt5cli.yml
@@ -0,0 +1,13 @@
+# Grafana dashboard provisioning for mt5cli dashboards.
+
+apiVersion: 1
+
+providers:
+ - name: mt5cli
+ type: file
+ disableDeletion: false
+ updateIntervalSeconds: 30
+ allowUiUpdates: true
+ options:
+ path: /var/lib/grafana/dashboards
+ foldersFromFilesStructure: false
diff --git a/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml b/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml
new file mode 100644
index 0000000..9dc244a
--- /dev/null
+++ b/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml
@@ -0,0 +1,16 @@
+# Grafana datasource provisioning for mt5cli SQLite.
+#
+# Requires the frser-sqlite-datasource plugin:
+# grafana-cli plugins install frser-sqlite-datasource
+#
+# Set `path` to the absolute path of your published history.mt5cli.db file.
+
+apiVersion: 1
+
+datasources:
+ - name: mt5cli-SQLite
+ type: frser-sqlite-datasource
+ access: proxy
+ isDefault: true
+ jsonData:
+ path: /data/mt5cli.db
diff --git a/mt5cli/cli.py b/mt5cli/cli.py
index 88d5801..13b69e5 100644
--- a/mt5cli/cli.py
+++ b/mt5cli/cli.py
@@ -803,7 +803,19 @@ def collect_history(
@app.command(rich_help_panel="Collection")
-def grafana_schema(ctx: typer.Context) -> None:
+def grafana_schema(
+ ctx: typer.Context,
+ publish_copy: Annotated[
+ Path | None,
+ typer.Option(
+ "--publish-copy",
+ help=(
+ "Publish a Grafana-ready SQLite copy to this path"
+ " after schema creation."
+ ),
+ ),
+ ] = None,
+) -> None:
"""Create or refresh Grafana-ready views and indexes in a SQLite database.
Idempotent — safe to run repeatedly on the same database. Requires SQLite
@@ -814,7 +826,11 @@ def grafana_schema(ctx: typer.Context) -> None:
"""
import sqlite3 as _sqlite3 # noqa: PLC0415
- from .grafana import create_snapshot_tables, ensure_grafana_schema # noqa: PLC0415
+ from .grafana import ( # noqa: PLC0415
+ create_snapshot_tables,
+ ensure_grafana_schema,
+ publish_grafana_copy,
+ )
export_ctx = _get_export_context(ctx)
if export_ctx.output_format != "sqlite3":
@@ -829,6 +845,9 @@ def grafana_schema(ctx: typer.Context) -> None:
create_snapshot_tables(conn)
ensure_grafana_schema(conn)
logger.info("Grafana schema applied to %s", export_ctx.output)
+ if publish_copy is not None:
+ publish_grafana_copy(export_ctx.output, publish_copy)
+ logger.info("Grafana copy published to %s", publish_copy)
@app.command(rich_help_panel="Collection")
@@ -867,6 +886,13 @@ def snapshot(
help="Ensure Grafana views and indexes exist.",
),
] = False,
+ publish_copy: Annotated[
+ Path | None,
+ typer.Option(
+ "--publish-copy",
+ help=("Publish a Grafana-ready SQLite copy to this path after snapshot."),
+ ),
+ ] = None,
) -> None:
"""Snapshot current account, position, order, and terminal state into SQLite.
@@ -894,6 +920,11 @@ def snapshot(
with_grafana_schema=with_grafana_schema,
)
logger.info("Snapshot written to %s", export_ctx.output)
+ if publish_copy is not None:
+ from .grafana import publish_grafana_copy # noqa: PLC0415
+
+ publish_grafana_copy(export_ctx.output, publish_copy)
+ logger.info("Grafana copy published to %s", publish_copy)
def main() -> None:
diff --git a/mt5cli/grafana.py b/mt5cli/grafana.py
index b58c6a8..6354623 100644
--- a/mt5cli/grafana.py
+++ b/mt5cli/grafana.py
@@ -2,9 +2,13 @@
from __future__ import annotations
+import contextlib
import datetime
import logging
+import os
import sqlite3
+import tempfile
+from pathlib import Path
from typing import cast
from .history import get_table_columns
@@ -478,6 +482,68 @@ def ensure_grafana_schema(conn: sqlite3.Connection) -> None:
create_grafana_indexes(conn)
+def publish_grafana_copy(
+ source: str | Path,
+ target: str | Path,
+) -> Path:
+ """Publish a consistent SQLite copy for Grafana using the backup API.
+
+ Uses the SQLite online backup API for a WAL-safe, consistent snapshot of
+ the source database. Writes to a temporary file beside the target, then
+ atomically replaces it so that a previous published copy is preserved if
+ publishing fails.
+
+ Args:
+ source: Path to the source SQLite database.
+ target: Destination path for the published copy.
+
+ Returns:
+ The resolved absolute target path.
+
+ Raises:
+ FileNotFoundError: If the source database does not exist.
+ ValueError: If source and target resolve to the same path.
+ """
+ source_path = Path(source)
+ target_path = Path(target)
+ if source_path.resolve() == target_path.resolve():
+ msg = "--publish-copy target must differ from the source database: " + str(
+ source_path
+ )
+ raise ValueError(msg)
+ if not source_path.exists():
+ raise FileNotFoundError(source_path)
+
+ target_path.parent.mkdir(parents=True, exist_ok=True)
+
+ tmp_fd, tmp_str = tempfile.mkstemp(
+ dir=target_path.parent,
+ suffix=".tmp",
+ prefix=target_path.name + ".",
+ )
+ tmp_path = Path(tmp_str)
+ try:
+ os.close(tmp_fd)
+ with (
+ contextlib.closing(sqlite3.connect(source_path)) as src,
+ contextlib.closing(sqlite3.connect(tmp_path)) as dst,
+ ):
+ src.backup(dst)
+ try:
+ target_mode = target_path.stat().st_mode & 0o777
+ except FileNotFoundError:
+ target_mode = 0o644
+ Path(tmp_path).chmod(target_mode)
+ tmp_path.replace(target_path)
+ except Exception:
+ with contextlib.suppress(OSError):
+ tmp_path.unlink()
+ raise
+
+ logger.info("Published Grafana copy: %s -> %s", source_path, target_path)
+ return target_path.resolve()
+
+
# ---------------------------------------------------------------------------
# Snapshot insert helpers
# ---------------------------------------------------------------------------
diff --git a/mt5cli/sdk.py b/mt5cli/sdk.py
index 433d9ce..0b27fb0 100644
--- a/mt5cli/sdk.py
+++ b/mt5cli/sdk.py
@@ -45,6 +45,7 @@ from .history import (
write_incremental_datasets,
)
from .retry import retry_with_backoff
+from .telemetry import get_metrics
from .utils import (
Dataset,
IfExists,
@@ -1026,20 +1027,24 @@ def update_history( # noqa: PLR0913
with closing(sqlite3.connect(request.output_path)) as conn, conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
- write_incremental_datasets(
- conn,
- client,
- symbols,
- request.selected,
- request.resolved_timeframes,
- request.resolved_tick_flags,
- request.fallback_start,
- request.end,
- deduplicate=deduplicate,
- create_rate_views=create_rate_views,
- with_views=with_views,
- include_account_events=include_account_events,
- )
+ m = get_metrics()
+ with m.record_history_update(dataset="history"):
+ before = conn.total_changes
+ write_incremental_datasets(
+ conn,
+ client,
+ symbols,
+ request.selected,
+ request.resolved_timeframes,
+ request.resolved_tick_flags,
+ request.fallback_start,
+ request.end,
+ deduplicate=deduplicate,
+ create_rate_views=create_rate_views,
+ with_views=with_views,
+ include_account_events=include_account_events,
+ )
+ m.add_history_rows(conn.total_changes - before, dataset="history")
def update_history_with_config( # noqa: PLR0913
@@ -2171,6 +2176,49 @@ def mt5_summary_as_df(*, config: Mt5Config | None = None) -> pd.DataFrame:
# ---------------------------------------------------------------------------
+def _emit_account_metrics(row: dict[str, object]) -> None:
+ login = str(row.get("login", ""))
+ server = str(row.get("server", ""))
+ get_metrics().record_account_state(
+ login=login,
+ server=server,
+ balance=float(row.get("balance") or 0.0), # type: ignore[arg-type]
+ equity=float(row.get("equity") or 0.0), # type: ignore[arg-type]
+ margin=float(row.get("margin") or 0.0), # type: ignore[arg-type]
+ margin_free=float(row.get("margin_free") or 0.0), # type: ignore[arg-type]
+ margin_level=float(row.get("margin_level") or 0.0), # type: ignore[arg-type]
+ )
+
+
+def _emit_position_metrics(
+ rows: list[dict[str, object]],
+ login: int | None,
+) -> None:
+ m = get_metrics()
+ login_str = str(login) if login is not None else ""
+ # Aggregate profit and volume by symbol so hedging accounts (multiple open
+ # positions sharing the same symbol) emit a single gauge value per symbol
+ # instead of overwriting with each row's value.
+ totals: dict[str, tuple[float, float]] = {}
+ for r in rows:
+ symbol = str(r.get("symbol", ""))
+ profit = float(r.get("profit") or 0.0) # type: ignore[arg-type]
+ volume = float(r.get("volume") or 0.0) # type: ignore[arg-type]
+ if symbol in totals:
+ prev_p, prev_v = totals[symbol]
+ totals[symbol] = (prev_p + profit, prev_v + volume)
+ else:
+ totals[symbol] = (profit, volume)
+ for symbol, (profit, volume) in totals.items():
+ m.record_position_state(
+ login=login_str,
+ server="",
+ symbol=symbol,
+ profit=profit,
+ volume=volume,
+ )
+
+
def _snapshot_account(
conn: sqlite3.Connection,
client: Mt5DataClient,
@@ -2184,6 +2232,7 @@ def _snapshot_account(
return None
row = cast("dict[str, object]", df.iloc[0].to_dict())
insert_account_snapshot(conn, run_id, row)
+ _emit_account_metrics(row)
login_val = row.get("login")
return int(login_val) if login_val is not None else None # type: ignore[arg-type]
@@ -2201,6 +2250,7 @@ def _snapshot_positions(
raw = df.to_dict(orient="records") if not df.empty else []
rows = cast("list[dict[str, object]]", raw)
insert_position_snapshots(conn, run_id, login, rows)
+ _emit_position_metrics(rows, login)
def _snapshot_orders(
@@ -2218,6 +2268,14 @@ def _snapshot_orders(
insert_order_snapshots(conn, run_id, login, rows)
+def _emit_terminal_metrics(row: dict[str, object]) -> None:
+ get_metrics().record_terminal_state(
+ connected=float(row.get("connected") or 0.0), # type: ignore[arg-type]
+ trade_allowed=float(row.get("trade_allowed") or 0.0), # type: ignore[arg-type]
+ trade_expert=float(row.get("trade_expert") or 0.0), # type: ignore[arg-type]
+ )
+
+
def _snapshot_terminal(
conn: sqlite3.Connection,
client: Mt5DataClient,
@@ -2231,6 +2289,7 @@ def _snapshot_terminal(
return
row = cast("dict[str, object]", df.iloc[0].to_dict())
insert_terminal_snapshot(conn, run_id, row)
+ _emit_terminal_metrics(row)
def update_observability(
@@ -2270,22 +2329,23 @@ def update_observability(
ensure_grafana_schema(conn)
else:
create_snapshot_tables(conn)
- run_id = start_snapshot_run(conn, observed_at)
- login: int | None = None
- try:
- if include_account:
- login = _snapshot_account(conn, client, run_id)
- if include_positions:
- _snapshot_positions(conn, client, run_id, login, symbols)
- if include_orders:
- _snapshot_orders(conn, client, run_id, login, symbols)
- if include_terminal:
- _snapshot_terminal(conn, client, run_id)
- record_snapshot_run(conn, run_id, "ok")
- except Exception:
- record_snapshot_run(conn, run_id, "error")
- conn.commit()
- raise
+ with get_metrics().record_snapshot_update():
+ run_id = start_snapshot_run(conn, observed_at)
+ login: int | None = None
+ try:
+ if include_account:
+ login = _snapshot_account(conn, client, run_id)
+ if include_positions:
+ _snapshot_positions(conn, client, run_id, login, symbols)
+ if include_orders:
+ _snapshot_orders(conn, client, run_id, login, symbols)
+ if include_terminal:
+ _snapshot_terminal(conn, client, run_id)
+ record_snapshot_run(conn, run_id, "ok")
+ except Exception:
+ record_snapshot_run(conn, run_id, "error")
+ conn.commit()
+ raise
def update_observability_with_config(
diff --git a/mt5cli/telemetry.py b/mt5cli/telemetry.py
new file mode 100644
index 0000000..5bb8496
--- /dev/null
+++ b/mt5cli/telemetry.py
@@ -0,0 +1,354 @@
+"""Optional OpenTelemetry metrics for MT5 history and snapshot observability."""
+
+from __future__ import annotations
+
+import logging
+import time
+from contextlib import contextmanager
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+logger = logging.getLogger(__name__)
+
+_otel_available_flag = False
+
+try:
+ import opentelemetry.metrics as _otel_metrics_mod
+ from opentelemetry.sdk.metrics import MeterProvider as _OtelMeterProvider
+ from opentelemetry.sdk.metrics.export import (
+ PeriodicExportingMetricReader as _OtelPeriodicReader,
+ )
+ from opentelemetry.sdk.resources import Resource as _OtelResource
+
+ _otel_available_flag = True
+except ImportError: # pragma: no cover
+ _otel_metrics_mod = None # type: ignore[assignment]
+ _OtelMeterProvider = None # type: ignore[assignment]
+ _OtelPeriodicReader = None # type: ignore[assignment]
+ _OtelResource = None # type: ignore[assignment]
+
+_OTEL_AVAILABLE: bool = _otel_available_flag
+
+try:
+ from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( # type: ignore[import]
+ OTLPMetricExporter as _OtelOTLPExporter, # type: ignore[reportUnknownVariableType]
+ )
+except ImportError: # pragma: no cover
+ _OtelOTLPExporter = None # type: ignore[assignment, misc]
+
+
+class _NoOp:
+ """No-op instrument that silently ignores all calls."""
+
+ def add(
+ self,
+ amount: float,
+ attributes: dict[str, str] | None = None,
+ ) -> None:
+ """No-op add."""
+
+ def set(
+ self,
+ amount: float,
+ attributes: dict[str, str] | None = None,
+ ) -> None:
+ """No-op set."""
+
+ def record(
+ self,
+ amount: float,
+ attributes: dict[str, str] | None = None,
+ ) -> None:
+ """No-op record."""
+
+
+_NOOP: _NoOp = _NoOp()
+
+
+class _Mt5Metrics:
+ """MT5 metric instrument registry.
+
+ Holds references to OTel instruments. All instruments are no-op until
+ :meth:`configure` is called with a compatible meter object.
+ """
+
+ def __init__(self) -> None:
+ self._history_duration: Any = _NOOP
+ self._history_rows: Any = _NOOP
+ self._history_failures: Any = _NOOP
+ self._snapshot_duration: Any = _NOOP
+ self._snapshot_failures: Any = _NOOP
+ self._account_balance: Any = _NOOP
+ self._account_equity: Any = _NOOP
+ self._account_margin: Any = _NOOP
+ self._account_margin_free: Any = _NOOP
+ self._account_margin_level: Any = _NOOP
+ self._position_profit: Any = _NOOP
+ self._position_volume: Any = _NOOP
+ self._terminal_connected: Any = _NOOP
+ self._terminal_trade_allowed: Any = _NOOP
+ self._terminal_trade_expert: Any = _NOOP
+ self._last_successful_update: Any = _NOOP
+
+ def configure(self, meter: Any) -> None: # noqa: ANN401
+ """Set up metric instruments from a meter object.
+
+ Args:
+ meter: An OpenTelemetry ``Meter`` or duck-typed compatible object
+ that supports ``create_counter``, ``create_histogram``, and
+ ``create_gauge``.
+ """
+ self._history_duration = meter.create_histogram(
+ "mt5_history_update_duration_seconds",
+ unit="s",
+ description="Duration of incremental history update operations.",
+ )
+ self._history_rows = meter.create_counter(
+ "mt5_history_update_rows_total",
+ description="Rows written during incremental history updates.",
+ )
+ self._history_failures = meter.create_counter(
+ "mt5_history_update_failures_total",
+ description="Number of incremental history update failures.",
+ )
+ self._snapshot_duration = meter.create_histogram(
+ "mt5_snapshot_update_duration_seconds",
+ unit="s",
+ description="Duration of snapshot update operations.",
+ )
+ self._snapshot_failures = meter.create_counter(
+ "mt5_snapshot_update_failures_total",
+ description="Number of snapshot update failures.",
+ )
+ self._account_balance = meter.create_gauge(
+ "mt5_account_balance",
+ description="Account balance.",
+ )
+ self._account_equity = meter.create_gauge(
+ "mt5_account_equity",
+ description="Account equity.",
+ )
+ self._account_margin = meter.create_gauge(
+ "mt5_account_margin",
+ description="Account margin used.",
+ )
+ self._account_margin_free = meter.create_gauge(
+ "mt5_account_margin_free",
+ description="Account free margin.",
+ )
+ self._account_margin_level = meter.create_gauge(
+ "mt5_account_margin_level",
+ description="Account margin level as a percentage.",
+ )
+ self._position_profit = meter.create_gauge(
+ "mt5_position_profit",
+ description="Floating profit for an open position.",
+ )
+ self._position_volume = meter.create_gauge(
+ "mt5_position_volume",
+ description="Volume of an open position.",
+ )
+ self._terminal_connected = meter.create_gauge(
+ "mt5_terminal_connected",
+ description="1 if the terminal is connected to the broker, 0 otherwise.",
+ )
+ self._terminal_trade_allowed = meter.create_gauge(
+ "mt5_terminal_trade_allowed",
+ description="1 if trading is allowed by the broker server, 0 otherwise.",
+ )
+ self._terminal_trade_expert = meter.create_gauge(
+ "mt5_terminal_trade_expert",
+ description="1 if Expert Advisor trading is enabled, 0 otherwise.",
+ )
+ self._last_successful_update = meter.create_gauge(
+ "mt5_last_successful_update_timestamp",
+ description="Unix timestamp of the last successful history update.",
+ )
+
+ @contextmanager
+ def record_history_update(
+ self,
+ *,
+ dataset: str,
+ ) -> Iterator[None]:
+ """Context manager recording history update duration and failures.
+
+ Args:
+ dataset: Dataset label (e.g. ``"rates"``).
+
+ Yields:
+ None inside the update operation.
+ """
+ attrs = {"dataset": dataset}
+ start = time.monotonic()
+ try:
+ yield
+ self._history_duration.record(time.monotonic() - start, attrs)
+ self._last_successful_update.set(time.time(), attrs)
+ except Exception:
+ self._history_failures.add(1, attrs)
+ raise
+
+ def add_history_rows(self, count: int, *, dataset: str) -> None:
+ """Increment the history rows-written counter.
+
+ Args:
+ count: Number of rows written during this update.
+ dataset: Dataset label (e.g. ``"rates"``).
+ """
+ self._history_rows.add(count, {"dataset": dataset})
+
+ @contextmanager
+ def record_snapshot_update(self) -> Iterator[None]:
+ """Context manager recording snapshot update duration and failures.
+
+ Yields:
+ None inside the snapshot operation.
+ """
+ start = time.monotonic()
+ try:
+ yield
+ self._snapshot_duration.record(time.monotonic() - start, {})
+ except Exception:
+ self._snapshot_failures.add(1, {})
+ raise
+
+ def record_account_state(
+ self,
+ *,
+ login: str,
+ server: str,
+ balance: float,
+ equity: float,
+ margin: float,
+ margin_free: float,
+ margin_level: float,
+ ) -> None:
+ """Emit account metric gauges.
+
+ Args:
+ login: Account login number (as string; not a password or secret).
+ server: Broker server name.
+ balance: Account balance.
+ equity: Account equity.
+ margin: Margin used.
+ margin_free: Free margin.
+ margin_level: Margin level percentage.
+ """
+ attrs: dict[str, str] = {"login": login, "server": server}
+ self._account_balance.set(balance, attrs)
+ self._account_equity.set(equity, attrs)
+ self._account_margin.set(margin, attrs)
+ self._account_margin_free.set(margin_free, attrs)
+ self._account_margin_level.set(margin_level, attrs)
+
+ def record_position_state(
+ self,
+ *,
+ login: str,
+ server: str,
+ symbol: str,
+ profit: float,
+ volume: float,
+ ) -> None:
+ """Emit position metric gauges.
+
+ Args:
+ login: Account login number (as string).
+ server: Broker server name.
+ symbol: Position symbol.
+ profit: Floating profit/loss.
+ volume: Position volume.
+ """
+ attrs: dict[str, str] = {"login": login, "server": server, "symbol": symbol}
+ self._position_profit.set(profit, attrs)
+ self._position_volume.set(volume, attrs)
+
+ def record_terminal_state(
+ self,
+ *,
+ connected: float,
+ trade_allowed: float,
+ trade_expert: float,
+ ) -> None:
+ """Emit terminal connection and trading status gauges.
+
+ Args:
+ connected: 1.0 if connected to the broker, 0.0 otherwise.
+ trade_allowed: 1.0 if broker server allows trading, 0.0 otherwise.
+ trade_expert: 1.0 if Expert Advisor trading is enabled, 0.0 otherwise.
+ """
+ self._terminal_connected.set(connected, {})
+ self._terminal_trade_allowed.set(trade_allowed, {})
+ self._terminal_trade_expert.set(trade_expert, {})
+
+
+_metrics = _Mt5Metrics()
+
+
+def configure_metrics(meter: Any) -> None: # noqa: ANN401
+ """Configure MT5 metrics using the provided meter.
+
+ Args:
+ meter: An OpenTelemetry ``Meter`` or duck-typed compatible object.
+ """
+ _metrics.configure(meter)
+
+
+def enable_otel_metrics(
+ service_name: str = "mt5cli",
+ readers: list[Any] | None = None,
+) -> None:
+ """Enable OTel metrics by wiring up an SDK ``MeterProvider`` pipeline.
+
+ Requires the ``otel`` optional dependency group:
+ ``pip install "mt5cli[otel]"``.
+
+ Args:
+ service_name: OTel meter/service name used for the ``Resource`` and
+ the meter itself.
+ readers: Optional list of metric readers. When *None* (the default),
+ a :class:`~opentelemetry.sdk.metrics.export.PeriodicExportingMetricReader`
+ backed by an OTLP HTTP exporter is created automatically
+ (reads the endpoint from ``OTEL_EXPORTER_OTLP_ENDPOINT``).
+ Pass a custom list (e.g. ``InMemoryMetricReader`` for tests)
+ to override.
+
+ Raises:
+ ImportError: If ``opentelemetry-api`` is not installed, or if
+ ``readers`` is *None* and
+ ``opentelemetry-exporter-otlp-proto-http`` is not installed.
+ """
+ if not _OTEL_AVAILABLE:
+ msg = (
+ "opentelemetry-api is not installed. "
+ 'Install it with: pip install "mt5cli[otel]"'
+ )
+ raise ImportError(msg)
+ if readers is None:
+ if _OtelOTLPExporter is None:
+ msg = (
+ "opentelemetry-exporter-otlp-proto-http is required for the "
+ "default OTLP export pipeline. "
+ 'Install it with: pip install "mt5cli[otel]" or pass a '
+ "custom readers list."
+ )
+ raise ImportError(msg)
+ readers = [_OtelPeriodicReader(_OtelOTLPExporter())] # type: ignore[misc]
+ resource = _OtelResource.create({"service.name": service_name}) # type: ignore[union-attr]
+ provider = _OtelMeterProvider(resource=resource, metric_readers=readers) # type: ignore[misc]
+ _otel_metrics_mod.set_meter_provider(provider) # type: ignore[union-attr]
+ meter = provider.get_meter(service_name)
+ configure_metrics(meter)
+
+
+def get_metrics() -> _Mt5Metrics:
+ """Return the global :class:`_Mt5Metrics` instance.
+
+ Returns:
+ The global metric registry (no-op until :func:`configure_metrics` is
+ called).
+ """
+ return _metrics
diff --git a/pyproject.toml b/pyproject.toml
index 9056a9e..2e4de7c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,6 +26,11 @@ classifiers = [
[project.optional-dependencies]
parquet = ["pyarrow >= 19.0.0"]
+otel = [
+ "opentelemetry-api",
+ "opentelemetry-sdk",
+ "opentelemetry-exporter-otlp-proto-http",
+]
[project.scripts]
mt5cli = "mt5cli.cli:main"
@@ -45,6 +50,8 @@ dev = [
"pytest-cov >= 5.0.0",
"pandas-stubs >= 2.2.3.250527",
"pyarrow >= 19.0.0",
+ "opentelemetry-api",
+ "opentelemetry-sdk",
"mkdocs >= 1.6.1",
"mkdocs-material >= 9.7.6",
"mkdocstrings[python] >= 1.0.4",
diff --git a/tests/test_cli.py b/tests/test_cli.py
index a60113b..861ec38 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -2022,6 +2022,77 @@ class TestSnapshotCommand:
assert result.exit_code == 0, result.output
assert updater.call_args.kwargs["with_grafana_schema"] is False
+ def test_snapshot_with_publish_copy(
+ self,
+ tmp_path: Path,
+ mocker: MockerFixture,
+ ) -> None:
+ """--publish-copy calls publish_grafana_copy after update_observability."""
+ mocker.patch("mt5cli.cli.sdk.update_observability_with_config")
+ mock_publish = mocker.patch("mt5cli.grafana.publish_grafana_copy")
+ copy_path = tmp_path / "grafana.db"
+ result = runner.invoke(
+ app,
+ [
+ "-o",
+ str(tmp_path / "out.db"),
+ "snapshot",
+ "--publish-copy",
+ str(copy_path),
+ ],
+ )
+ assert result.exit_code == 0, result.output
+ mock_publish.assert_called_once()
+
+ def test_snapshot_no_publish_copy_by_default(
+ self,
+ tmp_path: Path,
+ mocker: MockerFixture,
+ ) -> None:
+ """Snapshot does not call publish_grafana_copy without --publish-copy."""
+ mocker.patch("mt5cli.cli.sdk.update_observability_with_config")
+ mock_publish = mocker.patch("mt5cli.grafana.publish_grafana_copy")
+ result = runner.invoke(app, ["-o", str(tmp_path / "out.db"), "snapshot"])
+ assert result.exit_code == 0, result.output
+ mock_publish.assert_not_called()
+
+
+class TestGrafanaSchemaPublishCopy:
+ """Tests for grafana-schema --publish-copy option."""
+
+ def test_grafana_schema_with_publish_copy(
+ self,
+ tmp_path: Path,
+ mocker: MockerFixture,
+ ) -> None:
+ """grafana-schema --publish-copy calls publish_grafana_copy."""
+ mock_publish = mocker.patch("mt5cli.grafana.publish_grafana_copy")
+ output = tmp_path / "out.db"
+ copy_path = tmp_path / "grafana.db"
+ result = runner.invoke(
+ app,
+ [
+ "-o",
+ str(output),
+ "grafana-schema",
+ "--publish-copy",
+ str(copy_path),
+ ],
+ )
+ assert result.exit_code == 0, result.output
+ mock_publish.assert_called_once()
+
+ def test_grafana_schema_no_publish_copy_by_default(
+ self,
+ tmp_path: Path,
+ mocker: MockerFixture,
+ ) -> None:
+ """grafana-schema does not call publish_grafana_copy by default."""
+ mock_publish = mocker.patch("mt5cli.grafana.publish_grafana_copy")
+ result = runner.invoke(app, ["-o", str(tmp_path / "out.db"), "grafana-schema"])
+ assert result.exit_code == 0, result.output
+ mock_publish.assert_not_called()
+
class TestMain:
"""Tests for the main entry point."""
diff --git a/tests/test_examples.py b/tests/test_examples.py
new file mode 100644
index 0000000..ddfa60c
--- /dev/null
+++ b/tests/test_examples.py
@@ -0,0 +1,75 @@
+"""Tests for example files in examples/grafana/."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+_EXAMPLES_DIR = Path(__file__).parent.parent / "examples" / "grafana"
+_DASHBOARDS_DIR = _EXAMPLES_DIR / "dashboards"
+
+
+class TestGrafanaExamples:
+ """Validate structure and content of bundled Grafana example files."""
+
+ def test_dashboard_json_files_are_valid_json(self) -> None:
+ """All dashboard JSON files parse without error."""
+ paths = list(_DASHBOARDS_DIR.glob("*.json"))
+ assert paths, "No dashboard JSON files found"
+ for path in paths:
+ content = path.read_text(encoding="utf-8")
+ obj = json.loads(content)
+ assert isinstance(obj, dict), f"{path.name} root must be a JSON object"
+
+ def test_dashboard_json_has_no_private_placeholders(self) -> None:
+ """Dashboard JSON files contain no obvious credential placeholders."""
+ private_patterns = ["password", "api_key", "apikey"]
+ for path in _DASHBOARDS_DIR.glob("*.json"):
+ content = path.read_text(encoding="utf-8").lower()
+ for pat in private_patterns:
+ assert pat not in content, f"{path.name} contains {pat!r}"
+
+ def test_dashboard_json_uses_grafana_views(self) -> None:
+ """All dashboard JSON files query grafana_* views."""
+ for path in _DASHBOARDS_DIR.glob("*.json"):
+ content = path.read_text(encoding="utf-8")
+ assert "grafana_" in content, (
+ f"{path.name} must contain queries against grafana_* views"
+ )
+
+ def test_dashboard_json_has_uid(self) -> None:
+ """All dashboard JSON files have a non-empty uid field."""
+ for path in _DASHBOARDS_DIR.glob("*.json"):
+ obj = json.loads(path.read_text(encoding="utf-8"))
+ assert obj.get("uid"), f"{path.name} must have a uid"
+
+ def test_dashboard_json_has_title(self) -> None:
+ """All dashboard JSON files have a non-empty title field."""
+ for path in _DASHBOARDS_DIR.glob("*.json"):
+ obj = json.loads(path.read_text(encoding="utf-8"))
+ assert obj.get("title"), f"{path.name} must have a title"
+
+ def test_expected_dashboards_present(self) -> None:
+ """The three expected dashboard files are present."""
+ names = {p.name for p in _DASHBOARDS_DIR.glob("*.json")}
+ assert "mt5cli-overview.json" in names
+ assert "mt5cli-trades.json" in names
+ assert "mt5cli-market.json" in names
+
+ def test_readme_exists(self) -> None:
+ """examples/grafana/README.md is present."""
+ assert (_EXAMPLES_DIR / "README.md").is_file()
+
+ def test_compose_file_exists(self) -> None:
+ """examples/grafana/compose.yml is present."""
+ assert (_EXAMPLES_DIR / "compose.yml").is_file()
+
+ def test_datasource_provisioning_exists(self) -> None:
+ """Datasource provisioning YAML is present."""
+ assert (
+ _EXAMPLES_DIR / "provisioning" / "datasources" / "mt5cli-sqlite.yml"
+ ).is_file()
+
+ def test_dashboard_provisioning_exists(self) -> None:
+ """Dashboard provisioning YAML is present."""
+ assert (_EXAMPLES_DIR / "provisioning" / "dashboards" / "mt5cli.yml").is_file()
diff --git a/tests/test_grafana.py b/tests/test_grafana.py
index fa2fb40..d84c2a6 100644
--- a/tests/test_grafana.py
+++ b/tests/test_grafana.py
@@ -4,8 +4,9 @@ from __future__ import annotations
import logging
import sqlite3
+from pathlib import Path
from typing import TYPE_CHECKING
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
import pandas as pd
import pytest
@@ -24,6 +25,7 @@ from mt5cli.grafana import (
insert_order_snapshots,
insert_position_snapshots,
insert_terminal_snapshot,
+ publish_grafana_copy,
record_snapshot_run,
start_snapshot_run,
)
@@ -836,3 +838,154 @@ class TestSnapshotInserts:
record_snapshot_run(conn, run_id, "ok")
row = conn.execute("SELECT status, detail FROM snapshot_runs").fetchone()
assert row == ("ok", None)
+
+
+# ---------------------------------------------------------------------------
+# TestPublishGrafanaCopy
+# ---------------------------------------------------------------------------
+
+
+def _make_source_db(path: Path) -> None:
+ """Create a minimal source SQLite database with snapshot tables."""
+ with sqlite3.connect(path) as conn:
+ conn.execute("PRAGMA journal_mode=WAL")
+ create_snapshot_tables(conn)
+ conn.execute(
+ "INSERT INTO snapshot_runs (observed_at, status) VALUES (?, 'ok')",
+ (1700000000,),
+ )
+
+
+class TestPublishGrafanaCopy:
+ """Tests for publish_grafana_copy."""
+
+ def test_publish_to_fresh_target(self, tmp_path: Path) -> None:
+ """publish_grafana_copy creates the target file."""
+ source = tmp_path / "src.db"
+ target = tmp_path / "out" / "grafana.db"
+ _make_source_db(source)
+ result = publish_grafana_copy(source, target)
+ assert target.exists()
+ assert result == target.resolve()
+
+ def test_overwrite_existing_target(self, tmp_path: Path) -> None:
+ """publish_grafana_copy replaces an existing target without error."""
+ source = tmp_path / "src.db"
+ target = tmp_path / "grafana.db"
+ _make_source_db(source)
+ target.write_bytes(b"stale")
+ publish_grafana_copy(source, target)
+ # Target must now be a valid SQLite file from source
+ with sqlite3.connect(target) as conn:
+ tables = {
+ row[0]
+ for row in conn.execute(
+ "SELECT name FROM sqlite_master WHERE type='table'"
+ ).fetchall()
+ }
+ assert "snapshot_runs" in tables
+
+ def test_target_contains_source_tables(self, tmp_path: Path) -> None:
+ """Published target contains the same tables as the source."""
+ source = tmp_path / "src.db"
+ target = tmp_path / "grafana.db"
+ _make_source_db(source)
+ publish_grafana_copy(source, target)
+ with sqlite3.connect(target) as conn:
+ tables = {
+ row[0]
+ for row in conn.execute(
+ "SELECT name FROM sqlite_master WHERE type='table'"
+ ).fetchall()
+ }
+ assert {"snapshot_runs", "account_snapshots"}.issubset(tables)
+
+ def test_target_can_be_opened_readonly(self, tmp_path: Path) -> None:
+ """Published target can be opened with uri=True in read-only mode."""
+ source = tmp_path / "src.db"
+ target = tmp_path / "grafana.db"
+ _make_source_db(source)
+ publish_grafana_copy(source, target)
+ uri = f"file:{target}?mode=ro"
+ with sqlite3.connect(uri, uri=True) as conn:
+ row = conn.execute("SELECT status FROM snapshot_runs").fetchone()
+ assert row == ("ok",)
+
+ def test_same_path_raises(self, tmp_path: Path) -> None:
+ """publish_grafana_copy raises ValueError when source equals target."""
+ db = tmp_path / "history.db"
+ _make_source_db(db)
+ with pytest.raises(ValueError, match="must differ from the source"):
+ publish_grafana_copy(db, db)
+
+ def test_source_not_found_raises(self, tmp_path: Path) -> None:
+ """publish_grafana_copy raises FileNotFoundError when source is absent."""
+ with pytest.raises(FileNotFoundError):
+ publish_grafana_copy(tmp_path / "missing.db", tmp_path / "out.db")
+
+ def test_preserve_old_target_on_backup_failure(self, tmp_path: Path) -> None:
+ """Old target is preserved when the backup fails."""
+ source = tmp_path / "src.db"
+ target = tmp_path / "grafana.db"
+ _make_source_db(source)
+ original_content = b"original_data"
+ target.write_bytes(original_content)
+ with patch("sqlite3.connect") as mock_connect:
+ mock_src = MagicMock()
+ mock_src.__enter__ = MagicMock(return_value=mock_src)
+ mock_src.__exit__ = MagicMock(return_value=False)
+ mock_src.backup.side_effect = sqlite3.OperationalError("backup failed")
+ mock_connect.return_value = mock_src
+ with pytest.raises(sqlite3.OperationalError, match="backup failed"):
+ publish_grafana_copy(source, target)
+ assert target.read_bytes() == original_content
+
+ def test_temp_file_cleaned_up_on_failure(self, tmp_path: Path) -> None:
+ """Temporary file is removed when backup raises an exception."""
+ source = tmp_path / "src.db"
+ target = tmp_path / "grafana.db"
+ _make_source_db(source)
+ with patch("sqlite3.connect") as mock_connect:
+ mock_src = MagicMock()
+ mock_src.__enter__ = MagicMock(return_value=mock_src)
+ mock_src.__exit__ = MagicMock(return_value=False)
+ mock_src.backup.side_effect = sqlite3.OperationalError("fail")
+ mock_connect.return_value = mock_src
+ with pytest.raises(sqlite3.OperationalError):
+ publish_grafana_copy(source, target)
+ tmp_files = list(tmp_path.glob("grafana.db.*.tmp"))
+ assert not tmp_files, "Temp file should be cleaned up on failure"
+
+ def test_returns_path_object(self, tmp_path: Path) -> None:
+ """publish_grafana_copy returns a Path instance."""
+ source = tmp_path / "src.db"
+ target = tmp_path / "grafana.db"
+ _make_source_db(source)
+ result = publish_grafana_copy(source, target)
+ assert isinstance(result, Path)
+
+ def test_fresh_target_has_readable_permissions(self, tmp_path: Path) -> None:
+ """Published copy is readable by the owner."""
+ import stat as _stat # noqa: PLC0415
+
+ source = tmp_path / "src.db"
+ target = tmp_path / "grafana.db"
+ _make_source_db(source)
+ publish_grafana_copy(source, target)
+ mode = target.stat().st_mode & 0o777
+ assert bool(mode & _stat.S_IRUSR), "owner must be able to read"
+
+ @pytest.mark.skipif(
+ __import__("sys").platform == "win32",
+ reason="Windows does not support Unix-style group/other permission bits",
+ )
+ def test_overwrite_preserves_existing_target_mode(self, tmp_path: Path) -> None:
+ """Overwriting an existing target preserves that target's file mode."""
+ source = tmp_path / "src.db"
+ target = tmp_path / "grafana.db"
+ _make_source_db(source)
+ target.write_bytes(b"old")
+ target.chmod(0o640)
+ publish_grafana_copy(source, target)
+ mode = target.stat().st_mode & 0o777
+ assert mode == 0o640
diff --git a/tests/test_sdk.py b/tests/test_sdk.py
index 1b17fa9..a82f452 100644
--- a/tests/test_sdk.py
+++ b/tests/test_sdk.py
@@ -3120,3 +3120,144 @@ class TestUpdateObservability:
call_kwargs = spy.call_args.kwargs
assert call_kwargs["symbols"] == ["EURUSD"]
assert call_kwargs["include_account"] is False
+
+ def test_update_observability_invokes_snapshot_telemetry(
+ self,
+ mock_client: MagicMock,
+ mocker: MockerFixture,
+ tmp_path: Path,
+ ) -> None:
+ """update_observability calls record_snapshot_update on the global metrics."""
+ mock_metrics = MagicMock()
+ mock_cm = MagicMock()
+ mock_cm.__enter__ = MagicMock(return_value=None)
+ mock_cm.__exit__ = MagicMock(return_value=False)
+ mock_metrics.record_snapshot_update.return_value = mock_cm
+ mocker.patch("mt5cli.sdk.get_metrics", return_value=mock_metrics)
+ update_observability(client=mock_client, output=tmp_path / "obs.db")
+ mock_metrics.record_snapshot_update.assert_called_once()
+
+ def test_update_observability_emits_account_metrics(
+ self,
+ mock_client: MagicMock,
+ mocker: MockerFixture,
+ tmp_path: Path,
+ ) -> None:
+ """_snapshot_account emits account gauges via get_metrics."""
+ mock_metrics = MagicMock()
+ mock_cm = MagicMock()
+ mock_cm.__enter__ = MagicMock(return_value=None)
+ mock_cm.__exit__ = MagicMock(return_value=False)
+ mock_metrics.record_snapshot_update.return_value = mock_cm
+ mocker.patch("mt5cli.sdk.get_metrics", return_value=mock_metrics)
+ update_observability(client=mock_client, output=tmp_path / "obs.db")
+ mock_metrics.record_account_state.assert_called_once()
+
+ def test_update_observability_emits_terminal_metrics(
+ self,
+ mock_client: MagicMock,
+ mocker: MockerFixture,
+ tmp_path: Path,
+ ) -> None:
+ """_snapshot_terminal emits connected/trade gauges via get_metrics."""
+ mock_metrics = MagicMock()
+ mock_cm = MagicMock()
+ mock_cm.__enter__ = MagicMock(return_value=None)
+ mock_cm.__exit__ = MagicMock(return_value=False)
+ mock_metrics.record_snapshot_update.return_value = mock_cm
+ mocker.patch("mt5cli.sdk.get_metrics", return_value=mock_metrics)
+ update_observability(client=mock_client, output=tmp_path / "obs.db")
+ mock_metrics.record_terminal_state.assert_called_once_with(
+ connected=1.0, trade_allowed=1.0, trade_expert=1.0
+ )
+
+ def test_update_observability_aggregates_same_symbol_positions(
+ self,
+ mocker: MockerFixture,
+ tmp_path: Path,
+ ) -> None:
+ """Same-symbol positions are summed before emitting gauges (hedging)."""
+ mock_client = MagicMock()
+ mock_client.account_info_as_df.return_value = pd.DataFrame([
+ {
+ "login": 1,
+ "server": "demo",
+ "balance": 1000.0,
+ "equity": 1000.0,
+ "margin": 0.0,
+ "margin_free": 1000.0,
+ "margin_level": 0.0,
+ }
+ ])
+ mock_client.positions_get_as_df.return_value = pd.DataFrame([
+ {"ticket": 1, "symbol": "EURUSD", "profit": 10.0, "volume": 0.1},
+ {"ticket": 2, "symbol": "EURUSD", "profit": -5.0, "volume": 0.2},
+ {"ticket": 3, "symbol": "GBPUSD", "profit": 3.0, "volume": 0.05},
+ ])
+ mock_client.orders_get_as_df.return_value = pd.DataFrame()
+ mock_client.terminal_info_as_df.return_value = pd.DataFrame()
+ mock_metrics = MagicMock()
+ mock_cm = MagicMock()
+ mock_cm.__enter__ = MagicMock(return_value=None)
+ mock_cm.__exit__ = MagicMock(return_value=False)
+ mock_metrics.record_snapshot_update.return_value = mock_cm
+ mocker.patch("mt5cli.sdk.get_metrics", return_value=mock_metrics)
+ update_observability(client=mock_client, output=tmp_path / "obs.db")
+ calls = mock_metrics.record_position_state.call_args_list
+ # Two EURUSD positions should be collapsed to one call; GBPUSD is one call.
+ assert len(calls) == 2
+ by_symbol = {c.kwargs["symbol"]: c.kwargs for c in calls}
+ assert abs(float(by_symbol["EURUSD"]["profit"]) - 5.0) < 1e-9
+ assert abs(float(by_symbol["EURUSD"]["volume"]) - 0.3) < 1e-9
+ assert abs(float(by_symbol["GBPUSD"]["profit"]) - 3.0) < 1e-9
+ assert abs(float(by_symbol["GBPUSD"]["volume"]) - 0.05) < 1e-9
+
+
+class TestUpdateHistoryTelemetry:
+ """Tests for telemetry hooks in update_history."""
+
+ def test_update_history_invokes_history_telemetry(
+ self,
+ mocker: MockerFixture,
+ tmp_path: Path,
+ ) -> None:
+ """update_history wraps write_incremental_datasets with telemetry."""
+ mock_client = MagicMock()
+ mock_client.copy_rates_range_as_df.return_value = pd.DataFrame()
+ mock_client.history_orders_get_as_df.return_value = pd.DataFrame()
+ mock_client.history_deals_get_as_df.return_value = pd.DataFrame()
+ mock_metrics = MagicMock()
+ mock_cm = MagicMock()
+ mock_cm.__enter__ = MagicMock(return_value=None)
+ mock_cm.__exit__ = MagicMock(return_value=False)
+ mock_metrics.record_history_update.return_value = mock_cm
+ mocker.patch("mt5cli.sdk.get_metrics", return_value=mock_metrics)
+ update_history(
+ client=mock_client,
+ output=tmp_path / "hist.db",
+ symbols=["EURUSD"],
+ )
+ mock_metrics.record_history_update.assert_called_once_with(dataset="history")
+
+ def test_update_history_emits_history_rows(
+ self,
+ mocker: MockerFixture,
+ tmp_path: Path,
+ ) -> None:
+ """update_history calls add_history_rows with the SQLite change delta."""
+ mock_client = MagicMock()
+ mock_client.copy_rates_range_as_df.return_value = pd.DataFrame()
+ mock_client.history_orders_get_as_df.return_value = pd.DataFrame()
+ mock_client.history_deals_get_as_df.return_value = pd.DataFrame()
+ mock_metrics = MagicMock()
+ mock_cm = MagicMock()
+ mock_cm.__enter__ = MagicMock(return_value=None)
+ mock_cm.__exit__ = MagicMock(return_value=False)
+ mock_metrics.record_history_update.return_value = mock_cm
+ mocker.patch("mt5cli.sdk.get_metrics", return_value=mock_metrics)
+ update_history(
+ client=mock_client,
+ output=tmp_path / "hist.db",
+ symbols=["EURUSD"],
+ )
+ mock_metrics.add_history_rows.assert_called_once_with(0, dataset="history")
diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py
new file mode 100644
index 0000000..2861283
--- /dev/null
+++ b/tests/test_telemetry.py
@@ -0,0 +1,249 @@
+"""Tests for mt5cli.telemetry module."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import pytest
+from opentelemetry.sdk.metrics.export import InMemoryMetricReader
+
+from mt5cli.telemetry import (
+ _OTEL_AVAILABLE, # type: ignore[reportPrivateUsage]
+ _Mt5Metrics, # type: ignore[reportPrivateUsage]
+ _NoOp, # type: ignore[reportPrivateUsage]
+ configure_metrics,
+ enable_otel_metrics,
+ get_metrics,
+)
+
+
+class TestNoOp:
+ """Tests for _NoOp no-op instrument."""
+
+ def test_add_is_noop(self) -> None:
+ """_NoOp.add accepts amount and optional attributes without error."""
+ noop = _NoOp()
+ noop.add(1.0)
+ noop.add(1.0, {"key": "val"})
+
+ def test_set_is_noop(self) -> None:
+ """_NoOp.set accepts amount and optional attributes without error."""
+ noop = _NoOp()
+ noop.set(2.0)
+ noop.set(2.0, {"key": "val"})
+
+ def test_record_is_noop(self) -> None:
+ """_NoOp.record accepts amount and optional attributes without error."""
+ noop = _NoOp()
+ noop.record(3.0)
+ noop.record(3.0, {"key": "val"})
+
+
+class TestMt5Metrics:
+ """Tests for _Mt5Metrics."""
+
+ def test_default_instruments_are_noop(self) -> None:
+ """Default _Mt5Metrics methods do not raise before configure is called."""
+ m = _Mt5Metrics()
+ m.record_account_state(
+ login="123",
+ server="demo",
+ balance=1000.0,
+ equity=1050.0,
+ margin=100.0,
+ margin_free=950.0,
+ margin_level=1050.0,
+ )
+
+ def test_configure_calls_meter(self) -> None:
+ """configure() calls create_histogram, create_counter, create_gauge on meter."""
+ meter = MagicMock()
+ m = _Mt5Metrics()
+ m.configure(meter)
+ assert meter.create_histogram.called
+ assert meter.create_counter.called
+ assert meter.create_gauge.called
+
+ def test_record_history_update_success(self) -> None:
+ """record_history_update records duration and timestamp on success."""
+ meter = MagicMock()
+ m = _Mt5Metrics()
+ m.configure(meter)
+ with m.record_history_update(dataset="rates"):
+ pass
+ m._history_duration.record.assert_called_once() # type: ignore[reportPrivateUsage]
+ m._last_successful_update.set.assert_called_once() # type: ignore[reportPrivateUsage]
+ m._history_failures.add.assert_not_called() # type: ignore[reportPrivateUsage]
+
+ def test_record_history_update_failure(self) -> None:
+ """record_history_update increments failure counter and re-raises on error."""
+ meter = MagicMock()
+ m = _Mt5Metrics()
+ m.configure(meter)
+ exc = ValueError("boom")
+ with (
+ pytest.raises(ValueError, match="boom"),
+ m.record_history_update(dataset="rates"),
+ ):
+ raise exc
+ m._history_failures.add.assert_called_once_with( # type: ignore[reportPrivateUsage]
+ 1, {"dataset": "rates"}
+ )
+ m._history_duration.record.assert_not_called() # type: ignore[reportPrivateUsage]
+
+ def test_add_history_rows(self) -> None:
+ """add_history_rows increments the rows-written counter."""
+ meter = MagicMock()
+ m = _Mt5Metrics()
+ m.configure(meter)
+ m.add_history_rows(42, dataset="rates")
+ m._history_rows.add.assert_called_once_with( # type: ignore[reportPrivateUsage]
+ 42, {"dataset": "rates"}
+ )
+
+ def test_record_snapshot_update_success(self) -> None:
+ """record_snapshot_update records duration on success."""
+ meter = MagicMock()
+ m = _Mt5Metrics()
+ m.configure(meter)
+ with m.record_snapshot_update():
+ pass
+ m._snapshot_duration.record.assert_called_once() # type: ignore[reportPrivateUsage]
+ m._snapshot_failures.add.assert_not_called() # type: ignore[reportPrivateUsage]
+
+ def test_record_snapshot_update_failure(self) -> None:
+ """record_snapshot_update increments failure counter and re-raises on error."""
+ meter = MagicMock()
+ m = _Mt5Metrics()
+ m.configure(meter)
+ exc = RuntimeError("snap fail")
+ with (
+ pytest.raises(RuntimeError, match="snap fail"),
+ m.record_snapshot_update(),
+ ):
+ raise exc
+ m._snapshot_failures.add.assert_called_once_with(1, {}) # type: ignore[reportPrivateUsage]
+ m._snapshot_duration.record.assert_not_called() # type: ignore[reportPrivateUsage]
+
+ def test_record_position_state(self) -> None:
+ """record_position_state emits profit and volume gauges."""
+ meter = MagicMock()
+ m = _Mt5Metrics()
+ m.configure(meter)
+ m.record_position_state(
+ login="42",
+ server="demo",
+ symbol="EURUSD",
+ profit=12.5,
+ volume=0.01,
+ )
+ # Both profit and volume share the same gauge mock via create_gauge.
+ # Verify that set was called exactly twice (once each).
+ assert m._position_profit.set.call_count == 2 # type: ignore[reportPrivateUsage]
+
+ def test_record_terminal_state(self) -> None:
+ """record_terminal_state emits connected, trade_allowed, trade_expert gauges."""
+ meter = MagicMock()
+ m = _Mt5Metrics()
+ m.configure(meter)
+ m.record_terminal_state(connected=1.0, trade_allowed=1.0, trade_expert=0.0)
+ # All three terminal gauges share the same mock; set is called 3 times.
+ assert m._terminal_connected.set.call_count == 3 # type: ignore[reportPrivateUsage]
+
+ def test_record_account_state_after_configure(self) -> None:
+ """record_account_state emits all five account gauges."""
+ meter = MagicMock()
+ m = _Mt5Metrics()
+ m.configure(meter)
+ m.record_account_state(
+ login="99",
+ server="live",
+ balance=5000.0,
+ equity=5100.0,
+ margin=200.0,
+ margin_free=4800.0,
+ margin_level=2550.0,
+ )
+ # All five account gauges share the same gauge mock; set is called 5 times.
+ assert m._account_balance.set.call_count == 5 # type: ignore[reportPrivateUsage]
+
+ def test_record_history_update_noop_before_configure(self) -> None:
+ """record_history_update works without configure (no-op instruments)."""
+ m = _Mt5Metrics()
+ with m.record_history_update(dataset="ticks"):
+ pass
+
+ def test_record_snapshot_update_noop_before_configure(self) -> None:
+ """record_snapshot_update works without configure (no-op instruments)."""
+ m = _Mt5Metrics()
+ with m.record_snapshot_update():
+ pass
+
+
+class TestConfigureMetrics:
+ """Tests for configure_metrics and get_metrics."""
+
+ def test_configure_metrics_updates_global(self) -> None:
+ """configure_metrics wires up the global singleton."""
+ meter = MagicMock()
+ configure_metrics(meter)
+ assert get_metrics() is get_metrics()
+
+ def test_get_metrics_returns_mt5metrics(self) -> None:
+ """get_metrics returns the global _Mt5Metrics instance."""
+ assert isinstance(get_metrics(), _Mt5Metrics)
+
+
+class TestEnableOtelMetrics:
+ """Tests for enable_otel_metrics."""
+
+ def test_enable_raises_when_unavailable(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """enable_otel_metrics raises ImportError when OTel is not installed."""
+ monkeypatch.setattr("mt5cli.telemetry._OTEL_AVAILABLE", False)
+ with pytest.raises(ImportError, match="opentelemetry-api"):
+ enable_otel_metrics()
+
+ def test_enable_configures_sdk_pipeline_with_readers(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """enable_otel_metrics wires up an SDK MeterProvider with supplied readers."""
+ mock_mod = MagicMock()
+ monkeypatch.setattr("mt5cli.telemetry._OTEL_AVAILABLE", True)
+ monkeypatch.setattr("mt5cli.telemetry._otel_metrics_mod", mock_mod)
+ reader = InMemoryMetricReader()
+ enable_otel_metrics("my-service", readers=[reader])
+ mock_mod.set_meter_provider.assert_called_once()
+ provider = mock_mod.set_meter_provider.call_args[0][0]
+ assert provider.get_meter("my-service") is not None
+
+ def test_enable_default_readers_uses_otlp(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """enable_otel_metrics with no readers creates an OTLP pipeline by default."""
+ mock_mod = MagicMock()
+ monkeypatch.setattr("mt5cli.telemetry._OTEL_AVAILABLE", True)
+ monkeypatch.setattr("mt5cli.telemetry._otel_metrics_mod", mock_mod)
+ monkeypatch.setattr("mt5cli.telemetry._OtelOTLPExporter", MagicMock())
+ enable_otel_metrics("my-service")
+ mock_mod.set_meter_provider.assert_called_once()
+
+ def test_enable_default_readers_raises_when_otlp_missing(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """enable_otel_metrics raises ImportError when the OTLP exporter is missing."""
+ mock_mod = MagicMock()
+ monkeypatch.setattr("mt5cli.telemetry._OTEL_AVAILABLE", True)
+ monkeypatch.setattr("mt5cli.telemetry._otel_metrics_mod", mock_mod)
+ monkeypatch.setattr("mt5cli.telemetry._OtelOTLPExporter", None)
+ with pytest.raises(ImportError, match="opentelemetry-exporter-otlp-proto-http"):
+ enable_otel_metrics()
+
+ def test_otel_available_flag_is_bool(self) -> None:
+ """_OTEL_AVAILABLE is a boolean."""
+ assert isinstance(_OTEL_AVAILABLE, bool)
diff --git a/uv.lock b/uv.lock
index 814cadc..78b4b95 100644
--- a/uv.lock
+++ b/uv.lock
@@ -223,6 +223,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" },
]
+[[package]]
+name = "googleapis-common-protos"
+version = "1.75.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" },
+]
+
[[package]]
name = "griffelib"
version = "2.0.2"
@@ -358,7 +370,7 @@ name = "metatrader5"
version = "5.0.5640"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "numpy" },
+ { name = "numpy", marker = "sys_platform == 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a0/3b764c6743ef601ff12f7d8d62ca5768eb25e90d3758ac7e1d08e667af85/metatrader5-5.0.5640-cp311-cp311-win_amd64.whl", hash = "sha256:4057255f2d63138a3ea1a5d492715038a71d3177cad793fca97a5c58771b4eb1", size = 48091, upload-time = "2026-02-20T23:31:12.289Z" },
@@ -496,6 +508,11 @@ dependencies = [
]
[package.optional-dependencies]
+otel = [
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-exporter-otlp-proto-http" },
+ { name = "opentelemetry-sdk" },
+]
parquet = [
{ name = "pyarrow" },
]
@@ -505,6 +522,8 @@ dev = [
{ name = "mkdocs" },
{ name = "mkdocs-material" },
{ name = "mkdocstrings", extra = ["python"] },
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-sdk" },
{ name = "pandas-stubs" },
{ name = "pyarrow" },
{ name = "pymdown-extensions" },
@@ -518,17 +537,22 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.1.0" },
+ { name = "opentelemetry-api", marker = "extra == 'otel'" },
+ { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'" },
+ { name = "opentelemetry-sdk", marker = "extra == 'otel'" },
{ name = "pdmt5", specifier = ">=1.0.0" },
{ name = "pyarrow", marker = "extra == 'parquet'", specifier = ">=19.0.0" },
{ name = "typer", specifier = ">=0.15.0" },
]
-provides-extras = ["parquet"]
+provides-extras = ["parquet", "otel"]
[package.metadata.requires-dev]
dev = [
{ name = "mkdocs", specifier = ">=1.6.1" },
{ name = "mkdocs-material", specifier = ">=9.7.6" },
{ name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.4" },
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-sdk" },
{ name = "pandas-stubs", specifier = ">=2.2.3.250527" },
{ name = "pyarrow", specifier = ">=19.0.0" },
{ name = "pymdown-extensions", specifier = ">=10.21.2" },
@@ -606,6 +630,87 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" },
]
+[[package]]
+name = "opentelemetry-api"
+version = "1.43.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ae/cc/e4c9584181f86494df0f6bdec1a4f3280c50db44704dc2a407e994fc87bb/opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1", size = 73476, upload-time = "2026-06-24T15:19:55.323Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/17/83/6dba32b85f31868400440dc7ad2ca1eab94cbbf3a7b0459ed39f8311a9e2/opentelemetry_api-1.43.0-py3-none-any.whl", hash = "sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd", size = 61912, upload-time = "2026-06-24T15:19:35.434Z" },
+]
+
+[[package]]
+name = "opentelemetry-exporter-otlp-proto-common"
+version = "1.43.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-proto" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/c1/e8098490ab15abf116dcaf9fa89ededcb35547c7d08d4b5a62f573dc1e63/opentelemetry_exporter_otlp_proto_common-1.43.0.tar.gz", hash = "sha256:c4e32ba6d6b13bdb2b8f6764c4fd28d00192826561aa04f6d14eedfce7ac076f", size = 20197, upload-time = "2026-06-24T15:20:00.247Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/b2/41ebc74ae1d5859901f1b69305de58724bf043381103d6ef413521cbc35a/opentelemetry_exporter_otlp_proto_common-1.43.0-py3-none-any.whl", hash = "sha256:123c3f9cc87218562490c63b36f497bf3a722faf174a515d1443f31ababa6264", size = 17048, upload-time = "2026-06-24T15:19:41.264Z" },
+]
+
+[[package]]
+name = "opentelemetry-exporter-otlp-proto-http"
+version = "1.43.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "googleapis-common-protos" },
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-exporter-otlp-proto-common" },
+ { name = "opentelemetry-proto" },
+ { name = "opentelemetry-sdk" },
+ { name = "requests" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/92/0b9f56412483a8891d4843890294796c9df8ab42417bd9bad8035d840cb3/opentelemetry_exporter_otlp_proto_http-1.43.0.tar.gz", hash = "sha256:fa8a42bb7d00ee5391f4c0b04d8e6a46c03caa437903296ab73a81dc11ba118f", size = 25406, upload-time = "2026-06-24T15:20:01.515Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/20/b685ed7af2e17c29ffc8af56f1fa8bc2033258fc30fb0d2b722f49d13ba0/opentelemetry_exporter_otlp_proto_http-1.43.0-py3-none-any.whl", hash = "sha256:647f603aa8efdbdb4dbff842e0729d0406a6fff26b295a72d3d60e7d963b2610", size = 21795, upload-time = "2026-06-24T15:19:43.164Z" },
+]
+
+[[package]]
+name = "opentelemetry-proto"
+version = "1.43.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e0/b9/d357faefb40bda1d4799913e6af611171ff22a2dedcb93576bc92242d056/opentelemetry_proto-1.43.0.tar.gz", hash = "sha256:224778df17e1f3fafeaaa21d874236ca5f6ffc2f86e0899298ec7351aac27924", size = 46481, upload-time = "2026-06-24T15:20:07.625Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ed/a7/3e5308cf548b8f72529c7db1afdb3a404211982376a12927fd7759f77bf3/opentelemetry_proto-1.43.0-py3-none-any.whl", hash = "sha256:c58f1f7ef84bc7dc2834016c0c37fe0081dde7ca9f6339be1970fbf9cdaaa90d", size = 72489, upload-time = "2026-06-24T15:19:51.164Z" },
+]
+
+[[package]]
+name = "opentelemetry-sdk"
+version = "1.43.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-semantic-conventions" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3e/eb/5041074274ac0956b03637cc039d434569112468e875eddfcc9a0674ce06/opentelemetry_sdk-1.43.0.tar.gz", hash = "sha256:d8187c81c162df9913e4003dd6485f7390d9a24fc17026ec7387b8b8218b08e9", size = 254744, upload-time = "2026-06-24T15:20:08.467Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/e3/b17be23af124201c9f52eececd4cc8ddfed1597d37b4ee771895d325805c/opentelemetry_sdk-1.43.0-py3-none-any.whl", hash = "sha256:d1323a547c1ce69d6a069a17a44b7da82bb8b332051ecb074041f87642c86823", size = 178852, upload-time = "2026-06-24T15:19:52.169Z" },
+]
+
+[[package]]
+name = "opentelemetry-semantic-conventions"
+version = "0.64b0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-api" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5a/30/5f26df29509eccd86b99b481ac9ffa39da49ba9577cc69071c552ae30447/opentelemetry_semantic_conventions-0.64b0.tar.gz", hash = "sha256:72f76fb2d1582d9d033dd1fcd84532e961e6ff3d90d24ba6fabc72975a83864c", size = 148340, upload-time = "2026-06-24T15:20:09.267Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/ca/23ba87a221b574a7c5a99d48849d80bfe8b047624681357e2b002e566187/opentelemetry_semantic_conventions-0.64b0-py3-none-any.whl", hash = "sha256:ea77e85e354b8f604ddbe5f3d9135216f982fa4d77e5859ac30f6d8a50505aa6", size = 203713, upload-time = "2026-06-24T15:19:53.339Z" },
+]
+
[[package]]
name = "packaging"
version = "26.0"
@@ -721,6 +826,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
+[[package]]
+name = "protobuf"
+version = "7.35.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" },
+ { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" },
+ { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" },
+]
+
[[package]]
name = "pyarrow"
version = "23.0.1"