From d6e7c4b0bb44ef26e94cd6854da5162755c8449d Mon Sep 17 00:00:00 2001 From: code-hustler-ft3d Date: Mon, 12 Oct 2026 22:48:42 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20BYPASS=5FORDER=5FPRECAUTIONS=20fo?= =?UTF-8?q?r=20API=20=E2=86=92=20Precautions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BYPASS_ORDER_PRECAUTIONS=yes ticks Gateway's "Bypass Order Precautions for API Orders" box under Configure → Settings → API → Precautions, no unticks it, and unset leaves it as it is (issue #68). Some accounts show the box as "Bypass Order and Ticket Precautions for API Orders and Tickets"; the controller asks JSTATE which label is showing and sets that one. The box is read back after OK like Read-Only API: Verified, or ALERT_CONFIG_NOT_APPLIED with env_var=BYPASS_ORDER_PRECAUTIONS if Gateway didn't keep it or the box couldn't be set. A value that isn't one of yes/no, true/false, on/off, 1/0 gets a WARNING, for READ_ONLY_API too. scripts/ibc_config_to_env.py maps IBC's BypassOrderPrecautions. --- CHANGELOG.md | 14 ++ README.md | 3 +- docs/ARCHITECTURE.md | 10 +- docs/FROM_IBC.md | 1 + docs/MIGRATION.md | 2 + docs/OBSERVABILITY.md | 16 ++- gateway_controller.py | 134 +++++++++++++++--- scripts/ibc_config_to_env.py | 1 + tests/integration/SessionLockExitFixture.java | 25 +++- tests/integration/session_lockexit_drill.py | 38 ++++- tests/test_ibc_config_to_env.py | 5 + tests/test_pure_logic.py | 113 +++++++++++++++ 12 files changed, 333 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f20d5..def8641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to `ibg-controller` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- **`BYPASS_ORDER_PRECAUTIONS` (issue #68).** `yes` ticks Gateway's + "Bypass Order Precautions for API Orders" box under Configure → + Settings → API → Precautions, `no` unticks it, and unset leaves it as + it is. Some accounts show the box as "Bypass Order and Ticket + Precautions for API Orders and Tickets"; the controller sets whichever + label Gateway shows. It's read back after OK: `Verified: … reads back + as True`, or `ALERT_CONFIG_NOT_APPLIED ... env_var=BYPASS_ORDER_PRECAUTIONS` + if Gateway didn't keep it or the box isn't there to set. + `scripts/ibc_config_to_env.py` maps IBC's `BypassOrderPrecautions` to it. + ## [0.16.1] - 2026-10-12 ### Fixed diff --git a/README.md b/README.md index 862ded1..e6e6bd6 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Deeper guides: | Multi-method 2FA | ⚠️ needs one method | ⚠️ needs one method | both dialog shapes are detected and driven, but IBKR kicks the session on most accounts we've seen when the method is switched mid-login; run a single method, see [2FA](#2fa) | | Passkey prompt (`PASSKEY_AUTHENTICATE=yes`) | ⚠️ contributor-validated | ⚠️ untested | presses Authenticate; your authenticator completes WebAuthn; amd64 plus extra libraries; see [2FA](#2fa) | | Existing-session dialog | ✅ verified | ⚠️ code in place | also answered when it appears after the TOTP code (v0.16.0, unit-tested) | -| Post-login config (`READ_ONLY_API`, `TWS_MASTER_CLIENT_ID`, auto logoff/restart times) | ✅ verified | ⚠️ untested | selecting Auto restart and the AM/PM buttons: v0.16.0, unit-tested | +| Post-login config (`READ_ONLY_API`, `BYPASS_ORDER_PRECAUTIONS`, `TWS_MASTER_CLIENT_ID`, auto logoff/restart times) | ✅ verified | ⚠️ untested | `BYPASS_ORDER_PRECAUTIONS` (v0.16.2) and selecting Auto restart with Gateway's notice (v0.16.1): run on a paper 10.50.1f Gateway; the AM button: unit-tested | | Command server (`STOP`, `RESTART`, `RECONNECTACCOUNT`, `ENABLEAPI`) | ✅ verified | ⚠️ untested | `RECONNECTDATA` is TWS-only | ✅ verified = run end-to-end against a real IB account. @@ -253,6 +253,7 @@ real product. |---|---| | `TWS_MASTER_CLIENT_ID` | Master API client ID | | `READ_ONLY_API` | `yes`/`no`. Read back after login; `ALERT_CONFIG_NOT_APPLIED` if Gateway didn't keep it. | +| `BYPASS_ORDER_PRECAUTIONS` | `yes`/`no` (also `true`/`false`, `on`/`off`, `1`/`0`): ticks or unticks Configure → Settings → API → Precautions → Bypass Order Precautions for API Orders. Unset leaves it as it is. Read back after login like `READ_ONLY_API`. | | `AUTO_LOGOFF_TIME` / `AUTO_RESTART_TIME` | `HH:MM` / `HH:MM AM/PM`. Gateway shows one field or the other, depending on whether its Auto logoff (the default) or Auto restart button is selected; set both vars and the controller handles whichever is displayed. With only `AUTO_RESTART_TIME` set, the controller selects **Auto restart** first, since Gateway starts in Auto logoff mode (11:45 PM). It also selects the AM or PM radio button beside the time, and the read-back after login confirms what Gateway kept (`ALERT_CONFIG_NOT_APPLIED` if not). Gateway's own logoff timer is unreliable, so if the same Gateway is still running 5 minutes after `AUTO_LOGOFF_TIME`, the controller logs it off itself (`LOGOFF_BACKSTOP`). It only does this for a time written with AM or PM (`05:01 PM`) that Gateway showed back on that login, for a session that logged in before that time, within an hour after it, and only when `AUTO_RESTART_TIME` is unset and Gateway's time zone (`TIME_ZONE`) matches the container's (`TZ`) — so it never adds a logoff Gateway wasn't already scheduled to do. With no `AUTO_LOGOFF_TIME`, nothing changes. | | `AUTO_RESTART_ADOPT` | Default `yes`: when Gateway restarts itself at `AUTO_RESTART_TIME`, adopt the new JVM instead of launching a second one. Gateway normally carries the session across, so no login and no 2FA; when it doesn't, the controller re-drives login on the adopted JVM. `no` restores always-relaunch. Wait budget: `AUTO_RESTART_ADOPT_TIMEOUT_SECONDS` (90). | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3c08ca7..925c92e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -267,8 +267,9 @@ Why each was added: (live) / 4002 (paper) until it accepts a connection. **This is the definitive readiness signal.** 9. **Post-login config** (v0.2): `handle_post_login_config()` — if any - of `TWS_MASTER_CLIENT_ID` / `READ_ONLY_API` / `AUTO_LOGOFF_TIME` / - `AUTO_RESTART_TIME` is set, open Configure → Settings, navigate + of `TWS_MASTER_CLIENT_ID` / `READ_ONLY_API` / + `BYPASS_ORDER_PRECAUTIONS` / `AUTO_LOGOFF_TIME` / `AUTO_RESTART_TIME` + is set, open Configure → Settings, navigate tree via `JTREE_SELECT_PATH`, apply values via `SETTEXT_BY_LABEL` and `JCHECK`, commit via `CLICK_IN_WIN Configuration|OK`. Gateway shows *either* Auto Log Off Time *or* Auto Restart Time in Lock @@ -287,7 +288,10 @@ Why each was added: 15 s means `ALERT_CONFIG_NOT_APPLIED`, the open dialogs' text in the log, and no read-back. If the agent never returns a window list, nothing is read back either (a WARNING says so). Once the dialog has closed, it re-opens it and reads - back what it wrote: the Lock and Exit time, and Read-Only API (`JSTATE`). + back what it wrote: the Lock and Exit time, and Read-Only API and the + API → Precautions bypass box (`JSTATE`). Gateway labels that box one + of two ways depending on the account; the handler asks `JSTATE` for + each and sets the one that answers. 10. **Command server** (v0.2): `start_command_server()` — if `CONTROLLER_COMMAND_SERVER_PORT` is set, start a daemon thread listening on that TCP port for IBC-compat commands (`STOP`, diff --git a/docs/FROM_IBC.md b/docs/FROM_IBC.md index 45ff529..38c3ddb 100644 --- a/docs/FROM_IBC.md +++ b/docs/FROM_IBC.md @@ -63,6 +63,7 @@ review). The tool's `--help` output lists every key it knows about. |---|---|---| | `ExistingSessionDetectedAction` | `EXISTING_SESSION_DETECTED_ACTION` | `primary` / `primaryoverride` / `secondary` / `manual`. | | `ReadOnlyApi` | `READ_ONLY_API` | yes/no. | +| `BypassOrderPrecautions` | `BYPASS_ORDER_PRECAUTIONS` | yes/no: Configure → Settings → API → Precautions → Bypass Order Precautions for API Orders. Separate from `BypassWarning`, which is about dialog buttons. | | `AutoRestartTime` | `AUTO_RESTART_TIME` | `HH:MM AM/PM`. | | `IbAutoClosedown` | — | Set `AUTO_LOGOFF_TIME=HH:MM` directly; the controller drives the Gateway field with that value. | | `AllowBlindTrading` | `ALLOW_BLIND_TRADING` | yes/no. | diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 8f61347..a7d480d 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -140,6 +140,8 @@ after login completes. These IBC env vars now take effect: - **`TWS_MASTER_CLIENT_ID`** — integer, sets the Master API client ID - **`READ_ONLY_API`** — `yes`/`no`, toggles the Read-Only API checkbox +- **`BYPASS_ORDER_PRECAUTIONS`** — `yes`/`no`, toggles API → + Precautions → Bypass Order Precautions for API Orders - **`AUTO_LOGOFF_TIME`** — `HH:MM`, sets Lock and Exit → Set Auto Log Off Time - **`AUTO_RESTART_TIME`** — `HH:MM AM/PM`, sets Lock and Exit → Set diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index e88d140..b77fa7d 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -283,7 +283,9 @@ one of the two times is set and the controller selects its mode: line with `reason="discarded with a Lock and Exit change that could not be undone"`. Check Read-Only API over VNC before trading. -`READ_ONLY_API` is read back the same way (#69): +`READ_ONLY_API` is read back the same way (#69), and so is +`BYPASS_ORDER_PRECAUTIONS` (#68), whose `setting` is the box's label as +Gateway shows it: ``` ALERT_CONFIG_NOT_APPLIED mode=live setting="Read-Only API" env_var=READ_ONLY_API requested="no" reason="Gateway did not retain the value after OK; API clients cannot place or change orders" @@ -294,6 +296,18 @@ and read, but trading requests get error 321 ("The API interface is currently in Read-Only mode"). With `requested="yes"` the reason ends `API clients can place orders`. +For `BYPASS_ORDER_PRECAUTIONS` the read-back reason ends `API orders +still go through order precautions` (requested `yes`) or `API orders +still bypass order precautions` (requested `no`). Other reasons come +before OK, when the box couldn't be set at all: `could not select API → +Precautions`, `the bypass box is not showing, or is disabled, on API → +Precautions` (Gateway can show it greyed out), `more than one box on API +→ Precautions has that label`, and `the bypass box could not be set`. +Either way the precautions apply as Gateway had them. Both settings take +`yes`/`true`/`on`/`1` or `no`/`false`/`off`/`0`, in any case; anything +else (a typo such as `BYPASS_ORDER_PRECAUTIONS=y`) gets a WARNING and +leaves the box alone, as it does for `READ_ONLY_API`. + `reason="the settings dialog did not close after OK; see the dialog Gateway is showing"` (v0.16.1): Gateway held the dialog open behind a prompt the controller doesn't answer, so nothing in it is confirmed diff --git a/gateway_controller.py b/gateway_controller.py index 2077323..12c2e9f 100644 --- a/gateway_controller.py +++ b/gateway_controller.py @@ -3049,28 +3049,26 @@ def _verify_lock_exit_time_persisted(label, expected): return None -def _verify_read_only_api_persisted(expected): - """Re-open Configure → Settings after OK and confirm Read-Only API - holds ``expected``. Returns True/False, or None if it couldn't be - checked. +def _verify_checkbox_persisted(page, label, expected): + """Re-open Configure → Settings after OK and confirm the checkbox + ``label`` on ``page`` holds ``expected``. Returns True/False, or None + if it couldn't be checked. - While the box is ticked the API answers trading requests with error - 321 (#69). A click that succeeded in the dialog doesn't show that - Gateway kept the change; a read-back after the commit does. + A click that succeeded in the dialog doesn't show that Gateway kept + the change; a read-back after the commit does. """ try: if not _config_open(): log.warning(" Could not re-open config dialog to verify " - "Read-Only API; treating as unverified") + f"{label}; treating as unverified") return None try: - if not agent_jtree_select_path(CONFIG_WINDOW_TITLE_SUBSTR, - "API/Settings"): - log.warning(" Could not re-select API/Settings to verify " - "Read-Only API; treating as unverified") + if not agent_jtree_select_path(CONFIG_WINDOW_TITLE_SUBSTR, page): + log.warning(f" Could not re-select {page} to verify " + f"{label}; treating as unverified") return None time.sleep(0.5) # same panel-render settle as the write path - state = agent_jstate(CONFIG_WINDOW_TITLE_SUBSTR, "Read-Only API") + state = agent_jstate(CONFIG_WINDOW_TITLE_SUBSTR, label) return None if state is None else state == expected finally: # Cancel, never OK: verification must not itself commit anything. @@ -3078,11 +3076,45 @@ def _verify_read_only_api_persisted(expected): log.warning(" Could not close the verification dialog with " "Cancel — check for a stray config window") except Exception as e: - log.warning(f" Verification of Read-Only API raised " + log.warning(f" Verification of {label} raised " f"{type(e).__name__}: {e}; treating as unverified") return None +def _verify_read_only_api_persisted(expected): + """Read Read-Only API back after OK. While the box is ticked the API + answers trading requests with error 321 (#69).""" + return _verify_checkbox_persisted("API/Settings", "Read-Only API", expected) + + +# Gateway labels the API → Precautions box one of two ways, depending on +# the account (10.50.1f, feature.configure.plugins.custom.panel.R). +_BYPASS_PRECAUTIONS_LABELS = ( + "Bypass Order Precautions for API Orders", + "Bypass Order and Ticket Precautions for API Orders and Tickets", +) + + +def _bypass_precautions_label(): + """(label, None) for the bypass box on the open API → Precautions page, + or (None, reason) if neither label is showing and enabled. JSTATE only + reads, and asking directly keeps the label that isn't there out of the + log.""" + ambiguous = False + for label in _BYPASS_PRECAUTIONS_LABELS: + try: + resp = _agent_request(f"JSTATE {CONFIG_WINDOW_TITLE_SUBSTR}|{label}") or "" + except Exception as e: + log.warning(f"agent JSTATE {label!r}: {type(e).__name__}: {e}") + return None, "the agent did not answer" + if resp.startswith("OK value="): + return label, None + ambiguous = ambiguous or "jcheck_ambiguous" in resp + if ambiguous: + return None, "more than one box on API → Precautions has that label" + return None, "the bypass box is not showing, or is disabled, on API → Precautions" + + # Gateway 10.50.1f answers OK with this notice when the commit newly # selects Auto restart: a modal titled "IBKR Gateway", like the main # frame, reading "You have elected to have your trading platform restart @@ -3165,6 +3197,11 @@ def handle_post_login_config(): TWS_MASTER_CLIENT_ID — integer, API → Settings → Master client ID READ_ONLY_API — yes/no, API → Settings → Read-Only API + Present in Gateway's API → Precautions panel: + BYPASS_ORDER_PRECAUTIONS — yes/no, "Bypass Order Precautions for + API Orders" (or Gateway's longer "...and + Ticket Precautions..." label) + Present in Gateway's Lock and Exit panel (one of two, depending on whether the account has the autorestart daily-token cycle enabled — Gateway shows *either* Auto Log Off Time *or* Auto @@ -3176,8 +3213,8 @@ def handle_post_login_config(): isn't showing in this session, a clear warning is logged. NOT present in Gateway's config dialog at all (TWS-only; we warn): - ALLOW_BLIND_TRADING — TWS Precautions tab; Gateway has no - equivalent in its simplified config + ALLOW_BLIND_TRADING — TWS Precautions tab; Gateway's + Precautions page has no such box SAVE_TWS_SETTINGS — not a Gateway knob Skips any knob whose env var isn't set or is empty. Non-fatal on @@ -3190,13 +3227,16 @@ def handle_post_login_config(): read_only_api = _coerce_yes_no(read_only_api_raw) auto_logoff_time = os.environ.get("AUTO_LOGOFF_TIME", "").strip() auto_restart_time = os.environ.get("AUTO_RESTART_TIME", "").strip() + bypass_precautions_raw = os.environ.get("BYPASS_ORDER_PRECAUTIONS", "") + bypass_precautions = _coerce_yes_no(bypass_precautions_raw) # Truly TWS-only — not present in any Gateway config state we've observed allow_blind_trading = _coerce_yes_no(os.environ.get("ALLOW_BLIND_TRADING", "")) save_tws_settings = os.environ.get("SAVE_TWS_SETTINGS", "").strip() wanted_api_tab = master_client_id or read_only_api is not None + wanted_precautions_tab = bypass_precautions is not None wanted_lock_exit_tab = bool(auto_logoff_time) or bool(auto_restart_time) - wanted_anything = wanted_api_tab or wanted_lock_exit_tab + wanted_anything = wanted_api_tab or wanted_precautions_tab or wanted_lock_exit_tab # Early warning for the truly TWS-only vars tws_only_set = [] @@ -3221,8 +3261,17 @@ def handle_post_login_config(): f"Post-login config env: TWS_MASTER_CLIENT_ID={master_client_id!r}, " f"READ_ONLY_API={read_only_api_raw!r} (coerced={read_only_api!r}), " f"AUTO_LOGOFF_TIME={auto_logoff_time!r}, " - f"AUTO_RESTART_TIME={auto_restart_time!r}" + f"AUTO_RESTART_TIME={auto_restart_time!r}, " + f"BYPASS_ORDER_PRECAUTIONS={bypass_precautions_raw!r} " + f"(coerced={bypass_precautions!r})" ) + for name, raw, coerced in ( + ("READ_ONLY_API", read_only_api_raw, read_only_api), + ("BYPASS_ORDER_PRECAUTIONS", bypass_precautions_raw, bypass_precautions)): + if raw.strip() and coerced is None: + log.warning(f"Post-login config: {name}={raw.strip()!r} isn't one of " + "yes/no, true/false, on/off, 1/0; leaving that setting " + "as Gateway has it") if not wanted_anything: log.info("Post-login config: no supported env vars set, skipping") @@ -3238,6 +3287,8 @@ def handle_post_login_config(): # Read back after OK, like the Lock and Exit time (#69). read_only_written = False client_id_written = False + # The bypass box's label as Gateway showed it, once written. + precautions_written = None # (label, value, env var) of the Lock and Exit time we wrote, if any. # Verified after OK — see _verify_lock_exit_time_persisted. lock_exit_written = None @@ -3268,6 +3319,29 @@ def handle_post_login_config(): else: log.warning(" Failed to toggle Read-Only API") + if wanted_precautions_tab: + log.info(" Navigating to API → Precautions") + not_set = label = None + if not agent_jtree_select_path(CONFIG_WINDOW_TITLE_SUBSTR, "API/Precautions"): + not_set = "could not select API → Precautions" + else: + time.sleep(0.5) # let the right panel render + label, not_set = _bypass_precautions_label() + if label is not None: + log.info(f" Setting {label} = {bypass_precautions}") + if agent_jcheck(CONFIG_WINDOW_TITLE_SUBSTR, label, bypass_precautions): + changed = True + precautions_written = label + else: + not_set = "the bypass box could not be set" + if not_set: + log.error( + f"ALERT_CONFIG_NOT_APPLIED mode={TRADING_MODE} " + f"setting=\"{label or _BYPASS_PRECAUTIONS_LABELS[0]}\" " + f"env_var=BYPASS_ORDER_PRECAUTIONS " + f"requested=\"{bypass_precautions_raw.strip()}\" " + f"reason=\"{not_set}\"") + if wanted_lock_exit_tab: log.info(" Navigating to Lock and Exit") if not agent_jtree_select_path(CONFIG_WINDOW_TITLE_SUBSTR, "Lock and Exit"): @@ -3350,7 +3424,9 @@ def handle_post_login_config(): ("Read-Only API", "READ_ONLY_API", read_only_api_raw, read_only_api is not None), ("Master API client ID", "TWS_MASTER_CLIENT_ID", master_client_id, - bool(str(master_client_id or "").strip()))): + bool(str(master_client_id or "").strip())), + (precautions_written, "BYPASS_ORDER_PRECAUTIONS", + bypass_precautions_raw, precautions_written is not None)): if applied: log.error( f"ALERT_CONFIG_NOT_APPLIED mode={TRADING_MODE} " @@ -3377,6 +3453,9 @@ def handle_post_login_config(): master_client_id)) if read_only_written: unsaved.append(("Read-Only API", "READ_ONLY_API", read_only_api_raw)) + if precautions_written is not None: + unsaved.append((precautions_written, "BYPASS_ORDER_PRECAUTIONS", + bypass_precautions_raw)) if lock_exit_written is not None: label, value, env_var = lock_exit_written unsaved.append((label, env_var, value)) @@ -3413,6 +3492,23 @@ def handle_post_login_config(): else: log.warning(" Could not verify Read-Only API — check it " "under Configure → Settings → API → Settings.") + if precautions_written is not None: + label = precautions_written + time.sleep(1.0) # let the dialog fully dispose before re-opening + verified = _verify_checkbox_persisted("API/Precautions", label, + bypass_precautions) + if verified is True: + log.info(f" Verified: {label} reads back as {bypass_precautions}") + elif verified is False: + log.error( + f"ALERT_CONFIG_NOT_APPLIED mode={TRADING_MODE} " + f"setting=\"{label}\" env_var=BYPASS_ORDER_PRECAUTIONS " + f"requested=\"{bypass_precautions_raw.strip()}\" " + f"reason=\"Gateway did not retain the value after OK; " + f"{'API orders still go through order precautions' if bypass_precautions else 'API orders still bypass order precautions'}\"") + else: + log.warning(f" Could not verify {label} — check it under " + "Configure → Settings → API → Precautions.") if lock_exit_written is not None: label, value, env_var = lock_exit_written time.sleep(1.0) # let the dialog fully dispose before re-opening diff --git a/scripts/ibc_config_to_env.py b/scripts/ibc_config_to_env.py index 60e72be..7be3441 100755 --- a/scripts/ibc_config_to_env.py +++ b/scripts/ibc_config_to_env.py @@ -63,6 +63,7 @@ def _existing_session_action(v: str) -> Optional[str]: "IbPassword": ("TWS_PASSWORD", _identity), "TradingMode": ("TRADING_MODE", lambda v: v.strip().lower()), "ReadOnlyApi": ("READ_ONLY_API", _yes_no), + "BypassOrderPrecautions": ("BYPASS_ORDER_PRECAUTIONS", _yes_no), "ExistingSessionDetectedAction": ( "EXISTING_SESSION_DETECTED_ACTION", _existing_session_action), "AutoRestartTime": ("AUTO_RESTART_TIME", _identity), diff --git a/tests/integration/SessionLockExitFixture.java b/tests/integration/SessionLockExitFixture.java index 670858d..4acc0ae 100644 --- a/tests/integration/SessionLockExitFixture.java +++ b/tests/integration/SessionLockExitFixture.java @@ -30,8 +30,10 @@ * Gateway-shaped windows the controller drives through the real agent: * * - "IBKR Gateway" main frame with Configure → Settings. - * - "Trader Workstation Configuration": a tree (API/Settings, Lock and - * Exit) and the Lock and Exit page laid out as in Gateway 10.50.1f + * - "Trader Workstation Configuration": a tree (API/Settings, + * API/Precautions, Lock and Exit). API → Precautions holds Gateway's + * bypass box, labelled "Bypass Order Precautions for API Orders", or + * the longer "...and Ticket..." label after TICKET_LABEL (#68). And the Lock and Exit page laid out as in Gateway 10.50.1f * (monitored_panels.p, data_holders.br): an "Auto restart" / "Auto * logoff" radio group, ONE time label whose text Gateway switches with * the mode (the restart text only when daily restart is allowed), the @@ -60,7 +62,7 @@ public class SessionLockExitFixture { static final int LAG = 1500; static String mode, time, ampm, clientId; - static boolean readOnly, dailyAllowed = true, roStuck; + static boolean readOnly, dailyAllowed = true, roStuck, bypass, ticketLabel; static JFrame main; static void reset() { @@ -69,6 +71,7 @@ static void reset() { ampm = "PM"; readOnly = true; clientId = "0"; + bypass = false; } /** Gateway 10.50.1f (jutils.d2.a): a strict "hh:mm" DateFormat @@ -95,6 +98,7 @@ static void openConfig() { DefaultMutableTreeNode root = new DefaultMutableTreeNode("Configuration"); DefaultMutableTreeNode api = new DefaultMutableTreeNode("API"); api.add(new DefaultMutableTreeNode("Settings")); + api.add(new DefaultMutableTreeNode("Precautions")); root.add(api); root.add(new DefaultMutableTreeNode("Lock and Exit")); JTree tree = new JTree(root); @@ -115,6 +119,15 @@ static void openConfig() { apiPage.add(ro); right.add(apiPage, "api"); + // API → Precautions (feature.configure.plugins.custom.panel.R) + JPanel precPage = new JPanel(new GridLayout(0, 1)); + JCheckBox bp = new JCheckBox(ticketLabel + ? "Bypass Order and Ticket Precautions for API Orders and Tickets" + : "Bypass Order Precautions for API Orders", bypass); + precPage.add(bp); + precPage.add(new JCheckBox("Bypass Bond warning for API Orders")); + right.add(precPage, "prec"); + // Lock and Exit, shaped like 10.50.1f JPanel lockPage = new JPanel(); lockPage.setLayout(new BoxLayout(lockPage, BoxLayout.Y_AXIS)); @@ -161,6 +174,7 @@ static void openConfig() { TreePath p = ev.getNewLeadSelectionPath(); String last = p == null ? "" : p.getLastPathComponent().toString(); cards.show(right, "Settings".equals(last) ? "api" + : "Precautions".equals(last) ? "prec" : "Lock and Exit".equals(last) ? "lock" : "none"); }); @@ -178,8 +192,9 @@ static void openConfig() { ampm = am.isSelected() ? "AM" : "PM"; if (!roStuck) readOnly = ro.isSelected(); clientId = cid.getText().trim(); + bypass = bp.isSelected(); say("SAVED|mode=" + mode + "|time=" + time + "|ampm=" + ampm - + "|ro=" + readOnly + "|cid=" + clientId); + + "|ro=" + readOnly + "|cid=" + clientId + "|bypass=" + bypass); d.dispose(); }); JButton cancel = new JButton("Cancel"); @@ -269,6 +284,8 @@ public static void main(String[] args) throws Exception { case "DAILY": dailyAllowed = true; break; case "RO_STUCK": roStuck = true; break; case "RO_KEPT": roStuck = false; break; + case "TICKET_LABEL": ticketLabel = true; break; + case "ORDER_LABEL": ticketLabel = false; break; default: break; } say("ACK|" + line.trim()); diff --git a/tests/integration/session_lockexit_drill.py b/tests/integration/session_lockexit_drill.py index 9095121..f5d1573 100644 --- a/tests/integration/session_lockexit_drill.py +++ b/tests/integration/session_lockexit_drill.py @@ -10,6 +10,9 @@ a panel already in restart mode is left alone, an AM logoff time sticks, and a mode Gateway won't show (no daily restart) is switched back rather than saved without its time. +- #68: BYPASS_ORDER_PRECAUTIONS ticks or unticks the bypass box on + API → Precautions under either of Gateway's labels and reads it back; + unset leaves it alone. - #69: newly selecting Auto restart makes Gateway answer OK with its Automatic Restart notice (a modal titled "IBKR Gateway", like the main frame) and save the rest only once that's answered; the controller @@ -70,7 +73,8 @@ def since(mark, prefix): def config_pass(env): """One real post-login config pass with only ``env`` set.""" - keys = ("AUTO_RESTART_TIME", "AUTO_LOGOFF_TIME", "READ_ONLY_API", "TWS_MASTER_CLIENT_ID") + keys = ("AUTO_RESTART_TIME", "AUTO_LOGOFF_TIME", "READ_ONLY_API", "TWS_MASTER_CLIENT_ID", + "BYPASS_ORDER_PRECAUTIONS") clean = {k: "" for k in keys} clean.update(env) mark, log_mark = len(LINES), len(LOGS) @@ -197,6 +201,38 @@ def send(cmd): send("RO_KEPT") send("RESET") + print("\n--- #68: BYPASS_ORDER_PRECAUTIONS=yes, with the Automatic Restart notice ---") + saved, _c, logs = config_pass({"BYPASS_ORDER_PRECAUTIONS": "yes", "READ_ONLY_API": "no", + "AUTO_RESTART_TIME": "08:00 PM"}) + check("one save, the bypass box ticked", + len(saved) == 1 and saved[0].endswith("|bypass=true"), str(saved)) + check("read back as on", + any("Verified: Bypass Order Precautions for API Orders reads back as True" in m + for m in logs), [m for m in logs if "Bypass" in m]) + check("no ALERT_CONFIG_NOT_APPLIED", + not any("ALERT_CONFIG_NOT_APPLIED" in m for m in logs), + [m for m in logs if "ALERT" in m]) + + print("\n--- #68: the longer label some accounts show; unticking ---") + send("TICKET_LABEL") + saved, _c, logs = config_pass({"BYPASS_ORDER_PRECAUTIONS": "no"}) + check("saved with the box unticked", + saved and saved[-1].endswith("|bypass=false"), str(saved)) + check("read back under the longer label", + any("Verified: Bypass Order and Ticket Precautions for API Orders and Tickets " + "reads back as False" in m for m in logs), [m for m in logs if "Bypass" in m]) + check("no agent errors from the label that isn't there", + not any("not_found" in m for m in logs), [m for m in logs if "not_found" in m]) + send("ORDER_LABEL") + + print("\n--- #68: BYPASS_ORDER_PRECAUTIONS unset leaves the box as it is ---") + saved, _c, logs = config_pass({"READ_ONLY_API": "no"}) + check("box still unticked, nothing about it in the log", + saved and saved[-1].endswith("|bypass=false") + and not any("Precautions" in m for m in logs if "env:" not in m), + str(saved)) + send("RESET") + print("\n--- #63: prompt during the API-port wait (click lands 1.5 s late) ---") for name in ("_existing_session_takeovers", "_existing_session_click_times"): getattr(gc, name, []).clear() diff --git a/tests/test_ibc_config_to_env.py b/tests/test_ibc_config_to_env.py index bf3f0ec..c3ac50c 100644 --- a/tests/test_ibc_config_to_env.py +++ b/tests/test_ibc_config_to_env.py @@ -83,6 +83,11 @@ def test_yes_no_transform(self): self.assertEqual(d["READ_ONLY_API"], "yes") self.assertEqual(d["ALLOW_BLIND_TRADING"], "no") + def test_bypass_order_precautions_maps(self): + env, warnings = ibc.convert(ibc.parse_ibc_config("BypassOrderPrecautions=yes\n")) + self.assertEqual(dict(env)["BYPASS_ORDER_PRECAUTIONS"], "yes") + self.assertEqual(warnings, []) + def test_yes_no_unrecognized_warns(self): pairs = ibc.parse_ibc_config("ReadOnlyApi=maybe\n") env, warnings = ibc.convert(pairs) diff --git a/tests/test_pure_logic.py b/tests/test_pure_logic.py index edd634d..e94991f 100644 --- a/tests/test_pure_logic.py +++ b/tests/test_pure_logic.py @@ -5219,6 +5219,119 @@ def test_an_agent_error_is_unverified(self): close.assert_called_once_with("Cancel") +class TestBypassOrderPrecautions(unittest.TestCase): + """Issue #68: BYPASS_ORDER_PRECAUTIONS ticks or unticks Gateway's + bypass box on API → Precautions, under whichever of its two labels + Gateway shows, and reads it back after OK. Unset leaves it alone.""" + + SHORT, LONG = gc._BYPASS_PRECAUTIONS_LABELS + + def _config(self, env, shown=SHORT, tree_ok=True, jcheck_ok=True, + verified=True, committed=True): + base = {"AUTO_LOGOFF_TIME": "", "AUTO_RESTART_TIME": "", + "TWS_MASTER_CLIENT_ID": "", "READ_ONLY_API": "", + "BYPASS_ORDER_PRECAUTIONS": ""} + + def agent(cmd): # JSTATE, asked to find which label is showing + label = cmd.split("|", 1)[1] + if shown == "ambiguous": + return f"ERR jcheck_ambiguous jtoggle={label} matches=2" + return "OK value=false" if label == shown else f"ERR not_found jtoggle={label}" + + with patch.dict(os.environ, {**base, **env}), \ + patch.object(gc, "_config_open", return_value=True), \ + patch.object(gc, "agent_jtree_select_path", return_value=tree_ok) as tree, \ + patch.object(gc, "_agent_request", side_effect=agent) as req, \ + patch.object(gc, "agent_jcheck", return_value=jcheck_ok) as jcheck, \ + patch.object(gc, "_config_close", return_value=True) as close, \ + patch.object(gc, "_wait_config_committed", return_value=committed), \ + patch.object(gc, "_verify_checkbox_persisted", return_value=verified) as verify, \ + patch.object(gc.time, "sleep"), \ + self.assertLogs("controller", level="INFO") as cm, \ + _capture_controller_errors() as errors: + gc.handle_post_login_config() + return {"tree": [c.args[1] for c in tree.call_args_list], "req": req, + "jcheck": jcheck, "verify": verify, + "closes": [c.args[0] for c in close.call_args_list], + "out": cm.output, "errors": errors} + + def test_yes_ticks_the_box_and_reads_it_back(self): + r = self._config({"BYPASS_ORDER_PRECAUTIONS": "yes"}) + self.assertEqual(r["tree"], ["API/Precautions"]) + r["jcheck"].assert_called_once_with(gc.CONFIG_WINDOW_TITLE_SUBSTR, self.SHORT, True) + r["verify"].assert_called_once_with("API/Precautions", self.SHORT, True) + self.assertEqual(r["closes"], ["OK"]) + self.assertTrue(any(f"Verified: {self.SHORT} reads back as True" in m for m in r["out"])) + self.assertEqual(r["errors"], []) + + def test_the_longer_label_is_used_when_gateway_shows_it(self): + r = self._config({"BYPASS_ORDER_PRECAUTIONS": "no"}, shown=self.LONG) + r["jcheck"].assert_called_once_with(gc.CONFIG_WINDOW_TITLE_SUBSTR, self.LONG, False) + r["verify"].assert_called_once_with("API/Precautions", self.LONG, False) + self.assertTrue(any(f"Verified: {self.LONG} reads back as False" in m for m in r["out"])) + self.assertFalse(any("not_found" in m for m in r["out"])) # the other label, asked quietly + self.assertEqual(r["errors"], []) + + def test_a_missing_or_disabled_box_alerts_and_saves_nothing(self): + r = self._config({"BYPASS_ORDER_PRECAUTIONS": "yes"}, shown=None) + r["jcheck"].assert_not_called() + r["verify"].assert_not_called() + self.assertEqual(r["closes"], ["Cancel"]) + self.assertEqual(len(r["errors"]), 1, r["errors"]) + self.assertIn('env_var=BYPASS_ORDER_PRECAUTIONS requested="yes"', r["errors"][0]) + self.assertIn("not showing, or is disabled", r["errors"][0]) + + def test_a_page_it_cannot_reach_alerts(self): + r = self._config({"BYPASS_ORDER_PRECAUTIONS": "yes"}, tree_ok=False) + r["req"].assert_not_called() + self.assertIn("could not select API → Precautions", r["errors"][0]) + + def test_a_click_that_fails_alerts_under_the_label_shown(self): + r = self._config({"BYPASS_ORDER_PRECAUTIONS": "yes"}, shown=self.LONG, jcheck_ok=False) + r["verify"].assert_not_called() + self.assertIn("could not be set", r["errors"][0]) + self.assertIn(f'setting="{self.LONG}"', r["errors"][0]) + + def test_two_matching_boxes_are_named_as_such(self): + r = self._config({"BYPASS_ORDER_PRECAUTIONS": "yes"}, shown="ambiguous") + r["jcheck"].assert_not_called() + self.assertIn("more than one box", r["errors"][0]) + + def test_a_value_gateway_did_not_keep_alerts_both_ways(self): + r = self._config({"BYPASS_ORDER_PRECAUTIONS": "yes"}, verified=False) + self.assertEqual(len(r["errors"]), 1, r["errors"]) + self.assertIn(f'setting="{self.SHORT}" env_var=BYPASS_ORDER_PRECAUTIONS', r["errors"][0]) + self.assertIn("still go through order precautions", r["errors"][0]) + r = self._config({"BYPASS_ORDER_PRECAUTIONS": "no"}, verified=False) + self.assertIn("still bypass order precautions", r["errors"][0]) + + def test_an_unreadable_value_is_unverified_not_wrong(self): + r = self._config({"BYPASS_ORDER_PRECAUTIONS": "yes"}, verified=None) + self.assertEqual(r["errors"], []) + self.assertTrue(any(f"Could not verify {self.SHORT}" in m for m in r["out"])) + + def test_an_unclosed_dialog_reports_it_unsaved(self): + r = self._config({"BYPASS_ORDER_PRECAUTIONS": "yes"}, committed=False) + r["verify"].assert_not_called() + self.assertEqual(len(r["errors"]), 1, r["errors"]) + self.assertIn("env_var=BYPASS_ORDER_PRECAUTIONS", r["errors"][0]) + self.assertIn("did not close after OK", r["errors"][0]) + + def test_unset_or_unrecognised_leaves_the_box_alone(self): + for value in ("", "maybe"): + r = self._config({"BYPASS_ORDER_PRECAUTIONS": value, "READ_ONLY_API": "no"}) + self.assertNotIn("API/Precautions", r["tree"]) + r["req"].assert_not_called() + self.assertFalse(any(c.args[1] in gc._BYPASS_PRECAUTIONS_LABELS + for c in r["jcheck"].call_args_list)) + self.assertTrue(any("BYPASS_ORDER_PRECAUTIONS='maybe' (coerced=None)" in m + for m in r["out"])) + self.assertTrue(any("BYPASS_ORDER_PRECAUTIONS='maybe' isn't one of yes/no" in m + for m in r["out"])) + r = self._config({"BYPASS_ORDER_PRECAUTIONS": "", "READ_ONLY_API": "no"}) + self.assertFalse(any("isn't one of" in m for m in r["out"])) + + class TestConfigCommitWait(unittest.TestCase): """Issue #69: Gateway 10.50.1f answers OK with a notice when the commit newly selects Auto restart, and saves the rest of the dialog only once