1use nautilus_core::{UUID4, UnixNanos};
22use nautilus_model::{
23 enums::{AccountType, LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce},
24 events::AccountState,
25 identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
26 reports::{FillReport, OrderStatusReport},
27 types::{AccountBalance, Currency, Money, Price, Quantity},
28};
29
30use super::user_data::{BinanceSpotAccountPositionMsg, BinanceSpotExecutionReport};
31use crate::common::{
32 consts::BINANCE_NAUTILUS_SPOT_BROKER_ID,
33 encoder::decode_broker_id,
34 enums::{BinanceOrderStatus, BinanceSide, BinanceTimeInForce},
35};
36
37pub fn parse_spot_exec_report_to_order_status(
43 msg: &BinanceSpotExecutionReport,
44 instrument_id: InstrumentId,
45 price_precision: u8,
46 size_precision: u8,
47 account_id: AccountId,
48 ts_init: UnixNanos,
49) -> anyhow::Result<OrderStatusReport> {
50 let client_order_id = ClientOrderId::new(decode_broker_id(
51 &msg.client_order_id,
52 BINANCE_NAUTILUS_SPOT_BROKER_ID,
53 ));
54 let venue_order_id = VenueOrderId::new(msg.order_id.to_string());
55 let ts_event = UnixNanos::from_millis(msg.event_time as u64);
56
57 let order_side = match msg.side {
58 BinanceSide::Buy => OrderSide::Buy,
59 BinanceSide::Sell => OrderSide::Sell,
60 };
61
62 let order_status = parse_order_status(msg.order_status);
63 let order_type = parse_spot_order_type(&msg.order_type);
64 let time_in_force = parse_time_in_force(msg.time_in_force);
65
66 let quantity: f64 = msg.original_qty.parse().unwrap_or(0.0);
67 let filled_qty: f64 = msg.cumulative_filled_qty.parse().unwrap_or(0.0);
68 let price: f64 = msg.price.parse().unwrap_or(0.0);
69
70 let avg_px = if filled_qty > 0.0 {
71 let cum_quote: f64 = msg.cumulative_quote_qty.parse().unwrap_or(0.0);
72 Some(Price::new(cum_quote / filled_qty, price_precision))
73 } else {
74 None
75 };
76
77 let mut report = OrderStatusReport::new(
78 account_id,
79 instrument_id,
80 Some(client_order_id),
81 venue_order_id,
82 order_side,
83 order_type,
84 time_in_force,
85 order_status,
86 Quantity::new(quantity, size_precision),
87 Quantity::new(filled_qty, size_precision),
88 ts_event,
89 ts_event,
90 ts_init,
91 None, );
93
94 report.price = Some(Price::new(price, price_precision));
95 report.post_only = msg.order_type == "LIMIT_MAKER";
96
97 let stop_price: f64 = msg.stop_price.parse().unwrap_or(0.0);
98 if stop_price > 0.0 {
99 report.trigger_price = Some(Price::new(stop_price, price_precision));
100 }
101
102 if let Some(avg) = avg_px {
103 report.avg_px = Some(avg.as_decimal());
104 }
105
106 Ok(report)
107}
108
109pub fn parse_spot_exec_report_to_fill(
115 msg: &BinanceSpotExecutionReport,
116 instrument_id: InstrumentId,
117 price_precision: u8,
118 size_precision: u8,
119 account_id: AccountId,
120 ts_init: UnixNanos,
121) -> anyhow::Result<FillReport> {
122 let client_order_id = ClientOrderId::new(decode_broker_id(
123 &msg.client_order_id,
124 BINANCE_NAUTILUS_SPOT_BROKER_ID,
125 ));
126 let venue_order_id = VenueOrderId::new(msg.order_id.to_string());
127 let trade_id = TradeId::new(msg.trade_id.to_string());
128 let ts_event = UnixNanos::from_millis(msg.event_time as u64);
129
130 let order_side = match msg.side {
131 BinanceSide::Buy => OrderSide::Buy,
132 BinanceSide::Sell => OrderSide::Sell,
133 };
134
135 let liquidity_side = if msg.is_maker {
136 LiquiditySide::Maker
137 } else {
138 LiquiditySide::Taker
139 };
140
141 let last_qty: f64 = msg.last_filled_qty.parse().unwrap_or(0.0);
142 let last_px: f64 = msg.last_filled_price.parse().unwrap_or(0.0);
143 let commission: f64 = msg.commission.parse().unwrap_or(0.0);
144 let commission_currency = msg
145 .commission_asset
146 .as_ref()
147 .map_or_else(Currency::USDT, |a| {
148 Currency::get_or_create_crypto(a.as_str())
149 });
150
151 Ok(FillReport::new(
152 account_id,
153 instrument_id,
154 venue_order_id,
155 trade_id,
156 order_side,
157 Quantity::new(last_qty, size_precision),
158 Price::new(last_px, price_precision),
159 Money::new(commission, commission_currency),
160 liquidity_side,
161 Some(client_order_id),
162 None, ts_event,
164 ts_init,
165 None, ))
167}
168
169pub fn parse_spot_account_position(
171 msg: &BinanceSpotAccountPositionMsg,
172 account_id: AccountId,
173 ts_init: UnixNanos,
174) -> AccountState {
175 let ts_event = UnixNanos::from_millis(msg.event_time as u64);
176
177 let balances: Vec<AccountBalance> = msg
178 .balances
179 .iter()
180 .filter_map(|b| {
181 let total = b.free + b.locked;
182 let currency = Currency::get_or_create_crypto(b.asset.as_str());
183 AccountBalance::from_total_and_locked(total, b.locked, currency).ok()
184 })
185 .collect();
186
187 AccountState::new(
188 account_id,
189 AccountType::Cash,
190 balances,
191 vec![], true, UUID4::new(),
194 ts_event,
195 ts_init,
196 None, )
198}
199
200fn parse_order_status(status: BinanceOrderStatus) -> OrderStatus {
201 match status {
202 BinanceOrderStatus::New | BinanceOrderStatus::PendingNew => OrderStatus::Accepted,
203 BinanceOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
204 BinanceOrderStatus::Filled
205 | BinanceOrderStatus::NewAdl
206 | BinanceOrderStatus::NewInsurance => OrderStatus::Filled,
207 BinanceOrderStatus::Canceled | BinanceOrderStatus::PendingCancel => OrderStatus::Canceled,
208 BinanceOrderStatus::Rejected => OrderStatus::Rejected,
209 BinanceOrderStatus::Expired | BinanceOrderStatus::ExpiredInMatch => OrderStatus::Expired,
210 BinanceOrderStatus::Unknown => OrderStatus::Accepted,
211 }
212}
213
214fn parse_spot_order_type(order_type: &str) -> OrderType {
215 match order_type {
216 "LIMIT" | "LIMIT_MAKER" => OrderType::Limit,
217 "MARKET" => OrderType::Market,
218 "STOP_LOSS" => OrderType::StopMarket,
219 "STOP_LOSS_LIMIT" => OrderType::StopLimit,
220 "TAKE_PROFIT" => OrderType::MarketIfTouched,
221 "TAKE_PROFIT_LIMIT" => OrderType::LimitIfTouched,
222 _ => OrderType::Market,
223 }
224}
225
226fn parse_time_in_force(tif: BinanceTimeInForce) -> TimeInForce {
227 match tif {
228 BinanceTimeInForce::Gtc | BinanceTimeInForce::Gtx => TimeInForce::Gtc,
229 BinanceTimeInForce::Ioc | BinanceTimeInForce::Rpi => TimeInForce::Ioc,
230 BinanceTimeInForce::Fok => TimeInForce::Fok,
231 BinanceTimeInForce::Gtd => TimeInForce::Gtd,
232 BinanceTimeInForce::Unknown => TimeInForce::Gtc,
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use rstest::rstest;
239
240 use super::*;
241 use crate::{
242 common::testing::load_fixture_string,
243 spot::websocket::trading::user_data::BinanceSpotExecutionReport,
244 };
245
246 const PRICE_PRECISION: u8 = 2;
247 const SIZE_PRECISION: u8 = 5;
248
249 fn instrument_id() -> InstrumentId {
250 InstrumentId::from("ETHUSDT.BINANCE")
251 }
252
253 #[rstest]
254 fn test_parse_execution_report_to_order_status_report() {
255 let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
256 let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
257 let account_id = AccountId::from("BINANCE-001");
258 let ts_init = UnixNanos::from(1_000_000_000u64);
259
260 let report = parse_spot_exec_report_to_order_status(
261 &msg,
262 instrument_id(),
263 PRICE_PRECISION,
264 SIZE_PRECISION,
265 account_id,
266 ts_init,
267 )
268 .unwrap();
269
270 assert_eq!(report.account_id, account_id);
271 assert_eq!(report.instrument_id, instrument_id());
272 assert_eq!(report.order_side, OrderSide::Buy);
273 assert_eq!(report.order_status, OrderStatus::Accepted);
274 assert_eq!(report.order_type, OrderType::Limit);
275 assert_eq!(report.time_in_force, TimeInForce::Gtc);
276 assert_eq!(report.venue_order_id, VenueOrderId::new("12345678"));
277 assert_eq!(
278 report.client_order_id,
279 Some(ClientOrderId::from("O-20200101-000000-000-000-0")),
280 );
281 assert_eq!(report.quantity, Quantity::new(1.0, SIZE_PRECISION));
282 assert_eq!(report.filled_qty, Quantity::new(0.0, SIZE_PRECISION));
283 assert_eq!(report.price, Some(Price::new(2500.0, PRICE_PRECISION)));
284 assert!(report.avg_px.is_none());
285 assert!(!report.post_only);
286 assert!(report.trigger_price.is_none());
287 assert_eq!(
288 report.ts_accepted,
289 UnixNanos::from(1_709_654_400_000_000_000u64)
290 );
291 assert_eq!(
292 report.ts_last,
293 UnixNanos::from(1_709_654_400_000_000_000u64)
294 );
295 assert_eq!(report.ts_init, ts_init);
296 }
297
298 #[rstest]
299 fn test_parse_execution_report_limit_maker_sets_post_only() {
300 let json = r#"{
301 "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
302 "c":"x-TD67BGP9-T0000000000000","S":"SELL","o":"LIMIT_MAKER",
303 "f":"GTC","q":"0.5","p":"2600.00","P":"0",
304 "x":"NEW","X":"NEW","r":"NONE","i":12345679,
305 "l":"0","z":"0","L":"0","n":"0","N":null,
306 "T":1709654400000,"t":-1,"w":true,"m":false,
307 "O":1709654400000,"Z":"0","C":""
308 }"#;
309 let msg: BinanceSpotExecutionReport = serde_json::from_str(json).unwrap();
310 let account_id = AccountId::from("BINANCE-001");
311 let ts_init = UnixNanos::from(1_000_000_000u64);
312
313 let report = parse_spot_exec_report_to_order_status(
314 &msg,
315 instrument_id(),
316 PRICE_PRECISION,
317 SIZE_PRECISION,
318 account_id,
319 ts_init,
320 )
321 .unwrap();
322
323 assert_eq!(report.order_type, OrderType::Limit);
324 assert!(report.post_only, "LIMIT_MAKER must set post_only");
325 }
326
327 #[rstest]
328 fn test_parse_execution_report_partial_fill_computes_avg_px() {
329 let json = r#"{
330 "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
331 "c":"x-TD67BGP9-T0000000000000","S":"BUY","o":"LIMIT",
332 "f":"GTC","q":"2.0","p":"2500.00","P":"0",
333 "x":"TRADE","X":"PARTIALLY_FILLED","r":"NONE","i":12345678,
334 "l":"0.5","z":"0.5","L":"2499.00","n":"0.00100000","N":"ETH",
335 "T":1709654400000,"t":98765432,"w":true,"m":false,
336 "O":1709654400000,"Z":"1249.50","C":""
337 }"#;
338 let msg: BinanceSpotExecutionReport = serde_json::from_str(json).unwrap();
339 let account_id = AccountId::from("BINANCE-001");
340 let ts_init = UnixNanos::from(1_000_000_000u64);
341
342 let report = parse_spot_exec_report_to_order_status(
343 &msg,
344 instrument_id(),
345 PRICE_PRECISION,
346 SIZE_PRECISION,
347 account_id,
348 ts_init,
349 )
350 .unwrap();
351
352 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
353 assert_eq!(report.quantity, Quantity::new(2.0, SIZE_PRECISION));
354 assert_eq!(report.filled_qty, Quantity::new(0.5, SIZE_PRECISION));
355
356 assert_eq!(report.avg_px.unwrap().to_string(), "2499.00");
358 }
359
360 #[rstest]
361 fn test_parse_execution_report_stop_loss_has_trigger_price() {
362 let json = load_fixture_string("spot/user_data_json/execution_report_stop_loss.json");
363 let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
364 let account_id = AccountId::from("BINANCE-001");
365 let ts_init = UnixNanos::from(1_000_000_000u64);
366
367 let report = parse_spot_exec_report_to_order_status(
368 &msg,
369 instrument_id(),
370 PRICE_PRECISION,
371 SIZE_PRECISION,
372 account_id,
373 ts_init,
374 )
375 .unwrap();
376
377 assert_eq!(report.order_type, OrderType::StopLimit);
378 assert_eq!(report.order_side, OrderSide::Sell);
379 assert_eq!(
380 report.client_order_id,
381 Some(ClientOrderId::from("O-20200101-000000-000-000-1")),
382 );
383 assert_eq!(
384 report.trigger_price,
385 Some(Price::new(2450.0, PRICE_PRECISION))
386 );
387 assert_eq!(report.price, Some(Price::new(2400.0, PRICE_PRECISION)));
388 }
389
390 #[rstest]
391 fn test_parse_execution_report_to_fill_report() {
392 let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
393 let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
394 let account_id = AccountId::from("BINANCE-001");
395 let ts_init = UnixNanos::from(1_000_000_000u64);
396
397 let report = parse_spot_exec_report_to_fill(
398 &msg,
399 instrument_id(),
400 PRICE_PRECISION,
401 SIZE_PRECISION,
402 account_id,
403 ts_init,
404 )
405 .unwrap();
406
407 assert_eq!(report.account_id, account_id);
408 assert_eq!(report.instrument_id, instrument_id());
409 assert_eq!(report.order_side, OrderSide::Buy);
410 assert_eq!(report.liquidity_side, LiquiditySide::Maker);
411 assert_eq!(report.trade_id, TradeId::new("98765432"));
412 assert_eq!(
413 report.client_order_id,
414 Some(ClientOrderId::from("O-20200101-000000-000-000-0")),
415 );
416 }
417
418 #[rstest]
419 fn test_parse_account_position() {
420 let json = load_fixture_string("spot/user_data_json/account_position.json");
421 let msg: BinanceSpotAccountPositionMsg = serde_json::from_str(&json).unwrap();
422 let account_id = AccountId::from("BINANCE-001");
423 let ts_init = UnixNanos::from(1_000_000_000u64);
424
425 let state = parse_spot_account_position(&msg, account_id, ts_init);
426
427 assert_eq!(state.account_id, account_id);
428 assert_eq!(state.account_type, AccountType::Cash);
429 assert!(state.is_reported);
430 assert_eq!(state.balances.len(), 2);
431 }
432
433 #[rstest]
437 fn test_parse_account_position_precision_drift() {
438 let json = r#"{
439 "e": "outboundAccountPosition",
440 "E": 1700000000000,
441 "u": 1700000000000,
442 "B": [{
443 "a": "ETH",
444 "f": "9.999999994999",
445 "l": "0.000000040000"
446 }]
447 }"#;
448 let msg: BinanceSpotAccountPositionMsg = serde_json::from_str(json).unwrap();
449 let account_id = AccountId::from("BINANCE-001");
450 let ts_init = UnixNanos::from(1_000_000_000u64);
451
452 let state = parse_spot_account_position(&msg, account_id, ts_init);
453
454 assert_eq!(state.balances.len(), 1);
455 let balance = &state.balances[0];
456 assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
457 }
458}