Skip to main content

futucli/cmd/trade_ext/
modify_reconcile.rs

1use std::future::Future;
2use std::time::Duration;
3
4use futu_domain_trade_write::{
5    ModifyOrderEffectiveFields,
6    effective_modify_order_fields_like_cpp as effective_fields_from_domain,
7    modify_order_uses_futures_price_precision_like_cpp,
8};
9use futu_trd::query::Order;
10use futu_trd::types::{ModifyOrderOp, TrdMarket};
11use tokio::time::{Instant, sleep_until, timeout_at};
12const MODIFY_RECONCILE_ATTEMPTS: usize = 3;
13const MODIFY_RECONCILE_DELAY: Duration = Duration::from_millis(350);
14const MODIFY_RECONCILE_DEADLINE: Duration = Duration::from_millis(2_500);
15
16#[derive(Debug, Clone)]
17pub(super) struct ModifyOrderReconcileExpectation {
18    input_order_id: u64,
19    order_id_ex: String,
20    returned_order_id: u64,
21    fields: ModifyOrderEffectiveFields,
22    qty_scale: i64,
23    price_scale: i64,
24}
25
26impl ModifyOrderReconcileExpectation {
27    pub(super) fn new(
28        input_order_id: u64,
29        order_id_ex: impl Into<String>,
30        returned_order_id: u64,
31        market: TrdMarket,
32        qty: Option<f64>,
33        price: Option<f64>,
34    ) -> Self {
35        let uses_futures_price_precision =
36            modify_order_uses_futures_price_precision_like_cpp(Some(market as i32), 0);
37        let fields = effective_modify_order_fields(market, qty, price);
38        Self {
39            input_order_id,
40            order_id_ex: order_id_ex.into(),
41            returned_order_id,
42            fields,
43            qty_scale: if market == TrdMarket::Prediction {
44                100
45            } else {
46                1
47            },
48            price_scale: if uses_futures_price_precision {
49                1_000_000_000
50            } else {
51                10_000
52            },
53        }
54    }
55
56    fn matches(&self, order: &Order) -> bool {
57        (self.input_order_id != 0 && order.order_id == self.input_order_id)
58            || (self.returned_order_id != 0 && order.order_id == self.returned_order_id)
59            || (!self.order_id_ex.is_empty() && order.order_id_ex == self.order_id_ex)
60    }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub(super) enum ModifyOrderObservation {
65    Applied,
66    NotApplied { detail: String },
67    OrderNotVisible,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub(super) enum ModifyOrderReconcileOutcome {
72    Applied,
73    NotApplied { detail: String },
74    OrderNotVisible,
75    RefreshFailed,
76}
77
78impl ModifyOrderReconcileOutcome {
79    pub(super) fn label(&self) -> &'static str {
80        match self {
81            Self::Applied => "applied",
82            Self::NotApplied { .. } => "not_applied",
83            Self::OrderNotVisible => "order_not_visible",
84            Self::RefreshFailed => "refresh_failed",
85        }
86    }
87
88    pub(super) fn detail(&self) -> Option<&str> {
89        match self {
90            Self::NotApplied { detail } => Some(detail),
91            _ => None,
92        }
93    }
94
95    pub(super) fn failure_message(&self) -> Option<String> {
96        if self == &Self::Applied {
97            return None;
98        }
99        let detail = self
100            .detail()
101            .map(|detail| format!(" detail={detail}"))
102            .unwrap_or_default();
103        Some(format!(
104            "reconcile_required outcome={}{}",
105            self.label(),
106            detail
107        ))
108    }
109}
110
111#[derive(Debug, Clone, Copy)]
112pub(super) struct ModifyOrderReconcilePolicy {
113    attempts: usize,
114    delay: Duration,
115    deadline: Duration,
116}
117
118impl ModifyOrderReconcilePolicy {
119    pub(super) fn cli_default() -> Self {
120        Self {
121            attempts: MODIFY_RECONCILE_ATTEMPTS,
122            delay: MODIFY_RECONCILE_DELAY,
123            deadline: MODIFY_RECONCILE_DEADLINE,
124        }
125    }
126
127    #[cfg(test)]
128    pub(super) fn for_tests(attempts: usize) -> Self {
129        Self {
130            attempts,
131            delay: Duration::ZERO,
132            deadline: Duration::from_secs(1),
133        }
134    }
135}
136
137/// Computes the values that C++ actually sends to the real backend.
138///
139/// Ref: `APIServer_Trd_ModifyOrder.cpp:324-343` first normalizes API qty/price;
140/// `NNProto_Trd_OrderOpReal.cpp:543-569` then emits non-futures prices with four
141/// decimals, futures with nine, Prediction qty with two, and ordinary qty as i64.
142pub(super) fn effective_modify_order_fields(
143    market: TrdMarket,
144    qty: Option<f64>,
145    price: Option<f64>,
146) -> ModifyOrderEffectiveFields {
147    effective_fields_from_domain(
148        qty,
149        price,
150        market == TrdMarket::Prediction,
151        modify_order_uses_futures_price_precision_like_cpp(Some(market as i32), 0),
152    )
153}
154
155fn same_scaled_value(actual: f64, expected: f64, scale: i64) -> bool {
156    (actual * scale as f64).round() as i64 == (expected * scale as f64).round() as i64
157}
158
159pub(super) fn observe_modify_order(
160    orders: &[Order],
161    expected: &ModifyOrderReconcileExpectation,
162) -> ModifyOrderObservation {
163    let Some(order) = orders.iter().find(|order| expected.matches(order)) else {
164        return ModifyOrderObservation::OrderNotVisible;
165    };
166
167    let qty_matches = expected
168        .fields
169        .qty
170        .is_none_or(|qty| same_scaled_value(order.qty, qty, expected.qty_scale));
171    let price_matches = expected
172        .fields
173        .price
174        .is_none_or(|price| same_scaled_value(order.price, price, expected.price_scale));
175    if qty_matches && price_matches {
176        return ModifyOrderObservation::Applied;
177    }
178
179    let mut differences = Vec::new();
180    if !qty_matches {
181        differences.push(format!(
182            "qty expected={:?} actual={}",
183            expected.fields.qty, order.qty
184        ));
185    }
186    if !price_matches {
187        differences.push(format!(
188            "price expected={:?} actual={}",
189            expected.fields.price, order.price
190        ));
191    }
192    // `last_err_msg` is retained only as a diagnostic. It never independently
193    // decides success/failure because C++ can retain text from an earlier op.
194    if !order.last_err_msg.is_empty() {
195        differences.push(format!("last_err_msg={:?}", order.last_err_msg));
196    }
197    ModifyOrderObservation::NotApplied {
198        detail: differences.join("; "),
199    }
200}
201
202pub(super) fn should_reconcile_modify_order(op: ModifyOrderOp) -> bool {
203    op == ModifyOrderOp::Normal
204}
205
206pub(super) async fn reconcile_modify_order_with<Refresh, RefreshFuture>(
207    policy: ModifyOrderReconcilePolicy,
208    expected: &ModifyOrderReconcileExpectation,
209    mut refresh: Refresh,
210) -> ModifyOrderReconcileOutcome
211where
212    Refresh: FnMut(Duration) -> RefreshFuture,
213    RefreshFuture: Future<Output = Result<Vec<Order>, String>>,
214{
215    let deadline = Instant::now() + policy.deadline;
216    let mut last_successful_observation = None;
217
218    for _ in 0..policy.attempts {
219        let wake_at = Instant::now() + policy.delay;
220        if timeout_at(deadline, sleep_until(wake_at)).await.is_err() {
221            break;
222        }
223        let remaining = deadline.saturating_duration_since(Instant::now());
224        if remaining.is_zero() {
225            break;
226        }
227        // The refresh implementation owns its timeout using `remaining`. Do not
228        // wrap it in another timeout: dropping `FutuClient::request_with_timeout`
229        // at the same deadline would bypass its pending-map cleanup.
230        let result = refresh(remaining).await;
231        let Ok(orders) = result else {
232            continue;
233        };
234        let observation = observe_modify_order(&orders, expected);
235        if observation == ModifyOrderObservation::Applied {
236            return ModifyOrderReconcileOutcome::Applied;
237        }
238        last_successful_observation = Some(observation);
239    }
240
241    match last_successful_observation {
242        Some(ModifyOrderObservation::NotApplied { detail }) => {
243            ModifyOrderReconcileOutcome::NotApplied { detail }
244        }
245        Some(ModifyOrderObservation::OrderNotVisible) => {
246            ModifyOrderReconcileOutcome::OrderNotVisible
247        }
248        Some(ModifyOrderObservation::Applied) => ModifyOrderReconcileOutcome::Applied,
249        None => ModifyOrderReconcileOutcome::RefreshFailed,
250    }
251}