Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8da5ee9242 | |||
| 9dbb46fbb1 | |||
| dfe80ce500 | |||
| 15bfd17db3 |
@@ -29,6 +29,12 @@ Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data han
|
||||
pip install -U mt5cli MetaTrader5
|
||||
```
|
||||
|
||||
Parquet export is not included by default. To enable it, install the `parquet` extra:
|
||||
|
||||
```bash
|
||||
pip install -U "mt5cli[parquet]" MetaTrader5
|
||||
```
|
||||
|
||||
## Python API (downstream packages)
|
||||
|
||||
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
|
||||
@@ -178,10 +184,13 @@ python -m mt5cli -o account.csv account-info
|
||||
| `recent-history-deals` | Export historical deals from a recent trailing window |
|
||||
| `mt5-summary` | Export terminal/account status summary |
|
||||
| `order-check` | Check funds sufficiency for a trade request |
|
||||
| `order-send` | Send a trade request to the trade server (`--yes` required) |
|
||||
| `order-send` | Send a raw trade request to the trade server (`--yes` required; expert path) |
|
||||
| `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` available) |
|
||||
| `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
|
||||
|
||||
Use `order-check` to validate a request payload before running `order-send --yes`.
|
||||
`close-positions` is the safer high-level alternative that builds correct close
|
||||
requests automatically. At least one `--symbol` or `--ticket` must be provided.
|
||||
|
||||
### `collect-history`
|
||||
|
||||
|
||||
@@ -111,6 +111,14 @@ strategy entries, exits, Kelly sizing, or signal logic.
|
||||
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
||||
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
||||
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
||||
| `ProjectionMode` | Literal type for `calculate_symbol_group_margin_ratio` projection |
|
||||
|
||||
`calculate_symbol_group_margin_ratio` accepts an optional `projection_mode`
|
||||
parameter (`"add"` by default). Pass `projection_mode="replace_symbol"` to
|
||||
subtract current exposure for `new_symbol` before adding the candidate margin —
|
||||
useful for reversal-style projections. mt5cli only calculates broker-facing
|
||||
exposure; downstream applications own thresholds, risk guard actions, and
|
||||
strategy policy.
|
||||
|
||||
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
|
||||
|
||||
@@ -182,7 +190,12 @@ The Typer application in `mt5cli.cli` exposes file-export commands documented in
|
||||
- Delegate to the same Python APIs described here; they are not duplicated
|
||||
business logic.
|
||||
|
||||
`order-send` requires `--yes` before placing live trades.
|
||||
`order-send` is the expert raw-request path; it requires `--yes` and a fully
|
||||
constructed request payload. `close-positions` is the safer high-level helper
|
||||
that closes open positions by `--symbol` or `--ticket` using
|
||||
`close_open_positions()`. Both `order-send --yes` and `close-positions --yes`
|
||||
are live execution paths. `close-positions --dry-run` previews close orders
|
||||
without placing them and does not require `--yes`.
|
||||
|
||||
## Internal helpers (not stable)
|
||||
|
||||
|
||||
@@ -27,6 +27,12 @@ mt5cli provides a stable `MT5Client` Python API, standardized dataset schemas, s
|
||||
pip install mt5cli
|
||||
```
|
||||
|
||||
Parquet export is not included by default. To enable it, install the `parquet` extra:
|
||||
|
||||
```bash
|
||||
pip install "mt5cli[parquet]"
|
||||
```
|
||||
|
||||
## Python API for downstream packages
|
||||
|
||||
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
|
||||
|
||||
@@ -120,6 +120,7 @@ from .trading import (
|
||||
OrderSide,
|
||||
OrderTimeMode,
|
||||
PositionSide,
|
||||
ProjectionMode,
|
||||
calculate_account_projected_margin_ratio,
|
||||
calculate_margin_and_volume,
|
||||
calculate_new_position_margin_ratio,
|
||||
@@ -192,6 +193,7 @@ __all__ = [
|
||||
"OrderSide",
|
||||
"OrderTimeMode",
|
||||
"PositionSide",
|
||||
"ProjectionMode",
|
||||
"RateTarget",
|
||||
"ThrottledHistoryUpdater",
|
||||
"account_info",
|
||||
|
||||
+93
-2
@@ -2,17 +2,20 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime # noqa: TC003
|
||||
from pathlib import Path # noqa: TC003
|
||||
from typing import TYPE_CHECKING, Annotated, Any, cast
|
||||
|
||||
import pandas as pd
|
||||
import typer
|
||||
from pdmt5 import Mt5Config
|
||||
|
||||
from . import sdk
|
||||
from .client import MT5Client
|
||||
from .trading import OrderExecutionResult, close_open_positions, create_trading_client
|
||||
from .utils import (
|
||||
DATETIME_TYPE,
|
||||
REQUEST_TYPE,
|
||||
@@ -29,8 +32,6 @@ from .utils import (
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -600,6 +601,96 @@ def order_send(
|
||||
_export_command(ctx, lambda client: client.order_send(request))
|
||||
|
||||
|
||||
_EXECUTION_RESULT_COLUMNS: list[str] = [
|
||||
"status",
|
||||
"symbol",
|
||||
"order_side",
|
||||
"volume",
|
||||
"retcode",
|
||||
"comment",
|
||||
"request",
|
||||
"response",
|
||||
"dry_run",
|
||||
]
|
||||
|
||||
|
||||
def _execution_results_to_df(results: list[OrderExecutionResult]) -> pd.DataFrame:
|
||||
if not results:
|
||||
return pd.DataFrame(columns=_EXECUTION_RESULT_COLUMNS)
|
||||
rows = [
|
||||
{
|
||||
**r,
|
||||
"request": json.dumps(r["request"]),
|
||||
"response": json.dumps(r["response"]),
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
@app.command()
|
||||
def close_positions(
|
||||
ctx: typer.Context,
|
||||
symbol: Annotated[
|
||||
list[str] | None,
|
||||
typer.Option(
|
||||
"--symbol",
|
||||
"-s",
|
||||
help="Symbol to close (repeat for multiple symbols).",
|
||||
),
|
||||
] = None,
|
||||
ticket: Annotated[
|
||||
list[int] | None,
|
||||
typer.Option(
|
||||
"--ticket",
|
||||
"-t",
|
||||
help="Position ticket to close (repeat for multiple tickets).",
|
||||
),
|
||||
] = None,
|
||||
dry_run: Annotated[
|
||||
bool,
|
||||
typer.Option("--dry-run", help="Preview close orders without executing them."),
|
||||
] = False,
|
||||
yes: Annotated[
|
||||
bool,
|
||||
typer.Option("--yes", help="Confirm live position closing."),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Close open positions by symbol or ticket.
|
||||
|
||||
Delegates to :func:`mt5cli.trading.close_open_positions`. At least one
|
||||
``--symbol`` or ``--ticket`` must be provided to avoid accidentally closing
|
||||
all positions. Use ``--dry-run`` to preview without executing; ``--yes`` is
|
||||
required for live execution.
|
||||
|
||||
``order-send`` is the expert raw-request path. ``close-positions`` is the
|
||||
safer high-level helper that builds correct close requests automatically.
|
||||
|
||||
Raises:
|
||||
typer.BadParameter: If neither ``--symbol`` nor ``--ticket`` is given,
|
||||
or if ``--yes`` is missing for a live (non-dry-run) run.
|
||||
"""
|
||||
if not symbol and not ticket:
|
||||
msg = "Provide at least one --symbol or --ticket to close positions."
|
||||
raise typer.BadParameter(msg)
|
||||
if not dry_run and not yes:
|
||||
msg = "Pass --yes to close live positions."
|
||||
raise typer.BadParameter(msg, param_hint="--yes")
|
||||
export_ctx = _get_export_context(ctx)
|
||||
client = create_trading_client(config=export_ctx.config)
|
||||
try:
|
||||
results = close_open_positions(
|
||||
client,
|
||||
symbols=list(symbol) if symbol else None,
|
||||
tickets=list(ticket) if ticket else None,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
finally:
|
||||
client.shutdown()
|
||||
df = _execution_results_to_df(results)
|
||||
_execute_export(ctx, lambda: df)
|
||||
|
||||
|
||||
@app.command()
|
||||
def collect_history(
|
||||
ctx: typer.Context,
|
||||
|
||||
@@ -17,6 +17,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||
"OrderSide",
|
||||
"OrderTimeMode",
|
||||
"PositionSide",
|
||||
"ProjectionMode",
|
||||
"ExecutionStatus",
|
||||
"MarginVolume",
|
||||
"OrderExecutionResult",
|
||||
|
||||
+34
-10
@@ -25,6 +25,7 @@ OrderSide = Literal["BUY", "SELL"]
|
||||
OrderFillingMode = Literal["IOC", "FOK", "RETURN"]
|
||||
OrderTimeMode = Literal["GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"]
|
||||
ExecutionStatus = Literal["executed", "dry_run", "skipped", "failed"]
|
||||
ProjectionMode = Literal["add", "replace_symbol"]
|
||||
|
||||
|
||||
class MarginVolume(TypedDict):
|
||||
@@ -127,6 +128,7 @@ __all__ = [
|
||||
"OrderSide",
|
||||
"OrderTimeMode",
|
||||
"PositionSide",
|
||||
"ProjectionMode",
|
||||
"calculate_account_projected_margin_ratio",
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
@@ -920,6 +922,16 @@ def calculate_projected_margin_ratio(
|
||||
return margin / equity
|
||||
|
||||
|
||||
def _validate_projection_mode(projection_mode: str) -> ProjectionMode:
|
||||
if projection_mode not in {"add", "replace_symbol"}:
|
||||
msg = (
|
||||
f"Unsupported projection mode: {projection_mode!r}. "
|
||||
"Expected 'add' or 'replace_symbol'."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return cast("ProjectionMode", projection_mode)
|
||||
|
||||
|
||||
def calculate_symbol_group_margin_ratio(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
@@ -928,13 +940,22 @@ def calculate_symbol_group_margin_ratio(
|
||||
new_position_side: OrderSide | None = None,
|
||||
new_position_volume: float = 0.0,
|
||||
suppress_errors: bool = True,
|
||||
projection_mode: ProjectionMode = "add",
|
||||
) -> float:
|
||||
"""Return estimated symbol-group margin over account equity.
|
||||
|
||||
Per-symbol current exposure is summed with
|
||||
:func:`calculate_positions_margin_by_symbol`. When ``new_symbol`` is inside
|
||||
the input symbol group, optional projected order margin is added for that
|
||||
symbol. Invalid equity always raises to fail closed.
|
||||
the input symbol group and candidate side/volume are provided, projected order
|
||||
margin is applied according to ``projection_mode``:
|
||||
|
||||
- ``"add"`` (default): adds candidate margin to the group total.
|
||||
- ``"replace_symbol"``: subtracts current margin for ``new_symbol``, then
|
||||
adds candidate margin. Useful for reversal-style projections where the new
|
||||
order is intended to replace existing exposure for that symbol.
|
||||
|
||||
If the candidate margin estimation fails, the subtraction is also skipped so
|
||||
the operation is atomic. Invalid equity always raises to fail closed.
|
||||
|
||||
Raises:
|
||||
AttributeError: When symbol margin lookup or projected margin lookup
|
||||
@@ -945,23 +966,22 @@ def calculate_symbol_group_margin_ratio(
|
||||
lookup or projected margin lookup fails and ``suppress_errors`` is
|
||||
``False``.
|
||||
"""
|
||||
projection_mode = _validate_projection_mode(projection_mode)
|
||||
equity = _account_equity(client)
|
||||
unique_symbols = list(dict.fromkeys(symbols))
|
||||
margin = sum(
|
||||
calculate_positions_margin_by_symbol(
|
||||
client,
|
||||
symbols=unique_symbols,
|
||||
suppress_errors=suppress_errors,
|
||||
).values(),
|
||||
0.0,
|
||||
per_symbol = calculate_positions_margin_by_symbol(
|
||||
client,
|
||||
symbols=unique_symbols,
|
||||
suppress_errors=suppress_errors,
|
||||
)
|
||||
margin = sum(per_symbol.values(), 0.0)
|
||||
if (
|
||||
new_symbol in unique_symbols
|
||||
and new_position_side is not None
|
||||
and new_position_volume > 0
|
||||
):
|
||||
try:
|
||||
margin += estimate_order_margin(
|
||||
candidate_margin = estimate_order_margin(
|
||||
client,
|
||||
new_symbol,
|
||||
new_position_side,
|
||||
@@ -971,6 +991,10 @@ def calculate_symbol_group_margin_ratio(
|
||||
if not suppress_errors:
|
||||
raise
|
||||
_logger.warning("Skipping projected margin for %r.", new_symbol)
|
||||
else:
|
||||
if projection_mode == "replace_symbol":
|
||||
margin = max(0.0, margin - per_symbol.get(new_symbol, 0.0))
|
||||
margin += candidate_margin
|
||||
return margin / equity
|
||||
|
||||
|
||||
|
||||
@@ -314,6 +314,7 @@ def export_dataframe(
|
||||
table_name: Table name for SQLite3 output.
|
||||
|
||||
Raises:
|
||||
ImportError: If the parquet format is requested but pyarrow is not installed.
|
||||
ValueError: If the output format is not supported.
|
||||
"""
|
||||
if output_format == "csv":
|
||||
@@ -326,6 +327,14 @@ def export_dataframe(
|
||||
indent=2,
|
||||
)
|
||||
elif output_format == "parquet":
|
||||
try:
|
||||
__import__("pyarrow")
|
||||
except ImportError as exc:
|
||||
msg = (
|
||||
"Parquet export requires the optional dependency pyarrow. "
|
||||
'Install it with: pip install "mt5cli[parquet]"'
|
||||
)
|
||||
raise ImportError(msg) from exc
|
||||
df.to_parquet(output_path, index=False)
|
||||
elif output_format == "sqlite3":
|
||||
export_dataframe_to_sqlite(
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mt5cli"
|
||||
version = "0.9.5"
|
||||
version = "0.9.7"
|
||||
description = "Generic MT5 data and execution infrastructure for Python applications"
|
||||
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
@@ -11,7 +11,6 @@ requires-python = ">= 3.11, < 3.14"
|
||||
dependencies = [
|
||||
"pdmt5>=0.3.0",
|
||||
"click >= 8.1.0",
|
||||
"pyarrow >= 19.0.0",
|
||||
"typer >= 0.15.0",
|
||||
]
|
||||
classifiers = [
|
||||
@@ -25,6 +24,9 @@ classifiers = [
|
||||
"Topic :: Office/Business :: Financial :: Investment",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
parquet = ["pyarrow >= 19.0.0"]
|
||||
|
||||
[project.scripts]
|
||||
mt5cli = "mt5cli.cli:main"
|
||||
|
||||
@@ -42,6 +44,7 @@ dev = [
|
||||
"pytest-mock >= 3.12.0",
|
||||
"pytest-cov >= 5.0.0",
|
||||
"pandas-stubs >= 2.2.3.250527",
|
||||
"pyarrow >= 19.0.0",
|
||||
"mkdocs >= 1.6.1",
|
||||
"mkdocs-material >= 9.7.6",
|
||||
"mkdocstrings[python] >= 1.0.4",
|
||||
|
||||
@@ -740,6 +740,306 @@ class TestCommands:
|
||||
assert "must be a JSON object" in normalize_cli_output(result.output)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# close-positions command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_mock_trading_client() -> MagicMock:
|
||||
"""Return a MagicMock Mt5TradingClient with trading constants set."""
|
||||
client = MagicMock()
|
||||
client.mt5.POSITION_TYPE_BUY = 0
|
||||
client.mt5.POSITION_TYPE_SELL = 1
|
||||
client.mt5.ORDER_TYPE_BUY = 10
|
||||
client.mt5.ORDER_TYPE_SELL = 11
|
||||
client.mt5.TRADE_ACTION_DEAL = 20
|
||||
client.mt5.ORDER_FILLING_IOC = 30
|
||||
client.mt5.ORDER_TIME_GTC = 40
|
||||
client.mt5.TRADE_RETCODE_DONE = 10009
|
||||
client.mt5.TRADE_RETCODE_PLACED = 10008
|
||||
client.mt5.TRADE_RETCODE_DONE_PARTIAL = 10010
|
||||
return client
|
||||
|
||||
|
||||
class TestClosePositions:
|
||||
"""Tests for the close-positions command."""
|
||||
|
||||
@pytest.fixture
|
||||
def trading_client(self, mocker: MockerFixture) -> MagicMock:
|
||||
"""Patch create_trading_client and return a mock trading client."""
|
||||
client = _build_mock_trading_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame([
|
||||
{"ticket": 1, "symbol": "JP225", "type": 0, "volume": 1.0},
|
||||
{"ticket": 2, "symbol": "EURUSD", "type": 1, "volume": 0.5},
|
||||
])
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
|
||||
mocker.patch("mt5cli.cli.create_trading_client", return_value=client)
|
||||
return client
|
||||
|
||||
def test_dry_run_does_not_require_yes(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
trading_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test --dry-run mode succeeds without --yes."""
|
||||
output = tmp_path / "close.json"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["-o", str(output), "close-positions", "--symbol", "JP225", "--dry-run"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert output.exists()
|
||||
trading_client.order_send.assert_not_called()
|
||||
trading_client.shutdown.assert_called_once()
|
||||
|
||||
def test_live_requires_yes(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
trading_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test live close-positions fails without --yes."""
|
||||
output = tmp_path / "close.json"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["-o", str(output), "close-positions", "--symbol", "JP225"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Pass --yes" in normalize_cli_output(result.output)
|
||||
trading_client.order_send.assert_not_called()
|
||||
|
||||
def test_live_with_yes_calls_order_send(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
trading_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test --yes triggers live execution for matching positions."""
|
||||
trading_client.order_send.return_value = {"retcode": 10009, "comment": "ok"}
|
||||
output = tmp_path / "close.json"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["-o", str(output), "close-positions", "--symbol", "JP225", "--yes"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
trading_client.order_send.assert_called_once()
|
||||
trading_client.shutdown.assert_called_once()
|
||||
|
||||
def test_symbol_filter_passed_through(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
trading_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test --symbol values are used to filter positions."""
|
||||
output = tmp_path / "close.json"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"-o",
|
||||
str(output),
|
||||
"close-positions",
|
||||
"--symbol",
|
||||
"JP225",
|
||||
"--dry-run",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(output.read_text())
|
||||
assert len(data) == 1
|
||||
assert data[0]["symbol"] == "JP225"
|
||||
trading_client.shutdown.assert_called_once()
|
||||
|
||||
def test_multiple_symbols_filter(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
trading_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test multiple --symbol options are combined."""
|
||||
output = tmp_path / "close.json"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"-o",
|
||||
str(output),
|
||||
"close-positions",
|
||||
"--symbol",
|
||||
"JP225",
|
||||
"--symbol",
|
||||
"EURUSD",
|
||||
"--dry-run",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(output.read_text())
|
||||
assert len(data) == 2
|
||||
symbols = {row["symbol"] for row in data}
|
||||
assert symbols == {"JP225", "EURUSD"}
|
||||
trading_client.shutdown.assert_called_once()
|
||||
|
||||
def test_ticket_filter_passed_through(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
trading_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test --ticket values are used to filter positions."""
|
||||
output = tmp_path / "close.json"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"-o",
|
||||
str(output),
|
||||
"close-positions",
|
||||
"--ticket",
|
||||
"2",
|
||||
"--dry-run",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(output.read_text())
|
||||
assert len(data) == 1
|
||||
assert data[0]["symbol"] == "EURUSD"
|
||||
trading_client.shutdown.assert_called_once()
|
||||
|
||||
def test_symbol_and_ticket_combined(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
trading_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test --symbol and --ticket apply AND semantics when combined."""
|
||||
output = tmp_path / "close.json"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"-o",
|
||||
str(output),
|
||||
"close-positions",
|
||||
"--symbol",
|
||||
"JP225",
|
||||
"--ticket",
|
||||
"1",
|
||||
"--dry-run",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(output.read_text())
|
||||
# symbol=JP225 AND ticket=1 → exactly one match
|
||||
assert len(data) == 1
|
||||
assert data[0]["symbol"] == "JP225"
|
||||
trading_client.shutdown.assert_called_once()
|
||||
|
||||
def test_missing_symbol_and_ticket_fails(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test that omitting both --symbol and --ticket fails closed."""
|
||||
mocker.patch("mt5cli.cli.create_trading_client")
|
||||
output = tmp_path / "close.json"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["-o", str(output), "close-positions", "--dry-run"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "symbol" in normalize_cli_output(result.output).lower()
|
||||
|
||||
def test_output_export_dry_run(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
trading_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test dry-run results export with status=dry_run."""
|
||||
output = tmp_path / "close.json"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["-o", str(output), "close-positions", "--symbol", "JP225", "--dry-run"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
trading_client.shutdown.assert_called_once()
|
||||
data = json.loads(output.read_text())
|
||||
assert data[0]["status"] == "dry_run"
|
||||
assert data[0]["dry_run"] is True
|
||||
assert data[0]["order_side"] == "SELL"
|
||||
|
||||
def test_order_send_unchanged(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test that order-send behavior is unchanged by close-positions addition."""
|
||||
output = tmp_path / "out.csv"
|
||||
request = json.dumps({"action": 1, "symbol": "EURUSD", "volume": 0.1})
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["-o", str(output), "order-send", "--request", request, "--yes"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_client.order_send_as_df.assert_called_once()
|
||||
|
||||
def test_shutdown_called_on_close_error(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test that shutdown is called even when close_open_positions raises."""
|
||||
client = _build_mock_trading_client()
|
||||
client.positions_get_as_df.side_effect = RuntimeError("connection lost")
|
||||
mocker.patch("mt5cli.cli.create_trading_client", return_value=client)
|
||||
output = tmp_path / "close.json"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["-o", str(output), "close-positions", "--symbol", "JP225", "--dry-run"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
client.shutdown.assert_called_once()
|
||||
|
||||
def test_dry_run_wins_over_yes(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
trading_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test that --dry-run takes precedence when combined with --yes."""
|
||||
output = tmp_path / "close.json"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"-o",
|
||||
str(output),
|
||||
"close-positions",
|
||||
"--symbol",
|
||||
"JP225",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
trading_client.order_send.assert_not_called()
|
||||
trading_client.shutdown.assert_called_once()
|
||||
|
||||
def test_no_matching_positions_exports_empty_result(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
trading_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test that zero filter matches produces an empty JSON array."""
|
||||
trading_client.positions_get_as_df.return_value = pd.DataFrame([
|
||||
{"ticket": 1, "symbol": "JP225", "type": 0, "volume": 1.0},
|
||||
])
|
||||
output = tmp_path / "close.json"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"-o",
|
||||
str(output),
|
||||
"close-positions",
|
||||
"--symbol",
|
||||
"NONEXISTENT",
|
||||
"--dry-run",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
trading_client.shutdown.assert_called_once()
|
||||
assert output.exists()
|
||||
assert json.loads(output.read_text()) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Callback / shared options
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+52
-36
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from importlib.metadata import requires
|
||||
from pathlib import Path
|
||||
from typing import get_type_hints
|
||||
from unittest.mock import MagicMock
|
||||
@@ -234,16 +235,19 @@ def test_is_recoverable_mt5_error(exc: Exception) -> None:
|
||||
assert is_recoverable_mt5_error(exc)
|
||||
|
||||
|
||||
def test_normalize_mt5_exception_maps_types() -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("exc", "expected_type"),
|
||||
[
|
||||
(Mt5RuntimeError("x"), Mt5ConnectionError),
|
||||
(Mt5TradingError("x"), Mt5OperationError),
|
||||
],
|
||||
)
|
||||
def test_normalize_mt5_exception_maps_types(
|
||||
exc: Exception,
|
||||
expected_type: type[Mt5ConnectionError | Mt5OperationError],
|
||||
) -> None:
|
||||
"""MT5 exceptions map to stable mt5cli types."""
|
||||
assert isinstance(
|
||||
normalize_mt5_exception(Mt5RuntimeError("x")),
|
||||
Mt5ConnectionError,
|
||||
)
|
||||
assert isinstance(
|
||||
normalize_mt5_exception(Mt5TradingError("x")),
|
||||
Mt5OperationError,
|
||||
)
|
||||
assert isinstance(normalize_mt5_exception(exc), expected_type)
|
||||
|
||||
|
||||
def test_call_with_normalized_errors_reraises_mapped_type() -> None:
|
||||
@@ -420,26 +424,24 @@ def test_normalize_time_columns_skips_absent_time_fields() -> None:
|
||||
assert list(result.columns) == ["open"]
|
||||
|
||||
|
||||
def test_normalize_time_columns_converts_unix_seconds() -> None:
|
||||
"""Numeric MT5 ``time`` values are interpreted as Unix seconds."""
|
||||
frame = pd.DataFrame({"time": [1704067200]})
|
||||
result = normalize_time_columns(frame, DataKind.rates)
|
||||
assert result.loc[0, "time"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
||||
|
||||
|
||||
def test_normalize_time_columns_converts_unix_milliseconds() -> None:
|
||||
"""Numeric MT5 ``time_msc`` values are interpreted as Unix milliseconds."""
|
||||
frame = pd.DataFrame({"time_msc": [1704067200000]})
|
||||
result = normalize_time_columns(frame, DataKind.ticks)
|
||||
assert result.loc[0, "time_msc"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
||||
|
||||
|
||||
def test_normalize_time_columns_preserves_utc_datetimes() -> None:
|
||||
"""Already-converted datetime values remain UTC-normalized."""
|
||||
aware = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
frame = pd.DataFrame({"time": [aware]})
|
||||
result = normalize_time_columns(frame, DataKind.rates)
|
||||
assert result.loc[0, "time"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
||||
@pytest.mark.parametrize(
|
||||
("col", "value", "kind"),
|
||||
[
|
||||
("time", 1704067200, DataKind.rates),
|
||||
("time_msc", 1704067200000, DataKind.ticks),
|
||||
("time", datetime(2024, 1, 1, tzinfo=UTC), DataKind.rates),
|
||||
("time", "2024-01-01T00:00:00+00:00", DataKind.rates),
|
||||
],
|
||||
)
|
||||
def test_normalize_time_columns_coerces_value(
|
||||
col: str,
|
||||
value: object,
|
||||
kind: DataKind,
|
||||
) -> None:
|
||||
"""Time column values are coerced to UTC timestamps regardless of input type."""
|
||||
frame = pd.DataFrame({col: [value]})
|
||||
result = normalize_time_columns(frame, kind)
|
||||
assert result.loc[0, col] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
||||
|
||||
|
||||
def test_normalize_time_columns_handles_optional_order_times() -> None:
|
||||
@@ -489,13 +491,6 @@ def test_ensure_utc_columns_skips_missing_columns() -> None:
|
||||
assert "time" in result.columns
|
||||
|
||||
|
||||
def test_normalize_time_columns_coerces_string_timestamps() -> None:
|
||||
"""String timestamps are parsed with timezone-aware datetime coercion."""
|
||||
frame = pd.DataFrame({"time": ["2024-01-01T00:00:00+00:00"]})
|
||||
result = normalize_time_columns(frame, DataKind.rates)
|
||||
assert result.loc[0, "time"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
||||
|
||||
|
||||
def test_ensure_utc_columns_coerces_non_mt5_columns() -> None:
|
||||
"""Non-MT5 columns still coerce to UTC datetimes."""
|
||||
frame = pd.DataFrame({"created_at": ["2024-01-01T00:00:00+00:00"]})
|
||||
@@ -838,3 +833,24 @@ class TestStableSdkContract:
|
||||
assert result.index.tz is not None
|
||||
assert "time" not in result.columns
|
||||
assert "close" in result.columns
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Packaging metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parquet_extra_declares_pyarrow() -> None:
|
||||
"""Package metadata lists pyarrow under the parquet optional extra."""
|
||||
reqs = requires("mt5cli") or []
|
||||
parquet_reqs = [r for r in reqs if "pyarrow" in r and "parquet" in r]
|
||||
assert parquet_reqs, "pyarrow not found in parquet optional extra"
|
||||
|
||||
|
||||
def test_pyarrow_not_in_core_dependencies() -> None:
|
||||
"""Pyarrow is not a core dependency; it belongs only in the parquet extra."""
|
||||
reqs = requires("mt5cli") or []
|
||||
core_reqs = [r for r in reqs if "extra ==" not in r]
|
||||
assert not any("pyarrow" in r for r in core_reqs), (
|
||||
"pyarrow should not appear in core dependencies"
|
||||
)
|
||||
|
||||
+19
-54
@@ -705,19 +705,25 @@ class TestIncrementalStart:
|
||||
assert starts["EURUSD", 1] == datetime(2024, 1, 2, tzinfo=UTC)
|
||||
assert starts["GBPUSD", 1] == datetime(2024, 1, 3, tzinfo=UTC)
|
||||
|
||||
def test_load_incremental_start_datetimes_requires_timeframe_column(
|
||||
@pytest.mark.parametrize(
|
||||
("ddl", "missing_col"),
|
||||
[
|
||||
("CREATE TABLE rates(symbol TEXT, time TEXT, open REAL)", "timeframe"),
|
||||
("CREATE TABLE rates(timeframe INTEGER, time TEXT, open REAL)", "symbol"),
|
||||
("CREATE TABLE rates(symbol TEXT, timeframe INTEGER, open REAL)", "time"),
|
||||
],
|
||||
)
|
||||
def test_load_incremental_start_datetimes_requires_column(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
ddl: str,
|
||||
missing_col: str,
|
||||
) -> None:
|
||||
"""Test rates tables without timeframe fail fast during incremental resume."""
|
||||
"""Test rates tables missing a required column fail fast."""
|
||||
fallback = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
with sqlite3.connect(tmp_path / "rates-without-timeframe.db") as conn:
|
||||
conn.execute("CREATE TABLE rates(symbol TEXT, time TEXT, open REAL)")
|
||||
conn.execute(
|
||||
"INSERT INTO rates(symbol, time, open) VALUES (?, ?, ?)",
|
||||
("EURUSD", "2024-01-02T00:00:00+00:00", 1.0),
|
||||
)
|
||||
with pytest.raises(ValueError, match="missing: timeframe") as exc_info:
|
||||
with sqlite3.connect(tmp_path / f"rates-no-{missing_col}.db") as conn:
|
||||
conn.execute(ddl)
|
||||
with pytest.raises(ValueError, match=f"missing: {missing_col}") as exc_info:
|
||||
load_incremental_start_datetimes(
|
||||
conn,
|
||||
Dataset.rates,
|
||||
@@ -725,47 +731,7 @@ class TestIncrementalStart:
|
||||
timeframes=[1],
|
||||
fallback_start=fallback,
|
||||
)
|
||||
assert "timeframe" in str(exc_info.value)
|
||||
|
||||
def test_load_incremental_start_datetimes_requires_symbol_column(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test rates tables without symbol fail fast during incremental resume."""
|
||||
fallback = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
with sqlite3.connect(tmp_path / "rates-no-symbol.db") as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates(timeframe INTEGER, time TEXT, open REAL)",
|
||||
)
|
||||
with pytest.raises(ValueError, match="missing: symbol") as exc_info:
|
||||
load_incremental_start_datetimes(
|
||||
conn,
|
||||
Dataset.rates,
|
||||
symbols=["EURUSD"],
|
||||
timeframes=[1],
|
||||
fallback_start=fallback,
|
||||
)
|
||||
assert "symbol" in str(exc_info.value)
|
||||
|
||||
def test_load_incremental_start_datetimes_requires_time_column(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test rates tables without time fail fast during incremental resume."""
|
||||
fallback = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
with sqlite3.connect(tmp_path / "rates-no-time.db") as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates(symbol TEXT, timeframe INTEGER, open REAL)",
|
||||
)
|
||||
with pytest.raises(ValueError, match="missing: time") as exc_info:
|
||||
load_incremental_start_datetimes(
|
||||
conn,
|
||||
Dataset.rates,
|
||||
symbols=["EURUSD"],
|
||||
timeframes=[1],
|
||||
fallback_start=fallback,
|
||||
)
|
||||
assert "time" in str(exc_info.value)
|
||||
assert missing_col in str(exc_info.value)
|
||||
|
||||
def test_load_incremental_start_datetimes_rejects_unrelated_rates_columns(
|
||||
self,
|
||||
@@ -1800,12 +1766,11 @@ class TestIncrementalIntegration:
|
||||
)
|
||||
assert written_tables == set()
|
||||
|
||||
def test_resolve_history_tick_flags_invalid(self) -> None:
|
||||
@pytest.mark.parametrize("flags", ["BAD", 7])
|
||||
def test_resolve_history_tick_flags_invalid(self, flags: str | int) -> None:
|
||||
"""Test invalid tick flags raise ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid tick flags"):
|
||||
resolve_history_tick_flags("BAD")
|
||||
with pytest.raises(ValueError, match="Invalid tick flags"):
|
||||
resolve_history_tick_flags(7)
|
||||
resolve_history_tick_flags(flags)
|
||||
|
||||
def test_resolve_history_timeframes_invalid(self) -> None:
|
||||
"""Test invalid timeframes raise ValueError."""
|
||||
|
||||
+31
-43
@@ -1938,29 +1938,28 @@ class TestResolveAccountSpec:
|
||||
assert [a.server for a in resolved] == ["Shared", "Fixed"]
|
||||
assert all(a.timeout == 1000 for a in resolved)
|
||||
|
||||
def test_resolve_account_spec_with_whole_dollar_env(
|
||||
@pytest.mark.parametrize(
|
||||
("allow_whole_dollar_env", "expected"),
|
||||
[
|
||||
(True, "secret"),
|
||||
(False, "$MT5_PASSWORD"),
|
||||
],
|
||||
)
|
||||
def test_resolve_account_spec_whole_dollar_password(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
allow_whole_dollar_env: bool,
|
||||
expected: str,
|
||||
) -> None:
|
||||
"""Account spec expands $ENV_NAME when allow_whole_dollar_env=True."""
|
||||
"""Test resolve_account_spec expands $ENV_NAME password only with opt-in."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
account = AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")
|
||||
|
||||
resolved = resolve_account_spec(account, allow_whole_dollar_env=True)
|
||||
resolved = resolve_account_spec(
|
||||
account, allow_whole_dollar_env=allow_whole_dollar_env
|
||||
)
|
||||
|
||||
assert resolved.password == "secret" # noqa: S105
|
||||
|
||||
def test_resolve_account_spec_whole_dollar_not_expanded_by_default(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test resolve_account_spec leaves $ENV_NAME literal by default."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
account = AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")
|
||||
|
||||
resolved = resolve_account_spec(account)
|
||||
|
||||
assert resolved.password == "$MT5_PASSWORD" # noqa: S105
|
||||
assert resolved.password == expected
|
||||
|
||||
def test_resolve_account_specs_with_whole_dollar_env(
|
||||
self,
|
||||
@@ -1994,38 +1993,27 @@ class TestResolveAccountSpec:
|
||||
class TestBuildConfigWholeDollarEnv:
|
||||
"""Tests for build_config with allow_whole_dollar_env."""
|
||||
|
||||
def test_build_config_substitutes_server_with_opt_in(
|
||||
@pytest.mark.parametrize(
|
||||
("env_var", "field", "env_value"),
|
||||
[
|
||||
("MT5_SERVER", "server", "Broker-Demo"),
|
||||
("MT5_PASSWORD", "password", "secret"),
|
||||
("MT5_PATH", "path", "/opt/mt5/terminal64.exe"),
|
||||
],
|
||||
)
|
||||
def test_build_config_substitutes_field_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
env_var: str,
|
||||
field: str,
|
||||
env_value: str,
|
||||
) -> None:
|
||||
"""build_config expands $ENV_NAME server when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
||||
"""Test build_config expands $ENV_NAME fields when opt-in is enabled."""
|
||||
monkeypatch.setenv(env_var, env_value)
|
||||
|
||||
config = build_config(server="$MT5_SERVER", allow_whole_dollar_env=True)
|
||||
config = build_config(**{field: f"${env_var}"}, allow_whole_dollar_env=True) # type: ignore[arg-type]
|
||||
|
||||
assert config.server == "Broker-Demo"
|
||||
|
||||
def test_build_config_substitutes_password_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""build_config expands $ENV_NAME password when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
|
||||
config = build_config(password="$MT5_PASSWORD", allow_whole_dollar_env=True)
|
||||
|
||||
assert config.password == "secret" # noqa: S105
|
||||
|
||||
def test_build_config_substitutes_path_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test build_config expands $ENV_NAME path when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_PATH", "/opt/mt5/terminal64.exe")
|
||||
|
||||
config = build_config(path="$MT5_PATH", allow_whole_dollar_env=True)
|
||||
|
||||
assert config.path == "/opt/mt5/terminal64.exe"
|
||||
assert getattr(config, field) == env_value
|
||||
|
||||
def test_build_config_leaves_dollar_literal_by_default(
|
||||
self,
|
||||
|
||||
+425
-545
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -111,6 +112,17 @@ class TestExportDataframe:
|
||||
result = pd.read_parquet(output)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
|
||||
def test_export_parquet_without_pyarrow(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
sample_df: pd.DataFrame,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test that a clear error is raised when pyarrow is not installed."""
|
||||
monkeypatch.setitem(sys.modules, "pyarrow", None)
|
||||
with pytest.raises(ImportError, match="mt5cli\\[parquet\\]"):
|
||||
export_dataframe(sample_df, tmp_path / "out.parquet", "parquet")
|
||||
|
||||
def test_export_sqlite3(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test SQLite3 export."""
|
||||
output = tmp_path / "out.db"
|
||||
|
||||
@@ -487,21 +487,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mt5cli"
|
||||
version = "0.9.5"
|
||||
version = "0.9.7"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "pdmt5" },
|
||||
{ name = "pyarrow" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
parquet = [
|
||||
{ name = "pyarrow" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "mkdocs" },
|
||||
{ name = "mkdocs-material" },
|
||||
{ name = "mkdocstrings", extra = ["python"] },
|
||||
{ name = "pandas-stubs" },
|
||||
{ name = "pyarrow" },
|
||||
{ name = "pymdown-extensions" },
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
@@ -514,9 +519,10 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8.1.0" },
|
||||
{ name = "pdmt5", specifier = ">=0.3.0" },
|
||||
{ name = "pyarrow", specifier = ">=19.0.0" },
|
||||
{ name = "pyarrow", marker = "extra == 'parquet'", specifier = ">=19.0.0" },
|
||||
{ name = "typer", specifier = ">=0.15.0" },
|
||||
]
|
||||
provides-extras = ["parquet"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@@ -524,6 +530,7 @@ dev = [
|
||||
{ name = "mkdocs-material", specifier = ">=9.7.6" },
|
||||
{ name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.4" },
|
||||
{ name = "pandas-stubs", specifier = ">=2.2.3.250527" },
|
||||
{ name = "pyarrow", specifier = ">=19.0.0" },
|
||||
{ name = "pymdown-extensions", specifier = ">=10.21.2" },
|
||||
{ name = "pyright", specifier = ">=1.1.407" },
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
|
||||
Reference in New Issue
Block a user