Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9356d5dcdf | |||
| 0fad55d609 |
@@ -6,6 +6,12 @@ Command-line tool for exporting MetaTrader 5 data to CSV, JSON, Parquet, and SQL
|
|||||||
|
|
||||||
Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5.
|
Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **pdmt5** — canonical MT5 client, DataFrame/trading primitives, and MT5 constant parsing (`TIMEFRAME_*`, `COPY_TICKS_*`, order types).
|
||||||
|
- **mt5cli** — CLI commands, CSV/JSON/Parquet/SQLite export, SQLite history collection, rate views, and local batch/automation SDK helpers built on pdmt5.
|
||||||
|
- **mt5api** — sibling HTTP adapter for remote MT5 access; not a dependency of mt5cli.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Multi-format export**: CSV, JSON, Parquet, and SQLite3 output formats
|
- **Multi-format export**: CSV, JSON, Parquet, and SQLite3 output formats
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ Command-line tool for MetaTrader 5 data export.
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **pdmt5** — canonical MT5 client, DataFrame/trading primitives, and MT5 constant parsing (`TIMEFRAME_*`, `COPY_TICKS_*`, order types).
|
||||||
|
- **mt5cli** — CLI commands, CSV/JSON/Parquet/SQLite export, SQLite history collection, rate views, and local batch/automation SDK helpers built on pdmt5.
|
||||||
|
- **mt5api** — sibling HTTP adapter for remote MT5 access; not a dependency of mt5cli.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Multi-format export**: CSV, JSON, Parquet, and SQLite3 output formats
|
- **Multi-format export**: CSV, JSON, Parquet, and SQLite3 output formats
|
||||||
|
|||||||
+55
-54
@@ -96,6 +96,15 @@ def _sdk_client(ctx: typer.Context) -> sdk.Mt5CliClient:
|
|||||||
return sdk.Mt5CliClient(config=export_ctx.config)
|
return sdk.Mt5CliClient(config=export_ctx.config)
|
||||||
|
|
||||||
|
|
||||||
|
def _export_command(
|
||||||
|
ctx: typer.Context,
|
||||||
|
fetch_fn: Callable[[sdk.Mt5CliClient], pd.DataFrame],
|
||||||
|
) -> None:
|
||||||
|
"""Create an SDK client, fetch a DataFrame, and export it."""
|
||||||
|
client = _sdk_client(ctx)
|
||||||
|
_execute_export(ctx, lambda: fetch_fn(client))
|
||||||
|
|
||||||
|
|
||||||
@app.callback()
|
@app.callback()
|
||||||
def _callback( # pyright: ignore[reportUnusedFunction]
|
def _callback( # pyright: ignore[reportUnusedFunction]
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
@@ -193,10 +202,9 @@ def rates_from(
|
|||||||
count: Annotated[int, typer.Option(help="Number of records.")],
|
count: Annotated[int, typer.Option(help="Number of records.")],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export rates from a start date."""
|
"""Export rates from a start date."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(
|
||||||
_execute_export(
|
|
||||||
ctx,
|
ctx,
|
||||||
lambda: client.copy_rates_from(symbol, timeframe, date_from, count),
|
lambda client: client.copy_rates_from(symbol, timeframe, date_from, count),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -215,10 +223,14 @@ def rates_from_pos(
|
|||||||
count: Annotated[int, typer.Option(help="Number of records.")],
|
count: Annotated[int, typer.Option(help="Number of records.")],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export rates from a start position."""
|
"""Export rates from a start position."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(
|
||||||
_execute_export(
|
|
||||||
ctx,
|
ctx,
|
||||||
lambda: client.copy_rates_from_pos(symbol, timeframe, start_pos, count),
|
lambda client: client.copy_rates_from_pos(
|
||||||
|
symbol,
|
||||||
|
timeframe,
|
||||||
|
start_pos,
|
||||||
|
count,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -240,10 +252,14 @@ def latest_rates(
|
|||||||
] = 0,
|
] = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export latest rates from a start position."""
|
"""Export latest rates from a start position."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(
|
||||||
_execute_export(
|
|
||||||
ctx,
|
ctx,
|
||||||
lambda: client.latest_rates(symbol, timeframe, count, start_pos=start_pos),
|
lambda client: client.latest_rates(
|
||||||
|
symbol,
|
||||||
|
timeframe,
|
||||||
|
count,
|
||||||
|
start_pos=start_pos,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -268,10 +284,9 @@ def rates_range(
|
|||||||
],
|
],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export rates for a date range."""
|
"""Export rates for a date range."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(
|
||||||
_execute_export(
|
|
||||||
ctx,
|
ctx,
|
||||||
lambda: client.copy_rates_range(symbol, timeframe, date_from, date_to),
|
lambda client: client.copy_rates_range(symbol, timeframe, date_from, date_to),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -293,10 +308,9 @@ def ticks_from(
|
|||||||
],
|
],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export ticks from a start date."""
|
"""Export ticks from a start date."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(
|
||||||
_execute_export(
|
|
||||||
ctx,
|
ctx,
|
||||||
lambda: client.copy_ticks_from(symbol, date_from, count, flags),
|
lambda client: client.copy_ticks_from(symbol, date_from, count, flags),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -318,10 +332,9 @@ def ticks_range(
|
|||||||
],
|
],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export ticks for a date range."""
|
"""Export ticks for a date range."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(
|
||||||
_execute_export(
|
|
||||||
ctx,
|
ctx,
|
||||||
lambda: client.copy_ticks_range(symbol, date_from, date_to, flags),
|
lambda client: client.copy_ticks_range(symbol, date_from, date_to, flags),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -347,13 +360,12 @@ def ticks_recent(
|
|||||||
click_type=TICK_FLAGS_TYPE,
|
click_type=TICK_FLAGS_TYPE,
|
||||||
help="Tick flags (ALL, INFO, TRADE, or integer).",
|
help="Tick flags (ALL, INFO, TRADE, or integer).",
|
||||||
),
|
),
|
||||||
] = 1,
|
] = "ALL", # pyright: ignore[reportArgumentType]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export ticks from a recent time window."""
|
"""Export ticks from a recent time window."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(
|
||||||
_execute_export(
|
|
||||||
ctx,
|
ctx,
|
||||||
lambda: client.recent_ticks(
|
lambda client: client.recent_ticks(
|
||||||
symbol,
|
symbol,
|
||||||
seconds,
|
seconds,
|
||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
@@ -366,13 +378,13 @@ def ticks_recent(
|
|||||||
@app.command()
|
@app.command()
|
||||||
def account_info(ctx: typer.Context) -> None:
|
def account_info(ctx: typer.Context) -> None:
|
||||||
"""Export account information."""
|
"""Export account information."""
|
||||||
_execute_export(ctx, _sdk_client(ctx).account_info)
|
_export_command(ctx, lambda client: client.account_info())
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def terminal_info(ctx: typer.Context) -> None:
|
def terminal_info(ctx: typer.Context) -> None:
|
||||||
"""Export terminal information."""
|
"""Export terminal information."""
|
||||||
_execute_export(ctx, _sdk_client(ctx).terminal_info)
|
_export_command(ctx, lambda client: client.terminal_info())
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -384,8 +396,7 @@ def symbols(
|
|||||||
] = None,
|
] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export symbol list."""
|
"""Export symbol list."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(ctx, lambda client: client.symbols(group=group))
|
||||||
_execute_export(ctx, lambda: client.symbols(group=group))
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -394,8 +405,7 @@ def symbol_info(
|
|||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export symbol details."""
|
"""Export symbol details."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(ctx, lambda client: client.symbol_info(symbol))
|
||||||
_execute_export(ctx, lambda: client.symbol_info(symbol))
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -404,8 +414,7 @@ def minimum_margins(
|
|||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export minimum-volume buy and sell margin requirements."""
|
"""Export minimum-volume buy and sell margin requirements."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(ctx, lambda client: client.minimum_margins(symbol))
|
||||||
_execute_export(ctx, lambda: client.minimum_margins(symbol))
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -416,10 +425,9 @@ def orders(
|
|||||||
ticket: Annotated[int | None, typer.Option(help="Ticket filter.")] = None,
|
ticket: Annotated[int | None, typer.Option(help="Ticket filter.")] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export active orders."""
|
"""Export active orders."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(
|
||||||
_execute_export(
|
|
||||||
ctx,
|
ctx,
|
||||||
lambda: client.orders(symbol=symbol, group=group, ticket=ticket),
|
lambda client: client.orders(symbol=symbol, group=group, ticket=ticket),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -431,10 +439,9 @@ def positions(
|
|||||||
ticket: Annotated[int | None, typer.Option(help="Ticket filter.")] = None,
|
ticket: Annotated[int | None, typer.Option(help="Ticket filter.")] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export open positions."""
|
"""Export open positions."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(
|
||||||
_execute_export(
|
|
||||||
ctx,
|
ctx,
|
||||||
lambda: client.positions(symbol=symbol, group=group, ticket=ticket),
|
lambda client: client.positions(symbol=symbol, group=group, ticket=ticket),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -455,10 +462,9 @@ def history_orders(
|
|||||||
position: Annotated[int | None, typer.Option(help="Position ticket.")] = None,
|
position: Annotated[int | None, typer.Option(help="Position ticket.")] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export historical orders."""
|
"""Export historical orders."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(
|
||||||
_execute_export(
|
|
||||||
ctx,
|
ctx,
|
||||||
lambda: client.history_orders(
|
lambda client: client.history_orders(
|
||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
group=group,
|
group=group,
|
||||||
@@ -486,10 +492,9 @@ def history_deals(
|
|||||||
position: Annotated[int | None, typer.Option(help="Position ticket.")] = None,
|
position: Annotated[int | None, typer.Option(help="Position ticket.")] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export historical deals."""
|
"""Export historical deals."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(
|
||||||
_execute_export(
|
|
||||||
ctx,
|
ctx,
|
||||||
lambda: client.history_deals(
|
lambda client: client.history_deals(
|
||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
group=group,
|
group=group,
|
||||||
@@ -512,10 +517,9 @@ def recent_history_deals(
|
|||||||
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export historical deals from a recent trailing window."""
|
"""Export historical deals from a recent trailing window."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(
|
||||||
_execute_export(
|
|
||||||
ctx,
|
ctx,
|
||||||
lambda: client.recent_history_deals(
|
lambda client: client.recent_history_deals(
|
||||||
hours,
|
hours,
|
||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
group=group,
|
group=group,
|
||||||
@@ -527,20 +531,19 @@ def recent_history_deals(
|
|||||||
@app.command()
|
@app.command()
|
||||||
def mt5_summary(ctx: typer.Context) -> None:
|
def mt5_summary(ctx: typer.Context) -> None:
|
||||||
"""Export a compact terminal/account status summary."""
|
"""Export a compact terminal/account status summary."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(ctx, lambda client: client.mt5_summary_as_df())
|
||||||
_execute_export(ctx, client.mt5_summary_as_df)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def version(ctx: typer.Context) -> None:
|
def version(ctx: typer.Context) -> None:
|
||||||
"""Export MetaTrader5 version information."""
|
"""Export MetaTrader5 version information."""
|
||||||
_execute_export(ctx, _sdk_client(ctx).version)
|
_export_command(ctx, lambda client: client.version())
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def last_error(ctx: typer.Context) -> None:
|
def last_error(ctx: typer.Context) -> None:
|
||||||
"""Export the last error information."""
|
"""Export the last error information."""
|
||||||
_execute_export(ctx, _sdk_client(ctx).last_error)
|
_export_command(ctx, lambda client: client.last_error())
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -549,8 +552,7 @@ def symbol_info_tick(
|
|||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export the last tick for a symbol."""
|
"""Export the last tick for a symbol."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(ctx, lambda client: client.symbol_info_tick(symbol))
|
||||||
_execute_export(ctx, lambda: client.symbol_info_tick(symbol))
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -559,8 +561,7 @@ def market_book(
|
|||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export market depth (order book) for a symbol."""
|
"""Export market depth (order book) for a symbol."""
|
||||||
client = _sdk_client(ctx)
|
_export_command(ctx, lambda client: client.market_book(symbol))
|
||||||
_execute_export(ctx, lambda: client.market_book(symbol))
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -656,7 +657,7 @@ def collect_history(
|
|||||||
click_type=TICK_FLAGS_TYPE,
|
click_type=TICK_FLAGS_TYPE,
|
||||||
help="Tick copy flags (ALL, INFO, TRADE, or integer).",
|
help="Tick copy flags (ALL, INFO, TRADE, or integer).",
|
||||||
),
|
),
|
||||||
] = 1,
|
] = "ALL", # pyright: ignore[reportArgumentType]
|
||||||
if_exists: Annotated[
|
if_exists: Annotated[
|
||||||
IfExists,
|
IfExists,
|
||||||
typer.Option(
|
typer.Option(
|
||||||
|
|||||||
+102
-59
@@ -10,9 +10,10 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Literal, cast
|
from typing import TYPE_CHECKING, Literal, cast
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
from pdmt5 import get_timeframe_name as _get_timeframe_name
|
||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
TIMEFRAME_MAP,
|
TIMEFRAME_NAMES,
|
||||||
Dataset,
|
Dataset,
|
||||||
IfExists,
|
IfExists,
|
||||||
parse_datetime,
|
parse_datetime,
|
||||||
@@ -27,7 +28,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_HISTORY_TIMEFRAMES: tuple[str, ...] = tuple(TIMEFRAME_MAP)
|
DEFAULT_HISTORY_TIMEFRAMES: tuple[str, ...] = TIMEFRAME_NAMES
|
||||||
|
|
||||||
_HISTORY_DEDUP_KEYS: dict[Dataset, tuple[tuple[str, ...], ...]] = {
|
_HISTORY_DEDUP_KEYS: dict[Dataset, tuple[tuple[str, ...], ...]] = {
|
||||||
Dataset.rates: (("symbol", "timeframe", "time"), ("symbol", "time")),
|
Dataset.rates: (("symbol", "timeframe", "time"), ("symbol", "time")),
|
||||||
@@ -80,7 +81,7 @@ def resolve_history_timeframes(
|
|||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
resolved: list[int] = []
|
resolved: list[int] = []
|
||||||
for value in raw:
|
for value in raw:
|
||||||
tf = value if isinstance(value, int) else parse_timeframe(str(value))
|
tf = parse_timeframe(value)
|
||||||
if tf not in seen:
|
if tf not in seen:
|
||||||
seen.add(tf)
|
seen.add(tf)
|
||||||
resolved.append(tf)
|
resolved.append(tf)
|
||||||
@@ -93,17 +94,16 @@ def resolve_history_tick_flags(flags: int | str) -> int:
|
|||||||
Returns:
|
Returns:
|
||||||
Integer tick flag value.
|
Integer tick flag value.
|
||||||
"""
|
"""
|
||||||
if isinstance(flags, int):
|
|
||||||
return flags
|
|
||||||
return parse_tick_flags(flags)
|
return parse_tick_flags(flags)
|
||||||
|
|
||||||
|
|
||||||
def resolve_granularity_name(timeframe: int) -> str:
|
def resolve_granularity_name(timeframe: int) -> str:
|
||||||
"""Return a granularity name for a timeframe integer when known."""
|
"""Return a granularity name for a timeframe integer when known."""
|
||||||
for name, value in TIMEFRAME_MAP.items():
|
try:
|
||||||
if value == timeframe:
|
name = _get_timeframe_name(timeframe)
|
||||||
return name
|
except ValueError:
|
||||||
return str(timeframe)
|
return str(timeframe)
|
||||||
|
return name.removeprefix("TIMEFRAME_")
|
||||||
|
|
||||||
|
|
||||||
def drop_forming_rate_bar(df_rate: pd.DataFrame) -> pd.DataFrame:
|
def drop_forming_rate_bar(df_rate: pd.DataFrame) -> pd.DataFrame:
|
||||||
@@ -1332,6 +1332,50 @@ def create_rate_compatibility_views(conn: sqlite3.Connection) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_symbol_frames(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
symbols: Sequence[str],
|
||||||
|
dataset: Dataset,
|
||||||
|
if_exists: IfExists,
|
||||||
|
written_columns: dict[Dataset, set[str]],
|
||||||
|
fetch_frame: Callable[[str], pd.DataFrame],
|
||||||
|
) -> bool:
|
||||||
|
"""Stream per-symbol frames into SQLite.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the dataset table was written.
|
||||||
|
"""
|
||||||
|
table_exists = False
|
||||||
|
for sym in symbols:
|
||||||
|
table_exists = write_streamed_frame(
|
||||||
|
conn,
|
||||||
|
fetch_frame(sym),
|
||||||
|
dataset,
|
||||||
|
table_exists,
|
||||||
|
if_exists,
|
||||||
|
written_columns,
|
||||||
|
)
|
||||||
|
return table_exists
|
||||||
|
|
||||||
|
|
||||||
|
def _record_symbol_time_dedup(
|
||||||
|
dedup_scopes: dict[Dataset, list[DedupScope]],
|
||||||
|
written_tables: set[Dataset],
|
||||||
|
dataset: Dataset,
|
||||||
|
symbol: str,
|
||||||
|
start_date: datetime,
|
||||||
|
) -> None:
|
||||||
|
"""Record a symbol-scoped deduplication window after an incremental write."""
|
||||||
|
written_tables.add(dataset)
|
||||||
|
_record_dedup_scope(
|
||||||
|
dedup_scopes,
|
||||||
|
dataset,
|
||||||
|
"symbol = ? AND time >= ?",
|
||||||
|
(symbol, start_date),
|
||||||
|
frozenset({"symbol", "time"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def write_rates_dataset(
|
def write_rates_dataset(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
client: Mt5DataClient,
|
client: Mt5DataClient,
|
||||||
@@ -1347,8 +1391,8 @@ def write_rates_dataset(
|
|||||||
Returns:
|
Returns:
|
||||||
True if the rates table was written.
|
True if the rates table was written.
|
||||||
"""
|
"""
|
||||||
table_exists = False
|
|
||||||
for sym in symbols:
|
def _fetch_rates_frame(sym: str) -> pd.DataFrame:
|
||||||
frame = client.copy_rates_range_as_df(
|
frame = client.copy_rates_range_as_df(
|
||||||
symbol=sym,
|
symbol=sym,
|
||||||
timeframe=timeframe,
|
timeframe=timeframe,
|
||||||
@@ -1358,15 +1402,16 @@ def write_rates_dataset(
|
|||||||
if len(frame.columns) != 0:
|
if len(frame.columns) != 0:
|
||||||
frame.insert(0, "symbol", sym)
|
frame.insert(0, "symbol", sym)
|
||||||
frame.insert(1, "timeframe", timeframe)
|
frame.insert(1, "timeframe", timeframe)
|
||||||
table_exists = write_streamed_frame(
|
return frame
|
||||||
conn,
|
|
||||||
frame,
|
return _stream_symbol_frames(
|
||||||
Dataset.rates,
|
conn,
|
||||||
table_exists,
|
symbols,
|
||||||
if_exists,
|
Dataset.rates,
|
||||||
written_columns,
|
if_exists,
|
||||||
)
|
written_columns,
|
||||||
return table_exists
|
_fetch_rates_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def write_ticks_dataset(
|
def write_ticks_dataset(
|
||||||
@@ -1384,8 +1429,8 @@ def write_ticks_dataset(
|
|||||||
Returns:
|
Returns:
|
||||||
True if the ticks table was written.
|
True if the ticks table was written.
|
||||||
"""
|
"""
|
||||||
table_exists = False
|
|
||||||
for sym in symbols:
|
def _fetch_ticks_frame(sym: str) -> pd.DataFrame:
|
||||||
frame = client.copy_ticks_range_as_df(
|
frame = client.copy_ticks_range_as_df(
|
||||||
symbol=sym,
|
symbol=sym,
|
||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
@@ -1394,15 +1439,16 @@ def write_ticks_dataset(
|
|||||||
).drop(columns=["symbol"], errors="ignore")
|
).drop(columns=["symbol"], errors="ignore")
|
||||||
if len(frame.columns) != 0:
|
if len(frame.columns) != 0:
|
||||||
frame.insert(0, "symbol", sym)
|
frame.insert(0, "symbol", sym)
|
||||||
table_exists = write_streamed_frame(
|
return frame
|
||||||
conn,
|
|
||||||
frame,
|
return _stream_symbol_frames(
|
||||||
Dataset.ticks,
|
conn,
|
||||||
table_exists,
|
symbols,
|
||||||
if_exists,
|
Dataset.ticks,
|
||||||
written_columns,
|
if_exists,
|
||||||
)
|
written_columns,
|
||||||
return table_exists
|
_fetch_ticks_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def write_history_dataset(
|
def write_history_dataset(
|
||||||
@@ -1437,22 +1483,22 @@ def write_history_dataset(
|
|||||||
if_exists,
|
if_exists,
|
||||||
written_columns,
|
written_columns,
|
||||||
)
|
)
|
||||||
for sym in symbols:
|
|
||||||
frame = fetch(date_from=date_from, date_to=date_to, symbol=sym)
|
def _fetch_history_frame(sym: str) -> pd.DataFrame:
|
||||||
frame = filter_trade_history_frame(
|
return filter_trade_history_frame(
|
||||||
frame,
|
fetch(date_from=date_from, date_to=date_to, symbol=sym),
|
||||||
[sym],
|
[sym],
|
||||||
include_account_events=False,
|
include_account_events=False,
|
||||||
)
|
)
|
||||||
table_exists = write_streamed_frame(
|
|
||||||
conn,
|
return _stream_symbol_frames(
|
||||||
frame,
|
conn,
|
||||||
dataset,
|
symbols,
|
||||||
table_exists,
|
dataset,
|
||||||
if_exists,
|
if_exists,
|
||||||
written_columns,
|
written_columns,
|
||||||
)
|
_fetch_history_frame,
|
||||||
return table_exists
|
)
|
||||||
|
|
||||||
|
|
||||||
def _write_incremental_rates(
|
def _write_incremental_rates(
|
||||||
@@ -1525,13 +1571,12 @@ def _write_incremental_ticks(
|
|||||||
IfExists.APPEND,
|
IfExists.APPEND,
|
||||||
written_columns,
|
written_columns,
|
||||||
):
|
):
|
||||||
written_tables.add(Dataset.ticks)
|
_record_symbol_time_dedup(
|
||||||
_record_dedup_scope(
|
|
||||||
dedup_scopes,
|
dedup_scopes,
|
||||||
|
written_tables,
|
||||||
Dataset.ticks,
|
Dataset.ticks,
|
||||||
"symbol = ? AND time >= ?",
|
symbol,
|
||||||
(symbol, start_date),
|
start_date,
|
||||||
frozenset({"symbol", "time"}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1564,13 +1609,12 @@ def _write_incremental_history_orders(
|
|||||||
written_columns,
|
written_columns,
|
||||||
include_account_events=False,
|
include_account_events=False,
|
||||||
):
|
):
|
||||||
written_tables.add(Dataset.history_orders)
|
_record_symbol_time_dedup(
|
||||||
_record_dedup_scope(
|
|
||||||
dedup_scopes,
|
dedup_scopes,
|
||||||
|
written_tables,
|
||||||
Dataset.history_orders,
|
Dataset.history_orders,
|
||||||
"symbol = ? AND time >= ?",
|
symbol,
|
||||||
(symbol, start_date),
|
start_date,
|
||||||
frozenset({"symbol", "time"}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1662,13 +1706,12 @@ def _write_incremental_history_deals(
|
|||||||
written_columns,
|
written_columns,
|
||||||
include_account_events=False,
|
include_account_events=False,
|
||||||
):
|
):
|
||||||
written_tables.add(Dataset.history_deals)
|
_record_symbol_time_dedup(
|
||||||
_record_dedup_scope(
|
|
||||||
dedup_scopes,
|
dedup_scopes,
|
||||||
|
written_tables,
|
||||||
Dataset.history_deals,
|
Dataset.history_deals,
|
||||||
"symbol = ? AND time >= ?",
|
symbol,
|
||||||
(symbol, start_date),
|
start_date,
|
||||||
frozenset({"symbol", "time"}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-5
@@ -64,6 +64,9 @@ _MT5_HISTORY_CLIENT_CALL_FUNCTIONS: frozenset[str] = frozenset({
|
|||||||
"write_ticks_dataset",
|
"write_ticks_dataset",
|
||||||
"write_history_dataset",
|
"write_history_dataset",
|
||||||
"_write_incremental_history_deals",
|
"_write_incremental_history_deals",
|
||||||
|
"_fetch_rates_frame",
|
||||||
|
"_fetch_ticks_frame",
|
||||||
|
"_fetch_history_frame",
|
||||||
})
|
})
|
||||||
_NON_CALLABLE_TYPE_ERROR = re.compile(r"^'[^']+' object is not callable$")
|
_NON_CALLABLE_TYPE_ERROR = re.compile(r"^'[^']+' object is not callable$")
|
||||||
|
|
||||||
@@ -144,14 +147,10 @@ __all__ = [
|
|||||||
|
|
||||||
|
|
||||||
def _coerce_timeframe(timeframe: int | str) -> int:
|
def _coerce_timeframe(timeframe: int | str) -> int:
|
||||||
if isinstance(timeframe, int):
|
|
||||||
return timeframe
|
|
||||||
return parse_timeframe(timeframe)
|
return parse_timeframe(timeframe)
|
||||||
|
|
||||||
|
|
||||||
def _coerce_tick_flags(flags: int | str) -> int:
|
def _coerce_tick_flags(flags: int | str) -> int:
|
||||||
if isinstance(flags, int):
|
|
||||||
return flags
|
|
||||||
return parse_tick_flags(flags)
|
return parse_tick_flags(flags)
|
||||||
|
|
||||||
|
|
||||||
@@ -1171,7 +1170,7 @@ def collect_history(
|
|||||||
*,
|
*,
|
||||||
datasets: set[Dataset] | None = None,
|
datasets: set[Dataset] | None = None,
|
||||||
timeframe: int | str = 1,
|
timeframe: int | str = 1,
|
||||||
flags: int | str = 1,
|
flags: int | str = "ALL",
|
||||||
if_exists: IfExists = IfExists.FAIL,
|
if_exists: IfExists = IfExists.FAIL,
|
||||||
with_views: bool = False,
|
with_views: bool = False,
|
||||||
config: Mt5Config | None = None,
|
config: Mt5Config | None = None,
|
||||||
|
|||||||
+31
-50
@@ -10,6 +10,9 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Any, TypeGuard
|
from typing import TYPE_CHECKING, Any, TypeGuard
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
from pdmt5 import COPY_TICKS_MAP, TIMEFRAME_MAP
|
||||||
|
from pdmt5 import parse_copy_ticks as _parse_copy_ticks
|
||||||
|
from pdmt5 import parse_timeframe as _parse_timeframe
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
@@ -20,35 +23,15 @@ if TYPE_CHECKING:
|
|||||||
# Constants
|
# Constants
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TIMEFRAME_MAP: dict[str, int] = {
|
# Backward-compatible snapshot; prefer ``COPY_TICKS_MAP`` from pdmt5 directly.
|
||||||
"M1": 1,
|
TICK_FLAG_MAP: dict[str, int] = dict(COPY_TICKS_MAP)
|
||||||
"M2": 2,
|
|
||||||
"M3": 3,
|
|
||||||
"M4": 4,
|
|
||||||
"M5": 5,
|
|
||||||
"M6": 6,
|
|
||||||
"M10": 10,
|
|
||||||
"M12": 12,
|
|
||||||
"M15": 15,
|
|
||||||
"M20": 20,
|
|
||||||
"M30": 30,
|
|
||||||
"H1": 16385,
|
|
||||||
"H2": 16386,
|
|
||||||
"H3": 16387,
|
|
||||||
"H4": 16388,
|
|
||||||
"H6": 16390,
|
|
||||||
"H8": 16392,
|
|
||||||
"H12": 16396,
|
|
||||||
"D1": 16408,
|
|
||||||
"W1": 32769,
|
|
||||||
"MN1": 49153,
|
|
||||||
}
|
|
||||||
|
|
||||||
TICK_FLAG_MAP: dict[str, int] = {
|
TIMEFRAME_NAMES: tuple[str, ...] = tuple(
|
||||||
"ALL": 1,
|
name for name in TIMEFRAME_MAP if not name.startswith("TIMEFRAME_")
|
||||||
"INFO": 2,
|
)
|
||||||
"TRADE": 4,
|
_TICK_FLAG_NAMES: tuple[str, ...] = tuple(
|
||||||
}
|
name for name in COPY_TICKS_MAP if not name.startswith("COPY_TICKS_")
|
||||||
|
)
|
||||||
|
|
||||||
_FORMAT_EXTENSIONS: dict[str, str] = {
|
_FORMAT_EXTENSIONS: dict[str, str] = {
|
||||||
".csv": "csv",
|
".csv": "csv",
|
||||||
@@ -160,10 +143,8 @@ class _TimeframeType(click.ParamType):
|
|||||||
Returns:
|
Returns:
|
||||||
Integer timeframe value.
|
Integer timeframe value.
|
||||||
"""
|
"""
|
||||||
if isinstance(value, int):
|
|
||||||
return value
|
|
||||||
try:
|
try:
|
||||||
return parse_timeframe(str(value))
|
return parse_timeframe(value)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
self.fail(str(exc), param, ctx)
|
self.fail(str(exc), param, ctx)
|
||||||
|
|
||||||
@@ -189,10 +170,8 @@ class _TickFlagsType(click.ParamType):
|
|||||||
Returns:
|
Returns:
|
||||||
Integer tick flag value.
|
Integer tick flag value.
|
||||||
"""
|
"""
|
||||||
if isinstance(value, int):
|
|
||||||
return value
|
|
||||||
try:
|
try:
|
||||||
return parse_tick_flags(str(value))
|
return parse_tick_flags(value)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
self.fail(str(exc), param, ctx)
|
self.fail(str(exc), param, ctx)
|
||||||
|
|
||||||
@@ -370,7 +349,7 @@ def parse_datetime(value: str) -> datetime:
|
|||||||
return dt
|
return dt
|
||||||
|
|
||||||
|
|
||||||
def parse_timeframe(value: str) -> int:
|
def parse_timeframe(value: object) -> int:
|
||||||
"""Parse a timeframe string or integer value.
|
"""Parse a timeframe string or integer value.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -382,37 +361,39 @@ def parse_timeframe(value: str) -> int:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If the timeframe is invalid.
|
ValueError: If the timeframe is invalid.
|
||||||
"""
|
"""
|
||||||
upper = value.upper()
|
|
||||||
if upper in TIMEFRAME_MAP:
|
|
||||||
return TIMEFRAME_MAP[upper]
|
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return _parse_timeframe(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
valid = ", ".join(TIMEFRAME_MAP)
|
display = value if isinstance(value, str) else repr(value)
|
||||||
msg = f"Invalid timeframe: '{value}'. Use one of: {valid}, or an integer."
|
valid = ", ".join(TIMEFRAME_NAMES)
|
||||||
|
msg = (
|
||||||
|
f"Invalid timeframe: '{display}'. "
|
||||||
|
f"Use one of: {valid}, or a supported integer."
|
||||||
|
)
|
||||||
raise ValueError(msg) from None
|
raise ValueError(msg) from None
|
||||||
|
|
||||||
|
|
||||||
def parse_tick_flags(value: str) -> int:
|
def parse_tick_flags(value: object) -> int:
|
||||||
"""Parse tick flags string or integer value.
|
"""Parse tick flags string or integer value.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: Tick flag name (ALL, INFO, TRADE) or integer value.
|
value: Tick flag name (ALL, INFO, TRADE, COPY_TICKS_*) or integer value.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Integer tick flag value.
|
Integer tick flag value compatible with MetaTrader 5 ``COPY_TICKS_*``.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If the flag is invalid.
|
ValueError: If the flag is invalid.
|
||||||
"""
|
"""
|
||||||
upper = value.upper()
|
|
||||||
if upper in TICK_FLAG_MAP:
|
|
||||||
return TICK_FLAG_MAP[upper]
|
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return _parse_copy_ticks(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
valid = ", ".join(TICK_FLAG_MAP)
|
display = value if isinstance(value, str) else repr(value)
|
||||||
msg = f"Invalid tick flags: '{value}'. Use one of: {valid}, or an integer."
|
valid = ", ".join(_TICK_FLAG_NAMES)
|
||||||
|
msg = (
|
||||||
|
f"Invalid tick flags: '{display}'. "
|
||||||
|
f"Use one of: {valid}, or a supported integer."
|
||||||
|
)
|
||||||
raise ValueError(msg) from None
|
raise ValueError(msg) from None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mt5cli"
|
name = "mt5cli"
|
||||||
version = "0.6.1"
|
version = "0.7.1"
|
||||||
description = "Command-line tool for MetaTrader 5"
|
description = "Command-line tool for MetaTrader 5"
|
||||||
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||||
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||||
@@ -9,7 +9,7 @@ license-files = ["LICENSE"]
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">= 3.11, < 3.14"
|
requires-python = ">= 3.11, < 3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pdmt5 >= 0.2.3",
|
"pdmt5>=0.3.0",
|
||||||
"click >= 8.1.0",
|
"click >= 8.1.0",
|
||||||
"pyarrow >= 19.0.0",
|
"pyarrow >= 19.0.0",
|
||||||
"typer >= 0.15.0",
|
"typer >= 0.15.0",
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Shared pytest fixtures for mt5cli tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
from pytest_mock import MockerFixture # noqa: TC002
|
||||||
|
|
||||||
|
_DATAFRAME_METHODS = (
|
||||||
|
"copy_rates_from_as_df",
|
||||||
|
"copy_rates_from_pos_as_df",
|
||||||
|
"copy_rates_range_as_df",
|
||||||
|
"copy_ticks_from_as_df",
|
||||||
|
"copy_ticks_range_as_df",
|
||||||
|
"account_info_as_df",
|
||||||
|
"terminal_info_as_df",
|
||||||
|
"symbols_get_as_df",
|
||||||
|
"symbol_info_as_df",
|
||||||
|
"orders_get_as_df",
|
||||||
|
"positions_get_as_df",
|
||||||
|
"history_orders_get_as_df",
|
||||||
|
"history_deals_get_as_df",
|
||||||
|
"version_as_df",
|
||||||
|
"last_error_as_df",
|
||||||
|
"symbol_info_tick_as_df",
|
||||||
|
"market_book_get_as_df",
|
||||||
|
"order_check_as_df",
|
||||||
|
"order_send_as_df",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_mock_mt5_data_client() -> MagicMock:
|
||||||
|
"""Return a MagicMock Mt5DataClient with common DataFrame stubs."""
|
||||||
|
client = MagicMock()
|
||||||
|
sample_df = pd.DataFrame({"col": [1]})
|
||||||
|
for method_name in _DATAFRAME_METHODS:
|
||||||
|
getattr(client, method_name).return_value = sample_df
|
||||||
|
client.version.return_value = (5, 0, 1)
|
||||||
|
client.terminal_info.return_value = {"connected": True, "paths": ["terminal.exe"]}
|
||||||
|
client.account_info.return_value = {"login": 123, "limits": {"modes": ["demo"]}}
|
||||||
|
client.symbols_total.return_value = 42
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_client(mocker: MockerFixture) -> MagicMock:
|
||||||
|
"""Create and patch a mock Mt5DataClient for CLI and SDK tests."""
|
||||||
|
client = build_mock_mt5_data_client()
|
||||||
|
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||||
|
return client
|
||||||
+5
-37
@@ -69,38 +69,6 @@ class TestExecuteExport:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_client(mocker: MockerFixture) -> MagicMock:
|
|
||||||
"""Create and patch a mock Mt5DataClient for CLI tests."""
|
|
||||||
client = MagicMock()
|
|
||||||
sample_df = pd.DataFrame({"col": [1]})
|
|
||||||
client.copy_rates_from_as_df.return_value = sample_df
|
|
||||||
client.copy_rates_from_pos_as_df.return_value = sample_df
|
|
||||||
client.copy_rates_range_as_df.return_value = sample_df
|
|
||||||
client.copy_ticks_from_as_df.return_value = sample_df
|
|
||||||
client.copy_ticks_range_as_df.return_value = sample_df
|
|
||||||
client.account_info_as_df.return_value = sample_df
|
|
||||||
client.terminal_info_as_df.return_value = sample_df
|
|
||||||
client.symbols_get_as_df.return_value = sample_df
|
|
||||||
client.symbol_info_as_df.return_value = sample_df
|
|
||||||
client.orders_get_as_df.return_value = sample_df
|
|
||||||
client.positions_get_as_df.return_value = sample_df
|
|
||||||
client.history_orders_get_as_df.return_value = sample_df
|
|
||||||
client.history_deals_get_as_df.return_value = sample_df
|
|
||||||
client.version_as_df.return_value = sample_df
|
|
||||||
client.last_error_as_df.return_value = sample_df
|
|
||||||
client.symbol_info_tick_as_df.return_value = sample_df
|
|
||||||
client.market_book_get_as_df.return_value = sample_df
|
|
||||||
client.order_check_as_df.return_value = sample_df
|
|
||||||
client.order_send_as_df.return_value = sample_df
|
|
||||||
client.version.return_value = (5, 0, 1)
|
|
||||||
client.terminal_info.return_value = {"connected": True, "paths": ["terminal.exe"]}
|
|
||||||
client.account_info.return_value = {"login": 123, "limits": {"modes": ["demo"]}}
|
|
||||||
client.symbols_total.return_value = 42
|
|
||||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
class TestCommands:
|
class TestCommands:
|
||||||
"""Tests for all CLI subcommands via CliRunner."""
|
"""Tests for all CLI subcommands via CliRunner."""
|
||||||
|
|
||||||
@@ -317,7 +285,7 @@ class TestCommands:
|
|||||||
symbol="EURUSD",
|
symbol="EURUSD",
|
||||||
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
||||||
count=100,
|
count=100,
|
||||||
flags=1,
|
flags=-1,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_ticks_range(
|
def test_ticks_range(
|
||||||
@@ -348,7 +316,7 @@ class TestCommands:
|
|||||||
symbol="EURUSD",
|
symbol="EURUSD",
|
||||||
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
||||||
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
||||||
flags=2,
|
flags=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_ticks_recent(
|
def test_ticks_recent(
|
||||||
@@ -381,7 +349,7 @@ class TestCommands:
|
|||||||
symbol="EURUSD",
|
symbol="EURUSD",
|
||||||
date_from=datetime(2024, 1, 2, tzinfo=UTC) - timedelta(seconds=120),
|
date_from=datetime(2024, 1, 2, tzinfo=UTC) - timedelta(seconds=120),
|
||||||
count=500,
|
count=500,
|
||||||
flags=1,
|
flags=-1,
|
||||||
)
|
)
|
||||||
mock_client.copy_ticks_range_as_df.assert_not_called()
|
mock_client.copy_ticks_range_as_df.assert_not_called()
|
||||||
|
|
||||||
@@ -1000,7 +968,7 @@ class TestCollectHistory:
|
|||||||
symbol="EURUSD",
|
symbol="EURUSD",
|
||||||
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
||||||
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
||||||
flags=1,
|
flags=-1,
|
||||||
)
|
)
|
||||||
with sqlite3.connect(output) as conn:
|
with sqlite3.connect(output) as conn:
|
||||||
tables = {
|
tables = {
|
||||||
@@ -1213,7 +1181,7 @@ class TestCollectHistory:
|
|||||||
symbol="EURUSD",
|
symbol="EURUSD",
|
||||||
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
||||||
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
||||||
flags=1,
|
flags=-1,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_collect_history_with_views(
|
def test_collect_history_with_views(
|
||||||
|
|||||||
+17
-1
@@ -517,6 +517,9 @@ class TestResolveHistorySettings:
|
|||||||
"""Test default timeframes include all fixed MT5 values."""
|
"""Test default timeframes include all fixed MT5 values."""
|
||||||
resolved = resolve_history_timeframes(None)
|
resolved = resolve_history_timeframes(None)
|
||||||
assert len(resolved) == len(DEFAULT_HISTORY_TIMEFRAMES)
|
assert len(resolved) == len(DEFAULT_HISTORY_TIMEFRAMES)
|
||||||
|
assert not any(
|
||||||
|
name.startswith("TIMEFRAME_") for name in DEFAULT_HISTORY_TIMEFRAMES
|
||||||
|
)
|
||||||
assert 1 in resolved
|
assert 1 in resolved
|
||||||
assert TIMEFRAME_MAP["H1"] in resolved
|
assert TIMEFRAME_MAP["H1"] in resolved
|
||||||
|
|
||||||
@@ -526,7 +529,7 @@ class TestResolveHistorySettings:
|
|||||||
|
|
||||||
def test_resolve_history_tick_flags(self) -> None:
|
def test_resolve_history_tick_flags(self) -> None:
|
||||||
"""Test tick flag resolution."""
|
"""Test tick flag resolution."""
|
||||||
assert resolve_history_tick_flags("ALL") == 1
|
assert resolve_history_tick_flags("ALL") == -1
|
||||||
assert resolve_history_tick_flags(2) == 2
|
assert resolve_history_tick_flags(2) == 2
|
||||||
|
|
||||||
def test_resolve_granularity_name_falls_back_to_integer(self) -> None:
|
def test_resolve_granularity_name_falls_back_to_integer(self) -> None:
|
||||||
@@ -534,6 +537,17 @@ class TestResolveHistorySettings:
|
|||||||
assert resolve_granularity_name(999) == "999"
|
assert resolve_granularity_name(999) == "999"
|
||||||
assert resolve_granularity_name(1) == "M1"
|
assert resolve_granularity_name(1) == "M1"
|
||||||
|
|
||||||
|
def test_resolve_granularity_name_strips_official_prefix(
|
||||||
|
self,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test official pdmt5 timeframe names are normalized to short aliases."""
|
||||||
|
mocker.patch(
|
||||||
|
"mt5cli.history._get_timeframe_name",
|
||||||
|
return_value="TIMEFRAME_H1",
|
||||||
|
)
|
||||||
|
assert resolve_granularity_name(16385) == "H1"
|
||||||
|
|
||||||
|
|
||||||
class TestDropFormingRateBar:
|
class TestDropFormingRateBar:
|
||||||
"""Tests for drop_forming_rate_bar."""
|
"""Tests for drop_forming_rate_bar."""
|
||||||
@@ -1754,6 +1768,8 @@ class TestIncrementalIntegration:
|
|||||||
"""Test invalid tick flags raise ValueError."""
|
"""Test invalid tick flags raise ValueError."""
|
||||||
with pytest.raises(ValueError, match="Invalid tick flags"):
|
with pytest.raises(ValueError, match="Invalid tick flags"):
|
||||||
resolve_history_tick_flags("BAD")
|
resolve_history_tick_flags("BAD")
|
||||||
|
with pytest.raises(ValueError, match="Invalid tick flags"):
|
||||||
|
resolve_history_tick_flags(7)
|
||||||
|
|
||||||
def test_resolve_history_timeframes_invalid(self) -> None:
|
def test_resolve_history_timeframes_invalid(self) -> None:
|
||||||
"""Test invalid timeframes raise ValueError."""
|
"""Test invalid timeframes raise ValueError."""
|
||||||
|
|||||||
+5
-31
@@ -132,32 +132,6 @@ _DEALS_FIXTURE: dict[str, list[object]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_client(mocker: MockerFixture) -> MagicMock:
|
|
||||||
"""Create and patch a mock Mt5DataClient for SDK tests."""
|
|
||||||
client = MagicMock()
|
|
||||||
sample_df = pd.DataFrame({"col": [1]})
|
|
||||||
client.copy_rates_from_as_df.return_value = sample_df
|
|
||||||
client.copy_rates_from_pos_as_df.return_value = sample_df
|
|
||||||
client.copy_rates_range_as_df.return_value = sample_df
|
|
||||||
client.copy_ticks_from_as_df.return_value = sample_df
|
|
||||||
client.copy_ticks_range_as_df.return_value = sample_df
|
|
||||||
client.account_info_as_df.return_value = sample_df
|
|
||||||
client.terminal_info_as_df.return_value = sample_df
|
|
||||||
client.symbols_get_as_df.return_value = sample_df
|
|
||||||
client.symbol_info_as_df.return_value = sample_df
|
|
||||||
client.orders_get_as_df.return_value = sample_df
|
|
||||||
client.positions_get_as_df.return_value = sample_df
|
|
||||||
client.history_orders_get_as_df.return_value = sample_df
|
|
||||||
client.history_deals_get_as_df.return_value = sample_df
|
|
||||||
client.version_as_df.return_value = sample_df
|
|
||||||
client.last_error_as_df.return_value = sample_df
|
|
||||||
client.symbol_info_tick_as_df.return_value = sample_df
|
|
||||||
client.market_book_get_as_df.return_value = sample_df
|
|
||||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
def _build_history_client(mocker: MockerFixture) -> MagicMock:
|
def _build_history_client(mocker: MockerFixture) -> MagicMock:
|
||||||
"""Build a mocked Mt5DataClient with per-symbol history results."""
|
"""Build a mocked Mt5DataClient with per-symbol history results."""
|
||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
@@ -390,7 +364,7 @@ class TestMt5CliClient:
|
|||||||
symbol="EURUSD",
|
symbol="EURUSD",
|
||||||
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
||||||
count=100,
|
count=100,
|
||||||
flags=2,
|
flags=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_history_orders_accepts_string_dates(
|
def test_history_orders_accepts_string_dates(
|
||||||
@@ -951,7 +925,7 @@ class TestUpdateHistory:
|
|||||||
assert kwargs["symbol"] == "EURUSD"
|
assert kwargs["symbol"] == "EURUSD"
|
||||||
assert kwargs["date_from"] == expected_start
|
assert kwargs["date_from"] == expected_start
|
||||||
assert kwargs["date_to"] == date_to
|
assert kwargs["date_to"] == date_to
|
||||||
assert kwargs["flags"] == 1
|
assert kwargs["flags"] == -1
|
||||||
return pd.DataFrame({
|
return pd.DataFrame({
|
||||||
"time": ["2024-01-01T12:00:00+00:00"],
|
"time": ["2024-01-01T12:00:00+00:00"],
|
||||||
"time_msc": [1_704_110_400_000],
|
"time_msc": [1_704_110_400_000],
|
||||||
@@ -1119,7 +1093,7 @@ class TestRecentTicks:
|
|||||||
symbol="EURUSD",
|
symbol="EURUSD",
|
||||||
date_from=end - timedelta(seconds=60),
|
date_from=end - timedelta(seconds=60),
|
||||||
count=100,
|
count=100,
|
||||||
flags=2,
|
flags=1,
|
||||||
)
|
)
|
||||||
client.copy_ticks_range_as_df.assert_not_called()
|
client.copy_ticks_range_as_df.assert_not_called()
|
||||||
|
|
||||||
@@ -1149,7 +1123,7 @@ class TestRecentTicks:
|
|||||||
assert kwargs["symbol"] == "EURUSD"
|
assert kwargs["symbol"] == "EURUSD"
|
||||||
assert kwargs["date_to"] == tick.time
|
assert kwargs["date_to"] == tick.time
|
||||||
assert kwargs["date_from"] == tick.time - timedelta(seconds=30)
|
assert kwargs["date_from"] == tick.time - timedelta(seconds=30)
|
||||||
assert kwargs["flags"] == 1
|
assert kwargs["flags"] == -1
|
||||||
|
|
||||||
def test_recent_ticks_rejects_unsupported_tick_time(
|
def test_recent_ticks_rejects_unsupported_tick_time(
|
||||||
self,
|
self,
|
||||||
@@ -1219,7 +1193,7 @@ class TestRecentTicks:
|
|||||||
symbol="EURUSD",
|
symbol="EURUSD",
|
||||||
date_from=end - timedelta(seconds=60),
|
date_from=end - timedelta(seconds=60),
|
||||||
date_to=end,
|
date_to=end,
|
||||||
flags=1,
|
flags=-1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+51
-14
@@ -274,8 +274,14 @@ class TestParseTimeframe:
|
|||||||
assert parse_timeframe(value) == expected
|
assert parse_timeframe(value) == expected
|
||||||
|
|
||||||
def test_integer_timeframe(self) -> None:
|
def test_integer_timeframe(self) -> None:
|
||||||
"""Test parsing integer timeframe."""
|
"""Test parsing supported integer timeframes."""
|
||||||
assert parse_timeframe("42") == 42
|
assert parse_timeframe("1") == 1
|
||||||
|
assert parse_timeframe(16385) == 16385
|
||||||
|
|
||||||
|
def test_unsupported_integer_timeframe_raises(self) -> None:
|
||||||
|
"""Test that unsupported integer timeframes raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Invalid timeframe"):
|
||||||
|
parse_timeframe("42")
|
||||||
|
|
||||||
def test_invalid_timeframe_raises(self) -> None:
|
def test_invalid_timeframe_raises(self) -> None:
|
||||||
"""Test that invalid timeframe raises ValueError."""
|
"""Test that invalid timeframe raises ValueError."""
|
||||||
@@ -288,15 +294,21 @@ class TestParseTickFlags:
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("value", "expected"),
|
("value", "expected"),
|
||||||
[("ALL", 1), ("info", 2), ("TRADE", 4)],
|
[("ALL", -1), ("info", 1), ("TRADE", 2), ("COPY_TICKS_ALL", -1)],
|
||||||
)
|
)
|
||||||
def test_named_flag(self, value: str, expected: int) -> None:
|
def test_named_flag(self, value: str, expected: int) -> None:
|
||||||
"""Test parsing named tick flags."""
|
"""Test parsing named tick flags."""
|
||||||
assert parse_tick_flags(value) == expected
|
assert parse_tick_flags(value) == expected
|
||||||
|
|
||||||
def test_integer_flag(self) -> None:
|
def test_integer_flag(self) -> None:
|
||||||
"""Test parsing integer tick flag."""
|
"""Test parsing supported integer tick flags."""
|
||||||
assert parse_tick_flags("7") == 7
|
assert parse_tick_flags("-1") == -1
|
||||||
|
assert parse_tick_flags(2) == 2
|
||||||
|
|
||||||
|
def test_unsupported_integer_flag_raises(self) -> None:
|
||||||
|
"""Test that unsupported integer tick flags raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Invalid tick flags"):
|
||||||
|
parse_tick_flags("7")
|
||||||
|
|
||||||
def test_invalid_flag_raises(self) -> None:
|
def test_invalid_flag_raises(self) -> None:
|
||||||
"""Test that invalid flag raises ValueError."""
|
"""Test that invalid flag raises ValueError."""
|
||||||
@@ -355,8 +367,11 @@ class TestConstants:
|
|||||||
assert key in TIMEFRAME_MAP
|
assert key in TIMEFRAME_MAP
|
||||||
|
|
||||||
def test_tick_flag_map_has_expected_keys(self) -> None:
|
def test_tick_flag_map_has_expected_keys(self) -> None:
|
||||||
"""Test that TICK_FLAG_MAP contains standard flags."""
|
"""Test that TICK_FLAG_MAP contains standard flags with MT5 values."""
|
||||||
assert set(TICK_FLAG_MAP) == {"ALL", "INFO", "TRADE"}
|
assert {"ALL", "INFO", "TRADE"} <= set(TICK_FLAG_MAP)
|
||||||
|
assert TICK_FLAG_MAP["ALL"] == -1
|
||||||
|
assert TICK_FLAG_MAP["INFO"] == 1
|
||||||
|
assert TICK_FLAG_MAP["TRADE"] == 2
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("dataset", "expected"),
|
("dataset", "expected"),
|
||||||
@@ -403,26 +418,48 @@ class TestTimeframeType:
|
|||||||
"""Test converting a string to timeframe integer."""
|
"""Test converting a string to timeframe integer."""
|
||||||
assert TIMEFRAME_TYPE.convert("H1", None, None) == 16385
|
assert TIMEFRAME_TYPE.convert("H1", None, None) == 16385
|
||||||
|
|
||||||
def test_convert_int_passthrough(self) -> None:
|
def test_convert_int(self) -> None:
|
||||||
"""Test that integer values pass through unchanged."""
|
"""Test converting supported integer timeframe values."""
|
||||||
assert TIMEFRAME_TYPE.convert(42, None, None) == 42
|
assert TIMEFRAME_TYPE.convert(16385, None, None) == 16385
|
||||||
|
|
||||||
|
def test_convert_unsupported_int(self) -> None:
|
||||||
|
"""Test that unsupported integer values raise BadParameter."""
|
||||||
|
with pytest.raises(Exception, match="Invalid timeframe"):
|
||||||
|
TIMEFRAME_TYPE.convert(42, None, None)
|
||||||
|
|
||||||
def test_convert_invalid(self) -> None:
|
def test_convert_invalid(self) -> None:
|
||||||
"""Test that invalid values raise BadParameter."""
|
"""Test that invalid values raise BadParameter."""
|
||||||
with pytest.raises(Exception, match="Invalid timeframe"):
|
with pytest.raises(Exception, match="Invalid timeframe"):
|
||||||
TIMEFRAME_TYPE.convert("bad", None, None)
|
TIMEFRAME_TYPE.convert("bad", None, None)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [True, False, None, 1.5])
|
||||||
|
def test_convert_invalid_types(self, value: object) -> None:
|
||||||
|
"""Test that bool, float, and None values raise BadParameter."""
|
||||||
|
with pytest.raises(Exception, match="Invalid timeframe"):
|
||||||
|
TIMEFRAME_TYPE.convert(value, None, None)
|
||||||
|
|
||||||
|
|
||||||
class TestTickFlagsType:
|
class TestTickFlagsType:
|
||||||
"""Tests for _TickFlagsType."""
|
"""Tests for _TickFlagsType."""
|
||||||
|
|
||||||
def test_convert_string(self) -> None:
|
def test_convert_string(self) -> None:
|
||||||
"""Test converting a string to tick flags integer."""
|
"""Test converting a string to tick flags integer."""
|
||||||
assert TICK_FLAGS_TYPE.convert("ALL", None, None) == 1
|
assert TICK_FLAGS_TYPE.convert("ALL", None, None) == -1
|
||||||
|
|
||||||
def test_convert_int_passthrough(self) -> None:
|
def test_convert_int(self) -> None:
|
||||||
"""Test that integer values pass through unchanged."""
|
"""Test converting supported integer tick flag values."""
|
||||||
assert TICK_FLAGS_TYPE.convert(7, None, None) == 7
|
assert TICK_FLAGS_TYPE.convert(2, None, None) == 2
|
||||||
|
|
||||||
|
def test_convert_unsupported_int(self) -> None:
|
||||||
|
"""Test that unsupported integer values raise BadParameter."""
|
||||||
|
with pytest.raises(Exception, match="Invalid tick flags"):
|
||||||
|
TICK_FLAGS_TYPE.convert(7, None, None)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [True, False, None, 1.5])
|
||||||
|
def test_convert_invalid_types(self, value: object) -> None:
|
||||||
|
"""Test that bool, float, and None values raise BadParameter."""
|
||||||
|
with pytest.raises(Exception, match="Invalid tick flags"):
|
||||||
|
TICK_FLAGS_TYPE.convert(value, None, None)
|
||||||
|
|
||||||
def test_convert_invalid(self) -> None:
|
def test_convert_invalid(self) -> None:
|
||||||
"""Test that invalid values raise BadParameter."""
|
"""Test that invalid values raise BadParameter."""
|
||||||
|
|||||||
@@ -487,7 +487,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mt5cli"
|
name = "mt5cli"
|
||||||
version = "0.6.1"
|
version = "0.7.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
@@ -513,7 +513,7 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "click", specifier = ">=8.1.0" },
|
{ name = "click", specifier = ">=8.1.0" },
|
||||||
{ name = "pdmt5", specifier = ">=0.2.3" },
|
{ name = "pdmt5", specifier = ">=0.3.0" },
|
||||||
{ name = "pyarrow", specifier = ">=19.0.0" },
|
{ name = "pyarrow", specifier = ">=19.0.0" },
|
||||||
{ name = "typer", specifier = ">=0.15.0" },
|
{ name = "typer", specifier = ">=0.15.0" },
|
||||||
]
|
]
|
||||||
@@ -684,16 +684,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdmt5"
|
name = "pdmt5"
|
||||||
version = "0.2.3"
|
version = "0.3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "metatrader5", marker = "sys_platform == 'win32'" },
|
{ name = "metatrader5", marker = "sys_platform == 'win32'" },
|
||||||
{ name = "pandas" },
|
{ name = "pandas" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/02/25/52d9d954504ccdd0fe91f715ab74c424d61234b237cc4160d3ebe20070f1/pdmt5-0.2.3.tar.gz", hash = "sha256:21384f5826fb0125fee3f93c90b108340f55ab53b1c819d229ceac162289d2ec", size = 226665, upload-time = "2026-02-05T13:28:21.071Z" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/75/c5e52a9cf459b85b2dd52f83e70857571b1b45805c9fe610b3959a26ac15/pdmt5-0.2.3-py3-none-any.whl", hash = "sha256:f92246a05cfc3b7feb3ab0cc5b48768a4d84aad6b02e7a68060948f5828718a1", size = 22967, upload-time = "2026-02-05T13:28:19.523Z" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user