diff --git a/CHANGELOG.md b/CHANGELOG.md index a019891cb..253619298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **US overnight alignment:** the WebSocket only returns US overnight data when `Config::enable_overnight` is set, while REST always does. On the HTTP path (and only there) the SDK applies the WebSocket's rule for US equities: `quote().overnight_quote` is cleared, overnight `intraday` lines are dropped, and for `TradeSessions::All` candlestick queries overnight bars are dropped and count/range-capped windows are topped up with `POST /quote/history-candlesticks` offset queries (≤1000 bars per request, ≤8 extra requests; a failure in a top-up request fails the call). `trades` and `calc_indexes` are passed through unchanged. - **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`; 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/c/csrc/include/longbridge.h b/c/csrc/include/longbridge.h index 6c0e1590a..c0ff5ecc3 100644 --- a/c/csrc/include/longbridge.h +++ b/c/csrc/include/longbridge.h @@ -1291,6 +1291,10 @@ typedef enum lb_order_status_t { * Partial Withdrawal */ OrderStatusPartialWithdrawal, + /** + * Monitoring (Time-Conditional Order) + */ + OrderStatusDelayedNotReported, } lb_order_status_t; /** @@ -4694,6 +4698,17 @@ 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; 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; + /** + * 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; /** @@ -4817,6 +4832,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, which must be later than now and no more than 90 days ahead. + */ + const int64_t *delayed_until; } lb_submit_order_options_t; /** @@ -4879,6 +4900,12 @@ 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); + * must be later than now and no more than 90 days ahead. Only `LO` / `MO` + * support time conditions. + */ + const int64_t *delayed_until; } CSubmitMultiLegOrderOptions; /** @@ -5943,6 +5970,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; /** @@ -6579,6 +6610,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 897021a93..2a222ab46 100644 --- a/c/src/trade_context/context.rs +++ b/c/src/trade_context/context.rs @@ -472,6 +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)); } + let delayed_until = if (*opts).clear_delayed_until { + opts2 = opts2.clear_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(); @@ -521,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.map_err(delayed_until_error)? { + opts2 = opts2.delayed_until(delayed_until); + } ctx_inner.replace_order(opts2).await?; Ok(()) }); @@ -585,6 +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)); } + 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(); @@ -616,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.map_err(delayed_until_error)? { + opts2 = opts2.delayed_until(delayed_until); + } let resp: CCow = CCow::new(ctx_inner.submit_order(opts2).await?); Ok(resp) }); @@ -655,7 +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)); } + let delayed_until = parse_delayed_until((*opts).delayed_until); execute_async(callback, ctx, userdata, async move { + 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_multileg(opts2).await?); Ok(resp) @@ -873,3 +890,21 @@ 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, +) -> Result, time::error::ComponentRange> { + if value.is_null() { + return Ok(None); + } + 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/enum_types.rs b/c/src/trade_context/enum_types.rs index 8ab96821a..5f6030499 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/c/src/trade_context/types.rs b/c/src/trade_context/types.rs index eb14ae2bf..92c789fd4 100644 --- a/c/src/trade_context/types.rs +++ b/c/src/trade_context/types.rs @@ -949,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)] @@ -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,13 @@ 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; 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`) + pub clear_delayed_until: bool, } /// Options for submit order request @@ -1293,6 +1310,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, which must be later than now and no more than 90 days ahead. + pub delayed_until: *const i64, } /// Response for submit order request @@ -1361,6 +1382,10 @@ 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); + /// must be later than now and no more than 90 days ahead. Only `LO` / `MO` + /// support time conditions. + pub delayed_until: *const i64, } /// Account balance @@ -2401,6 +2426,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)] @@ -2434,6 +2461,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 +2509,7 @@ impl From for COrderDetailOwned { trigger_count, monitor_price, remark, + delayed_until, free_status, free_amount, free_currency, @@ -2525,6 +2554,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 +2606,7 @@ impl ToFFI for COrderDetailOwned { trigger_count, monitor_price, remark, + delayed_until, free_status, free_amount, free_currency, @@ -2665,6 +2696,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 8959eeffe..5ecea2cd7 100644 --- a/cpp/include/types.hpp +++ b/cpp/include/types.hpp @@ -1527,6 +1527,8 @@ enum class OrderStatus Expired, /// Partial Withdrawal PartialWithdrawal, + /// Monitoring (Time-Conditional Order) + DelayedNotReported, }; /// Order type @@ -1831,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 @@ -1957,6 +1962,13 @@ struct ReplaceOrderOptions std::optional remark; /// Attached order params std::optional attached_params; + /// 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`) + bool clear_delayed_until = false; }; /// Options for submit order request @@ -2001,6 +2013,10 @@ 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, which + /// must be later than now and no more than 90 days ahead. + std::optional delayed_until; }; /// Response for submit order request @@ -2043,6 +2059,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), later than now + /// and no more than 90 days ahead. Only `LO` / `MO` support time conditions. + std::optional delayed_until; }; /// Cash info @@ -2397,6 +2416,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 1d28123c5..1ebffb224 100644 --- a/cpp/src/convert.hpp +++ b/cpp/src/convert.hpp @@ -1143,6 +1143,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"); } @@ -1188,6 +1190,8 @@ convert(OrderStatus status) return OrderStatusExpired; case OrderStatus::PartialWithdrawal: return OrderStatusPartialWithdrawal; + case OrderStatus::DelayedNotReported: + return OrderStatusDelayedNotReported; default: throw std::invalid_argument("unreachable"); } @@ -1734,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, @@ -2167,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 b568242ea..18f0aec68 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 7628e287d..105abe2a5 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 56609805d..cb8d0fde1 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/OrderStatus.java b/java/javasrc/src/main/java/com/longbridge/trade/OrderStatus.java index 8813cc4ae..7fbeae1f6 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/javasrc/src/main/java/com/longbridge/trade/ReplaceOrderOptions.java b/java/javasrc/src/main/java/com/longbridge/trade/ReplaceOrderOptions.java index cbd5d7863..21226224d 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 2468ec9a8..6999f9999 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 027b26015..50a5d4a80 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 9e75f01b1..ef4be8386 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 0128dbd63..b9dfe54c3 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/java/src/types/enum_types.rs b/java/src/types/enum_types.rs index 7089eccdb..f9328dbc0 100644 --- a/java/src/types/enum_types.rs +++ b/java/src/types/enum_types.rs @@ -399,6 +399,7 @@ impl_java_enum!( Canceled, Expired, PartialWithdrawal, + DelayedNotReported, ] ); diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts index 9ea50edaa..5464597de 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 */ @@ -7518,7 +7528,9 @@ export declare const enum OrderStatus { /** Expired */ Expired = 16, /** Partial Withdrawal */ - PartialWithdrawal = 17 + PartialWithdrawal = 17, + /** Monitoring (Time-Conditional Order) */ + DelayedNotReported = 18 } /** Order tag */ @@ -8153,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 `delayedUntil`) + */ + clearDelayedUntil?: boolean } /** A filter condition for screener_search Mode B. */ @@ -8673,6 +8695,13 @@ 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, which must be later than now + * and no more than 90 days ahead. Only `LO` / `MO` support time + * conditions. + */ + delayedUntil?: Date } /** Options for submit order request */ @@ -8720,6 +8749,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/index.js b/nodejs/index.js index b5d3f4297..61b6f820e 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`) } diff --git a/nodejs/src/trade/context.rs b/nodejs/src/trade/context.rs index dd791569d..2b3bb169c 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 4163c06ba..1f3755afb 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::try_from_datetime, }; /// Parameters for replacing an attached order @@ -116,11 +118,18 @@ 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 `delayedUntil`) + 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); @@ -152,6 +161,11 @@ impl<'env> From> for longbridge::trade::ReplaceOrderOp if let Some(p) = opts.attached_params { opts2 = opts2.attached_params(p.into()); } - opts2 + 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(try_from_datetime("delayedUntil", delayed_until)?); + } + Ok(opts2) } } diff --git a/nodejs/src/trade/requests/submit_multileg.rs b/nodejs/src/trade/requests/submit_multileg.rs index 535c5889a..562d75d95 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::try_from_datetime, }; /// A leg of a multi-leg combination order to submit @@ -38,13 +40,19 @@ 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, which must be later than now + /// and no more than 90 days ahead. Only `LO` / `MO` support time + /// conditions. + 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) }); @@ -64,6 +72,9 @@ impl<'env> From> if let Some(id) = opts.client_request_id { opts2 = opts2.client_request_id(id); } - opts2 + if let Some(delayed_until) = opts.delayed_until { + opts2 = opts2.delayed_until(try_from_datetime("delayedUntil", delayed_until)?); + } + Ok(opts2) } } diff --git a/nodejs/src/trade/requests/submit_order.rs b/nodejs/src/trade/requests/submit_order.rs index bddab1092..c40ab568c 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::try_from_datetime, }; /// Parameters for submitting an attached order @@ -102,11 +104,16 @@ 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 { - #[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(), @@ -153,6 +160,9 @@ impl<'env> From> for longbridge::trade::SubmitOrderOpti if let Some(p) = opts.attached_params { opts2 = opts2.attached_params(p.into()); } - opts2 + if let Some(delayed_until) = opts.delayed_until { + opts2 = opts2.delayed_until(try_from_datetime("delayedUntil", delayed_until)?); + } + Ok(opts2) } } diff --git a/nodejs/src/trade/types.rs b/nodejs/src/trade/types.rs index 87739a385..37d926a46 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] @@ -443,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, @@ -641,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/nodejs/src/utils.rs b/nodejs/src/utils.rs index a1530a464..fea8cc3e0 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/pysrc/longbridge/openapi.pyi b/python/pysrc/longbridge/openapi.pyi index 463a16ed5..722f570f2 100644 --- a/python/pysrc/longbridge/openapi.pyi +++ b/python/pysrc/longbridge/openapi.pyi @@ -5814,6 +5814,11 @@ class OrderStatus: PartialWithdrawal """ + class DelayedNotReported(OrderStatus): + """ + Monitoring (Time-Conditional Order) + """ + class OrderTag: """ Order tag @@ -6654,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 @@ -6990,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 @@ -7675,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 @@ -7690,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. 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: :: @@ -7730,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 @@ -7753,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). 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 @@ -7791,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, @@ -7808,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 (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 @@ -11568,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. @@ -11585,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. 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: :: @@ -11629,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. @@ -11652,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). 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: :: @@ -11697,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, @@ -11715,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 (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: :: diff --git a/python/src/time.rs b/python/src/time.rs index ebeb7800f..f92dba118 100644 --- a/python/src/time.rs +++ b/python/src/time.rs @@ -55,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 5629c42d2..9a93f2b4d 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::{ @@ -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 962aa8794..50504b465 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, @@ -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 524db71a3..2b80162d0 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)] @@ -568,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, @@ -766,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/serde_utils.rs b/rust/src/serde_utils.rs index 0af2d7ca4..38443e184 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 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; + + 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 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) + } +} + pub(crate) mod rfc3339_opt { use super::*; diff --git a/rust/src/trade/requests/mod.rs b/rust/src/trade/requests/mod.rs index 0563ca4dd..bdeee789b 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 f6a72eb05..4261f9a4b 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,33 @@ 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. 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(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 { + Self { + delayed_until: Some(1), + ..self + } + } + /// Set attached order parameters pub fn attached_params(self, params: ReplaceAttachedParams) -> Self { Self { @@ -303,3 +334,47 @@ 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.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"], + 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) + ); + } +} diff --git a/rust/src/trade/requests/submit_multileg.rs b/rust/src/trade/requests/submit_multileg.rs index 8009825c7..f8ded9c3d 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,48 @@ 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(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 cc82bd09d..37b8c2252 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(super::delayed_until_timestamp(delayed_until)), + ..self + } + } + /// Set attached order parameters pub fn attached_params(self, params: SubmitAttachedParams) -> Self { Self { @@ -297,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 72ee15398..c6ca8a0a2 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 @@ -499,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_lenient::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, @@ -700,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_lenient::deserialize", + serialize_with = "serde_utils::rfc3339_opt::serialize" + )] + pub delayed_until: Option, /// Commission-free Status pub free_status: CommissionFreeStatus, /// Commission-free amount @@ -1963,7 +1982,8 @@ mod tests { "limit_depth_level": 0, "trigger_count": 0, "monitor_price": "", - "remark": "abc" + "remark": "abc", + "delayed_until": "1767225600" } ] } @@ -2004,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] @@ -2081,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] @@ -2153,6 +2175,7 @@ mod tests { "trigger_count": 0, "monitor_price": "", "remark": "1680863603.927165", + "delayed_until": "0", "free_status": "None", "free_amount": "", "free_currency": "", @@ -2191,6 +2214,40 @@ 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))); + + // 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()); + + // 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), + (serde_json::json!("-5"), 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}"); + } } }