1use std::sync::Arc;
19
20use arrow::{
21 array::{
22 BooleanBuilder, Float64Builder, StringBuilder, TimestampNanosecondBuilder, UInt8Builder,
23 UInt32Builder, UInt64Builder,
24 },
25 datatypes::Schema,
26 error::ArrowError,
27 record_batch::RecordBatch,
28};
29use nautilus_model::position::Position;
30
31use super::{
32 bool_field, float64_field, money_to_f64, quantity_to_f64, timestamp_field, uint8_field,
33 uint32_field, uint64_field, unix_nanos_to_i64, utf8_field,
34};
35use crate::arrow::timestamp_data_type;
36
37#[must_use]
39pub fn position_schema() -> Schema {
40 Schema::new(vec![
41 utf8_field("trader_id", false),
42 utf8_field("strategy_id", false),
43 utf8_field("instrument_id", false),
44 utf8_field("position_id", false),
45 utf8_field("account_id", false),
46 utf8_field("opening_order_id", false),
47 utf8_field("closing_order_id", true),
48 utf8_field("entry", false),
49 utf8_field("side", false),
50 float64_field("signed_qty", false),
51 float64_field("quantity", false),
52 float64_field("peak_qty", false),
53 uint8_field("price_precision", false),
54 uint8_field("size_precision", false),
55 float64_field("multiplier", false),
56 bool_field("is_inverse", false),
57 bool_field("is_currency_pair", false),
58 utf8_field("instrument_class", false),
59 utf8_field("base_currency", true),
60 utf8_field("quote_currency", false),
61 utf8_field("settlement_currency", false),
62 timestamp_field("ts_init", false),
63 timestamp_field("ts_opened", false),
64 timestamp_field("ts_last", false),
65 timestamp_field("ts_closed", true),
66 uint64_field("duration_ns", false),
67 float64_field("avg_px_open", false),
68 float64_field("avg_px_close", true),
69 float64_field("realized_return", false),
70 float64_field("realized_pnl_amount", true),
71 utf8_field("realized_pnl_currency", true),
72 utf8_field("trade_ids", false),
73 float64_field("buy_qty", false),
74 float64_field("sell_qty", false),
75 utf8_field("commissions", false),
76 uint32_field("event_count", false),
77 uint32_field("adjustment_count", false),
78 ])
79}
80
81fn trade_ids_to_json(position: &Position) -> String {
82 let mut trade_ids: Vec<String> = position.trade_ids.iter().map(ToString::to_string).collect();
83 trade_ids.sort();
84 serde_json::to_string(&trade_ids).unwrap_or_default()
85}
86
87fn commissions_to_json(position: &Position) -> String {
88 let mut commissions: Vec<(String, f64)> = position
89 .commissions
90 .iter()
91 .map(|(currency, money)| (currency.to_string(), money_to_f64(money)))
92 .collect();
93 commissions.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0));
94 serde_json::to_string(&commissions).unwrap_or_default()
95}
96
97pub fn encode_positions(data: &[Position]) -> Result<RecordBatch, ArrowError> {
112 let mut trader_id = StringBuilder::new();
113 let mut strategy_id = StringBuilder::new();
114 let mut instrument_id = StringBuilder::new();
115 let mut position_id = StringBuilder::new();
116 let mut account_id = StringBuilder::new();
117 let mut opening_order_id = StringBuilder::new();
118 let mut closing_order_id = StringBuilder::new();
119 let mut entry = StringBuilder::new();
120 let mut side = StringBuilder::new();
121 let mut signed_qty = Float64Builder::with_capacity(data.len());
122 let mut quantity = Float64Builder::with_capacity(data.len());
123 let mut peak_qty = Float64Builder::with_capacity(data.len());
124 let mut price_precision = UInt8Builder::with_capacity(data.len());
125 let mut size_precision = UInt8Builder::with_capacity(data.len());
126 let mut multiplier = Float64Builder::with_capacity(data.len());
127 let mut is_inverse = BooleanBuilder::with_capacity(data.len());
128 let mut is_currency_pair = BooleanBuilder::with_capacity(data.len());
129 let mut instrument_class = StringBuilder::new();
130 let mut base_currency = StringBuilder::new();
131 let mut quote_currency = StringBuilder::new();
132 let mut settlement_currency = StringBuilder::new();
133 let mut ts_init =
134 TimestampNanosecondBuilder::with_capacity(data.len()).with_data_type(timestamp_data_type());
135 let mut ts_opened =
136 TimestampNanosecondBuilder::with_capacity(data.len()).with_data_type(timestamp_data_type());
137 let mut ts_last =
138 TimestampNanosecondBuilder::with_capacity(data.len()).with_data_type(timestamp_data_type());
139 let mut ts_closed =
140 TimestampNanosecondBuilder::with_capacity(data.len()).with_data_type(timestamp_data_type());
141 let mut duration_ns = UInt64Builder::with_capacity(data.len());
142 let mut avg_px_open = Float64Builder::with_capacity(data.len());
143 let mut avg_px_close = Float64Builder::with_capacity(data.len());
144 let mut realized_return = Float64Builder::with_capacity(data.len());
145 let mut realized_pnl_amount = Float64Builder::with_capacity(data.len());
146 let mut realized_pnl_currency = StringBuilder::new();
147 let mut trade_ids = StringBuilder::new();
148 let mut buy_qty = Float64Builder::with_capacity(data.len());
149 let mut sell_qty = Float64Builder::with_capacity(data.len());
150 let mut commissions = StringBuilder::new();
151 let mut event_count = UInt32Builder::with_capacity(data.len());
152 let mut adjustment_count = UInt32Builder::with_capacity(data.len());
153
154 for position in data {
155 trader_id.append_value(position.trader_id);
156 strategy_id.append_value(position.strategy_id);
157 instrument_id.append_value(position.instrument_id.to_string());
158 position_id.append_value(position.id);
159 account_id.append_value(position.account_id);
160 opening_order_id.append_value(position.opening_order_id);
161 closing_order_id.append_option(position.closing_order_id.map(|v| v.to_string()));
162 entry.append_value(format!("{}", position.entry));
163 side.append_value(format!("{}", position.side));
164 signed_qty.append_value(position.signed_qty);
165 quantity.append_value(quantity_to_f64(&position.quantity));
166 peak_qty.append_value(quantity_to_f64(&position.peak_qty));
167 price_precision.append_value(position.price_precision);
168 size_precision.append_value(position.size_precision);
169 multiplier.append_value(quantity_to_f64(&position.multiplier));
170 is_inverse.append_value(position.is_inverse);
171 is_currency_pair.append_value(position.is_currency_pair);
172 instrument_class.append_value(format!("{}", position.instrument_class));
173 base_currency.append_option(position.base_currency.map(|v| v.to_string()));
174 quote_currency.append_value(position.quote_currency.to_string());
175 settlement_currency.append_value(position.settlement_currency.to_string());
176 ts_init.append_value(unix_nanos_to_i64(position.ts_init.as_u64()));
177 ts_opened.append_value(unix_nanos_to_i64(position.ts_opened.as_u64()));
178 ts_last.append_value(unix_nanos_to_i64(position.ts_last.as_u64()));
179 ts_closed.append_option(position.ts_closed.map(|v| unix_nanos_to_i64(v.as_u64())));
180 duration_ns.append_value(position.duration_ns.as_u64());
181 avg_px_open.append_value(position.avg_px_open);
182 avg_px_close.append_option(position.avg_px_close);
183 realized_return.append_value(position.realized_return);
184 realized_pnl_amount.append_option(position.realized_pnl.map(|v| money_to_f64(&v)));
185 realized_pnl_currency.append_option(position.realized_pnl.map(|v| v.currency.to_string()));
186 trade_ids.append_value(trade_ids_to_json(position));
187 buy_qty.append_value(quantity_to_f64(&position.buy_qty));
188 sell_qty.append_value(quantity_to_f64(&position.sell_qty));
189 commissions.append_value(commissions_to_json(position));
190 event_count.append_value(position.events.len() as u32);
191 adjustment_count.append_value(position.adjustments.len() as u32);
192 }
193
194 RecordBatch::try_new(
195 Arc::new(position_schema()),
196 vec![
197 Arc::new(trader_id.finish()),
198 Arc::new(strategy_id.finish()),
199 Arc::new(instrument_id.finish()),
200 Arc::new(position_id.finish()),
201 Arc::new(account_id.finish()),
202 Arc::new(opening_order_id.finish()),
203 Arc::new(closing_order_id.finish()),
204 Arc::new(entry.finish()),
205 Arc::new(side.finish()),
206 Arc::new(signed_qty.finish()),
207 Arc::new(quantity.finish()),
208 Arc::new(peak_qty.finish()),
209 Arc::new(price_precision.finish()),
210 Arc::new(size_precision.finish()),
211 Arc::new(multiplier.finish()),
212 Arc::new(is_inverse.finish()),
213 Arc::new(is_currency_pair.finish()),
214 Arc::new(instrument_class.finish()),
215 Arc::new(base_currency.finish()),
216 Arc::new(quote_currency.finish()),
217 Arc::new(settlement_currency.finish()),
218 Arc::new(ts_init.finish()),
219 Arc::new(ts_opened.finish()),
220 Arc::new(ts_last.finish()),
221 Arc::new(ts_closed.finish()),
222 Arc::new(duration_ns.finish()),
223 Arc::new(avg_px_open.finish()),
224 Arc::new(avg_px_close.finish()),
225 Arc::new(realized_return.finish()),
226 Arc::new(realized_pnl_amount.finish()),
227 Arc::new(realized_pnl_currency.finish()),
228 Arc::new(trade_ids.finish()),
229 Arc::new(buy_qty.finish()),
230 Arc::new(sell_qty.finish()),
231 Arc::new(commissions.finish()),
232 Arc::new(event_count.finish()),
233 Arc::new(adjustment_count.finish()),
234 ],
235 )
236}
237
238#[cfg(test)]
239mod tests {
240 use arrow::{
241 array::{
242 Array, BooleanArray, Float64Array, StringArray, TimestampNanosecondArray, UInt8Array,
243 UInt32Array, UInt64Array,
244 },
245 datatypes::{DataType, TimeUnit},
246 };
247 use nautilus_model::{
248 enums::OrderSide,
249 events::{OrderFilled, order::spec::OrderFilledSpec},
250 identifiers::{ClientOrderId, PositionId, TradeId, VenueOrderId},
251 instruments::{CurrencyPair, InstrumentAny, stubs::currency_pair_btcusdt},
252 types::{Money, Price, Quantity},
253 };
254 use rstest::rstest;
255
256 use super::*;
257
258 #[expect(clippy::too_many_arguments)]
259 fn make_fill(
260 instrument: &CurrencyPair,
261 side: OrderSide,
262 qty: &str,
263 price: &str,
264 trade_id: &str,
265 order_id: &str,
266 ts: u64,
267 commission: Option<Money>,
268 ) -> OrderFilled {
269 OrderFilledSpec::builder()
270 .instrument_id(instrument.id)
271 .client_order_id(ClientOrderId::from(order_id))
272 .venue_order_id(VenueOrderId::from(order_id))
273 .trade_id(TradeId::from(trade_id))
274 .order_side(side)
275 .last_qty(Quantity::from(qty))
276 .last_px(Price::from(price))
277 .currency(instrument.quote_currency)
278 .ts_event(ts.into())
279 .ts_init((ts + 1).into())
280 .position_id(PositionId::from("P-001"))
281 .maybe_commission(commission)
282 .build()
283 }
284
285 fn make_position(ts: u64) -> Position {
286 let instrument = currency_pair_btcusdt();
287 let fill = make_fill(
288 &instrument,
289 OrderSide::Buy,
290 "1.0",
291 "50000.0",
292 "T-1",
293 "O-1",
294 ts,
295 None,
296 );
297 let any = InstrumentAny::CurrencyPair(instrument);
298 Position::new(&any, fill)
299 }
300
301 #[rstest]
302 fn test_encode_positions_schema() {
303 let batch = encode_positions(&[]).unwrap();
304 let schema = batch.schema();
305 let fields = schema.fields();
306 assert_eq!(fields.len(), 37);
307 assert_eq!(fields[0].name(), "trader_id");
308 assert_eq!(fields[0].data_type(), &DataType::Utf8);
309 assert_eq!(fields[9].name(), "signed_qty");
310 assert_eq!(fields[9].data_type(), &DataType::Float64);
311 assert_eq!(fields[12].name(), "price_precision");
312 assert_eq!(fields[12].data_type(), &DataType::UInt8);
313 assert_eq!(fields[15].name(), "is_inverse");
314 assert_eq!(fields[15].data_type(), &DataType::Boolean);
315 assert_eq!(fields[21].name(), "ts_init");
316 assert_eq!(
317 fields[21].data_type(),
318 &DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into()))
319 );
320 assert_eq!(fields[25].name(), "duration_ns");
321 assert_eq!(fields[25].data_type(), &DataType::UInt64);
322 assert_eq!(fields[35].name(), "event_count");
323 assert_eq!(fields[35].data_type(), &DataType::UInt32);
324 }
325
326 #[rstest]
327 fn test_encode_positions_empty() {
328 let batch = encode_positions(&[]).unwrap();
329 assert_eq!(batch.num_rows(), 0);
330 assert_eq!(batch.schema().fields().len(), 37);
331 }
332
333 #[rstest]
334 fn test_encode_positions_values() {
335 let positions = vec![make_position(1_000_000)];
336 let batch = encode_positions(&positions).unwrap();
337
338 assert_eq!(batch.num_rows(), 1);
339
340 let trader_id_col = batch
341 .column(0)
342 .as_any()
343 .downcast_ref::<StringArray>()
344 .unwrap();
345 let quantity_col = batch
346 .column(10)
347 .as_any()
348 .downcast_ref::<Float64Array>()
349 .unwrap();
350 let price_precision_col = batch
351 .column(12)
352 .as_any()
353 .downcast_ref::<UInt8Array>()
354 .unwrap();
355 let is_currency_pair_col = batch
356 .column(16)
357 .as_any()
358 .downcast_ref::<BooleanArray>()
359 .unwrap();
360 let ts_opened_col = batch
361 .column(22)
362 .as_any()
363 .downcast_ref::<TimestampNanosecondArray>()
364 .unwrap();
365 let duration_col = batch
366 .column(25)
367 .as_any()
368 .downcast_ref::<UInt64Array>()
369 .unwrap();
370 let event_count_col = batch
371 .column(35)
372 .as_any()
373 .downcast_ref::<UInt32Array>()
374 .unwrap();
375
376 assert_eq!(trader_id_col.value(0), "TRADER-001");
377 assert!((quantity_col.value(0) - 1.0).abs() < 1e-9);
378 assert_eq!(price_precision_col.value(0), 2);
379 assert!(is_currency_pair_col.value(0));
380 assert_eq!(ts_opened_col.value(0), 1_000_000);
381 assert_eq!(duration_col.value(0), 0);
382 assert_eq!(event_count_col.value(0), 1);
383 }
384
385 #[rstest]
386 fn test_encode_positions_nullable_fields() {
387 let positions = vec![make_position(1_000)];
388 let batch = encode_positions(&positions).unwrap();
389
390 let closing_order_id_col = batch
391 .column(6)
392 .as_any()
393 .downcast_ref::<StringArray>()
394 .unwrap();
395 let ts_closed_col = batch
396 .column(24)
397 .as_any()
398 .downcast_ref::<TimestampNanosecondArray>()
399 .unwrap();
400 let avg_px_close_col = batch
401 .column(27)
402 .as_any()
403 .downcast_ref::<Float64Array>()
404 .unwrap();
405
406 assert!(closing_order_id_col.is_null(0));
407 assert!(ts_closed_col.is_null(0));
408 assert!(avg_px_close_col.is_null(0));
409 }
410
411 #[rstest]
412 fn test_encode_positions_trade_ids_sorted() {
413 let instrument = currency_pair_btcusdt();
414 let any = InstrumentAny::CurrencyPair(instrument.clone());
415 let open = make_fill(
416 &instrument,
417 OrderSide::Buy,
418 "1.0",
419 "50000.0",
420 "T-Z",
421 "O-1",
422 1_000,
423 None,
424 );
425 let add = make_fill(
426 &instrument,
427 OrderSide::Buy,
428 "1.0",
429 "50000.0",
430 "T-A",
431 "O-2",
432 2_000,
433 None,
434 );
435 let mut position = Position::new(&any, open);
436 position.apply(&add);
437
438 let batch = encode_positions(&[position]).unwrap();
439 let trade_ids_col = batch
440 .column(31)
441 .as_any()
442 .downcast_ref::<StringArray>()
443 .unwrap();
444
445 let parsed: Vec<String> = serde_json::from_str(trade_ids_col.value(0)).unwrap();
446 assert_eq!(parsed, vec!["T-A".to_string(), "T-Z".to_string()]);
447 }
448
449 #[rstest]
450 fn test_encode_positions_closed() {
451 let instrument = currency_pair_btcusdt();
452 let any = InstrumentAny::CurrencyPair(instrument.clone());
453 let open = make_fill(
454 &instrument,
455 OrderSide::Buy,
456 "1.0",
457 "50000.0",
458 "T-1",
459 "O-1",
460 1_000,
461 None,
462 );
463 let close = make_fill(
464 &instrument,
465 OrderSide::Sell,
466 "1.0",
467 "50500.0",
468 "T-2",
469 "O-2",
470 5_000,
471 None,
472 );
473 let mut position = Position::new(&any, open);
474 position.apply(&close);
475
476 let batch = encode_positions(&[position]).unwrap();
477 let closing_order_id_col = batch
478 .column(6)
479 .as_any()
480 .downcast_ref::<StringArray>()
481 .unwrap();
482 let ts_closed_col = batch
483 .column(24)
484 .as_any()
485 .downcast_ref::<TimestampNanosecondArray>()
486 .unwrap();
487 let duration_col = batch
488 .column(25)
489 .as_any()
490 .downcast_ref::<UInt64Array>()
491 .unwrap();
492 let avg_px_close_col = batch
493 .column(27)
494 .as_any()
495 .downcast_ref::<Float64Array>()
496 .unwrap();
497 let realized_pnl_amount_col = batch
498 .column(29)
499 .as_any()
500 .downcast_ref::<Float64Array>()
501 .unwrap();
502 let realized_pnl_currency_col = batch
503 .column(30)
504 .as_any()
505 .downcast_ref::<StringArray>()
506 .unwrap();
507 let event_count_col = batch
508 .column(35)
509 .as_any()
510 .downcast_ref::<UInt32Array>()
511 .unwrap();
512
513 assert_eq!(closing_order_id_col.value(0), "O-2");
514 assert!(!ts_closed_col.is_null(0));
515 assert_eq!(ts_closed_col.value(0), 5_000);
516 assert_eq!(duration_col.value(0), 4_000);
517 assert!((avg_px_close_col.value(0) - 50_500.0).abs() < 1e-9);
518 assert!(!realized_pnl_amount_col.is_null(0));
519 assert_eq!(realized_pnl_currency_col.value(0), "USDT");
520 assert_eq!(event_count_col.value(0), 2);
521 }
522
523 #[rstest]
524 fn test_encode_positions_commissions_sorted() {
525 let instrument = currency_pair_btcusdt();
526 let any = InstrumentAny::CurrencyPair(instrument.clone());
527 let usdt_fill = make_fill(
528 &instrument,
529 OrderSide::Buy,
530 "1.0",
531 "50000.0",
532 "T-1",
533 "O-1",
534 1_000,
535 Some(Money::from("0.50 USDT")),
536 );
537 let btc_fill = make_fill(
538 &instrument,
539 OrderSide::Buy,
540 "1.0",
541 "50000.0",
542 "T-2",
543 "O-2",
544 2_000,
545 Some(Money::from("0.00001 BTC")),
546 );
547 let mut position = Position::new(&any, usdt_fill);
548 position.apply(&btc_fill);
549
550 let batch = encode_positions(&[position]).unwrap();
551 let commissions_col = batch
552 .column(34)
553 .as_any()
554 .downcast_ref::<StringArray>()
555 .unwrap();
556
557 let parsed: Vec<(String, f64)> = serde_json::from_str(commissions_col.value(0)).unwrap();
558 let currencies: Vec<&str> = parsed.iter().map(|(c, _)| c.as_str()).collect();
559 assert_eq!(currencies, vec!["BTC", "USDT"]);
560 }
561}