Skip to main content

pipsocket

Python client for the Pipsocket API: trade MetaTrader 5 accounts hosted by Pipsocket from Linux, macOS or Windows.

Why

The terminal runs at Pipsocket, so your code runs anywhere Python does: one pip install, no terminal on your machine, and typed answers.

The MetaTrader5 package pipsocket
Runs on Windows only (its wheels on PyPI are all win_amd64, checked 9 Nov 2026) Linux, macOS, Windows
Needs An MT5 terminal running on the same machine A Pipsocket account; the terminal is hosted

Install

pip install pipsocket

Python 3.10 or later. Its dependencies are httpx and websockets.

Quickstart

Make your workspace's API key on the dashboard's API key page, where your accounts' ids are listed too, and put the key in PIPSOCKET_KEY. Use a demo account while you try things.

Each symbol allows its own filling modes, and MT5 rejects an order with any other (retcode 10030), so read them, and the smallest volume, from the symbol rather than assuming one:

from pipsocket import Pipsocket

ps = Pipsocket()  # reads PIPSOCKET_KEY
acct = ps.account("acc_01m6tx3k9f2qw8r7bznd5yvh4c")

print(acct.get()["balance"])
eurusd = acct.symbols.get("EURUSD")
filling = eurusd["filling_modes"][0]  # one the symbol allows
volume = eurusd["volume_min"]

order = acct.orders.place(symbol="EURUSD", type="buy", volume=volume, type_filling=filling)
if order.outstanding:  # pending or unknown: it may still execute
    order = acct.requests.wait(order.id)

if order.accepted and order.result["deal"]:  # MT5 took it and made a deal: filled
    print("filled:", order.result["deal"], "at", order.result["price"])
    # On a hedging account the position's ticket is the order's.
    position = order.result["position"] or order.result["order"]
    closed = acct.positions.close(position, volume=volume, type_filling=filling)
    print("closed:", closed.state)
elif order.state == "rejected":
    print("rejected:", order.retcode_name)
else:
    print(order.state, order.message)

Answers are plain dicts, typed with TypedDicts from the API's OpenAPI document (pipsocket.types), so they go straight into anything that takes dicts:

from pipsocket import Pipsocket

acct = Pipsocket().account("acc_01m6tx3k9f2qw8r7bznd5yvh4c")

# History ranges are the broker's server time in seconds, both ends included, not UTC: take
# them from the account's server_time (None until its terminal has reported).
now = acct.get()["server_time"]
if now is not None:
    deals = acct.history.deals(now - 86400, now)  # the last 24 hours
    profit = sum(deal["profit"] for deal in deals)
    print(len(deals), "deals,", profit)

Order outcomes

Each trade call (orders.place, orders.modify, orders.cancel, positions.close, positions.close_by, positions.set_levels) is one MT5 trade request, sent once and recorded as a request (req_...). The call returns that request as a TradeRequest, whatever its state: a rejection is a result, not an exception. acct.requests.get(id) looks one up, and acct.requests.wait(id, timeout=30) looks it up until it is final or the time is up, then returns it as it stands; it never raises for "still pending" and never sends anything.

A TradeRequest has id, state, sent, result, message, error, settlement, the whole body as raw, the idempotency_key it was sent with and the http_status, plus accepted, final, outstanding, retcode and retcode_name.

from pipsocket import Pipsocket

acct = Pipsocket().account("acc_01m6tx3k9f2qw8r7bznd5yvh4c")

request = acct.requests.get("req_5f0c2a9e41d7b3a8c6e2f190")
match request.state:
    case "completed" | "placed" | "partial":
        if request.result["deal"]:
            print("filled: deal", request.result["deal"])
        else:  # a pending order placed, or a change made: no deal
            print("done, no deal: order", request.result["order"])
    case "rejected":
        print("MT5 rejected it:", request.retcode, request.retcode_name)
    case "pending" | "unknown":
        print("no outcome yet: it may still execute")
    case _:
        print(request.state, request.message)
state Meaning
completed MT5 completed the request (retcode 10009). Not necessarily a fill: see below. Final.
placed MT5 placed it (retcode 10008), such as a pending order. Final.
partial MT5 completed part of it (retcode 10010). Final.
rejected MT5 rejected it: retcode says why. Final.
pending Sent, no answer from the trade server yet. Never sent again.
unknown No answer within 130 seconds: it may have executed. Settled by proof only.
not_sent The terminal didn't send it: result["error"] says why. Final.
not_executed Was unknown, settled as never executed (settlement says by whom). Final.
executed Was unknown, settled as executed by the account's owner. Final.

