1ffac45d57
* 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>
683 lines
22 KiB
Python
683 lines
22 KiB
Python
"""Grafana-oriented SQLite views, indexes, and snapshot tables."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import datetime
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import cast
|
|
|
|
from .history import get_table_columns
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_TRADE_DEAL_TYPES_SQL = "(0, 1)"
|
|
|
|
_GRAFANA_VIEW_NAMES = (
|
|
"grafana_rates",
|
|
"grafana_ticks",
|
|
"grafana_history_deals",
|
|
"grafana_history_orders",
|
|
"grafana_trade_deals",
|
|
"grafana_cash_events",
|
|
"grafana_realized_pnl",
|
|
"grafana_symbol_pnl",
|
|
"grafana_trade_stats",
|
|
"grafana_account_snapshots",
|
|
"grafana_position_snapshots",
|
|
"grafana_order_snapshots",
|
|
"grafana_terminal_snapshots",
|
|
)
|
|
|
|
|
|
def _to_epoch_int(value: object) -> int | None:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, datetime.datetime):
|
|
return int(value.timestamp())
|
|
if isinstance(value, (int, float)):
|
|
return int(value)
|
|
return None
|
|
|
|
|
|
def _time_col_expr(col: str) -> str:
|
|
return (
|
|
f"CASE WHEN typeof(\"{col}\") IN ('integer', 'real')"
|
|
f' THEN CAST("{col}" AS INTEGER)'
|
|
f" ELSE CAST(strftime('%s', \"{col}\") AS INTEGER) END"
|
|
)
|
|
|
|
|
|
def _create_view_safe(
|
|
conn: sqlite3.Connection,
|
|
name: str,
|
|
select_sql: str,
|
|
) -> None:
|
|
try:
|
|
conn.execute(f'DROP VIEW IF EXISTS "{name}"')
|
|
conn.execute(f'CREATE VIEW "{name}" AS {select_sql}')
|
|
except sqlite3.Error as exc:
|
|
logger.warning("Skipping view %s: %s", name, exc)
|
|
|
|
|
|
def _other_cols(all_cols: set[str], exclude: set[str]) -> list[str]:
|
|
return sorted(all_cols - exclude)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Snapshot table DDL
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_SNAPSHOT_TABLE_DDLS: list[str] = [
|
|
"""CREATE TABLE IF NOT EXISTS snapshot_runs (
|
|
run_id INTEGER PRIMARY KEY,
|
|
observed_at INTEGER NOT NULL,
|
|
status TEXT NOT NULL,
|
|
detail TEXT
|
|
)""",
|
|
"""CREATE TABLE IF NOT EXISTS account_snapshots (
|
|
run_id INTEGER NOT NULL,
|
|
login INTEGER,
|
|
currency TEXT,
|
|
balance REAL,
|
|
equity REAL,
|
|
margin REAL,
|
|
margin_free REAL,
|
|
margin_level REAL,
|
|
profit REAL,
|
|
leverage INTEGER
|
|
)""",
|
|
"""CREATE TABLE IF NOT EXISTS position_snapshots (
|
|
run_id INTEGER NOT NULL,
|
|
login INTEGER,
|
|
ticket INTEGER,
|
|
position_id INTEGER,
|
|
symbol TEXT,
|
|
type INTEGER,
|
|
volume REAL,
|
|
price_open REAL,
|
|
price_current REAL,
|
|
profit REAL,
|
|
swap REAL,
|
|
comment TEXT,
|
|
magic INTEGER
|
|
)""",
|
|
"""CREATE TABLE IF NOT EXISTS order_snapshots (
|
|
run_id INTEGER NOT NULL,
|
|
login INTEGER,
|
|
ticket INTEGER,
|
|
symbol TEXT,
|
|
type INTEGER,
|
|
volume_current REAL,
|
|
price_open REAL,
|
|
price_current REAL,
|
|
state INTEGER,
|
|
comment TEXT,
|
|
magic INTEGER,
|
|
time_setup INTEGER
|
|
)""",
|
|
"""CREATE TABLE IF NOT EXISTS terminal_snapshots (
|
|
run_id INTEGER NOT NULL,
|
|
name TEXT,
|
|
connected INTEGER,
|
|
community_account INTEGER,
|
|
trade_allowed INTEGER,
|
|
trade_expert INTEGER,
|
|
path TEXT,
|
|
company TEXT,
|
|
language TEXT
|
|
)""",
|
|
]
|
|
|
|
|
|
def create_snapshot_tables(conn: sqlite3.Connection) -> None:
|
|
"""Create snapshot tables idempotently."""
|
|
for ddl in _SNAPSHOT_TABLE_DDLS:
|
|
conn.execute(ddl)
|
|
|
|
|
|
def start_snapshot_run(conn: sqlite3.Connection, observed_at: int) -> int:
|
|
"""Insert a snapshot_runs row with status 'running' and return its run_id.
|
|
|
|
Returns:
|
|
The auto-assigned run_id for the new row.
|
|
"""
|
|
cursor = conn.execute(
|
|
"INSERT INTO snapshot_runs (observed_at, status) VALUES (?, 'running')",
|
|
(observed_at,),
|
|
)
|
|
return cast("int", cursor.lastrowid)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# View builders
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_grafana_rates(conn: sqlite3.Connection) -> None:
|
|
cols = get_table_columns(conn, "rates")
|
|
required = {"time", "symbol", "timeframe"}
|
|
if not required.issubset(cols):
|
|
logger.warning(
|
|
"Skipping grafana_rates: rates table missing columns %s",
|
|
sorted(required - cols),
|
|
)
|
|
return
|
|
time_expr = _time_col_expr("time")
|
|
others = _other_cols(cols, {"time"})
|
|
other_sql = ", ".join(f'"{c}"' for c in others)
|
|
_create_view_safe(
|
|
conn,
|
|
"grafana_rates",
|
|
f'SELECT {time_expr} AS "time", {other_sql} FROM "rates"', # noqa: S608
|
|
)
|
|
|
|
|
|
def _build_grafana_ticks(conn: sqlite3.Connection) -> None:
|
|
cols = get_table_columns(conn, "ticks")
|
|
required = {"time", "symbol"}
|
|
if not required.issubset(cols):
|
|
logger.warning(
|
|
"Skipping grafana_ticks: ticks table missing columns %s",
|
|
sorted(required - cols),
|
|
)
|
|
return
|
|
time_expr = _time_col_expr("time")
|
|
others = _other_cols(cols, {"time"})
|
|
other_sql = ", ".join(f'"{c}"' for c in others)
|
|
_create_view_safe(
|
|
conn,
|
|
"grafana_ticks",
|
|
f'SELECT {time_expr} AS "time", {other_sql} FROM "ticks"', # noqa: S608
|
|
)
|
|
|
|
|
|
def _build_grafana_history_deals(conn: sqlite3.Connection) -> None:
|
|
cols = get_table_columns(conn, "history_deals")
|
|
if "time" not in cols:
|
|
logger.warning("Skipping grafana_history_deals: history_deals.time is missing")
|
|
return
|
|
time_expr = _time_col_expr("time")
|
|
others = _other_cols(cols, {"time"})
|
|
other_sql = ", ".join(f'"{c}"' for c in others)
|
|
_create_view_safe(
|
|
conn,
|
|
"grafana_history_deals",
|
|
f'SELECT {time_expr} AS "time", {other_sql} FROM "history_deals"', # noqa: S608
|
|
)
|
|
|
|
|
|
def _build_grafana_history_orders(conn: sqlite3.Connection) -> None:
|
|
cols = get_table_columns(conn, "history_orders")
|
|
if "time_setup" not in cols:
|
|
logger.warning(
|
|
"Skipping grafana_history_orders: history_orders.time_setup is missing"
|
|
)
|
|
return
|
|
time_expr = _time_col_expr("time_setup")
|
|
others = _other_cols(cols, set())
|
|
other_sql = ", ".join(f'"{c}"' for c in others)
|
|
_create_view_safe(
|
|
conn,
|
|
"grafana_history_orders",
|
|
f'SELECT {time_expr} AS "time", {other_sql} FROM "history_orders"', # noqa: S608
|
|
)
|
|
|
|
|
|
def _build_grafana_trade_deals(conn: sqlite3.Connection) -> None:
|
|
cols = get_table_columns(conn, "history_deals")
|
|
required = {"time", "type"}
|
|
if not required.issubset(cols):
|
|
logger.warning(
|
|
"Skipping grafana_trade_deals: history_deals missing columns %s",
|
|
sorted(required - cols),
|
|
)
|
|
return
|
|
time_expr = _time_col_expr("time")
|
|
others = _other_cols(cols, {"time"})
|
|
other_sql = ", ".join(f'"{c}"' for c in others)
|
|
_create_view_safe(
|
|
conn,
|
|
"grafana_trade_deals",
|
|
f'SELECT {time_expr} AS "time", {other_sql}' # noqa: S608
|
|
f' FROM "history_deals" WHERE "type" IN {_TRADE_DEAL_TYPES_SQL}',
|
|
)
|
|
|
|
|
|
def _build_grafana_cash_events(conn: sqlite3.Connection) -> None:
|
|
cols = get_table_columns(conn, "history_deals")
|
|
required = {"time", "type"}
|
|
if not required.issubset(cols):
|
|
logger.warning(
|
|
"Skipping grafana_cash_events: history_deals missing columns %s",
|
|
sorted(required - cols),
|
|
)
|
|
return
|
|
time_expr = _time_col_expr("time")
|
|
others = _other_cols(cols, {"time"})
|
|
other_sql = ", ".join(f'"{c}"' for c in others)
|
|
_create_view_safe(
|
|
conn,
|
|
"grafana_cash_events",
|
|
f'SELECT {time_expr} AS "time", {other_sql}' # noqa: S608
|
|
f' FROM "history_deals" WHERE "type" NOT IN {_TRADE_DEAL_TYPES_SQL}',
|
|
)
|
|
|
|
|
|
def _build_grafana_realized_pnl(conn: sqlite3.Connection) -> None:
|
|
cols = get_table_columns(conn, "history_deals")
|
|
required = {"symbol", "profit", "type", "entry"}
|
|
if not required.issubset(cols):
|
|
logger.warning(
|
|
"Skipping grafana_realized_pnl: history_deals missing columns %s",
|
|
sorted(required - cols),
|
|
)
|
|
return
|
|
_create_view_safe(
|
|
conn,
|
|
"grafana_realized_pnl",
|
|
'SELECT "symbol",' # noqa: S608
|
|
' SUM("profit") AS cumulative_pnl, COUNT(*) AS deal_count'
|
|
' FROM "history_deals"'
|
|
f' WHERE "type" IN {_TRADE_DEAL_TYPES_SQL}'
|
|
' AND "entry" IN (1, 2, 3)'
|
|
' AND "symbol" IS NOT NULL AND "symbol" != \'\''
|
|
' GROUP BY "symbol"',
|
|
)
|
|
|
|
|
|
def _build_grafana_symbol_pnl(conn: sqlite3.Connection) -> None:
|
|
cols = get_table_columns(conn, "history_deals")
|
|
required = {"time", "symbol", "profit", "type", "entry"}
|
|
if not required.issubset(cols):
|
|
logger.warning(
|
|
"Skipping grafana_symbol_pnl: history_deals missing columns %s",
|
|
sorted(required - cols),
|
|
)
|
|
return
|
|
time_expr = _time_col_expr("time")
|
|
select_parts = [f'{time_expr} AS "time"', '"symbol"', '"profit"']
|
|
if "volume" in cols:
|
|
select_parts.append('"volume"')
|
|
if "price" in cols:
|
|
select_parts.append('"price"')
|
|
select_sql = ", ".join(select_parts)
|
|
_create_view_safe(
|
|
conn,
|
|
"grafana_symbol_pnl",
|
|
f'SELECT {select_sql} FROM "history_deals"' # noqa: S608
|
|
f' WHERE "type" IN {_TRADE_DEAL_TYPES_SQL}'
|
|
' AND "entry" IN (1, 2, 3)'
|
|
' AND "symbol" IS NOT NULL AND "symbol" != \'\'',
|
|
)
|
|
|
|
|
|
def _build_grafana_trade_stats(conn: sqlite3.Connection) -> None:
|
|
cols = get_table_columns(conn, "history_deals")
|
|
required = {"symbol", "profit", "type"}
|
|
if not required.issubset(cols):
|
|
logger.warning(
|
|
"Skipping grafana_trade_stats: history_deals missing columns %s",
|
|
sorted(required - cols),
|
|
)
|
|
return
|
|
has_entry = "entry" in cols
|
|
entry_filter = ' AND "entry" IN (1, 2, 3)' if has_entry else ""
|
|
_create_view_safe(
|
|
conn,
|
|
"grafana_trade_stats",
|
|
'SELECT "symbol",' # noqa: S608
|
|
" COUNT(*) AS total_deals,"
|
|
' SUM(CASE WHEN "profit" > 0 THEN 1 ELSE 0 END) AS winning_deals,'
|
|
' SUM(CASE WHEN "profit" <= 0 THEN 1 ELSE 0 END) AS losing_deals,'
|
|
' SUM("profit") AS total_profit,'
|
|
' AVG("profit") AS avg_profit,'
|
|
' MAX("profit") AS max_profit,'
|
|
' MIN("profit") AS min_profit'
|
|
' FROM "history_deals"'
|
|
f' WHERE "type" IN {_TRADE_DEAL_TYPES_SQL}'
|
|
f"{entry_filter}"
|
|
' AND "symbol" IS NOT NULL AND "symbol" != \'\''
|
|
' GROUP BY "symbol"',
|
|
)
|
|
|
|
|
|
def _build_snapshot_view(
|
|
conn: sqlite3.Connection,
|
|
view_name: str,
|
|
table_name: str,
|
|
) -> None:
|
|
cols = get_table_columns(conn, table_name)
|
|
if not cols:
|
|
logger.warning("Skipping %s: %s table missing", view_name, table_name)
|
|
return
|
|
if "run_id" not in cols:
|
|
logger.warning("Skipping %s: %s missing run_id column", view_name, table_name)
|
|
return
|
|
others = _other_cols(cols, {"run_id"})
|
|
run_cols = get_table_columns(conn, "snapshot_runs")
|
|
if {"run_id", "observed_at", "status"}.issubset(run_cols):
|
|
other_sql = (", " + ", ".join(f's."{c}"' for c in others)) if others else ""
|
|
select_cols = f'r."observed_at" AS "time", s."run_id"{other_sql}'
|
|
_create_view_safe(
|
|
conn,
|
|
view_name,
|
|
f'SELECT {select_cols} FROM "{table_name}" s' # noqa: S608
|
|
f' JOIN "snapshot_runs" r ON s."run_id" = r."run_id"'
|
|
f" WHERE r.\"status\" = 'ok'",
|
|
)
|
|
else:
|
|
logger.warning("Skipping %s: snapshot_runs missing required columns", view_name)
|
|
|
|
|
|
def _build_grafana_account_snapshots(conn: sqlite3.Connection) -> None:
|
|
_build_snapshot_view(conn, "grafana_account_snapshots", "account_snapshots")
|
|
|
|
|
|
def _build_grafana_position_snapshots(conn: sqlite3.Connection) -> None:
|
|
_build_snapshot_view(conn, "grafana_position_snapshots", "position_snapshots")
|
|
|
|
|
|
def _build_grafana_order_snapshots(conn: sqlite3.Connection) -> None:
|
|
_build_snapshot_view(conn, "grafana_order_snapshots", "order_snapshots")
|
|
|
|
|
|
def _build_grafana_terminal_snapshots(conn: sqlite3.Connection) -> None:
|
|
_build_snapshot_view(conn, "grafana_terminal_snapshots", "terminal_snapshots")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def create_grafana_views(conn: sqlite3.Connection) -> None:
|
|
"""Create all Grafana-facing views idempotently.
|
|
|
|
Missing source tables cause the affected view to be skipped with a warning;
|
|
other views are unaffected. Stale views whose source table or required
|
|
columns have disappeared are dropped before rebuild.
|
|
"""
|
|
for name in _GRAFANA_VIEW_NAMES:
|
|
conn.execute(f'DROP VIEW IF EXISTS "{name}"')
|
|
_build_grafana_rates(conn)
|
|
_build_grafana_ticks(conn)
|
|
_build_grafana_history_deals(conn)
|
|
_build_grafana_history_orders(conn)
|
|
_build_grafana_trade_deals(conn)
|
|
_build_grafana_cash_events(conn)
|
|
_build_grafana_realized_pnl(conn)
|
|
_build_grafana_symbol_pnl(conn)
|
|
_build_grafana_trade_stats(conn)
|
|
_build_grafana_account_snapshots(conn)
|
|
_build_grafana_position_snapshots(conn)
|
|
_build_grafana_order_snapshots(conn)
|
|
_build_grafana_terminal_snapshots(conn)
|
|
|
|
|
|
def create_grafana_indexes(conn: sqlite3.Connection) -> None:
|
|
"""Create Grafana query performance indexes idempotently."""
|
|
rates_cols = get_table_columns(conn, "rates")
|
|
if {"time", "symbol", "timeframe"}.issubset(rates_cols):
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_rates_time_symbol_timeframe"
|
|
' ON "rates"("time", "symbol", "timeframe")',
|
|
)
|
|
|
|
ticks_cols = get_table_columns(conn, "ticks")
|
|
if {"time", "symbol"}.issubset(ticks_cols):
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_ticks_time_symbol"
|
|
' ON "ticks"("time", "symbol")',
|
|
)
|
|
|
|
deals_cols = get_table_columns(conn, "history_deals")
|
|
if {"time", "symbol"}.issubset(deals_cols):
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_history_deals_time_symbol"
|
|
' ON "history_deals"("time", "symbol")',
|
|
)
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_history_deals_symbol_time"
|
|
' ON "history_deals"("symbol", "time")',
|
|
)
|
|
|
|
orders_cols = get_table_columns(conn, "history_orders")
|
|
if {"time_setup", "symbol"}.issubset(orders_cols):
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_history_orders_time_setup_symbol"
|
|
' ON "history_orders"("time_setup", "symbol")',
|
|
)
|
|
|
|
if {"run_id", "login"}.issubset(get_table_columns(conn, "account_snapshots")):
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_account_snapshots_time_login"
|
|
' ON "account_snapshots"("run_id", "login")',
|
|
)
|
|
if {"run_id", "symbol"}.issubset(get_table_columns(conn, "position_snapshots")):
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_position_snapshots_time_symbol"
|
|
' ON "position_snapshots"("run_id", "symbol")',
|
|
)
|
|
if {"run_id", "symbol"}.issubset(get_table_columns(conn, "order_snapshots")):
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_order_snapshots_time_symbol"
|
|
' ON "order_snapshots"("run_id", "symbol")',
|
|
)
|
|
if {"observed_at", "status"}.issubset(get_table_columns(conn, "snapshot_runs")):
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_snapshot_runs_time_status"
|
|
' ON "snapshot_runs"("observed_at", "status")',
|
|
)
|
|
|
|
|
|
def ensure_grafana_schema(conn: sqlite3.Connection) -> None:
|
|
"""Create snapshot tables, Grafana views, and indexes idempotently."""
|
|
create_snapshot_tables(conn)
|
|
create_grafana_views(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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def insert_account_snapshot(
|
|
conn: sqlite3.Connection,
|
|
run_id: int,
|
|
row: dict[str, object],
|
|
) -> None:
|
|
"""Append one account state row to account_snapshots."""
|
|
conn.execute(
|
|
"INSERT INTO account_snapshots"
|
|
" (run_id, login, currency, balance, equity,"
|
|
" margin, margin_free, margin_level, profit, leverage)"
|
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
run_id,
|
|
row.get("login"),
|
|
row.get("currency"),
|
|
row.get("balance"),
|
|
row.get("equity"),
|
|
row.get("margin"),
|
|
row.get("margin_free"),
|
|
row.get("margin_level"),
|
|
row.get("profit"),
|
|
row.get("leverage"),
|
|
),
|
|
)
|
|
|
|
|
|
def insert_position_snapshots(
|
|
conn: sqlite3.Connection,
|
|
run_id: int,
|
|
login: int | None,
|
|
rows: list[dict[str, object]],
|
|
) -> None:
|
|
"""Append position rows to position_snapshots; no-op when rows is empty."""
|
|
if not rows:
|
|
return
|
|
conn.executemany(
|
|
"INSERT INTO position_snapshots"
|
|
" (run_id, login, ticket, position_id, symbol, type, volume,"
|
|
" price_open, price_current, profit, swap, comment, magic)"
|
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
[
|
|
(
|
|
run_id,
|
|
login,
|
|
r.get("ticket"),
|
|
r.get("position_id"),
|
|
r.get("symbol"),
|
|
r.get("type"),
|
|
r.get("volume"),
|
|
r.get("price_open"),
|
|
r.get("price_current"),
|
|
r.get("profit"),
|
|
r.get("swap"),
|
|
r.get("comment"),
|
|
r.get("magic"),
|
|
)
|
|
for r in rows
|
|
],
|
|
)
|
|
|
|
|
|
def insert_order_snapshots(
|
|
conn: sqlite3.Connection,
|
|
run_id: int,
|
|
login: int | None,
|
|
rows: list[dict[str, object]],
|
|
) -> None:
|
|
"""Append order rows to order_snapshots; no-op when rows is empty."""
|
|
if not rows:
|
|
return
|
|
conn.executemany(
|
|
"INSERT INTO order_snapshots"
|
|
" (run_id, login, ticket, symbol, type, volume_current,"
|
|
" price_open, price_current, state, comment, magic, time_setup)"
|
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
[
|
|
(
|
|
run_id,
|
|
login,
|
|
r.get("ticket"),
|
|
r.get("symbol"),
|
|
r.get("type"),
|
|
r.get("volume_current"),
|
|
r.get("price_open"),
|
|
r.get("price_current"),
|
|
r.get("state"),
|
|
r.get("comment"),
|
|
r.get("magic"),
|
|
_to_epoch_int(r.get("time_setup")),
|
|
)
|
|
for r in rows
|
|
],
|
|
)
|
|
|
|
|
|
def insert_terminal_snapshot(
|
|
conn: sqlite3.Connection,
|
|
run_id: int,
|
|
row: dict[str, object],
|
|
) -> None:
|
|
"""Append one terminal state row to terminal_snapshots."""
|
|
conn.execute(
|
|
"INSERT INTO terminal_snapshots"
|
|
" (run_id, name, connected, community_account,"
|
|
" trade_allowed, trade_expert, path, company, language)"
|
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
run_id,
|
|
row.get("name"),
|
|
row.get("connected"),
|
|
row.get("community_account"),
|
|
row.get("trade_allowed"),
|
|
row.get("trade_expert"),
|
|
row.get("path"),
|
|
row.get("company"),
|
|
row.get("language"),
|
|
),
|
|
)
|
|
|
|
|
|
def record_snapshot_run(
|
|
conn: sqlite3.Connection,
|
|
run_id: int,
|
|
status: str,
|
|
detail: str | None = None,
|
|
) -> None:
|
|
"""Finalize a snapshot run by setting its status."""
|
|
conn.execute(
|
|
"UPDATE snapshot_runs SET status = ?, detail = ? WHERE run_id = ?",
|
|
(status, detail, run_id),
|
|
)
|