rustrade-execution 0.20.0

Stream private account data from financial venues, and execute (live or mock) orders.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
use crate::order::{
    id::{ClientOrderId, OrderId, StrategyId},
    request::ReduceOnly,
};
use chrono::{DateTime, Utc};
use derive_more::{Constructor, From};
use rust_decimal::Decimal;
use rustrade_instrument::{
    Side,
    asset::QuoteAsset,
    instrument::{Instrument, kind::InstrumentKind, round_toward_zero_to_step},
};
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
use std::fmt::{Display, Formatter};

/// A venue's own identifier for one trade, carried through opaquely.
///
/// # Uniqueness is the venue's to define, and is often narrower than global
///
/// This library does not mint these — it passes through whatever the venue said, so what an id
/// distinguishes is whatever that venue distinguishes. Binance's are **per symbol**, which is why
/// this crate's own deduplication keys on the instrument alongside the id rather than on the id
/// alone; Hyperliquid reports a transaction hash that can cover several matches at once. A
/// consumer that needs a key should use `(exchange, instrument, TradeId)` and not assume any part
/// of it is redundant.
///
/// [`SimulatedVenue`](crate::exchange::mock::SimulatedVenue) mints ids unique within one venue
/// instance for its lifetime — and, because each venue counts from zero, **not** across the
/// several a multi-exchange backtest holds. That is the same scoping a real venue gives, for the
/// same reason.
///
/// # `Ord` is byte order, not time order
///
/// The derived comparison is lexicographic over the underlying string, so `"10" < "9"`. It exists
/// so this type can key a map or a set; it carries no chronological meaning and is not a stand-in
/// for one. Order trades by [`Trade::time_exchange`].
///
/// # Finding the order behind a trade
///
/// Use [`Trade::order_id`], which is typed. Do **not** parse this id: one order can print more
/// than once, several venues embed structure of their own here (IBKR's `execution_id` is
/// dot-separated, Alpaca's can be a UUID), and no format is common to them.
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, From)]
pub struct TradeId<T = SmolStr>(pub T);

impl<T> Display for TradeId<T>
where
    T: Display,
{
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        Display::fmt(&self.0, f)
    }
}

impl TradeId {
    pub fn new<S: AsRef<str>>(id: S) -> Self {
        Self(SmolStr::new(id))
    }
}

/// One execution: a fill the venue reported, or one the engine settled itself.
///
/// `#[non_exhaustive]`, so a field can be added without breaking a caller. Outside this crate,
/// build one with [`Trade::new`] and the `with_*` methods, and change a copy by assigning its
/// public fields.
#[non_exhaustive]
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
pub struct Trade<AssetKey, InstrumentKey> {
    pub id: TradeId,
    pub order_id: OrderId,
    pub instrument: InstrumentKey,
    pub strategy: StrategyId,
    /// When the fill happened, as the venue stamped it, unless
    /// [`time_source`](Self::time_source) says this host's clock filled it in.
    pub time_exchange: DateTime<Utc>,
    pub side: Side,
    pub price: Decimal,
    /// The size of *this* execution.
    pub quantity: Decimal,
    /// The order's cumulative filled quantity as of this execution, as the venue reported it --
    /// **not** the size of this execution, which is [`Trade::quantity`].
    ///
    /// A fill carries two facts: that an execution happened, and what it left the order at. A
    /// consumer that tracks order state needs both, and they arrive together or not at all. Where
    /// the venue supplies the second, carrying it here lets the order be advanced from the fill
    /// itself rather than from a separate message that may never come.
    ///
    /// Advancing from a cumulative is idempotent: re-applying the same fill, or applying two out
    /// of order, cannot double-count, because the receiver takes the greater of what it holds and
    /// what is reported rather than adding to a running total.
    ///
    /// `None` means the venue's fill payload does not carry it, and the order's state must be
    /// learned some other way -- an order snapshot, or a reconciliation fetch. It is not a claim
    /// that nothing is filled. Known producers, as of writing:
    ///
    /// | Source | Reports it |
    /// |---|---|
    /// | Binance Spot / Margin WebSocket `executionReport` | yes (`z`) |
    /// | Binance Spot / Margin fill recovery after a disconnect | yes, rebuilt from the order's executions; `None` where that lookup failed or ran out of time |
    /// | Binance Spot / Margin `fetch_trades` (`myTrades` REST) | no |
    /// | Alpaca WebSocket `trade_updates` | yes (`order.filled_qty`) |
    /// | Alpaca account-activities REST | yes (`cum_qty`) |
    /// | Interactive Brokers `ExecutionData` | yes (`cumulative_quantity`) |
    /// | Hyperliquid `userFills` | no |
    /// | `SimulatedVenue` | yes |
    ///
    /// `#[serde(default)]` so trades serialised before this field existed still deserialise, as
    /// `None`.
    #[serde(default)]
    pub order_filled_quantity: Option<Decimal>,
    pub fees: AssetFees<AssetKey>,
    /// Why the fill happened, as far as its producer can tell. See [`TradeOrigin`] for what each
    /// venue reports.
    ///
    /// `#[serde(default)]` so trades serialised before this field existed still deserialise, as
    /// [`TradeOrigin::Order`].
    #[serde(default)]
    pub origin: TradeOrigin,
    /// The side of the position the fill applies to, when the venue names one: [`Side::Buy`] for
    /// a long, [`Side::Sell`] for a short.
    ///
    /// A venue in hedge mode, holding a long and a short in one instrument at once, names the
    /// position each fill belongs to (Binance USD-M futures' `LONG` and `SHORT`). The engine in
    /// `OmsMode::Hedging` checks a forced fill, which matches none of its orders, against it: a
    /// forced fill must reduce the position the venue names. It only ever confirms the side a
    /// reducing fill already implies, the side opposite its own, and does not choose among
    /// several of the engine's positions on that side.
    ///
    /// It takes part in the derived `Eq`, `Hash` and `Ord`, as every field does: deduplicate fills
    /// by instrument and [`id`](Self::id), not by the whole `Trade`.
    /// `None` when the venue names none, as every client in this crate does as of writing: none
    /// trades a venue in hedge mode.
    ///
    /// `#[serde(default)]` so trades serialised before this field existed still deserialise, as
    /// `None`.
    #[serde(default)]
    pub position_side: Option<Side>,
    /// The client order id of the order this fill belongs to, as the venue reported it with the
    /// fill.
    ///
    /// The id is chosen before the order is sent, so a consumer can attribute a fill by it at
    /// once. That includes a fill of an order placed outside the engine on the same account,
    /// which no order the engine knows matches: [`order_id`](Self::order_id) is the venue's, and
    /// the fill can arrive before the venue's response to the order names it, or long after, in a
    /// recovery read. A consumer that trades an account outside the engine can mark those orders'
    /// client order ids, by a prefix say, and keep their fills from the engine.
    ///
    /// `None` means the fill's payload did not carry one. It is not a claim that the order has
    /// none. Known producers, as of writing:
    ///
    /// | Source | Reports it |
    /// |---|---|
    /// | Binance Spot / Margin WebSocket `executionReport` | yes (`c`) |
    /// | Binance Spot / Margin fill recovery after a disconnect | yes, read from each recovered fill's order; `None` where that lookup failed or ran out of time |
    /// | Binance Spot / Margin `fetch_trades` (`myTrades` REST) | no |
    /// | Alpaca WebSocket `trade_updates` | yes (`order.client_order_id`) |
    /// | Alpaca account-activities REST (fill recovery, `fetch_trades`), `fetch_option_events` | no |
    /// | Interactive Brokers executions | yes, the order reference, which this crate's client sets to the client order id; `None` for an order without one |
    /// | Hyperliquid `userFills` | yes, the client order id the order's cloid maps to; `None` for an order placed without a cloid |
    /// | `SimulatedVenue` | yes |
    /// | The `rustrade` engine's expiry settlement | no |
    ///
    /// `#[serde(default)]` so trades serialised before this field existed still deserialise, as
    /// `None`.
    #[serde(default)]
    pub client_order_id: Option<ClientOrderId>,
    /// Whose clock [`time_exchange`](Self::time_exchange) was read from. See [`TimeSource`] for
    /// when a producer fills in this host's.
    ///
    /// `#[serde(default)]` so trades serialised before this field existed still deserialise, as
    /// [`TimeSource::Venue`].
    #[serde(default)]
    pub time_source: TimeSource,
}

