From eefcf473dfa8a02fee6c396d444a40c60a8a0e70 Mon Sep 17 00:00:00 2001 From: Andrii Zamovsky Date: Wed, 8 Jul 2026 16:06:58 -0700 Subject: [PATCH] Add IBKR-native --order-ref for multi-strategy order tagging. Expose orderRef on place/list/modify paths with optional prefix filtering so agents can own orders without inventing a parallel client-order-id concept. Co-authored-by: Cursor --- README.md | 13 +++ src/ibkr_cli/app.py | 66 +++++++++-- src/ibkr_cli/ib_service.py | 66 +++++++++++ tests/test_cli.py | 1 + tests/test_order_ref.py | 232 +++++++++++++++++++++++++++++++++++++ 5 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 tests/test_order_ref.py diff --git a/README.md b/README.md index 1bc7c8e..4ec000d 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,19 @@ ibkr buy AAPL 10 --take-profit 160.00 --stop-loss 140.00 --preview --profile gat ibkr buy AAPL 10 --type LMT --limit 150.00 --take-profit 160.00 --stop-loss 140.00 --preview --profile gateway-live ``` +#### Order reference (`orderRef`) + +Tag orders with IBKR's native `orderRef` for ownership / multi-strategy filtering: + +```bash +ibkr sell AAPL 10 --type LMT --limit 150.00 --order-ref fr-AAPL-20260708143000 --submit --profile gateway-paper +ibkr orders open --order-ref-prefix fr- --profile gateway-paper +ibkr orders completed --order-ref-prefix fr- --profile gateway-paper +ibkr orders executions --order-ref-prefix fr- --profile gateway-paper +``` + +`--order-ref` is stored as TWS API `orderRef` for the order's lifetime and returned on open/completed orders and executions as `order_ref`. Bracket children use `{order-ref}-tp` / `{order-ref}-sl`. + ### Update The CLI automatically checks for new versions once a day. To manually check and upgrade: diff --git a/src/ibkr_cli/app.py b/src/ibkr_cli/app.py index 70196fc..b83b6b3 100644 --- a/src/ibkr_cli/app.py +++ b/src/ibkr_cli/app.py @@ -308,6 +308,7 @@ def render_open_orders_table(rows: Sequence[Dict[str, object]], account: Optiona table.add_column("Action") table.add_column("Qty", justify="right") table.add_column("Limit", justify="right") + table.add_column("Order Ref") table.add_column("Status") table.add_column("Filled", justify="right") table.add_column("Remaining", justify="right") @@ -320,6 +321,7 @@ def render_open_orders_table(rows: Sequence[Dict[str, object]], account: Optiona str(row["action"]), "" if row["quantity"] is None else str(row["quantity"]), "" if row["limit_price"] is None else str(row["limit_price"]), + "" if row["order_ref"] is None else str(row["order_ref"]), str(row["status"]), "" if row["filled"] is None else str(row["filled"]), "" if row["remaining"] is None else str(row["remaining"]), @@ -335,6 +337,7 @@ def render_completed_orders_table(rows: Sequence[Dict[str, object]], account: Op table.add_column("Type") table.add_column("Action") table.add_column("Qty", justify="right") + table.add_column("Order Ref") table.add_column("Status") table.add_column("Avg Fill", justify="right") for row in rows: @@ -345,6 +348,7 @@ def render_completed_orders_table(rows: Sequence[Dict[str, object]], account: Op str(row["order_type"]), str(row["action"]), "" if row["quantity"] is None else str(row["quantity"]), + "" if row["order_ref"] is None else str(row["order_ref"]), str(row["status"]), "" if row["avg_fill_price"] is None else str(row["avg_fill_price"]), ) @@ -360,6 +364,7 @@ def render_executions_table(rows: Sequence[Dict[str, object]], account: Optional table.add_column("Side") table.add_column("Shares", justify="right") table.add_column("Price", justify="right") + table.add_column("Order Ref") table.add_column("Commission", justify="right") table.add_column("Realized PnL", justify="right") for row in rows: @@ -371,6 +376,7 @@ def render_executions_table(rows: Sequence[Dict[str, object]], account: Optional str(row["side"]), "" if row["shares"] is None else str(row["shares"]), "" if row["price"] is None else str(row["price"]), + "" if row["order_ref"] is None else str(row["order_ref"]), "" if row["commission"] is None else str(row["commission"]), "" if row["realized_pnl"] is None else str(row["realized_pnl"]), ) @@ -399,6 +405,7 @@ def render_order_preview_table(payload: Dict[str, object]) -> Table: "trailing_percent", "tif", "outside_rth", + "order_ref", "status", "init_margin_before", "init_margin_change", @@ -445,6 +452,7 @@ def render_trade_result_table(payload: Dict[str, object]) -> Table: "parent_id", "tif", "outside_rth", + "order_ref", "order_id", "perm_id", "client_id", @@ -721,19 +729,29 @@ def positions( def orders_open( profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile name to use."), account: Optional[str] = typer.Option(None, "--account", help="IBKR account identifier."), + order_ref_prefix: Optional[str] = typer.Option( + None, + "--order-ref-prefix", + help="Only include orders whose orderRef starts with this prefix.", + ), timeout: float = typer.Option(4.0, "--timeout", min=0.1, help="API timeout in seconds."), json_output: bool = typer.Option(False, "--json", help="Output JSON instead of a table."), ) -> None: config, _, selected_name, selected_profile = resolve_profile_or_exit(profile, json_output=json_output) try: - payload = get_open_orders(selected_profile, timeout=timeout, account=account) + payload = get_open_orders( + selected_profile, + timeout=timeout, + account=account, + order_ref_prefix=order_ref_prefix, + ) except Exception as exc: exit_with_error( f"Failed to fetch open orders via profile '{selected_name}': {exc}", code=ERROR_ORDER_QUERY_FAILED, exit_code=EXIT_CODE_API, json_output=json_output, - details={"profile": selected_name, "account": account}, + details={"profile": selected_name, "account": account, "order_ref_prefix": order_ref_prefix}, ) return @@ -754,6 +772,11 @@ def orders_completed( profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile name to use."), account: Optional[str] = typer.Option(None, "--account", help="IBKR account identifier."), api_only: bool = typer.Option(False, "--api-only", help="Only include API-originated orders."), + order_ref_prefix: Optional[str] = typer.Option( + None, + "--order-ref-prefix", + help="Only include orders whose orderRef starts with this prefix.", + ), timeout: float = typer.Option(4.0, "--timeout", min=0.1, help="API timeout in seconds."), json_output: bool = typer.Option(False, "--json", help="Output JSON instead of a table."), ) -> None: @@ -764,6 +787,7 @@ def orders_completed( timeout=timeout, account=account, api_only=api_only, + order_ref_prefix=order_ref_prefix, ) except Exception as exc: exit_with_error( @@ -771,7 +795,12 @@ def orders_completed( code=ERROR_ORDER_QUERY_FAILED, exit_code=EXIT_CODE_API, json_output=json_output, - details={"profile": selected_name, "account": account, "api_only": api_only}, + details={ + "profile": selected_name, + "account": account, + "api_only": api_only, + "order_ref_prefix": order_ref_prefix, + }, ) return @@ -791,19 +820,29 @@ def orders_completed( def orders_executions( profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile name to use."), account: Optional[str] = typer.Option(None, "--account", help="IBKR account identifier."), + order_ref_prefix: Optional[str] = typer.Option( + None, + "--order-ref-prefix", + help="Only include executions whose orderRef starts with this prefix.", + ), timeout: float = typer.Option(4.0, "--timeout", min=0.1, help="API timeout in seconds."), json_output: bool = typer.Option(False, "--json", help="Output JSON instead of a table."), ) -> None: config, _, selected_name, selected_profile = resolve_profile_or_exit(profile, json_output=json_output) try: - payload = get_executions(selected_profile, timeout=timeout, account=account) + payload = get_executions( + selected_profile, + timeout=timeout, + account=account, + order_ref_prefix=order_ref_prefix, + ) except Exception as exc: exit_with_error( f"Failed to fetch executions via profile '{selected_name}': {exc}", code=ERROR_ORDER_QUERY_FAILED, exit_code=EXIT_CODE_API, json_output=json_output, - details={"profile": selected_name, "account": account}, + details={"profile": selected_name, "account": account, "order_ref_prefix": order_ref_prefix}, ) return @@ -840,6 +879,7 @@ def execute_trade_command( trail_percent: Optional[float] = None, take_profit: Optional[float] = None, stop_loss: Optional[float] = None, + order_ref: Optional[str] = None, ) -> None: if preview == submit: exit_with_error( @@ -870,6 +910,7 @@ def execute_trade_command( trail_percent=trail_percent, take_profit_price=take_profit, stop_loss_price=stop_loss, + order_ref=order_ref, ) if preview: payload = preview_stock_order(selected_profile, **order_kwargs) @@ -890,6 +931,7 @@ def execute_trade_command( "quantity": quantity, "order_type": order_type, "account": account, + "order_ref": order_ref, }, ) return @@ -925,6 +967,11 @@ def buy( stop_loss: Optional[float] = typer.Option(None, "--stop-loss", help="Stop-loss price (creates a bracket order)."), tif: str = typer.Option("DAY", "--tif", help="Time in force."), outside_rth: bool = typer.Option(False, "--outside-rth", help="Allow execution outside regular trading hours."), + order_ref: Optional[str] = typer.Option( + None, + "--order-ref", + help="IBKR orderRef — user tag associated with the order for its lifetime.", + ), preview: bool = typer.Option(False, "--preview", help="Run a what-if preview instead of placing an order."), submit: bool = typer.Option(False, "--submit", help="Place the order for real."), account: Optional[str] = typer.Option(None, "--account", help="IBKR account identifier."), @@ -935,7 +982,7 @@ def buy( "BUY", symbol, quantity, profile, exchange, currency, order_type, limit_price, tif, outside_rth, preview, submit, account, timeout, json_output, stop_price=stop_price, trail_amount=trail_amount, trail_percent=trail_percent, - take_profit=take_profit, stop_loss=stop_loss, + take_profit=take_profit, stop_loss=stop_loss, order_ref=order_ref, ) @@ -955,6 +1002,11 @@ def sell( stop_loss: Optional[float] = typer.Option(None, "--stop-loss", help="Stop-loss price (creates a bracket order)."), tif: str = typer.Option("DAY", "--tif", help="Time in force."), outside_rth: bool = typer.Option(False, "--outside-rth", help="Allow execution outside regular trading hours."), + order_ref: Optional[str] = typer.Option( + None, + "--order-ref", + help="IBKR orderRef — user tag associated with the order for its lifetime.", + ), preview: bool = typer.Option(False, "--preview", help="Run a what-if preview instead of placing an order."), submit: bool = typer.Option(False, "--submit", help="Place the order for real."), account: Optional[str] = typer.Option(None, "--account", help="IBKR account identifier."), @@ -965,7 +1017,7 @@ def sell( "SELL", symbol, quantity, profile, exchange, currency, order_type, limit_price, tif, outside_rth, preview, submit, account, timeout, json_output, stop_price=stop_price, trail_amount=trail_amount, trail_percent=trail_percent, - take_profit=take_profit, stop_loss=stop_loss, + take_profit=take_profit, stop_loss=stop_loss, order_ref=order_ref, ) diff --git a/src/ibkr_cli/ib_service.py b/src/ibkr_cli/ib_service.py index fb89a5f..9f042af 100644 --- a/src/ibkr_cli/ib_service.py +++ b/src/ibkr_cli/ib_service.py @@ -275,6 +275,7 @@ def get_open_orders( profile: ProfileConfig, timeout: float = 4.0, account: Optional[str] = None, + order_ref_prefix: Optional[str] = None, ) -> Dict[str, object]: with ib_session(profile, timeout=timeout) as ib: managed_accounts = list(ib.managedAccounts()) @@ -307,12 +308,14 @@ def get_open_orders( "limit_price": _normalize_number(order.lmtPrice), "aux_price": _normalize_number(order.auxPrice), "tif": order.tif, + "order_ref": _order_ref_value(order), "status": status.status, "filled": _normalize_number(status.filled), "remaining": _normalize_number(status.remaining), } ) + rows = _filter_rows_by_order_ref_prefix(rows, order_ref_prefix) rows.sort( key=lambda row: ( str(row["account"]), @@ -324,6 +327,7 @@ def get_open_orders( return { "managed_accounts": managed_accounts, "selected_account": account, + "order_ref_prefix": order_ref_prefix, "rows": rows, } @@ -333,6 +337,7 @@ def get_completed_orders( timeout: float = 4.0, account: Optional[str] = None, api_only: bool = False, + order_ref_prefix: Optional[str] = None, ) -> Dict[str, object]: with ib_session(profile, timeout=timeout) as ib: managed_accounts = list(ib.managedAccounts()) @@ -412,6 +417,7 @@ def get_completed_orders( "limit_price": _normalize_number(order.lmtPrice), "aux_price": _normalize_number(order.auxPrice), "tif": order.tif, + "order_ref": _order_ref_value(order), "status": status.status, "filled": filled, "remaining": remaining, @@ -419,6 +425,7 @@ def get_completed_orders( } ) + rows = _filter_rows_by_order_ref_prefix(rows, order_ref_prefix) rows.sort( key=lambda row: ( str(row["account"]), @@ -431,6 +438,7 @@ def get_completed_orders( "managed_accounts": managed_accounts, "selected_account": account, "api_only": api_only, + "order_ref_prefix": order_ref_prefix, "rows": rows, } @@ -439,6 +447,7 @@ def get_executions( profile: ProfileConfig, timeout: float = 4.0, account: Optional[str] = None, + order_ref_prefix: Optional[str] = None, ) -> Dict[str, object]: with ib_session(profile, timeout=timeout) as ib: managed_accounts = list(ib.managedAccounts()) @@ -472,12 +481,14 @@ def get_executions( "price": _normalize_number(execution.price), "cum_qty": _normalize_number(execution.cumQty), "avg_price": _normalize_number(execution.avgPrice), + "order_ref": _order_ref_value(execution), "commission": _normalize_number(commission_report.commission), "commission_currency": commission_report.currency, "realized_pnl": _normalize_number(commission_report.realizedPNL), } ) + rows = _filter_rows_by_order_ref_prefix(rows, order_ref_prefix) rows.sort( key=lambda row: ( str(row["account"]), @@ -490,6 +501,7 @@ def get_executions( return { "managed_accounts": managed_accounts, "selected_account": account, + "order_ref_prefix": order_ref_prefix, "rows": rows, } @@ -497,6 +509,40 @@ def get_executions( _SUPPORTED_ORDER_TYPES = ("MKT", "LMT", "STP", "STP LMT", "TRAIL") +def normalize_order_ref(order_ref: Optional[str]) -> Optional[str]: + """Validate and normalize an IBKR orderRef string.""" + if order_ref is None: + return None + normalized = order_ref.strip() + if not normalized: + raise ValueError("--order-ref cannot be empty or whitespace-only.") + return normalized + + +def _order_ref_value(order_or_execution: object) -> Optional[str]: + value = getattr(order_or_execution, "orderRef", None) or None + if value is None: + return None + text = str(value).strip() + return text or None + + +def _filter_rows_by_order_ref_prefix( + rows: List[Dict[str, object]], + order_ref_prefix: Optional[str], +) -> List[Dict[str, object]]: + if order_ref_prefix is None: + return rows + prefix = order_ref_prefix.strip() + if not prefix: + raise ValueError("--order-ref-prefix cannot be empty or whitespace-only.") + return [ + row + for row in rows + if isinstance(row.get("order_ref"), str) and row["order_ref"].startswith(prefix) + ] + + def _prepare_stock_order( ib: object, action: str, @@ -512,6 +558,7 @@ def _prepare_stock_order( stop_price: Optional[float] = None, trail_stop_price: Optional[float] = None, trail_percent: Optional[float] = None, + order_ref: Optional[str] = None, ) -> tuple[List[str], str, object, object]: try: from ib_async import LimitOrder, MarketOrder, Order, StopLimitOrder, StopOrder, Stock @@ -575,6 +622,9 @@ def _prepare_stock_order( qualified_contract = qualified[0] common_kwargs = dict(tif=normalized_tif, outsideRth=outside_rth, account=selected_account) + normalized_order_ref = normalize_order_ref(order_ref) + if normalized_order_ref: + common_kwargs["orderRef"] = normalized_order_ref if normalized_order_type == "LMT": order = LimitOrder(normalized_action, quantity, limit_price, **common_kwargs) @@ -615,6 +665,7 @@ def _prepare_bracket_order( account: Optional[str], take_profit_price: float, stop_loss_price: float, + order_ref: Optional[str] = None, ) -> tuple[List[str], str, object, list]: try: from ib_async import LimitOrder, MarketOrder, StopOrder, Stock @@ -665,6 +716,12 @@ def _prepare_bracket_order( stop_loss = StopOrder(reverse_action, quantity, stop_loss_price, **common_kwargs) stop_loss.transmit = True # last child transmits the whole bracket + normalized_order_ref = normalize_order_ref(order_ref) + if normalized_order_ref: + parent.orderRef = normalized_order_ref + take_profit.orderRef = f"{normalized_order_ref}-tp" + stop_loss.orderRef = f"{normalized_order_ref}-sl" + return managed_accounts, selected_account, qualified_contract, [parent, take_profit, stop_loss] @@ -700,6 +757,7 @@ def _trade_payload( "parent_id": order.parentId if order.parentId else None, "tif": order.tif, "outside_rth": bool(order.outsideRth), + "order_ref": _order_ref_value(order), "order_id": order.orderId, "perm_id": order.permId, "client_id": order.clientId, @@ -733,6 +791,7 @@ def preview_stock_order( trail_percent: Optional[float] = None, take_profit_price: Optional[float] = None, stop_loss_price: Optional[float] = None, + order_ref: Optional[str] = None, ) -> Dict[str, object]: is_bracket = take_profit_price is not None or stop_loss_price is not None with ib_session(profile, timeout=timeout, readonly=False) as ib: @@ -753,6 +812,7 @@ def preview_stock_order( account=account, take_profit_price=take_profit_price, stop_loss_price=stop_loss_price, + order_ref=order_ref, ) order = orders[0] # preview the parent order order.transmit = True # override for whatIfOrder preview @@ -772,6 +832,7 @@ def preview_stock_order( stop_price=stop_price, trail_stop_price=trail_stop_price, trail_percent=trail_percent, + order_ref=order_ref, ) matcher = lambda current_contract: current_contract is not None and getattr(current_contract, "conId", None) == qualified_contract.conId @@ -799,6 +860,7 @@ def preview_stock_order( "trailing_percent": _normalize_number(getattr(order, "trailingPercent", None)), "tif": order.tif, "outside_rth": outside_rth, + "order_ref": _order_ref_value(order), "status": state.status, "init_margin_before": _normalize_number(state.initMarginBefore), "init_margin_change": _normalize_number(state.initMarginChange), @@ -843,6 +905,7 @@ def submit_stock_order( trail_percent: Optional[float] = None, take_profit_price: Optional[float] = None, stop_loss_price: Optional[float] = None, + order_ref: Optional[str] = None, ) -> Dict[str, object]: is_bracket = take_profit_price is not None or stop_loss_price is not None with ib_session(profile, timeout=timeout, readonly=False) as ib: @@ -863,6 +926,7 @@ def submit_stock_order( account=account, take_profit_price=take_profit_price, stop_loss_price=stop_loss_price, + order_ref=order_ref, ) with _capture_ib_errors(ib) as raw_errors: with _suppress_ib_async_logs(): @@ -899,6 +963,7 @@ def submit_stock_order( stop_price=stop_price, trail_stop_price=trail_stop_price, trail_percent=trail_percent, + order_ref=order_ref, ) with _capture_ib_errors(ib) as raw_errors: @@ -974,6 +1039,7 @@ def _build_clean_modify_order(source_order: object) -> object: order.tif = source_order.tif order.outsideRth = source_order.outsideRth order.account = source_order.account + order.orderRef = getattr(source_order, "orderRef", "") or "" # Bracket / OCA linkage order.parentId = source_order.parentId order.ocaGroup = source_order.ocaGroup diff --git a/tests/test_cli.py b/tests/test_cli.py index bfd038b..848aa98 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -220,6 +220,7 @@ def fake_preview(profile, **kwargs): "trail_percent": None, "take_profit_price": None, "stop_loss_price": None, + "order_ref": None, }, ) diff --git a/tests/test_order_ref.py b/tests/test_order_ref.py new file mode 100644 index 0000000..f07a7a1 --- /dev/null +++ b/tests/test_order_ref.py @@ -0,0 +1,232 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from ibkr_cli import app as app_module +from ibkr_cli import ib_service +from ibkr_cli.config import default_config + +runner = CliRunner() + + +def stub_profile(): + config = default_config() + selected_name = "gateway-paper" + selected_profile = config.profiles[selected_name] + return config, True, selected_name, selected_profile + + +class OrderRefServiceTests(unittest.TestCase): + def test_normalize_order_ref_strips(self) -> None: + self.assertEqual(ib_service.normalize_order_ref(" fr-AMPH-1 "), "fr-AMPH-1") + self.assertIsNone(ib_service.normalize_order_ref(None)) + + def test_normalize_order_ref_rejects_blank(self) -> None: + with self.assertRaises(ValueError): + ib_service.normalize_order_ref(" ") + + def test_filter_rows_by_order_ref_prefix(self) -> None: + rows = [ + {"order_ref": "fr-AMPH-1"}, + {"order_ref": "rm-AAPL-1"}, + {"order_ref": None}, + ] + filtered = ib_service._filter_rows_by_order_ref_prefix(rows, "fr-") + self.assertEqual(filtered, [{"order_ref": "fr-AMPH-1"}]) + + def test_filter_rows_by_order_ref_prefix_rejects_blank(self) -> None: + with self.assertRaises(ValueError): + ib_service._filter_rows_by_order_ref_prefix([{"order_ref": "fr-1"}], " ") + + def test_build_clean_modify_order_preserves_order_ref(self) -> None: + source = SimpleNamespace( + orderId=10, + clientId=1, + permId=100, + action="SELL", + totalQuantity=1, + orderType="LMT", + lmtPrice=10.0, + auxPrice=0.0, + tif="DAY", + outsideRth=True, + account="DU123", + orderRef="fr-AMPH-test", + parentId=0, + ocaGroup="", + ocaType=0, + transmit=True, + trailStopPrice=0.0, + trailingPercent=0.0, + goodAfterTime="", + goodTillDate="", + ) + order = ib_service._build_clean_modify_order(source) + self.assertEqual(order.orderRef, "fr-AMPH-test") + + def test_prepare_stock_order_sets_order_ref(self) -> None: + contract = SimpleNamespace( + symbol="AMPH", + localSymbol="AMPH", + exchange="SMART", + primaryExchange="NASDAQ", + currency="USD", + secType="STK", + conId=1, + ) + ib = MagicMock() + ib.managedAccounts.return_value = ["DU123"] + ib.qualifyContracts.return_value = [contract] + + managed, account, qualified, order = ib_service._prepare_stock_order( + ib, + action="SELL", + symbol="AMPH", + quantity=1, + exchange="SMART", + currency="USD", + order_type="LMT", + limit_price=10.0, + tif="DAY", + outside_rth=True, + account=None, + order_ref=" fr-AMPH-test ", + ) + + self.assertEqual(managed, ["DU123"]) + self.assertEqual(account, "DU123") + self.assertIs(qualified, contract) + self.assertEqual(order.orderRef, "fr-AMPH-test") + + def test_prepare_bracket_order_sets_parent_and_child_refs(self) -> None: + contract = SimpleNamespace( + symbol="AAPL", + localSymbol="AAPL", + exchange="SMART", + primaryExchange="NASDAQ", + currency="USD", + secType="STK", + conId=2, + ) + ib = MagicMock() + ib.managedAccounts.return_value = ["DU123"] + ib.qualifyContracts.return_value = [contract] + + _managed, _account, _contract, orders = ib_service._prepare_bracket_order( + ib, + action="BUY", + symbol="AAPL", + quantity=1, + exchange="SMART", + currency="USD", + order_type="MKT", + limit_price=None, + tif="DAY", + outside_rth=False, + account=None, + take_profit_price=200.0, + stop_loss_price=100.0, + order_ref="fr-AAPL-1", + ) + + parent, take_profit, stop_loss = orders + self.assertEqual(parent.orderRef, "fr-AAPL-1") + self.assertEqual(take_profit.orderRef, "fr-AAPL-1-tp") + self.assertEqual(stop_loss.orderRef, "fr-AAPL-1-sl") + + +class OrderRefCliTests(unittest.TestCase): + def test_buy_help_shows_order_ref(self) -> None: + result = runner.invoke(app_module.app, ["buy", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("--order-ref", result.stdout) + + def test_sell_help_shows_order_ref(self) -> None: + result = runner.invoke(app_module.app, ["sell", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("--order-ref", result.stdout) + + def test_orders_open_help_shows_order_ref_prefix(self) -> None: + result = runner.invoke(app_module.app, ["orders", "open", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("--order-ref-prefix", result.stdout) + + def test_sell_preview_passes_order_ref(self) -> None: + captured = {} + rendered = {} + + def fake_preview(profile, **kwargs): + captured["kwargs"] = kwargs + return { + "preview_only": True, + "selected_account": "DU123", + "symbol": "AMPH", + "local_symbol": "AMPH", + "exchange": "SMART", + "primary_exchange": "NASDAQ", + "currency": "USD", + "sec_type": "STK", + "con_id": 1, + "action": "SELL", + "quantity": 1.0, + "order_type": "LMT", + "limit_price": 10.0, + "stop_price": None, + "aux_price": None, + "trailing_percent": None, + "tif": "DAY", + "outside_rth": True, + "order_ref": kwargs.get("order_ref"), + "status": "PreSubmitted", + "init_margin_before": 0.0, + "init_margin_change": 1.0, + "init_margin_after": 1.0, + "maint_margin_before": 0.0, + "maint_margin_change": 1.0, + "maint_margin_after": 1.0, + "equity_with_loan_before": 100.0, + "equity_with_loan_change": 0.0, + "equity_with_loan_after": 100.0, + "commission": None, + "min_commission": None, + "max_commission": None, + "commission_currency": None, + "warning_text": None, + "raw_error_codes": [], + "raw_errors": [], + } + + with patch.object( + app_module, + "resolve_profile_or_exit", + side_effect=lambda profile, json_output=False: stub_profile(), + ): + with patch.object(app_module, "preview_stock_order", side_effect=fake_preview): + with patch.object( + app_module, + "print_json", + side_effect=lambda payload: rendered.setdefault("payload", payload), + ): + result = runner.invoke( + app_module.app, + [ + "sell", + "AMPH", + "1", + "--type", + "LMT", + "--limit", + "10", + "--outside-rth", + "--order-ref", + "fr-AMPH-test", + "--preview", + "--json", + ], + ) + + self.assertEqual(result.exit_code, 0, result.stdout + result.stderr) + self.assertEqual(captured["kwargs"]["order_ref"], "fr-AMPH-test") + self.assertEqual(rendered["payload"]["order_ref"], "fr-AMPH-test")