Files
mt5cli/mt5cli/history.py
T
Daichi Narushima 74e3754a80 Fix mt5cli issues #92-#95 and #97 (#102)
* feat: add MT5 order metadata, coverage report, and env-backed CLI config

* fix: align review-driven trading and history contracts

* Bump pdmt5 to 1.1.0

* test: stabilize history gaps CLI assertion

* fix: address review follow-ups for gaps and filling mode
2026-07-04 14:19:56 +09:00

2263 lines
73 KiB
Python

"""SQLite storage helpers for the ``collect-history`` incremental data pipeline."""
from __future__ import annotations
import logging
import re
import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Literal, SupportsInt, cast, overload
import pandas as pd
from pdmt5 import get_timeframe_name as _get_timeframe_name
from .schemas import DEDUP_KEYS, DataKind, ensure_utc_columns
from .utils import (
TIMEFRAME_NAMES,
Dataset,
IfExists,
parse_datetime,
parse_tick_flags,
parse_timeframe,
)
if TYPE_CHECKING:
from collections.abc import Callable, Mapping, Sequence
from pdmt5 import Mt5DataClient
logger = logging.getLogger(__name__)
_SQLITE_TEXT_TIME_COLUMNS: frozenset[str] = frozenset({
"time",
"time_setup",
"time_done",
})
_SQLITE_CANONICAL_TIME_FORMAT = "%Y-%m-%dT%H:%M:%f+00:00"
DEFAULT_HISTORY_TIMEFRAMES: tuple[str, ...] = TIMEFRAME_NAMES
DEFAULT_HISTORY_DATASETS: frozenset[Dataset] = frozenset({
Dataset.rates,
Dataset.history_orders,
Dataset.history_deals,
})
_HISTORY_DEDUP_KEYS: dict[Dataset, tuple[tuple[str, ...], ...]] = {
Dataset.rates: DEDUP_KEYS[DataKind.rates],
Dataset.ticks: DEDUP_KEYS[DataKind.ticks],
Dataset.history_orders: DEDUP_KEYS[DataKind.history_orders],
Dataset.history_deals: DEDUP_KEYS[DataKind.history_deals],
}
_TRADE_DEAL_TYPES: tuple[int, int] = (0, 1)
_TRADE_DEAL_TYPES_SQL = f"({', '.join(str(value) for value in _TRADE_DEAL_TYPES)})"
_POSITIONS_VIEW_REQUIRED_COLUMNS: frozenset[str] = frozenset({
"position_id",
"symbol",
"time",
"type",
"entry",
"volume",
"price",
"profit",
})
_RATE_GAP_COLUMNS: tuple[str, ...] = (
"table",
"symbol",
"timeframe",
"granularity",
"granularity_seconds",
"gap_start",
"gap_end",
"missing_intervals",
)
_RATE_VIEW_NAME_RE = re.compile(
r"^rate_(?P<symbol>.+)__(?:(?P<granularity>[A-Z0-9]+)_)?(?P<timeframe>\d+)$",
)
_MIN_TIMESTAMPS_FOR_GAPS = 2
def quote_sqlite_identifier(identifier: str) -> str:
"""Return a safely quoted SQLite identifier using double quotes."""
return '"' + identifier.replace('"', '""') + '"'
def resolve_history_datasets(datasets: set[Dataset] | None) -> set[Dataset]:
"""Resolve configured history datasets.
Returns:
``DEFAULT_HISTORY_DATASETS`` (rates, history-orders, history-deals)
when ``datasets`` is None, otherwise the configured selection (which
may be empty or explicitly include ``Dataset.ticks``).
"""
if datasets is None:
return set(DEFAULT_HISTORY_DATASETS)
return set(datasets)
def resolve_history_timeframes(
timeframes: Sequence[int | str] | None,
) -> list[int]:
"""Resolve rate timeframes, deduplicating aliases for the same integer.
Returns:
Ordered list of unique timeframe integers.
"""
raw = timeframes if timeframes is not None else DEFAULT_HISTORY_TIMEFRAMES
seen: set[int] = set()
resolved: list[int] = []
for value in raw:
tf = parse_timeframe(value)
if tf not in seen:
seen.add(tf)
resolved.append(tf)
return resolved
def resolve_history_tick_flags(flags: int | str) -> int:
"""Resolve tick copy flags from an integer or name.
Returns:
Integer tick flag value.
"""
return parse_tick_flags(flags)
def resolve_granularity_name(timeframe: int) -> str:
"""Return a granularity name for a timeframe integer when known."""
try:
name = _get_timeframe_name(timeframe)
except ValueError:
return str(timeframe)
return name.removeprefix("TIMEFRAME_")
def drop_forming_rate_bar(df_rate: pd.DataFrame) -> pd.DataFrame:
"""Return closed bars from chronologically ordered MT5 rate data.
MetaTrader 5 ``copy_rates_from_pos(start_pos=0)`` includes the still-forming
current bar as the last row. Slice it off so downstream logic only sees
completed bars. Empty frames and single-row frames return empty results.
Args:
df_rate: Rate data ordered oldest-to-newest with the forming bar last.
Returns:
A new DataFrame with all rows except the last. Index and columns are
preserved. The input frame is not modified.
"""
return df_rate.iloc[:-1].copy()
def build_rate_view_name(
*,
symbol: str,
granularity: str,
granularity_count: int,
timeframe: int,
) -> str:
"""Return a collision-free offline optimize view name.
View names always include the timeframe integer after a ``__`` separator so
a symbol such as ``EURUSD_M1`` cannot collide with ``EURUSD`` at timeframe
``M1``.
"""
if granularity_count == 1:
return f"rate_{symbol}__{timeframe}"
return f"rate_{symbol}__{granularity}_{timeframe}"
def resolve_rate_table_name(symbol: str, granularity: str) -> str:
"""Return the canonical normalized SQLite rate table name.
The normalized history table stores all symbols and timeframes in
``rates``; use :func:`resolve_rate_view_name` for per-symbol compatibility
view names.
Returns:
Canonical normalized rates table name.
Raises:
ValueError: If ``symbol`` or ``granularity`` is invalid.
"""
parse_timeframe(granularity)
if not symbol.strip():
msg = "symbol must not be empty."
raise ValueError(msg)
return Dataset.rates.table_name
SqliteConnOrPath = sqlite3.Connection | Path | str
def _require_non_empty_identifier(identifier: str, kind: str) -> str:
value = identifier.strip()
if not value:
msg = f"SQLite {kind} name must not be empty."
raise ValueError(msg)
return value
def _open_history_connection(
conn_or_path: SqliteConnOrPath | None,
) -> tuple[sqlite3.Connection | None, bool]:
"""Open a read-only SQLite connection when given a path.
Returns:
A connection and whether the caller should close it. When ``conn_or_path``
is None or the path does not exist, returns ``(None, False)`` without
creating a database file.
"""
if conn_or_path is None:
return None, False
if isinstance(conn_or_path, sqlite3.Connection):
return conn_or_path, False
path = Path(conn_or_path)
if not path.exists():
return None, False
conn = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True)
return conn, True
def _open_existing_sqlite_database(
conn_or_path: SqliteConnOrPath,
) -> tuple[sqlite3.Connection, bool]:
"""Open a read-only SQLite database or reuse an existing connection.
Returns:
Tuple of connection and whether the caller should close it.
Raises:
ValueError: If the database path does not exist or is not a file.
"""
if isinstance(conn_or_path, sqlite3.Connection):
return conn_or_path, False
path = Path(conn_or_path)
if not path.exists():
msg = f"SQLite database not found: {path}"
raise ValueError(msg)
if not path.is_file():
msg = f"SQLite database path is not a file: {path}"
raise ValueError(msg)
conn = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True)
return conn, True
def _empty_rate_gap_report() -> pd.DataFrame:
return pd.DataFrame(columns=_RATE_GAP_COLUMNS)
def _coerce_optional_int(value: object) -> int | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, str):
text = value.strip()
if text.lstrip("+-").isdigit():
return int(text)
return None
if not hasattr(value, "__int__"):
return None
try:
return int(cast("SupportsInt", value))
except (TypeError, ValueError):
return None
def _rate_gap_metadata(
table: str,
frame: pd.DataFrame,
*,
granularity_seconds: int,
) -> dict[str, object]:
symbol: str | None = None
timeframe: int | None = None
granularity: str | None = None
if "symbol" in frame.columns:
symbols = {str(value) for value in frame["symbol"].dropna().unique()}
if len(symbols) == 1:
symbol = next(iter(symbols))
if "timeframe" in frame.columns:
timeframes = {
coerced
for value in frame["timeframe"].dropna().unique()
if (coerced := _coerce_optional_int(value)) is not None
}
if len(timeframes) == 1:
timeframe = next(iter(timeframes))
if timeframe is None and (match := _RATE_VIEW_NAME_RE.fullmatch(table)) is not None:
symbol = symbol or match.group("symbol")
timeframe = int(match.group("timeframe"))
granularity = match.group("granularity") or resolve_granularity_name(timeframe)
if timeframe is not None and granularity is None:
granularity = resolve_granularity_name(timeframe)
return {
"table": table,
"symbol": symbol,
"timeframe": timeframe,
"granularity": granularity,
"granularity_seconds": granularity_seconds,
}
def _iter_rate_gap_groups(frame: pd.DataFrame) -> list[pd.DataFrame]:
series_columns = [
column for column in ("symbol", "timeframe") if column in frame.columns
]
if not series_columns:
return [frame]
return [
group for _, group in frame.groupby(series_columns, dropna=False, sort=False)
]
def report_rate_gaps(
conn: sqlite3.Connection,
table: str,
*,
granularity_seconds: int,
min_gap_intervals: int = 1,
) -> pd.DataFrame:
"""Return one row per detected gap from a SQLite rate table or view.
Raises:
ValueError: If the table name, schema, timestamps, or gap parameters
are invalid.
"""
table_name = _validate_rate_load_request(table, count=None)
if granularity_seconds <= 0:
msg = "granularity_seconds must be positive."
raise ValueError(msg)
if min_gap_intervals <= 0:
msg = "min_gap_intervals must be positive."
raise ValueError(msg)
columns = get_table_columns(conn, table_name)
_ensure_rate_columns(columns, table_name)
quoted_table = quote_sqlite_identifier(table_name)
frame = cast(
"pd.DataFrame",
pd.read_sql_query( # type: ignore[reportUnknownMemberType]
f"SELECT * FROM {quoted_table} ORDER BY time ASC", # noqa: S608
conn,
),
)
if frame.empty:
return _empty_rate_gap_report()
parsed_times = frame["time"].map(parse_sqlite_timestamp)
if parsed_times.isna().any():
msg = f"SQLite table or view {table_name!r} contains unparsable time values."
raise ValueError(msg)
series_frame = frame.copy()
series_frame["time"] = parsed_times
rows: list[dict[str, object]] = []
for group in _iter_rate_gap_groups(series_frame):
unique_times = group["time"].drop_duplicates().sort_values(ignore_index=True)
if len(unique_times) < _MIN_TIMESTAMPS_FOR_GAPS:
continue
metadata = _rate_gap_metadata(
table_name,
group,
granularity_seconds=granularity_seconds,
)
deltas = unique_times.diff().dropna()
for index, delta in enumerate(deltas, start=1):
delta_seconds = int(delta.total_seconds())
missing_intervals = max(
((delta_seconds + (granularity_seconds - 1)) // granularity_seconds)
- 1,
0,
)
if missing_intervals < min_gap_intervals:
continue
previous_time = unique_times.iloc[index - 1]
next_time = unique_times.iloc[index]
rows.append({
**metadata,
"gap_start": (
previous_time.to_pydatetime()
+ timedelta(seconds=granularity_seconds)
),
"gap_end": (
next_time.to_pydatetime() - timedelta(seconds=granularity_seconds)
),
"missing_intervals": missing_intervals,
})
return pd.DataFrame(rows, columns=_RATE_GAP_COLUMNS)
def _validate_rate_load_request(table: str, count: int | None) -> str:
table_name = _require_non_empty_identifier(table, "table or view")
if count is not None and count <= 0:
msg = "count must be positive when provided."
raise ValueError(msg)
return table_name
def _ensure_rate_columns(columns: set[str], table: str) -> None:
if not columns:
msg = f"SQLite table or view not found: {table}"
raise ValueError(msg)
if "time" not in columns:
msg = f"SQLite table or view {table!r} must include a time column."
raise ValueError(msg)
if "close" not in columns and not {"ask", "bid"}.issubset(columns):
msg = (
f"SQLite table or view {table!r} must include close, "
"or both ask and bid columns."
)
raise ValueError(msg)
def _parse_rate_time_index(frame: pd.DataFrame, table: str) -> pd.DataFrame:
parsed = frame["time"].map(parse_sqlite_timestamp)
if parsed.isna().any():
msg = f"SQLite table or view {table!r} contains unparsable time values."
raise ValueError(msg)
result = frame.drop(columns=["time"])
result.index = pd.DatetimeIndex(parsed, name="time")
return result.sort_index(kind="stable")
def load_rate_data_from_connection(
connection: sqlite3.Connection,
table: str,
count: int | None = None,
) -> pd.DataFrame:
"""Load rate-like data from a SQLite table or view.
Args:
connection: Open SQLite connection.
table: Source table or view name.
count: Optional number of most recent rows to load.
Returns:
DataFrame indexed by ascending ``time``.
Raises:
ValueError: If inputs, schema, timestamps are invalid, or the table
or view contains no rows.
"""
table_name = _validate_rate_load_request(table, count)
columns = get_table_columns(connection, table_name)
_ensure_rate_columns(columns, table_name)
quoted_table = quote_sqlite_identifier(table_name)
if count is None:
frame = cast(
"pd.DataFrame",
pd.read_sql_query( # type: ignore[reportUnknownMemberType]
f"SELECT * FROM {quoted_table} ORDER BY time ASC", # noqa: S608
connection,
),
)
else:
frame = cast(
"pd.DataFrame",
pd.read_sql_query( # type: ignore[reportUnknownMemberType]
f"SELECT * FROM {quoted_table} ORDER BY time DESC LIMIT ?", # noqa: S608
connection,
params=(count,),
),
)
if frame.empty:
msg = f"SQLite table or view {table_name!r} contains no rows."
raise ValueError(msg)
return _parse_rate_time_index(frame, table_name)
def load_rate_data(
conn_or_path: SqliteConnOrPath,
table: str,
count: int | None = None,
) -> pd.DataFrame:
"""Load rate-like data from a SQLite database path or connection.
Args:
conn_or_path: SQLite database path or open connection.
table: Source table or view name.
count: Optional number of most recent rows to load.
Returns:
DataFrame indexed by ascending ``time``.
"""
conn, should_close = _open_existing_sqlite_database(conn_or_path)
try:
return load_rate_data_from_connection(conn, table, count=count)
finally:
if should_close:
conn.close()
def _load_rates_timeframe_counts(conn: sqlite3.Connection) -> dict[str, int] | None:
"""Return distinct timeframe counts per symbol from the normalized rates table."""
columns = get_table_columns(conn, Dataset.rates.table_name)
if not {"symbol", "timeframe"}.issubset(columns):
return None
rows = conn.execute(
"SELECT symbol, COUNT(DISTINCT timeframe) FROM rates GROUP BY symbol",
).fetchall()
return {str(symbol): int(count) for symbol, count in rows}
def _load_existing_rate_views(conn: sqlite3.Connection) -> set[str]:
"""Return mt5cli-managed ``rate_*__*`` compatibility view names."""
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'view' AND name GLOB 'rate_*__*'",
).fetchall()
return {str(row[0]) for row in rows}
def _rate_view_name_candidates(
*,
symbol: str,
granularity: str,
granularity_count: int,
timeframe: int,
) -> list[str]:
"""Return candidate view names in preference order."""
single = build_rate_view_name(
symbol=symbol,
granularity=granularity,
granularity_count=1,
timeframe=timeframe,
)
if granularity_count <= 1:
return [single]
multi = build_rate_view_name(
symbol=symbol,
granularity=granularity,
granularity_count=granularity_count,
timeframe=timeframe,
)
return [multi, single]
def _resolve_rate_view_name_from_context(
*,
symbol: str,
timeframe: int,
granularity_name: str,
timeframe_counts: dict[str, int] | None,
existing_views: set[str],
require_existing: bool = False,
) -> str:
"""Resolve one rate view name using preloaded SQLite metadata.
Returns:
Preferred mt5cli-managed rate compatibility view name.
Raises:
ValueError: If ``require_existing`` is True and no managed view exists.
"""
if timeframe_counts is None or symbol not in timeframe_counts:
candidates = [
build_rate_view_name(
symbol=symbol,
granularity=granularity_name,
granularity_count=1,
timeframe=timeframe,
),
build_rate_view_name(
symbol=symbol,
granularity=granularity_name,
granularity_count=2,
timeframe=timeframe,
),
]
else:
candidates = _rate_view_name_candidates(
symbol=symbol,
granularity=granularity_name,
granularity_count=timeframe_counts[symbol],
timeframe=timeframe,
)
for candidate in candidates:
if candidate in existing_views:
return candidate
if require_existing:
msg = (
f"No rate compatibility view exists for symbol {symbol!r} "
f"and granularity {granularity_name!r}; "
f"candidates: {', '.join(candidates)}."
)
raise ValueError(msg)
return candidates[0]
def resolve_rate_view_name(
conn_or_path: SqliteConnOrPath | None,
symbol: str,
granularity: str,
*,
require_existing: bool = False,
) -> str:
"""Resolve the mt5cli-managed rate compatibility view name.
Args:
conn_or_path: SQLite database path or open connection. When None or a
non-existing path and ``require_existing`` is False, the deterministic
default view name is returned without creating a database file.
symbol: Symbol stored in the normalized ``rates`` table.
granularity: Timeframe name (for example ``M1``) or integer string.
require_existing: When True, require the database and a managed view to exist.
Returns:
View name such as ``rate_EURUSD__1`` or ``rate_EURUSD__M1_1``.
Raises:
ValueError: If ``require_existing`` is True and the database or view is missing.
"""
timeframe = parse_timeframe(granularity)
granularity_name = resolve_granularity_name(timeframe)
conn, should_close = _open_history_connection(conn_or_path)
try:
if conn is None:
if require_existing:
path = (
conn_or_path
if isinstance(conn_or_path, (Path, str))
else "database"
)
msg = f"SQLite database not found: {path}"
raise ValueError(msg)
return build_rate_view_name(
symbol=symbol,
granularity=granularity_name,
granularity_count=1,
timeframe=timeframe,
)
return _resolve_rate_view_name_from_context(
symbol=symbol,
timeframe=timeframe,
granularity_name=granularity_name,
timeframe_counts=_load_rates_timeframe_counts(conn),
existing_views=_load_existing_rate_views(conn),
require_existing=require_existing,
)
finally:
if should_close and conn is not None:
conn.close()
def resolve_rate_view_names(
conn_or_path: SqliteConnOrPath | None,
symbols: Sequence[str],
granularities: Sequence[str],
*,
require_existing: bool = False,
) -> list[str]:
"""Resolve rate compatibility view names for symbol and granularity pairs.
Args:
conn_or_path: SQLite database path or open connection. When None or a
non-existing path and ``require_existing`` is False, deterministic
default view names are returned without creating a database file.
symbols: Symbols stored in the normalized ``rates`` table.
granularities: Timeframe names (for example ``M1``) or integer strings.
require_existing: When True, require the database and managed views to exist.
Returns:
View names in row-major order: every ``granularity`` for the first
symbol, then every granularity for the next symbol, and so on.
"""
conn, should_close = _open_history_connection(conn_or_path)
try:
if conn is None:
return [
resolve_rate_view_name(
conn_or_path,
symbol,
granularity,
require_existing=require_existing,
)
for symbol in symbols
for granularity in granularities
]
timeframe_counts = _load_rates_timeframe_counts(conn)
existing_views = _load_existing_rate_views(conn)
resolved: list[str] = []
for symbol in symbols:
for granularity in granularities:
timeframe = parse_timeframe(granularity)
resolved.append(
_resolve_rate_view_name_from_context(
symbol=symbol,
timeframe=timeframe,
granularity_name=resolve_granularity_name(timeframe),
timeframe_counts=timeframe_counts,
existing_views=existing_views,
require_existing=require_existing,
),
)
return resolved
finally:
if should_close and conn is not None:
conn.close()
@dataclass(frozen=True)
class RateTarget:
"""A single rate series identified by symbol and timeframe.
Attributes:
symbol: MT5 symbol name, or None when the rate series is addressed only
by an explicit table (for example a custom SQLite view).
timeframe: MT5 timeframe as an integer or name (for example ``M1``).
"""
symbol: str | None
timeframe: int | str
def __post_init__(self) -> None:
"""Normalize accepted timeframe aliases to the stored integer value."""
if not isinstance(self.timeframe, int):
object.__setattr__(self, "timeframe", parse_timeframe(self.timeframe))
@property
def timeframe_int(self) -> int:
"""Return the timeframe as its integer MT5 value."""
return cast("int", self.timeframe)
def build_rate_targets(
symbols: Sequence[str],
timeframes: Sequence[int | str],
*,
allow_missing_symbol: bool = False,
) -> list[RateTarget]:
"""Build rate targets for every symbol and timeframe combination.
Args:
symbols: MT5 symbol names. May be empty when ``allow_missing_symbol``.
timeframes: MT5 timeframes as integers or names (for example ``M1``).
allow_missing_symbol: When True and ``symbols`` is empty, build targets
with ``symbol=None`` for each timeframe instead of raising.
Returns:
Targets in row-major order: every timeframe for the first symbol, then
every timeframe for the next symbol, and so on.
Raises:
ValueError: If ``timeframes`` is empty, or ``symbols`` is empty and
``allow_missing_symbol`` is False.
"""
if not timeframes:
msg = "At least one timeframe is required."
raise ValueError(msg)
if not symbols:
if not allow_missing_symbol:
msg = "At least one symbol is required."
raise ValueError(msg)
return [RateTarget(symbol=None, timeframe=tf) for tf in timeframes]
return [
RateTarget(symbol=symbol, timeframe=tf)
for symbol in symbols
for tf in timeframes
]
def resolve_rate_tables(
conn_or_path: SqliteConnOrPath | None,
targets: Sequence[RateTarget],
explicit_tables: Sequence[str] | None = None,
*,
require_existing: bool = False,
) -> list[str]:
"""Resolve SQLite table or view names for rate targets.
Args:
conn_or_path: SQLite database path or open connection. May be None when
``explicit_tables`` is provided, or when ``require_existing`` is
False and deterministic default view names are sufficient.
targets: Rate targets to resolve.
explicit_tables: Optional explicit table or view names. When provided,
they are used as-is and must match the number of targets.
require_existing: When True, require the database and managed views to
exist for each symbol target. Ignored when ``explicit_tables`` is
provided.
Returns:
Table or view names aligned with ``targets``.
Raises:
ValueError: If ``targets`` is empty, ``explicit_tables`` length does not
match the target count, a target without a symbol is resolved
without an explicit table, or ``require_existing`` is True and the
database or a managed view is missing.
"""
target_list = list(targets)
if not target_list:
msg = "At least one rate target is required."
raise ValueError(msg)
if explicit_tables is not None:
tables = list(explicit_tables)
if len(tables) != len(target_list):
msg = (
f"Expected {len(target_list)} explicit table(s) "
f"to match the targets, got {len(tables)}."
)
raise ValueError(msg)
return tables
if any(target.symbol is None for target in target_list):
msg = (
"Cannot resolve a rate table for a target without a symbol; "
"provide explicit_tables."
)
raise ValueError(msg)
conn, should_close = _open_history_connection(conn_or_path)
try:
if conn is None:
if require_existing:
path = (
conn_or_path
if isinstance(conn_or_path, (Path, str))
else "database"
)
msg = f"SQLite database not found: {path}"
raise ValueError(msg)
timeframe_counts = None
existing_views: set[str] = set()
else:
timeframe_counts = _load_rates_timeframe_counts(conn)
existing_views = _load_existing_rate_views(conn)
resolved: list[str] = []
for target in target_list:
symbol = cast("str", target.symbol)
timeframe = target.timeframe_int
resolved.append(
_resolve_rate_view_name_from_context(
symbol=symbol,
timeframe=timeframe,
granularity_name=resolve_granularity_name(timeframe),
timeframe_counts=timeframe_counts,
existing_views=existing_views,
require_existing=require_existing,
),
)
return resolved
finally:
if should_close and conn is not None:
conn.close()
if TYPE_CHECKING:
@overload
def load_rate_series_from_sqlite(
conn_or_path: SqliteConnOrPath,
targets: None = None,
count: int | None = None,
explicit_tables: None = None,
*,
table: str,
) -> pd.DataFrame: ...
@overload
def load_rate_series_from_sqlite(
conn_or_path: SqliteConnOrPath,
targets: None = None,
count: int | None = None,
explicit_tables: Sequence[str] | None = None,
*,
table: None = None,
) -> dict[tuple[str | None, int], pd.DataFrame]: ...
@overload
def load_rate_series_from_sqlite(
conn_or_path: SqliteConnOrPath,
targets: Sequence[RateTarget],
count: int,
explicit_tables: Sequence[str] | None = None,
*,
table: None = None,
) -> dict[tuple[str | None, int], pd.DataFrame]: ...
def load_rate_series_from_sqlite(
conn_or_path: SqliteConnOrPath,
targets: Sequence[RateTarget] | None = None,
count: int | None = None,
explicit_tables: Sequence[str] | None = None,
*,
table: str | None = None,
) -> dict[tuple[str | None, int], pd.DataFrame] | pd.DataFrame:
"""Load one table/view or multiple rate series from a SQLite database.
Args:
conn_or_path: SQLite database path or open connection.
targets: Rate targets to load. Each ``(symbol, timeframe_int)`` pair must
be unique. Omit when loading a single explicit ``table``.
count: Optional number of most recent rows to load per series.
explicit_tables: Optional explicit table or view names matching targets.
When omitted, managed ``rate_*`` compatibility views must already
exist in the database.
table: Optional single table or view name to load directly.
Returns:
A DataFrame when ``table`` is provided, otherwise a mapping keyed by
``(symbol, timeframe_int)`` to each rate DataFrame.
Raises:
ValueError: If ``count`` is not positive, targets are empty, duplicate
``(symbol, timeframe_int)`` pairs are present, or table resolution
fails.
"""
if table is not None:
return load_rate_data(conn_or_path, table, count=count)
if count is None or count <= 0:
msg = "count must be positive."
raise ValueError(msg)
if targets is None:
msg = "targets are required when table is not provided."
raise ValueError(msg)
target_list = list(targets)
if not target_list:
msg = "At least one rate target is required."
raise ValueError(msg)
if explicit_tables is None and any(target.symbol is None for target in target_list):
msg = (
"Cannot resolve a rate table for a target without a symbol; "
"provide explicit_tables."
)
raise ValueError(msg)
seen_keys: set[tuple[str | None, int]] = set()
for target in target_list:
key = (target.symbol, target.timeframe_int)
if key in seen_keys:
symbol_repr = repr(target.symbol)
msg = f"Duplicate rate target: ({symbol_repr}, {target.timeframe_int})"
raise ValueError(msg)
seen_keys.add(key)
tables = (
resolve_rate_tables(None, target_list, explicit_tables)
if explicit_tables is not None
else None
)
conn, should_close = _open_existing_sqlite_database(conn_or_path)
try:
resolved_tables = tables or resolve_rate_tables(
conn,
target_list,
require_existing=True,
)
return {
(target.symbol, target.timeframe_int): load_rate_data_from_connection(
conn,
table,
count=count,
)
for target, table in zip(target_list, resolved_tables, strict=True)
}
finally:
if should_close:
conn.close()
def load_rate_series_by_granularity(
conn_or_path: SqliteConnOrPath,
symbols: Sequence[str],
granularities: Sequence[int | str],
count: int,
*,
explicit_tables: Sequence[str] | None = None,
allow_missing_symbol: bool = False,
) -> dict[tuple[str | None, str], pd.DataFrame]:
"""Load rate series keyed by symbol and string granularity name.
Builds targets with :func:`build_rate_targets` and loads them with
:func:`load_rate_series_from_sqlite`, then rekeys the result by granularity
name (for example ``M1``) instead of the integer timeframe to reduce
downstream boilerplate.
Args:
conn_or_path: SQLite database path or open connection.
symbols: MT5 symbol names. May be empty when ``allow_missing_symbol``.
granularities: MT5 timeframes as integers or names (for example ``M1``).
count: Number of most recent rows to load per series.
explicit_tables: Optional explicit table or view names matching the
built targets in row-major order. Required when symbols are omitted.
allow_missing_symbol: When True and ``symbols`` is empty, build targets
with ``symbol=None`` for each granularity instead of raising.
Returns:
Mapping keyed by ``(symbol | None, granularity_name)`` to each rate
DataFrame. Propagates ``ValueError`` (via :func:`build_rate_targets` and
:func:`load_rate_series_from_sqlite`) when inputs are empty or invalid,
table resolution fails, or duplicate targets are present.
"""
targets = build_rate_targets(
symbols,
granularities,
allow_missing_symbol=allow_missing_symbol,
)
series = load_rate_series_from_sqlite(
conn_or_path,
targets,
count,
explicit_tables=explicit_tables,
)
return {
(symbol, resolve_granularity_name(timeframe)): frame
for (symbol, timeframe), frame in series.items()
}
def get_table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
"""Return existing SQLite columns for a table."""
quoted_table = quote_sqlite_identifier(table)
rows = conn.execute(f"PRAGMA table_info({quoted_table})").fetchall()
return {str(row[1]) for row in rows}
def _parse_string_sqlite_timestamp(value: str) -> datetime | None:
try:
return parse_datetime(value)
except ValueError:
parsed_ts = pd.to_datetime(value, utc=True, errors="coerce")
if pd.isna(parsed_ts):
logger.warning("Ignoring unparseable history timestamp: %s", value)
return None
return parsed_ts.to_pydatetime()
def parse_sqlite_timestamp(value: object) -> datetime | None:
"""Parse a SQLite history timestamp value.
Returns:
Parsed timezone-aware datetime, or None when parsing fails.
"""
if value is None:
return None
if isinstance(value, datetime):
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
if isinstance(value, int | float):
return datetime.fromtimestamp(float(value), tz=UTC)
if isinstance(value, str):
return _parse_string_sqlite_timestamp(value)
logger.warning("Ignoring unsupported history timestamp type: %s", type(value))
return None
def _serialize_sqlite_timestamp(value: object) -> str | None:
parsed = parse_sqlite_timestamp(value)
if parsed is None:
return None
utc_value = parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
utc_value = utc_value.astimezone(UTC)
timespec = "microseconds" if utc_value.microsecond else "seconds"
return utc_value.isoformat(timespec=timespec)
def _require_serialized_sqlite_timestamp(value: object) -> str:
serialized = _serialize_sqlite_timestamp(value)
if serialized is None:
msg = f"Invalid SQLite timestamp boundary: {value!r}"
raise ValueError(msg)
return serialized
def _canonicalize_sqlite_time_columns(frame: pd.DataFrame) -> pd.DataFrame:
columns = [
column for column in _SQLITE_TEXT_TIME_COLUMNS if column in frame.columns
]
if not columns:
return frame
normalized = ensure_utc_columns(frame, columns)
for column in columns:
normalized[column] = normalized[column].map(_serialize_sqlite_timestamp)
return normalized
def _sqlite_dedup_key_expression(column: str) -> str:
quoted = quote_sqlite_identifier(column)
if column != "time":
return quoted
normalized = _sqlite_normalized_time_expression(column)
return f"COALESCE({normalized}, CAST({quoted} AS TEXT))"
def _sqlite_normalized_time_expression(column: str) -> str:
"""Return a canonical UTC timestamp expression for mixed SQLite time values."""
quoted = quote_sqlite_identifier(column)
return (
"COALESCE("
f"strftime('{_SQLITE_CANONICAL_TIME_FORMAT}', {quoted}), "
f"strftime('{_SQLITE_CANONICAL_TIME_FORMAT}', {quoted}, 'unixepoch')"
")"
)
def _load_latest_parseable_time(
conn: sqlite3.Connection,
table: str,
*,
where_clause: str | None = None,
params: Sequence[object] = (),
) -> datetime | None:
quoted_table = quote_sqlite_identifier(table)
time_expr = _sqlite_normalized_time_expression("time")
query = f"SELECT time FROM {quoted_table} WHERE {time_expr} IS NOT NULL" # noqa: S608
if where_clause:
query += f" AND {where_clause}"
query += f" ORDER BY {time_expr} DESC, ROWID DESC LIMIT 1"
row = conn.execute(query, tuple(params)).fetchone()
return parse_sqlite_timestamp(row[0] if row else None)
def get_history_deals_account_event_start_datetime(
conn: sqlite3.Connection,
*,
fallback_start: datetime,
) -> datetime:
"""Return the next update start for account-level history_deals rows."""
table = Dataset.history_deals.table_name
columns = get_table_columns(conn, table)
if "time" not in columns:
return fallback_start
if "type" in columns:
where_clause = f"type NOT IN {_TRADE_DEAL_TYPES_SQL}"
elif "symbol" in columns:
where_clause = "symbol IS NULL OR symbol = ''"
else:
return fallback_start
parsed = _load_latest_parseable_time(
conn,
table,
where_clause=where_clause,
)
return parsed if parsed is not None else fallback_start
_REQUIRED_RATE_COLUMNS = frozenset({"symbol", "timeframe", "time"})
def _validate_rates_schema(columns: set[str]) -> None:
"""Validate an existing rates table has normalized incremental columns.
Raises:
ValueError: If required columns are missing.
"""
missing = _REQUIRED_RATE_COLUMNS - columns
if missing:
msg = (
"The rates table must include symbol, timeframe, and time columns"
" for incremental updates; "
f"missing: {', '.join(sorted(missing))}."
)
raise ValueError(msg)
def _load_grouped_rate_start_datetimes(
conn: sqlite3.Connection,
table: str,
*,
symbols: Sequence[str],
timeframes: Sequence[int],
fallback_start: datetime,
) -> dict[tuple[str, int | None], datetime]:
symbol_placeholders = ", ".join("?" for _ in symbols)
timeframe_placeholders = ", ".join("?" for _ in timeframes)
time_expr = _sqlite_normalized_time_expression("time")
rows = conn.execute(
"SELECT symbol, timeframe, MAX(" # noqa: S608
f"{time_expr}) FROM "
f"{quote_sqlite_identifier(table)}"
f" WHERE symbol IN ({symbol_placeholders})"
f" AND timeframe IN ({timeframe_placeholders})"
f" AND {time_expr} IS NOT NULL"
f" GROUP BY symbol, timeframe",
[*symbols, *timeframes],
).fetchall()
parsed_by_key: dict[tuple[str, int | None], datetime] = {}
for row_symbol, row_timeframe, max_time in rows:
parsed = parse_sqlite_timestamp(max_time)
if parsed is not None:
parsed_by_key[str(row_symbol), int(row_timeframe)] = parsed
return {
(symbol, timeframe): parsed_by_key.get((symbol, timeframe), fallback_start)
for symbol in symbols
for timeframe in timeframes
}
def _load_symbol_start_datetimes(
conn: sqlite3.Connection,
table: str,
*,
symbols: Sequence[str],
fallback_start: datetime,
) -> dict[tuple[str, int | None], datetime]:
symbol_placeholders = ", ".join("?" for _ in symbols)
time_expr = _sqlite_normalized_time_expression("time")
rows = conn.execute(
"SELECT symbol, MAX(" # noqa: S608
f"{time_expr}) FROM "
f"{quote_sqlite_identifier(table)}"
f" WHERE symbol IN ({symbol_placeholders})"
f" AND {time_expr} IS NOT NULL"
f" GROUP BY symbol",
list(symbols),
).fetchall()
parsed_by_key: dict[tuple[str, int | None], datetime] = {}
for row_symbol, max_time in rows:
parsed = parse_sqlite_timestamp(max_time)
if parsed is not None:
parsed_by_key[str(row_symbol), None] = parsed
return {
(symbol, None): parsed_by_key.get((symbol, None), fallback_start)
for symbol in symbols
}
def load_incremental_start_datetimes(
conn: sqlite3.Connection,
dataset: Dataset,
*,
symbols: Sequence[str],
timeframes: Sequence[int] | None = None,
fallback_start: datetime,
) -> dict[tuple[str, int | None], datetime]:
"""Return next update start datetimes keyed by symbol and optional timeframe."""
table = dataset.table_name
columns = get_table_columns(conn, table)
if dataset is Dataset.rates and columns:
_validate_rates_schema(columns)
if "time" not in columns:
if dataset is Dataset.rates and timeframes is not None:
return {
(symbol, timeframe): fallback_start
for symbol in symbols
for timeframe in timeframes
}
return {(symbol, None): fallback_start for symbol in symbols}
if (
dataset is Dataset.rates
and timeframes is not None
and {"symbol", "timeframe"}.issubset(columns)
):
return _load_grouped_rate_start_datetimes(
conn,
table,
symbols=symbols,
timeframes=timeframes,
fallback_start=fallback_start,
)
if "symbol" in columns:
return _load_symbol_start_datetimes(
conn,
table,
symbols=symbols,
fallback_start=fallback_start,
)
parsed = _load_latest_parseable_time(conn, table)
shared_start = parsed if parsed is not None else fallback_start
return {(symbol, None): shared_start for symbol in symbols}
def get_incremental_start_datetime(
conn: sqlite3.Connection,
dataset: Dataset,
*,
symbol: str,
timeframe: int | None,
fallback_start: datetime,
) -> datetime:
"""Return the next update start datetime from existing MAX(time)."""
timeframes = [timeframe] if timeframe is not None else None
starts = load_incremental_start_datetimes(
conn,
dataset,
symbols=[symbol],
timeframes=timeframes,
fallback_start=fallback_start,
)
return starts[symbol, timeframe]
def append_dataframe(
conn: sqlite3.Connection,
frame: pd.DataFrame,
table_name: str,
if_exists: IfExists,
) -> bool:
"""Append a DataFrame to SQLite when it has a schema.
Returns:
True if a table was written, False if the frame had no columns.
"""
if len(frame.columns) == 0:
logger.warning("Skipping %s: dataset returned no columns", table_name)
return False
writable = _canonicalize_sqlite_time_columns(frame)
writable.to_sql( # type: ignore[reportUnknownMemberType]
table_name,
conn,
if_exists=if_exists.value,
index=False,
chunksize=50_000,
)
return True
def record_written_columns(
written_columns: dict[Dataset, set[str]],
dataset: Dataset,
frame: pd.DataFrame,
) -> None:
"""Remember columns for datasets written during collection."""
columns = set(frame.columns)
if dataset in written_columns:
written_columns[dataset].update(columns)
else:
written_columns[dataset] = columns
def augment_written_columns_from_sqlite(
conn: sqlite3.Connection,
datasets: set[Dataset],
written_columns: dict[Dataset, set[str]],
) -> None:
"""Add existing table columns to the written column map."""
for dataset in datasets:
columns = get_table_columns(conn, dataset.table_name)
if not columns:
continue
if dataset in written_columns:
written_columns[dataset].update(columns)
else:
written_columns[dataset] = columns
def write_streamed_frame(
conn: sqlite3.Connection,
frame: pd.DataFrame,
dataset: Dataset,
table_exists: bool,
if_exists: IfExists,
written_columns: dict[Dataset, set[str]],
) -> bool:
"""Write one streamed dataset frame and track table state.
Returns:
True if the dataset table exists after this write attempt.
"""
write_mode = IfExists.APPEND if table_exists else if_exists
if append_dataframe(conn, frame, dataset.table_name, write_mode):
record_written_columns(written_columns, dataset, frame)
return True
return table_exists
def drop_duplicates_in_table(
cursor: sqlite3.Cursor,
table: str,
ids: list[str],
*,
keep: Literal["first", "last"] = "last",
scope_where: str | None = None,
scope_params: tuple[object, ...] = (),
) -> None:
"""Remove duplicate rows, keeping the first or last ROWID per key group.
Raises:
ValueError: If the table or column names are invalid.
"""
if not table.isidentifier():
msg = f"Invalid table name: {table}"
raise ValueError(msg)
if invalid := {column for column in ids if not column.isidentifier()}:
msg = f"Invalid column names: {', '.join(sorted(invalid))}"
raise ValueError(msg)
ids_csv = ", ".join(_sqlite_dedup_key_expression(column) for column in ids)
rowid_selector = "MIN" if keep == "first" else "MAX"
prepared_scope_params = tuple(
_require_serialized_sqlite_timestamp(value)
if isinstance(value, datetime)
else value
for value in scope_params
)
if scope_where:
delete_sql = (
f"DELETE FROM {table} WHERE {scope_where} AND ROWID NOT IN" # noqa: S608
f" (SELECT {rowid_selector}(ROWID) FROM {table} WHERE {scope_where}"
f" GROUP BY {ids_csv})"
)
cursor.execute(delete_sql, prepared_scope_params + prepared_scope_params)
return
cursor.execute(
f"DELETE FROM {table} WHERE ROWID NOT IN" # noqa: S608
f" (SELECT {rowid_selector}(ROWID) FROM {table} GROUP BY {ids_csv})",
)
@dataclass(frozen=True)
class DedupScope:
"""Scoped deduplication predicate and the columns it references.
Attributes:
where: SQL predicate appended to the duplicate-removal query.
params: Parameters bound to the scope predicate.
required_columns: Columns that must be present in the written table for
the scope to run.
"""
where: str
params: tuple[object, ...]
required_columns: frozenset[str]
def _record_dedup_scope(
dedup_scopes: dict[Dataset, list[DedupScope]],
dataset: Dataset,
scope_where: str,
scope_params: tuple[object, ...],
required_columns: frozenset[str],
) -> None:
dedup_scopes.setdefault(dataset, []).append(
DedupScope(scope_where, scope_params, required_columns),
)
def deduplicate_history_tables(
conn: sqlite3.Connection,
written_columns: dict[Dataset, set[str]],
written_tables: set[Dataset],
dedup_scopes: Mapping[Dataset, Sequence[DedupScope]] | None = None,
) -> None:
"""Deduplicate appended history tables by stable identifiers.
Scopes whose required columns are not present in the written table are
skipped. If all scopes for a dataset are skipped, the table receives one
unscoped deduplication pass instead.
"""
cursor = conn.cursor()
for dataset in written_tables:
columns = written_columns.get(dataset, set())
table = dataset.table_name
keys = next(
(
candidate
for candidate in _HISTORY_DEDUP_KEYS[dataset]
if set(candidate).issubset(columns)
),
None,
)
if keys is None:
logger.warning(
"Skipping %s deduplication: no supported key columns",
table,
)
continue
raw_scopes: Sequence[DedupScope] = (
dedup_scopes.get(dataset, ()) if dedup_scopes else ()
)
scopes = [scope for scope in raw_scopes if scope.required_columns <= columns]
if scopes:
for scope in scopes:
drop_duplicates_in_table(
cursor,
table,
list(keys),
keep="last",
scope_where=scope.where,
scope_params=scope.params,
)
continue
drop_duplicates_in_table(cursor, table, list(keys), keep="last")
def create_history_indexes(
conn: sqlite3.Connection,
written_columns: dict[Dataset, set[str]],
) -> None:
"""Create useful indexes for collected history tables when present."""
if {"symbol", "timeframe", "time"}.issubset(
written_columns.get(Dataset.rates, set()),
):
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_rates_symbol_timeframe_time"
" ON rates(symbol, timeframe, time)",
)
if {"symbol", "time"}.issubset(written_columns.get(Dataset.ticks, set())):
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_ticks_symbol_time ON ticks(symbol, time)",
)
if {"position_id", "symbol"}.issubset(
written_columns.get(Dataset.history_deals, set()),
):
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_history_deals_position_symbol"
" ON history_deals(position_id, symbol)",
)
def _history_deals_account_event_mask(frame: pd.DataFrame) -> pd.Series:
if "type" in frame.columns:
return ~frame["type"].isin(_TRADE_DEAL_TYPES)
if "symbol" in frame.columns:
return frame["symbol"].isna() | frame["symbol"].eq("")
return pd.Series(data=False, index=frame.index)
def _frame_parsed_times(frame: pd.DataFrame) -> pd.Series:
if "time" not in frame.columns:
return pd.Series([pd.NaT] * len(frame), index=frame.index, dtype="object")
return frame["time"].map(parse_sqlite_timestamp)
def filter_incremental_history_deals_frame(
frame: pd.DataFrame,
symbols: Sequence[str],
start_by_symbol: dict[str, datetime],
account_event_start: datetime,
) -> pd.DataFrame:
"""Filter incrementally fetched history_deals by symbol and event start times.
Returns:
Rows for selected symbols at or after each symbol start, plus account
events at or after ``account_event_start``.
"""
if frame.empty:
return frame.copy()
parsed_times = _frame_parsed_times(frame)
time_valid = parsed_times.notna()
account_event_mask = _history_deals_account_event_mask(frame)
account_keep = account_event_mask & (parsed_times >= account_event_start)
trade_keep = pd.Series(data=False, index=frame.index)
if "symbol" in frame.columns:
for symbol in symbols:
trade_keep |= (
(frame["symbol"] == symbol)
& (parsed_times >= start_by_symbol[symbol])
& ~account_event_mask
)
keep = (account_keep | trade_keep) & time_valid
return frame.loc[keep].copy()
def filter_trade_history_frame(
frame: pd.DataFrame,
symbols: Sequence[str],
*,
include_account_events: bool,
) -> pd.DataFrame:
"""Filter trade history rows to selected symbols and account events.
Returns:
Filtered history rows.
"""
if "symbol" not in frame.columns:
return frame
symbol_mask = frame["symbol"].isin(symbols)
if not include_account_events:
return frame.loc[symbol_mask].copy()
account_event_mask = _history_deals_account_event_mask(frame)
return frame.loc[symbol_mask | account_event_mask].copy()
def create_cash_events_view(
conn: sqlite3.Connection,
deals_columns: set[str],
) -> bool:
"""Create the cash_events SQLite view derived from history_deals.
Returns:
True if the view was created, False if required columns are missing.
"""
if "type" not in deals_columns:
logger.warning("Skipping cash_events view: history_deals.type is missing")
return False
conn.execute("DROP VIEW IF EXISTS cash_events")
conn.execute(
"CREATE VIEW cash_events AS" # noqa: S608
f" SELECT * FROM history_deals WHERE type NOT IN {_TRADE_DEAL_TYPES_SQL}",
)
return True
def create_positions_reconstructed_view(
conn: sqlite3.Connection,
deals_columns: set[str],
) -> bool:
"""Create the positions_reconstructed SQLite view derived from history_deals.
Returns:
True if the view was created, False if required columns are missing.
"""
if not _POSITIONS_VIEW_REQUIRED_COLUMNS.issubset(deals_columns):
missing = ", ".join(sorted(_POSITIONS_VIEW_REQUIRED_COLUMNS - deals_columns))
logger.warning(
"Skipping positions_reconstructed view: history_deals missing columns: %s",
missing,
)
return False
conn.execute("DROP VIEW IF EXISTS positions_reconstructed")
conn.execute(
"CREATE VIEW positions_reconstructed AS" # noqa: S608
" SELECT"
" position_id,"
" symbol,"
" MIN(CASE WHEN entry = 0 THEN time END) AS open_time,"
" MAX(CASE WHEN entry IN (1, 2, 3) THEN time END) AS close_time,"
" MIN(CASE WHEN entry = 0 THEN type END) AS direction,"
" SUM(CASE WHEN entry = 0 THEN volume ELSE 0 END) AS volume_open,"
" SUM(CASE WHEN entry IN (1, 2, 3) THEN volume ELSE 0 END) AS volume_close,"
" SUM(CASE WHEN entry = 2 THEN volume ELSE 0 END) AS volume_reversal,"
" CASE"
" WHEN SUM(CASE WHEN entry = 0 THEN volume ELSE 0 END) > 0"
" THEN SUM(CASE WHEN entry = 0 THEN price * volume ELSE 0 END)"
" / SUM(CASE WHEN entry = 0 THEN volume ELSE 0 END)"
" END AS open_price,"
" CASE"
" WHEN SUM(CASE WHEN entry IN (1, 2, 3) THEN volume ELSE 0 END) > 0"
" THEN SUM(CASE WHEN entry IN (1, 2, 3) THEN price * volume ELSE 0 END)"
" / SUM(CASE WHEN entry IN (1, 2, 3) THEN volume ELSE 0 END)"
" END AS close_price,"
" SUM(profit) AS total_profit,"
" SUM(CASE WHEN entry = 2 THEN 1 ELSE 0 END) AS reversal_count,"
" COUNT(*) AS deals_count"
" FROM history_deals"
f" WHERE type IN {_TRADE_DEAL_TYPES_SQL} AND position_id != 0"
" GROUP BY position_id, symbol"
" HAVING SUM(CASE WHEN entry IN (1, 2, 3) THEN 1 ELSE 0 END) > 0",
)
return True
def drop_rate_compatibility_views(conn: sqlite3.Connection) -> None:
"""Drop all mt5cli-managed ``rate_*`` compatibility views."""
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'view' AND name GLOB 'rate_*'",
).fetchall()
for (view_name,) in rows:
quoted_view_name = quote_sqlite_identifier(str(view_name))
conn.execute(f"DROP VIEW IF EXISTS {quoted_view_name}")
def create_rate_compatibility_views(conn: sqlite3.Connection) -> None:
"""Create rate compatibility views from the normalized rates table."""
columns = get_table_columns(conn, Dataset.rates.table_name)
if not {"symbol", "timeframe", "time"}.issubset(columns):
return
drop_rate_compatibility_views(conn)
select_columns = sorted(columns - {"symbol", "timeframe"})
quoted_columns = ", ".join(f'"{column}"' for column in select_columns)
rows = conn.execute(
"SELECT DISTINCT symbol, timeframe FROM rates ORDER BY symbol, timeframe",
).fetchall()
timeframes_by_symbol: dict[str, list[int]] = {}
for symbol, timeframe in rows:
timeframes_by_symbol.setdefault(str(symbol), []).append(int(timeframe))
for symbol, timeframes in timeframes_by_symbol.items():
for timeframe in timeframes:
granularity = resolve_granularity_name(timeframe)
view_name = build_rate_view_name(
symbol=symbol,
granularity=granularity,
granularity_count=len(timeframes),
timeframe=timeframe,
)
quoted_view_name = quote_sqlite_identifier(view_name)
escaped_symbol = symbol.replace("'", "''")
conn.execute(
f"CREATE VIEW {quoted_view_name} AS" # noqa: S608
f" SELECT {quoted_columns} FROM rates"
f" WHERE symbol = '{escaped_symbol}'"
f" AND timeframe = {timeframe}",
)
def _stream_symbol_frames(
conn: sqlite3.Connection,
symbols: Sequence[str],
dataset: Dataset,
if_exists: IfExists,
written_columns: dict[Dataset, set[str]],
fetch_frame: Callable[[str], pd.DataFrame],
) -> bool:
"""Stream per-symbol frames into SQLite.
Returns:
True if the dataset table was written.
"""
table_exists = False
for sym in symbols:
table_exists = write_streamed_frame(
conn,
fetch_frame(sym),
dataset,
table_exists,
if_exists,
written_columns,
)
return table_exists
def _record_symbol_time_dedup(
dedup_scopes: dict[Dataset, list[DedupScope]],
written_tables: set[Dataset],
dataset: Dataset,
symbol: str,
start_date: datetime,
) -> None:
"""Record a symbol-scoped deduplication window after an incremental write."""
written_tables.add(dataset)
time_expr = _sqlite_normalized_time_expression("time")
_record_dedup_scope(
dedup_scopes,
dataset,
f"symbol = ? AND {time_expr} >= ?",
(symbol, _require_serialized_sqlite_timestamp(start_date)),
frozenset({"symbol", "time"}),
)
def write_rates_dataset(
conn: sqlite3.Connection,
client: Mt5DataClient,
symbols: Sequence[str],
timeframe: int,
date_from: datetime,
date_to: datetime,
if_exists: IfExists,
written_columns: dict[Dataset, set[str]],
) -> bool:
"""Stream rates frames into SQLite.
Returns:
True if the rates table was written.
"""
def _fetch_rates_frame(sym: str) -> pd.DataFrame:
frame = client.copy_rates_range_as_df(
symbol=sym,
timeframe=timeframe,
date_from=date_from,
date_to=date_to,
).drop(columns=["symbol", "timeframe"], errors="ignore")
if len(frame.columns) != 0:
frame.insert(0, "symbol", sym)
frame.insert(1, "timeframe", timeframe)
return frame
return _stream_symbol_frames(
conn,
symbols,
Dataset.rates,
if_exists,
written_columns,
_fetch_rates_frame,
)
def write_ticks_dataset(
conn: sqlite3.Connection,
client: Mt5DataClient,
symbols: Sequence[str],
flags: int,
date_from: datetime,
date_to: datetime,
if_exists: IfExists,
written_columns: dict[Dataset, set[str]],
) -> bool:
"""Stream ticks frames into SQLite.
Returns:
True if the ticks table was written.
"""
def _fetch_ticks_frame(sym: str) -> pd.DataFrame:
frame = client.copy_ticks_range_as_df(
symbol=sym,
date_from=date_from,
date_to=date_to,
flags=flags,
).drop(columns=["symbol"], errors="ignore")
if len(frame.columns) != 0:
frame.insert(0, "symbol", sym)
return frame
return _stream_symbol_frames(
conn,
symbols,
Dataset.ticks,
if_exists,
written_columns,
_fetch_ticks_frame,
)
def write_history_dataset(
conn: sqlite3.Connection,
fetch: Callable[..., pd.DataFrame],
dataset: Dataset,
symbols: Sequence[str],
date_from: datetime,
date_to: datetime,
if_exists: IfExists,
written_columns: dict[Dataset, set[str]],
*,
include_account_events: bool = False,
) -> bool:
"""Stream a history dataset into SQLite.
Returns:
True if the target table was written.
"""
table_exists = False
if include_account_events:
frame = filter_trade_history_frame(
fetch(date_from=date_from, date_to=date_to),
symbols,
include_account_events=True,
)
return write_streamed_frame(
conn,
frame,
dataset,
table_exists,
if_exists,
written_columns,
)
def _fetch_history_frame(sym: str) -> pd.DataFrame:
return filter_trade_history_frame(
fetch(date_from=date_from, date_to=date_to, symbol=sym),
[sym],
include_account_events=False,
)
return _stream_symbol_frames(
conn,
symbols,
dataset,
if_exists,
written_columns,
_fetch_history_frame,
)
def _write_incremental_rates(
conn: sqlite3.Connection,
client: Mt5DataClient,
symbols: Sequence[str],
resolved_timeframes: list[int],
fallback_start: datetime,
end_date: datetime,
written_columns: dict[Dataset, set[str]],
written_tables: set[Dataset],
dedup_scopes: dict[Dataset, list[DedupScope]],
) -> None:
start_by_key = load_incremental_start_datetimes(
conn,
Dataset.rates,
symbols=symbols,
timeframes=resolved_timeframes,
fallback_start=fallback_start,
)
for symbol in symbols:
for timeframe in resolved_timeframes:
start_date = start_by_key[symbol, timeframe]
if write_rates_dataset(
conn,
client,
[symbol],
timeframe,
start_date,
end_date,
IfExists.APPEND,
written_columns,
):
written_tables.add(Dataset.rates)
time_expr = _sqlite_normalized_time_expression("time")
_record_dedup_scope(
dedup_scopes,
Dataset.rates,
f"symbol = ? AND timeframe = ? AND {time_expr} >= ?",
(
symbol,
timeframe,
_require_serialized_sqlite_timestamp(start_date),
),
frozenset({"symbol", "timeframe", "time"}),
)
def _write_incremental_ticks(
conn: sqlite3.Connection,
client: Mt5DataClient,
symbols: Sequence[str],
resolved_tick_flags: int,
fallback_start: datetime,
end_date: datetime,
written_columns: dict[Dataset, set[str]],
written_tables: set[Dataset],
dedup_scopes: dict[Dataset, list[DedupScope]],
) -> None:
start_by_symbol = load_incremental_start_datetimes(
conn,
Dataset.ticks,
symbols=symbols,
fallback_start=fallback_start,
)
for symbol in symbols:
start_date = start_by_symbol[symbol, None]
if write_ticks_dataset(
conn,
client,
[symbol],
resolved_tick_flags,
start_date,
end_date,
IfExists.APPEND,
written_columns,
):
_record_symbol_time_dedup(
dedup_scopes,
written_tables,
Dataset.ticks,
symbol,
start_date,
)
def _write_incremental_history_orders(
conn: sqlite3.Connection,
client: Mt5DataClient,
symbols: Sequence[str],
fallback_start: datetime,
end_date: datetime,
written_columns: dict[Dataset, set[str]],
written_tables: set[Dataset],
dedup_scopes: dict[Dataset, list[DedupScope]],
) -> None:
start_by_symbol = load_incremental_start_datetimes(
conn,
Dataset.history_orders,
symbols=symbols,
fallback_start=fallback_start,
)
for symbol in symbols:
start_date = start_by_symbol[symbol, None]
if write_history_dataset(
conn,
client.history_orders_get_as_df,
Dataset.history_orders,
[symbol],
start_date,
end_date,
IfExists.APPEND,
written_columns,
include_account_events=False,
):
_record_symbol_time_dedup(
dedup_scopes,
written_tables,
Dataset.history_orders,
symbol,
start_date,
)
def _write_incremental_history_deals(
conn: sqlite3.Connection,
client: Mt5DataClient,
symbols: Sequence[str],
fallback_start: datetime,
end_date: datetime,
written_columns: dict[Dataset, set[str]],
written_tables: set[Dataset],
dedup_scopes: dict[Dataset, list[DedupScope]],
*,
include_account_events: bool,
) -> None:
if include_account_events:
start_by_symbol = load_incremental_start_datetimes(
conn,
Dataset.history_deals,
symbols=symbols,
fallback_start=fallback_start,
)
account_event_start = get_history_deals_account_event_start_datetime(
conn,
fallback_start=fallback_start,
)
fetch_start = min([*start_by_symbol.values(), account_event_start])
frame = filter_incremental_history_deals_frame(
client.history_deals_get_as_df(
date_from=fetch_start,
date_to=end_date,
),
symbols,
{symbol: start_by_symbol[symbol, None] for symbol in symbols},
account_event_start,
)
if write_streamed_frame(
conn,
frame,
Dataset.history_deals,
table_exists=False,
if_exists=IfExists.APPEND,
written_columns=written_columns,
):
written_tables.add(Dataset.history_deals)
columns = get_table_columns(conn, Dataset.history_deals.table_name)
time_expr = _sqlite_normalized_time_expression("time")
if "symbol" in columns:
for symbol in symbols:
_record_dedup_scope(
dedup_scopes,
Dataset.history_deals,
f"symbol = ? AND {time_expr} >= ?",
(
symbol,
_require_serialized_sqlite_timestamp(
start_by_symbol[symbol, None]
),
),
frozenset({"symbol", "time"}),
)
if "type" in columns:
_record_dedup_scope(
dedup_scopes,
Dataset.history_deals,
f"type NOT IN {_TRADE_DEAL_TYPES_SQL} AND {time_expr} >= ?",
(_require_serialized_sqlite_timestamp(account_event_start),),
frozenset({"type", "time"}),
)
if "type" not in columns and "symbol" in columns:
_record_dedup_scope(
dedup_scopes,
Dataset.history_deals,
f"(symbol IS NULL OR symbol = '') AND {time_expr} >= ?",
(_require_serialized_sqlite_timestamp(account_event_start),),
frozenset({"symbol", "time"}),
)
return
start_by_symbol = load_incremental_start_datetimes(
conn,
Dataset.history_deals,
symbols=symbols,
fallback_start=fallback_start,
)
for symbol in symbols:
start_date = start_by_symbol[symbol, None]
if write_history_dataset(
conn,
client.history_deals_get_as_df,
Dataset.history_deals,
[symbol],
start_date,
end_date,
IfExists.APPEND,
written_columns,
include_account_events=False,
):
_record_symbol_time_dedup(
dedup_scopes,
written_tables,
Dataset.history_deals,
symbol,
start_date,
)
def _finalize_incremental_writes(
conn: sqlite3.Connection,
selected_datasets: set[Dataset],
written_columns: dict[Dataset, set[str]],
written_tables: set[Dataset],
dedup_scopes: dict[Dataset, list[DedupScope]],
*,
deduplicate: bool,
create_rate_views: bool,
with_views: bool,
) -> None:
augment_written_columns_from_sqlite(conn, selected_datasets, written_columns)
create_history_indexes(conn, written_columns)
if deduplicate:
deduplicate_history_tables(
conn,
written_columns,
written_tables,
dedup_scopes,
)
if create_rate_views and Dataset.rates in written_tables:
create_rate_compatibility_views(conn)
if with_views and Dataset.history_deals in selected_datasets:
if Dataset.history_deals in written_columns:
deal_columns = written_columns[Dataset.history_deals]
create_cash_events_view(conn, deal_columns)
create_positions_reconstructed_view(conn, deal_columns)
if (
Dataset.history_deals not in written_columns
and Dataset.history_deals not in written_tables
):
logger.warning(
"with_views ignored: history_deals table was not available",
)
def write_incremental_datasets( # noqa: PLR0913
conn: sqlite3.Connection,
client: Mt5DataClient,
symbols: Sequence[str],
selected_datasets: set[Dataset],
resolved_timeframes: list[int],
resolved_tick_flags: int,
fallback_start: datetime,
end_date: datetime,
*,
deduplicate: bool,
create_rate_views: bool,
with_views: bool,
include_account_events: bool,
) -> tuple[set[Dataset], dict[Dataset, set[str]]]:
"""Append selected datasets incrementally and refresh indexes and views.
Returns:
Written datasets and their columns.
"""
written_columns: dict[Dataset, set[str]] = {}
written_tables: set[Dataset] = set()
dedup_scopes: dict[Dataset, list[DedupScope]] = {}
if Dataset.rates in selected_datasets:
_write_incremental_rates(
conn,
client,
symbols,
resolved_timeframes,
fallback_start,
end_date,
written_columns,
written_tables,
dedup_scopes,
)
if Dataset.ticks in selected_datasets:
_write_incremental_ticks(
conn,
client,
symbols,
resolved_tick_flags,
fallback_start,
end_date,
written_columns,
written_tables,
dedup_scopes,
)
if Dataset.history_orders in selected_datasets:
_write_incremental_history_orders(
conn,
client,
symbols,
fallback_start,
end_date,
written_columns,
written_tables,
dedup_scopes,
)
if Dataset.history_deals in selected_datasets:
_write_incremental_history_deals(
conn,
client,
symbols,
fallback_start,
end_date,
written_columns,
written_tables,
dedup_scopes,
include_account_events=include_account_events,
)
_finalize_incremental_writes(
conn,
selected_datasets,
written_columns,
written_tables,
dedup_scopes,
deduplicate=deduplicate,
create_rate_views=create_rate_views,
with_views=with_views,
)
return written_tables, written_columns
def write_collected_datasets(
conn: sqlite3.Connection,
client: Mt5DataClient,
symbols: Sequence[str],
datasets: set[Dataset],
timeframe: int,
flags: int,
date_from: datetime,
date_to: datetime,
if_exists: IfExists,
) -> tuple[set[Dataset], dict[Dataset, set[str]]]:
"""Collect selected datasets and stream each symbol frame into SQLite.
Returns:
Written datasets and their columns.
"""
written_columns: dict[Dataset, set[str]] = {}
written_tables: set[Dataset] = set()
if Dataset.rates in datasets and write_rates_dataset(
conn,
client,
symbols,
timeframe,
date_from,
date_to,
if_exists,
written_columns,
):
written_tables.add(Dataset.rates)
if Dataset.ticks in datasets and write_ticks_dataset(
conn,
client,
symbols,
flags,
date_from,
date_to,
if_exists,
written_columns,
):
written_tables.add(Dataset.ticks)
if Dataset.history_orders in datasets and write_history_dataset(
conn,
client.history_orders_get_as_df,
Dataset.history_orders,
symbols,
date_from,
date_to,
if_exists,
written_columns,
include_account_events=False,
):
written_tables.add(Dataset.history_orders)
if Dataset.history_deals in datasets and write_history_dataset(
conn,
client.history_deals_get_as_df,
Dataset.history_deals,
symbols,
date_from,
date_to,
if_exists,
written_columns,
include_account_events=False,
):
written_tables.add(Dataset.history_deals)
return written_tables, written_columns