impl<AssetKey, InstrumentKey> Trade<AssetKey, InstrumentKey> {
    /// A trade of [`TradeOrigin::Order`]. Set another origin with
    /// [`with_origin`](Self::with_origin).
    #[allow(clippy::too_many_arguments)] // One argument per field a fill always has.
    pub fn new(
        id: TradeId,
        order_id: OrderId,
        instrument: InstrumentKey,
        strategy: StrategyId,
        time_exchange: DateTime<Utc>,
        side: Side,
        price: Decimal,
        quantity: Decimal,
        order_filled_quantity: Option<Decimal>,
        fees: AssetFees<AssetKey>,
    ) -> Self {
        Self {
            id,
            order_id,
            instrument,
            strategy,
            time_exchange,
            side,
            price,
            quantity,
            order_filled_quantity,
            fees,
            origin: TradeOrigin::Order,
            position_side: None,
            client_order_id: None,
            time_source: TimeSource::Venue,
        }
    }

    /// This trade with its [`origin`](Self::origin) set.
    #[must_use]
    pub fn with_origin(self, origin: TradeOrigin) -> Self {
        Self { origin, ..self }
    }

    /// This trade with the [`position_side`](Self::position_side) the venue named.
    #[must_use]
    pub fn with_position_side(self, position_side: Side) -> Self {
        Self {
            position_side: Some(position_side),
            ..self
        }
    }

    /// This trade with the [`client_order_id`](Self::client_order_id) the venue reported.
    #[must_use]
    pub fn with_client_order_id(self, client_order_id: ClientOrderId) -> Self {
        Self {
            client_order_id: Some(client_order_id),
            ..self
        }
    }

    /// This trade with its [`time_source`](Self::time_source) set.
    #[must_use]
    pub fn with_time_source(self, time_source: TimeSource) -> Self {
        Self {
            time_source,
            ..self
        }
    }

    pub fn value_quote(&self) -> Decimal {
        self.price * self.quantity.abs()
    }

