Forráskód Böngészése

Document client-id order recovery

Lukas Goldschmidt 1 hónapja
szülő
commit
b26a0a30f4

+ 16 - 6
README.md

@@ -35,13 +35,16 @@ This service is the order-and-account layer of the stack. It owns exchange acces
 
 ## Current status
 
-This folder is the starting scaffold. The architecture and intent are captured in `trader27.md` and will be expanded into:
+This service is operational and used by the dashboard and MCP tools.
 
-- server contract
-- account/exchange adapters
-- execution state model
-- logging and audit trail
-- test harness / paper trading mode
+Implemented pieces include:
+
+- account CRUD in the dashboard
+- Bitstamp market/currency metadata cache
+- account info lookup
+- order placement, query, and cancel
+- client-scoped open-order recovery via `client_id`
+- bulk cancel that skips stale exchange-missing rows and marks them locally
 
 ## Dashboard shape
 
@@ -76,3 +79,10 @@ These are stored in SQLite for reuse across restarts.
 `exec-mcp` no longer owns public live-price streaming. Price lookup is expected to come from `crypto` MCP's cached `get_price` path when needed.
 
 `exec-mcp` keeps only the Bitstamp private websocket needed for order state updates.
+
+## Order recovery notes
+
+- `client_id` is optional on `place_order` and is stored on the local order record.
+- `get_open_orders(account_id, client_id=None)` filters by `client_id` when supplied.
+- `cancel_all_orders(account_id, client_id=None)` cancels all open orders for the account, and if an exchange order is already missing it is marked locally so it no longer appears open.
+- `expire_time` is optional, no `expire_time` means a normal order, and `expire_time` means a GTD order.

+ 1 - 1
pyproject.toml

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "exec-mcp"
-version = "0.1.0"
+version = "0.1.1"
 description = "Execution MCP for Trader27"
 readme = "README.md"
 requires-python = ">=3.11"

+ 22 - 0
src/exec_mcp/bitstamp.py

@@ -47,6 +47,17 @@ class LG_Trading(Trading):
         url = self._construct_url("sell/", base, quote)
         return self._post(url, data=data, return_json=True, version=2)
 
+    def sell_order(self, amount, price, base="btc", quote="usd", limit_price=None, ioc_order=False, gtd_order=False):
+        data = {'amount': amount, 'price': price}
+        if limit_price is not None:
+            data['limit_price'] = limit_price
+        if ioc_order is True:
+            data['ioc_order'] = True
+        if gtd_order is True:
+            data['gtd_order'] = True
+        url = self._construct_url("sell/", base, quote)
+        return self._post(url, data=data, return_json=True, version=2)
+
     def buy_gtd_order(self, amount, price, base="btc", quote="usd", limit_price=None, ioc_order=False, gtd_order=True, expire_time=None):
         data = {'amount': amount, 'price': price}
         if limit_price is not None:
@@ -60,6 +71,17 @@ class LG_Trading(Trading):
         url = self._construct_url("buy/", base, quote)
         return self._post(url, data=data, return_json=True, version=2)
 
+    def buy_order(self, amount, price, base="btc", quote="usd", limit_price=None, ioc_order=False, gtd_order=False):
+        data = {'amount': amount, 'price': price}
+        if limit_price is not None:
+            data['limit_price'] = limit_price
+        if ioc_order is True:
+            data['ioc_order'] = True
+        if gtd_order is True:
+            data['gtd_order'] = True
+        url = self._construct_url("buy/", base, quote)
+        return self._post(url, data=data, return_json=True, version=2)
+
     def fees_trading(self, base=False, quote=False):
         url = self._construct_url("fees/trading/", base, quote)
         return self._post(url, return_json=True, version=2)

+ 52 - 13
src/exec_mcp/server.py

@@ -13,7 +13,7 @@ from .services_bitstamp import fetch_account_info as fetch_remote_account_info
 from .bitstamp_metadata import METADATA_REFRESH_SECONDS, refresh_metadata, list_markets as bitstamp_list_markets, list_currencies as bitstamp_list_currencies
 from .bitstamp_fx import FX_REFRESH_SECONDS, refresh_eur_usd
 from .bitstamp_private_ws import private_ws_main
-from .services_orders import place_order as service_place_order, query_order as service_query_order, cancel_order as service_cancel_order
+from .services_orders import place_order as service_place_order, query_order as service_query_order, cancel_order as service_cancel_order, get_open_orders as service_get_open_orders, cancel_all_orders as service_cancel_all_orders
 from .storage import init_db
 
 mcp = FastMCP("exec-mcp")
