mt5cli 0.6.1__tar.gz → 0.7.1__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.6.1 → mt5cli-0.7.1}/PKG-INFO +8 -2
- {mt5cli-0.6.1 → mt5cli-0.7.1}/README.md +6 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/docs/index.md +6 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/mt5cli/cli.py +55 -54
- {mt5cli-0.6.1 → mt5cli-0.7.1}/mt5cli/history.py +102 -59
- {mt5cli-0.6.1 → mt5cli-0.7.1}/mt5cli/sdk.py +4 -5
- {mt5cli-0.6.1 → mt5cli-0.7.1}/mt5cli/utils.py +31 -50
- {mt5cli-0.6.1 → mt5cli-0.7.1}/pyproject.toml +2 -2
- mt5cli-0.7.1/tests/conftest.py +52 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/tests/test_cli.py +5 -37
- {mt5cli-0.6.1 → mt5cli-0.7.1}/tests/test_history.py +17 -1
- {mt5cli-0.6.1 → mt5cli-0.7.1}/tests/test_sdk.py +5 -31
- {mt5cli-0.6.1 → mt5cli-0.7.1}/tests/test_utils.py +51 -14
- {mt5cli-0.6.1 → mt5cli-0.7.1}/uv.lock +5 -5
- {mt5cli-0.6.1 → mt5cli-0.7.1}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/.claude/agents/codex.md +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/.claude/settings.json +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/.github/FUNDING.yml +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/.github/dependabot.yml +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/.github/renovate.json +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/.github/workflows/ci.yml +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/.github/workflows/claude.yml +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/.github/workflows/release.yml +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/.gitignore +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/AGENTS.md +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/CLAUDE.md +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/LICENSE +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/docs/api/cli.md +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/docs/api/history.md +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/docs/api/index.md +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/docs/api/sdk.md +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/docs/api/trading.md +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/docs/api/utils.md +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/mkdocs.yml +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/mt5cli/__init__.py +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/mt5cli/__main__.py +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/mt5cli/trading.py +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/tests/__init__.py +0 -0
- {mt5cli-0.6.1 → mt5cli-0.7.1}/tests/test_trading.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mt5cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
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>
|
|
@@ -17,7 +17,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
17
17
|
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
18
18
|
Requires-Python: <3.14,>=3.11
|
|
19
19
|
Requires-Dist: click>=8.1.0
|
|
20
|
-
Requires-Dist: pdmt5>=0.
|
|
20
|
+
Requires-Dist: pdmt5>=0.3.0
|
|
21
21
|
Requires-Dist: pyarrow>=19.0.0
|
|
22
22
|
Requires-Dist: typer>=0.15.0
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
@@ -30,6 +30,12 @@ Command-line tool for exporting MetaTrader 5 data to CSV, JSON, Parquet, and SQL
|
|
|
30
30
|
|
|
31
31
|
Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5.
|
|
32
32
|
|
|
33
|
+
## Architecture
|
|
34
|
+
|
|
35
|
+
- **pdmt5** — canonical MT5 client, DataFrame/trading primitives, and MT5 constant parsing (`TIMEFRAME_*`, `COPY_TICKS_*`, order types).
|
|
36
|
+
- **mt5cli** — CLI commands, CSV/JSON/Parquet/SQLite export, SQLite history collection, rate views, and local batch/automation SDK helpers built on pdmt5.
|
|
37
|
+
- **mt5api** — sibling HTTP adapter for remote MT5 access; not a dependency of mt5cli.
|
|
38
|
+
|
|
33
39
|
## Features
|
|
34
40
|
|
|
35
41
|
- **Multi-format export**: CSV, JSON, Parquet, and SQLite3 output formats
|
|
@@ -6,6 +6,12 @@ Command-line tool for exporting MetaTrader 5 data to CSV, JSON, Parquet, and SQL
|
|
|
6
6
|
|
|
7
7
|
Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5.
|
|
8
8
|
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
- **pdmt5** — canonical MT5 client, DataFrame/trading primitives, and MT5 constant parsing (`TIMEFRAME_*`, `COPY_TICKS_*`, order types).
|
|
12
|
+
- **mt5cli** — CLI commands, CSV/JSON/Parquet/SQLite export, SQLite history collection, rate views, and local batch/automation SDK helpers built on pdmt5.
|
|
13
|
+
- **mt5api** — sibling HTTP adapter for remote MT5 access; not a dependency of mt5cli.
|
|
14
|
+
|
|
9
15
|
## Features
|
|
10
16
|
|
|
11
17
|
- **Multi-format export**: CSV, JSON, Parquet, and SQLite3 output formats
|
|
@@ -6,6 +6,12 @@ Command-line tool for MetaTrader 5 data export.
|
|
|
6
6
|
|
|
7
7
|
mt5cli is a CLI application that exports MetaTrader 5 trading data to multiple file formats. It is built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5.
|
|
8
8
|
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
- **pdmt5** — canonical MT5 client, DataFrame/trading primitives, and MT5 constant parsing (`TIMEFRAME_*`, `COPY_TICKS_*`, order types).
|
|
12
|
+
- **mt5cli** — CLI commands, CSV/JSON/Parquet/SQLite export, SQLite history collection, rate views, and local batch/automation SDK helpers built on pdmt5.
|
|
13
|
+
- **mt5api** — sibling HTTP adapter for remote MT5 access; not a dependency of mt5cli.
|
|
14
|
+
|
|
9
15
|
## Features
|
|
10
16
|
|
|
11
17
|
- **Multi-format export**: CSV, JSON, Parquet, and SQLite3 output formats
|
|
@@ -96,6 +96,15 @@ def _sdk_client(ctx: typer.Context) -> sdk.Mt5CliClient:
|
|
|
96
96
|
return sdk.Mt5CliClient(config=export_ctx.config)
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
def _export_command(
|
|
100
|
+
ctx: typer.Context,
|
|
101
|
+
fetch_fn: Callable[[sdk.Mt5CliClient], pd.DataFrame],
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Create an SDK client, fetch a DataFrame, and export it."""
|
|
104
|
+
client = _sdk_client(ctx)
|
|
105
|
+
_execute_export(ctx, lambda: fetch_fn(client))
|
|
106
|
+
|
|
107
|
+
|
|
99
108
|
@app.callback()
|
|
100
109
|
def _callback( # pyright: ignore[reportUnusedFunction]
|
|
101
110
|
ctx: typer.Context,
|
|
@@ -193,10 +202,9 @@ def rates_from(
|
|
|
193
202
|
count: Annotated[int, typer.Option(help="Number of records.")],
|
|
194
203
|
) -> None:
|
|
195
204
|
"""Export rates from a start date."""
|
|
196
|
-
|
|
197
|
-
_execute_export(
|
|
205
|
+
_export_command(
|
|
198
206
|
ctx,
|
|
199
|
-
lambda: client.copy_rates_from(symbol, timeframe, date_from, count),
|
|
207
|
+
lambda client: client.copy_rates_from(symbol, timeframe, date_from, count),
|
|
200
208
|
)
|
|
201
209
|
|
|
202
210
|
|
|
@@ -215,10 +223,14 @@ def rates_from_pos(
|
|
|
215
223
|
count: Annotated[int, typer.Option(help="Number of records.")],
|
|
216
224
|
) -> None:
|
|
217
225
|
"""Export rates from a start position."""
|
|
218
|
-
|
|
219
|
-
_execute_export(
|
|
226
|
+
_export_command(
|
|
220
227
|
ctx,
|
|
221
|
-
lambda: client.copy_rates_from_pos(
|
|
228
|
+
lambda client: client.copy_rates_from_pos(
|
|
229
|
+
symbol,
|
|
230
|
+
timeframe,
|
|
231
|
+
start_pos,
|
|
232
|
+
count,
|
|
233
|
+
),
|
|
222
234
|
)
|
|
223
235
|
|
|
224
236
|
|
|
@@ -240,10 +252,14 @@ def latest_rates(
|
|
|
240
252
|
] = 0,
|
|
241
253
|
) -> None:
|
|
242
254
|
"""Export latest rates from a start position."""
|
|
243
|
-
|
|
244
|
-
_execute_export(
|
|
255
|
+
_export_command(
|
|
245
256
|
ctx,
|
|
246
|
-
lambda: client.latest_rates(
|
|
257
|
+
lambda client: client.latest_rates(
|
|
258
|
+
symbol,
|
|
259
|
+
timeframe,
|
|
260
|
+
count,
|
|
261
|
+
start_pos=start_pos,
|
|
262
|
+
),
|
|
247
263
|
)
|
|
248
264
|
|
|
249
265
|
|
|
@@ -268,10 +284,9 @@ def rates_range(
|
|
|
268
284
|
],
|
|
269
285
|
) -> None:
|
|
270
286
|
"""Export rates for a date range."""
|
|
271
|
-
|
|
272
|
-
_execute_export(
|
|
287
|
+
_export_command(
|
|
273
288
|
ctx,
|
|
274
|
-
lambda: client.copy_rates_range(symbol, timeframe, date_from, date_to),
|
|
289
|
+
lambda client: client.copy_rates_range(symbol, timeframe, date_from, date_to),
|
|
275
290
|
)
|
|
276
291
|
|
|
277
292
|
|
|
@@ -293,10 +308,9 @@ def ticks_from(
|
|
|
293
308
|
],
|
|
294
309
|
) -> None:
|
|
295
310
|
"""Export ticks from a start date."""
|
|
296
|
-
|
|
297
|
-
_execute_export(
|
|
311
|
+
_export_command(
|
|
298
312
|
ctx,
|
|
299
|
-
lambda: client.copy_ticks_from(symbol, date_from, count, flags),
|
|
313
|
+
lambda client: client.copy_ticks_from(symbol, date_from, count, flags),
|
|
300
314
|
)
|
|
301
315
|
|
|
302
316
|
|
|
@@ -318,10 +332,9 @@ def ticks_range(
|
|
|
318
332
|
],
|
|
319
333
|
) -> None:
|
|
320
334
|
"""Export ticks for a date range."""
|
|
321
|
-
|
|
322
|
-
_execute_export(
|
|
335
|
+
_export_command(
|
|
323
336
|
ctx,
|
|
324
|
-
lambda: client.copy_ticks_range(symbol, date_from, date_to, flags),
|
|
337
|
+
lambda client: client.copy_ticks_range(symbol, date_from, date_to, flags),
|
|
325
338
|
)
|
|
326
339
|
|
|
327
340
|
|
|
@@ -347,13 +360,12 @@ def ticks_recent(
|
|
|
347
360
|
click_type=TICK_FLAGS_TYPE,
|
|
348
361
|
help="Tick flags (ALL, INFO, TRADE, or integer).",
|
|
349
362
|
),
|
|
350
|
-
] =
|
|
363
|
+
] = "ALL", # pyright: ignore[reportArgumentType]
|
|
351
364
|
) -> None:
|
|
352
365
|
"""Export ticks from a recent time window."""
|
|
353
|
-
|
|
354
|
-
_execute_export(
|
|
366
|
+
_export_command(
|
|
355
367
|
ctx,
|
|
356
|
-
lambda: client.recent_ticks(
|
|
368
|
+
lambda client: client.recent_ticks(
|
|
357
369
|
symbol,
|
|
358
370
|
seconds,
|
|
359
371
|
date_to=date_to,
|
|
@@ -366,13 +378,13 @@ def ticks_recent(
|
|
|
366
378
|
@app.command()
|
|
367
379
|
def account_info(ctx: typer.Context) -> None:
|
|
368
380
|
"""Export account information."""
|
|
369
|
-
|
|
381
|
+
_export_command(ctx, lambda client: client.account_info())
|
|
370
382
|
|
|
371
383
|
|
|
372
384
|
@app.command()
|
|
373
385
|
def terminal_info(ctx: typer.Context) -> None:
|
|
374
386
|
"""Export terminal information."""
|
|
375
|
-
|
|
387
|
+
_export_command(ctx, lambda client: client.terminal_info())
|
|
376
388
|
|
|
377
389
|
|
|
378
390
|
@app.command()
|
|
@@ -384,8 +396,7 @@ def symbols(
|
|
|
384
396
|
] = None,
|
|
385
397
|
) -> None:
|
|
386
398
|
"""Export symbol list."""
|
|
387
|
-
client =
|
|
388
|
-
_execute_export(ctx, lambda: client.symbols(group=group))
|
|
399
|
+
_export_command(ctx, lambda client: client.symbols(group=group))
|
|
389
400
|
|
|
390
401
|
|
|
391
402
|
@app.command()
|
|
@@ -394,8 +405,7 @@ def symbol_info(
|
|
|
394
405
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
|
395
406
|
) -> None:
|
|
396
407
|
"""Export symbol details."""
|
|
397
|
-
client
|
|
398
|
-
_execute_export(ctx, lambda: client.symbol_info(symbol))
|
|
408
|
+
_export_command(ctx, lambda client: client.symbol_info(symbol))
|
|
399
409
|
|
|
400
410
|
|
|
401
411
|
@app.command()
|
|
@@ -404,8 +414,7 @@ def minimum_margins(
|
|
|
404
414
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
|
405
415
|
) -> None:
|
|
406
416
|
"""Export minimum-volume buy and sell margin requirements."""
|
|
407
|
-
client
|
|
408
|
-
_execute_export(ctx, lambda: client.minimum_margins(symbol))
|
|
417
|
+
_export_command(ctx, lambda client: client.minimum_margins(symbol))
|
|
409
418
|
|
|
410
419
|
|
|
411
420
|
@app.command()
|
|
@@ -416,10 +425,9 @@ def orders(
|
|
|
416
425
|
ticket: Annotated[int | None, typer.Option(help="Ticket filter.")] = None,
|
|
417
426
|
) -> None:
|
|
418
427
|
"""Export active orders."""
|
|
419
|
-
|
|
420
|
-
_execute_export(
|
|
428
|
+
_export_command(
|
|
421
429
|
ctx,
|
|
422
|
-
lambda: client.orders(symbol=symbol, group=group, ticket=ticket),
|
|
430
|
+
lambda client: client.orders(symbol=symbol, group=group, ticket=ticket),
|
|
423
431
|
)
|
|
424
432
|
|
|
425
433
|
|
|
@@ -431,10 +439,9 @@ def positions(
|
|
|
431
439
|
ticket: Annotated[int | None, typer.Option(help="Ticket filter.")] = None,
|
|
432
440
|
) -> None:
|
|
433
441
|
"""Export open positions."""
|
|
434
|
-
|
|
435
|
-
_execute_export(
|
|
442
|
+
_export_command(
|
|
436
443
|
ctx,
|
|
437
|
-
lambda: client.positions(symbol=symbol, group=group, ticket=ticket),
|
|
444
|
+
lambda client: client.positions(symbol=symbol, group=group, ticket=ticket),
|
|
438
445
|
)
|
|
439
446
|
|
|
440
447
|
|
|
@@ -455,10 +462,9 @@ def history_orders(
|
|
|
455
462
|
position: Annotated[int | None, typer.Option(help="Position ticket.")] = None,
|
|
456
463
|
) -> None:
|
|
457
464
|
"""Export historical orders."""
|
|
458
|
-
|
|
459
|
-
_execute_export(
|
|
465
|
+
_export_command(
|
|
460
466
|
ctx,
|
|
461
|
-
lambda: client.history_orders(
|
|
467
|
+
lambda client: client.history_orders(
|
|
462
468
|
date_from=date_from,
|
|
463
469
|
date_to=date_to,
|
|
464
470
|
group=group,
|
|
@@ -486,10 +492,9 @@ def history_deals(
|
|
|
486
492
|
position: Annotated[int | None, typer.Option(help="Position ticket.")] = None,
|
|
487
493
|
) -> None:
|
|
488
494
|
"""Export historical deals."""
|
|
489
|
-
|
|
490
|
-
_execute_export(
|
|
495
|
+
_export_command(
|
|
491
496
|
ctx,
|
|
492
|
-
lambda: client.history_deals(
|
|
497
|
+
lambda client: client.history_deals(
|
|
493
498
|
date_from=date_from,
|
|
494
499
|
date_to=date_to,
|
|
495
500
|
group=group,
|
|
@@ -512,10 +517,9 @@ def recent_history_deals(
|
|
|
512
517
|
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
|
513
518
|
) -> None:
|
|
514
519
|
"""Export historical deals from a recent trailing window."""
|
|
515
|
-
|
|
516
|
-
_execute_export(
|
|
520
|
+
_export_command(
|
|
517
521
|
ctx,
|
|
518
|
-
lambda: client.recent_history_deals(
|
|
522
|
+
lambda client: client.recent_history_deals(
|
|
519
523
|
hours,
|
|
520
524
|
date_to=date_to,
|
|
521
525
|
group=group,
|
|
@@ -527,20 +531,19 @@ def recent_history_deals(
|
|
|
527
531
|
@app.command()
|
|
528
532
|
def mt5_summary(ctx: typer.Context) -> None:
|
|
529
533
|
"""Export a compact terminal/account status summary."""
|
|
530
|
-
client
|
|
531
|
-
_execute_export(ctx, client.mt5_summary_as_df)
|
|
534
|
+
_export_command(ctx, lambda client: client.mt5_summary_as_df())
|
|
532
535
|
|
|
533
536
|
|
|
534
537
|
@app.command()
|
|
535
538
|
def version(ctx: typer.Context) -> None:
|
|
536
539
|
"""Export MetaTrader5 version information."""
|
|
537
|
-
|
|
540
|
+
_export_command(ctx, lambda client: client.version())
|
|
538
541
|
|
|
539
542
|
|
|
540
543
|
@app.command()
|
|
541
544
|
def last_error(ctx: typer.Context) -> None:
|
|
542
545
|
"""Export the last error information."""
|
|
543
|
-
|
|
546
|
+
_export_command(ctx, lambda client: client.last_error())
|
|
544
547
|
|
|
545
548
|
|
|
546
549
|
@app.command()
|
|
@@ -549,8 +552,7 @@ def symbol_info_tick(
|
|
|
549
552
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
|
550
553
|
) -> None:
|
|
551
554
|
"""Export the last tick for a symbol."""
|
|
552
|
-
client
|
|
553
|
-
_execute_export(ctx, lambda: client.symbol_info_tick(symbol))
|
|
555
|
+
_export_command(ctx, lambda client: client.symbol_info_tick(symbol))
|
|
554
556
|
|
|
555
557
|
|
|
556
558
|
@app.command()
|
|
@@ -559,8 +561,7 @@ def market_book(
|
|
|
559
561
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
|
560
562
|
) -> None:
|
|
561
563
|
"""Export market depth (order book) for a symbol."""
|
|
562
|
-
client
|
|
563
|
-
_execute_export(ctx, lambda: client.market_book(symbol))
|
|
564
|
+
_export_command(ctx, lambda client: client.market_book(symbol))
|
|
564
565
|
|
|
565
566
|
|
|
566
567
|
@app.command()
|
|
@@ -656,7 +657,7 @@ def collect_history(
|
|
|
656
657
|
click_type=TICK_FLAGS_TYPE,
|
|
657
658
|
help="Tick copy flags (ALL, INFO, TRADE, or integer).",
|
|
658
659
|
),
|
|
659
|
-
] =
|
|
660
|
+
] = "ALL", # pyright: ignore[reportArgumentType]
|
|
660
661
|
if_exists: Annotated[
|
|
661
662
|
IfExists,
|
|
662
663
|
typer.Option(
|
|
@@ -10,9 +10,10 @@ from pathlib import Path
|
|
|
10
10
|
from typing import TYPE_CHECKING, Literal, cast
|
|
11
11
|
|
|
12
12
|
import pandas as pd
|
|
13
|
+
from pdmt5 import get_timeframe_name as _get_timeframe_name
|
|
13
14
|
|
|
14
15
|
from .utils import (
|
|
15
|
-
|
|
16
|
+
TIMEFRAME_NAMES,
|
|
16
17
|
Dataset,
|
|
17
18
|
IfExists,
|
|
18
19
|
parse_datetime,
|
|
@@ -27,7 +28,7 @@ if TYPE_CHECKING:
|
|
|
27
28
|
|
|
28
29
|
logger = logging.getLogger(__name__)
|
|
29
30
|
|
|
30
|
-
DEFAULT_HISTORY_TIMEFRAMES: tuple[str, ...] =
|
|
31
|
+
DEFAULT_HISTORY_TIMEFRAMES: tuple[str, ...] = TIMEFRAME_NAMES
|
|
31
32
|
|
|
32
33
|
_HISTORY_DEDUP_KEYS: dict[Dataset, tuple[tuple[str, ...], ...]] = {
|
|
33
34
|
Dataset.rates: (("symbol", "timeframe", "time"), ("symbol", "time")),
|
|
@@ -80,7 +81,7 @@ def resolve_history_timeframes(
|
|
|
80
81
|
seen: set[int] = set()
|
|
81
82
|
resolved: list[int] = []
|
|
82
83
|
for value in raw:
|
|
83
|
-
tf =
|
|
84
|
+
tf = parse_timeframe(value)
|
|
84
85
|
if tf not in seen:
|
|
85
86
|
seen.add(tf)
|
|
86
87
|
resolved.append(tf)
|
|
@@ -93,17 +94,16 @@ def resolve_history_tick_flags(flags: int | str) -> int:
|
|
|
93
94
|
Returns:
|
|
94
95
|
Integer tick flag value.
|
|
95
96
|
"""
|
|
96
|
-
if isinstance(flags, int):
|
|
97
|
-
return flags
|
|
98
97
|
return parse_tick_flags(flags)
|
|
99
98
|
|
|
100
99
|
|
|
101
100
|
def resolve_granularity_name(timeframe: int) -> str:
|
|
102
101
|
"""Return a granularity name for a timeframe integer when known."""
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
try:
|
|
103
|
+
name = _get_timeframe_name(timeframe)
|
|
104
|
+
except ValueError:
|
|
105
|
+
return str(timeframe)
|
|
106
|
+
return name.removeprefix("TIMEFRAME_")
|
|
107
107
|
|
|
108
108
|
|
|
109
109
|
def drop_forming_rate_bar(df_rate: pd.DataFrame) -> pd.DataFrame:
|
|
@@ -1332,6 +1332,50 @@ def create_rate_compatibility_views(conn: sqlite3.Connection) -> None:
|
|
|
1332
1332
|
)
|
|
1333
1333
|
|
|
1334
1334
|
|
|
1335
|
+
def _stream_symbol_frames(
|
|
1336
|
+
conn: sqlite3.Connection,
|
|
1337
|
+
symbols: Sequence[str],
|
|
1338
|
+
dataset: Dataset,
|
|
1339
|
+
if_exists: IfExists,
|
|
1340
|
+
written_columns: dict[Dataset, set[str]],
|
|
1341
|
+
fetch_frame: Callable[[str], pd.DataFrame],
|
|
1342
|
+
) -> bool:
|
|
1343
|
+
"""Stream per-symbol frames into SQLite.
|
|
1344
|
+
|
|
1345
|
+
Returns:
|
|
1346
|
+
True if the dataset table was written.
|
|
1347
|
+
"""
|
|
1348
|
+
table_exists = False
|
|
1349
|
+
for sym in symbols:
|
|
1350
|
+
table_exists = write_streamed_frame(
|
|
1351
|
+
conn,
|
|
1352
|
+
fetch_frame(sym),
|
|
1353
|
+
dataset,
|
|
1354
|
+
table_exists,
|
|
1355
|
+
if_exists,
|
|
1356
|
+
written_columns,
|
|
1357
|
+
)
|
|
1358
|
+
return table_exists
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def _record_symbol_time_dedup(
|
|
1362
|
+
dedup_scopes: dict[Dataset, list[DedupScope]],
|
|
1363
|
+
written_tables: set[Dataset],
|
|
1364
|
+
dataset: Dataset,
|
|
1365
|
+
symbol: str,
|
|
1366
|
+
start_date: datetime,
|
|
1367
|
+
) -> None:
|
|
1368
|
+
"""Record a symbol-scoped deduplication window after an incremental write."""
|
|
1369
|
+
written_tables.add(dataset)
|
|
1370
|
+
_record_dedup_scope(
|
|
1371
|
+
dedup_scopes,
|
|
1372
|
+
dataset,
|
|
1373
|
+
"symbol = ? AND time >= ?",
|
|
1374
|
+
(symbol, start_date),
|
|
1375
|
+
frozenset({"symbol", "time"}),
|
|
1376
|
+
)
|
|
1377
|
+
|
|
1378
|
+
|
|
1335
1379
|
def write_rates_dataset(
|
|
1336
1380
|
conn: sqlite3.Connection,
|
|
1337
1381
|
client: Mt5DataClient,
|
|
@@ -1347,8 +1391,8 @@ def write_rates_dataset(
|
|
|
1347
1391
|
Returns:
|
|
1348
1392
|
True if the rates table was written.
|
|
1349
1393
|
"""
|
|
1350
|
-
|
|
1351
|
-
|
|
1394
|
+
|
|
1395
|
+
def _fetch_rates_frame(sym: str) -> pd.DataFrame:
|
|
1352
1396
|
frame = client.copy_rates_range_as_df(
|
|
1353
1397
|
symbol=sym,
|
|
1354
1398
|
timeframe=timeframe,
|
|
@@ -1358,15 +1402,16 @@ def write_rates_dataset(
|
|
|
1358
1402
|
if len(frame.columns) != 0:
|
|
1359
1403
|
frame.insert(0, "symbol", sym)
|
|
1360
1404
|
frame.insert(1, "timeframe", timeframe)
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1405
|
+
return frame
|
|
1406
|
+
|
|
1407
|
+
return _stream_symbol_frames(
|
|
1408
|
+
conn,
|
|
1409
|
+
symbols,
|
|
1410
|
+
Dataset.rates,
|
|
1411
|
+
if_exists,
|
|
1412
|
+
written_columns,
|
|
1413
|
+
_fetch_rates_frame,
|
|
1414
|
+
)
|
|
1370
1415
|
|
|
1371
1416
|
|
|
1372
1417
|
def write_ticks_dataset(
|
|
@@ -1384,8 +1429,8 @@ def write_ticks_dataset(
|
|
|
1384
1429
|
Returns:
|
|
1385
1430
|
True if the ticks table was written.
|
|
1386
1431
|
"""
|
|
1387
|
-
|
|
1388
|
-
|
|
1432
|
+
|
|
1433
|
+
def _fetch_ticks_frame(sym: str) -> pd.DataFrame:
|
|
1389
1434
|
frame = client.copy_ticks_range_as_df(
|
|
1390
1435
|
symbol=sym,
|
|
1391
1436
|
date_from=date_from,
|
|
@@ -1394,15 +1439,16 @@ def write_ticks_dataset(
|
|
|
1394
1439
|
).drop(columns=["symbol"], errors="ignore")
|
|
1395
1440
|
if len(frame.columns) != 0:
|
|
1396
1441
|
frame.insert(0, "symbol", sym)
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1442
|
+
return frame
|
|
1443
|
+
|
|
1444
|
+
return _stream_symbol_frames(
|
|
1445
|
+
conn,
|
|
1446
|
+
symbols,
|
|
1447
|
+
Dataset.ticks,
|
|
1448
|
+
if_exists,
|
|
1449
|
+
written_columns,
|
|
1450
|
+
_fetch_ticks_frame,
|
|
1451
|
+
)
|
|
1406
1452
|
|
|
1407
1453
|
|
|
1408
1454
|
def write_history_dataset(
|
|
@@ -1437,22 +1483,22 @@ def write_history_dataset(
|
|
|
1437
1483
|
if_exists,
|
|
1438
1484
|
written_columns,
|
|
1439
1485
|
)
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1486
|
+
|
|
1487
|
+
def _fetch_history_frame(sym: str) -> pd.DataFrame:
|
|
1488
|
+
return filter_trade_history_frame(
|
|
1489
|
+
fetch(date_from=date_from, date_to=date_to, symbol=sym),
|
|
1444
1490
|
[sym],
|
|
1445
1491
|
include_account_events=False,
|
|
1446
1492
|
)
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1493
|
+
|
|
1494
|
+
return _stream_symbol_frames(
|
|
1495
|
+
conn,
|
|
1496
|
+
symbols,
|
|
1497
|
+
dataset,
|
|
1498
|
+
if_exists,
|
|
1499
|
+
written_columns,
|
|
1500
|
+
_fetch_history_frame,
|
|
1501
|
+
)
|
|
1456
1502
|
|
|
1457
1503
|
|
|
1458
1504
|
def _write_incremental_rates(
|
|
@@ -1525,13 +1571,12 @@ def _write_incremental_ticks(
|
|
|
1525
1571
|
IfExists.APPEND,
|
|
1526
1572
|
written_columns,
|
|
1527
1573
|
):
|
|
1528
|
-
|
|
1529
|
-
_record_dedup_scope(
|
|
1574
|
+
_record_symbol_time_dedup(
|
|
1530
1575
|
dedup_scopes,
|
|
1576
|
+
written_tables,
|
|
1531
1577
|
Dataset.ticks,
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
frozenset({"symbol", "time"}),
|
|
1578
|
+
symbol,
|
|
1579
|
+
start_date,
|
|
1535
1580
|
)
|
|
1536
1581
|
|
|
1537
1582
|
|
|
@@ -1564,13 +1609,12 @@ def _write_incremental_history_orders(
|
|
|
1564
1609
|
written_columns,
|
|
1565
1610
|
include_account_events=False,
|
|
1566
1611
|
):
|
|
1567
|
-
|
|
1568
|
-
_record_dedup_scope(
|
|
1612
|
+
_record_symbol_time_dedup(
|
|
1569
1613
|
dedup_scopes,
|
|
1614
|
+
written_tables,
|
|
1570
1615
|
Dataset.history_orders,
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
frozenset({"symbol", "time"}),
|
|
1616
|
+
symbol,
|
|
1617
|
+
start_date,
|
|
1574
1618
|
)
|
|
1575
1619
|
|
|
1576
1620
|
|
|
@@ -1662,13 +1706,12 @@ def _write_incremental_history_deals(
|
|
|
1662
1706
|
written_columns,
|
|
1663
1707
|
include_account_events=False,
|
|
1664
1708
|
):
|
|
1665
|
-
|
|
1666
|
-
_record_dedup_scope(
|
|
1709
|
+
_record_symbol_time_dedup(
|
|
1667
1710
|
dedup_scopes,
|
|
1711
|
+
written_tables,
|
|
1668
1712
|
Dataset.history_deals,
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
frozenset({"symbol", "time"}),
|
|
1713
|
+
symbol,
|
|
1714
|
+
start_date,
|
|
1672
1715
|
)
|
|
1673
1716
|
|
|
1674
1717
|
|
|
@@ -64,6 +64,9 @@ _MT5_HISTORY_CLIENT_CALL_FUNCTIONS: frozenset[str] = frozenset({
|
|
|
64
64
|
"write_ticks_dataset",
|
|
65
65
|
"write_history_dataset",
|
|
66
66
|
"_write_incremental_history_deals",
|
|
67
|
+
"_fetch_rates_frame",
|
|
68
|
+
"_fetch_ticks_frame",
|
|
69
|
+
"_fetch_history_frame",
|
|
67
70
|
})
|
|
68
71
|
_NON_CALLABLE_TYPE_ERROR = re.compile(r"^'[^']+' object is not callable$")
|
|
69
72
|
|
|
@@ -144,14 +147,10 @@ __all__ = [
|
|
|
144
147
|
|
|
145
148
|
|
|
146
149
|
def _coerce_timeframe(timeframe: int | str) -> int:
|
|
147
|
-
if isinstance(timeframe, int):
|
|
148
|
-
return timeframe
|
|
149
150
|
return parse_timeframe(timeframe)
|
|
150
151
|
|
|
151
152
|
|
|
152
153
|
def _coerce_tick_flags(flags: int | str) -> int:
|
|
153
|
-
if isinstance(flags, int):
|
|
154
|
-
return flags
|
|
155
154
|
return parse_tick_flags(flags)
|
|
156
155
|
|
|
157
156
|
|
|
@@ -1171,7 +1170,7 @@ def collect_history(
|
|
|
1171
1170
|
*,
|
|
1172
1171
|
datasets: set[Dataset] | None = None,
|
|
1173
1172
|
timeframe: int | str = 1,
|
|
1174
|
-
flags: int | str =
|
|
1173
|
+
flags: int | str = "ALL",
|
|
1175
1174
|
if_exists: IfExists = IfExists.FAIL,
|
|
1176
1175
|
with_views: bool = False,
|
|
1177
1176
|
config: Mt5Config | None = None,
|
|
@@ -10,6 +10,9 @@ from pathlib import Path
|
|
|
10
10
|
from typing import TYPE_CHECKING, Any, TypeGuard
|
|
11
11
|
|
|
12
12
|
import click
|
|
13
|
+
from pdmt5 import COPY_TICKS_MAP, TIMEFRAME_MAP
|
|
14
|
+
from pdmt5 import parse_copy_ticks as _parse_copy_ticks
|
|
15
|
+
from pdmt5 import parse_timeframe as _parse_timeframe
|
|
13
16
|
|
|
14
17
|
if TYPE_CHECKING:
|
|
15
18
|
from collections.abc import Sequence
|
|
@@ -20,35 +23,15 @@ if TYPE_CHECKING:
|
|
|
20
23
|
# Constants
|
|
21
24
|
# ---------------------------------------------------------------------------
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"M2": 2,
|
|
26
|
-
"M3": 3,
|
|
27
|
-
"M4": 4,
|
|
28
|
-
"M5": 5,
|
|
29
|
-
"M6": 6,
|
|
30
|
-
"M10": 10,
|
|
31
|
-
"M12": 12,
|
|
32
|
-
"M15": 15,
|
|
33
|
-
"M20": 20,
|
|
34
|
-
"M30": 30,
|
|
35
|
-
"H1": 16385,
|
|
36
|
-
"H2": 16386,
|
|
37
|
-
"H3": 16387,
|
|
38
|
-
"H4": 16388,
|
|
39
|
-
"H6": 16390,
|
|
40
|
-
"H8": 16392,
|
|
41
|
-
"H12": 16396,
|
|
42
|
-
"D1": 16408,
|
|
43
|
-
"W1": 32769,
|
|
44
|
-
"MN1": 49153,
|
|
45
|
-
}
|
|
26
|
+
# Backward-compatible snapshot; prefer ``COPY_TICKS_MAP`` from pdmt5 directly.
|
|
27
|
+
TICK_FLAG_MAP: dict[str, int] = dict(COPY_TICKS_MAP)
|
|
46
28
|
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
29
|
+
TIMEFRAME_NAMES: tuple[str, ...] = tuple(
|
|
30
|
+
name for name in TIMEFRAME_MAP if not name.startswith("TIMEFRAME_")
|
|
31
|
+
)
|
|
32
|
+
_TICK_FLAG_NAMES: tuple[str, ...] = tuple(
|
|
33
|
+
name for name in COPY_TICKS_MAP if not name.startswith("COPY_TICKS_")
|
|
34
|
+
)
|
|
52
35
|
|
|
53
36
|
_FORMAT_EXTENSIONS: dict[str, str] = {
|
|
54
37
|
".csv": "csv",
|
|
@@ -160,10 +143,8 @@ class _TimeframeType(click.ParamType):
|
|
|
160
143
|
Returns:
|
|
161
144
|
Integer timeframe value.
|
|
162
145
|
"""
|
|
163
|
-
if isinstance(value, int):
|
|
164
|
-
return value
|
|
165
146
|
try:
|
|
166
|
-
return parse_timeframe(
|
|
147
|
+
return parse_timeframe(value)
|
|
167
148
|
except ValueError as exc:
|
|
168
149
|
self.fail(str(exc), param, ctx)
|
|
169
150
|
|
|
@@ -189,10 +170,8 @@ class _TickFlagsType(click.ParamType):
|
|
|
189
170
|
Returns:
|
|
190
171
|
Integer tick flag value.
|
|
191
172
|
"""
|
|
192
|
-
if isinstance(value, int):
|
|
193
|
-
return value
|
|
194
173
|
try:
|
|
195
|
-
return parse_tick_flags(
|
|
174
|
+
return parse_tick_flags(value)
|
|
196
175
|
except ValueError as exc:
|
|
197
176
|
self.fail(str(exc), param, ctx)
|
|
198
177
|
|
|
@@ -370,7 +349,7 @@ def parse_datetime(value: str) -> datetime:
|
|
|
370
349
|
return dt
|
|
371
350
|
|
|
372
351
|
|
|
373
|
-
def parse_timeframe(value:
|
|
352
|
+
def parse_timeframe(value: object) -> int:
|
|
374
353
|
"""Parse a timeframe string or integer value.
|
|
375
354
|
|
|
376
355
|
Args:
|
|
@@ -382,37 +361,39 @@ def parse_timeframe(value: str) -> int:
|
|
|
382
361
|
Raises:
|
|
383
362
|
ValueError: If the timeframe is invalid.
|
|
384
363
|
"""
|
|
385
|
-
upper = value.upper()
|
|
386
|
-
if upper in TIMEFRAME_MAP:
|
|
387
|
-
return TIMEFRAME_MAP[upper]
|
|
388
364
|
try:
|
|
389
|
-
return
|
|
365
|
+
return _parse_timeframe(value)
|
|
390
366
|
except ValueError:
|
|
391
|
-
|
|
392
|
-
|
|
367
|
+
display = value if isinstance(value, str) else repr(value)
|
|
368
|
+
valid = ", ".join(TIMEFRAME_NAMES)
|
|
369
|
+
msg = (
|
|
370
|
+
f"Invalid timeframe: '{display}'. "
|
|
371
|
+
f"Use one of: {valid}, or a supported integer."
|
|
372
|
+
)
|
|
393
373
|
raise ValueError(msg) from None
|
|
394
374
|
|
|
395
375
|
|
|
396
|
-
def parse_tick_flags(value:
|
|
376
|
+
def parse_tick_flags(value: object) -> int:
|
|
397
377
|
"""Parse tick flags string or integer value.
|
|
398
378
|
|
|
399
379
|
Args:
|
|
400
|
-
value: Tick flag name (ALL, INFO, TRADE) or integer value.
|
|
380
|
+
value: Tick flag name (ALL, INFO, TRADE, COPY_TICKS_*) or integer value.
|
|
401
381
|
|
|
402
382
|
Returns:
|
|
403
|
-
Integer tick flag value
|
|
383
|
+
Integer tick flag value compatible with MetaTrader 5 ``COPY_TICKS_*``.
|
|
404
384
|
|
|
405
385
|
Raises:
|
|
406
386
|
ValueError: If the flag is invalid.
|
|
407
387
|
"""
|
|
408
|
-
upper = value.upper()
|
|
409
|
-
if upper in TICK_FLAG_MAP:
|
|
410
|
-
return TICK_FLAG_MAP[upper]
|
|
411
388
|
try:
|
|
412
|
-
return
|
|
389
|
+
return _parse_copy_ticks(value)
|
|
413
390
|
except ValueError:
|
|
414
|
-
|
|
415
|
-
|
|
391
|
+
display = value if isinstance(value, str) else repr(value)
|
|
392
|
+
valid = ", ".join(_TICK_FLAG_NAMES)
|
|
393
|
+
msg = (
|
|
394
|
+
f"Invalid tick flags: '{display}'. "
|
|
395
|
+
f"Use one of: {valid}, or a supported integer."
|
|
396
|
+
)
|
|
416
397
|
raise ValueError(msg) from None
|
|
417
398
|
|
|
418
399
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mt5cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.1"
|
|
4
4
|
description = "Command-line tool for MetaTrader 5"
|
|
5
5
|
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
6
6
|
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
@@ -9,7 +9,7 @@ license-files = ["LICENSE"]
|
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">= 3.11, < 3.14"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"pdmt5
|
|
12
|
+
"pdmt5>=0.3.0",
|
|
13
13
|
"click >= 8.1.0",
|
|
14
14
|
"pyarrow >= 19.0.0",
|
|
15
15
|
"typer >= 0.15.0",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Shared pytest fixtures for mt5cli tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import pytest
|
|
9
|
+
from pytest_mock import MockerFixture # noqa: TC002
|
|
10
|
+
|
|
11
|
+
_DATAFRAME_METHODS = (
|
|
12
|
+
"copy_rates_from_as_df",
|
|
13
|
+
"copy_rates_from_pos_as_df",
|
|
14
|
+
"copy_rates_range_as_df",
|
|
15
|
+
"copy_ticks_from_as_df",
|
|
16
|
+
"copy_ticks_range_as_df",
|
|
17
|
+
"account_info_as_df",
|
|
18
|
+
"terminal_info_as_df",
|
|
19
|
+
"symbols_get_as_df",
|
|
20
|
+
"symbol_info_as_df",
|
|
21
|
+
"orders_get_as_df",
|
|
22
|
+
"positions_get_as_df",
|
|
23
|
+
"history_orders_get_as_df",
|
|
24
|
+
"history_deals_get_as_df",
|
|
25
|
+
"version_as_df",
|
|
26
|
+
"last_error_as_df",
|
|
27
|
+
"symbol_info_tick_as_df",
|
|
28
|
+
"market_book_get_as_df",
|
|
29
|
+
"order_check_as_df",
|
|
30
|
+
"order_send_as_df",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_mock_mt5_data_client() -> MagicMock:
|
|
35
|
+
"""Return a MagicMock Mt5DataClient with common DataFrame stubs."""
|
|
36
|
+
client = MagicMock()
|
|
37
|
+
sample_df = pd.DataFrame({"col": [1]})
|
|
38
|
+
for method_name in _DATAFRAME_METHODS:
|
|
39
|
+
getattr(client, method_name).return_value = sample_df
|
|
40
|
+
client.version.return_value = (5, 0, 1)
|
|
41
|
+
client.terminal_info.return_value = {"connected": True, "paths": ["terminal.exe"]}
|
|
42
|
+
client.account_info.return_value = {"login": 123, "limits": {"modes": ["demo"]}}
|
|
43
|
+
client.symbols_total.return_value = 42
|
|
44
|
+
return client
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def mock_client(mocker: MockerFixture) -> MagicMock:
|
|
49
|
+
"""Create and patch a mock Mt5DataClient for CLI and SDK tests."""
|
|
50
|
+
client = build_mock_mt5_data_client()
|
|
51
|
+
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
|
52
|
+
return client
|
|
@@ -69,38 +69,6 @@ class TestExecuteExport:
|
|
|
69
69
|
# ---------------------------------------------------------------------------
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
@pytest.fixture
|
|
73
|
-
def mock_client(mocker: MockerFixture) -> MagicMock:
|
|
74
|
-
"""Create and patch a mock Mt5DataClient for CLI tests."""
|
|
75
|
-
client = MagicMock()
|
|
76
|
-
sample_df = pd.DataFrame({"col": [1]})
|
|
77
|
-
client.copy_rates_from_as_df.return_value = sample_df
|
|
78
|
-
client.copy_rates_from_pos_as_df.return_value = sample_df
|
|
79
|
-
client.copy_rates_range_as_df.return_value = sample_df
|
|
80
|
-
client.copy_ticks_from_as_df.return_value = sample_df
|
|
81
|
-
client.copy_ticks_range_as_df.return_value = sample_df
|
|
82
|
-
client.account_info_as_df.return_value = sample_df
|
|
83
|
-
client.terminal_info_as_df.return_value = sample_df
|
|
84
|
-
client.symbols_get_as_df.return_value = sample_df
|
|
85
|
-
client.symbol_info_as_df.return_value = sample_df
|
|
86
|
-
client.orders_get_as_df.return_value = sample_df
|
|
87
|
-
client.positions_get_as_df.return_value = sample_df
|
|
88
|
-
client.history_orders_get_as_df.return_value = sample_df
|
|
89
|
-
client.history_deals_get_as_df.return_value = sample_df
|
|
90
|
-
client.version_as_df.return_value = sample_df
|
|
91
|
-
client.last_error_as_df.return_value = sample_df
|
|
92
|
-
client.symbol_info_tick_as_df.return_value = sample_df
|
|
93
|
-
client.market_book_get_as_df.return_value = sample_df
|
|
94
|
-
client.order_check_as_df.return_value = sample_df
|
|
95
|
-
client.order_send_as_df.return_value = sample_df
|
|
96
|
-
client.version.return_value = (5, 0, 1)
|
|
97
|
-
client.terminal_info.return_value = {"connected": True, "paths": ["terminal.exe"]}
|
|
98
|
-
client.account_info.return_value = {"login": 123, "limits": {"modes": ["demo"]}}
|
|
99
|
-
client.symbols_total.return_value = 42
|
|
100
|
-
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
|
101
|
-
return client
|
|
102
|
-
|
|
103
|
-
|
|
104
72
|
class TestCommands:
|
|
105
73
|
"""Tests for all CLI subcommands via CliRunner."""
|
|
106
74
|
|
|
@@ -317,7 +285,7 @@ class TestCommands:
|
|
|
317
285
|
symbol="EURUSD",
|
|
318
286
|
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
|
319
287
|
count=100,
|
|
320
|
-
flags
|
|
288
|
+
flags=-1,
|
|
321
289
|
)
|
|
322
290
|
|
|
323
291
|
def test_ticks_range(
|
|
@@ -348,7 +316,7 @@ class TestCommands:
|
|
|
348
316
|
symbol="EURUSD",
|
|
349
317
|
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
|
350
318
|
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
|
351
|
-
flags=
|
|
319
|
+
flags=1,
|
|
352
320
|
)
|
|
353
321
|
|
|
354
322
|
def test_ticks_recent(
|
|
@@ -381,7 +349,7 @@ class TestCommands:
|
|
|
381
349
|
symbol="EURUSD",
|
|
382
350
|
date_from=datetime(2024, 1, 2, tzinfo=UTC) - timedelta(seconds=120),
|
|
383
351
|
count=500,
|
|
384
|
-
flags
|
|
352
|
+
flags=-1,
|
|
385
353
|
)
|
|
386
354
|
mock_client.copy_ticks_range_as_df.assert_not_called()
|
|
387
355
|
|
|
@@ -1000,7 +968,7 @@ class TestCollectHistory:
|
|
|
1000
968
|
symbol="EURUSD",
|
|
1001
969
|
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
|
1002
970
|
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
|
1003
|
-
flags
|
|
971
|
+
flags=-1,
|
|
1004
972
|
)
|
|
1005
973
|
with sqlite3.connect(output) as conn:
|
|
1006
974
|
tables = {
|
|
@@ -1213,7 +1181,7 @@ class TestCollectHistory:
|
|
|
1213
1181
|
symbol="EURUSD",
|
|
1214
1182
|
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
|
1215
1183
|
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
|
1216
|
-
flags
|
|
1184
|
+
flags=-1,
|
|
1217
1185
|
)
|
|
1218
1186
|
|
|
1219
1187
|
def test_collect_history_with_views(
|
|
@@ -517,6 +517,9 @@ class TestResolveHistorySettings:
|
|
|
517
517
|
"""Test default timeframes include all fixed MT5 values."""
|
|
518
518
|
resolved = resolve_history_timeframes(None)
|
|
519
519
|
assert len(resolved) == len(DEFAULT_HISTORY_TIMEFRAMES)
|
|
520
|
+
assert not any(
|
|
521
|
+
name.startswith("TIMEFRAME_") for name in DEFAULT_HISTORY_TIMEFRAMES
|
|
522
|
+
)
|
|
520
523
|
assert 1 in resolved
|
|
521
524
|
assert TIMEFRAME_MAP["H1"] in resolved
|
|
522
525
|
|
|
@@ -526,7 +529,7 @@ class TestResolveHistorySettings:
|
|
|
526
529
|
|
|
527
530
|
def test_resolve_history_tick_flags(self) -> None:
|
|
528
531
|
"""Test tick flag resolution."""
|
|
529
|
-
assert resolve_history_tick_flags("ALL") == 1
|
|
532
|
+
assert resolve_history_tick_flags("ALL") == -1
|
|
530
533
|
assert resolve_history_tick_flags(2) == 2
|
|
531
534
|
|
|
532
535
|
def test_resolve_granularity_name_falls_back_to_integer(self) -> None:
|
|
@@ -534,6 +537,17 @@ class TestResolveHistorySettings:
|
|
|
534
537
|
assert resolve_granularity_name(999) == "999"
|
|
535
538
|
assert resolve_granularity_name(1) == "M1"
|
|
536
539
|
|
|
540
|
+
def test_resolve_granularity_name_strips_official_prefix(
|
|
541
|
+
self,
|
|
542
|
+
mocker: MockerFixture,
|
|
543
|
+
) -> None:
|
|
544
|
+
"""Test official pdmt5 timeframe names are normalized to short aliases."""
|
|
545
|
+
mocker.patch(
|
|
546
|
+
"mt5cli.history._get_timeframe_name",
|
|
547
|
+
return_value="TIMEFRAME_H1",
|
|
548
|
+
)
|
|
549
|
+
assert resolve_granularity_name(16385) == "H1"
|
|
550
|
+
|
|
537
551
|
|
|
538
552
|
class TestDropFormingRateBar:
|
|
539
553
|
"""Tests for drop_forming_rate_bar."""
|
|
@@ -1754,6 +1768,8 @@ class TestIncrementalIntegration:
|
|
|
1754
1768
|
"""Test invalid tick flags raise ValueError."""
|
|
1755
1769
|
with pytest.raises(ValueError, match="Invalid tick flags"):
|
|
1756
1770
|
resolve_history_tick_flags("BAD")
|
|
1771
|
+
with pytest.raises(ValueError, match="Invalid tick flags"):
|
|
1772
|
+
resolve_history_tick_flags(7)
|
|
1757
1773
|
|
|
1758
1774
|
def test_resolve_history_timeframes_invalid(self) -> None:
|
|
1759
1775
|
"""Test invalid timeframes raise ValueError."""
|
|
@@ -132,32 +132,6 @@ _DEALS_FIXTURE: dict[str, list[object]] = {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
|
|
135
|
-
@pytest.fixture
|
|
136
|
-
def mock_client(mocker: MockerFixture) -> MagicMock:
|
|
137
|
-
"""Create and patch a mock Mt5DataClient for SDK tests."""
|
|
138
|
-
client = MagicMock()
|
|
139
|
-
sample_df = pd.DataFrame({"col": [1]})
|
|
140
|
-
client.copy_rates_from_as_df.return_value = sample_df
|
|
141
|
-
client.copy_rates_from_pos_as_df.return_value = sample_df
|
|
142
|
-
client.copy_rates_range_as_df.return_value = sample_df
|
|
143
|
-
client.copy_ticks_from_as_df.return_value = sample_df
|
|
144
|
-
client.copy_ticks_range_as_df.return_value = sample_df
|
|
145
|
-
client.account_info_as_df.return_value = sample_df
|
|
146
|
-
client.terminal_info_as_df.return_value = sample_df
|
|
147
|
-
client.symbols_get_as_df.return_value = sample_df
|
|
148
|
-
client.symbol_info_as_df.return_value = sample_df
|
|
149
|
-
client.orders_get_as_df.return_value = sample_df
|
|
150
|
-
client.positions_get_as_df.return_value = sample_df
|
|
151
|
-
client.history_orders_get_as_df.return_value = sample_df
|
|
152
|
-
client.history_deals_get_as_df.return_value = sample_df
|
|
153
|
-
client.version_as_df.return_value = sample_df
|
|
154
|
-
client.last_error_as_df.return_value = sample_df
|
|
155
|
-
client.symbol_info_tick_as_df.return_value = sample_df
|
|
156
|
-
client.market_book_get_as_df.return_value = sample_df
|
|
157
|
-
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
|
158
|
-
return client
|
|
159
|
-
|
|
160
|
-
|
|
161
135
|
def _build_history_client(mocker: MockerFixture) -> MagicMock:
|
|
162
136
|
"""Build a mocked Mt5DataClient with per-symbol history results."""
|
|
163
137
|
client = MagicMock()
|
|
@@ -390,7 +364,7 @@ class TestMt5CliClient:
|
|
|
390
364
|
symbol="EURUSD",
|
|
391
365
|
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
|
392
366
|
count=100,
|
|
393
|
-
flags=
|
|
367
|
+
flags=1,
|
|
394
368
|
)
|
|
395
369
|
|
|
396
370
|
def test_history_orders_accepts_string_dates(
|
|
@@ -951,7 +925,7 @@ class TestUpdateHistory:
|
|
|
951
925
|
assert kwargs["symbol"] == "EURUSD"
|
|
952
926
|
assert kwargs["date_from"] == expected_start
|
|
953
927
|
assert kwargs["date_to"] == date_to
|
|
954
|
-
assert kwargs["flags"] == 1
|
|
928
|
+
assert kwargs["flags"] == -1
|
|
955
929
|
return pd.DataFrame({
|
|
956
930
|
"time": ["2024-01-01T12:00:00+00:00"],
|
|
957
931
|
"time_msc": [1_704_110_400_000],
|
|
@@ -1119,7 +1093,7 @@ class TestRecentTicks:
|
|
|
1119
1093
|
symbol="EURUSD",
|
|
1120
1094
|
date_from=end - timedelta(seconds=60),
|
|
1121
1095
|
count=100,
|
|
1122
|
-
flags=
|
|
1096
|
+
flags=1,
|
|
1123
1097
|
)
|
|
1124
1098
|
client.copy_ticks_range_as_df.assert_not_called()
|
|
1125
1099
|
|
|
@@ -1149,7 +1123,7 @@ class TestRecentTicks:
|
|
|
1149
1123
|
assert kwargs["symbol"] == "EURUSD"
|
|
1150
1124
|
assert kwargs["date_to"] == tick.time
|
|
1151
1125
|
assert kwargs["date_from"] == tick.time - timedelta(seconds=30)
|
|
1152
|
-
assert kwargs["flags"] == 1
|
|
1126
|
+
assert kwargs["flags"] == -1
|
|
1153
1127
|
|
|
1154
1128
|
def test_recent_ticks_rejects_unsupported_tick_time(
|
|
1155
1129
|
self,
|
|
@@ -1219,7 +1193,7 @@ class TestRecentTicks:
|
|
|
1219
1193
|
symbol="EURUSD",
|
|
1220
1194
|
date_from=end - timedelta(seconds=60),
|
|
1221
1195
|
date_to=end,
|
|
1222
|
-
flags
|
|
1196
|
+
flags=-1,
|
|
1223
1197
|
)
|
|
1224
1198
|
|
|
1225
1199
|
|
|
@@ -274,8 +274,14 @@ class TestParseTimeframe:
|
|
|
274
274
|
assert parse_timeframe(value) == expected
|
|
275
275
|
|
|
276
276
|
def test_integer_timeframe(self) -> None:
|
|
277
|
-
"""Test parsing integer
|
|
278
|
-
assert parse_timeframe("
|
|
277
|
+
"""Test parsing supported integer timeframes."""
|
|
278
|
+
assert parse_timeframe("1") == 1
|
|
279
|
+
assert parse_timeframe(16385) == 16385
|
|
280
|
+
|
|
281
|
+
def test_unsupported_integer_timeframe_raises(self) -> None:
|
|
282
|
+
"""Test that unsupported integer timeframes raise ValueError."""
|
|
283
|
+
with pytest.raises(ValueError, match="Invalid timeframe"):
|
|
284
|
+
parse_timeframe("42")
|
|
279
285
|
|
|
280
286
|
def test_invalid_timeframe_raises(self) -> None:
|
|
281
287
|
"""Test that invalid timeframe raises ValueError."""
|
|
@@ -288,15 +294,21 @@ class TestParseTickFlags:
|
|
|
288
294
|
|
|
289
295
|
@pytest.mark.parametrize(
|
|
290
296
|
("value", "expected"),
|
|
291
|
-
[("ALL", 1), ("info",
|
|
297
|
+
[("ALL", -1), ("info", 1), ("TRADE", 2), ("COPY_TICKS_ALL", -1)],
|
|
292
298
|
)
|
|
293
299
|
def test_named_flag(self, value: str, expected: int) -> None:
|
|
294
300
|
"""Test parsing named tick flags."""
|
|
295
301
|
assert parse_tick_flags(value) == expected
|
|
296
302
|
|
|
297
303
|
def test_integer_flag(self) -> None:
|
|
298
|
-
"""Test parsing integer tick
|
|
299
|
-
assert parse_tick_flags("
|
|
304
|
+
"""Test parsing supported integer tick flags."""
|
|
305
|
+
assert parse_tick_flags("-1") == -1
|
|
306
|
+
assert parse_tick_flags(2) == 2
|
|
307
|
+
|
|
308
|
+
def test_unsupported_integer_flag_raises(self) -> None:
|
|
309
|
+
"""Test that unsupported integer tick flags raise ValueError."""
|
|
310
|
+
with pytest.raises(ValueError, match="Invalid tick flags"):
|
|
311
|
+
parse_tick_flags("7")
|
|
300
312
|
|
|
301
313
|
def test_invalid_flag_raises(self) -> None:
|
|
302
314
|
"""Test that invalid flag raises ValueError."""
|
|
@@ -355,8 +367,11 @@ class TestConstants:
|
|
|
355
367
|
assert key in TIMEFRAME_MAP
|
|
356
368
|
|
|
357
369
|
def test_tick_flag_map_has_expected_keys(self) -> None:
|
|
358
|
-
"""Test that TICK_FLAG_MAP contains standard flags."""
|
|
359
|
-
assert
|
|
370
|
+
"""Test that TICK_FLAG_MAP contains standard flags with MT5 values."""
|
|
371
|
+
assert {"ALL", "INFO", "TRADE"} <= set(TICK_FLAG_MAP)
|
|
372
|
+
assert TICK_FLAG_MAP["ALL"] == -1
|
|
373
|
+
assert TICK_FLAG_MAP["INFO"] == 1
|
|
374
|
+
assert TICK_FLAG_MAP["TRADE"] == 2
|
|
360
375
|
|
|
361
376
|
@pytest.mark.parametrize(
|
|
362
377
|
("dataset", "expected"),
|
|
@@ -403,26 +418,48 @@ class TestTimeframeType:
|
|
|
403
418
|
"""Test converting a string to timeframe integer."""
|
|
404
419
|
assert TIMEFRAME_TYPE.convert("H1", None, None) == 16385
|
|
405
420
|
|
|
406
|
-
def
|
|
407
|
-
"""Test
|
|
408
|
-
assert TIMEFRAME_TYPE.convert(
|
|
421
|
+
def test_convert_int(self) -> None:
|
|
422
|
+
"""Test converting supported integer timeframe values."""
|
|
423
|
+
assert TIMEFRAME_TYPE.convert(16385, None, None) == 16385
|
|
424
|
+
|
|
425
|
+
def test_convert_unsupported_int(self) -> None:
|
|
426
|
+
"""Test that unsupported integer values raise BadParameter."""
|
|
427
|
+
with pytest.raises(Exception, match="Invalid timeframe"):
|
|
428
|
+
TIMEFRAME_TYPE.convert(42, None, None)
|
|
409
429
|
|
|
410
430
|
def test_convert_invalid(self) -> None:
|
|
411
431
|
"""Test that invalid values raise BadParameter."""
|
|
412
432
|
with pytest.raises(Exception, match="Invalid timeframe"):
|
|
413
433
|
TIMEFRAME_TYPE.convert("bad", None, None)
|
|
414
434
|
|
|
435
|
+
@pytest.mark.parametrize("value", [True, False, None, 1.5])
|
|
436
|
+
def test_convert_invalid_types(self, value: object) -> None:
|
|
437
|
+
"""Test that bool, float, and None values raise BadParameter."""
|
|
438
|
+
with pytest.raises(Exception, match="Invalid timeframe"):
|
|
439
|
+
TIMEFRAME_TYPE.convert(value, None, None)
|
|
440
|
+
|
|
415
441
|
|
|
416
442
|
class TestTickFlagsType:
|
|
417
443
|
"""Tests for _TickFlagsType."""
|
|
418
444
|
|
|
419
445
|
def test_convert_string(self) -> None:
|
|
420
446
|
"""Test converting a string to tick flags integer."""
|
|
421
|
-
assert TICK_FLAGS_TYPE.convert("ALL", None, None) == 1
|
|
447
|
+
assert TICK_FLAGS_TYPE.convert("ALL", None, None) == -1
|
|
448
|
+
|
|
449
|
+
def test_convert_int(self) -> None:
|
|
450
|
+
"""Test converting supported integer tick flag values."""
|
|
451
|
+
assert TICK_FLAGS_TYPE.convert(2, None, None) == 2
|
|
422
452
|
|
|
423
|
-
def
|
|
424
|
-
"""Test that integer values
|
|
425
|
-
|
|
453
|
+
def test_convert_unsupported_int(self) -> None:
|
|
454
|
+
"""Test that unsupported integer values raise BadParameter."""
|
|
455
|
+
with pytest.raises(Exception, match="Invalid tick flags"):
|
|
456
|
+
TICK_FLAGS_TYPE.convert(7, None, None)
|
|
457
|
+
|
|
458
|
+
@pytest.mark.parametrize("value", [True, False, None, 1.5])
|
|
459
|
+
def test_convert_invalid_types(self, value: object) -> None:
|
|
460
|
+
"""Test that bool, float, and None values raise BadParameter."""
|
|
461
|
+
with pytest.raises(Exception, match="Invalid tick flags"):
|
|
462
|
+
TICK_FLAGS_TYPE.convert(value, None, None)
|
|
426
463
|
|
|
427
464
|
def test_convert_invalid(self) -> None:
|
|
428
465
|
"""Test that invalid values raise BadParameter."""
|
|
@@ -487,7 +487,7 @@ wheels = [
|
|
|
487
487
|
|
|
488
488
|
[[package]]
|
|
489
489
|
name = "mt5cli"
|
|
490
|
-
version = "0.
|
|
490
|
+
version = "0.7.1"
|
|
491
491
|
source = { editable = "." }
|
|
492
492
|
dependencies = [
|
|
493
493
|
{ name = "click" },
|
|
@@ -513,7 +513,7 @@ dev = [
|
|
|
513
513
|
[package.metadata]
|
|
514
514
|
requires-dist = [
|
|
515
515
|
{ name = "click", specifier = ">=8.1.0" },
|
|
516
|
-
{ name = "pdmt5", specifier = ">=0.
|
|
516
|
+
{ name = "pdmt5", specifier = ">=0.3.0" },
|
|
517
517
|
{ name = "pyarrow", specifier = ">=19.0.0" },
|
|
518
518
|
{ name = "typer", specifier = ">=0.15.0" },
|
|
519
519
|
]
|
|
@@ -684,16 +684,16 @@ wheels = [
|
|
|
684
684
|
|
|
685
685
|
[[package]]
|
|
686
686
|
name = "pdmt5"
|
|
687
|
-
version = "0.
|
|
687
|
+
version = "0.3.0"
|
|
688
688
|
source = { registry = "https://pypi.org/simple" }
|
|
689
689
|
dependencies = [
|
|
690
690
|
{ name = "metatrader5", marker = "sys_platform == 'win32'" },
|
|
691
691
|
{ name = "pandas" },
|
|
692
692
|
{ name = "pydantic" },
|
|
693
693
|
]
|
|
694
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
694
|
+
sdist = { url = "https://files.pythonhosted.org/packages/bf/cc/c8fa3a01e0e34178fec8527992f7bb8eda5881477ce23aaacaa9b2ef7bec/pdmt5-0.3.0.tar.gz", hash = "sha256:bb612d5c2695eafac9b2a7b74756e13bd383d7e5517bd90c9a2efa92492c484c", size = 215100, upload-time = "2026-06-11T13:26:46.976Z" }
|
|
695
695
|
wheels = [
|
|
696
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
696
|
+
{ url = "https://files.pythonhosted.org/packages/f2/03/b12cc4c9db983d971c9172b3765161b6d91136d0624e6718a04dd815e7a1/pdmt5-0.3.0-py3-none-any.whl", hash = "sha256:5388b406cc583202600cfe22c9d781679b1d931b1ed5a2b5dcf37c566149b49f", size = 26250, upload-time = "2026-06-11T13:26:45.689Z" },
|
|
697
697
|
]
|
|
698
698
|
|
|
699
699
|
[[package]]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|