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:
co-authored by
Claude Sonnet 4.6
agent
parent
d27da02f3f
commit
1ffac45d57
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user