@@ -234,28 +234,39 @@ def http_dashboard_delete_account(account_id: str) -> RedirectResponse:
 
 @mcp.tool()
 def list_accounts(enabled_only: bool = True, venue: str | None = None) -> list[dict]:
-    """List configured accounts.
+    """list_accounts(enabled_only=True, venue=None)
 
-    `enabled_only` defaults to true. If `venue` is omitted, all venues are returned.
+    Return account metadata only, never secrets. If `venue` is set, filter to
+    that exchange. `enabled_only` defaults to true.
     """
     return repo.list_accounts(venue=venue, enabled_only=enabled_only)
 
 
 @mcp.tool()
 def list_markets() -> list[dict]:
-    """Return the cached Bitstamp markets metadata."""
+    """list_markets()
+
+    Return cached Bitstamp market metadata from the local SQLite cache.
+    """
     return bitstamp_list_markets()
 
 
 @mcp.tool()
 def list_currencies() -> list[dict]:
-    """Return the cached Bitstamp currencies metadata."""
+    """list_currencies()
+
+    Return cached Bitstamp currency metadata from the local SQLite cache.
+    """
     return bitstamp_list_currencies()
 
 
 @mcp.tool()
 def get_account_info(account_id: str) -> dict:
-    """Return account details and live valuation when available."""
+    """get_account_info(account_id)
+
+    Return account metadata plus venue-specific live account details when
+    available.
+    """
     account = repo.get_account(account_id)
     if account["venue"] == "bitstamp":
         return fetch_remote_account_info(account_id)
@@ -263,26 +274,54 @@ def get_account_info(account_id: str) -> dict:
 
 
 @mcp.tool()
-def place_order(account_id: str, market: str, side: str, order_type: str, amount, price=None, expire_time: int | None = None, client_order_id: str | None = None) -> dict:
-    """Place a Bitstamp order.
+def place_order(account_id: str, market: str, side: str, order_type: str, amount, price=None, expire_time: int | None = None, client_id: str | None = None, client_order_id: str | None = None) -> dict:
+    """place_order(account_id, market, side, order_type, amount, price=None, expire_time=None, client_id=None, client_order_id=None)
 
-    `market` is a Bitstamp symbol like `xrpusd`, `amount` is base units, and
-    `expire_time` is relative seconds from now.
+    Place a Bitstamp order. `market` is a Bitstamp symbol like `xrpusd`,
+    `amount` is base units, and `expire_time` is relative seconds from now.
+    `client_id` is optional and must be a string when provided.
     """
-    return service_place_order(account_id=account_id, market=market, side=side, order_type=order_type, amount=amount, price=price, expire_time=expire_time, client_order_id=client_order_id)
+    return service_place_order(account_id=account_id, market=market, side=side, order_type=order_type, amount=amount, price=price, expire_time=expire_time, client_id=client_id, client_order_id=client_order_id)
 
 
 @mcp.tool()
 def query_order(account_id: str, order_id, client_order_id: str | None = None, omit_transactions: bool | None = None) -> dict:
-    """Query a Bitstamp order by order id."""
+    """query_order(account_id, order_id, client_order_id=None, omit_transactions=None)
+
+    Query a Bitstamp order by exchange order id. `client_order_id` and
+    `omit_transactions` are optional Bitstamp query parameters.
+    """
     return service_query_order(account_id=account_id, order_id=order_id, client_order_id=client_order_id, omit_transactions=omit_transactions)
 
 
 @mcp.tool()
 def cancel_order(account_id: str, order_id) -> dict:
-    """Cancel a Bitstamp order by order id."""
+    """cancel_order(account_id, order_id)
+
+    Cancel one Bitstamp order by exchange order id.
+    """
     return service_cancel_order(account_id=account_id, order_id=order_id)
 
 
+@mcp.tool()
+def get_open_orders(account_id: str, client_id: str | None = None) -> dict:
+    """get_open_orders(account_id, client_id=None)
+
+    List open orders for one account. If `client_id` is provided, only return
+    orders recorded with that client identifier.
+    """
+    return service_get_open_orders(account_id=account_id, client_id=client_id)
+
+
+@mcp.tool()
+def cancel_all_orders(account_id: str, client_id: str | None = None) -> dict:
+    """cancel_all_orders(account_id, client_id=None)
+
+    Cancel all open orders for one account. If `client_id` is provided, only
+    cancel orders recorded with that client identifier.
+    """
+    return service_cancel_all_orders(account_id=account_id, client_id=client_id)
+
+
 def main() -> None:
     init_db()