completed means MT5 completed the request, whatever it was: a market order filled, but also a pending order placed, an order modified or cancelled, or stops set. So accepted (completed, placed or partial) says MT5 took the request, not that anything filled. A fill shows as a deal: result["deal"] is non-zero (and sent["action"] is "deal"). A pending order that was placed has result["order"] set and result["deal"] 0.

raise_for_state() raises TradeNotAccepted for rejected, not_sent and not_executed, for those who prefer exceptions. orders.check(...) is MT5's dry run of an order and sends nothing to the broker. A state this SDK doesn't know yet comes through as its string, neither accepted nor final. See Errors and retcodes.

Streaming

acct.stream(...) keeps the account's WebSocket open: requests settling, deals, prices and the account as they happen, reconnected for you.

from pipsocket import Pipsocket, TradeRequest

acct = Pipsocket().account("acc_01m6tx3k9f2qw8r7bznd5yvh4c")

with acct.stream(ticks=["EURUSD"]) as events:
    for event in events:
        match event["type"]:
            case "resync":  # after every (re)connection or gap: a new baseline
                print("resync:", event["reason"], len(event["positions"] or []), "positions")
            case "tick":
                print(event["symbol"], event["bid"], event["ask"])
            case "request":
                print("settled:", TradeRequest.from_event(event).state)
                break

Events are the API's messages as they are (see the WebSocket stream), plus one the SDK adds, resync. The helper sends the tick set again after every reconnect, answers the keep-alive, and resyncs when the socket was closed, the stream said lagged, the account's host came back, or your code fell behind (reason: connected, reconnected, lagged, host_back, overflow).

What a resync gives:

  • A new baseline: account, positions and orders read over REST after the stream was live. Replace your own state with it. positions and orders are None when the terminal isn't running, which is not the same as empty.
  • History since the last checkpoint, best effort: history has the deals and orders from the broker's server time from to to, or is None with history_reason saying why (no_checkpoint, account_changed, range_too_large or unavailable).
  • Every deal, request, transaction and status event from then on, delivered after it.

What it doesn't give: one moment in time (the reads happen one after another), an order between events and the snapshot (events are notices, not deltas: never add them to the snapshot), or the events from a gap. Events that happened while the stream was away are not replayed, request events included: the resync's snapshot and history cover what changed, and for a request you are waiting on, acct.requests.wait(id) looks it up over REST. Ticks and transaction events from a gap are gone. Deduplicate deals and orders by ticket and requests by id, and read again when exact state matters. snapshot=False sends resync without reading anything.

A resync's history starts 5 minutes before its checkpoint (the newest broker time the stream delivered), so it can include deals from before you opened the stream; deals the stream already delivered as events are left out of it.

The sync iterator (for event in events) blocks until an event arrives. To stop it, break out of the loop, or call events.close() from another thread: iteration then ends at once, and events still queued are not delivered. AsyncPipsocket has the same helper, with async with and async for, where cancelling the task stops it too.

While a stream is open on an account handle, acct.requests.wait(id) ends as soon as the stream sees the request settle.

Errors, retries and idempotency keys

