Several terminals on one broker login
Runs against the dev environment today, where adding a second terminal to a login was run on 3 October 2026. The sample it quotes is [
../samples/several_terminals_one_login.py].
You can run more than one terminal on the same broker login, on every plan. A common reason: one terminal runs an EA on crypto, another streams and trades everything else, and you want both live at once.
Every terminal can do everything: trade, run an EA, stream its own instruments. There is no “main” terminal and no read-only copy.
They share the account
The terminals are separate sessions of one account at the broker. Each of them sees the same balance, the same positions, the same orders and the same history. That has two consequences worth planning for:
- An order sent from one terminal is visible from all of them. MFX Hub never sends one order twice. If two of your terminals each send the same order, the broker receives two orders.
- On a netting account, two strategies on one symbol net into one position. A buy from one terminal and a sell from another reduce each other. If each strategy needs its own position, use a hedging account or separate accounts.
They share a symbol’s prices
Prices for a symbol on a broker’s server are one stream for all your terminals there. If two of your terminals watch the same symbol, a subscriber to that symbol’s price channel gets each tick once, whichever terminal read it first, not once for each terminal.
Ticks are samples. A tick older than one already sent for the symbol is not sent, so prices only move forward in the broker’s time; and if the platform is ever slow, late ticks are dropped and the latest price still arrives. Read the latest price as the truth, and do not count ticks.
Add one
Request a terminal as you did the first one, then give it the same login. From the sample:
return await client.terminals.create(
farm_id=field(like, "farm")["id"],
broker_id=field(like, "broker")["id"],
server_id=field(like, "server")["id"],
streaming=bool(field(like, "streaming", False)),
idempotency_key=f"same-login-{uuid.uuid4()}",
)
return await client.terminals.submit_credentials(
terminal_id,
login=login,
password=password,
server=server,
idempotency_key=f"same-login-credential-{uuid.uuid4()}",
)
Each terminal holds its own copy of the credential. MFX Hub never copies a password from one of your terminals to another.
How many
Two limits apply, and the refusal tells you which one you met. Both are checked when you submit the credential, and a refusal stores nothing.
| Limit | Counts | The refusal |
|---|---|---|
| Your plan’s terminals per login | your own terminals on the login that are not RETIRED or FAILED |
409 quota_exceeded, details.reason is login_terminal_limit, with details.quota and details.in_use |
| The broker’s sessions per login | every session on the login | 409 placement_error, details.reason is login_session_limit |
The broker’s limit is the broker’s own rule, and it counts every session on the login, including ones MFX Hub cannot see, such as a desktop terminal you are logged in to yourself. That is why the second refusal carries no numbers.
From the sample:
reason = refused.details.get("reason")
The first time a server holds one more session
Brokers do not publish how many sessions a login may hold, so MFX Hub learns it per broker server by watching. When the terminal you add is one more session than MFX Hub has seen that server hold, its first minutes are watched:
- The broker holds them all. Nothing more happens. This is the usual case.
- The broker drops one of your other sessions to make room. MFX Hub stops the terminal you just
added, so the earlier one reconnects. The new terminal reads
DRAININGand thenRETIRED, with the reasonthe broker allows N sessions on this login. From then on, one terminal too many is refused when you submit its credential, before anything is started.
Only one such check runs on a broker server at a time. While it runs, another request for one more
session on that server answers 409 placement_error with details.retry_after_s. Wait that
long and submit again.
How many of yours share a login
Every terminal you read carries same_login_terminals: how many of your terminals that are not
RETIRED or FAILED use its login, this one included. It is empty until the terminal has a
credential.
print(f"{terminal_id} {seen.state}; yours on its login: "
f"{field(seen, 'same_login_terminals')}") # fmt: skip
Changing the password
Because each terminal holds its own copy, a password you change at the broker has to be submitted
to each terminal on the login. The answer to a change tells you how many are left:
same_login_terminals_stale is the number of your other terminals on the login that were last
given their password before this one. Work through them until it reads 0. See
Changing a terminal’s password.
Retiring one
Retiring a terminal ends its session and nothing else. The other terminals on the login keep
running, and their same_login_terminals drops by one.
Run it
python samples/several_terminals_one_login.py <terminal id> --key-id mfxk_... --private-key-file <your key file>
<terminal id> is a terminal of yours that already uses the login. Your key needs the scopes
term:read and term:write. The login, the server and the password are typed at a prompt and go
nowhere else. The script prints each change of state and ends with PASS when the new terminal is
ACTIVE.
The runnable sample: samples/several_terminals_one_login.py
Executed in CI against MFX Hub on every change to the knowledge base.
"""One more terminal on a broker login you already use, and how many of yours now share it.
The runnable half of guides/several-terminals-on-one-login.md. It does only what a customer can do:
every call is the public, signed API through the SDK.
python samples/several_terminals_one_login.py <terminal id> --key-id mfxk_... --private-key-file <your key file>
`<terminal id>` is a terminal of yours that already uses the login. The new terminal is requested on
the same farm, broker and server. The login, the server and the password are typed at a prompt.
They are never an argument, an environment variable or a file, so they reach no shell history, no
process list and no CI log.
"""
from __future__ import annotations
import argparse
import asyncio
import getpass
import time
import uuid
from pathlib import Path
from typing import Any
from mfx import MfxApiError, MfxClient, Secret, SigningKey
BASE_URL = "https://api.dev.mfxhub.com/v1"
POLL_S = 5
#: A first login is usually under two minutes; a cold host can take longer.
TIMEOUT_S = 20 * 60
#: What shows a terminal is logged in. Ticks are not part of it: a terminal with no instruments,
#: or one whose market is closed, is logged in and quotes nothing.
LIVE = ("ProcessRunning", "BrokerConnected")
#: The two limits on terminals per login, as `details.reason` names them.
PLAN_LIMIT = "login_terminal_limit"
BROKER_LIMIT = "login_session_limit"
def field(answer: Any, name: str, default: Any = None) -> Any:
"""A field of the answer, whether or not this SDK version has a typed attribute for it."""
data = answer.to_dict() if hasattr(answer, "to_dict") else dict(answer)
return data.get(name, default)
def explain(refused: MfxApiError) -> str:
"""Which limit refused one more terminal on the login, in a sentence."""
reason = refused.details.get("reason")
if reason == PLAN_LIMIT:
quota, in_use = refused.details.get("quota"), refused.details.get("in_use")
return f"your plan allows {quota} terminals on one login and you have {in_use}"
if reason == BROKER_LIMIT and refused.details.get("retry_after_s"):
wait = refused.details["retry_after_s"]
return f"the broker's limit on this server is being measured; try again in {wait} s"
if reason == BROKER_LIMIT:
return "the broker's limit of sessions on this login is reached"
return str(refused)
async def add_terminal(client: MfxClient, like: Any) -> Any:
"""Request one more terminal on the same farm, broker and server as `like`."""
return await client.terminals.create(
farm_id=field(like, "farm")["id"],
broker_id=field(like, "broker")["id"],
server_id=field(like, "server")["id"],
streaming=bool(field(like, "streaming", False)),
idempotency_key=f"same-login-{uuid.uuid4()}",
)
async def submit_login(
client: MfxClient, terminal_id: str, *, login: int, server: str, password: Secret
) -> Any:
"""Give the new terminal the same login. Each terminal holds its own copy of the credential."""
return await client.terminals.submit_credentials(
terminal_id,
login=login,
password=password,
server=server,
idempotency_key=f"same-login-credential-{uuid.uuid4()}",
)
def live(terminal: Any) -> bool:
"""ACTIVE with the process up and the broker connected."""
if str(terminal.state) != "ACTIVE":
return False
seen = terminal.conditions.to_dict() if terminal.conditions else {}
return all(seen.get(name, {}).get("status") == "ok" for name in LIVE)
async def wait_until_live(client: MfxClient, terminal_id: str) -> str:
"""Poll until the terminal is logged in, failed or stopped, printing each change."""
started = time.monotonic()
last = None
while time.monotonic() - started < TIMEOUT_S:
terminal = await client.terminals.get(terminal_id)
shown = (str(terminal.state), live(terminal))
if shown != last:
print(f"[{int(time.monotonic() - started):>4}s] {shown[0]} live={shown[1]}")
last = shown
if shown[0] in ("FAILED", "DRAINING", "RETIRED"):
return shown[0]
if shown[1]:
return "ACTIVE"
await asyncio.sleep(POLL_S)
raise SystemExit(f"FAILED: not live after {TIMEOUT_S}s")
async def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("terminal_id", help="a terminal of yours that already uses the login")
parser.add_argument("--key-id", required=True)
parser.add_argument("--private-key-file", required=True, type=Path)
parser.add_argument("--base-url", default=BASE_URL)
args = parser.parse_args()
client = MfxClient(
args.base_url, args.key_id, SigningKey(args.private_key_file.read_text().strip())
)
first = await client.terminals.get(args.terminal_id)
print(f"{args.terminal_id} is {first.state}; yours on its login: "
f"{field(first, 'same_login_terminals')}") # fmt: skip
login = int(input("Broker login (the one that terminal already has): "))
server = input("Broker server: ").strip()
password = Secret(getpass.getpass("Password: "))
second = await add_terminal(client, first)
print(f"requested {second.id}: {second.state}")
try:
await submit_login(client, str(second.id), login=login, server=server, password=password)
except MfxApiError as refused:
# Nothing was stored. The terminal you requested has no credential; retire it.
await client.terminals.retire(str(second.id), idempotency_key=f"retire-{uuid.uuid4()}")
raise SystemExit(f"REFUSED: {explain(refused)}") from refused
state = await wait_until_live(client, str(second.id))
for terminal_id in (args.terminal_id, str(second.id)):
seen = await client.terminals.get(terminal_id)
print(f"{terminal_id} {seen.state}; yours on its login: "
f"{field(seen, 'same_login_terminals')}") # fmt: skip
print("PASS" if state == "ACTIVE" else f"FAILED: the new terminal is {state}; read its conditions")
if __name__ == "__main__":
asyncio.run(main())