1use crate::{
2 balance::{AssetBalance, Balance},
3 error::UnindexedClientError,
4 position::{Position, PositionReport},
5};
6use chrono::{DateTime, Utc};
7use fnv::FnvHashMap;
8use ibapi::contracts::SecurityType;
9use rust_decimal::Decimal;
10use rustrade_instrument::{
11 asset::name::AssetNameExchange, instrument::name::InstrumentNameExchange,
12};
13use smol_str::SmolStr;
14use std::str::FromStr;
15use tracing::warn;
16
17#[derive(Debug, Default)]
19pub struct BalanceAggregator {
20 balances: FnvHashMap<SmolStr, CurrencyBalance>,
22}
23
24#[derive(Debug, Default, Clone)]
25struct CurrencyBalance {
26 total_cash: Option<Decimal>,
27 available_funds: Option<Decimal>,
28}
29
30impl BalanceAggregator {
31 pub fn new() -> Self {
32 Self::default()
33 }
34
35 pub fn process(&mut self, summary: &ibapi::accounts::AccountSummary) {
37 let currency = SmolStr::new(&summary.currency);
38 let entry = self.balances.entry(currency).or_default();
39
40 match summary.tag.as_str() {
41 "TotalCashValue" => {
42 match Decimal::from_str(&summary.value) {
44 Ok(val) => entry.total_cash = Some(val),
45 Err(e) => {
46 warn!(tag = %summary.tag, value = %summary.value, error = %e, "Failed to parse balance")
47 }
48 }
49 }
50 "AvailableFunds" => match Decimal::from_str(&summary.value) {
51 Ok(val) => entry.available_funds = Some(val),
52 Err(e) => {
53 warn!(tag = %summary.tag, value = %summary.value, error = %e, "Failed to parse balance")
54 }
55 },
56 _ => {}
57 }
58 }
59
60 pub fn to_balances(&self) -> Vec<AssetBalance<AssetNameExchange>> {
62 let now = Utc::now();
63 self.balances
64 .iter()
65 .filter_map(|(currency, bal)| {
66 let total = bal.total_cash?;
67 let free = bal.available_funds.unwrap_or(total);
68
69 Some(AssetBalance {
70 asset: AssetNameExchange::from(currency.as_str()),
71 balance: Balance::new(total, free),
72 time_exchange: now,
73 })
74 })
75 .collect()
76 }
77
78 pub fn clear(&mut self) {
80 self.balances.clear();
81 }
82}
83
84#[derive(Debug, Default)]
93pub(crate) struct PositionAggregator {
94 reports: Vec<(InstrumentNameExchange, ibapi::accounts::Position)>,
96 index: FnvHashMap<(String, InstrumentNameExchange), usize>,
97 listed_all: bool,
100}
101
102impl PositionAggregator {
103 pub(crate) fn listing_ended(&mut self) {
105 self.listed_all = true;
106 }
107
108 pub(crate) fn process(
110 &mut self,
111 instrument: InstrumentNameExchange,
112 position: ibapi::accounts::Position,
113 ) {
114 let key = (position.account.clone(), instrument);
115 match self.index.get(&key) {
116 Some(&i) => self.reports[i].1 = position,
117 None => {
118 self.index.insert(key.clone(), self.reports.len());
119 self.reports.push((key.1, position));
120 }
121 }
122 }
123
124 pub(crate) fn into_reports<'a>(
137 self,
138 now: DateTime<Utc>,
139 requested: impl IntoIterator<Item = &'a InstrumentNameExchange>,
140 ) -> Result<Vec<(InstrumentNameExchange, PositionReport)>, UnindexedClientError> {
141 let listed_all = self.listed_all;
142 let mut reports: Vec<_> = self
143 .into_positions(now)?
144 .into_iter()
145 .map(|(instrument, position)| {
146 let report = position.map_or(PositionReport::Flat, PositionReport::Open);
147 (instrument, report)
148 })
149 .collect();
150 let unlisted: Vec<_> = requested
151 .into_iter()
152 .filter(|instrument| !reports.iter().any(|(listed, _)| listed == *instrument))
153 .cloned()
154 .collect();
155 if listed_all {
156 reports.extend(
157 unlisted
158 .into_iter()
159 .map(|instrument| (instrument, PositionReport::Flat)),
160 );
161 } else if !unlisted.is_empty() {
162 warn!(
163 unlisted = unlisted.len(),
164 "IB did not finish listing positions during the read; requested instruments it \
165 did not list are left out rather than reported flat"
166 );
167 }
168 Ok(reports)
169 }
170
171 pub(crate) fn into_positions(
179 self,
180 now: DateTime<Utc>,
181 ) -> Result<Vec<(InstrumentNameExchange, Option<Position>)>, UnindexedClientError> {
182 let mut positions: Vec<(InstrumentNameExchange, Option<Position>)> = Vec::new();
183 let mut by_instrument = FnvHashMap::default();
184 for (instrument, report) in self.reports {
185 let position = convert_position(&report, now)?;
186 match by_instrument.get(&instrument) {
187 None => {
188 by_instrument.insert(instrument.clone(), positions.len());
189 positions.push((instrument, position));
190 }
191 Some(&i) => {
192 let kept = &mut positions[i].1;
193 match (kept.is_some(), position) {
194 (_, None) => {}
196 (false, position) => *kept = position,
197 (true, Some(_)) => warn!(
198 instrument = %instrument,
199 account = %report.account,
200 "IB reports a position in this instrument in more than one account; \
201 keeping the first account's and dropping this one"
202 ),
203 }
204 }
205 }
206 }
207 Ok(positions)
208 }
209}
210
211fn convert_position(
219 report: &ibapi::accounts::Position,
220 now: DateTime<Utc>,
221) -> Result<Option<Position>, UnindexedClientError> {
222 let quantity = Decimal::try_from(report.position).map_err(|e| {
223 UnindexedClientError::Internal(format!(
224 "IB position quantity {} for contract {} is not a decimal: {e}",
225 report.position, report.contract.contract_id
226 ))
227 })?;
228 if quantity.is_zero() {
229 return Ok(None);
230 }
231 Ok(Some(Position::new(
232 quantity,
233 entry_price(report),
234 None,
235 None,
236 None,
237 None,
238 now,
239 )))
240}
241
242fn entry_price(report: &ibapi::accounts::Position) -> Option<Decimal> {
253 if report.average_cost == 0.0 {
254 return None;
255 }
256 let contract = &report.contract;
257 let average_cost = Decimal::try_from(report.average_cost)
258 .inspect_err(|e| {
259 warn!(
260 contract_id = contract.contract_id,
261 average_cost = report.average_cost,
262 error = %e,
263 "IB average cost is not a decimal; leaving the entry price unset"
264 )
265 })
266 .ok()?;
267 let multiplier = Decimal::from_str(&contract.multiplier)
268 .ok()
269 .filter(|m| m.is_sign_positive() && !m.is_zero());
270 match (multiplier, &contract.security_type) {
271 (Some(multiplier), _) => average_cost.checked_div(multiplier),
272 (None, SecurityType::Stock | SecurityType::ForexPair) => Some(average_cost),
273 (None, security_type) => {
274 warn!(
275 contract_id = contract.contract_id,
276 %security_type,
277 multiplier = %contract.multiplier,
278 "IB sent no contract multiplier; leaving the entry price unset"
279 );
280 None
281 }
282 }
283}
284
285#[cfg(test)]
286#[allow(clippy::unwrap_used)] mod tests {
288 use super::*;
289 use ibapi::accounts::AccountSummary;
290 use rust_decimal_macros::dec;
291 use std::str::FromStr;
292
293 fn mock_account_summary(tag: &str, value: &str, currency: &str) -> AccountSummary {
294 AccountSummary {
295 account: "DU123456".to_string(),
296 tag: tag.to_string(),
297 value: value.to_string(),
298 currency: currency.to_string(),
299 }
300 }
301
302 #[test]
303 fn test_balance_aggregator() {
304 let mut agg = BalanceAggregator::new();
305
306 agg.process(&mock_account_summary("TotalCashValue", "10000.50", "USD"));
307 agg.process(&mock_account_summary("AvailableFunds", "8000.25", "USD"));
308 agg.process(&mock_account_summary("TotalCashValue", "5000.00", "EUR"));
309
310 let balances = agg.to_balances();
311 assert_eq!(balances.len(), 2);
312
313 let usd = balances.iter().find(|b| b.asset.as_ref() == "USD").unwrap();
314 assert_eq!(usd.balance.total, Decimal::from_str("10000.50").unwrap());
315 assert_eq!(usd.balance.free, Decimal::from_str("8000.25").unwrap());
316
317 let eur = balances.iter().find(|b| b.asset.as_ref() == "EUR").unwrap();
318 assert_eq!(eur.balance.total, Decimal::from_str("5000.00").unwrap());
319 assert_eq!(eur.balance.free, Decimal::from_str("5000.00").unwrap());
320 }
321
322 fn report(
323 account: &str,
324 security_type: SecurityType,
325 multiplier: &str,
326 quantity: f64,
327 average_cost: f64,
328 ) -> ibapi::accounts::Position {
329 ibapi::accounts::Position {
330 account: account.to_string(),
331 contract: ibapi::contracts::Contract {
332 contract_id: 42,
333 security_type,
334 multiplier: multiplier.to_string(),
335 ..Default::default()
336 },
337 position: quantity,
338 average_cost,
339 }
340 }
341
342 fn instrument(name: &str) -> InstrumentNameExchange {
343 InstrumentNameExchange::from(name)
344 }
345
346 fn now() -> DateTime<Utc> {
347 DateTime::from_timestamp(1_700_000_000, 0).unwrap()
348 }
349
350 #[test]
351 fn test_convert_position_stock_long() {
352 let position =
353 convert_position(&report("DU1", SecurityType::Stock, "", 10.0, 150.25), now())
354 .unwrap()
355 .unwrap();
356 assert_eq!(
357 position,
358 Position::new(dec!(10), Some(dec!(150.25)), None, None, None, None, now())
359 );
360 }
361
362 #[test]
363 fn test_convert_position_short_keeps_sign() {
364 let position = convert_position(&report("DU1", SecurityType::Stock, "", -5.0, 20.0), now())
365 .unwrap()
366 .unwrap();
367 assert_eq!(position.quantity, dec!(-5));
368 assert_eq!(position.entry_price, Some(dec!(20)));
369 }
370
371 #[test]
372 fn test_convert_position_fractional_quantity_is_rounded_to_float_precision() {
373 let position = convert_position(&report("DU1", SecurityType::Stock, "", 0.3, 100.0), now())
375 .unwrap()
376 .unwrap();
377 assert_eq!(position.quantity, dec!(0.3));
378 }
379
380 #[test]
381 fn test_convert_position_zero_quantity_is_none() {
382 assert_eq!(
383 convert_position(&report("DU1", SecurityType::Stock, "", 0.0, 150.0), now()).unwrap(),
384 None
385 );
386 }
387
388 #[test]
389 fn test_convert_position_non_finite_quantity_is_internal_error() {
390 let result = convert_position(
391 &report("DU1", SecurityType::Stock, "", f64::NAN, 1.0),
392 now(),
393 );
394 assert!(matches!(result, Err(UnindexedClientError::Internal(_))));
395 }
396
397 #[test]
398 fn test_entry_price_divides_by_multiplier() {
399 let option = report("DU1", SecurityType::Option, "100", -2.0, 235.0);
401 assert_eq!(entry_price(&option), Some(dec!(2.35)));
402 let future = report("DU1", SecurityType::Future, "50", 1.0, 250_012.5);
404 assert_eq!(entry_price(&future), Some(dec!(5000.25)));
405 }
406
407 #[test]
408 fn test_entry_price_without_multiplier() {
409 assert_eq!(
411 entry_price(&report("DU1", SecurityType::Stock, "", 1.0, 150.0)),
412 Some(dec!(150))
413 );
414 assert_eq!(
415 entry_price(&report("DU1", SecurityType::ForexPair, "0", 1.0, 1.085)),
416 Some(dec!(1.085))
417 );
418 assert_eq!(
420 entry_price(&report("DU1", SecurityType::Future, "", 1.0, 250_000.0)),
421 None
422 );
423 assert_eq!(
424 entry_price(&report("DU1", SecurityType::Option, "0", 1.0, 235.0)),
425 None
426 );
427 }
428
429 #[test]
430 fn test_entry_price_unset_or_invalid_average_cost_is_none() {
431 assert_eq!(
432 entry_price(&report("DU1", SecurityType::Stock, "", 1.0, 0.0)),
433 None
434 );
435 assert_eq!(
436 entry_price(&report("DU1", SecurityType::Stock, "", 1.0, f64::MAX)),
437 None
438 );
439 assert_eq!(
440 entry_price(&report("DU1", SecurityType::Stock, "", 1.0, f64::NAN)),
441 None
442 );
443 }
444
445 #[test]
446 fn test_position_aggregator_latest_report_per_account_wins() {
447 let mut agg = PositionAggregator::default();
448 agg.process(
449 instrument("AAPL"),
450 report("DU1", SecurityType::Stock, "", 10.0, 150.0),
451 );
452 agg.process(
453 instrument("MSFT"),
454 report("DU1", SecurityType::Stock, "", 3.0, 400.0),
455 );
456 agg.process(
457 instrument("AAPL"),
458 report("DU1", SecurityType::Stock, "", 12.0, 151.0),
459 );
460
461 let positions = agg.into_positions(now()).unwrap();
462 assert_eq!(positions.len(), 2);
463 assert_eq!(positions[0].0, instrument("AAPL"));
464 assert_eq!(positions[0].1.as_ref().unwrap().quantity, dec!(12));
465 assert_eq!(positions[1].0, instrument("MSFT"));
466 }
467
468 #[test]
469 fn test_position_aggregator_closed_position_is_listed_without_position() {
470 let mut agg = PositionAggregator::default();
471 agg.process(
472 instrument("AAPL"),
473 report("DU1", SecurityType::Stock, "", 10.0, 150.0),
474 );
475 agg.process(
476 instrument("AAPL"),
477 report("DU1", SecurityType::Stock, "", 0.0, 0.0),
478 );
479
480 assert_eq!(
481 agg.into_positions(now()).unwrap(),
482 vec![(instrument("AAPL"), None)]
483 );
484 }
485
486 #[test]
487 fn test_position_aggregator_later_flat_account_and_update_keep_latest() {
488 let mut agg = PositionAggregator::default();
489 agg.process(
490 instrument("AAPL"),
491 report("DU2", SecurityType::Stock, "", 7.0, 150.0),
492 );
493 agg.process(
495 instrument("AAPL"),
496 report("DU1", SecurityType::Stock, "", 0.0, 0.0),
497 );
498 agg.process(
500 instrument("AAPL"),
501 report("DU2", SecurityType::Stock, "", 9.0, 151.0),
502 );
503
504 let positions = agg.into_positions(now()).unwrap();
505 assert_eq!(positions.len(), 1);
506 assert_eq!(positions[0].1.as_ref().unwrap().quantity, dec!(9));
507 }
508
509 #[test]
510 fn test_position_aggregator_non_finite_quantity_is_internal_error() {
511 let mut agg = PositionAggregator::default();
512 agg.process(
513 instrument("AAPL"),
514 report("DU1", SecurityType::Stock, "", f64::NAN, 150.0),
515 );
516
517 assert!(matches!(
518 agg.into_positions(now()),
519 Err(UnindexedClientError::Internal(_))
520 ));
521 }
522
523 #[test]
524 fn test_position_aggregator_multi_account_keeps_first_non_zero() {
525 let mut agg = PositionAggregator::default();
526 agg.process(
528 instrument("AAPL"),
529 report("DU1", SecurityType::Stock, "", 0.0, 0.0),
530 );
531 agg.process(
532 instrument("AAPL"),
533 report("DU2", SecurityType::Stock, "", 7.0, 150.0),
534 );
535 agg.process(
537 instrument("AAPL"),
538 report("DU3", SecurityType::Stock, "", 4.0, 149.0),
539 );
540
541 let positions = agg.into_positions(now()).unwrap();
542 assert_eq!(positions.len(), 1);
543 assert_eq!(positions[0].1.as_ref().unwrap().quantity, dec!(7));
544 }
545
546 #[test]
547 fn test_balance_aggregator_clear() {
548 let mut agg = BalanceAggregator::new();
549 agg.process(&mock_account_summary("TotalCashValue", "1000", "USD"));
550 assert_eq!(agg.to_balances().len(), 1);
551
552 agg.clear();
553 assert!(agg.to_balances().is_empty());
554 }
555
556 #[test]
557 fn reports_are_open_or_flat_and_add_unlisted_instruments_only_after_a_complete_listing() {
558 let requested = [instrument("AAPL"), instrument("MSFT")];
559 let reports = |events: &[&str]| {
560 let mut agg = PositionAggregator::default();
561 for event in events {
562 match *event {
563 "end" => agg.listing_ended(),
564 name => {
565 let quantity = if name == "F" { 0.0 } else { 3.0 };
566 agg.process(
567 instrument(name),
568 report("DU1", SecurityType::Stock, "", quantity, 10.0),
569 );
570 }
571 }
572 }
573 agg.into_reports(now(), &requested).unwrap()
574 };
575 let aapl = || {
576 (
577 instrument("AAPL"),
578 PositionReport::from_position(
579 convert_position(&report("DU1", SecurityType::Stock, "", 3.0, 10.0), now())
580 .unwrap()
581 .unwrap(),
582 ),
583 )
584 };
585
586 assert_eq!(
587 reports(&["AAPL", "F", "end"]),
588 vec![
589 aapl(),
590 (instrument("F"), PositionReport::Flat),
591 (instrument("MSFT"), PositionReport::Flat),
592 ]
593 );
594 assert_eq!(
596 reports(&["AAPL", "F"]),
597 vec![aapl(), (instrument("F"), PositionReport::Flat)]
598 );
599 }
600}