mt5cli 0.5.0__tar.gz → 0.5.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mt5cli-0.5.0 → mt5cli-0.5.2}/PKG-INFO +5 -2
- {mt5cli-0.5.0 → mt5cli-0.5.2}/README.md +4 -1
- {mt5cli-0.5.0 → mt5cli-0.5.2}/docs/api/history.md +34 -2
- {mt5cli-0.5.0 → mt5cli-0.5.2}/mt5cli/__init__.py +40 -1
- {mt5cli-0.5.0 → mt5cli-0.5.2}/mt5cli/history.py +271 -16
- {mt5cli-0.5.0 → mt5cli-0.5.2}/mt5cli/sdk.py +138 -1
- {mt5cli-0.5.0 → mt5cli-0.5.2}/pyproject.toml +1 -1
- {mt5cli-0.5.0 → mt5cli-0.5.2}/tests/test_history.py +459 -3
- {mt5cli-0.5.0 → mt5cli-0.5.2}/tests/test_sdk.py +178 -1
- {mt5cli-0.5.0 → mt5cli-0.5.2}/uv.lock +1 -1
- {mt5cli-0.5.0 → mt5cli-0.5.2}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/.claude/agents/codex.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/.claude/settings.json +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/.github/FUNDING.yml +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/.github/dependabot.yml +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/.github/renovate.json +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/.github/workflows/ci.yml +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/.github/workflows/claude.yml +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/.github/workflows/release.yml +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/.gitignore +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/AGENTS.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/CLAUDE.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/LICENSE +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/docs/api/cli.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/docs/api/index.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/docs/api/sdk.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/docs/api/utils.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/docs/index.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/mkdocs.yml +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/mt5cli/__main__.py +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/mt5cli/cli.py +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/mt5cli/utils.py +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/tests/__init__.py +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/tests/test_cli.py +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.2}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mt5cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Command-line tool for MetaTrader 5
|
|
5
5
|
Project-URL: Repository, https://github.com/dceoy/mt5cli.git
|
|
6
6
|
Author-email: dceoy <dceoy@users.noreply.github.com>
|
|
@@ -157,8 +157,11 @@ update_history_with_config(
|
|
|
157
157
|
- **`update_history`**: incremental append based on existing SQLite `MAX(time)` per symbol (and timeframe for rates); account-level deals use a separate cursor when `include_account_events=True`.
|
|
158
158
|
- **`rates` table**: normalized storage with `symbol` and `timeframe` columns.
|
|
159
159
|
- **Rate compatibility views**: mt5cli manages all `rate_*` views. Naming is `rate_<symbol>__<timeframe>` when a symbol has one timeframe, otherwise `rate_<symbol>__<granularity>_<timeframe>` (for example `rate_EURUSD__M1_1`). Stale `rate_*` views are dropped and recreated when rates change for offline tools such as mteor optimize.
|
|
160
|
-
- **Rate view resolution**: use `
|
|
160
|
+
- **Rate view resolution**: use `resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases. Both accept `None` (or a missing path) and return deterministic default names unless `require_existing=True`.
|
|
161
161
|
- **Rate view loading**: use `load_rate_data()` / `load_rate_data_from_connection()` to load a SQLite rate table or view into a `DatetimeIndex` DataFrame.
|
|
162
|
+
- **Multi-series rate loading**: use `build_rate_targets()` to build neutral `RateTarget(symbol, timeframe)` pairs, `resolve_rate_tables()` to map them to table/view names (pass `require_existing=True` for strict resolution), and `load_rate_series_from_sqlite()` to load them into a mapping keyed by `(symbol, integer timeframe)`. The loader requires existing managed views unless `explicit_tables` is supplied, and rejects duplicate `(symbol, timeframe)` targets.
|
|
163
|
+
- **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping.
|
|
164
|
+
- **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `Mt5CliClient` that shuts down on exit.
|
|
162
165
|
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
|
|
163
166
|
- **Recent ticks and margins**: `recent_ticks()` and `minimum_margins()` SDK helpers (and matching CLI commands) cover common downstream read-only queries.
|
|
164
167
|
|
|
@@ -133,8 +133,11 @@ update_history_with_config(
|
|
|
133
133
|
- **`update_history`**: incremental append based on existing SQLite `MAX(time)` per symbol (and timeframe for rates); account-level deals use a separate cursor when `include_account_events=True`.
|
|
134
134
|
- **`rates` table**: normalized storage with `symbol` and `timeframe` columns.
|
|
135
135
|
- **Rate compatibility views**: mt5cli manages all `rate_*` views. Naming is `rate_<symbol>__<timeframe>` when a symbol has one timeframe, otherwise `rate_<symbol>__<granularity>_<timeframe>` (for example `rate_EURUSD__M1_1`). Stale `rate_*` views are dropped and recreated when rates change for offline tools such as mteor optimize.
|
|
136
|
-
- **Rate view resolution**: use `
|
|
136
|
+
- **Rate view resolution**: use `resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases. Both accept `None` (or a missing path) and return deterministic default names unless `require_existing=True`.
|
|
137
137
|
- **Rate view loading**: use `load_rate_data()` / `load_rate_data_from_connection()` to load a SQLite rate table or view into a `DatetimeIndex` DataFrame.
|
|
138
|
+
- **Multi-series rate loading**: use `build_rate_targets()` to build neutral `RateTarget(symbol, timeframe)` pairs, `resolve_rate_tables()` to map them to table/view names (pass `require_existing=True` for strict resolution), and `load_rate_series_from_sqlite()` to load them into a mapping keyed by `(symbol, integer timeframe)`. The loader requires existing managed views unless `explicit_tables` is supplied, and rejects duplicate `(symbol, timeframe)` targets.
|
|
139
|
+
- **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping.
|
|
140
|
+
- **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `Mt5CliClient` that shuts down on exit.
|
|
138
141
|
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
|
|
139
142
|
- **Recent ticks and margins**: `recent_ticks()` and `minimum_margins()` SDK helpers (and matching CLI commands) cover common downstream read-only queries.
|
|
140
143
|
|
|
@@ -133,8 +133,8 @@ The `update_history` SDK path uses the same base tables and optional
|
|
|
133
133
|
### Rate view resolution
|
|
134
134
|
|
|
135
135
|
Downstream tools can resolve mt5cli-managed compatibility view names from an
|
|
136
|
-
existing SQLite history database without creating files or guessing
|
|
137
|
-
|
|
136
|
+
existing SQLite history database without creating files or guessing naming
|
|
137
|
+
schemes:
|
|
138
138
|
|
|
139
139
|
```python
|
|
140
140
|
from pathlib import Path
|
|
@@ -183,3 +183,35 @@ rates = load_rate_data(Path("history.db"), view, count=1000)
|
|
|
183
183
|
The loader accepts close-based OHLC rate data or tick-like bid/ask data. It
|
|
184
184
|
validates that `time` exists, parses timestamps with pandas, and returns a
|
|
185
185
|
DataFrame indexed by ascending `DatetimeIndex` named `time`.
|
|
186
|
+
|
|
187
|
+
### Multi-series rate loading
|
|
188
|
+
|
|
189
|
+
For loading many rate series at once, build neutral `RateTarget` pairs and load
|
|
190
|
+
them from SQLite in one call. View names are resolved via the same
|
|
191
|
+
compatibility-view rules, or you can pass `explicit_tables` to bypass resolution:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from pathlib import Path
|
|
195
|
+
|
|
196
|
+
from mt5cli import build_rate_targets, load_rate_series_from_sqlite
|
|
197
|
+
|
|
198
|
+
targets = build_rate_targets(["EURUSD", "GBPUSD"], ["M1", "H1"])
|
|
199
|
+
series = load_rate_series_from_sqlite(Path("history.db"), targets, count=1000)
|
|
200
|
+
frame = series["EURUSD", 1] # keyed by (symbol, integer timeframe)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
- `build_rate_targets()` returns `RateTarget(symbol, timeframe)` pairs in
|
|
204
|
+
row-major order, normalizing timeframe names such as `"M1"` to their integer
|
|
205
|
+
values; set `allow_missing_symbol=True` to address series solely by
|
|
206
|
+
`explicit_tables` (targets carry `symbol=None`).
|
|
207
|
+
- `resolve_rate_tables()` maps targets to table or view names and validates that
|
|
208
|
+
any `explicit_tables` count matches the target count. Pass
|
|
209
|
+
`require_existing=True` to raise `ValueError` instead of returning a
|
|
210
|
+
best-guess name when the database or managed view is missing. When
|
|
211
|
+
`explicit_tables` is provided, names are returned as-is and
|
|
212
|
+
`require_existing` is ignored.
|
|
213
|
+
- `load_rate_series_from_sqlite()` returns a mapping keyed by
|
|
214
|
+
`(symbol, integer timeframe)`. Unless `explicit_tables` is supplied, it
|
|
215
|
+
requires existing managed `rate_*` compatibility views and raises
|
|
216
|
+
`ValueError` when they are missing. Duplicate `(symbol, timeframe)` targets
|
|
217
|
+
are rejected.
|
|
@@ -2,13 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
from importlib.metadata import version
|
|
4
4
|
|
|
5
|
-
from .history import
|
|
5
|
+
from .history import (
|
|
6
|
+
RateTarget,
|
|
7
|
+
build_rate_targets,
|
|
8
|
+
build_rate_view_name,
|
|
9
|
+
load_rate_data,
|
|
10
|
+
load_rate_data_from_connection,
|
|
11
|
+
load_rate_series_from_sqlite,
|
|
12
|
+
resolve_history_datasets,
|
|
13
|
+
resolve_history_tick_flags,
|
|
14
|
+
resolve_history_timeframes,
|
|
15
|
+
resolve_rate_tables,
|
|
16
|
+
resolve_rate_view_name,
|
|
17
|
+
resolve_rate_view_names,
|
|
18
|
+
)
|
|
6
19
|
from .sdk import (
|
|
20
|
+
AccountSpec,
|
|
7
21
|
Mt5CliClient,
|
|
8
22
|
account_info,
|
|
9
23
|
build_config,
|
|
10
24
|
collect_history,
|
|
11
25
|
collect_latest_rates,
|
|
26
|
+
collect_latest_rates_for_accounts,
|
|
12
27
|
copy_rates_from,
|
|
13
28
|
copy_rates_from_pos,
|
|
14
29
|
copy_rates_range,
|
|
@@ -20,6 +35,7 @@ from .sdk import (
|
|
|
20
35
|
latest_rates,
|
|
21
36
|
market_book,
|
|
22
37
|
minimum_margins,
|
|
38
|
+
mt5_session,
|
|
23
39
|
mt5_summary,
|
|
24
40
|
mt5_summary_as_df,
|
|
25
41
|
orders,
|
|
@@ -37,23 +53,35 @@ from .sdk import (
|
|
|
37
53
|
version as mt5_version,
|
|
38
54
|
)
|
|
39
55
|
from .utils import (
|
|
56
|
+
TICK_FLAG_MAP,
|
|
57
|
+
TIMEFRAME_MAP,
|
|
40
58
|
Dataset,
|
|
41
59
|
IfExists,
|
|
42
60
|
detect_format,
|
|
43
61
|
export_dataframe,
|
|
44
62
|
export_dataframe_to_sqlite,
|
|
63
|
+
parse_datetime,
|
|
64
|
+
parse_tick_flags,
|
|
65
|
+
parse_timeframe,
|
|
45
66
|
)
|
|
46
67
|
|
|
47
68
|
__version__ = version(__package__) if __package__ else None
|
|
48
69
|
|
|
49
70
|
__all__ = [
|
|
71
|
+
"TICK_FLAG_MAP",
|
|
72
|
+
"TIMEFRAME_MAP",
|
|
73
|
+
"AccountSpec",
|
|
50
74
|
"Dataset",
|
|
51
75
|
"IfExists",
|
|
52
76
|
"Mt5CliClient",
|
|
77
|
+
"RateTarget",
|
|
53
78
|
"account_info",
|
|
54
79
|
"build_config",
|
|
80
|
+
"build_rate_targets",
|
|
81
|
+
"build_rate_view_name",
|
|
55
82
|
"collect_history",
|
|
56
83
|
"collect_latest_rates",
|
|
84
|
+
"collect_latest_rates_for_accounts",
|
|
57
85
|
"copy_rates_from",
|
|
58
86
|
"copy_rates_from_pos",
|
|
59
87
|
"copy_rates_range",
|
|
@@ -68,15 +96,26 @@ __all__ = [
|
|
|
68
96
|
"latest_rates",
|
|
69
97
|
"load_rate_data",
|
|
70
98
|
"load_rate_data_from_connection",
|
|
99
|
+
"load_rate_series_from_sqlite",
|
|
71
100
|
"market_book",
|
|
72
101
|
"minimum_margins",
|
|
102
|
+
"mt5_session",
|
|
73
103
|
"mt5_summary",
|
|
74
104
|
"mt5_summary_as_df",
|
|
75
105
|
"mt5_version",
|
|
76
106
|
"orders",
|
|
107
|
+
"parse_datetime",
|
|
108
|
+
"parse_tick_flags",
|
|
109
|
+
"parse_timeframe",
|
|
77
110
|
"positions",
|
|
78
111
|
"recent_history_deals",
|
|
79
112
|
"recent_ticks",
|
|
113
|
+
"resolve_history_datasets",
|
|
114
|
+
"resolve_history_tick_flags",
|
|
115
|
+
"resolve_history_timeframes",
|
|
116
|
+
"resolve_rate_tables",
|
|
117
|
+
"resolve_rate_view_name",
|
|
118
|
+
"resolve_rate_view_names",
|
|
80
119
|
"symbol_info",
|
|
81
120
|
"symbol_info_tick",
|
|
82
121
|
"symbols",
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
6
|
import sqlite3
|
|
7
|
+
from dataclasses import dataclass
|
|
7
8
|
from datetime import UTC, datetime
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import TYPE_CHECKING, Literal, cast
|
|
@@ -20,7 +21,7 @@ from .utils import (
|
|
|
20
21
|
)
|
|
21
22
|
|
|
22
23
|
if TYPE_CHECKING:
|
|
23
|
-
from collections.abc import Callable, Sequence
|
|
24
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
24
25
|
|
|
25
26
|
from pdmt5 import Mt5DataClient
|
|
26
27
|
|
|
@@ -135,14 +136,17 @@ def _require_non_empty_identifier(identifier: str, kind: str) -> str:
|
|
|
135
136
|
|
|
136
137
|
|
|
137
138
|
def _open_history_connection(
|
|
138
|
-
conn_or_path: SqliteConnOrPath,
|
|
139
|
+
conn_or_path: SqliteConnOrPath | None,
|
|
139
140
|
) -> tuple[sqlite3.Connection | None, bool]:
|
|
140
141
|
"""Open a read-only SQLite connection when given a path.
|
|
141
142
|
|
|
142
143
|
Returns:
|
|
143
|
-
A connection and whether the caller should close it. When
|
|
144
|
-
not exist, returns ``(None, False)`` without
|
|
144
|
+
A connection and whether the caller should close it. When ``conn_or_path``
|
|
145
|
+
is None or the path does not exist, returns ``(None, False)`` without
|
|
146
|
+
creating a database file.
|
|
145
147
|
"""
|
|
148
|
+
if conn_or_path is None:
|
|
149
|
+
return None, False
|
|
146
150
|
if isinstance(conn_or_path, sqlite3.Connection):
|
|
147
151
|
return conn_or_path, False
|
|
148
152
|
path = Path(conn_or_path)
|
|
@@ -376,7 +380,7 @@ def _resolve_rate_view_name_from_context(
|
|
|
376
380
|
|
|
377
381
|
|
|
378
382
|
def resolve_rate_view_name(
|
|
379
|
-
conn_or_path: SqliteConnOrPath,
|
|
383
|
+
conn_or_path: SqliteConnOrPath | None,
|
|
380
384
|
symbol: str,
|
|
381
385
|
granularity: str,
|
|
382
386
|
*,
|
|
@@ -385,7 +389,9 @@ def resolve_rate_view_name(
|
|
|
385
389
|
"""Resolve the mt5cli-managed rate compatibility view name.
|
|
386
390
|
|
|
387
391
|
Args:
|
|
388
|
-
conn_or_path: SQLite database path or open connection.
|
|
392
|
+
conn_or_path: SQLite database path or open connection. When None or a
|
|
393
|
+
non-existing path and ``require_existing`` is False, the deterministic
|
|
394
|
+
default view name is returned without creating a database file.
|
|
389
395
|
symbol: Symbol stored in the normalized ``rates`` table.
|
|
390
396
|
granularity: Timeframe name (for example ``M1``) or integer string.
|
|
391
397
|
require_existing: When True, require the database and a managed view to exist.
|
|
@@ -429,7 +435,7 @@ def resolve_rate_view_name(
|
|
|
429
435
|
|
|
430
436
|
|
|
431
437
|
def resolve_rate_view_names(
|
|
432
|
-
conn_or_path: SqliteConnOrPath,
|
|
438
|
+
conn_or_path: SqliteConnOrPath | None,
|
|
433
439
|
symbols: Sequence[str],
|
|
434
440
|
granularities: Sequence[str],
|
|
435
441
|
*,
|
|
@@ -438,7 +444,9 @@ def resolve_rate_view_names(
|
|
|
438
444
|
"""Resolve rate compatibility view names for symbol and granularity pairs.
|
|
439
445
|
|
|
440
446
|
Args:
|
|
441
|
-
conn_or_path: SQLite database path or open connection.
|
|
447
|
+
conn_or_path: SQLite database path or open connection. When None or a
|
|
448
|
+
non-existing path and ``require_existing`` is False, deterministic
|
|
449
|
+
default view names are returned without creating a database file.
|
|
442
450
|
symbols: Symbols stored in the normalized ``rates`` table.
|
|
443
451
|
granularities: Timeframe names (for example ``M1``) or integer strings.
|
|
444
452
|
require_existing: When True, require the database and managed views to exist.
|
|
@@ -482,6 +490,222 @@ def resolve_rate_view_names(
|
|
|
482
490
|
conn.close()
|
|
483
491
|
|
|
484
492
|
|
|
493
|
+
@dataclass(frozen=True)
|
|
494
|
+
class RateTarget:
|
|
495
|
+
"""A single rate series identified by symbol and timeframe.
|
|
496
|
+
|
|
497
|
+
Attributes:
|
|
498
|
+
symbol: MT5 symbol name, or None when the rate series is addressed only
|
|
499
|
+
by an explicit table (for example a custom SQLite view).
|
|
500
|
+
timeframe: MT5 timeframe as an integer or name (for example ``M1``).
|
|
501
|
+
"""
|
|
502
|
+
|
|
503
|
+
symbol: str | None
|
|
504
|
+
timeframe: int | str
|
|
505
|
+
|
|
506
|
+
def __post_init__(self) -> None:
|
|
507
|
+
"""Normalize accepted timeframe aliases to the stored integer value."""
|
|
508
|
+
if not isinstance(self.timeframe, int):
|
|
509
|
+
object.__setattr__(self, "timeframe", parse_timeframe(self.timeframe))
|
|
510
|
+
|
|
511
|
+
@property
|
|
512
|
+
def timeframe_int(self) -> int:
|
|
513
|
+
"""Return the timeframe as its integer MT5 value."""
|
|
514
|
+
return cast("int", self.timeframe)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def build_rate_targets(
|
|
518
|
+
symbols: Sequence[str],
|
|
519
|
+
timeframes: Sequence[int | str],
|
|
520
|
+
*,
|
|
521
|
+
allow_missing_symbol: bool = False,
|
|
522
|
+
) -> list[RateTarget]:
|
|
523
|
+
"""Build rate targets for every symbol and timeframe combination.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
symbols: MT5 symbol names. May be empty when ``allow_missing_symbol``.
|
|
527
|
+
timeframes: MT5 timeframes as integers or names (for example ``M1``).
|
|
528
|
+
allow_missing_symbol: When True and ``symbols`` is empty, build targets
|
|
529
|
+
with ``symbol=None`` for each timeframe instead of raising.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
Targets in row-major order: every timeframe for the first symbol, then
|
|
533
|
+
every timeframe for the next symbol, and so on.
|
|
534
|
+
|
|
535
|
+
Raises:
|
|
536
|
+
ValueError: If ``timeframes`` is empty, or ``symbols`` is empty and
|
|
537
|
+
``allow_missing_symbol`` is False.
|
|
538
|
+
"""
|
|
539
|
+
if not timeframes:
|
|
540
|
+
msg = "At least one timeframe is required."
|
|
541
|
+
raise ValueError(msg)
|
|
542
|
+
if not symbols:
|
|
543
|
+
if not allow_missing_symbol:
|
|
544
|
+
msg = "At least one symbol is required."
|
|
545
|
+
raise ValueError(msg)
|
|
546
|
+
return [RateTarget(symbol=None, timeframe=tf) for tf in timeframes]
|
|
547
|
+
return [
|
|
548
|
+
RateTarget(symbol=symbol, timeframe=tf)
|
|
549
|
+
for symbol in symbols
|
|
550
|
+
for tf in timeframes
|
|
551
|
+
]
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def resolve_rate_tables(
|
|
555
|
+
conn_or_path: SqliteConnOrPath | None,
|
|
556
|
+
targets: Sequence[RateTarget],
|
|
557
|
+
explicit_tables: Sequence[str] | None = None,
|
|
558
|
+
*,
|
|
559
|
+
require_existing: bool = False,
|
|
560
|
+
) -> list[str]:
|
|
561
|
+
"""Resolve SQLite table or view names for rate targets.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
conn_or_path: SQLite database path or open connection. May be None when
|
|
565
|
+
``explicit_tables`` is provided, or when ``require_existing`` is
|
|
566
|
+
False and deterministic default view names are sufficient.
|
|
567
|
+
targets: Rate targets to resolve.
|
|
568
|
+
explicit_tables: Optional explicit table or view names. When provided,
|
|
569
|
+
they are used as-is and must match the number of targets.
|
|
570
|
+
require_existing: When True, require the database and managed views to
|
|
571
|
+
exist for each symbol target. Ignored when ``explicit_tables`` is
|
|
572
|
+
provided.
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
Table or view names aligned with ``targets``.
|
|
576
|
+
|
|
577
|
+
Raises:
|
|
578
|
+
ValueError: If ``targets`` is empty, ``explicit_tables`` length does not
|
|
579
|
+
match the target count, a target without a symbol is resolved
|
|
580
|
+
without an explicit table, or ``require_existing`` is True and the
|
|
581
|
+
database or a managed view is missing.
|
|
582
|
+
"""
|
|
583
|
+
target_list = list(targets)
|
|
584
|
+
if not target_list:
|
|
585
|
+
msg = "At least one rate target is required."
|
|
586
|
+
raise ValueError(msg)
|
|
587
|
+
if explicit_tables is not None:
|
|
588
|
+
tables = list(explicit_tables)
|
|
589
|
+
if len(tables) != len(target_list):
|
|
590
|
+
msg = (
|
|
591
|
+
f"Expected {len(target_list)} explicit table(s) "
|
|
592
|
+
f"to match the targets, got {len(tables)}."
|
|
593
|
+
)
|
|
594
|
+
raise ValueError(msg)
|
|
595
|
+
return tables
|
|
596
|
+
if any(target.symbol is None for target in target_list):
|
|
597
|
+
msg = (
|
|
598
|
+
"Cannot resolve a rate table for a target without a symbol; "
|
|
599
|
+
"provide explicit_tables."
|
|
600
|
+
)
|
|
601
|
+
raise ValueError(msg)
|
|
602
|
+
conn, should_close = _open_history_connection(conn_or_path)
|
|
603
|
+
try:
|
|
604
|
+
if conn is None:
|
|
605
|
+
if require_existing:
|
|
606
|
+
path = (
|
|
607
|
+
conn_or_path
|
|
608
|
+
if isinstance(conn_or_path, (Path, str))
|
|
609
|
+
else "database"
|
|
610
|
+
)
|
|
611
|
+
msg = f"SQLite database not found: {path}"
|
|
612
|
+
raise ValueError(msg)
|
|
613
|
+
timeframe_counts = None
|
|
614
|
+
existing_views: set[str] = set()
|
|
615
|
+
else:
|
|
616
|
+
timeframe_counts = _load_rates_timeframe_counts(conn)
|
|
617
|
+
existing_views = _load_existing_rate_views(conn)
|
|
618
|
+
resolved: list[str] = []
|
|
619
|
+
for target in target_list:
|
|
620
|
+
symbol = cast("str", target.symbol)
|
|
621
|
+
timeframe = target.timeframe_int
|
|
622
|
+
resolved.append(
|
|
623
|
+
_resolve_rate_view_name_from_context(
|
|
624
|
+
symbol=symbol,
|
|
625
|
+
timeframe=timeframe,
|
|
626
|
+
granularity_name=resolve_granularity_name(timeframe),
|
|
627
|
+
timeframe_counts=timeframe_counts,
|
|
628
|
+
existing_views=existing_views,
|
|
629
|
+
require_existing=require_existing,
|
|
630
|
+
),
|
|
631
|
+
)
|
|
632
|
+
return resolved
|
|
633
|
+
finally:
|
|
634
|
+
if should_close and conn is not None:
|
|
635
|
+
conn.close()
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def load_rate_series_from_sqlite(
|
|
639
|
+
conn_or_path: SqliteConnOrPath,
|
|
640
|
+
targets: Sequence[RateTarget],
|
|
641
|
+
count: int,
|
|
642
|
+
explicit_tables: Sequence[str] | None = None,
|
|
643
|
+
) -> dict[tuple[str | None, int], pd.DataFrame]:
|
|
644
|
+
"""Load multiple rate series from a SQLite database.
|
|
645
|
+
|
|
646
|
+
Args:
|
|
647
|
+
conn_or_path: SQLite database path or open connection.
|
|
648
|
+
targets: Rate targets to load. Each ``(symbol, timeframe_int)`` pair
|
|
649
|
+
must be unique.
|
|
650
|
+
count: Number of most recent rows to load per series.
|
|
651
|
+
explicit_tables: Optional explicit table or view names matching targets.
|
|
652
|
+
When omitted, managed ``rate_*`` compatibility views must already
|
|
653
|
+
exist in the database.
|
|
654
|
+
|
|
655
|
+
Returns:
|
|
656
|
+
Mapping keyed by ``(symbol, timeframe_int)`` to each rate DataFrame.
|
|
657
|
+
|
|
658
|
+
Raises:
|
|
659
|
+
ValueError: If ``count`` is not positive, targets are empty, duplicate
|
|
660
|
+
``(symbol, timeframe_int)`` pairs are present, or table resolution
|
|
661
|
+
fails.
|
|
662
|
+
"""
|
|
663
|
+
if count <= 0:
|
|
664
|
+
msg = "count must be positive."
|
|
665
|
+
raise ValueError(msg)
|
|
666
|
+
target_list = list(targets)
|
|
667
|
+
if not target_list:
|
|
668
|
+
msg = "At least one rate target is required."
|
|
669
|
+
raise ValueError(msg)
|
|
670
|
+
if explicit_tables is None and any(target.symbol is None for target in target_list):
|
|
671
|
+
msg = (
|
|
672
|
+
"Cannot resolve a rate table for a target without a symbol; "
|
|
673
|
+
"provide explicit_tables."
|
|
674
|
+
)
|
|
675
|
+
raise ValueError(msg)
|
|
676
|
+
seen_keys: set[tuple[str | None, int]] = set()
|
|
677
|
+
for target in target_list:
|
|
678
|
+
key = (target.symbol, target.timeframe_int)
|
|
679
|
+
if key in seen_keys:
|
|
680
|
+
symbol_repr = repr(target.symbol)
|
|
681
|
+
msg = f"Duplicate rate target: ({symbol_repr}, {target.timeframe_int})"
|
|
682
|
+
raise ValueError(msg)
|
|
683
|
+
seen_keys.add(key)
|
|
684
|
+
tables = (
|
|
685
|
+
resolve_rate_tables(None, target_list, explicit_tables)
|
|
686
|
+
if explicit_tables is not None
|
|
687
|
+
else None
|
|
688
|
+
)
|
|
689
|
+
conn, should_close = _open_existing_sqlite_database(conn_or_path)
|
|
690
|
+
try:
|
|
691
|
+
resolved_tables = tables or resolve_rate_tables(
|
|
692
|
+
conn,
|
|
693
|
+
target_list,
|
|
694
|
+
require_existing=True,
|
|
695
|
+
)
|
|
696
|
+
return {
|
|
697
|
+
(target.symbol, target.timeframe_int): load_rate_data_from_connection(
|
|
698
|
+
conn,
|
|
699
|
+
table,
|
|
700
|
+
count=count,
|
|
701
|
+
)
|
|
702
|
+
for target, table in zip(target_list, resolved_tables, strict=True)
|
|
703
|
+
}
|
|
704
|
+
finally:
|
|
705
|
+
if should_close:
|
|
706
|
+
conn.close()
|
|
707
|
+
|
|
708
|
+
|
|
485
709
|
def get_table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
|
486
710
|
"""Return existing SQLite columns for a table."""
|
|
487
711
|
quoted_table = quote_sqlite_identifier(table)
|
|
@@ -765,7 +989,20 @@ def drop_duplicates_in_table(
|
|
|
765
989
|
)
|
|
766
990
|
|
|
767
991
|
|
|
768
|
-
|
|
992
|
+
@dataclass(frozen=True)
|
|
993
|
+
class DedupScope:
|
|
994
|
+
"""Scoped deduplication predicate and the columns it references.
|
|
995
|
+
|
|
996
|
+
Attributes:
|
|
997
|
+
where: SQL predicate appended to the duplicate-removal query.
|
|
998
|
+
params: Parameters bound to the scope predicate.
|
|
999
|
+
required_columns: Columns that must be present in the written table for
|
|
1000
|
+
the scope to run.
|
|
1001
|
+
"""
|
|
1002
|
+
|
|
1003
|
+
where: str
|
|
1004
|
+
params: tuple[object, ...]
|
|
1005
|
+
required_columns: frozenset[str]
|
|
769
1006
|
|
|
770
1007
|
|
|
771
1008
|
def _record_dedup_scope(
|
|
@@ -773,17 +1010,25 @@ def _record_dedup_scope(
|
|
|
773
1010
|
dataset: Dataset,
|
|
774
1011
|
scope_where: str,
|
|
775
1012
|
scope_params: tuple[object, ...],
|
|
1013
|
+
required_columns: frozenset[str],
|
|
776
1014
|
) -> None:
|
|
777
|
-
dedup_scopes.setdefault(dataset, []).append(
|
|
1015
|
+
dedup_scopes.setdefault(dataset, []).append(
|
|
1016
|
+
DedupScope(scope_where, scope_params, required_columns),
|
|
1017
|
+
)
|
|
778
1018
|
|
|
779
1019
|
|
|
780
1020
|
def deduplicate_history_tables(
|
|
781
1021
|
conn: sqlite3.Connection,
|
|
782
1022
|
written_columns: dict[Dataset, set[str]],
|
|
783
1023
|
written_tables: set[Dataset],
|
|
784
|
-
dedup_scopes:
|
|
1024
|
+
dedup_scopes: Mapping[Dataset, Sequence[DedupScope]] | None = None,
|
|
785
1025
|
) -> None:
|
|
786
|
-
"""Deduplicate appended history tables by stable identifiers.
|
|
1026
|
+
"""Deduplicate appended history tables by stable identifiers.
|
|
1027
|
+
|
|
1028
|
+
Scopes whose required columns are not present in the written table are
|
|
1029
|
+
skipped. If all scopes for a dataset are skipped, the table receives one
|
|
1030
|
+
unscoped deduplication pass instead.
|
|
1031
|
+
"""
|
|
787
1032
|
cursor = conn.cursor()
|
|
788
1033
|
for dataset in written_tables:
|
|
789
1034
|
columns = written_columns.get(dataset, set())
|
|
@@ -802,16 +1047,19 @@ def deduplicate_history_tables(
|
|
|
802
1047
|
table,
|
|
803
1048
|
)
|
|
804
1049
|
continue
|
|
805
|
-
|
|
1050
|
+
raw_scopes: Sequence[DedupScope] = (
|
|
1051
|
+
dedup_scopes.get(dataset, ()) if dedup_scopes else ()
|
|
1052
|
+
)
|
|
1053
|
+
scopes = [scope for scope in raw_scopes if scope.required_columns <= columns]
|
|
806
1054
|
if scopes:
|
|
807
|
-
for
|
|
1055
|
+
for scope in scopes:
|
|
808
1056
|
drop_duplicates_in_table(
|
|
809
1057
|
cursor,
|
|
810
1058
|
table,
|
|
811
1059
|
list(keys),
|
|
812
1060
|
keep="last",
|
|
813
|
-
scope_where=
|
|
814
|
-
scope_params=
|
|
1061
|
+
scope_where=scope.where,
|
|
1062
|
+
scope_params=scope.params,
|
|
815
1063
|
)
|
|
816
1064
|
continue
|
|
817
1065
|
drop_duplicates_in_table(cursor, table, list(keys), keep="last")
|
|
@@ -1178,6 +1426,7 @@ def _write_incremental_rates(
|
|
|
1178
1426
|
Dataset.rates,
|
|
1179
1427
|
"symbol = ? AND timeframe = ? AND time >= ?",
|
|
1180
1428
|
(symbol, timeframe, start_date),
|
|
1429
|
+
frozenset({"symbol", "timeframe", "time"}),
|
|
1181
1430
|
)
|
|
1182
1431
|
|
|
1183
1432
|
|
|
@@ -1216,6 +1465,7 @@ def _write_incremental_ticks(
|
|
|
1216
1465
|
Dataset.ticks,
|
|
1217
1466
|
"symbol = ? AND time >= ?",
|
|
1218
1467
|
(symbol, start_date),
|
|
1468
|
+
frozenset({"symbol", "time"}),
|
|
1219
1469
|
)
|
|
1220
1470
|
|
|
1221
1471
|
|
|
@@ -1254,6 +1504,7 @@ def _write_incremental_history_orders(
|
|
|
1254
1504
|
Dataset.history_orders,
|
|
1255
1505
|
"symbol = ? AND time >= ?",
|
|
1256
1506
|
(symbol, start_date),
|
|
1507
|
+
frozenset({"symbol", "time"}),
|
|
1257
1508
|
)
|
|
1258
1509
|
|
|
1259
1510
|
|
|
@@ -1307,6 +1558,7 @@ def _write_incremental_history_deals(
|
|
|
1307
1558
|
Dataset.history_deals,
|
|
1308
1559
|
"symbol = ? AND time >= ?",
|
|
1309
1560
|
(symbol, start_by_symbol[symbol, None]),
|
|
1561
|
+
frozenset({"symbol", "time"}),
|
|
1310
1562
|
)
|
|
1311
1563
|
if "type" in columns:
|
|
1312
1564
|
_record_dedup_scope(
|
|
@@ -1314,6 +1566,7 @@ def _write_incremental_history_deals(
|
|
|
1314
1566
|
Dataset.history_deals,
|
|
1315
1567
|
f"type NOT IN {_TRADE_DEAL_TYPES_SQL} AND time >= ?",
|
|
1316
1568
|
(account_event_start,),
|
|
1569
|
+
frozenset({"type", "time"}),
|
|
1317
1570
|
)
|
|
1318
1571
|
if "type" not in columns and "symbol" in columns:
|
|
1319
1572
|
_record_dedup_scope(
|
|
@@ -1321,6 +1574,7 @@ def _write_incremental_history_deals(
|
|
|
1321
1574
|
Dataset.history_deals,
|
|
1322
1575
|
"(symbol IS NULL OR symbol = '') AND time >= ?",
|
|
1323
1576
|
(account_event_start,),
|
|
1577
|
+
frozenset({"symbol", "time"}),
|
|
1324
1578
|
)
|
|
1325
1579
|
return
|
|
1326
1580
|
start_by_symbol = load_incremental_start_datetimes(
|
|
@@ -1348,6 +1602,7 @@ def _write_incremental_history_deals(
|
|
|
1348
1602
|
Dataset.history_deals,
|
|
1349
1603
|
"symbol = ? AND time >= ?",
|
|
1350
1604
|
(symbol, start_date),
|
|
1605
|
+
frozenset({"symbol", "time"}),
|
|
1351
1606
|
)
|
|
1352
1607
|
|
|
1353
1608
|
|