    /// How this fill moves the position it applies to, when that is not by its own
    /// [`side`](Self::side) and [`quantity`](Self::quantity): a spot fill whose fee the venue
    /// charged in `instrument`'s base asset, or a `reduce_only` fill that would grow or flip the
    /// position. `None` otherwise: the fill then moves the position by its own side and quantity.
    ///
    /// The `rustrade` engine applies every fill with this function. A component that mirrors
    /// positions outside the engine, such as a ledger of the fills an execution client forwarded,
    /// agrees with the engine by calling it too.
    ///
    /// `held` is the position the fill applies to, as it stands before the fill, signed: positive
    /// for a long, negative for a short, zero for none. In an account holding a long and a short
    /// at once, it is the one position the fill is routed to. `reduce_only` is what the fill's
    /// order was sent as, its
    /// [`RequestOpen::reduce_only`](crate::order::request::RequestOpen::reduce_only). Both
    /// reduce-only values move the position alike: they differ only in how the engine books the
    /// excess (see [`ReduceOnly`]).
    ///
    /// # The rule
    ///
    /// In this order:
    ///
    /// - **The fee is netted.** It leaves the account in base on either side, so the holding moves
    ///   by the fill's signed quantity less the fee: a buy of `q` with a base fee `f` holds
    ///   `q − f`, and a sell delivers `q + f`. A negative fee, a rebate, adds to it. Only on a
    ///   spot instrument, spot margin included. A derivative's position is counted in contracts,
    ///   and an inverse contract pays its fees in the settlement coin, so a fee there never
    ///   changes the quantity. A fee in quote or a third asset, such as BNB, and a fee of zero
    ///   are not netted.
    /// - **A reduce-only move never grows or flips the position.** When
    ///   [`ReduceOnly::is_reduce_only`], a netted
    ///   move larger than the opposite position `held` is cut to land it flat, and one that does
    ///   not reduce it at all, because `held` is zero or on the move's side, moves nothing. What
    ///   is cut off is [`HoldingMove::excess`]. A cut move is not rounded below: flat is on any
    ///   step, and a position the fill left as it was is not the fill's to round. This applies to
    ///   any instrument kind.
    /// - **The position is rounded toward zero to the step.** Only when the fee was netted and
    ///   the move not cut. When [`Instrument::quantity_step`] gives a step, the position the fill
    ///   leaves, `held` plus the netted move, is rounded toward zero to a whole multiple of it, and
    ///   the move is what lands the position there. A venue sells only whole steps, so a position
    ///   off the step could never be closed to flat. It is the position that is rounded, not the
    ///   move: a sell of 1 with a fee of 0.001 from a long of 2, in steps of 0.01, leaves 0.999, so
    ///   the position is 0.99 and the move a sell of 1.01. What is rounded off is
    ///   [`HoldingMove::dust`]. A `held` already off the step is rounded too, so the dust can
    ///   include a remainder this fill did not create. Without a step, the dust is zero.
    /// - **The move's side is its sign.** A fee larger than a buy's quantity sells the difference.
    ///   A move of zero keeps the netted move's side, which is the fill's own unless the fee
    ///   reversed it, with a quantity of zero.
    ///
    /// # What it leaves to the caller
    ///
    /// - **The price and the fee.** The netted quantity keeps the fill's price, so PnL is charged
    ///   the fee's quote equivalent, its `fees_quote`: buying `q − f` at `p` and paying `f × p`
    ///   costs `q × p`, what the fill paid. The engine charges nothing for the dust: it is held,
    ///   a long's as base the account holds and a short's as base still owed on the loan, and
    ///   the position books neither it nor the quote it cost. So the position agrees with the
    ///   account at the fill's price, and the dust's value moves with the price outside it.
    /// - **Only the fee the venue reported** is netted. A fee the caller computes itself, as the
    ///   engine's fee model does, is in quote, and reaches PnL alone.
    /// - **The excess and its share of the fee.** No position takes the excess: the account
    ///   holds it, or the venue spent it, as on a margin repayment sized to cover interest, or it
    ///   was free base the order was sized to trade, under [`ReduceOnly::PositionAndFreeBase`].
    ///   The engine charges the position the share of the fill's fees that the move is of the
    ///   netted move, and the rest to its tear sheet as fees no position carries. It reports the
    ///   excess without charging its value to PnL.
    /// - **A settlement fill larger than its position, in a hedged account.** Where an account
    ///   can hold a long and a short at once (the engine's `OmsMode::Hedging`), the engine does not
    ///   call this for a settlement fill, such as a liquidation, that matches none of its orders
    ///   and is larger than the position it reduces: it cuts that fill to the position, unnetted,
    ///   so the position lands flat, and reports the excess. A mirror that matches the engine does
    ///   the same rather than calling this. Such a fill is of no order of the engine's, so it is
    ///   never reduce-only. With one position per instrument (`OmsMode::Netting`), the engine
    ///   never cuts a settlement fill, so this applies to every fill.
    pub fn holding_move<ExchangeKey>(
        &self,
        instrument: &Instrument<ExchangeKey, AssetKey>,
        held: Decimal,
        reduce_only: ReduceOnly,
    ) -> Option<HoldingMove>
    where
        AssetKey: PartialEq,
    {
        let base_fee = matches!(instrument.kind, InstrumentKind::Spot)
            && self.fees.asset == instrument.underlying.base
            && !self.fees.fees.is_zero();
        let netted = signed(self.side, self.quantity.abs())
            - if base_fee {
                self.fees.fees
            } else {
                Decimal::ZERO
            };
        let (netted_side, _) = unsigned(netted, self.side);

        if reduce_only.is_reduce_only()
            && let Some(moved) = reduced_by(held, netted)
        {
            let (side, quantity) = unsigned(moved, netted_side);
            return Some(HoldingMove {
                side,
                quantity,
                dust: Decimal::ZERO,
                excess: netted - moved,
            });
        }
        if !base_fee {
            return None;
        }

        let holds = held + netted;
        let rounded = instrument
            .quantity_step()
            .map_or(holds, |step| round_toward_zero_to_step(holds, step));
        let (side, quantity) = unsigned(rounded - held, netted_side);
        Some(HoldingMove {
            side,
            quantity,
            dust: holds - rounded,
            excess: Decimal::ZERO,
        })
    }
}

/// How a fill moves the position it applies to, when that is not by its own side and quantity:
/// see [`Trade::holding_move`].
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize)]
pub struct HoldingMove {
    /// [`Side::Buy`] if the move adds to the position, [`Side::Sell`] if it takes from it.
    pub side: Side,
    /// The size of the move, never negative.
    pub quantity: Decimal,
    /// The part of the netted holding rounded off the position to land it on the quantity step,
    /// signed as a position is, and less than one step in size. Positive is base the account holds
    /// that a long does not count; negative is base still owed on a short's loan that the short
    /// does not count. Zero without a step, or when the position lands on it.
    ///
    /// It stays in the account's balance. Each fill's is rounded off on its own, so the balance can
    /// gather more than one step of it over many fills, which no position holds.
    pub dust: Decimal,
    /// The part of a reduce-only fill's netted move cut off so it does not grow or flip the
    /// position, signed as a move is: positive for a buy. Zero unless the move was cut. No
    /// position takes it.
    pub excess: Decimal,
}

/// `quantity` with the sign of `side`: positive for a buy, or for a long position.
fn signed(side: Side, quantity: Decimal) -> Decimal {
    match side {
        Side::Buy => quantity,
        Side::Sell => -quantity,
    }
}

/// The side and size of a signed move: a buy if positive, a sell if negative, and `at_zero`
/// with a size of zero if it moves nothing.
fn unsigned(moved: Decimal, at_zero: Side) -> (Side, Decimal) {
    let side = match moved.cmp(&Decimal::ZERO) {
        std::cmp::Ordering::Greater => Side::Buy,
        std::cmp::Ordering::Less => Side::Sell,
        std::cmp::Ordering::Equal => at_zero,
    };
    (side, moved.abs())
}

