feat: add publish_grafana_copy, Grafana examples, and optional OTel metrics (#89)

* feat: Grafana copy publishing, dashboard examples, and optional OTel metrics

Implements three observability improvements:

#82 — publish_grafana_copy(): Uses SQLite online backup API (WAL-safe) to
atomically publish a consistent read-only copy beside the target. Adds
--publish-copy option to grafana-schema and snapshot CLI commands.

#83 — examples/grafana/: Minimal working Grafana setup with docker-compose,
provisioning datasource/dashboard YAML, and three dashboard JSON files
(mt5cli-overview, mt5cli-trades, mt5cli-market). All queries use grafana_*
views; no credentials or private paths included.

#84 — mt5cli/telemetry.py: Optional OTel metrics behind mt5cli[otel] extra.
Base install is unaffected. Adds _Mt5Metrics singleton (no-op until
configure_metrics() is called), wraps update_history() and
update_observability() with record_history_update / record_snapshot_update
context managers, and emits account/position gauges from snapshots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: replace ambiguous multiplication sign in comment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: normalize markdown formatting in grafana README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: preserve file mode on Grafana copy and fix unsupported time macro

- publish_grafana_copy: chmod temp file to match the existing target's
  permissions (or 0o644 when no prior target exists) before atomic
  replace, so Grafana running as a different OS user (e.g. UID 472 in
  Docker) can read the published database
- mt5cli-market.json: replace unsupported \$__timeFilter(time) with the
  epoch-based filter supported by frser-sqlite-datasource:
  "time" >= \$__from / 1000 AND "time" < \$__to / 1000

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: skip Windows-incompatible mode test, rename compose file to compose.yaml

- Skip test_overwrite_preserves_existing_target_mode on win32 since
  Windows chmod does not preserve Unix group/other permission bits
- Simplify test_fresh_target_has_readable_permissions to check owner
  read bit only (portable across platforms)
- Rename docker-compose.yml -> compose.yaml (modern Compose convention)
- Update README and test reference to match new filename

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: rename *.yaml to *.yml in examples/grafana

Renames compose.yaml, mt5cli-sqlite.yaml, and mt5cli.yaml to .yml;
updates README and test references accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: format Grafana dashboards and expand qa script to include JSON

- Update qa.sh prettier pattern to format JSON files alongside markdown
- Reformat Grafana dashboard JSONs with consistent spacing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address owner review comments before merge

- qa.sh: fix Prettier glob from `{,d,json}` to `{md,json}` so Markdown
  files are actually formatted by local QA (P2)
- compose.yml: add GF_INSTALL_PLUGINS env var so the frser-sqlite-datasource
  plugin is installed at container start (P1)
- telemetry.py: replace no-op get_meter() call with a real SDK MeterProvider
  pipeline; add optional `readers` kwarg so callers can inject custom readers
  (e.g. InMemoryMetricReader in tests) without needing the OTLP package (P1)
- sdk.py: aggregate profit and volume by symbol before emitting gauge values
  so hedging accounts with multiple same-symbol positions emit one point per
  symbol instead of overwriting with each row (P2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: emit mt5_history_update_rows_total via conn.total_changes delta

The counter was registered but never incremented, making the advertised
history-update throughput metric permanently zero. Add add_history_rows()
to _Mt5Metrics and call it in update_history() using the SQLite
total_changes delta measured around write_incremental_datasets().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address three owner review comments

- compose.yml: replace soft fallback with :? error expansion so Compose
  refuses to start when MT5CLI_DB_PATH is unset or empty (P1)
- README.md: tell native Windows users to copy only the datasource
  provisioning file; the dashboards yml contains a Docker-specific path
  that is invalid on Windows (P2)
- telemetry.py / sdk.py: emit mt5_terminal_connected,
  mt5_terminal_trade_allowed, and mt5_terminal_trade_expert gauges via a
  new record_terminal_state() method called from _snapshot_terminal(),
  completing the connection-status metric surface from issue #84 (P2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add snapshot freshness panel and win-rate column to dashboards

- mt5cli-overview.json: add a full-width "Last Snapshot" stat panel
  (dateTimeFromNow unit) below the account stats, querying
  MAX(time)*1000 from grafana_account_snapshots so users can tell
  whether Grafana is reading a current published copy (#83)
- mt5cli-trades.json: add win_rate_pct computed column to the Trade
  Statistics by Symbol table via 100.0 * winning_deals / NULLIF(
  total_deals, 0), with a percent unit override and "Win Rate (%)"
  display label (#83)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: reject same source and target path in publish_grafana_copy

Adds an early same-path guard to publish_grafana_copy: resolves both
paths before any I/O and raises ValueError if they are identical,
preventing the function from overwriting the live source database with
its own backup copy. Also adds a unit test for the rejected case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address ruff EM102/TRY003/E501 in same-path guard

Assigns the ValueError message to a variable before raising and
shortens the test docstring to stay within the 88-char line limit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: apply ruff format to publish_grafana_copy error message

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove grafana_ticks panel from default market dashboard

The Tick Bid/Ask panel queried grafana_ticks which only exists when users
collect tick data (opt-in). Users following the default OHLCV-only setup
path hit "no such table: grafana_ticks" on dashboard load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: close SQLite connections before atomic replace in publish_grafana_copy

Wrap both src and dst connections with contextlib.closing() so they are
explicitly closed before tmp_path.replace(target_path) runs. Without
this, sqlite3.Connection's context manager only commits/rolls back but
leaves the file handle open, which can cause PermissionError on Windows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: rename history.grafana.db to history.mt5cli.db in Grafana examples

frser-sqlite-datasource blocks paths containing "grafana.db" via its
internal blocklist. Rename the recommended published filename in the
README, compose comment, and datasource provisioning comment to avoid
a blocked/denied datasource for native Windows users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: update Docker Compose quick-start to pass MT5CLI_DB_PATH

The compose.yml already required MT5CLI_DB_PATH via ${MT5CLI_DB_PATH:?...},
but the README still showed bare `docker compose up -d`. Update the section
to show the env-var-prefixed invocation and document the .env file alternative.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: agent <agent@localhost>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daichi Narushima
2026-06-28 17:14:17 +09:00
committed by GitHub
parent d27da02f3f
commit 1ffac45d57
19 changed files with 2032 additions and 36 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ uv run pyright .
uv run pytest uv run pytest
# Markdown # Markdown
npx -y prettier --write './**/*.md' npx -y prettier --write './**/*.{md,json}'
# GitHub Actions # GitHub Actions
case "${OSTYPE}" in case "${OSTYPE}" in
+95
View File
@@ -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 <https://grafana.com/grafana/download/>.
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 <http://localhost:3000> (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**.
+26
View File
@@ -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:
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
@@ -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
+33 -2
View File
@@ -803,7 +803,19 @@ def collect_history(
@app.command(rich_help_panel="Collection") @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. """Create or refresh Grafana-ready views and indexes in a SQLite database.
Idempotent — safe to run repeatedly on the same database. Requires SQLite 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 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) export_ctx = _get_export_context(ctx)
if export_ctx.output_format != "sqlite3": if export_ctx.output_format != "sqlite3":
@@ -829,6 +845,9 @@ def grafana_schema(ctx: typer.Context) -> None:
create_snapshot_tables(conn) create_snapshot_tables(conn)
ensure_grafana_schema(conn) ensure_grafana_schema(conn)
logger.info("Grafana schema applied to %s", export_ctx.output) 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") @app.command(rich_help_panel="Collection")
@@ -867,6 +886,13 @@ def snapshot(
help="Ensure Grafana views and indexes exist.", help="Ensure Grafana views and indexes exist.",
), ),
] = False, ] = False,
publish_copy: Annotated[
Path | None,
typer.Option(
"--publish-copy",
help=("Publish a Grafana-ready SQLite copy to this path after snapshot."),
),
] = None,
) -> None: ) -> None:
"""Snapshot current account, position, order, and terminal state into SQLite. """Snapshot current account, position, order, and terminal state into SQLite.
@@ -894,6 +920,11 @@ def snapshot(
with_grafana_schema=with_grafana_schema, with_grafana_schema=with_grafana_schema,
) )
logger.info("Snapshot written to %s", export_ctx.output) 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: def main() -> None:
+66
View File
@@ -2,9 +2,13 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import datetime import datetime
import logging import logging
import os
import sqlite3 import sqlite3
import tempfile
from pathlib import Path
from typing import cast from typing import cast
from .history import get_table_columns from .history import get_table_columns
@@ -478,6 +482,68 @@ def ensure_grafana_schema(conn: sqlite3.Connection) -> None:
create_grafana_indexes(conn) 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 # Snapshot insert helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+60
View File
@@ -45,6 +45,7 @@ from .history import (
write_incremental_datasets, write_incremental_datasets,
) )
from .retry import retry_with_backoff from .retry import retry_with_backoff
from .telemetry import get_metrics
from .utils import ( from .utils import (
Dataset, Dataset,
IfExists, IfExists,
@@ -1026,6 +1027,9 @@ def update_history( # noqa: PLR0913
with closing(sqlite3.connect(request.output_path)) as conn, conn: with closing(sqlite3.connect(request.output_path)) as conn, conn:
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL") conn.execute("PRAGMA synchronous=NORMAL")
m = get_metrics()
with m.record_history_update(dataset="history"):
before = conn.total_changes
write_incremental_datasets( write_incremental_datasets(
conn, conn,
client, client,
@@ -1040,6 +1044,7 @@ def update_history( # noqa: PLR0913
with_views=with_views, with_views=with_views,
include_account_events=include_account_events, include_account_events=include_account_events,
) )
m.add_history_rows(conn.total_changes - before, dataset="history")
def update_history_with_config( # noqa: PLR0913 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( def _snapshot_account(
conn: sqlite3.Connection, conn: sqlite3.Connection,
client: Mt5DataClient, client: Mt5DataClient,
@@ -2184,6 +2232,7 @@ def _snapshot_account(
return None return None
row = cast("dict[str, object]", df.iloc[0].to_dict()) row = cast("dict[str, object]", df.iloc[0].to_dict())
insert_account_snapshot(conn, run_id, row) insert_account_snapshot(conn, run_id, row)
_emit_account_metrics(row)
login_val = row.get("login") login_val = row.get("login")
return int(login_val) if login_val is not None else None # type: ignore[arg-type] 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 [] raw = df.to_dict(orient="records") if not df.empty else []
rows = cast("list[dict[str, object]]", raw) rows = cast("list[dict[str, object]]", raw)
insert_position_snapshots(conn, run_id, login, rows) insert_position_snapshots(conn, run_id, login, rows)
_emit_position_metrics(rows, login)
def _snapshot_orders( def _snapshot_orders(
@@ -2218,6 +2268,14 @@ def _snapshot_orders(
insert_order_snapshots(conn, run_id, login, rows) 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( def _snapshot_terminal(
conn: sqlite3.Connection, conn: sqlite3.Connection,
client: Mt5DataClient, client: Mt5DataClient,
@@ -2231,6 +2289,7 @@ def _snapshot_terminal(
return return
row = cast("dict[str, object]", df.iloc[0].to_dict()) row = cast("dict[str, object]", df.iloc[0].to_dict())
insert_terminal_snapshot(conn, run_id, row) insert_terminal_snapshot(conn, run_id, row)
_emit_terminal_metrics(row)
def update_observability( def update_observability(
@@ -2270,6 +2329,7 @@ def update_observability(
ensure_grafana_schema(conn) ensure_grafana_schema(conn)
else: else:
create_snapshot_tables(conn) create_snapshot_tables(conn)
with get_metrics().record_snapshot_update():
run_id = start_snapshot_run(conn, observed_at) run_id = start_snapshot_run(conn, observed_at)
login: int | None = None login: int | None = None
try: try:
+354
View File
@@ -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
+7
View File
@@ -26,6 +26,11 @@ classifiers = [
[project.optional-dependencies] [project.optional-dependencies]
parquet = ["pyarrow >= 19.0.0"] parquet = ["pyarrow >= 19.0.0"]
otel = [
"opentelemetry-api",
"opentelemetry-sdk",
"opentelemetry-exporter-otlp-proto-http",
]
[project.scripts] [project.scripts]
mt5cli = "mt5cli.cli:main" mt5cli = "mt5cli.cli:main"
@@ -45,6 +50,8 @@ dev = [
"pytest-cov >= 5.0.0", "pytest-cov >= 5.0.0",
"pandas-stubs >= 2.2.3.250527", "pandas-stubs >= 2.2.3.250527",
"pyarrow >= 19.0.0", "pyarrow >= 19.0.0",
"opentelemetry-api",
"opentelemetry-sdk",
"mkdocs >= 1.6.1", "mkdocs >= 1.6.1",
"mkdocs-material >= 9.7.6", "mkdocs-material >= 9.7.6",
"mkdocstrings[python] >= 1.0.4", "mkdocstrings[python] >= 1.0.4",
+71
View File
@@ -2022,6 +2022,77 @@ class TestSnapshotCommand:
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert updater.call_args.kwargs["with_grafana_schema"] is False 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: class TestMain:
"""Tests for the main entry point.""" """Tests for the main entry point."""
+75
View File
@@ -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()
+154 -1
View File
@@ -4,8 +4,9 @@ from __future__ import annotations
import logging import logging
import sqlite3 import sqlite3
from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
import pandas as pd import pandas as pd
import pytest import pytest
@@ -24,6 +25,7 @@ from mt5cli.grafana import (
insert_order_snapshots, insert_order_snapshots,
insert_position_snapshots, insert_position_snapshots,
insert_terminal_snapshot, insert_terminal_snapshot,
publish_grafana_copy,
record_snapshot_run, record_snapshot_run,
start_snapshot_run, start_snapshot_run,
) )
@@ -836,3 +838,154 @@ class TestSnapshotInserts:
record_snapshot_run(conn, run_id, "ok") record_snapshot_run(conn, run_id, "ok")
row = conn.execute("SELECT status, detail FROM snapshot_runs").fetchone() row = conn.execute("SELECT status, detail FROM snapshot_runs").fetchone()
assert row == ("ok", None) 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
+141
View File
@@ -3120,3 +3120,144 @@ class TestUpdateObservability:
call_kwargs = spy.call_args.kwargs call_kwargs = spy.call_args.kwargs
assert call_kwargs["symbols"] == ["EURUSD"] assert call_kwargs["symbols"] == ["EURUSD"]
assert call_kwargs["include_account"] is False 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")
+249
View File
@@ -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)
Generated
+122 -2
View File
@@ -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" }, { 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]] [[package]]
name = "griffelib" name = "griffelib"
version = "2.0.2" version = "2.0.2"
@@ -358,7 +370,7 @@ name = "metatrader5"
version = "5.0.5640" version = "5.0.5640"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "numpy" }, { name = "numpy", marker = "sys_platform == 'win32'" },
] ]
wheels = [ 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" }, { 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] [package.optional-dependencies]
otel = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
{ name = "opentelemetry-sdk" },
]
parquet = [ parquet = [
{ name = "pyarrow" }, { name = "pyarrow" },
] ]
@@ -505,6 +522,8 @@ dev = [
{ name = "mkdocs" }, { name = "mkdocs" },
{ name = "mkdocs-material" }, { name = "mkdocs-material" },
{ name = "mkdocstrings", extra = ["python"] }, { name = "mkdocstrings", extra = ["python"] },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-sdk" },
{ name = "pandas-stubs" }, { name = "pandas-stubs" },
{ name = "pyarrow" }, { name = "pyarrow" },
{ name = "pymdown-extensions" }, { name = "pymdown-extensions" },
@@ -518,17 +537,22 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "click", specifier = ">=8.1.0" }, { 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 = "pdmt5", specifier = ">=1.0.0" },
{ name = "pyarrow", marker = "extra == 'parquet'", specifier = ">=19.0.0" }, { name = "pyarrow", marker = "extra == 'parquet'", specifier = ">=19.0.0" },
{ name = "typer", specifier = ">=0.15.0" }, { name = "typer", specifier = ">=0.15.0" },
] ]
provides-extras = ["parquet"] provides-extras = ["parquet", "otel"]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "mkdocs", specifier = ">=1.6.1" }, { name = "mkdocs", specifier = ">=1.6.1" },
{ name = "mkdocs-material", specifier = ">=9.7.6" }, { name = "mkdocs-material", specifier = ">=9.7.6" },
{ name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.4" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.4" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-sdk" },
{ name = "pandas-stubs", specifier = ">=2.2.3.250527" }, { name = "pandas-stubs", specifier = ">=2.2.3.250527" },
{ name = "pyarrow", specifier = ">=19.0.0" }, { name = "pyarrow", specifier = ">=19.0.0" },
{ name = "pymdown-extensions", specifier = ">=10.21.2" }, { 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" }, { 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]] [[package]]
name = "packaging" name = "packaging"
version = "26.0" 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" }, { 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]] [[package]]
name = "pyarrow" name = "pyarrow"
version = "23.0.1" version = "23.0.1"