b2bb2ad0a0
* Add public helpers to resolve rate compatibility view names. Expose resolve_rate_view_name and resolve_rate_view_names in mt5cli.history so consumers can derive mt5cli-managed SQLite view names from stored rates metadata without reimplementing the naming rules. Co-authored-by: Cursor <cursoragent@cursor.com> * Add reusable export, tick-window, and margin helpers for downstream tools. Expose SQLite append/dedup export, recent tick retrieval, and minimum margin summary through the SDK and CLI so projects like mteor can depend on mt5cli instead of duplicating MT5 data plumbing. Co-authored-by: Cursor <cursoragent@cursor.com> * Bump version to 0.4.3. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback for rate view resolution and SDK helpers. Harden SQLite read-only connections, tighten view discovery, improve recent_ticks fetch efficiency, default SQLite export to append, and expand tests and docs. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix read-only SQLite URI construction on Windows. Use Path.as_uri() so encoded file URIs work cross-platform with mode=ro. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1402 lines
44 KiB
Python
1402 lines
44 KiB
Python
"""SQLite storage helpers for the ``collect-history`` incremental data pipeline."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sqlite3
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Literal
|
|
|
|
import pandas as pd
|
|
|
|
from .utils import (
|
|
TIMEFRAME_MAP,
|
|
Dataset,
|
|
IfExists,
|
|
parse_datetime,
|
|
parse_tick_flags,
|
|
parse_timeframe,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable, Sequence
|
|
|
|
from pdmt5 import Mt5DataClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_HISTORY_TIMEFRAMES: tuple[str, ...] = tuple(TIMEFRAME_MAP)
|
|
|
|
_HISTORY_DEDUP_KEYS: dict[Dataset, tuple[tuple[str, ...], ...]] = {
|
|
Dataset.rates: (("symbol", "timeframe", "time"), ("symbol", "time")),
|
|
Dataset.ticks: (("symbol", "time_msc"), ("symbol", "time")),
|
|
Dataset.history_orders: (("ticket",), ("symbol", "time", "type")),
|
|
Dataset.history_deals: (("ticket",), ("symbol", "time", "type", "entry")),
|
|
}
|
|
|
|
_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",
|
|
})
|
|
|
|
|
|
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:
|
|
All supported datasets when ``datasets`` is None, otherwise the
|
|
configured selection (which may be empty).
|
|
"""
|
|
if datasets is None:
|
|
return set(Dataset)
|
|
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 = value if isinstance(value, int) else parse_timeframe(str(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.
|
|
"""
|
|
if isinstance(flags, int):
|
|
return flags
|
|
return parse_tick_flags(flags)
|
|
|
|
|
|
def resolve_granularity_name(timeframe: int) -> str:
|
|
"""Return a granularity name for a timeframe integer when known."""
|
|
for name, value in TIMEFRAME_MAP.items():
|
|
if value == timeframe:
|
|
return name
|
|
return str(timeframe)
|
|
|
|
|
|
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}"
|
|
|
|
|
|
SqliteConnOrPath = sqlite3.Connection | Path | str
|
|
|
|
|
|
def _open_history_connection(
|
|
conn_or_path: SqliteConnOrPath,
|
|
) -> 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 the path does
|
|
not exist, returns ``(None, False)`` without creating a database file.
|
|
"""
|
|
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 _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,
|
|
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.
|
|
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,
|
|
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.
|
|
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()
|
|
|
|
|
|
def get_table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
|
"""Return existing SQLite columns for a table."""
|
|
rows = conn.execute(f"PRAGMA table_info({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 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
|
|
row = conn.execute(
|
|
f"SELECT MAX(time) FROM {table} WHERE {where_clause}", # noqa: S608
|
|
).fetchone()
|
|
parsed = parse_sqlite_timestamp(row[0] if row else None)
|
|
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_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}
|
|
|
|
parsed_by_key: dict[tuple[str, int | None], datetime] = {}
|
|
if (
|
|
dataset is Dataset.rates
|
|
and timeframes is not None
|
|
and {"symbol", "timeframe"}.issubset(columns)
|
|
):
|
|
symbol_placeholders = ", ".join("?" for _ in symbols)
|
|
timeframe_placeholders = ", ".join("?" for _ in timeframes)
|
|
grouped_rates_query = (
|
|
"SELECT symbol, timeframe, MAX(time) FROM " # noqa: S608
|
|
f"{table} WHERE symbol IN ({symbol_placeholders})"
|
|
f" AND timeframe IN ({timeframe_placeholders})"
|
|
" GROUP BY symbol, timeframe"
|
|
)
|
|
rows = conn.execute(
|
|
grouped_rates_query,
|
|
[*symbols, *timeframes],
|
|
).fetchall()
|
|
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
|
|
}
|
|
|
|
if "symbol" in columns:
|
|
symbol_placeholders = ", ".join("?" for _ in symbols)
|
|
rows = conn.execute(
|
|
f"SELECT symbol, MAX(time) FROM {table}" # noqa: S608
|
|
f" WHERE symbol IN ({symbol_placeholders}) GROUP BY symbol",
|
|
list(symbols),
|
|
).fetchall()
|
|
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
|
|
}
|
|
|
|
row = conn.execute(f"SELECT MAX(time) FROM {table}").fetchone() # noqa: S608
|
|
parsed = parse_sqlite_timestamp(row[0] if row else None)
|
|
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
|
|
frame.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(f'"{column}"' for column in ids)
|
|
rowid_selector = "MIN" if keep == "first" else "MAX"
|
|
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, scope_params + 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})",
|
|
)
|
|
|
|
|
|
DedupScope = tuple[str, tuple[object, ...]]
|
|
|
|
|
|
def _record_dedup_scope(
|
|
dedup_scopes: dict[Dataset, list[DedupScope]],
|
|
dataset: Dataset,
|
|
scope_where: str,
|
|
scope_params: tuple[object, ...],
|
|
) -> None:
|
|
dedup_scopes.setdefault(dataset, []).append((scope_where, scope_params))
|
|
|
|
|
|
def deduplicate_history_tables(
|
|
conn: sqlite3.Connection,
|
|
written_columns: dict[Dataset, set[str]],
|
|
written_tables: set[Dataset],
|
|
dedup_scopes: dict[Dataset, list[DedupScope]] | None = None,
|
|
) -> None:
|
|
"""Deduplicate appended history tables by stable identifiers."""
|
|
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
|
|
scopes = dedup_scopes.get(dataset, []) if dedup_scopes else []
|
|
if scopes:
|
|
for scope_where, scope_params 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 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.
|
|
"""
|
|
table_exists = False
|
|
for sym in symbols:
|
|
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)
|
|
table_exists = write_streamed_frame(
|
|
conn,
|
|
frame,
|
|
Dataset.rates,
|
|
table_exists,
|
|
if_exists,
|
|
written_columns,
|
|
)
|
|
return table_exists
|
|
|
|
|
|
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.
|
|
"""
|
|
table_exists = False
|
|
for sym in symbols:
|
|
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)
|
|
table_exists = write_streamed_frame(
|
|
conn,
|
|
frame,
|
|
Dataset.ticks,
|
|
table_exists,
|
|
if_exists,
|
|
written_columns,
|
|
)
|
|
return table_exists
|
|
|
|
|
|
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,
|
|
)
|
|
for sym in symbols:
|
|
frame = fetch(date_from=date_from, date_to=date_to, symbol=sym)
|
|
frame = filter_trade_history_frame(
|
|
frame,
|
|
[sym],
|
|
include_account_events=False,
|
|
)
|
|
table_exists = write_streamed_frame(
|
|
conn,
|
|
frame,
|
|
dataset,
|
|
table_exists,
|
|
if_exists,
|
|
written_columns,
|
|
)
|
|
return table_exists
|
|
|
|
|
|
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)
|
|
_record_dedup_scope(
|
|
dedup_scopes,
|
|
Dataset.rates,
|
|
"symbol = ? AND timeframe = ? AND time >= ?",
|
|
(symbol, timeframe, start_date),
|
|
)
|
|
|
|
|
|
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,
|
|
):
|
|
written_tables.add(Dataset.ticks)
|
|
_record_dedup_scope(
|
|
dedup_scopes,
|
|
Dataset.ticks,
|
|
"symbol = ? AND time >= ?",
|
|
(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,
|
|
):
|
|
written_tables.add(Dataset.history_orders)
|
|
_record_dedup_scope(
|
|
dedup_scopes,
|
|
Dataset.history_orders,
|
|
"symbol = ? AND time >= ?",
|
|
(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)
|
|
if "symbol" in columns:
|
|
for symbol in symbols:
|
|
_record_dedup_scope(
|
|
dedup_scopes,
|
|
Dataset.history_deals,
|
|
"symbol = ? AND time >= ?",
|
|
(symbol, start_by_symbol[symbol, None]),
|
|
)
|
|
if "type" in columns:
|
|
_record_dedup_scope(
|
|
dedup_scopes,
|
|
Dataset.history_deals,
|
|
f"type NOT IN {_TRADE_DEAL_TYPES_SQL} AND time >= ?",
|
|
(account_event_start,),
|
|
)
|
|
if "type" not in columns and "symbol" in columns:
|
|
_record_dedup_scope(
|
|
dedup_scopes,
|
|
Dataset.history_deals,
|
|
"(symbol IS NULL OR symbol = '') AND time >= ?",
|
|
(account_event_start,),
|
|
)
|
|
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,
|
|
):
|
|
written_tables.add(Dataset.history_deals)
|
|
_record_dedup_scope(
|
|
dedup_scopes,
|
|
Dataset.history_deals,
|
|
"symbol = ? AND time >= ?",
|
|
(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
|