/// The part of a reduce-only signed move `moved` that the signed position `held` takes, when that is
/// less than all of it: nothing unless the move reduces `held`, and no more than lands `held`
/// flat. `None` when `held` takes all of it.
fn reduced_by(held: Decimal, moved: Decimal) -> Option<Decimal> {
    // Opposite signs reduce. A flat `held` has no sign to oppose, and a zero move takes nothing.
    if held.cmp(&Decimal::ZERO) != moved.cmp(&Decimal::ZERO).reverse() {
        return (!moved.is_zero()).then_some(Decimal::ZERO);
    }
    (moved.abs() > held.abs()).then_some(-held)
}

/// What one [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades) call
/// read of the span it was asked for.
///
/// A venue reads a span in bounded calls, so one call may stop before the span's end. `resume`
/// says whether this one did:
/// - `None`: every fill in the span the venue still holds is in `trades`.
/// - `Some(t)`: the call stopped at its bound, and fills from `t` on may be missing from
///   `trades`. Read on from `start = t` with the same end. That read can return again fills this
///   one returned at or after `t`, so match them by instrument and [`TradeId`].
///
/// `trades` can be empty while `resume` is `Some`: a venue that reads every instrument's fills
/// and filters them afterwards can spend a whole call on other instruments'. A caller that takes
/// `trades` without reading on from `resume` can miss fills.
///
/// A venue also keeps fills for a limited time or count. `held_since` says when that limit cuts
/// into the span, so a caller can tell fills the venue no longer has from fills that never
/// happened.
#[non_exhaustive]
#[must_use = "`resume` says whether the span was read to its end"]
#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize)]
pub struct TradesRead<AssetKey, InstrumentKey> {
    /// The fills read, in the span and for the requested instruments.
    pub trades: Vec<Trade<AssetKey, InstrumentKey>>,
    /// Where to read on from, or `None` when the read reached the span's end.
    pub resume: Option<DateTime<Utc>>,
    /// The earliest moment the read covers, when the span starts before it; fills before it may
    /// exist but be missing from `trades`. It is after the span's end when none of the span can
    /// be read. `None` when the venue's limit is not known to cut into the span, which is no
    /// claim that the venue holds fills without limit.
    ///
    /// A caller that needs every fill in the span treats the part before `held_since` as a gap:
    /// `resume: None` covers what the venue holds, not what it held. The limit cuts into the span
    /// from its start, so only the first call of a span can report it; a caller reading on from
    /// `resume` has nothing to learn from a later call's.
    pub held_since: Option<DateTime<Utc>>,
}

impl<AssetKey, InstrumentKey> TradesRead<AssetKey, InstrumentKey> {
    /// A read that stopped at `resume` (`None`: it reached the span's end), with no venue limit
    /// cutting into the span.
    pub fn new(trades: Vec<Trade<AssetKey, InstrumentKey>>, resume: Option<DateTime<Utc>>) -> Self {
        Self {
            trades,
            resume,
            held_since: None,
        }
    }

    /// A read that reached the span's end.
    pub fn complete(trades: Vec<Trade<AssetKey, InstrumentKey>>) -> Self {
        Self::new(trades, None)
    }

    /// Sets [`held_since`](Self::held_since).
    pub fn with_held_since(mut self, held_since: Option<DateTime<Utc>>) -> Self {
        self.held_since = held_since;
        self
    }
}

impl<AssetKey, InstrumentKey> Display for Trade<AssetKey, InstrumentKey>
where
    AssetKey: Display,
    InstrumentKey: Display,
{
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{{ instrument: {}, side: {}, price: {}, quantity: {}, time: {}",
            self.instrument, self.side, self.price, self.quantity, self.time_exchange
        )?;
        // Shown only when it is not the default, so a forced fill stands out in a log line.
        if self.origin != TradeOrigin::Order {
            write!(f, ", origin: {}", self.origin)?;
        }
        write!(f, " }}")
    }
}

/// Why a [`Trade`] happened, as far as its producer can tell.
///
/// # `Order` is the default, not a guarantee
///
/// [`Order`](Self::Order) means the producer reported no other origin. Most venues mark some
/// origins and not others, so a fill can be `Order` and still be forced, or still be for an order
/// this client did not place. What each producer reports, as of writing:
///
/// | Source | Reports |
/// |---|---|
/// | Interactive Brokers | [`Liquidation`](Self::Liquidation), [`External`](Self::External) (opt-in), `Order` |
/// | Binance Spot / Margin | `Order` only |
/// | Hyperliquid | `Order` only |
/// | Alpaca | `Order` on fills; [`Expiry`](Self::Expiry), [`Exercise`](Self::Exercise), [`Assignment`](Self::Assignment) and [`External`](Self::External) from `AlpacaClient::fetch_option_events` |
/// | [`SimulatedVenue`](crate::exchange::mock::SimulatedVenue) | [`Liquidation`](Self::Liquidation) (opt-in), `Order` |
/// | The `rustrade` engine's expiry settlement | [`Expiry`](Self::Expiry) |
///
/// - **Interactive Brokers** flags a fill of an IB-initiated liquidation. Another API client's
///   fill is reported, as `External`, only when `IbkrConfig::other_clients_fills` opts in.
/// - **Binance** marks no forced liquidation in its Spot or Margin order stream: its liquidation
///   markers are documented for futures only. Every fill on the account arrives, whoever placed
///   its order. `BinanceMargin::fetch_forced_liquidations` reads the margin orders Binance placed
///   to liquidate the account, for a caller to match against its fills.
/// - **Hyperliquid** fills carry a liquidation marker, but the SDK this crate uses drops it.
/// - **Alpaca** marks no liquidation. Option assignment, exercise and expiry are account
///   activities, which this crate does not read.
/// - The **simulated venue** reports the forced trades of a spot margin liquidation, which
///   [`SpotMarginLiquidation`](crate::margin::SpotMarginLiquidation) opts into, with no client
///   order id.
///
/// A forced variant takes precedence over [`External`](Self::External): a liquidation is
/// reported as a liquidation whoever placed the order it closed.
///
/// # Serialised form and ordering
///
/// Serialised as the variant's name (`"Liquidation"`), as [`Side`] is, while `Display` writes it
/// in lower case (`liquidation`). The names are a stored format: they are not renamed, and new
/// variants are added after the existing ones. A trade with an origin a build does not know does
/// not deserialise in that build.
///
/// `Ord` follows declaration order. It exists so [`Trade`] can derive `Ord`, and carries no
/// meaning beyond that.
#[non_exhaustive]
#[derive(
    Debug, Copy, Clone, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize,
)]
pub enum TradeOrigin {
    /// A fill of an order, with no other origin reported. See the type's docs for why this is not
    /// a guarantee that this client placed the order.
    #[default]
    Order,
    /// The venue or broker closed the position because the account fell short of margin.
    Liquidation,
    /// The venue reduced the position against a counterparty it was liquidating
    /// (auto-deleveraging).
    Adl,
    /// An option this account was short was assigned.
    Assignment,
    /// An option this account held was exercised, by the holder or automatically.
    Exercise,
    /// The instrument expired and the position was settled.
    Expiry,
    /// A fill of an order that another client, such as another API connection or the venue's own
    /// trading screen, placed on the same account.
    External,
}

