Files
Daichi Narushima 513eb7617d feat: add fetch_recent_history_deals_for_trading_client to stable SDK (#90)
* refactor: collapse repeated tests with pytest.mark.parametrize

Collapse 13 near-identical test methods into 4 parametrized tests
across test_cli.py and test_sdk.py, keeping all 1045 cases passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add fetch_recent_history_deals_for_trading_client to stable SDK

Adds a generic history deal retrieval helper for active trading clients,
a _HistoryDealsClientProtocol describing the minimal required interface,
clarified create_trading_client() docs (returns pdmt5.Mt5DataClient, not
MT5Client), 9 unit tests at 100% coverage, and updated trading.md and
public-contract.md with examples and out-of-scope strategy semantics note.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: narrow Mt5CliClient protocol claim and preserve empty deal DataFrame schema

- _HistoryDealsClientProtocol docstring and fetch_recent_history_deals_for_trading_client
  docstring now explicitly state that Mt5CliClient (mt5_session) exposes
  history_deals() not history_deals_get_as_df() and does not satisfy the protocol;
  the function is for trading-client sessions (pdmt5.Mt5DataClient) only
- Empty DataFrames with columns are now passed through with reset_index rather
  than replaced by a bare pd.DataFrame(), preserving schema for callers that rely
  on stable column names even in no-deal windows
- Tests updated to assert schema preservation on empty results and bare empty on None

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add combined protocol so create_trading_client() is type-safe with history deals helper

Adds _TradingHistoryDealsClientProtocol combining _Mt5ClientProtocol and
_HistoryDealsClientProtocol, and updates create_trading_client() and
mt5_trading_session() to return/yield this combined type so the natural SDK
flow `client = create_trading_client(...); fetch_recent_history_deals_for_trading_client(client)`
is type-safe under pyright strict without casts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: validate hours is finite before timedelta in fetch_recent_history_deals_for_trading_client

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Bump version to 1.1.1

---------

Co-authored-by: agent <agent@localhost>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 05:28:34 +09:00

10 KiB

Trading Module

::: mt5cli.trading

Trading-capable MT5 sessions

create_trading_client() and mt5_trading_session() complement the read-only mt5_session() helper in sdk.py. They return or yield an initialized client supporting order execution and account management, use Mt5Config.path to launch the terminal when configured, and mt5_trading_session() always calls shutdown() on exit.

create_trading_client() returns a raw pdmt5.Mt5DataClient instance, not the higher-level MT5Client wrapper. Use mt5_session() / MT5Client for read-only data collection; use mt5_trading_session() only where order placement or trading calculations are required.

from mt5cli import create_trading_client, mt5_trading_session

with mt5_trading_session(
    path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
    login="12345",
    password="secret",
    server="Broker-Demo",
    retry_count=2,
) as client:
    positions = client.positions_get_as_df(symbol="EURUSD")

client = create_trading_client(login=12345, server="Broker-Demo")
try:
    account = client.account_info_as_dict()
finally:
    client.shutdown()

login accepts int, numeric str, or an empty string; empty strings are treated as unset. path, password, server, and timeout are forwarded to pdmt5.Mt5Config, and omitted timeout values keep the lower-level default. Use mt5_session() / MT5Client for read-only data collection.

State and order helpers

These helpers are strategy-agnostic and do not depend on signal detection, betting logic, or scheduling code in downstream applications.

from mt5cli import (
    calculate_positions_margin,
    calculate_spread_ratio,
    calculate_margin_and_volume,
    close_open_positions,
    detect_position_side,
    determine_order_limits,
    estimate_order_margin,
    fetch_latest_closed_rates_for_trading_client,
    fetch_latest_closed_rates_indexed,
    get_account_snapshot,
    get_positions_frame,
    get_symbol_snapshot,
    get_tick_snapshot,
    normalize_order_volume,
    place_market_order,
)

account = get_account_snapshot(client)
symbol = get_symbol_snapshot(client, "EURUSD")
tick = get_tick_snapshot(client, "EURUSD")
positions = get_positions_frame(client, "EURUSD")
side = detect_position_side(client, "EURUSD")
spread_ratio = calculate_spread_ratio(client, "EURUSD")
volume = normalize_order_volume(
    0.15,
    volume_min=symbol["volume_min"],
    volume_max=symbol["volume_max"],
    volume_step=symbol["volume_step"],
)
buy_margin = (
    estimate_order_margin(client, "EURUSD", "BUY", volume) if volume > 0 else 0.0
)
open_margin = calculate_positions_margin(client, symbols=["EURUSD"])
closed_bars = fetch_latest_closed_rates_for_trading_client(
    client,
    symbol="EURUSD",
    granularity="M1",
    count=100,
)
# Or fetch with a UTC DatetimeIndex instead of a "time" column:
indexed_bars = fetch_latest_closed_rates_indexed(
    client,
    symbol="EURUSD",
    granularity="M1",
    count=100,
)
# indexed_bars.index is a UTC-aware DatetimeIndex named "time"
sizing = calculate_margin_and_volume(
    client,
    "EURUSD",
    unit_margin_ratio=0.5,
    preserved_margin_ratio=0.2,
)
limits = determine_order_limits(
    client,
    "EURUSD",
    side="long",
    stop_loss_limit_ratio=0.01,
    take_profit_limit_ratio=0.02,
)
preview = place_market_order(
    client,
    symbol="EURUSD",
    volume=sizing["buy_volume"],
    order_side="BUY",
    sl=limits["stop_loss"],
    tp=limits["take_profit"],
    dry_run=True,
)
closed = close_open_positions(client, symbols="EURUSD", dry_run=True)

detect_position_side() returns long for buy-only exposure, short for sell-only exposure, and None for no positions or mixed long/short exposure. calculate_spread_ratio() uses (ask - bid) / ((ask + bid) / 2) and raises Mt5OperationError when bid or ask is missing or non-positive. normalize_order_volume() returns 0.0 for invalid constraints or sub-minimum requests; check the result before calling estimate_order_margin(), which requires a positive finite volume. calculate_positions_margin() silently skips rows with missing symbols, non-positive volumes, non-finite volumes, or unsupported position types, but propagates Mt5OperationError from estimate_order_margin() when a valid row encounters invalid tick data or margin results from the broker.

SL/TP ratios for determine_order_limits() must satisfy 0 <= ratio < 1; 0 omits that level. SL/TP prices are rounded with symbol digits metadata when available. determine_order_limits() pre-validates computed SL/TP prices against available trade_stops_level * point metadata when present; violations raise Mt5OperationError. This is a planning helper only: it does not guarantee broker acceptance because live validation can still depend on price movement, bid/ask side, freeze levels, and server-side rules, and it does not validate trade_freeze_level. When symbol metadata cannot be loaded, protective prices still round with digits=8 and stop-level validation is skipped. unit_margin_ratio and preserved_margin_ratio for calculate_margin_and_volume() accept 0 <= ratio <= 1; unit_margin_ratio=0 requests one minimum valid unit when the post-reserve margin can afford it. Negative margin_free is clamped to 0.0 before sizing. Execution helpers return normalized OrderExecutionResult dictionaries containing the request, response, status, retcode, and dry_run flag; dry_run=True never sends an order or mutates Market Watch visibility. ensure_symbol_selected() adds hidden symbols to Market Watch before live order placement and SL/TP updates. Failed, malformed, or unknown broker retcodes are fail-closed and returned as status="failed" while keeping the normalized response for inspection.

Order planning return contracts

from mt5cli import MarginVolume, OrderLimits, OrderExecutionResult

sizing: MarginVolume = calculate_margin_and_volume(
    client,
    "EURUSD",
    unit_margin_ratio=0.5,
    preserved_margin_ratio=0.2,
)
limits: OrderLimits = determine_order_limits(
    client,
    "EURUSD",
    side="long",
    stop_loss_limit_ratio=0.01,
    take_profit_limit_ratio=0.02,
)
preview: OrderExecutionResult = place_market_order(
    client,
    symbol="EURUSD",
    volume=sizing["buy_volume"],
    order_side="BUY",
    sl=limits["stop_loss"],
    tp=limits["take_profit"],
    dry_run=True,
)
updates: list[OrderExecutionResult] = update_sltp_for_open_positions(
    client,
    symbol="EURUSD",
    stop_loss=limits["stop_loss"],
    dry_run=True,
)

Closes issue #33: strategy-neutral order planning and execution helpers exposed through the stable package root without embedding entry/exit policy.

Retrieving recent history deals

fetch_recent_history_deals_for_trading_client() fetches history deals from an already-connected trading client over a trailing time window. It works directly with the object returned by create_trading_client() (a raw pdmt5.Mt5DataClient) without requiring any additional wrapping.

The helper returns a chronologically sorted DataFrame with a RangeIndex and all columns from the underlying client (time, symbol, type, entry, volume, profit, position_id, etc.). It does not apply any strategy-specific transformations — entry/exit classification, Kelly fractions, and betting semantics belong in downstream applications.

from mt5cli import (
    create_trading_client,
    fetch_recent_history_deals_for_trading_client,
)

client = create_trading_client(login=12345, server="Broker-Demo")
try:
    deals_df = fetch_recent_history_deals_for_trading_client(
        client,
        symbol="JP225",
        hours=24,
    )
finally:
    client.shutdown()

Or inside a managed session:

from mt5cli import fetch_recent_history_deals_for_trading_client, mt5_trading_session

with mt5_trading_session(login=12345, server="Broker-Demo") as client:
    deals_df = fetch_recent_history_deals_for_trading_client(
        client,
        symbol="JP225",
        hours=48,
    )

hours must be positive; date_to defaults to datetime.now(UTC). An empty or None result from the underlying client is normalized to an empty DataFrame.

Downstream packages own all strategy-specific transformations. mt5cli does not provide entry-deal classification, Kelly sizing, or any betting-specific helpers.

Migration from application-local helpers

Application-local concern mt5cli replacement
Manual terminal spawn/kill around trading code mt5_trading_session()
Local position-side detection detect_position_side()
Local margin/volume sizing calculate_margin_and_volume()
Local broker volume step normalization normalize_order_volume()
Local order or position margin estimation estimate_order_margin(), calculate_positions_margin()
Local closed-bar fetch from a trading session fetch_latest_closed_rates_for_trading_client(), fetch_latest_closed_rates_indexed()
Local recent deal history fetch from a trading session fetch_recent_history_deals_for_trading_client()
Local SL/TP price derivation determine_order_limits()
Throttled SQLite history loop with ad-hoc error handling ThrottledHistoryUpdater(suppress_errors=True)

Keep read-only data collection on mt5_session() / MT5Client; use mt5_trading_session() only where order placement or trading calculations are required.