From 864eb7f82728060975aba9e4e4cfd5e79e90ce86 Mon Sep 17 00:00:00 2001 From: Sunli Date: Fri, 18 Sep 2026 11:54:12 +0800 Subject: [PATCH 1/9] feat(trade): add DelayedNotReported order status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway reports time-conditional orders that are being monitored, before they are reported to the exchange, with the order status `DelayedNotReported` (see longbridge/developers#1263). No SDK enum matched it, so `impl_serde_for_enum_string!`'s `unwrap_or_default()` turned it into `OrderStatus::Unknown` — monitoring orders were indistinguishable from a genuinely unrecognized status in `today_orders` / `history_orders` / `order_detail` and the order-changed push. Added across all six layers: Rust, C (header regenerated by cbindgen), C++ (enum plus both conversion directions), Java (JNI list plus the Java enum), Node.js (`index.d.ts` regenerated) and Python (plus the `openapi.pyi` stub). The variant is appended after `PartialWithdrawal` rather than placed next to the other `*NotReported` values: the C/C++ and Node.js enums use implicit discriminants, so inserting mid-list would renumber every following value — an ABI break for the C/C++ bindings, and a silent mismatch for TypeScript code that inlined the old `const enum` values. --- CHANGELOG.md | 6 ++++++ c/csrc/include/longbridge.h | 4 ++++ c/src/trade_context/enum_types.rs | 3 +++ cpp/include/types.hpp | 2 ++ cpp/src/convert.hpp | 4 ++++ .../src/main/java/com/longbridge/trade/OrderStatus.java | 2 ++ java/src/types/enum_types.rs | 1 + nodejs/index.d.ts | 4 +++- nodejs/src/trade/types.rs | 2 ++ python/pysrc/longbridge/openapi.pyi | 5 +++++ python/src/trade/types.rs | 2 ++ rust/src/trade/types.rs | 3 +++ 12 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b424597b..0f4825d0bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **All SDKs:** `OrderStatus` gains a `DelayedNotReported` variant (wire value `DelayedNotReported`) — "monitoring", the state a time-conditional order sits in before it is reported to the exchange. Previously this status deserialized to `OrderStatus::Unknown`, so time-conditional orders being monitored were indistinguishable from a genuinely unrecognized status in `today_orders` / `history_orders` / `order_detail` and the order-changed push. The variant is appended **after** `PartialWithdrawal` rather than inserted next to the other `*NotReported` values on purpose: the C/C++/Node.js enums use implicit discriminants, and inserting mid-list would renumber every following value (an ABI break for the C/C++ bindings, and a silent mismatch for TypeScript code that inlined the old `const enum` values) + ## [5.0.0] - 2026-09-14 ### Changed diff --git a/c/csrc/include/longbridge.h b/c/csrc/include/longbridge.h index e3b78b5540..2e501c36ed 100644 --- a/c/csrc/include/longbridge.h +++ b/c/csrc/include/longbridge.h @@ -1261,6 +1261,10 @@ typedef enum lb_order_status_t { * Partial Withdrawal */ OrderStatusPartialWithdrawal, + /** + * Monitoring (Time-Conditional Order) + */ + OrderStatusDelayedNotReported, } lb_order_status_t; /** diff --git a/c/src/trade_context/enum_types.rs b/c/src/trade_context/enum_types.rs index dc7260af3d..e13a2ef13e 100644 --- a/c/src/trade_context/enum_types.rs +++ b/c/src/trade_context/enum_types.rs @@ -138,6 +138,9 @@ pub enum COrderStatus { /// Partial Withdrawal #[c(remote = "PartialWithdrawal")] OrderStatusPartialWithdrawal, + /// Monitoring (Time-Conditional Order) + #[c(remote = "DelayedNotReported")] + OrderStatusDelayedNotReported, } /// Order tag diff --git a/cpp/include/types.hpp b/cpp/include/types.hpp index 33ae6ffb7b..a83fcdae18 100644 --- a/cpp/include/types.hpp +++ b/cpp/include/types.hpp @@ -1480,6 +1480,8 @@ enum class OrderStatus Expired, /// Partial Withdrawal PartialWithdrawal, + /// Monitoring (Time-Conditional Order) + DelayedNotReported, }; /// Order type diff --git a/cpp/src/convert.hpp b/cpp/src/convert.hpp index f18c05ff96..482080ba35 100644 --- a/cpp/src/convert.hpp +++ b/cpp/src/convert.hpp @@ -1051,6 +1051,8 @@ convert(lb_order_status_t status) return OrderStatus::Expired; case OrderStatusPartialWithdrawal: return OrderStatus::PartialWithdrawal; + case OrderStatusDelayedNotReported: + return OrderStatus::DelayedNotReported; default: throw std::invalid_argument("unreachable"); } @@ -1096,6 +1098,8 @@ convert(OrderStatus status) return OrderStatusExpired; case OrderStatus::PartialWithdrawal: return OrderStatusPartialWithdrawal; + case OrderStatus::DelayedNotReported: + return OrderStatusDelayedNotReported; default: throw std::invalid_argument("unreachable"); } diff --git a/java/javasrc/src/main/java/com/longbridge/trade/OrderStatus.java b/java/javasrc/src/main/java/com/longbridge/trade/OrderStatus.java index 8813cc4ae4..7fbeae1f65 100644 --- a/java/javasrc/src/main/java/com/longbridge/trade/OrderStatus.java +++ b/java/javasrc/src/main/java/com/longbridge/trade/OrderStatus.java @@ -40,4 +40,6 @@ public enum OrderStatus { Expired, /** Partial withdrawal */ PartialWithdrawal, + /** Monitoring (time-conditional order) */ + DelayedNotReported, } diff --git a/java/src/types/enum_types.rs b/java/src/types/enum_types.rs index 38ceb4fcac..1d16d9428b 100644 --- a/java/src/types/enum_types.rs +++ b/java/src/types/enum_types.rs @@ -381,6 +381,7 @@ impl_java_enum!( Canceled, Expired, PartialWithdrawal, + DelayedNotReported, ] ); diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts index df10f667e8..5425b6bf16 100644 --- a/nodejs/index.d.ts +++ b/nodejs/index.d.ts @@ -6227,7 +6227,9 @@ export declare const enum OrderStatus { /** Expired */ Expired = 16, /** Partial Withdrawal */ - PartialWithdrawal = 17 + PartialWithdrawal = 17, + /** Monitoring (Time-Conditional Order) */ + DelayedNotReported = 18 } /** Order tag */ diff --git a/nodejs/src/trade/types.rs b/nodejs/src/trade/types.rs index b251bc9c70..859a156b50 100644 --- a/nodejs/src/trade/types.rs +++ b/nodejs/src/trade/types.rs @@ -74,6 +74,8 @@ pub enum OrderStatus { Expired, /// Partial Withdrawal PartialWithdrawal, + /// Monitoring (Time-Conditional Order) + DelayedNotReported, } #[napi_derive::napi] diff --git a/python/pysrc/longbridge/openapi.pyi b/python/pysrc/longbridge/openapi.pyi index cdfb56adbd..407e3e1569 100644 --- a/python/pysrc/longbridge/openapi.pyi +++ b/python/pysrc/longbridge/openapi.pyi @@ -5704,6 +5704,11 @@ class OrderStatus: PartialWithdrawal """ + class DelayedNotReported(OrderStatus): + """ + Monitoring (Time-Conditional Order) + """ + class OrderTag: """ Order tag diff --git a/python/src/trade/types.rs b/python/src/trade/types.rs index 0baefd3455..cd281e2be0 100644 --- a/python/src/trade/types.rs +++ b/python/src/trade/types.rs @@ -90,6 +90,8 @@ pub(crate) enum OrderStatus { Expired, /// Partial Withdrawal PartialWithdrawal, + /// Monitoring (Time-Conditional Order) + DelayedNotReported, } #[pyclass(eq, eq_int, from_py_object)] diff --git a/rust/src/trade/types.rs b/rust/src/trade/types.rs index 46f079c175..7ea71b56aa 100644 --- a/rust/src/trade/types.rs +++ b/rust/src/trade/types.rs @@ -109,6 +109,9 @@ pub enum OrderStatus { /// Partial Withdrawal #[strum(serialize = "PartialWithdrawal")] PartialWithdrawal, + /// Monitoring (Time-Conditional Order) + #[strum(serialize = "DelayedNotReported")] + DelayedNotReported, } /// Execution From 38184a7e5fa3532294de35037c27fb90ae9093e9 Mon Sep 17 00:00:00 2001 From: Sunli Date: Fri, 18 Sep 2026 11:58:49 +0800 Subject: [PATCH 2/9] chore(nodejs): regenerate index.js with the pinned @napi-rs/cli 3.8.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The committed index.js was generated by an older CLI than the 3.8.6 the release workflow pins, so regenerating it alongside index.d.ts produces loader churn unrelated to the enum change. Committing it here keeps the two generated files in step with each other and with CI. What the regenerated loader changes: - `require('node:fs')` → `require('fs')` and the optional chaining in the musl / win32 probes is expanded, restoring older-Node compatibility - NAPI_RS_FORCE_WASI is now tri-state ('true' / 'error' / unset) instead of any-non-empty-string truthy, so NAPI_RS_FORCE_WASI=false or =0 no longer takes the WASI path and fails with ENOENT - adds NAPI_RS_WASI_FLAVOR for selecting one exact generated flavor - WASI candidates are resolved before being required, and the load-error chain is built without mutating the original errors; `error.cause` is assigned rather than passed to the Error options form, which Node < 16.9 ignores --- nodejs/index.js | 186 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 155 insertions(+), 31 deletions(-) diff --git a/nodejs/index.js b/nodejs/index.js index 88789ebfbe..ed780f4738 100644 --- a/nodejs/index.js +++ b/nodejs/index.js @@ -3,7 +3,7 @@ // @ts-nocheck /* auto-generated by NAPI-RS */ -const { readFileSync } = require('node:fs') +const { readFileSync } = require('fs') let nativeBinding = null const loadErrors = [] @@ -33,7 +33,7 @@ const isMuslFromFilesystem = () => { const isMuslFromReport = () => { let report = null - if (typeof process.report?.getReport === 'function') { + if (process.report && typeof process.report.getReport === 'function') { process.report.excludeNetwork = true report = process.report.getReport() } @@ -105,7 +105,7 @@ function requireNative() { } } else if (process.platform === 'win32') { if (process.arch === 'x64') { - if (process.config?.variables?.shlib_suffix === 'dll.a' || process.config?.variables?.node_target_type === 'shared_library') { + if ((process.config && process.config.variables && process.config.variables.shlib_suffix === 'dll.a') || (process.config && process.config.variables && process.config.variables.node_target_type === 'shared_library')) { try { return require('./longbridge.win32-x64-gnu.node') } catch (e) { @@ -523,54 +523,178 @@ function requireNative() { } } -nativeBinding = requireNative() +function createLoadErrorChain(errors) { + return errors.reduce((previous, current) => { + let message + try { + message = + current && typeof current.message === 'string' + ? current.message + : String(current) + } catch { + message = 'Unknown error' + } + const error = new Error(message) + error.cause = previous + return error + }, null) +} + +// NAPI_RS_FORCE_WASI is a tri-state flag: +// unset / any other value → native binding preferred, WASI is only a fallback +// 'true' → prefer WASI, but retain native as a lazy fallback +// 'error' → require WASI without initializing a native fallback +// Treating any non-empty string as truthy (the historical behavior) meant +// NAPI_RS_FORCE_WASI=false, NAPI_RS_FORCE_WASI=0, etc. inadvertently triggered +// the WASI path, causing ENOENT for packages shipped without a .wasi.cjs file. +// +// NAPI_RS_WASI_FLAVOR selects one exact generated flavor and implies strict +// WASI loading. It never crosses into another flavor or falls back to native. +const __napiWasiFlavors = ["wasm32-wasi"] +const __napiWasiFlavor = process.env.NAPI_RS_WASI_FLAVOR +const __napiWasiFlavorRequested = + typeof __napiWasiFlavor === 'string' && __napiWasiFlavor.length > 0 +if ( + __napiWasiFlavorRequested && + __napiWasiFlavors.indexOf(__napiWasiFlavor) === -1 +) { + throw new Error( + 'Unsupported WASI flavor "' + + __napiWasiFlavor + + '". Available flavors: ' + + __napiWasiFlavors.join(', '), + ) +} +const forceWasiError = process.env.NAPI_RS_FORCE_WASI === 'error' +const forceWasi = + process.env.NAPI_RS_FORCE_WASI === 'true' || + forceWasiError || + __napiWasiFlavorRequested + +if (!forceWasi) { + nativeBinding = requireNative() +} -if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { +if (!nativeBinding || forceWasi) { let wasiBinding = null - let wasiBindingError = null - try { - wasiBinding = require('./longbridge.wasi.cjs') - nativeBinding = wasiBinding - } catch (err) { - if (process.env.NAPI_RS_FORCE_WASI) { - wasiBindingError = err + let wasiBindingLoaded = false + const wasiBindingErrors = [] + const __napiWasiResolveCandidate = (specifier, isPackage, localArtifacts) => { + try { + require.resolve(specifier) + } catch (resolveError) { + if (!resolveError || resolveError.code !== 'MODULE_NOT_FOUND') { + throw resolveError + } + if (isPackage) { + try { + require.resolve(specifier + '/package.json') + } catch (packageError) { + if (packageError && packageError.code === 'MODULE_NOT_FOUND') { + return resolveError + } + // An exports restriction proves the package exists even when its + // package.json is not public. Preserve the root resolution failure. + throw resolveError + } + // The package exists but its main/export target is broken. + throw resolveError + } + return resolveError } + if (localArtifacts) { + let artifactError = null + for (let i = 0; i < localArtifacts.length; i++) { + try { + require.resolve(localArtifacts[i]) + return null + } catch (resolveError) { + if (!resolveError || resolveError.code !== 'MODULE_NOT_FOUND') { + throw resolveError + } + artifactError = resolveError + } + } + return artifactError + } + return null } - if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { + if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === "wasm32-wasi")) { + let candidateError = null + let candidateFailed = false try { - wasiBinding = require('longbridge-wasm32-wasi') - nativeBinding = wasiBinding + candidateError = __napiWasiResolveCandidate('./longbridge.wasi.cjs', false, ["./longbridge.wasm32-wasi.debug.wasm","./longbridge.wasm32-wasi.wasm"]) + candidateFailed = candidateError !== null + if (!candidateFailed) { + wasiBinding = require('./longbridge.wasi.cjs') + nativeBinding = wasiBinding + wasiBindingLoaded = true + } } catch (err) { - if (process.env.NAPI_RS_FORCE_WASI) { - if (!wasiBindingError) { - wasiBindingError = err - } else { - wasiBindingError.cause = err + candidateError = err + candidateFailed = true + } + if (candidateFailed) { + wasiBindingErrors.push(candidateError) + loadErrors.push(candidateError) + } + } + if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === "wasm32-wasi")) { + let candidateError = null + let candidateFailed = false + try { + candidateError = __napiWasiResolveCandidate('longbridge-wasm32-wasi', true, undefined) + candidateFailed = candidateError !== null + if (!candidateFailed) { + if (process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + const bindingPackageVersion = require('longbridge-wasm32-wasi/package.json').version + if (bindingPackageVersion !== '0.0.0') { + throw new Error(`WASI binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } } - loadErrors.push(err) + wasiBinding = require('longbridge-wasm32-wasi') + nativeBinding = wasiBinding + wasiBindingLoaded = true } + } catch (err) { + candidateError = err + candidateFailed = true + } + if (candidateFailed) { + wasiBindingErrors.push(candidateError) + loadErrors.push(candidateError) } } - if (process.env.NAPI_RS_FORCE_WASI === 'error' && !wasiBinding) { - const error = new Error('WASI binding not found and NAPI_RS_FORCE_WASI is set to error') - error.cause = wasiBindingError + if ( + !wasiBindingLoaded && + forceWasi && + !forceWasiError && + !__napiWasiFlavorRequested + ) { + nativeBinding = requireNative() + } + if ((forceWasiError || __napiWasiFlavorRequested) && !wasiBindingLoaded) { + const error = new Error( + __napiWasiFlavorRequested + ? 'WASI binding for flavor "' + __napiWasiFlavor + '" not found' + : 'WASI binding not found and NAPI_RS_FORCE_WASI is set to error', + ) + error.cause = createLoadErrorChain(wasiBindingErrors) throw error } } if (!nativeBinding) { if (loadErrors.length > 0) { - throw new Error( + const error = new Error( `Cannot find native binding. ` + `npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` + 'Please try `npm i` again after removing both package-lock.json and node_modules directory.', - { - cause: loadErrors.reduce((err, cur) => { - cur.cause = err - return cur - }), - }, ) + // assign instead of the `new Error(message, { cause })` options form, + // which Node < 16.9 silently ignores + error.cause = createLoadErrorChain(loadErrors) + throw error } throw new Error(`Failed to load native binding`) } From 00a10717419ba8cfb9de2f6c4dcae404e91d6202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Tue, 13 Oct 2026 22:12:33 +0800 Subject: [PATCH 3/9] feat(trade): support time-condition orders (delayed_until) Documented in longbridge/developers#1289. A time-condition order is held in DelayedNotReported status and sent automatically at a set time (later than now, at most 90 days ahead). - Rust: SubmitOrderOptions / SubmitMultiLegOrderOptions gain delayed_until(OffsetDateTime); ReplaceOrderOptions gains delayed_until(OffsetDateTime) and clear_delayed_until() (sends 1). Order / OrderDetail gain delayed_until: Option, defaulting to None when the field is absent or "0". - C: delayed_until pointers (and clear_delayed_until on replace) appended to the option structs; delayed_until on lb_order_t / lb_order_detail_t. Header regenerated by cbindgen. - C++, Java, Node.js (index.d.ts regenerated), Python (+ openapi.pyi) mirror the Rust API. --- CHANGELOG.md | 1 + c/csrc/include/longbridge.h | 29 ++++++++++ c/src/trade_context/context.rs | 20 +++++++ c/src/trade_context/types.rs | 33 +++++++++++ cpp/include/types.hpp | 18 ++++++ cpp/src/convert.hpp | 4 ++ cpp/src/trade_context.cpp | 13 +++++ .../main/java/com/longbridge/trade/Order.java | 13 ++++- .../com/longbridge/trade/OrderDetail.java | 13 ++++- .../longbridge/trade/ReplaceOrderOptions.java | 26 +++++++++ .../trade/SubmitMultiLegOrderOptions.java | 16 ++++++ .../longbridge/trade/SubmitOrderOptions.java | 15 +++++ java/src/trade_context.rs | 17 ++++++ java/src/types/classes.rs | 2 + nodejs/index.d.ts | 32 +++++++++++ nodejs/src/trade/requests/replace_order.rs | 13 +++++ nodejs/src/trade/requests/submit_multileg.rs | 9 +++ nodejs/src/trade/requests/submit_order.rs | 9 +++ nodejs/src/trade/types.rs | 8 +++ python/pysrc/longbridge/openapi.pyi | 26 +++++++++ python/src/trade/context.rs | 21 ++++++- python/src/trade/context_async.rs | 21 ++++++- python/src/trade/types.rs | 8 +++ rust/src/trade/requests/replace_order.rs | 57 +++++++++++++++++++ rust/src/trade/requests/submit_multileg.rs | 18 ++++++ rust/src/trade/requests/submit_order.rs | 18 +++++- rust/src/trade/types.rs | 21 ++++++- 27 files changed, 471 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9407d2b096..2c34ba4751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Known differences:** HTTP errors are `Error::HttpClient(OpenApi { .. })` rather than `Error::WsClient(ResponseError { .. })` (`Error::openapi_error_code()` / `into_simple_error()` cover both, but a non-JSON gateway error surfaces as `HttpClientError::UnexpectedHttpResponse` with no code); the REST gateway currently reports business errors (e.g. `301600`, `301607`) as `500`; the per-command rate limits the WebSocket applies from the quote profile are not applied on the HTTP path (the HTTP client retries `429` with back-off instead), and a candlestick top-up turns one call into up to 9 requests against `/quote/history-candlesticks`; during the US overnight session (20:00–04:00 New York) the REST gateway has already rolled over to the next trading day, so `intraday` for a US equity can be empty over HTTP (its lines are all overnight and dropped) and snapshot values of `quote`, `depth`, `calc_indexes` and `capital_flow` may reflect overnight trading that the WebSocket (without `enable_overnight`) does not report; `trades` may include overnight ticks during and shortly after the overnight session. The HTTP path has no client-side rate limiting. - **C / C++ / Java / Node.js / Python:** the `QuoteTransport` enum and the corresponding config option are exposed in every binding layer. - **All SDKs:** `OrderStatus` gains a `DelayedNotReported` variant (wire value `DelayedNotReported`) — "monitoring", the state a time-conditional order sits in before it is reported to the exchange. Previously this status deserialized to `OrderStatus::Unknown`, so time-conditional orders being monitored were indistinguishable from a genuinely unrecognized status in `today_orders` / `history_orders` / `order_detail` and the order-changed push. The variant is appended **after** `PartialWithdrawal` rather than inserted next to the other `*NotReported` values on purpose: the C/C++/Node.js enums use implicit discriminants, and inserting mid-list would renumber every following value (an ABI break for the C/C++ bindings, and a silent mismatch for TypeScript code that inlined the old `const enum` values) +- **All SDKs:** time-condition orders (longbridge/developers#1289). `SubmitOrderOptions` and `SubmitMultiLegOrderOptions` gain `delayed_until`: the order is held in `DelayedNotReported` status and sent automatically at that time (later than now, at most 90 days ahead; multi-leg orders support only `LO` / `MO`). `ReplaceOrderOptions` gains `delayed_until` to move the time and `clear_delayed_until` to turn the order back into a regular one; leaving both unset keeps the current time condition. `Order` and `OrderDetail` gain `delayed_until` (unset when the order is not a time-condition order), read from `today_orders` / `history_orders` / `order_detail`. Per layer: Rust `delayed_until(OffsetDateTime)` / `clear_delayed_until()` builder methods and `Option` fields; C `const int64_t *delayed_until` (Unix seconds) on `lb_submit_order_options_t` / `CSubmitMultiLegOrderOptions` / `lb_replace_order_options_t` (plus `bool clear_delayed_until`; all appended at the end of the structs) and on `lb_order_t` / `lb_order_detail_t`; C++ `std::optional delayed_until` (+ `bool clear_delayed_until` on `ReplaceOrderOptions`); Java `setDelayedUntil(OffsetDateTime)` / `ReplaceOrderOptions.clearDelayedUntil()` and `getDelayedUntil()`; Node.js `delayedUntil?: Date` / `clearDelayedUntil?: boolean` and `get delayedUntil(): Date | null`; Python `delayed_until: Optional[datetime]` / `clear_delayed_until: bool = False` keyword arguments and `Order.delayed_until` / `OrderDetail.delayed_until` ## [5.2.0] - 2026-09-30 diff --git a/c/csrc/include/longbridge.h b/c/csrc/include/longbridge.h index 08bd83cad4..db3192e0bc 100644 --- a/c/csrc/include/longbridge.h +++ b/c/csrc/include/longbridge.h @@ -4698,6 +4698,16 @@ typedef struct lb_replace_order_options_t { * Attached order parameters (can be null) */ const struct CReplaceAttachedParams *attached_params; + /** + * New time-condition submit time, Unix timestamp in seconds (can be + * null to keep the current time condition) + */ + const int64_t *delayed_until; + /** + * Clear the time condition, turning the order into a regular order + * (takes precedence over `delayed_until`) + */ + bool clear_delayed_until; } lb_replace_order_options_t; /** @@ -4821,6 +4831,12 @@ typedef struct lb_submit_order_options_t { * Attached order parameters (can be null) */ const struct CSubmitAttachedParams *attached_params; + /** + * Time-condition submit time, Unix timestamp in seconds (can be null). + * The order is held in `DelayedNotReported` status and only submitted at + * this time. + */ + const int64_t *delayed_until; } lb_submit_order_options_t; /** @@ -4883,6 +4899,11 @@ typedef struct CSubmitMultiLegOrderOptions { * The server caches this ID for 10 minutes. */ const char *client_request_id; + /** + * Time-condition submit time, Unix timestamp in seconds (can be null). + * Only `LO` / `MO` support time conditions. + */ + const int64_t *delayed_until; } CSubmitMultiLegOrderOptions; /** @@ -5930,6 +5951,10 @@ typedef struct lb_order_t { * Remark */ const char *remark; + /** + * Time-condition submit time (maybe null) + */ + const int64_t *delayed_until; /** * Attached orders */ @@ -6514,6 +6539,10 @@ typedef struct lb_order_detail_t { * Remark */ const char *remark; + /** + * Time-condition submit time (maybe null) + */ + const int64_t *delayed_until; /** * Commission-free Status */ diff --git a/c/src/trade_context/context.rs b/c/src/trade_context/context.rs index 897021a935..58330a7d41 100644 --- a/c/src/trade_context/context.rs +++ b/c/src/trade_context/context.rs @@ -472,6 +472,14 @@ pub unsafe extern "C" fn lb_trade_context_replace_order( if !(*opts).remark.is_null() { opts2 = opts2.remark(cstr_to_rust((*opts).remark)); } + if (*opts).clear_delayed_until { + opts2 = opts2.clear_delayed_until(); + } else if !(*opts).delayed_until.is_null() { + opts2 = opts2.delayed_until( + OffsetDateTime::from_unix_timestamp(*(*opts).delayed_until) + .expect("invalid delayed until"), + ); + } if !(*opts).attached_params.is_null() { let ap = &*(*opts).attached_params; let attached_order_type: AttachedOrderType = ap.attached_order_type.into(); @@ -585,6 +593,12 @@ pub unsafe extern "C" fn lb_trade_context_submit_order( if !(*opts).client_request_id.is_null() { opts2 = opts2.client_request_id(cstr_to_rust((*opts).client_request_id)); } + if !(*opts).delayed_until.is_null() { + opts2 = opts2.delayed_until( + OffsetDateTime::from_unix_timestamp(*(*opts).delayed_until) + .expect("invalid delayed until"), + ); + } if !(*opts).attached_params.is_null() { let ap = &*(*opts).attached_params; let attached_order_type: AttachedOrderType = ap.attached_order_type.into(); @@ -655,6 +669,12 @@ pub unsafe extern "C" fn lb_trade_context_submit_multileg( if !(*opts).client_request_id.is_null() { opts2 = opts2.client_request_id(cstr_to_rust((*opts).client_request_id)); } + if !(*opts).delayed_until.is_null() { + opts2 = opts2.delayed_until( + OffsetDateTime::from_unix_timestamp(*(*opts).delayed_until) + .expect("invalid delayed until"), + ); + } execute_async(callback, ctx, userdata, async move { let resp: CCow = CCow::new(ctx_inner.submit_multileg(opts2).await?); diff --git a/c/src/trade_context/types.rs b/c/src/trade_context/types.rs index eb14ae2bf2..0d451f8626 100644 --- a/c/src/trade_context/types.rs +++ b/c/src/trade_context/types.rs @@ -940,6 +940,8 @@ pub struct COrder { pub monitor_price: *const CDecimal, /// Remark pub remark: *const c_char, + /// Time-condition submit time (maybe null) + pub delayed_until: *const i64, /// Attached orders pub attached_orders: *const CAttachedOrderDetail, /// Number of attached orders @@ -982,6 +984,7 @@ pub(crate) struct COrderOwned { trigger_count: Option, monitor_price: Option, remark: CString, + delayed_until: Option, attached_orders: CVec, multi_leg: Option, } @@ -1018,6 +1021,7 @@ impl From for COrderOwned { trigger_count, monitor_price, remark, + delayed_until, attached_orders, multi_leg, } = order; @@ -1051,6 +1055,7 @@ impl From for COrderOwned { trigger_count, monitor_price: monitor_price.map(Into::into), remark: remark.into(), + delayed_until: delayed_until.map(OffsetDateTime::unix_timestamp), attached_orders: attached_orders.into(), multi_leg: multi_leg.map(Into::into), } @@ -1091,6 +1096,7 @@ impl ToFFI for COrderOwned { trigger_count, monitor_price, remark, + delayed_until, attached_orders, multi_leg, } = self; @@ -1169,6 +1175,10 @@ impl ToFFI for COrderOwned { .map(ToFFI::to_ffi_type) .unwrap_or(std::ptr::null()), remark: remark.to_ffi_type(), + delayed_until: delayed_until + .as_ref() + .map(|value| value as *const i64) + .unwrap_or(std::ptr::null()), attached_orders: attached_orders.to_ffi_type(), num_attached_orders: attached_orders.len(), has_multi_leg: multi_leg.is_some(), @@ -1248,6 +1258,12 @@ pub struct CReplaceOrderOptions { pub remark: *const c_char, /// Attached order parameters (can be null) pub attached_params: *const CReplaceAttachedParams, + /// New time-condition submit time, Unix timestamp in seconds (can be + /// null to keep the current time condition) + pub delayed_until: *const i64, + /// Clear the time condition, turning the order into a regular order + /// (takes precedence over `delayed_until`) + pub clear_delayed_until: bool, } /// Options for submit order request @@ -1293,6 +1309,10 @@ pub struct CSubmitOrderOptions { pub client_request_id: *const c_char, /// Attached order parameters (can be null) pub attached_params: *const CSubmitAttachedParams, + /// Time-condition submit time, Unix timestamp in seconds (can be null). + /// The order is held in `DelayedNotReported` status and only submitted at + /// this time. + pub delayed_until: *const i64, } /// Response for submit order request @@ -1361,6 +1381,9 @@ pub struct CSubmitMultiLegOrderOptions { /// If not specified, idempotency control is skipped. /// The server caches this ID for 10 minutes. pub client_request_id: *const c_char, + /// Time-condition submit time, Unix timestamp in seconds (can be null). + /// Only `LO` / `MO` support time conditions. + pub delayed_until: *const i64, } /// Account balance @@ -2366,6 +2389,8 @@ pub struct COrderDetail { pub monitor_price: *const CDecimal, /// Remark pub remark: *const c_char, + /// Time-condition submit time (maybe null) + pub delayed_until: *const i64, /// Commission-free Status pub free_status: CCommissionFreeStatus, /// Commission-free amount @@ -2434,6 +2459,7 @@ pub(crate) struct COrderDetailOwned { trigger_count: Option, monitor_price: Option, remark: CString, + delayed_until: Option, free_status: CCommissionFreeStatus, free_amount: Option, free_currency: Option, @@ -2481,6 +2507,7 @@ impl From for COrderDetailOwned { trigger_count, monitor_price, remark, + delayed_until, free_status, free_amount, free_currency, @@ -2525,6 +2552,7 @@ impl From for COrderDetailOwned { trigger_count, monitor_price: monitor_price.map(Into::into), remark: remark.into(), + delayed_until: delayed_until.map(OffsetDateTime::unix_timestamp), free_status: free_status.into(), free_amount: free_amount.map(Into::into), free_currency: free_currency.map(Into::into), @@ -2576,6 +2604,7 @@ impl ToFFI for COrderDetailOwned { trigger_count, monitor_price, remark, + delayed_until, free_status, free_amount, free_currency, @@ -2665,6 +2694,10 @@ impl ToFFI for COrderDetailOwned { .map(ToFFI::to_ffi_type) .unwrap_or(std::ptr::null()), remark: remark.to_ffi_type(), + delayed_until: delayed_until + .as_ref() + .map(|value| value as *const i64) + .unwrap_or(std::ptr::null()), free_status: *free_status, free_amount: free_amount .as_ref() diff --git a/cpp/include/types.hpp b/cpp/include/types.hpp index d448305e20..e77098cf9c 100644 --- a/cpp/include/types.hpp +++ b/cpp/include/types.hpp @@ -1833,6 +1833,9 @@ struct Order std::optional monitor_price; /// Remark std::string remark; + /// Time-condition submit time (Unix timestamp in seconds, not set when + /// the order is not a time-condition order) + std::optional delayed_until; /// Attached orders std::vector attached_orders; /// Multi-leg strategy information (only present for multi-leg option @@ -1959,6 +1962,12 @@ struct ReplaceOrderOptions std::optional remark; /// Attached order params std::optional attached_params; + /// New time-condition submit time (Unix timestamp in seconds); not set + /// keeps the current time condition + std::optional delayed_until; + /// Clear the time condition, turning the order into a regular order (takes + /// precedence over `delayed_until`) + bool clear_delayed_until = false; }; /// Options for submit order request @@ -2003,6 +2012,9 @@ struct SubmitOrderOptions std::optional client_request_id; /// Attached order params std::optional attached_params; + /// Time-condition submit time (Unix timestamp in seconds). The order is held + /// in `DelayedNotReported` status and only submitted at this time. + std::optional delayed_until; }; /// Response for submit order request @@ -2045,6 +2057,9 @@ struct SubmitMultiLegOrderOptions /// If not specified, idempotency control is skipped. /// The server caches this ID for 10 minutes. std::optional client_request_id; + /// Time-condition submit time (Unix timestamp in seconds). Only `LO` / `MO` + /// support time conditions. + std::optional delayed_until; }; /// Cash info @@ -2399,6 +2414,9 @@ struct OrderDetail std::optional monitor_price; /// Remark std::string remark; + /// Time-condition submit time (Unix timestamp in seconds, not set when + /// the order is not a time-condition order) + std::optional delayed_until; /// Commission-free Status CommissionFreeStatus free_status; /// Commission-free amount diff --git a/cpp/src/convert.hpp b/cpp/src/convert.hpp index 6bfceb9e7a..1ebffb224c 100644 --- a/cpp/src/convert.hpp +++ b/cpp/src/convert.hpp @@ -1738,6 +1738,8 @@ convert(const lb_order_t* order) order->monitor_price ? std::optional{ Decimal(order->monitor_price) } : std::nullopt, order->remark, + order->delayed_until ? std::optional{ *order->delayed_until } + : std::nullopt, attached_orders, order->has_multi_leg ? std::optional{ convert(&order->multi_leg) } : std::nullopt, @@ -2171,6 +2173,8 @@ convert(const lb_order_detail_t* order) order->monitor_price ? std::optional{ Decimal(order->monitor_price) } : std::nullopt, order->remark, + order->delayed_until ? std::optional{ *order->delayed_until } + : std::nullopt, convert(order->free_status), order->free_amount ? std::optional{ Decimal(order->free_amount) } : std::nullopt, diff --git a/cpp/src/trade_context.cpp b/cpp/src/trade_context.cpp index b568242eaf..18f0aec681 100644 --- a/cpp/src/trade_context.cpp +++ b/cpp/src/trade_context.cpp @@ -414,6 +414,8 @@ TradeContext::replace_order(const ReplaceOrderOptions& opts, nullptr, nullptr, nullptr, + nullptr, + false, }; longbridge::convert::CReplaceAttachedParamsStorage attached_params_storage; @@ -442,6 +444,9 @@ TradeContext::replace_order(const ReplaceOrderOptions& opts, attached_params_storage = convert_replace_attached(*opts.attached_params); opts2.attached_params = &attached_params_storage.params; } + opts2.delayed_until = + opts.delayed_until ? &opts.delayed_until.value() : nullptr; + opts2.clear_delayed_until = opts.clear_delayed_until; lb_trade_context_replace_order( ctx_, @@ -481,6 +486,7 @@ TradeContext::submit_order( opts.remark ? opts.remark->c_str() : nullptr, nullptr, nullptr, + nullptr, }; lb_date_t expire_date; lb_outside_rth_t outside_rth; @@ -525,6 +531,9 @@ TradeContext::submit_order( attached_params_storage = convert_submit_attached(*opts.attached_params); opts2.attached_params = &attached_params_storage.params; } + if (opts.delayed_until) { + opts2.delayed_until = &opts.delayed_until.value(); + } lb_trade_context_submit_order( ctx_, @@ -573,6 +582,7 @@ TradeContext::submit_multileg( nullptr, nullptr, nullptr, + nullptr, }; if (opts.submitted_price) { @@ -584,6 +594,9 @@ TradeContext::submit_multileg( if (opts.client_request_id) { opts2.client_request_id = opts.client_request_id->c_str(); } + if (opts.delayed_until) { + opts2.delayed_until = &opts.delayed_until.value(); + } lb_trade_context_submit_multileg( ctx_, diff --git a/java/javasrc/src/main/java/com/longbridge/trade/Order.java b/java/javasrc/src/main/java/com/longbridge/trade/Order.java index 7628e287d3..105abe2a53 100644 --- a/java/javasrc/src/main/java/com/longbridge/trade/Order.java +++ b/java/javasrc/src/main/java/com/longbridge/trade/Order.java @@ -37,6 +37,7 @@ public class Order { private Integer triggerCount; private BigDecimal monitorPrice; private String remark; + private OffsetDateTime delayedUntil; private AttachedOrderDetail[] attachedOrders = new AttachedOrderDetail[0]; private MultiLegInfo multiLeg; @@ -274,6 +275,16 @@ public String remark() { return remark; } + /** + * Returns the time-condition submit time. + * + * @return time-condition submit time, or {@code null} when the order is not + * a time-condition order + */ + public OffsetDateTime getDelayedUntil() { + return delayedUntil; + } + /** * Returns the limit depth level. * @@ -330,7 +341,7 @@ public String toString() { + updatedAt + ", triggerAt=" + triggerAt + ", trailingAmount=" + trailingAmount + ", trailingPercent=" + trailingPercent + ", limitOffset=" + limitOffset + ", triggerStatus=" + triggerStatus + ", currency=" + currency + ", outsideRth=" + outsideRth + ", limitDepthLevel=" + limitDepthLevel + ", triggerCount=" - + triggerCount + ", monitorPrice=" + monitorPrice + ", remark=" + remark + ", multiLeg=" + multiLeg + + triggerCount + ", monitorPrice=" + monitorPrice + ", remark=" + remark + ", delayedUntil=" + delayedUntil + ", multiLeg=" + multiLeg + "]"; } diff --git a/java/javasrc/src/main/java/com/longbridge/trade/OrderDetail.java b/java/javasrc/src/main/java/com/longbridge/trade/OrderDetail.java index 56609805d4..cb8d0fde10 100644 --- a/java/javasrc/src/main/java/com/longbridge/trade/OrderDetail.java +++ b/java/javasrc/src/main/java/com/longbridge/trade/OrderDetail.java @@ -38,6 +38,7 @@ public class OrderDetail { private Integer triggerCount; private BigDecimal monitorPrice; private String remark; + private OffsetDateTime delayedUntil; private CommissionFreeStatus freeStatus; private BigDecimal freeAmount; private String freeCurrency; @@ -313,6 +314,16 @@ public String getRemark() { return remark; } + /** + * Returns the time-condition submit time. + * + * @return time-condition submit time, or {@code null} when the order is not + * a time-condition order + */ + public OffsetDateTime getDelayedUntil() { + return delayedUntil; + } + /** * Returns the commission-free status. * @@ -441,7 +452,7 @@ public String toString() { + updatedAt + ", triggerAt=" + triggerAt + ", trailingAmount=" + trailingAmount + ", trailingPercent=" + trailingPercent + ", limitOffset=" + limitOffset + ", triggerStatus=" + triggerStatus + ", currency=" + currency + ", outsideRth=" + outsideRth + ", limitDepthLevel=" + limitDepthLevel + ", triggerCount=" - + triggerCount + ", monitorPrice=" + monitorPrice + ", remark=" + remark + ", freeStatus=" + freeStatus + + triggerCount + ", monitorPrice=" + monitorPrice + ", remark=" + remark + ", delayedUntil=" + delayedUntil + ", freeStatus=" + freeStatus + ", freeAmount=" + freeAmount + ", freeCurrency=" + freeCurrency + ", deductionsStatus=" + deductionsStatus + ", deductionsAmount=" + deductionsAmount + ", deductionsCurrency=" + deductionsCurrency + ", platformDeductedStatus=" + platformDeductedStatus diff --git a/java/javasrc/src/main/java/com/longbridge/trade/ReplaceOrderOptions.java b/java/javasrc/src/main/java/com/longbridge/trade/ReplaceOrderOptions.java index cbd5d7863f..21226224d7 100644 --- a/java/javasrc/src/main/java/com/longbridge/trade/ReplaceOrderOptions.java +++ b/java/javasrc/src/main/java/com/longbridge/trade/ReplaceOrderOptions.java @@ -1,6 +1,7 @@ package com.longbridge.trade; import java.math.BigDecimal; +import java.time.OffsetDateTime; /** * Options for replacing an order @@ -19,6 +20,8 @@ public class ReplaceOrderOptions { private BigDecimal monitorPrice; private String remark; private ReplaceAttachedParams attachedParams; + private OffsetDateTime delayedUntil; + private boolean clearDelayedUntil; /** * Constructs options for replacing an order. @@ -150,4 +153,27 @@ public ReplaceAttachedParams getAttachedParams() { return attachedParams; } + /** + * Sets a new time-condition submit time, which must be later than now and no + * more than 90 days ahead. Leave unset to keep the order's current time + * condition. + * + * @param delayedUntil new time-condition submit time + * @return this instance for chaining + */ + public ReplaceOrderOptions setDelayedUntil(OffsetDateTime delayedUntil) { + this.delayedUntil = delayedUntil; + return this; + } + + /** + * Clears the time condition, turning the order into a regular order. Takes + * precedence over {@link #setDelayedUntil(OffsetDateTime)}. + * + * @return this instance for chaining + */ + public ReplaceOrderOptions clearDelayedUntil() { + this.clearDelayedUntil = true; + return this; + } } diff --git a/java/javasrc/src/main/java/com/longbridge/trade/SubmitMultiLegOrderOptions.java b/java/javasrc/src/main/java/com/longbridge/trade/SubmitMultiLegOrderOptions.java index 2468ec9a8b..6999f9999e 100644 --- a/java/javasrc/src/main/java/com/longbridge/trade/SubmitMultiLegOrderOptions.java +++ b/java/javasrc/src/main/java/com/longbridge/trade/SubmitMultiLegOrderOptions.java @@ -1,6 +1,7 @@ package com.longbridge.trade; import java.math.BigDecimal; +import java.time.OffsetDateTime; /** * Options for submitting a multi-leg option combination order @@ -15,6 +16,7 @@ public class SubmitMultiLegOrderOptions { private BigDecimal submittedPrice; private String remark; private String clientRequestId; + private OffsetDateTime delayedUntil; /** * Constructs options for submitting a multi-leg option combination order. @@ -72,4 +74,18 @@ public SubmitMultiLegOrderOptions setClientRequestId(String clientRequestId) { this.clientRequestId = clientRequestId; return this; } + + /** + * Sets the time-condition submit time. The order is held in + * {@code DelayedNotReported} status and only submitted at this time, which + * must be later than now and no more than 90 days ahead. + * Only {@code LO} / {@code MO} support time conditions. + * + * @param delayedUntil time-condition submit time + * @return this instance for chaining + */ + public SubmitMultiLegOrderOptions setDelayedUntil(OffsetDateTime delayedUntil) { + this.delayedUntil = delayedUntil; + return this; + } } diff --git a/java/javasrc/src/main/java/com/longbridge/trade/SubmitOrderOptions.java b/java/javasrc/src/main/java/com/longbridge/trade/SubmitOrderOptions.java index 027b26015c..50a5d4a80f 100644 --- a/java/javasrc/src/main/java/com/longbridge/trade/SubmitOrderOptions.java +++ b/java/javasrc/src/main/java/com/longbridge/trade/SubmitOrderOptions.java @@ -1,6 +1,7 @@ package com.longbridge.trade; import java.math.BigDecimal; +import java.time.OffsetDateTime; import java.time.LocalDate; /** @@ -26,6 +27,7 @@ public class SubmitOrderOptions { private String remark; private String clientRequestId; private SubmitAttachedParams attachedParams; + private OffsetDateTime delayedUntil; /** * Constructs options for submitting an order. @@ -202,4 +204,17 @@ public SubmitOrderOptions setAttachedParams(SubmitAttachedParams attachedParams) public SubmitAttachedParams getAttachedParams() { return attachedParams; } + + /** + * Sets the time-condition submit time. The order is held in + * {@code DelayedNotReported} status and only submitted at this time, which + * must be later than now and no more than 90 days ahead. + * + * @param delayedUntil time-condition submit time + * @return this instance for chaining + */ + public SubmitOrderOptions setDelayedUntil(OffsetDateTime delayedUntil) { + this.delayedUntil = delayedUntil; + return this; + } } diff --git a/java/src/trade_context.rs b/java/src/trade_context.rs index 9e75f01b18..ef4be83867 100644 --- a/java/src/trade_context.rs +++ b/java/src/trade_context.rs @@ -545,6 +545,15 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_tradeContextReplaceO if let Some(remark) = remark { new_opts = new_opts.remark(remark); } + let clear_delayed_until: bool = get_field(env, &opts, "clearDelayedUntil")?; + if clear_delayed_until { + new_opts = new_opts.clear_delayed_until(); + } else { + let delayed_until: Option = get_field(env, &opts, "delayedUntil")?; + if let Some(delayed_until) = delayed_until { + new_opts = new_opts.delayed_until(delayed_until); + } + } let attached_params_obj = env.get_field( &opts, "attachedParams", @@ -635,6 +644,10 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_tradeContextSubmitOr if let Some(id) = client_request_id { new_opts = new_opts.client_request_id(id); } + let delayed_until: Option = get_field(env, &opts, "delayedUntil")?; + if let Some(delayed_until) = delayed_until { + new_opts = new_opts.delayed_until(delayed_until); + } let attached_params_obj = env.get_field( &opts, "attachedParams", @@ -708,6 +721,10 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_tradeContextSubmitMu if let Some(id) = client_request_id { new_opts = new_opts.client_request_id(id); } + let delayed_until: Option = get_field(env, &opts, "delayedUntil")?; + if let Some(delayed_until) = delayed_until { + new_opts = new_opts.delayed_until(delayed_until); + } async_util::execute(env, callback, async move { Ok(__owned_ctx.submit_multileg(new_opts).await?) diff --git a/java/src/types/classes.rs b/java/src/types/classes.rs index 0128dbd63b..b9dfe54c3b 100644 --- a/java/src/types/classes.rs +++ b/java/src/types/classes.rs @@ -740,6 +740,7 @@ impl_java_class!( trigger_count, monitor_price, remark, + delayed_until, #[java(objarray)] attached_orders, multi_leg @@ -1008,6 +1009,7 @@ impl_java_class!( trigger_count, monitor_price, remark, + delayed_until, free_status, free_amount, free_currency, diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts index 5c180126b2..f602078aa7 100644 --- a/nodejs/index.d.ts +++ b/nodejs/index.d.ts @@ -2723,6 +2723,11 @@ export declare class Order { get monitorPrice(): Decimal | null /** Remark */ get remark(): string + /** + * Time-condition submit time (not set when the order is not a + * time-condition order) + */ + get delayedUntil(): Date | null /** Attached orders */ get attachedOrders(): Array /** @@ -2832,6 +2837,11 @@ export declare class OrderDetail { get monitorPrice(): Decimal | null /** Remark */ get remark(): string + /** + * Time-condition submit time (not set when the order is not a + * time-condition order) + */ + get delayedUntil(): Date | null /** Commission-free Status */ get freeStatus(): CommissionFreeStatus /** Commission-free amount */ @@ -8155,6 +8165,16 @@ export interface ReplaceOrderOptions { remark?: string /** Attached order parameters */ attachedParams?: ReplaceAttachedParams + /** + * New time-condition submit time, which must be later than now and no + * more than 90 days ahead. Leave unset to keep the current time condition. + */ + delayedUntil?: Date + /** + * Clear the time condition, turning the order into a regular order (takes + * precedence over `delayed_until`) + */ + clearDelayedUntil?: boolean } /** A filter condition for screener_search Mode B. */ @@ -8675,6 +8695,12 @@ export interface SubmitMultiLegOrderOptions { * The server caches this ID for 10 minutes. */ clientRequestId?: string + /** + * Time-condition submit time. The order is held in `DelayedNotReported` + * status and only submitted at this time. Only `LO` / `MO` support time + * conditions. + */ + delayedUntil?: Date } /** Options for submit order request */ @@ -8722,6 +8748,12 @@ export interface SubmitOrderOptions { clientRequestId?: string /** Attached order parameters */ attachedParams?: SubmitAttachedParams + /** + * Time-condition submit time. The order is held in `DelayedNotReported` + * status and only submitted at this time, which must be later than now + * and no more than 90 days ahead. + */ + delayedUntil?: Date } /** Quote type of subscription */ diff --git a/nodejs/src/trade/requests/replace_order.rs b/nodejs/src/trade/requests/replace_order.rs index 4163c06ba2..2cf0ea8e09 100644 --- a/nodejs/src/trade/requests/replace_order.rs +++ b/nodejs/src/trade/requests/replace_order.rs @@ -1,8 +1,10 @@ +use chrono::{DateTime, Utc}; use napi::bindgen_prelude::ClassInstance; use crate::{ decimal::Decimal, trade::types::{AttachedOrderType, OrderType, OutsideRTH, TimeInForceType}, + utils::from_datetime, }; /// Parameters for replacing an attached order @@ -116,6 +118,12 @@ pub struct ReplaceOrderOptions<'env> { pub remark: Option, /// Attached order parameters pub attached_params: Option>, + /// New time-condition submit time, which must be later than now and no + /// more than 90 days ahead. Leave unset to keep the current time condition. + pub delayed_until: Option>, + /// Clear the time condition, turning the order into a regular order (takes + /// precedence over `delayed_until`) + pub clear_delayed_until: Option, } impl<'env> From> for longbridge::trade::ReplaceOrderOptions { @@ -152,6 +160,11 @@ impl<'env> From> for longbridge::trade::ReplaceOrderOp if let Some(p) = opts.attached_params { opts2 = opts2.attached_params(p.into()); } + if opts.clear_delayed_until.unwrap_or_default() { + opts2 = opts2.clear_delayed_until(); + } else if let Some(delayed_until) = opts.delayed_until { + opts2 = opts2.delayed_until(from_datetime(delayed_until)); + } opts2 } } diff --git a/nodejs/src/trade/requests/submit_multileg.rs b/nodejs/src/trade/requests/submit_multileg.rs index 535c5889af..8baf5cdae8 100644 --- a/nodejs/src/trade/requests/submit_multileg.rs +++ b/nodejs/src/trade/requests/submit_multileg.rs @@ -1,8 +1,10 @@ +use chrono::{DateTime, Utc}; use napi::bindgen_prelude::ClassInstance; use crate::{ decimal::Decimal, trade::types::{MultiLegStrategy, OrderSide, OrderType}, + utils::from_datetime, }; /// A leg of a multi-leg combination order to submit @@ -38,6 +40,10 @@ pub struct SubmitMultiLegOrderOptions<'env> { /// If not specified, idempotency control is skipped. /// The server caches this ID for 10 minutes. pub client_request_id: Option, + /// Time-condition submit time. The order is held in `DelayedNotReported` + /// status and only submitted at this time. Only `LO` / `MO` support time + /// conditions. + pub delayed_until: Option>, } impl<'env> From> @@ -64,6 +70,9 @@ impl<'env> From> if let Some(id) = opts.client_request_id { opts2 = opts2.client_request_id(id); } + if let Some(delayed_until) = opts.delayed_until { + opts2 = opts2.delayed_until(from_datetime(delayed_until)); + } opts2 } } diff --git a/nodejs/src/trade/requests/submit_order.rs b/nodejs/src/trade/requests/submit_order.rs index bddab1092b..884db4c923 100644 --- a/nodejs/src/trade/requests/submit_order.rs +++ b/nodejs/src/trade/requests/submit_order.rs @@ -1,9 +1,11 @@ +use chrono::{DateTime, Utc}; use napi::bindgen_prelude::ClassInstance; use crate::{ decimal::Decimal, time::NaiveDate, trade::types::{AttachedOrderType, OrderSide, OrderType, OutsideRTH, TimeInForceType}, + utils::from_datetime, }; /// Parameters for submitting an attached order @@ -102,6 +104,10 @@ pub struct SubmitOrderOptions<'env> { pub client_request_id: Option, /// Attached order parameters pub attached_params: Option>, + /// Time-condition submit time. The order is held in `DelayedNotReported` + /// status and only submitted at this time, which must be later than now + /// and no more than 90 days ahead. + pub delayed_until: Option>, } impl<'env> From> for longbridge::trade::SubmitOrderOptions { @@ -153,6 +159,9 @@ impl<'env> From> for longbridge::trade::SubmitOrderOpti if let Some(p) = opts.attached_params { opts2 = opts2.attached_params(p.into()); } + if let Some(delayed_until) = opts.delayed_until { + opts2 = opts2.delayed_until(from_datetime(delayed_until)); + } opts2 } } diff --git a/nodejs/src/trade/types.rs b/nodejs/src/trade/types.rs index 3a84f9e0a3..37d926a460 100644 --- a/nodejs/src/trade/types.rs +++ b/nodejs/src/trade/types.rs @@ -445,6 +445,10 @@ pub struct Order { monitor_price: Option, /// Remark remark: String, + /// Time-condition submit time (not set when the order is not a + /// time-condition order) + #[js(opt, datetime)] + delayed_until: Option>, /// Attached orders #[js(array)] attached_orders: Vec, @@ -643,6 +647,10 @@ pub struct OrderDetail { monitor_price: Option, /// Remark remark: String, + /// Time-condition submit time (not set when the order is not a + /// time-condition order) + #[js(opt, datetime)] + delayed_until: Option>, /// Commission-free Status free_status: CommissionFreeStatus, /// Commission-free amount diff --git a/python/pysrc/longbridge/openapi.pyi b/python/pysrc/longbridge/openapi.pyi index ef9b539bcf..9026c63d4d 100644 --- a/python/pysrc/longbridge/openapi.pyi +++ b/python/pysrc/longbridge/openapi.pyi @@ -6659,6 +6659,11 @@ class Order: Remark """ + delayed_until: Optional[datetime] + """ + Time-condition submit time (``None`` when the order is not a time-condition order) + """ + attached_orders: List[AttachedOrderDetail] """ Attached orders @@ -6995,6 +7000,11 @@ class OrderDetail: Remark """ + delayed_until: Optional[datetime] + """ + Time-condition submit time (``None`` when the order is not a time-condition order) + """ + free_status: Type[CommissionFreeStatus] """ Commission-free Status @@ -7680,6 +7690,8 @@ class TradeContext: monitor_price: Optional[Decimal] = None, remark: Optional[str] = None, attached_params: Optional[ReplaceAttachedParams] = None, + delayed_until: Optional[datetime] = None, + clear_delayed_until: bool = False, ) -> None: """ Replace order @@ -7695,6 +7707,8 @@ class TradeContext: trigger_count: Trigger count monitor_price: Monitor price remark: Remark (Maximum 64 characters) + delayed_until: New time-condition submit time (later than now, at most 90 days ahead); ``None`` keeps the current time condition + clear_delayed_until: Clear the time condition, turning the order into a regular order (takes precedence over ``delayed_until``) Examples: :: @@ -7735,6 +7749,7 @@ class TradeContext: remark: Optional[str] = None, client_request_id: Optional[str] = None, attached_params: Optional[SubmitAttachedParams] = None, + delayed_until: Optional[datetime] = None, ) -> SubmitOrderResponse: """ Submit order @@ -7758,6 +7773,7 @@ class TradeContext: remark: Remark (Maximum 64 characters) client_request_id: Idempotent request ID. If not specified, idempotency control is skipped. The server caches this ID for 10 minutes to prevent duplicate orders. attached_params: Attached order parameters + delayed_until: Time-condition submit time. The order is held in ``DelayedNotReported`` status and only submitted at this time (later than now, at most 90 days ahead) Returns: Response @@ -7796,6 +7812,7 @@ class TradeContext: submitted_price: Optional[Decimal] = None, remark: Optional[str] = None, client_request_id: Optional[str] = None, + delayed_until: Optional[datetime] = None, ) -> SubmitOrderResponse: """ Submit a multi-leg option combination order (such as vertical spreads, @@ -7813,6 +7830,7 @@ class TradeContext: submitted_price: Submitted price (required for limit order types such as `LO`) remark: Remark (Maximum 255 characters) client_request_id: Idempotent request ID. If not specified, idempotency control is skipped. The server caches this ID for 10 minutes to prevent duplicate orders. + delayed_until: Time-condition submit time (only ``LO`` / ``MO`` support time conditions) Returns: Response @@ -11573,6 +11591,8 @@ class AsyncTradeContext: monitor_price: Optional[Decimal] = None, remark: Optional[str] = None, attached_params: Optional[ReplaceAttachedParams] = None, + delayed_until: Optional[datetime] = None, + clear_delayed_until: bool = False, ) -> Awaitable[None]: """ Replace order. Returns an awaitable. Same parameters as sync TradeContext.replace_order. @@ -11590,6 +11610,8 @@ class AsyncTradeContext: monitor_price: Monitor price. remark: Remark (max 64 characters). attached_params: Attached order parameters. + delayed_until: New time-condition submit time (later than now, at most 90 days ahead); ``None`` keeps the current time condition. + clear_delayed_until: Clear the time condition, turning the order into a regular order (takes precedence over ``delayed_until``). Examples: :: @@ -11634,6 +11656,7 @@ class AsyncTradeContext: remark: Optional[str] = None, client_request_id: Optional[str] = None, attached_params: Optional[SubmitAttachedParams] = None, + delayed_until: Optional[datetime] = None, ) -> Awaitable[SubmitOrderResponse]: """ Submit order. Returns an awaitable that resolves to SubmitOrderResponse. Same parameters as sync TradeContext.submit_order. @@ -11657,6 +11680,7 @@ class AsyncTradeContext: remark: Remark (max 64 characters). client_request_id: Idempotent request ID. If not specified, idempotency control is skipped. The server caches this ID for 10 minutes to prevent duplicate orders. attached_params: Attached order parameters. + delayed_until: Time-condition submit time. The order is held in ``DelayedNotReported`` status and only submitted at this time (later than now, at most 90 days ahead). Examples: :: @@ -11702,6 +11726,7 @@ class AsyncTradeContext: submitted_price: Optional[Decimal] = None, remark: Optional[str] = None, client_request_id: Optional[str] = None, + delayed_until: Optional[datetime] = None, ) -> Awaitable[SubmitOrderResponse]: """ Submit a multi-leg option combination order (such as vertical spreads, @@ -11720,6 +11745,7 @@ class AsyncTradeContext: submitted_price: Submitted price (required for limit order types such as `LO`). remark: Remark (max 255 characters). client_request_id: Idempotent request ID. If not specified, idempotency control is skipped. The server caches this ID for 10 minutes to prevent duplicate orders. + delayed_until: Time-condition submit time (only ``LO`` / ``MO`` support time conditions). Examples: :: diff --git a/python/src/trade/context.rs b/python/src/trade/context.rs index 5629c42d28..c3d396f30c 100644 --- a/python/src/trade/context.rs +++ b/python/src/trade/context.rs @@ -257,7 +257,7 @@ impl TradeContext { } /// Replace order - #[pyo3(signature = (order_id, quantity, price = None, trigger_price = None, limit_offset = None, trailing_amount = None, trailing_percent = None, limit_depth_level = None, trigger_count = None, monitor_price = None, remark = None, attached_params = None))] + #[pyo3(signature = (order_id, quantity, price = None, trigger_price = None, limit_offset = None, trailing_amount = None, trailing_percent = None, limit_depth_level = None, trigger_count = None, monitor_price = None, remark = None, attached_params = None, delayed_until = None, clear_delayed_until = false))] #[allow(clippy::too_many_arguments)] fn replace_order( &self, @@ -273,6 +273,8 @@ impl TradeContext { monitor_price: Option, remark: Option, attached_params: Option, + delayed_until: Option, + clear_delayed_until: bool, ) -> PyResult<()> { let mut opts = ReplaceOrderOptions::new(order_id, quantity.into()); @@ -306,13 +308,18 @@ impl TradeContext { if let Some(p) = attached_params { opts = opts.attached_params(p.0); } + if clear_delayed_until { + opts = opts.clear_delayed_until(); + } else if let Some(delayed_until) = delayed_until { + opts = opts.delayed_until(delayed_until.0); + } self.ctx.replace_order(opts).map_err(ErrorNewType)?; Ok(()) } /// Submit order - #[pyo3(signature = (symbol, order_type, side, submitted_quantity, time_in_force, submitted_price = None, trigger_price = None, limit_offset = None, trailing_amount = None, trailing_percent = None, expire_date = None, outside_rth = None, limit_depth_level = None, trigger_count = None, monitor_price = None, remark = None, client_request_id = None, attached_params = None))] + #[pyo3(signature = (symbol, order_type, side, submitted_quantity, time_in_force, submitted_price = None, trigger_price = None, limit_offset = None, trailing_amount = None, trailing_percent = None, expire_date = None, outside_rth = None, limit_depth_level = None, trigger_count = None, monitor_price = None, remark = None, client_request_id = None, attached_params = None, delayed_until = None))] #[allow(clippy::too_many_arguments)] fn submit_order( &self, @@ -334,6 +341,7 @@ impl TradeContext { remark: Option, client_request_id: Option, attached_params: Option, + delayed_until: Option, ) -> PyResult { let mut opts = SubmitOrderOptions::new( symbol, @@ -382,6 +390,9 @@ impl TradeContext { if let Some(p) = attached_params { opts = opts.attached_params(p.0); } + if let Some(delayed_until) = delayed_until { + opts = opts.delayed_until(delayed_until.0); + } self.ctx .submit_order(opts) @@ -398,7 +409,7 @@ impl TradeContext { /// implied by `strategy` together with `side`, not by the sign of the /// ratio, and a negative or zero ratio is rejected by the server with /// `602001`. - #[pyo3(signature = (side, order_type, submitted_quantity, strategy, legs, submitted_price = None, remark = None, client_request_id = None))] + #[pyo3(signature = (side, order_type, submitted_quantity, strategy, legs, submitted_price = None, remark = None, client_request_id = None, delayed_until = None))] #[allow(clippy::too_many_arguments)] fn submit_multileg( &self, @@ -410,6 +421,7 @@ impl TradeContext { submitted_price: Option, remark: Option, client_request_id: Option, + delayed_until: Option, ) -> PyResult { let legs = legs .into_iter() @@ -433,6 +445,9 @@ impl TradeContext { if let Some(id) = client_request_id { opts = opts.client_request_id(id); } + if let Some(delayed_until) = delayed_until { + opts = opts.delayed_until(delayed_until.0); + } self.ctx .submit_multileg(opts) .map_err(ErrorNewType)? diff --git a/python/src/trade/context_async.rs b/python/src/trade/context_async.rs index 962aa8794f..eb5be3644b 100644 --- a/python/src/trade/context_async.rs +++ b/python/src/trade/context_async.rs @@ -293,7 +293,7 @@ impl AsyncTradeContext { } /// Replace order. Returns awaitable. - #[pyo3(signature = (order_id, quantity, price = None, trigger_price = None, limit_offset = None, trailing_amount = None, trailing_percent = None, limit_depth_level = None, trigger_count = None, monitor_price = None, remark = None, attached_params = None))] + #[pyo3(signature = (order_id, quantity, price = None, trigger_price = None, limit_offset = None, trailing_amount = None, trailing_percent = None, limit_depth_level = None, trigger_count = None, monitor_price = None, remark = None, attached_params = None, delayed_until = None, clear_delayed_until = false))] #[allow(clippy::too_many_arguments)] fn replace_order( &self, @@ -310,6 +310,8 @@ impl AsyncTradeContext { monitor_price: Option, remark: Option, attached_params: Option, + delayed_until: Option, + clear_delayed_until: bool, ) -> PyResult> { let ctx = self.ctx.clone(); let mut opts = ReplaceOrderOptions::new(order_id, quantity.into()); @@ -343,6 +345,11 @@ impl AsyncTradeContext { if let Some(p) = attached_params { opts = opts.attached_params(p.0); } + if clear_delayed_until { + opts = opts.clear_delayed_until(); + } else if let Some(delayed_until) = delayed_until { + opts = opts.delayed_until(delayed_until.0); + } pyo3_async_runtimes::tokio::future_into_py(py, async move { ctx.replace_order(opts).await.map_err(ErrorNewType)?; Ok(()) @@ -351,7 +358,7 @@ impl AsyncTradeContext { } /// Submit order. Returns awaitable. - #[pyo3(signature = (symbol, order_type, side, submitted_quantity, time_in_force, submitted_price = None, trigger_price = None, limit_offset = None, trailing_amount = None, trailing_percent = None, expire_date = None, outside_rth = None, limit_depth_level = None, trigger_count = None, monitor_price = None, remark = None, client_request_id = None, attached_params = None))] + #[pyo3(signature = (symbol, order_type, side, submitted_quantity, time_in_force, submitted_price = None, trigger_price = None, limit_offset = None, trailing_amount = None, trailing_percent = None, expire_date = None, outside_rth = None, limit_depth_level = None, trigger_count = None, monitor_price = None, remark = None, client_request_id = None, attached_params = None, delayed_until = None))] #[allow(clippy::too_many_arguments)] fn submit_order( &self, @@ -374,6 +381,7 @@ impl AsyncTradeContext { remark: Option, client_request_id: Option, attached_params: Option, + delayed_until: Option, ) -> PyResult> { let ctx = self.ctx.clone(); let mut opts = SubmitOrderOptions::new( @@ -422,6 +430,9 @@ impl AsyncTradeContext { if let Some(p) = attached_params { opts = opts.attached_params(p.0); } + if let Some(delayed_until) = delayed_until { + opts = opts.delayed_until(delayed_until.0); + } pyo3_async_runtimes::tokio::future_into_py(py, async move { let r: SubmitOrderResponse = ctx .submit_order(opts) @@ -440,7 +451,7 @@ impl AsyncTradeContext { /// implied by `strategy` together with `side`, not by the sign of the /// ratio, and a negative or zero ratio is rejected by the server with /// `602001`. - #[pyo3(signature = (side, order_type, submitted_quantity, strategy, legs, submitted_price = None, remark = None, client_request_id = None))] + #[pyo3(signature = (side, order_type, submitted_quantity, strategy, legs, submitted_price = None, remark = None, client_request_id = None, delayed_until = None))] #[allow(clippy::too_many_arguments)] fn submit_multileg( &self, @@ -453,6 +464,7 @@ impl AsyncTradeContext { submitted_price: Option, remark: Option, client_request_id: Option, + delayed_until: Option, ) -> PyResult> { let ctx = self.ctx.clone(); let legs = legs @@ -477,6 +489,9 @@ impl AsyncTradeContext { if let Some(id) = client_request_id { opts = opts.client_request_id(id); } + if let Some(delayed_until) = delayed_until { + opts = opts.delayed_until(delayed_until.0); + } pyo3_async_runtimes::tokio::future_into_py(py, async move { let r: SubmitOrderResponse = ctx .submit_multileg(opts) diff --git a/python/src/trade/types.rs b/python/src/trade/types.rs index 59fdba8607..2b80162d07 100644 --- a/python/src/trade/types.rs +++ b/python/src/trade/types.rs @@ -570,6 +570,10 @@ pub(crate) struct Order { monitor_price: Option, /// Remark remark: String, + /// Time-condition submit time (not set when the order is not a + /// time-condition order) + #[py(opt)] + delayed_until: Option, /// Attached orders #[py(array)] attached_orders: Vec, @@ -768,6 +772,10 @@ pub(crate) struct OrderDetail { monitor_price: Option, /// Remark remark: String, + /// Time-condition submit time (not set when the order is not a + /// time-condition order) + #[py(opt)] + delayed_until: Option, /// Commission-free Status free_status: CommissionFreeStatus, /// Commission-free amount diff --git a/rust/src/trade/requests/replace_order.rs b/rust/src/trade/requests/replace_order.rs index f6a72eb051..6aed074e14 100644 --- a/rust/src/trade/requests/replace_order.rs +++ b/rust/src/trade/requests/replace_order.rs @@ -1,5 +1,6 @@ use rust_decimal::Decimal; use serde::Serialize; +use time::OffsetDateTime; use crate::trade::{AttachedOrderType, OrderType, OutsideRTH, TimeInForceType}; @@ -27,6 +28,8 @@ pub struct ReplaceOrderOptions { #[serde(skip_serializing_if = "Option::is_none")] remark: Option, #[serde(skip_serializing_if = "Option::is_none")] + delayed_until: Option, + #[serde(skip_serializing_if = "Option::is_none")] attached_params: Option, } @@ -46,6 +49,7 @@ impl ReplaceOrderOptions { trigger_count: None, monitor_price: None, remark: None, + delayed_until: None, attached_params: None, } } @@ -140,6 +144,29 @@ impl ReplaceOrderOptions { } } + /// Set a new time-condition submit time. + /// + /// Must be later than now and no more than 90 days ahead. Leave unset to + /// keep the order's current time condition. + #[inline] + #[must_use] + pub fn delayed_until(self, delayed_until: OffsetDateTime) -> Self { + Self { + delayed_until: Some(delayed_until.unix_timestamp()), + ..self + } + } + + /// Clear the time condition, turning the order into a regular order. + #[inline] + #[must_use] + pub fn clear_delayed_until(self) -> Self { + Self { + delayed_until: Some(1), + ..self + } + } + /// Set attached order parameters pub fn attached_params(self, params: ReplaceAttachedParams) -> Self { Self { @@ -303,3 +330,33 @@ impl ReplaceAttachedParams { } } } + +#[cfg(test)] +mod tests { + use time::macros::datetime; + + use super::*; + + #[test] + fn delayed_until() { + let opts = ReplaceOrderOptions::new("1", Decimal::from(100)); + assert!( + serde_json::to_value(&opts) + .unwrap() + .get("delayed_until") + .is_none() + ); + + let opts = opts.delayed_until(datetime!(2026-01-01 00:00 UTC)); + assert_eq!( + serde_json::to_value(&opts).unwrap()["delayed_until"], + serde_json::json!(1767225600) + ); + + let opts = opts.clear_delayed_until(); + assert_eq!( + serde_json::to_value(&opts).unwrap()["delayed_until"], + serde_json::json!(1) + ); + } +} diff --git a/rust/src/trade/requests/submit_multileg.rs b/rust/src/trade/requests/submit_multileg.rs index 8009825c79..05db7da18e 100644 --- a/rust/src/trade/requests/submit_multileg.rs +++ b/rust/src/trade/requests/submit_multileg.rs @@ -1,5 +1,6 @@ use rust_decimal::Decimal; use serde::Serialize; +use time::OffsetDateTime; use crate::trade::{MultiLegStrategy, OrderSide, OrderType}; @@ -40,6 +41,8 @@ pub struct SubmitMultiLegOrderOptions { remark: Option, #[serde(skip_serializing_if = "Option::is_none")] client_request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + delayed_until: Option, } impl SubmitMultiLegOrderOptions { @@ -61,6 +64,7 @@ impl SubmitMultiLegOrderOptions { submitted_price: None, remark: None, client_request_id: None, + delayed_until: None, } } @@ -99,4 +103,18 @@ impl SubmitMultiLegOrderOptions { ..self } } + + /// Set the time-condition submit time. + /// + /// The order is held in `DelayedNotReported` status and only submitted at + /// this time, which must be later than now and no more than 90 days ahead. + /// Only `LO` / `MO` support time conditions. + #[inline] + #[must_use] + pub fn delayed_until(self, delayed_until: OffsetDateTime) -> Self { + Self { + delayed_until: Some(delayed_until.unix_timestamp()), + ..self + } + } } diff --git a/rust/src/trade/requests/submit_order.rs b/rust/src/trade/requests/submit_order.rs index cc82bd09d9..8f55e9842b 100644 --- a/rust/src/trade/requests/submit_order.rs +++ b/rust/src/trade/requests/submit_order.rs @@ -1,6 +1,6 @@ use rust_decimal::Decimal; use serde::Serialize; -use time::Date; +use time::{Date, OffsetDateTime}; use crate::{ serde_utils, @@ -43,6 +43,8 @@ pub struct SubmitOrderOptions { #[serde(skip_serializing_if = "Option::is_none")] client_request_id: Option, #[serde(skip_serializing_if = "Option::is_none")] + delayed_until: Option, + #[serde(skip_serializing_if = "Option::is_none")] attached_params: Option, } @@ -74,6 +76,7 @@ impl SubmitOrderOptions { monitor_price: None, remark: None, client_request_id: None, + delayed_until: None, attached_params: None, } } @@ -194,6 +197,19 @@ impl SubmitOrderOptions { } } + /// Set the time-condition submit time. + /// + /// The order is held in `DelayedNotReported` status and only submitted at + /// this time, which must be later than now and no more than 90 days ahead. + #[inline] + #[must_use] + pub fn delayed_until(self, delayed_until: OffsetDateTime) -> Self { + Self { + delayed_until: Some(delayed_until.unix_timestamp()), + ..self + } + } + /// Set attached order parameters pub fn attached_params(self, params: SubmitAttachedParams) -> Self { Self { diff --git a/rust/src/trade/types.rs b/rust/src/trade/types.rs index 8ca39fd522..c795b3807d 100644 --- a/rust/src/trade/types.rs +++ b/rust/src/trade/types.rs @@ -502,6 +502,14 @@ pub struct Order { pub monitor_price: Option, /// Remark pub remark: String, + /// Time-condition submit time (`None` when the order is not a + /// time-condition order) + #[serde( + default, + deserialize_with = "serde_utils::timestamp_opt::deserialize", + serialize_with = "serde_utils::rfc3339_opt::serialize" + )] + pub delayed_until: Option, /// Attached orders #[serde(default, deserialize_with = "crate::serde_utils::null_as_default")] pub attached_orders: Vec, @@ -703,6 +711,14 @@ pub struct OrderDetail { pub monitor_price: Option, /// Remark pub remark: String, + /// Time-condition submit time (`None` when the order is not a + /// time-condition order) + #[serde( + default, + deserialize_with = "serde_utils::timestamp_opt::deserialize", + serialize_with = "serde_utils::rfc3339_opt::serialize" + )] + pub delayed_until: Option, /// Commission-free Status pub free_status: CommissionFreeStatus, /// Commission-free amount @@ -1966,7 +1982,8 @@ mod tests { "limit_depth_level": 0, "trigger_count": 0, "monitor_price": "", - "remark": "abc" + "remark": "abc", + "delayed_until": "1767225600" } ] } @@ -2007,6 +2024,7 @@ mod tests { assert!(order.trigger_status.is_none()); assert_eq!(order.updated_at, Some(datetime!(2022-05-04 14:14:58 +8))); assert_eq!(order.remark, "abc"); + assert_eq!(order.delayed_until, Some(datetime!(2026-01-01 00:00 UTC))); } #[test] @@ -2084,6 +2102,7 @@ mod tests { assert!(order.trigger_status.is_none()); assert_eq!(order.updated_at, Some(datetime!(2022-05-04 14:14:58 +8))); assert_eq!(order.remark, "abc"); + assert!(order.delayed_until.is_none()); } #[test] From daba9af46b721bccd7d33f29ed93824dd945270e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Tue, 13 Oct 2026 22:30:06 +0800 Subject: [PATCH 4/9] fix: address round-1 review of time-condition orders - Rust: send a delayed_until that maps to 0/1 (the wire sentinels) as 2, so the server rejects it as a past time instead of silently placing a regular order or clearing the condition. Document last-call-wins for delayed_until / clear_delayed_until on replace. - C: report an out-of-range delayed_until through the callback instead of panicking across the FFI boundary; append delayed_until at the end of lb_order_t / lb_order_detail_t so existing offsets are unchanged. - Python: honour tzinfo on aware datetimes (previously their wall-clock time was read as UTC); document the naive/aware rules in the stub. - Node: doc comment refers to delayedUntil. - Tests for submit / multi-leg serialization, the sentinel clamp, OrderDetail delayed_until and the DelayedNotReported status. --- CHANGELOG.md | 6 ++- c/csrc/include/longbridge.h | 16 +++---- c/src/trade_context/context.rs | 49 +++++++++++++--------- c/src/trade_context/types.rs | 8 ++-- nodejs/src/trade/requests/replace_order.rs | 2 +- python/pysrc/longbridge/openapi.pyi | 12 +++--- python/src/time.rs | 9 ++++ rust/src/trade/requests/mod.rs | 10 +++++ rust/src/trade/requests/replace_order.rs | 15 ++++++- rust/src/trade/requests/submit_multileg.rs | 32 +++++++++++++- rust/src/trade/requests/submit_order.rs | 42 ++++++++++++++++++- rust/src/trade/types.rs | 14 ++++++- 12 files changed, 171 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c34ba4751..42a43e6a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Known differences:** HTTP errors are `Error::HttpClient(OpenApi { .. })` rather than `Error::WsClient(ResponseError { .. })` (`Error::openapi_error_code()` / `into_simple_error()` cover both, but a non-JSON gateway error surfaces as `HttpClientError::UnexpectedHttpResponse` with no code); the REST gateway currently reports business errors (e.g. `301600`, `301607`) as `500`; the per-command rate limits the WebSocket applies from the quote profile are not applied on the HTTP path (the HTTP client retries `429` with back-off instead), and a candlestick top-up turns one call into up to 9 requests against `/quote/history-candlesticks`; during the US overnight session (20:00–04:00 New York) the REST gateway has already rolled over to the next trading day, so `intraday` for a US equity can be empty over HTTP (its lines are all overnight and dropped) and snapshot values of `quote`, `depth`, `calc_indexes` and `capital_flow` may reflect overnight trading that the WebSocket (without `enable_overnight`) does not report; `trades` may include overnight ticks during and shortly after the overnight session. The HTTP path has no client-side rate limiting. - **C / C++ / Java / Node.js / Python:** the `QuoteTransport` enum and the corresponding config option are exposed in every binding layer. - **All SDKs:** `OrderStatus` gains a `DelayedNotReported` variant (wire value `DelayedNotReported`) — "monitoring", the state a time-conditional order sits in before it is reported to the exchange. Previously this status deserialized to `OrderStatus::Unknown`, so time-conditional orders being monitored were indistinguishable from a genuinely unrecognized status in `today_orders` / `history_orders` / `order_detail` and the order-changed push. The variant is appended **after** `PartialWithdrawal` rather than inserted next to the other `*NotReported` values on purpose: the C/C++/Node.js enums use implicit discriminants, and inserting mid-list would renumber every following value (an ABI break for the C/C++ bindings, and a silent mismatch for TypeScript code that inlined the old `const enum` values) -- **All SDKs:** time-condition orders (longbridge/developers#1289). `SubmitOrderOptions` and `SubmitMultiLegOrderOptions` gain `delayed_until`: the order is held in `DelayedNotReported` status and sent automatically at that time (later than now, at most 90 days ahead; multi-leg orders support only `LO` / `MO`). `ReplaceOrderOptions` gains `delayed_until` to move the time and `clear_delayed_until` to turn the order back into a regular one; leaving both unset keeps the current time condition. `Order` and `OrderDetail` gain `delayed_until` (unset when the order is not a time-condition order), read from `today_orders` / `history_orders` / `order_detail`. Per layer: Rust `delayed_until(OffsetDateTime)` / `clear_delayed_until()` builder methods and `Option` fields; C `const int64_t *delayed_until` (Unix seconds) on `lb_submit_order_options_t` / `CSubmitMultiLegOrderOptions` / `lb_replace_order_options_t` (plus `bool clear_delayed_until`; all appended at the end of the structs) and on `lb_order_t` / `lb_order_detail_t`; C++ `std::optional delayed_until` (+ `bool clear_delayed_until` on `ReplaceOrderOptions`); Java `setDelayedUntil(OffsetDateTime)` / `ReplaceOrderOptions.clearDelayedUntil()` and `getDelayedUntil()`; Node.js `delayedUntil?: Date` / `clearDelayedUntil?: boolean` and `get delayedUntil(): Date | null`; Python `delayed_until: Optional[datetime]` / `clear_delayed_until: bool = False` keyword arguments and `Order.delayed_until` / `OrderDetail.delayed_until` +- **All SDKs:** time-condition orders (longbridge/developers#1289). `SubmitOrderOptions` and `SubmitMultiLegOrderOptions` gain `delayed_until`: the order is held in `DelayedNotReported` status and sent automatically at that time (later than now, at most 90 days ahead; multi-leg orders support only `LO` / `MO`). `ReplaceOrderOptions` gains `delayed_until` to move the time and `clear_delayed_until` to turn the order back into a regular one; leaving both unset keeps the current time condition. A time at or before `1970-01-01T00:00:01Z` would collide with the wire sentinels (`0` = no time condition, `1` = clear) and is sent as `2` instead, so the server rejects it as a past time rather than silently placing a regular order or clearing the condition; an out-of-range C timestamp is reported through the callback instead of aborting. `Order` and `OrderDetail` gain `delayed_until` (unset when the order is not a time-condition order), read from `today_orders` / `history_orders` / `order_detail`. Per layer: Rust `delayed_until(OffsetDateTime)` / `clear_delayed_until()` builder methods and `Option` fields; C `const int64_t *delayed_until` (Unix seconds) on `lb_submit_order_options_t` / `CSubmitMultiLegOrderOptions` / `lb_replace_order_options_t` (plus `bool clear_delayed_until`) and on `lb_order_t` / `lb_order_detail_t`, every new field appended at the end of its struct so existing field offsets are unchanged; C++ `std::optional delayed_until` (+ `bool clear_delayed_until` on `ReplaceOrderOptions`); Java `setDelayedUntil(OffsetDateTime)` / `ReplaceOrderOptions.clearDelayedUntil()` and `getDelayedUntil()`; Node.js `delayedUntil?: Date` / `clearDelayedUntil?: boolean` and `get delayedUntil(): Date | null`; Python `delayed_until: Optional[datetime]` / `clear_delayed_until: bool = False` keyword arguments and `Order.delayed_until` / `OrderDetail.delayed_until` + +### Fixed + +- **Python:** a timezone-aware `datetime` passed to any SDK method (e.g. `start_at`, `end_at`, and the new `delayed_until`) was read as if its wall-clock time were UTC, ignoring its `tzinfo` — so `datetime(2026, 10, 14, 9, 30, tzinfo=ZoneInfo("Asia/Hong_Kong"))` meant 09:30 UTC (17:30 in Hong Kong). Aware datetimes now convert by their actual instant; naive datetimes are still read as UTC ## [5.2.0] - 2026-09-30 diff --git a/c/csrc/include/longbridge.h b/c/csrc/include/longbridge.h index db3192e0bc..fa0736cd01 100644 --- a/c/csrc/include/longbridge.h +++ b/c/csrc/include/longbridge.h @@ -5951,10 +5951,6 @@ typedef struct lb_order_t { * Remark */ const char *remark; - /** - * Time-condition submit time (maybe null) - */ - const int64_t *delayed_until; /** * Attached orders */ @@ -5972,6 +5968,10 @@ typedef struct lb_order_t { * Multi-leg strategy information (only valid when has_multi_leg is true) */ struct CMultiLegInfo multi_leg; + /** + * Time-condition submit time (maybe null) + */ + const int64_t *delayed_until; } lb_order_t; /** @@ -6539,10 +6539,6 @@ typedef struct lb_order_detail_t { * Remark */ const char *remark; - /** - * Time-condition submit time (maybe null) - */ - const int64_t *delayed_until; /** * Commission-free Status */ @@ -6612,6 +6608,10 @@ typedef struct lb_order_detail_t { * Multi-leg strategy information (only valid when has_multi_leg is true) */ struct CMultiLegInfo multi_leg; + /** + * Time-condition submit time (maybe null) + */ + const int64_t *delayed_until; } lb_order_detail_t; /** diff --git a/c/src/trade_context/context.rs b/c/src/trade_context/context.rs index 58330a7d41..6749c27441 100644 --- a/c/src/trade_context/context.rs +++ b/c/src/trade_context/context.rs @@ -472,14 +472,12 @@ pub unsafe extern "C" fn lb_trade_context_replace_order( if !(*opts).remark.is_null() { opts2 = opts2.remark(cstr_to_rust((*opts).remark)); } - if (*opts).clear_delayed_until { + let delayed_until = if (*opts).clear_delayed_until { opts2 = opts2.clear_delayed_until(); - } else if !(*opts).delayed_until.is_null() { - opts2 = opts2.delayed_until( - OffsetDateTime::from_unix_timestamp(*(*opts).delayed_until) - .expect("invalid delayed until"), - ); - } + Ok(None) + } else { + parse_delayed_until((*opts).delayed_until) + }; if !(*opts).attached_params.is_null() { let ap = &*(*opts).attached_params; let attached_order_type: AttachedOrderType = ap.attached_order_type.into(); @@ -529,6 +527,9 @@ pub unsafe extern "C" fn lb_trade_context_replace_order( opts2 = opts2.attached_params(rp); } execute_async(callback, ctx, userdata, async move { + if let Some(delayed_until) = delayed_until? { + opts2 = opts2.delayed_until(delayed_until); + } ctx_inner.replace_order(opts2).await?; Ok(()) }); @@ -593,12 +594,7 @@ pub unsafe extern "C" fn lb_trade_context_submit_order( if !(*opts).client_request_id.is_null() { opts2 = opts2.client_request_id(cstr_to_rust((*opts).client_request_id)); } - if !(*opts).delayed_until.is_null() { - opts2 = opts2.delayed_until( - OffsetDateTime::from_unix_timestamp(*(*opts).delayed_until) - .expect("invalid delayed until"), - ); - } + let delayed_until = parse_delayed_until((*opts).delayed_until); if !(*opts).attached_params.is_null() { let ap = &*(*opts).attached_params; let attached_order_type: AttachedOrderType = ap.attached_order_type.into(); @@ -630,6 +626,9 @@ pub unsafe extern "C" fn lb_trade_context_submit_order( opts2 = opts2.attached_params(sp); } execute_async(callback, ctx, userdata, async move { + if let Some(delayed_until) = delayed_until? { + opts2 = opts2.delayed_until(delayed_until); + } let resp: CCow = CCow::new(ctx_inner.submit_order(opts2).await?); Ok(resp) }); @@ -669,13 +668,11 @@ pub unsafe extern "C" fn lb_trade_context_submit_multileg( if !(*opts).client_request_id.is_null() { opts2 = opts2.client_request_id(cstr_to_rust((*opts).client_request_id)); } - if !(*opts).delayed_until.is_null() { - opts2 = opts2.delayed_until( - OffsetDateTime::from_unix_timestamp(*(*opts).delayed_until) - .expect("invalid delayed until"), - ); - } + let delayed_until = parse_delayed_until((*opts).delayed_until); execute_async(callback, ctx, userdata, async move { + if let Some(delayed_until) = delayed_until? { + opts2 = opts2.delayed_until(delayed_until); + } let resp: CCow = CCow::new(ctx_inner.submit_multileg(opts2).await?); Ok(resp) @@ -893,3 +890,17 @@ pub unsafe extern "C" fn lb_trade_context_estimate_max_purchase_quantity( Ok(resp) }); } + +/// Converts an optional C `delayed_until` (Unix seconds) into a time, reporting +/// an out-of-range value through the callback instead of panicking. +unsafe fn parse_delayed_until(value: *const i64) -> longbridge::Result> { + if value.is_null() { + return Ok(None); + } + unsafe { OffsetDateTime::from_unix_timestamp(*value) } + .map(Some) + .map_err(|err| longbridge::Error::ParseField { + name: "delayed_until", + error: err.to_string(), + }) +} diff --git a/c/src/trade_context/types.rs b/c/src/trade_context/types.rs index 0d451f8626..af4ef0c447 100644 --- a/c/src/trade_context/types.rs +++ b/c/src/trade_context/types.rs @@ -940,8 +940,6 @@ pub struct COrder { pub monitor_price: *const CDecimal, /// Remark pub remark: *const c_char, - /// Time-condition submit time (maybe null) - pub delayed_until: *const i64, /// Attached orders pub attached_orders: *const CAttachedOrderDetail, /// Number of attached orders @@ -951,6 +949,8 @@ pub struct COrder { pub has_multi_leg: bool, /// Multi-leg strategy information (only valid when has_multi_leg is true) pub multi_leg: CMultiLegInfo, + /// Time-condition submit time (maybe null) + pub delayed_until: *const i64, } #[derive(Debug)] @@ -2389,8 +2389,6 @@ pub struct COrderDetail { pub monitor_price: *const CDecimal, /// Remark pub remark: *const c_char, - /// Time-condition submit time (maybe null) - pub delayed_until: *const i64, /// Commission-free Status pub free_status: CCommissionFreeStatus, /// Commission-free amount @@ -2426,6 +2424,8 @@ pub struct COrderDetail { pub has_multi_leg: bool, /// Multi-leg strategy information (only valid when has_multi_leg is true) pub multi_leg: CMultiLegInfo, + /// Time-condition submit time (maybe null) + pub delayed_until: *const i64, } #[derive(Debug)] diff --git a/nodejs/src/trade/requests/replace_order.rs b/nodejs/src/trade/requests/replace_order.rs index 2cf0ea8e09..169f7af7ec 100644 --- a/nodejs/src/trade/requests/replace_order.rs +++ b/nodejs/src/trade/requests/replace_order.rs @@ -122,7 +122,7 @@ pub struct ReplaceOrderOptions<'env> { /// more than 90 days ahead. Leave unset to keep the current time condition. pub delayed_until: Option>, /// Clear the time condition, turning the order into a regular order (takes - /// precedence over `delayed_until`) + /// precedence over `delayedUntil`) pub clear_delayed_until: Option, } diff --git a/python/pysrc/longbridge/openapi.pyi b/python/pysrc/longbridge/openapi.pyi index 9026c63d4d..b0c4b2e2ba 100644 --- a/python/pysrc/longbridge/openapi.pyi +++ b/python/pysrc/longbridge/openapi.pyi @@ -7707,7 +7707,7 @@ class TradeContext: trigger_count: Trigger count monitor_price: Monitor price remark: Remark (Maximum 64 characters) - delayed_until: New time-condition submit time (later than now, at most 90 days ahead); ``None`` keeps the current time condition + delayed_until: New time-condition submit time (later than now, at most 90 days ahead); ``None`` keeps the current time condition. Pass a timezone-aware datetime; a naive one is read as UTC (``Order.delayed_until`` is naive local time, so call ``.astimezone()`` on it before passing it back) clear_delayed_until: Clear the time condition, turning the order into a regular order (takes precedence over ``delayed_until``) Examples: @@ -7773,7 +7773,7 @@ class TradeContext: remark: Remark (Maximum 64 characters) client_request_id: Idempotent request ID. If not specified, idempotency control is skipped. The server caches this ID for 10 minutes to prevent duplicate orders. attached_params: Attached order parameters - delayed_until: Time-condition submit time. The order is held in ``DelayedNotReported`` status and only submitted at this time (later than now, at most 90 days ahead) + delayed_until: Time-condition submit time. The order is held in ``DelayedNotReported`` status and only submitted at this time (later than now, at most 90 days ahead). Pass a timezone-aware datetime; a naive one is read as UTC (``Order.delayed_until`` is naive local time, so call ``.astimezone()`` on it before passing it back) Returns: Response @@ -7830,7 +7830,7 @@ class TradeContext: submitted_price: Submitted price (required for limit order types such as `LO`) remark: Remark (Maximum 255 characters) client_request_id: Idempotent request ID. If not specified, idempotency control is skipped. The server caches this ID for 10 minutes to prevent duplicate orders. - delayed_until: Time-condition submit time (only ``LO`` / ``MO`` support time conditions) + delayed_until: Time-condition submit time (only ``LO`` / ``MO`` support time conditions). Pass a timezone-aware datetime; a naive one is read as UTC (``Order.delayed_until`` is naive local time, so call ``.astimezone()`` on it before passing it back) Returns: Response @@ -11610,7 +11610,7 @@ class AsyncTradeContext: monitor_price: Monitor price. remark: Remark (max 64 characters). attached_params: Attached order parameters. - delayed_until: New time-condition submit time (later than now, at most 90 days ahead); ``None`` keeps the current time condition. + delayed_until: New time-condition submit time (later than now, at most 90 days ahead); ``None`` keeps the current time condition. Pass a timezone-aware datetime; a naive one is read as UTC (``Order.delayed_until`` is naive local time, so call ``.astimezone()`` on it before passing it back). clear_delayed_until: Clear the time condition, turning the order into a regular order (takes precedence over ``delayed_until``). Examples: @@ -11680,7 +11680,7 @@ class AsyncTradeContext: remark: Remark (max 64 characters). client_request_id: Idempotent request ID. If not specified, idempotency control is skipped. The server caches this ID for 10 minutes to prevent duplicate orders. attached_params: Attached order parameters. - delayed_until: Time-condition submit time. The order is held in ``DelayedNotReported`` status and only submitted at this time (later than now, at most 90 days ahead). + delayed_until: Time-condition submit time. The order is held in ``DelayedNotReported`` status and only submitted at this time (later than now, at most 90 days ahead). Pass a timezone-aware datetime; a naive one is read as UTC (``Order.delayed_until`` is naive local time, so call ``.astimezone()`` on it before passing it back). Examples: :: @@ -11745,7 +11745,7 @@ class AsyncTradeContext: submitted_price: Submitted price (required for limit order types such as `LO`). remark: Remark (max 255 characters). client_request_id: Idempotent request ID. If not specified, idempotency control is skipped. The server caches this ID for 10 minutes to prevent duplicate orders. - delayed_until: Time-condition submit time (only ``LO`` / ``MO`` support time conditions). + delayed_until: Time-condition submit time (only ``LO`` / ``MO`` support time conditions). Pass a timezone-aware datetime; a naive one is read as UTC (``Order.delayed_until`` is naive local time, so call ``.astimezone()`` on it before passing it back). Examples: :: diff --git a/python/src/time.rs b/python/src/time.rs index ebeb7800f2..9c9c0819e9 100644 --- a/python/src/time.rs +++ b/python/src/time.rs @@ -22,6 +22,15 @@ impl<'a, 'py> FromPyObject<'a, 'py> for PyOffsetDateTimeWrapper { fn extract(ob: Borrowed<'a, 'py, PyAny>) -> PyResult { let date: Bound = ob.extract()?; + // A timezone-aware datetime denotes an exact instant, so honour its + // offset; a naive one keeps the historical behaviour of being read as + // UTC. + if !date.getattr("tzinfo")?.is_none() { + let timestamp: f64 = date.call_method0("timestamp")?.extract()?; + return OffsetDateTime::from_unix_timestamp(timestamp.floor() as i64) + .map(Self) + .map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string())); + } let year = date.get_year(); let month = date.get_month(); let day = date.get_day(); diff --git a/rust/src/trade/requests/mod.rs b/rust/src/trade/requests/mod.rs index 0563ca4dd7..bdeee789b4 100644 --- a/rust/src/trade/requests/mod.rs +++ b/rust/src/trade/requests/mod.rs @@ -27,3 +27,13 @@ pub use get_today_orders::GetTodayOrdersOptions; pub use replace_order::{ReplaceAttachedParams, ReplaceOrderOptions}; pub use submit_multileg::{SubmitMultiLegOrderLeg, SubmitMultiLegOrderOptions}; pub use submit_order::{SubmitAttachedParams, SubmitOrderOptions}; + +/// Wire value for a time-condition submit time. +/// +/// `0` and `1` are sentinels on the wire (`0` = no time condition, and on +/// replace `1` = clear it), so a time that maps to either would silently be +/// read as one of them. Any time that early is in the past, which the server +/// rejects, so clamp it to `2` to get that rejection instead. +pub(crate) fn delayed_until_timestamp(delayed_until: time::OffsetDateTime) -> i64 { + delayed_until.unix_timestamp().max(2) +} diff --git a/rust/src/trade/requests/replace_order.rs b/rust/src/trade/requests/replace_order.rs index 6aed074e14..5d539fc995 100644 --- a/rust/src/trade/requests/replace_order.rs +++ b/rust/src/trade/requests/replace_order.rs @@ -147,17 +147,21 @@ impl ReplaceOrderOptions { /// Set a new time-condition submit time. /// /// Must be later than now and no more than 90 days ahead. Leave unset to - /// keep the order's current time condition. + /// keep the order's current time condition. If combined with + /// [`clear_delayed_until`](Self::clear_delayed_until), the last call wins. #[inline] #[must_use] pub fn delayed_until(self, delayed_until: OffsetDateTime) -> Self { Self { - delayed_until: Some(delayed_until.unix_timestamp()), + delayed_until: Some(super::delayed_until_timestamp(delayed_until)), ..self } } /// Clear the time condition, turning the order into a regular order. + /// + /// If combined with [`delayed_until`](Self::delayed_until), the last call + /// wins. #[inline] #[must_use] pub fn clear_delayed_until(self) -> Self { @@ -353,6 +357,13 @@ mod tests { serde_json::json!(1767225600) ); + let opts = opts.delayed_until(OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND); + assert_eq!( + serde_json::to_value(&opts).unwrap()["delayed_until"], + serde_json::json!(2), + "a time that maps to the clear sentinel must not be sent as 1" + ); + let opts = opts.clear_delayed_until(); assert_eq!( serde_json::to_value(&opts).unwrap()["delayed_until"], diff --git a/rust/src/trade/requests/submit_multileg.rs b/rust/src/trade/requests/submit_multileg.rs index 05db7da18e..f8ded9c3d2 100644 --- a/rust/src/trade/requests/submit_multileg.rs +++ b/rust/src/trade/requests/submit_multileg.rs @@ -113,8 +113,38 @@ impl SubmitMultiLegOrderOptions { #[must_use] pub fn delayed_until(self, delayed_until: OffsetDateTime) -> Self { Self { - delayed_until: Some(delayed_until.unix_timestamp()), + delayed_until: Some(super::delayed_until_timestamp(delayed_until)), ..self } } } + +#[cfg(test)] +mod tests { + use time::macros::datetime; + + use super::*; + + #[test] + fn delayed_until() { + let opts = SubmitMultiLegOrderOptions::new( + OrderSide::Buy, + OrderType::MO, + Decimal::from(1), + MultiLegStrategy::Straddle, + [], + ); + assert!( + serde_json::to_value(&opts) + .unwrap() + .get("delayed_until") + .is_none() + ); + + let opts = opts.delayed_until(datetime!(2026-01-01 00:00 UTC)); + assert_eq!( + serde_json::to_value(&opts).unwrap()["delayed_until"], + serde_json::json!(1767225600) + ); + } +} diff --git a/rust/src/trade/requests/submit_order.rs b/rust/src/trade/requests/submit_order.rs index 8f55e9842b..37b8c2252d 100644 --- a/rust/src/trade/requests/submit_order.rs +++ b/rust/src/trade/requests/submit_order.rs @@ -205,7 +205,7 @@ impl SubmitOrderOptions { #[must_use] pub fn delayed_until(self, delayed_until: OffsetDateTime) -> Self { Self { - delayed_until: Some(delayed_until.unix_timestamp()), + delayed_until: Some(super::delayed_until_timestamp(delayed_until)), ..self } } @@ -313,3 +313,43 @@ impl SubmitAttachedParams { } } } + +#[cfg(test)] +mod tests { + use time::macros::datetime; + + use super::*; + + fn options() -> SubmitOrderOptions { + SubmitOrderOptions::new( + "700.HK", + OrderType::LO, + OrderSide::Buy, + Decimal::from(100), + TimeInForceType::Day, + ) + } + + #[test] + fn delayed_until() { + assert!( + serde_json::to_value(options()) + .unwrap() + .get("delayed_until") + .is_none() + ); + + let opts = options().delayed_until(datetime!(2026-01-01 00:00 UTC)); + assert_eq!( + serde_json::to_value(&opts).unwrap()["delayed_until"], + serde_json::json!(1767225600) + ); + + let opts = options().delayed_until(OffsetDateTime::UNIX_EPOCH); + assert_eq!( + serde_json::to_value(&opts).unwrap()["delayed_until"], + serde_json::json!(2), + "a time that maps to 0 must not be sent as a regular order" + ); + } +} diff --git a/rust/src/trade/types.rs b/rust/src/trade/types.rs index c795b3807d..dbb9bfa59f 100644 --- a/rust/src/trade/types.rs +++ b/rust/src/trade/types.rs @@ -2175,6 +2175,7 @@ mod tests { "trigger_count": 0, "monitor_price": "", "remark": "1680863603.927165", + "delayed_until": "0", "free_status": "None", "free_amount": "", "free_currency": "", @@ -2213,6 +2214,17 @@ mod tests { } "#; - _ = serde_json::from_str::(data).unwrap(); + let detail = serde_json::from_str::(data).unwrap(); + assert!( + detail.delayed_until.is_none(), + "\"0\" means no time condition" + ); + + let mut value: serde_json::Value = serde_json::from_str(data).unwrap(); + value["status"] = "DelayedNotReported".into(); + value["delayed_until"] = "1767225600".into(); + let detail = serde_json::from_value::(value).unwrap(); + assert_eq!(detail.status, OrderStatus::DelayedNotReported); + assert_eq!(detail.delayed_until, Some(datetime!(2026-01-01 00:00 UTC))); } } From 2a17a052c57b9f661633d1c64c289d0585c460f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Tue, 13 Oct 2026 22:37:36 +0800 Subject: [PATCH 5/9] fix: address round-2 review of time-condition orders - Python: revert the SDK-wide tzinfo change to PyOffsetDateTimeWrapper, which shifted wall-clock parameters such as history_candlesticks_by_offset(time=...). delayed_until now uses a dedicated PyInstantWrapper: aware datetimes (utcoffset() not None) convert by their offset, naive ones are read as UTC. - Node: the submit / replace / multi-leg option conversions are now TryFrom, so an out-of-range delayedUntil Date is an InvalidArg error instead of a panic. - Rust: test that the last of delayed_until / clear_delayed_until wins. Verified end to end against a local fake HTTP server with the built Python wheel: aware +08:00, aware UTC and naive inputs all send 1767225600; epoch is clamped to 2; clear sends 1 and wins over a time. --- CHANGELOG.md | 6 +--- nodejs/src/trade/context.rs | 6 ++-- nodejs/src/trade/requests/replace_order.rs | 13 ++++---- nodejs/src/trade/requests/submit_multileg.rs | 13 ++++---- nodejs/src/trade/requests/submit_order.rs | 13 ++++---- nodejs/src/utils.rs | 7 ++++ python/src/time.rs | 35 +++++++++++++++----- python/src/trade/context.rs | 8 ++--- python/src/trade/context_async.rs | 8 ++--- rust/src/trade/requests/replace_order.rs | 7 ++++ 10 files changed, 73 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42a43e6a19..263089f0ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Known differences:** HTTP errors are `Error::HttpClient(OpenApi { .. })` rather than `Error::WsClient(ResponseError { .. })` (`Error::openapi_error_code()` / `into_simple_error()` cover both, but a non-JSON gateway error surfaces as `HttpClientError::UnexpectedHttpResponse` with no code); the REST gateway currently reports business errors (e.g. `301600`, `301607`) as `500`; the per-command rate limits the WebSocket applies from the quote profile are not applied on the HTTP path (the HTTP client retries `429` with back-off instead), and a candlestick top-up turns one call into up to 9 requests against `/quote/history-candlesticks`; during the US overnight session (20:00–04:00 New York) the REST gateway has already rolled over to the next trading day, so `intraday` for a US equity can be empty over HTTP (its lines are all overnight and dropped) and snapshot values of `quote`, `depth`, `calc_indexes` and `capital_flow` may reflect overnight trading that the WebSocket (without `enable_overnight`) does not report; `trades` may include overnight ticks during and shortly after the overnight session. The HTTP path has no client-side rate limiting. - **C / C++ / Java / Node.js / Python:** the `QuoteTransport` enum and the corresponding config option are exposed in every binding layer. - **All SDKs:** `OrderStatus` gains a `DelayedNotReported` variant (wire value `DelayedNotReported`) — "monitoring", the state a time-conditional order sits in before it is reported to the exchange. Previously this status deserialized to `OrderStatus::Unknown`, so time-conditional orders being monitored were indistinguishable from a genuinely unrecognized status in `today_orders` / `history_orders` / `order_detail` and the order-changed push. The variant is appended **after** `PartialWithdrawal` rather than inserted next to the other `*NotReported` values on purpose: the C/C++/Node.js enums use implicit discriminants, and inserting mid-list would renumber every following value (an ABI break for the C/C++ bindings, and a silent mismatch for TypeScript code that inlined the old `const enum` values) -- **All SDKs:** time-condition orders (longbridge/developers#1289). `SubmitOrderOptions` and `SubmitMultiLegOrderOptions` gain `delayed_until`: the order is held in `DelayedNotReported` status and sent automatically at that time (later than now, at most 90 days ahead; multi-leg orders support only `LO` / `MO`). `ReplaceOrderOptions` gains `delayed_until` to move the time and `clear_delayed_until` to turn the order back into a regular one; leaving both unset keeps the current time condition. A time at or before `1970-01-01T00:00:01Z` would collide with the wire sentinels (`0` = no time condition, `1` = clear) and is sent as `2` instead, so the server rejects it as a past time rather than silently placing a regular order or clearing the condition; an out-of-range C timestamp is reported through the callback instead of aborting. `Order` and `OrderDetail` gain `delayed_until` (unset when the order is not a time-condition order), read from `today_orders` / `history_orders` / `order_detail`. Per layer: Rust `delayed_until(OffsetDateTime)` / `clear_delayed_until()` builder methods and `Option` fields; C `const int64_t *delayed_until` (Unix seconds) on `lb_submit_order_options_t` / `CSubmitMultiLegOrderOptions` / `lb_replace_order_options_t` (plus `bool clear_delayed_until`) and on `lb_order_t` / `lb_order_detail_t`, every new field appended at the end of its struct so existing field offsets are unchanged; C++ `std::optional delayed_until` (+ `bool clear_delayed_until` on `ReplaceOrderOptions`); Java `setDelayedUntil(OffsetDateTime)` / `ReplaceOrderOptions.clearDelayedUntil()` and `getDelayedUntil()`; Node.js `delayedUntil?: Date` / `clearDelayedUntil?: boolean` and `get delayedUntil(): Date | null`; Python `delayed_until: Optional[datetime]` / `clear_delayed_until: bool = False` keyword arguments and `Order.delayed_until` / `OrderDetail.delayed_until` - -### Fixed - -- **Python:** a timezone-aware `datetime` passed to any SDK method (e.g. `start_at`, `end_at`, and the new `delayed_until`) was read as if its wall-clock time were UTC, ignoring its `tzinfo` — so `datetime(2026, 10, 14, 9, 30, tzinfo=ZoneInfo("Asia/Hong_Kong"))` meant 09:30 UTC (17:30 in Hong Kong). Aware datetimes now convert by their actual instant; naive datetimes are still read as UTC +- **All SDKs:** time-condition orders (longbridge/developers#1289). `SubmitOrderOptions` and `SubmitMultiLegOrderOptions` gain `delayed_until`: the order is held in `DelayedNotReported` status and sent automatically at that time (later than now, at most 90 days ahead; multi-leg orders support only `LO` / `MO`). `ReplaceOrderOptions` gains `delayed_until` to move the time and `clear_delayed_until` to turn the order back into a regular one; leaving both unset keeps the current time condition. A time at or before `1970-01-01T00:00:01Z` would collide with the wire sentinels (`0` = no time condition, `1` = clear) and is sent as `2` instead, so the server rejects it as a past time rather than silently placing a regular order or clearing the condition; an out-of-range C timestamp is reported through the callback instead of aborting. `Order` and `OrderDetail` gain `delayed_until` (unset when the order is not a time-condition order), read from `today_orders` / `history_orders` / `order_detail`. Per layer: Rust `delayed_until(OffsetDateTime)` / `clear_delayed_until()` builder methods and `Option` fields; C `const int64_t *delayed_until` (Unix seconds) on `lb_submit_order_options_t` / `CSubmitMultiLegOrderOptions` / `lb_replace_order_options_t` (plus `bool clear_delayed_until`) and on `lb_order_t` / `lb_order_detail_t`, every new field appended at the end of its struct so existing field offsets are unchanged; C++ `std::optional delayed_until` (+ `bool clear_delayed_until` on `ReplaceOrderOptions`); Java `setDelayedUntil(OffsetDateTime)` / `ReplaceOrderOptions.clearDelayedUntil()` and `getDelayedUntil()`; Node.js `delayedUntil?: Date` / `clearDelayedUntil?: boolean` and `get delayedUntil(): Date | null`; Python `delayed_until: Optional[datetime]` / `clear_delayed_until: bool = False` keyword arguments (a timezone-aware `datetime` is converted by its actual offset, a naive one is read as UTC) and `Order.delayed_until` / `OrderDetail.delayed_until` ## [5.2.0] - 2026-09-30 diff --git a/nodejs/src/trade/context.rs b/nodejs/src/trade/context.rs index dd791569db..2b3bb169c5 100644 --- a/nodejs/src/trade/context.rs +++ b/nodejs/src/trade/context.rs @@ -342,7 +342,7 @@ impl TradeContext { opts: ReplaceOrderOptions<'env>, ) -> Result> { let ctx = self.ctx.clone(); - let opts = longbridge::trade::ReplaceOrderOptions::from(opts); + let opts = longbridge::trade::ReplaceOrderOptions::try_from(opts)?; env.spawn_future(async move { ctx.replace_order(opts).await.map_err(ErrorNewType)?; Ok(()) @@ -382,7 +382,7 @@ impl TradeContext { opts: SubmitOrderOptions<'env>, ) -> Result> { let ctx = self.ctx.clone(); - let opts = longbridge::trade::SubmitOrderOptions::from(opts); + let opts = longbridge::trade::SubmitOrderOptions::try_from(opts)?; env.spawn_future(async move { let res = ctx.submit_order(opts).await.map_err(ErrorNewType)?; SubmitOrderResponse::try_from(res) @@ -427,7 +427,7 @@ impl TradeContext { opts: SubmitMultiLegOrderOptions<'env>, ) -> Result> { let ctx = self.ctx.clone(); - let opts = longbridge::trade::SubmitMultiLegOrderOptions::from(opts); + let opts = longbridge::trade::SubmitMultiLegOrderOptions::try_from(opts)?; env.spawn_future(async move { let res = ctx.submit_multileg(opts).await.map_err(ErrorNewType)?; SubmitOrderResponse::try_from(res) diff --git a/nodejs/src/trade/requests/replace_order.rs b/nodejs/src/trade/requests/replace_order.rs index 169f7af7ec..1f3755afbd 100644 --- a/nodejs/src/trade/requests/replace_order.rs +++ b/nodejs/src/trade/requests/replace_order.rs @@ -4,7 +4,7 @@ use napi::bindgen_prelude::ClassInstance; use crate::{ decimal::Decimal, trade::types::{AttachedOrderType, OrderType, OutsideRTH, TimeInForceType}, - utils::from_datetime, + utils::try_from_datetime, }; /// Parameters for replacing an attached order @@ -126,9 +126,10 @@ pub struct ReplaceOrderOptions<'env> { pub clear_delayed_until: Option, } -impl<'env> From> for longbridge::trade::ReplaceOrderOptions { - #[inline] - fn from(opts: ReplaceOrderOptions<'env>) -> Self { +impl<'env> TryFrom> for longbridge::trade::ReplaceOrderOptions { + type Error = napi::Error; + + fn try_from(opts: ReplaceOrderOptions<'env>) -> napi::Result { let mut opts2 = longbridge::trade::ReplaceOrderOptions::new(opts.order_id, opts.quantity.0); if let Some(price) = opts.price { opts2 = opts2.price(price.0); @@ -163,8 +164,8 @@ impl<'env> From> for longbridge::trade::ReplaceOrderOp if opts.clear_delayed_until.unwrap_or_default() { opts2 = opts2.clear_delayed_until(); } else if let Some(delayed_until) = opts.delayed_until { - opts2 = opts2.delayed_until(from_datetime(delayed_until)); + opts2 = opts2.delayed_until(try_from_datetime("delayedUntil", delayed_until)?); } - opts2 + Ok(opts2) } } diff --git a/nodejs/src/trade/requests/submit_multileg.rs b/nodejs/src/trade/requests/submit_multileg.rs index 8baf5cdae8..ad663cd5b3 100644 --- a/nodejs/src/trade/requests/submit_multileg.rs +++ b/nodejs/src/trade/requests/submit_multileg.rs @@ -4,7 +4,7 @@ use napi::bindgen_prelude::ClassInstance; use crate::{ decimal::Decimal, trade::types::{MultiLegStrategy, OrderSide, OrderType}, - utils::from_datetime, + utils::try_from_datetime, }; /// A leg of a multi-leg combination order to submit @@ -46,11 +46,12 @@ pub struct SubmitMultiLegOrderOptions<'env> { pub delayed_until: Option>, } -impl<'env> From> +impl<'env> TryFrom> for longbridge::trade::SubmitMultiLegOrderOptions { - #[inline] - fn from(opts: SubmitMultiLegOrderOptions<'env>) -> Self { + type Error = napi::Error; + + fn try_from(opts: SubmitMultiLegOrderOptions<'env>) -> napi::Result { let legs = opts.legs.into_iter().map(|leg| { longbridge::trade::SubmitMultiLegOrderLeg::new(leg.symbol, leg.ratio_quantity.0) }); @@ -71,8 +72,8 @@ impl<'env> From> opts2 = opts2.client_request_id(id); } if let Some(delayed_until) = opts.delayed_until { - opts2 = opts2.delayed_until(from_datetime(delayed_until)); + opts2 = opts2.delayed_until(try_from_datetime("delayedUntil", delayed_until)?); } - opts2 + Ok(opts2) } } diff --git a/nodejs/src/trade/requests/submit_order.rs b/nodejs/src/trade/requests/submit_order.rs index 884db4c923..c40ab568c8 100644 --- a/nodejs/src/trade/requests/submit_order.rs +++ b/nodejs/src/trade/requests/submit_order.rs @@ -5,7 +5,7 @@ use crate::{ decimal::Decimal, time::NaiveDate, trade::types::{AttachedOrderType, OrderSide, OrderType, OutsideRTH, TimeInForceType}, - utils::from_datetime, + utils::try_from_datetime, }; /// Parameters for submitting an attached order @@ -110,9 +110,10 @@ pub struct SubmitOrderOptions<'env> { pub delayed_until: Option>, } -impl<'env> From> for longbridge::trade::SubmitOrderOptions { - #[inline] - fn from(opts: SubmitOrderOptions<'env>) -> Self { +impl<'env> TryFrom> for longbridge::trade::SubmitOrderOptions { + type Error = napi::Error; + + fn try_from(opts: SubmitOrderOptions<'env>) -> napi::Result { let mut opts2 = longbridge::trade::SubmitOrderOptions::new( opts.symbol, opts.order_type.into(), @@ -160,8 +161,8 @@ impl<'env> From> for longbridge::trade::SubmitOrderOpti opts2 = opts2.attached_params(p.into()); } if let Some(delayed_until) = opts.delayed_until { - opts2 = opts2.delayed_until(from_datetime(delayed_until)); + opts2 = opts2.delayed_until(try_from_datetime("delayedUntil", delayed_until)?); } - opts2 + Ok(opts2) } } diff --git a/nodejs/src/utils.rs b/nodejs/src/utils.rs index a1530a4647..fea8cc3e0f 100644 --- a/nodejs/src/utils.rs +++ b/nodejs/src/utils.rs @@ -13,6 +13,13 @@ pub(crate) fn from_datetime(time: DateTime) -> OffsetDateTime { OffsetDateTime::from_unix_timestamp(time.timestamp()).expect("invalid timestamp") } +/// Like [`from_datetime`], but reports an out-of-range `Date` as an +/// `InvalidArg` error instead of panicking. +pub(crate) fn try_from_datetime(name: &str, time: DateTime) -> napi::Result { + OffsetDateTime::from_unix_timestamp(time.timestamp()) + .map_err(|err| napi::Error::new(napi::Status::InvalidArg, format!("invalid {name}: {err}"))) +} + pub(crate) trait ToJSON { fn to_json(&self) -> Value; } diff --git a/python/src/time.rs b/python/src/time.rs index 9c9c0819e9..f92dba1183 100644 --- a/python/src/time.rs +++ b/python/src/time.rs @@ -22,15 +22,6 @@ impl<'a, 'py> FromPyObject<'a, 'py> for PyOffsetDateTimeWrapper { fn extract(ob: Borrowed<'a, 'py, PyAny>) -> PyResult { let date: Bound = ob.extract()?; - // A timezone-aware datetime denotes an exact instant, so honour its - // offset; a naive one keeps the historical behaviour of being read as - // UTC. - if !date.getattr("tzinfo")?.is_none() { - let timestamp: f64 = date.call_method0("timestamp")?.extract()?; - return OffsetDateTime::from_unix_timestamp(timestamp.floor() as i64) - .map(Self) - .map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string())); - } let year = date.get_year(); let month = date.get_month(); let day = date.get_day(); @@ -64,6 +55,32 @@ impl<'py> IntoPyObject<'py> for PyOffsetDateTimeWrapper { } } +/// A Python `datetime` read as an exact instant: a timezone-aware value +/// (one whose `utcoffset()` is not `None`) converts by its actual offset, and a +/// naive value is read as UTC like [`PyOffsetDateTimeWrapper`]. +/// +/// Used where the value decides *when* something happens (e.g. the submit +/// time of a time-condition order), so an aware `datetime` must not be shifted +/// by its UTC offset. Existing parameters keep [`PyOffsetDateTimeWrapper`], +/// whose wall-clock semantics some callers rely on. +#[derive(Copy, Clone)] +pub(crate) struct PyInstantWrapper(pub(crate) OffsetDateTime); + +impl<'a, 'py> FromPyObject<'a, 'py> for PyInstantWrapper { + type Error = PyErr; + + fn extract(ob: Borrowed<'a, 'py, PyAny>) -> PyResult { + let date: Bound = ob.extract()?; + if date.call_method0("utcoffset")?.is_none() { + return Ok(Self(PyOffsetDateTimeWrapper::extract(ob)?.0)); + } + let timestamp: f64 = date.call_method0("timestamp")?.extract()?; + OffsetDateTime::from_unix_timestamp(timestamp.floor() as i64) + .map(Self) + .map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string())) + } +} + #[derive(Copy, Clone)] pub(crate) struct PyDateWrapper(pub(crate) Date); diff --git a/python/src/trade/context.rs b/python/src/trade/context.rs index c3d396f30c..9a93f2b4d9 100644 --- a/python/src/trade/context.rs +++ b/python/src/trade/context.rs @@ -17,7 +17,7 @@ use crate::{ config::Config, decimal::PyDecimal, error::ErrorNewType, - time::{PyDateWrapper, PyOffsetDateTimeWrapper}, + time::{PyDateWrapper, PyInstantWrapper, PyOffsetDateTimeWrapper}, trade::{ push::handle_push_event, types::{ @@ -273,7 +273,7 @@ impl TradeContext { monitor_price: Option, remark: Option, attached_params: Option, - delayed_until: Option, + delayed_until: Option, clear_delayed_until: bool, ) -> PyResult<()> { let mut opts = ReplaceOrderOptions::new(order_id, quantity.into()); @@ -341,7 +341,7 @@ impl TradeContext { remark: Option, client_request_id: Option, attached_params: Option, - delayed_until: Option, + delayed_until: Option, ) -> PyResult { let mut opts = SubmitOrderOptions::new( symbol, @@ -421,7 +421,7 @@ impl TradeContext { submitted_price: Option, remark: Option, client_request_id: Option, - delayed_until: Option, + delayed_until: Option, ) -> PyResult { let legs = legs .into_iter() diff --git a/python/src/trade/context_async.rs b/python/src/trade/context_async.rs index eb5be3644b..50504b465a 100644 --- a/python/src/trade/context_async.rs +++ b/python/src/trade/context_async.rs @@ -16,7 +16,7 @@ use crate::{ config::Config, decimal::PyDecimal, error::ErrorNewType, - time::{PyDateWrapper, PyOffsetDateTimeWrapper}, + time::{PyDateWrapper, PyInstantWrapper, PyOffsetDateTimeWrapper}, trade::{ context::Callbacks, push::handle_push_event, @@ -310,7 +310,7 @@ impl AsyncTradeContext { monitor_price: Option, remark: Option, attached_params: Option, - delayed_until: Option, + delayed_until: Option, clear_delayed_until: bool, ) -> PyResult> { let ctx = self.ctx.clone(); @@ -381,7 +381,7 @@ impl AsyncTradeContext { remark: Option, client_request_id: Option, attached_params: Option, - delayed_until: Option, + delayed_until: Option, ) -> PyResult> { let ctx = self.ctx.clone(); let mut opts = SubmitOrderOptions::new( @@ -464,7 +464,7 @@ impl AsyncTradeContext { submitted_price: Option, remark: Option, client_request_id: Option, - delayed_until: Option, + delayed_until: Option, ) -> PyResult> { let ctx = self.ctx.clone(); let legs = legs diff --git a/rust/src/trade/requests/replace_order.rs b/rust/src/trade/requests/replace_order.rs index 5d539fc995..4261f9a4ba 100644 --- a/rust/src/trade/requests/replace_order.rs +++ b/rust/src/trade/requests/replace_order.rs @@ -369,5 +369,12 @@ mod tests { serde_json::to_value(&opts).unwrap()["delayed_until"], serde_json::json!(1) ); + + // The last of `delayed_until` / `clear_delayed_until` wins. + let opts = opts.delayed_until(datetime!(2026-01-02 00:00 UTC)); + assert_eq!( + serde_json::to_value(&opts).unwrap()["delayed_until"], + serde_json::json!(1767312000) + ); } } From fa2d455c0deeaab4556a9d9a1247b06999f138ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Tue, 13 Oct 2026 22:42:21 +0800 Subject: [PATCH 6/9] fix: address round-3 review of time-condition orders - Document the later-than-now / 90-day limit on delayed_until in the C, C++, Node multi-leg and Python multi-leg docs (header and index.d.ts regenerated). - C: parse_delayed_until returns the small ComponentRange error and converts it to longbridge::Error at the call site, avoiding a new clippy::result_large_err warning. --- c/csrc/include/longbridge.h | 12 ++++++---- c/src/trade_context/context.rs | 24 ++++++++++++-------- c/src/trade_context/types.rs | 12 ++++++---- cpp/include/types.hpp | 12 ++++++---- nodejs/index.d.ts | 5 ++-- nodejs/src/trade/requests/submit_multileg.rs | 3 ++- python/pysrc/longbridge/openapi.pyi | 4 ++-- 7 files changed, 42 insertions(+), 30 deletions(-) diff --git a/c/csrc/include/longbridge.h b/c/csrc/include/longbridge.h index fa0736cd01..c0ff5ecc33 100644 --- a/c/csrc/include/longbridge.h +++ b/c/csrc/include/longbridge.h @@ -4699,8 +4699,9 @@ typedef struct lb_replace_order_options_t { */ const struct CReplaceAttachedParams *attached_params; /** - * New time-condition submit time, Unix timestamp in seconds (can be - * null to keep the current time condition) + * New time-condition submit time, Unix timestamp in seconds; must be + * later than now and no more than 90 days ahead (can be null to keep the + * current time condition) */ const int64_t *delayed_until; /** @@ -4834,7 +4835,7 @@ typedef struct lb_submit_order_options_t { /** * Time-condition submit time, Unix timestamp in seconds (can be null). * The order is held in `DelayedNotReported` status and only submitted at - * this time. + * this time, which must be later than now and no more than 90 days ahead. */ const int64_t *delayed_until; } lb_submit_order_options_t; @@ -4900,8 +4901,9 @@ typedef struct CSubmitMultiLegOrderOptions { */ const char *client_request_id; /** - * Time-condition submit time, Unix timestamp in seconds (can be null). - * Only `LO` / `MO` support time conditions. + * Time-condition submit time, Unix timestamp in seconds (can be null); + * must be later than now and no more than 90 days ahead. Only `LO` / `MO` + * support time conditions. */ const int64_t *delayed_until; } CSubmitMultiLegOrderOptions; diff --git a/c/src/trade_context/context.rs b/c/src/trade_context/context.rs index 6749c27441..2a222ab46d 100644 --- a/c/src/trade_context/context.rs +++ b/c/src/trade_context/context.rs @@ -527,7 +527,7 @@ pub unsafe extern "C" fn lb_trade_context_replace_order( opts2 = opts2.attached_params(rp); } execute_async(callback, ctx, userdata, async move { - if let Some(delayed_until) = delayed_until? { + if let Some(delayed_until) = delayed_until.map_err(delayed_until_error)? { opts2 = opts2.delayed_until(delayed_until); } ctx_inner.replace_order(opts2).await?; @@ -626,7 +626,7 @@ pub unsafe extern "C" fn lb_trade_context_submit_order( opts2 = opts2.attached_params(sp); } execute_async(callback, ctx, userdata, async move { - if let Some(delayed_until) = delayed_until? { + if let Some(delayed_until) = delayed_until.map_err(delayed_until_error)? { opts2 = opts2.delayed_until(delayed_until); } let resp: CCow = CCow::new(ctx_inner.submit_order(opts2).await?); @@ -670,7 +670,7 @@ pub unsafe extern "C" fn lb_trade_context_submit_multileg( } let delayed_until = parse_delayed_until((*opts).delayed_until); execute_async(callback, ctx, userdata, async move { - if let Some(delayed_until) = delayed_until? { + if let Some(delayed_until) = delayed_until.map_err(delayed_until_error)? { opts2 = opts2.delayed_until(delayed_until); } let resp: CCow = @@ -893,14 +893,18 @@ pub unsafe extern "C" fn lb_trade_context_estimate_max_purchase_quantity( /// Converts an optional C `delayed_until` (Unix seconds) into a time, reporting /// an out-of-range value through the callback instead of panicking. -unsafe fn parse_delayed_until(value: *const i64) -> longbridge::Result> { +unsafe fn parse_delayed_until( + value: *const i64, +) -> Result, time::error::ComponentRange> { if value.is_null() { return Ok(None); } - unsafe { OffsetDateTime::from_unix_timestamp(*value) } - .map(Some) - .map_err(|err| longbridge::Error::ParseField { - name: "delayed_until", - error: err.to_string(), - }) + unsafe { OffsetDateTime::from_unix_timestamp(*value) }.map(Some) +} + +fn delayed_until_error(err: time::error::ComponentRange) -> longbridge::Error { + longbridge::Error::ParseField { + name: "delayed_until", + error: err.to_string(), + } } diff --git a/c/src/trade_context/types.rs b/c/src/trade_context/types.rs index af4ef0c447..92c789fd46 100644 --- a/c/src/trade_context/types.rs +++ b/c/src/trade_context/types.rs @@ -1258,8 +1258,9 @@ pub struct CReplaceOrderOptions { pub remark: *const c_char, /// Attached order parameters (can be null) pub attached_params: *const CReplaceAttachedParams, - /// New time-condition submit time, Unix timestamp in seconds (can be - /// null to keep the current time condition) + /// New time-condition submit time, Unix timestamp in seconds; must be + /// later than now and no more than 90 days ahead (can be null to keep the + /// current time condition) pub delayed_until: *const i64, /// Clear the time condition, turning the order into a regular order /// (takes precedence over `delayed_until`) @@ -1311,7 +1312,7 @@ pub struct CSubmitOrderOptions { pub attached_params: *const CSubmitAttachedParams, /// Time-condition submit time, Unix timestamp in seconds (can be null). /// The order is held in `DelayedNotReported` status and only submitted at - /// this time. + /// this time, which must be later than now and no more than 90 days ahead. pub delayed_until: *const i64, } @@ -1381,8 +1382,9 @@ pub struct CSubmitMultiLegOrderOptions { /// If not specified, idempotency control is skipped. /// The server caches this ID for 10 minutes. pub client_request_id: *const c_char, - /// Time-condition submit time, Unix timestamp in seconds (can be null). - /// Only `LO` / `MO` support time conditions. + /// Time-condition submit time, Unix timestamp in seconds (can be null); + /// must be later than now and no more than 90 days ahead. Only `LO` / `MO` + /// support time conditions. pub delayed_until: *const i64, } diff --git a/cpp/include/types.hpp b/cpp/include/types.hpp index e77098cf9c..5ecea2cd75 100644 --- a/cpp/include/types.hpp +++ b/cpp/include/types.hpp @@ -1962,8 +1962,9 @@ struct ReplaceOrderOptions std::optional remark; /// Attached order params std::optional attached_params; - /// New time-condition submit time (Unix timestamp in seconds); not set - /// keeps the current time condition + /// New time-condition submit time (Unix timestamp in seconds), later than + /// now and no more than 90 days ahead; not set keeps the current time + /// condition std::optional delayed_until; /// Clear the time condition, turning the order into a regular order (takes /// precedence over `delayed_until`) @@ -2013,7 +2014,8 @@ struct SubmitOrderOptions /// Attached order params std::optional attached_params; /// Time-condition submit time (Unix timestamp in seconds). The order is held - /// in `DelayedNotReported` status and only submitted at this time. + /// in `DelayedNotReported` status and only submitted at this time, which + /// must be later than now and no more than 90 days ahead. std::optional delayed_until; }; @@ -2057,8 +2059,8 @@ struct SubmitMultiLegOrderOptions /// If not specified, idempotency control is skipped. /// The server caches this ID for 10 minutes. std::optional client_request_id; - /// Time-condition submit time (Unix timestamp in seconds). Only `LO` / `MO` - /// support time conditions. + /// Time-condition submit time (Unix timestamp in seconds), later than now + /// and no more than 90 days ahead. Only `LO` / `MO` support time conditions. std::optional delayed_until; }; diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts index f602078aa7..5464597dee 100644 --- a/nodejs/index.d.ts +++ b/nodejs/index.d.ts @@ -8172,7 +8172,7 @@ export interface ReplaceOrderOptions { delayedUntil?: Date /** * Clear the time condition, turning the order into a regular order (takes - * precedence over `delayed_until`) + * precedence over `delayedUntil`) */ clearDelayedUntil?: boolean } @@ -8697,7 +8697,8 @@ export interface SubmitMultiLegOrderOptions { clientRequestId?: string /** * Time-condition submit time. The order is held in `DelayedNotReported` - * status and only submitted at this time. Only `LO` / `MO` support time + * status and only submitted at this time, which must be later than now + * and no more than 90 days ahead. Only `LO` / `MO` support time * conditions. */ delayedUntil?: Date diff --git a/nodejs/src/trade/requests/submit_multileg.rs b/nodejs/src/trade/requests/submit_multileg.rs index ad663cd5b3..562d75d95d 100644 --- a/nodejs/src/trade/requests/submit_multileg.rs +++ b/nodejs/src/trade/requests/submit_multileg.rs @@ -41,7 +41,8 @@ pub struct SubmitMultiLegOrderOptions<'env> { /// The server caches this ID for 10 minutes. pub client_request_id: Option, /// Time-condition submit time. The order is held in `DelayedNotReported` - /// status and only submitted at this time. Only `LO` / `MO` support time + /// status and only submitted at this time, which must be later than now + /// and no more than 90 days ahead. Only `LO` / `MO` support time /// conditions. pub delayed_until: Option>, } diff --git a/python/pysrc/longbridge/openapi.pyi b/python/pysrc/longbridge/openapi.pyi index b0c4b2e2ba..722f570f2e 100644 --- a/python/pysrc/longbridge/openapi.pyi +++ b/python/pysrc/longbridge/openapi.pyi @@ -7830,7 +7830,7 @@ class TradeContext: submitted_price: Submitted price (required for limit order types such as `LO`) remark: Remark (Maximum 255 characters) client_request_id: Idempotent request ID. If not specified, idempotency control is skipped. The server caches this ID for 10 minutes to prevent duplicate orders. - delayed_until: Time-condition submit time (only ``LO`` / ``MO`` support time conditions). Pass a timezone-aware datetime; a naive one is read as UTC (``Order.delayed_until`` is naive local time, so call ``.astimezone()`` on it before passing it back) + delayed_until: Time-condition submit time (later than now, at most 90 days ahead; only ``LO`` / ``MO`` support time conditions). Pass a timezone-aware datetime; a naive one is read as UTC (``Order.delayed_until`` is naive local time, so call ``.astimezone()`` on it before passing it back) Returns: Response @@ -11745,7 +11745,7 @@ class AsyncTradeContext: submitted_price: Submitted price (required for limit order types such as `LO`). remark: Remark (max 255 characters). client_request_id: Idempotent request ID. If not specified, idempotency control is skipped. The server caches this ID for 10 minutes to prevent duplicate orders. - delayed_until: Time-condition submit time (only ``LO`` / ``MO`` support time conditions). Pass a timezone-aware datetime; a naive one is read as UTC (``Order.delayed_until`` is naive local time, so call ``.astimezone()`` on it before passing it back). + delayed_until: Time-condition submit time (later than now, at most 90 days ahead; only ``LO`` / ``MO`` support time conditions). Pass a timezone-aware datetime; a naive one is read as UTC (``Order.delayed_until`` is naive local time, so call ``.astimezone()`` on it before passing it back). Examples: :: From 32f5c569339799d4076d84bb2c2d101418355fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 14 Oct 2026 09:33:42 +0800 Subject: [PATCH 7/9] test: cover a null delayed_until in order responses The spec (longbridge/developers#1289) marks the response field nullable. --- rust/src/trade/types.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rust/src/trade/types.rs b/rust/src/trade/types.rs index dbb9bfa59f..262f5d7433 100644 --- a/rust/src/trade/types.rs +++ b/rust/src/trade/types.rs @@ -2226,5 +2226,11 @@ mod tests { let detail = serde_json::from_value::(value).unwrap(); assert_eq!(detail.status, OrderStatus::DelayedNotReported); assert_eq!(detail.delayed_until, Some(datetime!(2026-01-01 00:00 UTC))); + + // The spec marks delayed_until nullable. + let mut value: serde_json::Value = serde_json::from_str(data).unwrap(); + value["delayed_until"] = serde_json::Value::Null; + let detail = serde_json::from_value::(value).unwrap(); + assert!(detail.delayed_until.is_none()); } } From ee097e7cf31e811278b683e0e857ca642031eb0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 14 Oct 2026 09:36:46 +0800 Subject: [PATCH 8/9] fix: parse the new delayed_until response field leniently A malformed or out-of-range delayed_until (or a JSON number instead of the documented string) now reads as unset instead of failing the whole today_orders / history_orders / order_detail response, so upgrading users are not exposed to a field the server never sent before. CHANGELOG: spell out that the C option structs grow (rebuild against the new header, zero-initialize options) and the Rust source-level impact of the new variant and fields. --- CHANGELOG.md | 2 +- rust/src/serde_utils.rs | 28 ++++++++++++++++++++++++++++ rust/src/trade/types.rs | 20 ++++++++++++++++++-- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 263089f0ba..25329f597f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Known differences:** HTTP errors are `Error::HttpClient(OpenApi { .. })` rather than `Error::WsClient(ResponseError { .. })` (`Error::openapi_error_code()` / `into_simple_error()` cover both, but a non-JSON gateway error surfaces as `HttpClientError::UnexpectedHttpResponse` with no code); the REST gateway currently reports business errors (e.g. `301600`, `301607`) as `500`; the per-command rate limits the WebSocket applies from the quote profile are not applied on the HTTP path (the HTTP client retries `429` with back-off instead), and a candlestick top-up turns one call into up to 9 requests against `/quote/history-candlesticks`; during the US overnight session (20:00–04:00 New York) the REST gateway has already rolled over to the next trading day, so `intraday` for a US equity can be empty over HTTP (its lines are all overnight and dropped) and snapshot values of `quote`, `depth`, `calc_indexes` and `capital_flow` may reflect overnight trading that the WebSocket (without `enable_overnight`) does not report; `trades` may include overnight ticks during and shortly after the overnight session. The HTTP path has no client-side rate limiting. - **C / C++ / Java / Node.js / Python:** the `QuoteTransport` enum and the corresponding config option are exposed in every binding layer. - **All SDKs:** `OrderStatus` gains a `DelayedNotReported` variant (wire value `DelayedNotReported`) — "monitoring", the state a time-conditional order sits in before it is reported to the exchange. Previously this status deserialized to `OrderStatus::Unknown`, so time-conditional orders being monitored were indistinguishable from a genuinely unrecognized status in `today_orders` / `history_orders` / `order_detail` and the order-changed push. The variant is appended **after** `PartialWithdrawal` rather than inserted next to the other `*NotReported` values on purpose: the C/C++/Node.js enums use implicit discriminants, and inserting mid-list would renumber every following value (an ABI break for the C/C++ bindings, and a silent mismatch for TypeScript code that inlined the old `const enum` values) -- **All SDKs:** time-condition orders (longbridge/developers#1289). `SubmitOrderOptions` and `SubmitMultiLegOrderOptions` gain `delayed_until`: the order is held in `DelayedNotReported` status and sent automatically at that time (later than now, at most 90 days ahead; multi-leg orders support only `LO` / `MO`). `ReplaceOrderOptions` gains `delayed_until` to move the time and `clear_delayed_until` to turn the order back into a regular one; leaving both unset keeps the current time condition. A time at or before `1970-01-01T00:00:01Z` would collide with the wire sentinels (`0` = no time condition, `1` = clear) and is sent as `2` instead, so the server rejects it as a past time rather than silently placing a regular order or clearing the condition; an out-of-range C timestamp is reported through the callback instead of aborting. `Order` and `OrderDetail` gain `delayed_until` (unset when the order is not a time-condition order), read from `today_orders` / `history_orders` / `order_detail`. Per layer: Rust `delayed_until(OffsetDateTime)` / `clear_delayed_until()` builder methods and `Option` fields; C `const int64_t *delayed_until` (Unix seconds) on `lb_submit_order_options_t` / `CSubmitMultiLegOrderOptions` / `lb_replace_order_options_t` (plus `bool clear_delayed_until`) and on `lb_order_t` / `lb_order_detail_t`, every new field appended at the end of its struct so existing field offsets are unchanged; C++ `std::optional delayed_until` (+ `bool clear_delayed_until` on `ReplaceOrderOptions`); Java `setDelayedUntil(OffsetDateTime)` / `ReplaceOrderOptions.clearDelayedUntil()` and `getDelayedUntil()`; Node.js `delayedUntil?: Date` / `clearDelayedUntil?: boolean` and `get delayedUntil(): Date | null`; Python `delayed_until: Optional[datetime]` / `clear_delayed_until: bool = False` keyword arguments (a timezone-aware `datetime` is converted by its actual offset, a naive one is read as UTC) and `Order.delayed_until` / `OrderDetail.delayed_until` +- **All SDKs:** time-condition orders (longbridge/developers#1289). `SubmitOrderOptions` and `SubmitMultiLegOrderOptions` gain `delayed_until`: the order is held in `DelayedNotReported` status and sent automatically at that time (later than now, at most 90 days ahead; multi-leg orders support only `LO` / `MO`). `ReplaceOrderOptions` gains `delayed_until` to move the time and `clear_delayed_until` to turn the order back into a regular one; leaving both unset keeps the current time condition. A time at or before `1970-01-01T00:00:01Z` would collide with the wire sentinels (`0` = no time condition, `1` = clear) and is sent as `2` instead, so the server rejects it as a past time rather than silently placing a regular order or clearing the condition; an out-of-range C timestamp is reported through the callback instead of aborting. `Order` and `OrderDetail` gain `delayed_until` (unset when the order is not a time-condition order), read from `today_orders` / `history_orders` / `order_detail`; a malformed or out-of-range value in this new field (or a JSON number instead of a string) reads as unset instead of failing the whole response. Rust: as with earlier additions, the new `OrderStatus` variant and `Order` / `OrderDetail` fields affect exhaustive `match`es and struct literals. Per layer: Rust `delayed_until(OffsetDateTime)` / `clear_delayed_until()` builder methods and `Option` fields; C `const int64_t *delayed_until` (Unix seconds) on `lb_submit_order_options_t` / `CSubmitMultiLegOrderOptions` / `lb_replace_order_options_t` (plus `bool clear_delayed_until`) and on `lb_order_t` / `lb_order_detail_t`, every new field appended at the end of its struct so existing field offsets are unchanged — the option structs grow, so C/C++ code must be rebuilt against the new header and should zero-initialize option structs (`= {0}`) so the new pointers are null; C++ `std::optional delayed_until` (+ `bool clear_delayed_until` on `ReplaceOrderOptions`); Java `setDelayedUntil(OffsetDateTime)` / `ReplaceOrderOptions.clearDelayedUntil()` and `getDelayedUntil()`; Node.js `delayedUntil?: Date` / `clearDelayedUntil?: boolean` and `get delayedUntil(): Date | null`; Python `delayed_until: Optional[datetime]` / `clear_delayed_until: bool = False` keyword arguments (a timezone-aware `datetime` is converted by its actual offset, a naive one is read as UTC) and `Order.delayed_until` / `OrderDetail.delayed_until` ## [5.2.0] - 2026-09-30 diff --git a/rust/src/serde_utils.rs b/rust/src/serde_utils.rs index 0af2d7ca4f..80b71ad2b4 100644 --- a/rust/src/serde_utils.rs +++ b/rust/src/serde_utils.rs @@ -169,6 +169,34 @@ pub(crate) mod timestamp_opt { } } +/// Lenient optional Unix-seconds timestamp for newly added response fields: +/// accepts a string or a number; `null`, empty, `0`, or a value that cannot be +/// parsed or is out of range all become `None` instead of failing the whole +/// response. +pub(crate) mod timestamp_opt_lenient { + use serde_json::Value; + + use super::*; + + pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let seconds = match Option::::deserialize(deserializer)? { + Some(Value::String(value)) => value.trim().parse::().ok(), + Some(Value::Number(value)) => value.as_i64(), + _ => None, + }; + let datetime = seconds + .filter(|seconds| *seconds != 0) + .and_then(|seconds| OffsetDateTime::from_unix_timestamp(seconds).ok()); + if seconds.is_some_and(|seconds| seconds != 0) && datetime.is_none() { + tracing::warn!(?seconds, "ignoring out-of-range timestamp"); + } + Ok(datetime) + } +} + pub(crate) mod rfc3339_opt { use super::*; diff --git a/rust/src/trade/types.rs b/rust/src/trade/types.rs index 262f5d7433..59a9e643a4 100644 --- a/rust/src/trade/types.rs +++ b/rust/src/trade/types.rs @@ -506,7 +506,7 @@ pub struct Order { /// time-condition order) #[serde( default, - deserialize_with = "serde_utils::timestamp_opt::deserialize", + deserialize_with = "serde_utils::timestamp_opt_lenient::deserialize", serialize_with = "serde_utils::rfc3339_opt::serialize" )] pub delayed_until: Option, @@ -715,7 +715,7 @@ pub struct OrderDetail { /// time-condition order) #[serde( default, - deserialize_with = "serde_utils::timestamp_opt::deserialize", + deserialize_with = "serde_utils::timestamp_opt_lenient::deserialize", serialize_with = "serde_utils::rfc3339_opt::serialize" )] pub delayed_until: Option, @@ -2232,5 +2232,21 @@ mod tests { value["delayed_until"] = serde_json::Value::Null; let detail = serde_json::from_value::(value).unwrap(); assert!(detail.delayed_until.is_none()); + + // A malformed value in this new field must not fail the whole response. + for (raw, expected) in [ + ( + serde_json::json!(1767225600), + Some(datetime!(2026-01-01 00:00 UTC)), + ), + (serde_json::json!("abc"), None), + (serde_json::json!("99999999999999"), None), + (serde_json::json!(""), None), + ] { + let mut value: serde_json::Value = serde_json::from_str(data).unwrap(); + value["delayed_until"] = raw.clone(); + let detail = serde_json::from_value::(value).unwrap(); + assert_eq!(detail.delayed_until, expected, "{raw}"); + } } } From 3af0fe3d3932cf299eb62367e8ac8052fd55b169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 14 Oct 2026 09:38:43 +0800 Subject: [PATCH 9/9] fix: drop negative delayed_until values and correct the CHANGELOG wording The lenient parser accepts JSON integers (the CHANGELOG said they read as unset); negative values now read as unset too. --- CHANGELOG.md | 2 +- rust/src/serde_utils.rs | 14 +++++++------- rust/src/trade/types.rs | 1 + 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25329f597f..253619298b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Known differences:** HTTP errors are `Error::HttpClient(OpenApi { .. })` rather than `Error::WsClient(ResponseError { .. })` (`Error::openapi_error_code()` / `into_simple_error()` cover both, but a non-JSON gateway error surfaces as `HttpClientError::UnexpectedHttpResponse` with no code); the REST gateway currently reports business errors (e.g. `301600`, `301607`) as `500`; the per-command rate limits the WebSocket applies from the quote profile are not applied on the HTTP path (the HTTP client retries `429` with back-off instead), and a candlestick top-up turns one call into up to 9 requests against `/quote/history-candlesticks`; during the US overnight session (20:00–04:00 New York) the REST gateway has already rolled over to the next trading day, so `intraday` for a US equity can be empty over HTTP (its lines are all overnight and dropped) and snapshot values of `quote`, `depth`, `calc_indexes` and `capital_flow` may reflect overnight trading that the WebSocket (without `enable_overnight`) does not report; `trades` may include overnight ticks during and shortly after the overnight session. The HTTP path has no client-side rate limiting. - **C / C++ / Java / Node.js / Python:** the `QuoteTransport` enum and the corresponding config option are exposed in every binding layer. - **All SDKs:** `OrderStatus` gains a `DelayedNotReported` variant (wire value `DelayedNotReported`) — "monitoring", the state a time-conditional order sits in before it is reported to the exchange. Previously this status deserialized to `OrderStatus::Unknown`, so time-conditional orders being monitored were indistinguishable from a genuinely unrecognized status in `today_orders` / `history_orders` / `order_detail` and the order-changed push. The variant is appended **after** `PartialWithdrawal` rather than inserted next to the other `*NotReported` values on purpose: the C/C++/Node.js enums use implicit discriminants, and inserting mid-list would renumber every following value (an ABI break for the C/C++ bindings, and a silent mismatch for TypeScript code that inlined the old `const enum` values) -- **All SDKs:** time-condition orders (longbridge/developers#1289). `SubmitOrderOptions` and `SubmitMultiLegOrderOptions` gain `delayed_until`: the order is held in `DelayedNotReported` status and sent automatically at that time (later than now, at most 90 days ahead; multi-leg orders support only `LO` / `MO`). `ReplaceOrderOptions` gains `delayed_until` to move the time and `clear_delayed_until` to turn the order back into a regular one; leaving both unset keeps the current time condition. A time at or before `1970-01-01T00:00:01Z` would collide with the wire sentinels (`0` = no time condition, `1` = clear) and is sent as `2` instead, so the server rejects it as a past time rather than silently placing a regular order or clearing the condition; an out-of-range C timestamp is reported through the callback instead of aborting. `Order` and `OrderDetail` gain `delayed_until` (unset when the order is not a time-condition order), read from `today_orders` / `history_orders` / `order_detail`; a malformed or out-of-range value in this new field (or a JSON number instead of a string) reads as unset instead of failing the whole response. Rust: as with earlier additions, the new `OrderStatus` variant and `Order` / `OrderDetail` fields affect exhaustive `match`es and struct literals. Per layer: Rust `delayed_until(OffsetDateTime)` / `clear_delayed_until()` builder methods and `Option` fields; C `const int64_t *delayed_until` (Unix seconds) on `lb_submit_order_options_t` / `CSubmitMultiLegOrderOptions` / `lb_replace_order_options_t` (plus `bool clear_delayed_until`) and on `lb_order_t` / `lb_order_detail_t`, every new field appended at the end of its struct so existing field offsets are unchanged — the option structs grow, so C/C++ code must be rebuilt against the new header and should zero-initialize option structs (`= {0}`) so the new pointers are null; C++ `std::optional delayed_until` (+ `bool clear_delayed_until` on `ReplaceOrderOptions`); Java `setDelayedUntil(OffsetDateTime)` / `ReplaceOrderOptions.clearDelayedUntil()` and `getDelayedUntil()`; Node.js `delayedUntil?: Date` / `clearDelayedUntil?: boolean` and `get delayedUntil(): Date | null`; Python `delayed_until: Optional[datetime]` / `clear_delayed_until: bool = False` keyword arguments (a timezone-aware `datetime` is converted by its actual offset, a naive one is read as UTC) and `Order.delayed_until` / `OrderDetail.delayed_until` +- **All SDKs:** time-condition orders (longbridge/developers#1289). `SubmitOrderOptions` and `SubmitMultiLegOrderOptions` gain `delayed_until`: the order is held in `DelayedNotReported` status and sent automatically at that time (later than now, at most 90 days ahead; multi-leg orders support only `LO` / `MO`). `ReplaceOrderOptions` gains `delayed_until` to move the time and `clear_delayed_until` to turn the order back into a regular one; leaving both unset keeps the current time condition. A time at or before `1970-01-01T00:00:01Z` would collide with the wire sentinels (`0` = no time condition, `1` = clear) and is sent as `2` instead, so the server rejects it as a past time rather than silently placing a regular order or clearing the condition; an out-of-range C timestamp is reported through the callback instead of aborting. `Order` and `OrderDetail` gain `delayed_until` (unset when the order is not a time-condition order), read from `today_orders` / `history_orders` / `order_detail`; the new field also accepts a JSON integer as well as a string, and a malformed or out-of-range value (non-numeric string, float, negative, beyond year 9999) reads as unset instead of failing the whole response. Rust: as with earlier additions, the new `OrderStatus` variant and `Order` / `OrderDetail` fields affect exhaustive `match`es and struct literals. Per layer: Rust `delayed_until(OffsetDateTime)` / `clear_delayed_until()` builder methods and `Option` fields; C `const int64_t *delayed_until` (Unix seconds) on `lb_submit_order_options_t` / `CSubmitMultiLegOrderOptions` / `lb_replace_order_options_t` (plus `bool clear_delayed_until`) and on `lb_order_t` / `lb_order_detail_t`, every new field appended at the end of its struct so existing field offsets are unchanged — the option structs grow, so C/C++ code must be rebuilt against the new header and should zero-initialize option structs (`= {0}`) so the new pointers are null; C++ `std::optional delayed_until` (+ `bool clear_delayed_until` on `ReplaceOrderOptions`); Java `setDelayedUntil(OffsetDateTime)` / `ReplaceOrderOptions.clearDelayedUntil()` and `getDelayedUntil()`; Node.js `delayedUntil?: Date` / `clearDelayedUntil?: boolean` and `get delayedUntil(): Date | null`; Python `delayed_until: Optional[datetime]` / `clear_delayed_until: bool = False` keyword arguments (a timezone-aware `datetime` is converted by its actual offset, a naive one is read as UTC) and `Order.delayed_until` / `OrderDetail.delayed_until` ## [5.2.0] - 2026-09-30 diff --git a/rust/src/serde_utils.rs b/rust/src/serde_utils.rs index 80b71ad2b4..38443e184d 100644 --- a/rust/src/serde_utils.rs +++ b/rust/src/serde_utils.rs @@ -170,9 +170,9 @@ pub(crate) mod timestamp_opt { } /// Lenient optional Unix-seconds timestamp for newly added response fields: -/// accepts a string or a number; `null`, empty, `0`, or a value that cannot be -/// parsed or is out of range all become `None` instead of failing the whole -/// response. +/// accepts a string or an integer; `null`, empty, `0`, a negative value, or a +/// value that cannot be parsed or is out of range all become `None` instead of +/// failing the whole response. pub(crate) mod timestamp_opt_lenient { use serde_json::Value; @@ -187,10 +187,10 @@ pub(crate) mod timestamp_opt_lenient { Some(Value::Number(value)) => value.as_i64(), _ => None, }; - let datetime = seconds - .filter(|seconds| *seconds != 0) - .and_then(|seconds| OffsetDateTime::from_unix_timestamp(seconds).ok()); - if seconds.is_some_and(|seconds| seconds != 0) && datetime.is_none() { + let seconds = seconds.filter(|seconds| *seconds > 0); + let datetime = + seconds.and_then(|seconds| OffsetDateTime::from_unix_timestamp(seconds).ok()); + if seconds.is_some() && datetime.is_none() { tracing::warn!(?seconds, "ignoring out-of-range timestamp"); } Ok(datetime) diff --git a/rust/src/trade/types.rs b/rust/src/trade/types.rs index 59a9e643a4..c6ca8a0a25 100644 --- a/rust/src/trade/types.rs +++ b/rust/src/trade/types.rs @@ -2242,6 +2242,7 @@ mod tests { (serde_json::json!("abc"), None), (serde_json::json!("99999999999999"), None), (serde_json::json!(""), None), + (serde_json::json!("-5"), None), ] { let mut value: serde_json::Value = serde_json::from_str(data).unwrap(); value["delayed_until"] = raw.clone();