impl TradeOrigin {
    /// Whether the position changed without the account placing an order for it: a
    /// [`Liquidation`](Self::Liquidation), an [`Adl`](Self::Adl) or an
    /// [`Assignment`](Self::Assignment).
    ///
    /// An [`Exercise`](Self::Exercise) is the holder's right, and an [`Expiry`](Self::Expiry) is
    /// the instrument's end, so neither counts.
    pub fn is_forced(self) -> bool {
        matches!(self, Self::Liquidation | Self::Adl | Self::Assignment)
    }
}

impl Display for TradeOrigin {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            Self::Order => "order",
            Self::Liquidation => "liquidation",
            Self::Adl => "adl",
            Self::Assignment => "assignment",
            Self::Exercise => "exercise",
            Self::Expiry => "expiry",
            Self::External => "external",
        })
    }
}

/// Whose clock an event's time was read from.
///
/// A venue stamps its events with its own clock. When one arrives without a readable stamp, a
/// producer that keeps the event fills in this host's clock as it converts it, and says so with
/// [`Host`](Self::Host). The two clocks differ by however far this host's is skewed, so a time from
/// one does not compare with a time from the other: a host time can read seconds, or more, ahead
/// of the venue's, and an estimate of the venue's clock taken from it is late by as much.
///
/// What each producer does with a fill it has no venue time for, as of writing:
///
/// | Source | Without a venue time |
/// |---|---|
/// | Binance Spot / Margin `executionReport` | the report's event time (`E`), else [`Host`](Self::Host) |
/// | Binance Spot / Margin `myTrades` REST (`fetch_trades`, fill recovery) | [`Host`](Self::Host) |
/// | Alpaca `trade_updates` | the order's `updated_at`, else [`Host`](Self::Host) |
/// | Alpaca account-activities REST | [`Host`](Self::Host) |
/// | Interactive Brokers executions | [`Host`](Self::Host), also when the time's zone is unknown; `fetch_trades`, which selects by time, skips such a fill |
/// | Hyperliquid `userFills` | the fill is dropped, with a `warn!` |
/// | [`SimulatedVenue`](crate::exchange::mock::SimulatedVenue) | always [`Venue`](Self::Venue): its clock is the venue's |
///
/// Each fallback to this host's clock logs a `warn!`.
///
/// # Serialised form and ordering
///
/// Serialised as the variant's name (`"Host"`). The names are a stored format: they are not
/// renamed, and new variants are added after the existing ones. `Ord` follows declaration order.
/// It exists so [`Trade`] can derive `Ord`, and carries no meaning beyond that.
#[non_exhaustive]
#[derive(
    Debug, Copy, Clone, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize,
)]
pub enum TimeSource {
    /// The venue stamped it.
    #[default]
    Venue,
    /// This host's clock, read when the event was converted, because the venue sent no readable
    /// time.
    Host,
}

/// A venue's notice that a trade it reported earlier was busted or corrected.
///
/// Sent as [`AccountEventKind::TradeAmended`](crate::AccountEventKind::TradeAmended). The
/// [`Trade`] it amends was delivered as reported and is not withdrawn: positions, PnL and fees
/// built on it stay wrong until the consumer applies this. Find it by instrument and
/// [`original`](Self::original), reverse it, and apply the [`kind`](Self::kind)'s replacement, if
/// any. The library does not act on it beyond reporting it, and the engine only logs it.
///
/// A replacement can itself be amended later. That amendment names the replacement's id as its
/// `original`, so follow amendments by id, one at a time.
///
/// # Known limitations
///
/// - An order's cumulative filled quantity is not lowered by a bust:
///   [`Open::is_superseded_by`](crate::order::state::Open::is_superseded_by) refuses a lower
///   cumulative, so the order stays on what it reported before.
/// - Alpaca's fill recovery after a reconnect reads fills only, so an Alpaca amendment sent while
///   the stream was disconnected is not reported. IBKR's recovery reads corrections too.
///
/// # Delivery
///
/// Apply an amendment idempotently: reverse a given `original` once, and apply a replacement
/// once by its [`TradeId`]. Whether a venue can send one amendment twice, such as around a
/// reconnect, is not known for every producer. The library delivers each IBKR correction once
/// per account stream, but does not deduplicate Alpaca's amendments.
///
/// Known producers, as of writing:
/// - Alpaca's `trade_updates` `trade_bust` and `trade_correct`;
/// - IBKR's corrected executions, as [`Corrected`](TradeAmendmentKind::Corrected) only. IB
///   documents no busts.
#[non_exhaustive]
#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Constructor)]
pub struct TradeAmendment<AssetKey, InstrumentKey> {
    /// The instrument of the trade amended.
    pub instrument: InstrumentKey,
    /// The order whose trade was amended.
    pub order_id: OrderId,
    /// When the venue amended the trade, or this host's time when it was received where the venue
    /// does not say: IBKR does not say when it corrected an execution.
    pub time_exchange: DateTime<Utc>,
    /// The [`TradeId`] of the trade amended, as the venue named it. `None` when the venue's notice
    /// did not name it: match it to a trade of [`order_id`](Self::order_id) yourself, or
    /// reconcile with
    /// [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades).
    pub original: Option<TradeId>,
    /// What the venue did to the trade.
    pub kind: TradeAmendmentKind<AssetKey, InstrumentKey>,
}