+ 88 - 7
src/exec_mcp/services_orders.py

@@ -12,6 +12,9 @@ from .bitstamp_metadata import load_market_by_symbol
 from .storage import get_connection
 
 
+OPEN_ORDER_STATUSES = {"open", "new", "partially_filled"}
+
+
 def _utc_now() -> str:
     return datetime.now(timezone.utc).isoformat()
 
@@ -32,6 +35,30 @@ def _format_decimal(value, decimals: int) -> str:
     return str(Decimal(str(value)).quantize(quant, rounding=ROUND_DOWN))
 
 
+def _normalize_client_id(client_id: str | None) -> str | None:
+    if client_id is None:
+        return None
+    if not isinstance(client_id, str):
+        raise HTTPException(status_code=400, detail="client_id must be a string")
+    client_id = client_id.strip()
+    if not client_id:
+        raise HTTPException(status_code=400, detail="client_id must not be empty")
+    return client_id
+
+
+def _normalize_status(status) -> str:
+    return str(status or "unknown").strip().lower().replace(" ", "_")
+
+
+def _set_local_order_status(*, bitstamp_order_id: str, status: str) -> None:
+    with get_connection() as conn:
+        conn.execute(
+            "UPDATE order_records SET status = ?, updated_at = ? WHERE bitstamp_order_id = ?",
+            (_normalize_status(status), _utc_now(), bitstamp_order_id),
+        )
+        conn.commit()
+
+
 def _validate_order_shape(market: str, side: str, order_type: str, amount, price) -> tuple[str, str | None, dict]:
     meta = load_market_by_symbol(market)
     if meta is None:
@@ -61,8 +88,9 @@ def _validate_order_shape(market: str, side: str, order_type: str, amount, price
     return amount_fmt, price_fmt, meta
 
 
-def place_order(*, account_id: str, market: str, side: str, order_type: str, amount, price=None, expire_time: int | None = None, client_order_id: str | None = None) -> dict:
+def place_order(*, account_id: str, market: str, side: str, order_type: str, amount, price=None, expire_time: int | None = None, client_id: str | None = None, client_order_id: str | None = None) -> dict:
     client = _get_client(account_id)
+    client_id = _normalize_client_id(client_id)
     side = side.lower()
     order_type = order_type.lower()
     market = market.lower()
@@ -81,9 +109,15 @@ def place_order(*, account_id: str, market: str, side: str, order_type: str, amo
         if order_type not in {"market", "limit"}:
             raise HTTPException(status_code=400, detail="invalid order_type")
         if side == "buy":
-            result = client.trading.buy_gtd_order(amount=amount, price=price or "0", base=base, quote=quote, expire_time=expire_timestamp)
+            if expire_timestamp is None:
+                result = client.trading.buy_order(amount=amount, price=price or "0", base=base, quote=quote)
+            else:
+                result = client.trading.buy_gtd_order(amount=amount, price=price or "0", base=base, quote=quote, expire_time=expire_timestamp)
         elif side == "sell":
-            result = client.trading.sell_gtd_order(amount=amount, price=price or "0", base=base, quote=quote, expire_time=expire_timestamp)
+            if expire_timestamp is None:
+                result = client.trading.sell_order(amount=amount, price=price or "0", base=base, quote=quote)
+            else:
+                result = client.trading.sell_gtd_order(amount=amount, price=price or "0", base=base, quote=quote, expire_time=expire_timestamp)
         else:
             raise HTTPException(status_code=400, detail="invalid side")
     except BitstampError as exc:
@@ -99,16 +133,63 @@ def place_order(*, account_id: str, market: str, side: str, order_type: str, amo
         conn.execute(
             """
             INSERT INTO order_records
-            (id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_order_id, raw_json, created_at, updated_at)
-            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            (id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_id, client_order_id, raw_json, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
             """,
-            (record_id, account_id, market, side, order_type, amount, price, expire_time, str(result.get("status", "open")), bitstamp_order_id, client_order_id or result.get("client_order_id"), json.dumps(result), now, now),
+            (record_id, account_id, market, side, order_type, amount, price, expire_time, _normalize_status(result.get("status", "open")), bitstamp_order_id, client_id, client_order_id or result.get("client_order_id"), json.dumps(result), now, now),
         )
         conn.commit()
 
     return {"ok": True, "bitstamp_order_id": bitstamp_order_id, "record_id": record_id, "status": str(result.get("status", "open")), "raw": result}
 
 
+def get_open_orders(*, account_id: str, client_id: str | None = None) -> dict:
+    client_id = _normalize_client_id(client_id)
+    with get_connection() as conn:
+        if client_id is None:
+            rows = conn.execute(
+                """
+                SELECT id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_id, client_order_id, raw_json, created_at, updated_at
+                FROM order_records
+                WHERE account_id = ? AND lower(status) IN ('open', 'new', 'partially_filled')
+                ORDER BY created_at DESC
+                """,
+                (account_id,),
+            ).fetchall()
+        else:
+            rows = conn.execute(
+                """
+                SELECT id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_id, client_order_id, raw_json, created_at, updated_at
+                FROM order_records
+                WHERE account_id = ? AND client_id = ? AND lower(status) IN ('open', 'new', 'partially_filled')
+                ORDER BY created_at DESC
+                """,
+                (account_id, client_id),
+            ).fetchall()
+    return {"ok": True, "client_id": client_id, "orders": [dict(row) for row in rows]}
+
+
+def cancel_all_orders(*, account_id: str, client_id: str | None = None) -> dict:
+    client_id = _normalize_client_id(client_id)
+    orders = get_open_orders(account_id=account_id, client_id=client_id)["orders"]
+    results = []
+    for order in orders:
+        bitstamp_order_id = order.get("bitstamp_order_id")
+        if not bitstamp_order_id:
+            results.append({"order_id": None, "ok": False, "error": "missing bitstamp_order_id"})
+            continue
+        try:
+            results.append(cancel_order(account_id=account_id, order_id=bitstamp_order_id))
+        except HTTPException as exc:
+            detail = str(exc.detail)
+            if "not found" in detail.lower():
+                _set_local_order_status(bitstamp_order_id=bitstamp_order_id, status="missing")
+                results.append({"ok": False, "order_id": bitstamp_order_id, "error": detail, "status": "missing"})
+                continue
+            raise
+    return {"ok": True, "client_id": client_id, "cancelled": results, "count": len(results)}
+
+
 def query_order(*, account_id: str, order_id, client_order_id: str | None = None, omit_transactions: bool | None = None) -> dict:
     order_id = str(order_id)
     client = _get_client(account_id)
@@ -120,7 +201,7 @@ def query_order(*, account_id: str, order_id, client_order_id: str | None = None
     with get_connection() as conn:
         conn.execute(
             "UPDATE order_records SET status = ?, raw_json = ?, updated_at = ? WHERE bitstamp_order_id = ?",
-            (str(result.get("status", "unknown")), json.dumps(result), _utc_now(), order_id),
+            (_normalize_status(result.get("status", "unknown")), json.dumps(result), _utc_now(), order_id),
         )
         conn.commit()
 

+ 6 - 3
src/exec_mcp/storage.py

@@ -16,7 +16,7 @@ def get_connection() -> sqlite3.Connection:
 
 def _migrate_order_records(conn: sqlite3.Connection) -> None:
     columns = {row[1] for row in conn.execute("PRAGMA table_info(order_records)").fetchall()}
-    canonical = {"id", "account_id", "market", "side", "order_type", "amount", "price", "expire_time", "status", "bitstamp_order_id", "client_order_id", "raw_json", "created_at", "updated_at"}
+    canonical = {"id", "account_id", "market", "side", "order_type", "amount", "price", "expire_time", "status", "bitstamp_order_id", "client_id", "client_order_id", "raw_json", "created_at", "updated_at"}
     if not columns:
         return
     if columns == canonical:
@@ -37,6 +37,7 @@ def _migrate_order_records(conn: sqlite3.Connection) -> None:
             expire_time INTEGER,
             status TEXT NOT NULL,
             bitstamp_order_id TEXT,
+            client_id TEXT,
             client_order_id TEXT,
             raw_json TEXT NOT NULL DEFAULT '{}',
             created_at TEXT NOT NULL,
@@ -54,10 +55,11 @@ def _migrate_order_records(conn: sqlite3.Connection) -> None:
     status_expr = "COALESCE(status, 'unknown')" if "status" in legacy_columns else "'unknown'"
     created_expr = "created_at" if "created_at" in legacy_columns else "CURRENT_TIMESTAMP"
     updated_expr = "updated_at" if "updated_at" in legacy_columns else "CURRENT_TIMESTAMP"
+    client_id_expr = "client_id" if "client_id" in legacy_columns else "NULL"
     conn.execute(
         f"""
-        INSERT INTO order_records (id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_order_id, raw_json, created_at, updated_at)
-        SELECT id, account_id, {market_col}, {side_expr}, {order_type_col}, CAST({amount_col} AS TEXT), price, expire_time, {status_expr}, bitstamp_order_id, client_order_id, COALESCE({raw_col}, '{{}}'), {created_expr}, {updated_expr}
+        INSERT INTO order_records (id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_id, client_order_id, raw_json, created_at, updated_at)
+        SELECT id, account_id, {market_col}, {side_expr}, {order_type_col}, CAST({amount_col} AS TEXT), price, expire_time, {status_expr}, bitstamp_order_id, {client_id_expr}, client_order_id, COALESCE({raw_col}, '{{}}'), {created_expr}, {updated_expr}
         FROM order_records_legacy
         """
     )
@@ -112,6 +114,7 @@ def init_db() -> None:
                 expire_time INTEGER,
                 status TEXT NOT NULL,
                 bitstamp_order_id TEXT,
+                client_id TEXT,
                 client_order_id TEXT,
                 raw_json TEXT NOT NULL,
                 created_at TEXT NOT NULL,

+ 124 - 0
tests/test_client_id_bulk_tools.py

@@ -0,0 +1,124 @@
+from __future__ import annotations
+
+import json
+
+from exec_mcp import repo, storage
+from exec_mcp.services_orders import cancel_all_orders, get_open_orders
+from fastapi import HTTPException
+
+
+class _FakeTrading:
+    def cancel_order(self, order_id, version=2):
+        return True
+
+
+class _FakeClient:
+    def __init__(self):
+        self.trading = _FakeTrading()
+
+
+def test_bulk_tools_match_open_status_case_and_client_id(tmp_path, monkeypatch):
+    db_path = tmp_path / "exec.sqlite3"
+    monkeypatch.setattr(storage, "DB_PATH", db_path)
+    storage.init_db()
+    monkeypatch.setattr("exec_mcp.services_orders._get_client", lambda account_id: _FakeClient())
+
+    repo.create_account(display_name="strategy", venue="bitstamp", venue_account_ref="ref-1", api_key="k", api_secret="s")
+    account_id = repo.list_accounts(enabled_only=False)[0]["id"]
+
+    with storage.get_connection() as conn:
+        conn.execute(
+            """
+            INSERT INTO order_records
+            (id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_id, client_order_id, raw_json, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            """,
+            (
+                "rec-1",
+                account_id,
+                "xrpusd",
+                "sell",
+                "limit",
+                "10",
+                "2.00",
+                None,
+                "Open",
+                "1994292738961409",
+                "test",
+                None,
+                json.dumps({"id": "1994292738961409", "status": "Open"}),
+                "2026-04-09T08:20:50+00:00",
+                "2026-04-09T08:20:50+00:00",
+            ),
+        )
+        conn.commit()
+
+    open_orders = get_open_orders(account_id=account_id, client_id="test")
+    assert open_orders["ok"] is True
+    assert len(open_orders["orders"]) == 1
+    assert open_orders["orders"][0]["bitstamp_order_id"] == "1994292738961409"
+
+    cancelled = cancel_all_orders(account_id=account_id, client_id="test")
+    assert cancelled["ok"] is True
+    assert cancelled["count"] == 1
+    assert cancelled["cancelled"][0]["ok"] is True
+    assert cancelled["cancelled"][0]["order_id"] == "1994292738961409"
+
+
+def test_cancel_all_orders_skips_missing_orders_and_continues(tmp_path, monkeypatch):
+    db_path = tmp_path / "exec.sqlite3"
+    monkeypatch.setattr(storage, "DB_PATH", db_path)
+    storage.init_db()
+
+    repo.create_account(display_name="strategy", venue="bitstamp", venue_account_ref="ref-1", api_key="k", api_secret="s")
+    account_id = repo.list_accounts(enabled_only=False)[0]["id"]
+
+    with storage.get_connection() as conn:
+        for idx, order_id in enumerate(["missing-order", "ok-order"]):
+            conn.execute(
+                """
+                INSERT INTO order_records
+                (id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_id, client_order_id, raw_json, created_at, updated_at)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+                """,
+                (
+                    f"rec-{idx}",
+                    account_id,
+                    "xrpusd",
+                    "sell",
+                    "limit",
+                    "10",
+                    "2.00",
+                    None,
+                    "open",
+                    order_id,
+                    None,
+                    None,
+                    json.dumps({"id": order_id, "status": "Open"}),
+                    "2026-04-09T08:20:50+00:00",
+                    "2026-04-09T08:20:50+00:00",
+                ),
+            )
+        conn.commit()
+
+    def fake_cancel_order(*, account_id: str, order_id):
+        if order_id == "missing-order":
+            raise HTTPException(status_code=400, detail="Order not found")
+        with storage.get_connection() as conn:
+            conn.execute(
+                "UPDATE order_records SET status = ?, updated_at = ? WHERE bitstamp_order_id = ?",
+                ("cancelled", "2026-04-09T09:00:00+00:00", order_id),
+            )
+            conn.commit()
+        return {"ok": True, "order_id": order_id, "raw": {"id": order_id}}
+
+    monkeypatch.setattr("exec_mcp.services_orders.cancel_order", fake_cancel_order)
+
+    result = cancel_all_orders(account_id=account_id)
+    assert result["ok"] is True
+    assert result["count"] == 2
+    assert result["cancelled"][0]["status"] == "missing"
+    assert result["cancelled"][1]["ok"] is True
+
+    open_orders = get_open_orders(account_id=account_id)
+    assert open_orders["orders"] == []

+ 75 - 0
tests/test_client_id_orders.py

@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from exec_mcp import storage
+from exec_mcp import repo
+from exec_mcp.services_orders import get_open_orders, place_order
+
+
+class _FakeTrading:
+    def buy_order(self, **kwargs):
+        return {"id": "12345", "status": "open", "client_order_id": "broker-client-1", **kwargs}
+
+    def buy_gtd_order(self, **kwargs):
+        return {"id": "12345", "status": "open", "client_order_id": "broker-client-1", **kwargs}
+
+    def sell_order(self, **kwargs):
+        return {"id": "12345", "status": "open", "client_order_id": "broker-client-1", **kwargs}
+
+    def sell_gtd_order(self, **kwargs):
+        return {"id": "12345", "status": "open", "client_order_id": "broker-client-1", **kwargs}
+
+
+class _FakeClient:
+    def __init__(self):
+        self.trading = _FakeTrading()
+
+
+@pytest.fixture()
+def temp_db(tmp_path, monkeypatch):
+    db_path = tmp_path / "exec.sqlite3"
+    monkeypatch.setattr(storage, "DB_PATH", db_path)
+    storage.init_db()
+    return db_path
+
+
+def test_place_order_rejects_non_string_client_id(temp_db, monkeypatch):
+    monkeypatch.setattr("exec_mcp.services_orders._get_client", lambda account_id: _FakeClient())
+    monkeypatch.setattr("exec_mcp.services_orders.load_market_by_symbol", lambda market: {"base_decimals": 8, "counter_decimals": 2, "minimum_order_value": "0", "counter_currency": "USD"})
+
+    with pytest.raises(Exception) as exc:
+        place_order(
+            account_id="acct-1",
+            market="xrpusd",
+            side="buy",
+            order_type="market",
+            amount="10",
+            client_id=123,
+        )
+
+    assert "client_id must be a string" in str(exc.value)
+
+
+def test_place_order_persists_client_id_and_get_open_orders_filters(temp_db, monkeypatch):
+    monkeypatch.setattr("exec_mcp.services_orders._get_client", lambda account_id: _FakeClient())
+    monkeypatch.setattr("exec_mcp.services_orders.load_market_by_symbol", lambda market: {"base_decimals": 8, "counter_decimals": 2, "minimum_order_value": "0", "counter_currency": "USD"})
+    repo.create_account(display_name="strategy", venue="bitstamp", venue_account_ref="ref-1", api_key="k", api_secret="s")
+
+    result = place_order(
+        account_id=repo.list_accounts(enabled_only=False)[0]["id"],
+        market="xrpusd",
+        side="buy",
+        order_type="market",
+        amount="10",
+        client_id="strategy-a:v1:abc123",
+    )
+    assert result["ok"] is True
+
+    orders = get_open_orders(account_id=repo.list_accounts(enabled_only=False)[0]["id"], client_id="strategy-a:v1:abc123")
+    assert orders["ok"] is True
+    assert orders["client_id"] == "strategy-a:v1:abc123"
+    assert len(orders["orders"]) == 1
+    assert orders["orders"][0]["client_id"] == "strategy-a:v1:abc123"