An exception means there is no recorded request in hand. Every exception derives from PipsocketError. A refusal from the API is an APIError with status, code (the API's error), message, body and raw, and a subclass for each kind: AuthenticationError, NotFound, InvalidRequest, SymbolBusy, KillSwitchEngaged, BillingHold, AccountUnavailable, EngineBusy, Unavailable, TerminalTimeout, TerminalError, NoPrice and the rest. A network failure is an APIConnectionError (or APITimeoutError). None of these, raised from a trade call, means anything was sent, with one exception:

OutcomeUnknown means the trade may have reached the account's host and no answer came back. It may have executed. It carries the idempotency_key the call sent and the last error as cause. Call again with the same key: you get the recorded request if there is one, and the trade is sent if it never arrived. Or read positions and history first.

Every trade call sends an Idempotency-Key: a new one per call, the same one on every retry inside the call. Calls that fail in a way worth repeating (the host busy or away, a dropped connection, an answer that isn't the API's JSON, symbol_busy with Retry-After) are tried again, twice by default, waiting 0.5 s and then 1 s (or what Retry-After says), and no new attempt starts 60 seconds after the first: a market order sent minutes late is not what you asked for. Refusals that won't change, such as a bad key or kill_switch_engaged, are raised at once.

To survive a crash or a cancellation, make the key yourself and save it before sending. After a restart, the same call with the same key returns the recorded request instead of trading again. Make a new key for every new trade, and never build one from the order's fields (such as "buy-eurusd"): the key is the whole match, so a second order with the same key returns the first one and sends nothing.

A key protects a trade only while Pipsocket keeps its request: until 7 days after its outcome is known. After that the same key sends a new trade. So resolve a journal's open entries promptly, and never replay an old one blindly: check positions and history first.

import json
import pathlib

import pipsocket
from pipsocket import OutcomeUnknown, Pipsocket

acct = Pipsocket().account("acc_01m6tx3k9f2qw8r7bznd5yvh4c")
journal = pathlib.Path("orders.jsonl")


def note(entry: dict[str, object]) -> None:
    with journal.open("a") as file:
        file.write(json.dumps(entry) + "\n")


eurusd = acct.symbols.get("EURUSD")
filling, volume = eurusd["filling_modes"][0], eurusd["volume_min"]
key = pipsocket.new_key()
note({"key": key, "symbol": "EURUSD", "volume": volume})  # saved before it is sent

try:
    order = acct.orders.place(
        symbol="EURUSD", type="buy", volume=volume, type_filling=filling, idempotency_key=key
    )
except OutcomeUnknown as error:
    # The entry stays open: after a restart, resolve it with the same key.
    print("may have executed; call again with", error.idempotency_key)
else:
    if order.final:
        note({"key": key, "request": order.id, "state": order.state})  # resolved
    else:
        print("no outcome yet: wait for", order.id, "before closing the entry")
    print(order.id, order.state)

Timeouts and retries can be set on the client, or per call:

from pipsocket import Pipsocket

ps = Pipsocket(timeout=20, max_retries=4, retry_deadline=30)  # timeout is per attempt
acct = ps.account("acc_01m6tx3k9f2qw8r7bznd5yvh4c")
print(acct.kill_switch.get(max_retries=0)["engaged"])

The kill switch closes every position and cancels every pending order, and refuses new exposure until it is lifted. Engaging it again retries whatever is left:

from pipsocket import AccountUnavailable, Pipsocket

acct = Pipsocket().account("acc_01m6tx3k9f2qw8r7bznd5yvh4c")
try:
    outcome = acct.kill_switch.engage(deviation=20)
    print("flat:", outcome["flat"])
except AccountUnavailable as error:
    print("engaged, but the terminal didn't answer:", error.kill_switch)

Sync and async

Pipsocket is synchronous; AsyncPipsocket has the same calls, awaited.

import asyncio

from pipsocket import AsyncPipsocket


async def main() -> None:
    async with AsyncPipsocket() as ps:
        acct = ps.account("acc_01m6tx3k9f2qw8r7bznd5yvh4c")
        account, positions = await asyncio.gather(acct.get(), acct.positions.list())
        print(account["equity"], len(positions))


asyncio.run(main())

Keep the key on your server: the API doesn't answer browsers, and the key is never logged or put in an error message by this SDK.

Versioning and support

Versions follow semver and are 0.x for now: until 1.0, a minor release (0.2, 0.3) may change the API of this SDK, and the changelog says how. Report bugs in this SDK on GitHub issues. Questions about your account or billing go to support@pipsocket.com.

Licence

MIT. See LICENSE.

MetaTrader is a trademark of MetaQuotes Ltd. Pipsocket is not affiliated with MetaQuotes.

Metadata

Release files for pipsocket 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pipsocket 0.1.0
File Size Uploaded Provenance Actions
pipsocket-0.1.0.tar.gz 122.7 kB With provenance Details for pipsocket-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for pipsocket 0.1.0
File Interpreter ABI Platform Actions
pipsocket-0.1.0-py3-none-any.whl Python 3 none any Details for pipsocket-0.1.0-py3-none-any.whl

Total release size: 181.2 kB

Release files / pipsocket-0.1.0.tar.gz

Download URL pipsocket-0.1.0.tar.gz
Size 122.7 kB
Tags Source
SHA-256 checksum
How to use checksums
0236a35e55cc0acf99d88279f7a49e4b58058b826c997b5375bc9f0e1ee6b2c2
BLAKE2b-256 checksum
How to use checksums
734d815f8593879bfdae6d439970b68ed8908e0c7ec046d20acaa56d832af91e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 10, 2026.

Transparency log

Release files / pipsocket-0.1.0-py3-none-any.whl

Download URL pipsocket-0.1.0-py3-none-any.whl
Size 58.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c8f6fb030f62c9ade43f93230cdb1190508073b70a7a5db895e02f567c6a7d7f
BLAKE2b-256 checksum
How to use checksums
a94bc579e1131ada0caa7868597ff7db192b340e8fc5613e7705deb9e377b46a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 10, 2026.

Transparency log

Security for pipsocket 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for pipsocket-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 10, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page