/// What a [`TradeAmendment`] did to the trade.
#[non_exhaustive]
#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize)]
pub enum TradeAmendmentKind<AssetKey, InstrumentKey> {
    /// The trade was cancelled: it did not happen.
    Busted {
        /// The quantity busted, as a magnitude, when the venue said. A venue that reports it as
        /// a negative reversal is read as its absolute value.
        quantity: Option<Decimal>,
    },
    /// The trade was replaced, in full.
    Corrected {
        /// The trade as corrected: its price and quantity are the corrected values, not
        /// differences from the original's. It has its own [`TradeId`], which a later amendment
        /// of it names.
        replacement: Trade<AssetKey, InstrumentKey>,
    },
    /// The trade was corrected, but the venue's notice lacked what a replacement [`Trade`] needs.
    /// What it did carry is here. Reconcile the trade with
    /// [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades).
    CorrectedUnresolved {
        /// The id of the replacement, which a later amendment of it names.
        id: Option<TradeId>,
        /// The corrected price, if the notice had one.
        price: Option<Decimal>,
        /// The corrected quantity, if the notice had one.
        quantity: Option<Decimal>,
    },
}

#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
pub struct AssetFees<AssetKey> {
    pub asset: AssetKey,
    pub fees: Decimal,
    /// Fee value in quote currency when computable.
    /// - `Some(fees)` if fee asset == quote asset
    /// - `Some(fees * price)` if fee asset == base asset (computed by indexer)
    /// - `None` if fee asset is third-party (e.g., BNB), which needs a price the indexer does not
    ///   have. The `rustrade` engine fills it in when it prices that asset, before applying the
    ///   fill (see its `EngineState::update_from_account`). Also `None` when the indexer zeroed a
    ///   fee whose asset was not in its map (see
    ///   [`AccountEventIndexer::trade`](crate::indexer::AccountEventIndexer::trade)).
    ///
    /// The engine's position PnL counts only this quote equivalent. A fee with `None` is left
    /// out of PnL, and its raw [`fees`](Self::fees) stays recorded on the position. On a
    /// position's running fee totals, this is the sum of the quote equivalents its fees had, and
    /// `None` only while none had one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fees_quote: Option<Decimal>,
}

impl<AssetKey> AssetFees<AssetKey> {
    pub fn new(asset: AssetKey, fees: Decimal, fees_quote: Option<Decimal>) -> Self {
        Self {
            asset,
            fees,
            fees_quote,
        }
    }
}

impl AssetFees<QuoteAsset> {
    /// Construct fees already denominated in quote asset.
    /// Sets `fees_quote = Some(fees)` since no conversion needed.
    pub fn quote_fees(fees: Decimal) -> Self {
        Self {
            asset: QuoteAsset,
            fees,
            fees_quote: Some(fees),
        }
    }
}

impl Default for AssetFees<QuoteAsset> {
    fn default() -> Self {
        Self {
            asset: QuoteAsset,
            fees: Decimal::ZERO,
            fees_quote: Some(Decimal::ZERO),
        }
    }
}

