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,positionsandordersread over REST after the stream was live. Replace your own state with it.positionsandordersareNonewhen the terminal isn't running, which is not the same as empty. - History since the last checkpoint, best effort:
historyhas thedealsandordersfrom the broker's server timefromtoto, or isNonewithhistory_reasonsaying why (no_checkpoint,account_changed,range_too_largeorunavailable). - 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.
Links
- Docs: Quickstart, API reference, WebSocket stream, Errors and retcodes
- MCP server for AI apps
- Changelog
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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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 logRelease 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 logSecurity 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.
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 logA 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.