impl<AssetKey> Default for AssetFees<Option<AssetKey>> {
    fn default() -> Self {
        Self {
            asset: None,
            fees: Decimal::ZERO,
            fees_quote: None,
        }
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
mod tests {
    use super::*;
    use rust_decimal_macros::dec;
    use rustrade_instrument::{
        asset::{Asset, name::AssetNameExchange},
        exchange::ExchangeId,
        instrument::{
            kind::perpetual::PerpetualContract,
            name::InstrumentNameExchange,
            spec::{
                InstrumentSpec, InstrumentSpecNotional, InstrumentSpecPrice,
                InstrumentSpecQuantity, OrderQuantityUnits,
            },
        },
        test_utils,
    };

    fn trade() -> Trade<AssetNameExchange, InstrumentNameExchange> {
        Trade::new(
            TradeId::new("t-1"),
            OrderId::new("o-1"),
            InstrumentNameExchange::new("btc_usdt"),
            StrategyId::unknown(),
            DateTime::<Utc>::MIN_UTC,
            Side::Sell,
            Decimal::ONE,
            Decimal::TWO,
            None,
            AssetFees::new(AssetNameExchange::from("usdt"), Decimal::ZERO, None),
        )
    }

    #[test]
    fn a_trade_is_of_an_order_unless_given_another_origin() {
        assert_eq!(trade().origin, TradeOrigin::Order);
        assert_eq!(
            trade().with_origin(TradeOrigin::Adl).origin,
            TradeOrigin::Adl
        );
    }

    /// A trade serialised before `origin` existed still loads, as an order's.
    #[test]
    fn a_trade_without_an_origin_deserialises_as_an_orders() {
        let mut json = serde_json::to_value(trade().with_origin(TradeOrigin::External)).unwrap();
        assert_eq!(json["origin"], "External");
        json.as_object_mut().unwrap().remove("origin");

        let read: Trade<AssetNameExchange, InstrumentNameExchange> =
            serde_json::from_value(json).unwrap();
        assert_eq!(read, trade());
    }

    /// A trade serialised before `time_source` existed still loads, as stamped by the venue, and
    /// the source is stored by its variant's name.
    #[test]
    fn a_trade_without_a_time_source_deserialises_as_the_venues() {
        let mut json = serde_json::to_value(trade().with_time_source(TimeSource::Host)).unwrap();
        assert_eq!(json["time_source"], "Host");
        json.as_object_mut().unwrap().remove("time_source");

        let read: Trade<AssetNameExchange, InstrumentNameExchange> =
            serde_json::from_value(json).unwrap();
        assert_eq!(read.time_source, TimeSource::Venue);
        assert_eq!(read, trade());
    }

    #[test]
    fn display_names_the_origin_only_when_it_is_not_an_orders() {
        assert!(!trade().to_string().contains("origin"));
        assert!(
            trade()
                .with_origin(TradeOrigin::Liquidation)
                .to_string()
                .ends_with(", origin: liquidation }")
        );
    }

    // --- holding_move ------------------------------------------------------------------------

    /// A spot BTC/USDT instrument, with a quantity step in BTC when `step` is given.
    fn btc_usdt(step: Option<Decimal>) -> Instrument<ExchangeId, Asset> {
        let mut instrument = test_utils::instrument(ExchangeId::BinanceSpot, "btc", "usdt");
        instrument.spec = step.map(|step| {
            InstrumentSpec::new(
                InstrumentSpecPrice::new(dec!(0.01), dec!(0.01)),
                InstrumentSpecQuantity::new(
                    OrderQuantityUnits::Asset(test_utils::asset("btc")),
                    step,
                    step,
                ),
                InstrumentSpecNotional::new(Decimal::ZERO),
            )
        });
        instrument
    }

    /// A fill of `quantity` on `side`, charged `fee` in `fee_asset`.
    fn fill(side: Side, quantity: Decimal, fee_asset: &str, fee: Decimal) -> Trade<Asset, ()> {
        Trade::new(
            TradeId::new("t-1"),
            OrderId::new("o-1"),
            (),
            StrategyId::unknown(),
            DateTime::<Utc>::MIN_UTC,
            side,
            dec!(100),
            quantity,
            None,
            AssetFees::new(test_utils::asset(fee_asset), fee, None),
        )
    }

    fn moved(side: Side, quantity: Decimal, dust: Decimal) -> Option<HoldingMove> {
        Some(HoldingMove {
            side,
            quantity,
            dust,
            excess: Decimal::ZERO,
        })
    }

    fn cut(side: Side, quantity: Decimal, excess: Decimal) -> Option<HoldingMove> {
        Some(HoldingMove {
            side,
            quantity,
            dust: Decimal::ZERO,
            excess,
        })
    }

    #[test]
    fn holding_move_is_none_unless_a_spot_fill_pays_a_base_fee() {
        let mut perpetual = btc_usdt(None);
        perpetual.kind = InstrumentKind::Perpetual(PerpetualContract {
            contract_size: Decimal::ONE,
            settlement_asset: test_utils::asset("btc"),
        });
        let cases = [
            (
                "quote fee",
                btc_usdt(None),
                fill(Side::Buy, dec!(1), "usdt", dec!(0.1)),
            ),
            (
                "third-asset fee",
                btc_usdt(None),
                fill(Side::Buy, dec!(1), "bnb", dec!(0.001)),
            ),
            (
                "zero base fee",
                btc_usdt(None),
                fill(Side::Buy, dec!(1), "btc", Decimal::ZERO),
            ),
            (
                "derivative",
                perpetual,
                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
            ),
        ];

        for (case, instrument, fill) in cases {
            assert_eq!(
                fill.holding_move(&instrument, Decimal::ZERO, ReduceOnly::No),
                None,
                "{case}"
            );
        }
    }

    #[test]
    fn holding_move_nets_a_base_fee_on_either_side() {
        let spot = btc_usdt(None);
        let cases = [
            (
                "buy holds less",
                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
                moved(Side::Buy, dec!(0.999), Decimal::ZERO),
            ),
            (
                "sell delivers more",
                fill(Side::Sell, dec!(1), "btc", dec!(0.001)),
                moved(Side::Sell, dec!(1.001), Decimal::ZERO),
            ),
            (
                "rebate adds",
                fill(Side::Buy, dec!(1), "btc", dec!(-0.001)),
                moved(Side::Buy, dec!(1.001), Decimal::ZERO),
            ),
            (
                "fee above a buy sells",
                fill(Side::Buy, dec!(0.001), "btc", dec!(0.003)),
                moved(Side::Sell, dec!(0.002), Decimal::ZERO),
            ),
            (
                "fee equal to a buy moves nothing",
                fill(Side::Buy, dec!(0.001), "btc", dec!(0.001)),
                moved(Side::Buy, Decimal::ZERO, Decimal::ZERO),
            ),
        ];

        for (case, fill, expected) in cases {
            assert_eq!(
                fill.holding_move(&spot, dec!(2), ReduceOnly::No),
                expected,
                "{case}"
            );
        }
    }

    /// The position `held` plus the netted move is what is rounded, toward zero, and the move is
    /// what lands it there.
    #[test]
    fn holding_move_rounds_the_position_toward_zero_to_the_step() {
        let spot = btc_usdt(Some(dec!(0.01)));
        let cases = [
            (
                "buy from flat",
                Decimal::ZERO,
                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
                moved(Side::Buy, dec!(0.99), dec!(0.009)),
            ),
            // Rounding the move of 1.001 instead would leave a long of 1, more than the 0.999 held.
            (
                "sell from a long",
                dec!(2),
                fill(Side::Sell, dec!(1), "btc", dec!(0.001)),
                moved(Side::Sell, dec!(1.01), dec!(0.009)),
            ),
            // The fee leaves a short of 0.001, rounded away: base still owed, which no short counts.
            (
                "buy back a short",
                dec!(-1),
                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
                moved(Side::Buy, dec!(1), dec!(-0.001)),
            ),
            (
                "sell opening a short",
                Decimal::ZERO,
                fill(Side::Sell, dec!(1), "btc", dec!(0.001)),
                moved(Side::Sell, dec!(1), dec!(-0.001)),
            ),
            (
                "held off the step",
                dec!(0.005),
                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
                moved(Side::Buy, dec!(0.995), dec!(0.004)),
            ),
            (
                "lands on the step",
                dec!(0.001),
                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
                moved(Side::Buy, dec!(0.999), Decimal::ZERO),
            ),
        ];

        for (case, held, fill, expected) in cases {
            assert_eq!(
                fill.holding_move(&spot, held, ReduceOnly::No),
                expected,
                "{case}"
            );
        }
    }

    /// A rounded move of zero keeps the netted move's side: the fill's own, unless the fee
    /// reversed it.
    #[test]
    fn holding_move_rounded_to_nothing_keeps_the_netted_side() {
        let spot = btc_usdt(Some(dec!(1)));
        let cases = [
            (
                "buy",
                dec!(1),
                fill(Side::Buy, dec!(0.5), "btc", dec!(0.001)),
                moved(Side::Buy, Decimal::ZERO, dec!(0.499)),
            ),
            (
                "fee reverses the buy",
                dec!(-1),
                fill(Side::Buy, dec!(0.001), "btc", dec!(0.003)),
                moved(Side::Sell, Decimal::ZERO, dec!(-0.002)),
            ),
        ];

        for (case, held, fill, expected) in cases {
            assert_eq!(
                fill.holding_move(&spot, held, ReduceOnly::No),
                expected,
                "{case}"
            );
        }
    }

    /// A reduce-only move larger than the opposite position is cut to land it flat; one that
    /// reduces nothing moves nothing. Either way the cut is the excess, and nothing is rounded.
    #[test]
    fn holding_move_cuts_a_reduce_only_move_at_the_position() {
        let spot = btc_usdt(Some(dec!(0.01)));
        let cases = [
            // A repayment buy sized above the short: netted to 10.49, cut at 10.
            (
                "buy past a short",
                dec!(-10),
                fill(Side::Buy, dec!(10.5), "btc", dec!(0.01)),
                cut(Side::Buy, dec!(10), dec!(0.49)),
            ),
            (
                "sell past a long",
                dec!(1),
                fill(Side::Sell, dec!(1.5), "usdt", dec!(0.1)),
                cut(Side::Sell, dec!(1), dec!(-0.5)),
            ),
            (
                "buy from flat",
                Decimal::ZERO,
                fill(Side::Buy, dec!(1), "usdt", dec!(0.1)),
                cut(Side::Buy, Decimal::ZERO, dec!(1)),
            ),
            (
                "buy onto a long",
                dec!(1),
                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
                cut(Side::Buy, Decimal::ZERO, dec!(0.999)),
            ),
            // The fee reverses the buy into a sell, which a short does not reduce.
            (
                "fee reverses a buy onto a short",
                dec!(-1),
                fill(Side::Buy, dec!(0.001), "btc", dec!(0.003)),
                cut(Side::Sell, Decimal::ZERO, dec!(-0.002)),
            ),
        ];

        // Both reduce-only values move the position alike.
        for (case, held, fill, expected) in cases {
            for reduce_only in [ReduceOnly::Position, ReduceOnly::PositionAndFreeBase] {
                assert_eq!(
                    fill.holding_move(&spot, held, reduce_only),
                    expected,
                    "{case}, {reduce_only:?}"
                );
            }
        }
    }

    /// A reduce-only move the position takes whole is moved as any other fill: rounded when its
    /// base fee is netted, and `None` when it is not.
    #[test]
    fn holding_move_leaves_a_reduce_only_move_the_position_takes() {
        let spot = btc_usdt(Some(dec!(0.01)));
        let cases = [
            (
                "partial, base fee",
                dec!(-2),
                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
                moved(Side::Buy, dec!(1), dec!(-0.001)),
            ),
            (
                "to flat exactly, base fee",
                dec!(-0.999),
                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
                moved(Side::Buy, dec!(0.999), Decimal::ZERO),
            ),
            (
                "partial, rounded",
                dec!(2),
                fill(Side::Sell, dec!(1), "btc", dec!(0.001)),
                moved(Side::Sell, dec!(1.01), dec!(0.009)),
            ),
            (
                "to flat exactly, quote fee",
                dec!(1),
                fill(Side::Sell, dec!(1), "usdt", dec!(0.1)),
                None,
            ),
            (
                "fee equal to a buy moves nothing",
                dec!(-1),
                fill(Side::Buy, dec!(0.001), "btc", dec!(0.001)),
                moved(Side::Buy, Decimal::ZERO, Decimal::ZERO),
            ),
            // Nothing to cut, so a short already off the step is still rounded.
            (
                "fee equal to a buy, held off the step",
                dec!(-1.005),
                fill(Side::Buy, dec!(0.001), "btc", dec!(0.001)),
                moved(Side::Buy, dec!(0.005), dec!(-0.005)),
            ),
        ];

        // Both reduce-only values move the position alike.
        for (case, held, fill, expected) in cases {
            for reduce_only in [ReduceOnly::Position, ReduceOnly::PositionAndFreeBase] {
                assert_eq!(
                    fill.holding_move(&spot, held, reduce_only),
                    expected,
                    "{case}, {reduce_only:?}"
                );
            }
        }
    }

    /// The cut applies to any instrument kind, where no fee is netted.
    #[test]
    fn holding_move_cuts_a_reduce_only_derivative_fill() {
        let mut perpetual = btc_usdt(None);
        perpetual.kind = InstrumentKind::Perpetual(PerpetualContract {
            contract_size: Decimal::ONE,
            settlement_asset: test_utils::asset("btc"),
        });
        let fill = fill(Side::Buy, dec!(3), "btc", dec!(0.001));

        assert_eq!(
            fill.holding_move(&perpetual, dec!(-2), ReduceOnly::Position),
            cut(Side::Buy, dec!(2), dec!(1))
        );
        assert_eq!(
            fill.holding_move(&perpetual, dec!(-3), ReduceOnly::Position),
            None
        );
        assert_eq!(
            fill.holding_move(&perpetual, dec!(-2), ReduceOnly::No),
            None
        );
    }

    #[test]
    fn only_liquidation_adl_and_assignment_are_forced() {
        let forced: Vec<_> = [
            TradeOrigin::Order,
            TradeOrigin::Liquidation,
            TradeOrigin::Adl,
            TradeOrigin::Assignment,
            TradeOrigin::Exercise,
            TradeOrigin::Expiry,
            TradeOrigin::External,
        ]
        .into_iter()
        .filter(|origin| origin.is_forced())
        .collect();
        assert_eq!(
            forced,
            [
                TradeOrigin::Liquidation,
                TradeOrigin::Adl,
                TradeOrigin::Assignment
            ]
        );
    }
}