Skip to main content

nautilus_interactive_brokers/execution/
account.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Account management for Interactive Brokers execution client.
17
18use std::{collections::HashMap, sync::Arc};
19
20use anyhow::Context;
21use ibapi::{
22    accounts::{
23        AccountSummary, AccountSummaryResult, AccountSummaryTags,
24        types::{AccountGroup, AccountId as IbAccountId},
25    },
26    client::Client,
27    prelude::{StreamExt, SubscriptionItemStreamExt},
28};
29use nautilus_common::{
30    live::runner::get_exec_event_sender,
31    messages::{ExecutionEvent, ExecutionReport},
32};
33use nautilus_core::{Params, time::get_atomic_clock_realtime};
34use nautilus_live::task::TaskGroup;
35use nautilus_model::{
36    enums::PositionSide,
37    identifiers::AccountId,
38    instruments::Instrument,
39    reports::PositionStatusReport,
40    types::{AccountBalance, Currency, MarginBalance, Money, Quantity},
41};
42use rust_decimal::{Decimal, prelude::ToPrimitive};
43
44pub(crate) fn raw_ib_account_code(account_id: &AccountId) -> String {
45    account_id
46        .to_string()
47        .strip_prefix("IB-")
48        .unwrap_or(account_id.as_str())
49        .to_string()
50}
51
52/// Subscribe to account summary and parse to balances and margins.
53///
54/// # Errors
55///
56/// Returns an error if subscription fails.
57pub async fn subscribe_account_summary(
58    client: &Arc<Client>,
59    account_id: AccountId,
60) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>, Option<Params>)> {
61    let raw_account_id = raw_ib_account_code(&account_id);
62    // Request key account summary tags (includes TotalCashValue to match Python account summary info dict).
63    let tags = &[
64        AccountSummaryTags::NET_LIQUIDATION,
65        AccountSummaryTags::TOTAL_CASH_VALUE,
66        AccountSummaryTags::SETTLED_CASH,
67        AccountSummaryTags::BUYING_POWER,
68        AccountSummaryTags::EQUITY_WITH_LOAN_VALUE,
69        AccountSummaryTags::AVAILABLE_FUNDS,
70        AccountSummaryTags::EXCESS_LIQUIDITY,
71        AccountSummaryTags::INIT_MARGIN_REQ,
72        AccountSummaryTags::MAINT_MARGIN_REQ,
73        AccountSummaryTags::CUSHION,
74    ];
75
76    let group = AccountGroup("All".to_string());
77    let subscription = client
78        .account_summary(&group, tags)
79        .await
80        .context("Failed to subscribe to account summary")?;
81    let mut subscription = subscription.filter_data();
82
83    tracing::debug!("Subscribed to account summary for account: {}", account_id);
84
85    // Process initial account summary snapshot
86    // We collect all summary items until the API sends AccountSummaryResult::End, so the
87    // returned balances/margins are complete (matches Python behavior of waiting for all tags).
88    let mut balances: Vec<AccountBalance> = Vec::new();
89    let mut margins: Vec<MarginBalance> = Vec::new();
90    let mut info = Params::new();
91
92    while let Some(result) = subscription.next().await {
93        match result {
94            Ok(AccountSummaryResult::Summary(summary)) => {
95                // Filter for the specific account
96                if summary.account != raw_account_id {
97                    continue;
98                }
99
100                // Record the raw summary tag so the account state carries the
101                // venue-reported values (for example TotalCashValue) that do not
102                // map to the typed balances and margins.
103                info.insert(
104                    summary.tag.to_string(),
105                    serde_json::Value::from(summary.value.as_str()),
106                );
107
108                match parse_account_summary_to_balance(&summary) {
109                    Ok(balance) => {
110                        // Check if balance already exists for this currency
111                        if let Some(existing) = balances
112                            .iter_mut()
113                            .find(|b| b.total.currency == balance.total.currency)
114                        {
115                            if let Some(merged) = merge_account_summary_balance(
116                                existing,
117                                summary.tag.as_str(),
118                                &summary.value,
119                                &summary.currency,
120                            )? {
121                                *existing = merged;
122                            }
123                        } else {
124                            balances.push(balance);
125                        }
126                    }
127                    Err(e) => {
128                        tracing::warn!("Failed to parse account summary: {}", e);
129                    }
130                }
131
132                // Accumulate margin requirements by currency. IB reports INIT_MARGIN_REQ
133                // and MAINT_MARGIN_REQ as separate summary entries; merge them into one
134                // `MarginBalance` per currency so neither half overwrites the other when
135                // the account-wide margin store keys by `Currency`.
136                merge_account_summary_margin(&mut margins, &summary);
137            }
138            Ok(AccountSummaryResult::End) => {
139                break;
140            }
141            Err(e) => {
142                tracing::warn!("Error receiving account summary: {}", e);
143            }
144        }
145    }
146
147    tracing::debug!(
148        "Received account summary: {} balances, {} margins",
149        balances.len(),
150        margins.len()
151    );
152
153    Ok((
154        balances,
155        margins,
156        if info.is_empty() { None } else { Some(info) },
157    ))
158}
159
160fn merge_account_summary_margin(margins: &mut Vec<MarginBalance>, summary: &AccountSummary) {
161    let currency = match parse_currency(&summary.currency) {
162        Ok(currency) => currency,
163        Err(e) => {
164            tracing::warn!("Skipping margin summary with unknown currency: {}", e);
165            return;
166        }
167    };
168    let value = match parse_balance_decimal(&summary.value)
169        .and_then(|d| Money::from_decimal(d, currency).map_err(|e| anyhow::anyhow!(e.to_string())))
170    {
171        Ok(money) => money,
172        Err(e) => {
173            tracing::warn!("Failed to parse margin value '{}': {}", summary.value, e);
174            return;
175        }
176    };
177
178    let existing = margins
179        .iter_mut()
180        .find(|m| m.currency == currency && m.instrument_id.is_none());
181
182    match summary.tag.as_str() {
183        AccountSummaryTags::INIT_MARGIN_REQ => match existing {
184            Some(margin) => margin.initial = value,
185            None => margins.push(MarginBalance::new(value, Money::zero(currency), None)),
186        },
187        AccountSummaryTags::MAINT_MARGIN_REQ => match existing {
188            Some(margin) => margin.maintenance = value,
189            None => margins.push(MarginBalance::new(Money::zero(currency), value, None)),
190        },
191        _ => {}
192    }
193}
194
195fn merge_account_summary_balance(
196    existing: &AccountBalance,
197    tag: &str,
198    value: &str,
199    currency_code: &str,
200) -> anyhow::Result<Option<AccountBalance>> {
201    let currency = parse_currency(currency_code)?;
202
203    match tag {
204        AccountSummaryTags::SETTLED_CASH => {
205            let settled_cash = parse_balance_decimal(value)?;
206            Ok(Some(AccountBalance::from_total_and_locked(
207                settled_cash,
208                Decimal::ZERO,
209                currency,
210            )?))
211        }
212        AccountSummaryTags::NET_LIQUIDATION => {
213            let net_liq = parse_balance_decimal(value)?;
214            Ok(Some(AccountBalance::from_total_and_free(
215                net_liq,
216                existing.free.as_decimal(),
217                currency,
218            )?))
219        }
220        _ => Ok(None),
221    }
222}
223
224/// Subscribe to PnL updates for the account.
225///
226/// This spawns a background task to handle PnL updates.
227///
228/// # Errors
229///
230/// Returns an error if subscription fails.
231pub async fn subscribe_pnl(
232    client: &Arc<Client>,
233    account_id: AccountId,
234    session_tasks: &TaskGroup,
235) -> anyhow::Result<()> {
236    let account = IbAccountId(raw_ib_account_code(&account_id));
237    let subscription = client
238        .pnl(&account, None)
239        .await
240        .context("Failed to subscribe to PnL")?;
241    let mut subscription = subscription.filter_data();
242
243    tracing::debug!("Subscribed to PnL updates for account: {}", account_id);
244
245    // Process PnL updates in background task
246    let future = async move {
247        while let Some(result) = subscription.next().await {
248            match result {
249                Ok(pnl) => {
250                    tracing::debug!(
251                        "PnL update - Daily: {:.2}, Unrealized: {:?}, Realized: {:?}",
252                        pnl.daily_pnl,
253                        pnl.unrealized_pnl,
254                        pnl.realized_pnl
255                    );
256                    // Note: Account state updates are handled by position updates and account summary
257                    // PnL is informational and tracked separately. If needed, account state can be
258                    // generated by subscribing to account summary which includes updated balances.
259                }
260                Err(e) => {
261                    tracing::warn!("Error receiving PnL update: {}", e);
262                }
263            }
264        }
265    };
266    session_tasks
267        .spawn(future)
268        .context("Failed to register IB PnL task")?;
269
270    Ok(())
271}
272
273/// Track known positions for detecting external changes (e.g., option exercises).
274pub type PositionTracker = Arc<tokio::sync::Mutex<HashMap<i32, Decimal>>>;
275
276/// Create a new position tracker.
277pub fn create_position_tracker() -> PositionTracker {
278    Arc::new(tokio::sync::Mutex::new(HashMap::new()))
279}
280
281/// Check if a position update represents an external change (e.g., option exercise).
282pub async fn check_external_position_change(
283    position_tracker: &PositionTracker,
284    contract_id: i32,
285    new_quantity: Decimal,
286) -> Option<(bool, Decimal)> {
287    let mut tracker = position_tracker.lock().await;
288    let known_quantity = tracker.get(&contract_id).copied().unwrap_or(Decimal::ZERO);
289
290    if new_quantity.is_zero() {
291        return (!known_quantity.is_zero()).then_some((true, known_quantity));
292    }
293
294    // Check if this is an external position change
295    // If quantities match, this is likely from normal trading - not external
296    if known_quantity == new_quantity {
297        return None;
298    }
299
300    // This is a change - determine if it's external
301    // External changes occur when position changes without a corresponding execution
302    // Update tracked position
303    tracker.insert(contract_id, new_quantity);
304
305    // If we had a known position and it changed, it's likely external
306    if known_quantity != Decimal::ZERO && known_quantity != new_quantity {
307        Some((true, known_quantity))
308    } else {
309        // New position or first time seeing it
310        Some((false, known_quantity))
311    }
312}
313
314/// Initialize position tracking with existing positions.
315///
316/// This fetches all current positions and initializes the position tracker
317/// to avoid processing duplicates from execDetails.
318///
319/// # Errors
320///
321/// Returns an error if position request fails.
322pub async fn initialize_position_tracking(
323    client: &Arc<Client>,
324    account_id: AccountId,
325    position_tracker: PositionTracker,
326) -> anyhow::Result<()> {
327    let raw_account_id = raw_ib_account_code(&account_id);
328    let subscription = client
329        .positions()
330        .await
331        .context("Failed to request positions")?;
332    let mut subscription = subscription.filter_data();
333
334    tracing::debug!("Initializing position tracking for account: {}", account_id);
335
336    let mut position_count = 0;
337    let mut tracker = position_tracker.lock().await;
338
339    while let Some(result) = subscription.next().await {
340        match result {
341            Ok(ibapi::accounts::PositionUpdate::Position(position)) => {
342                // Filter for the specific account
343                if position.account != raw_account_id {
344                    continue;
345                }
346
347                let contract_id = position.contract.contract_id;
348                let quantity = Decimal::from_f64_retain(position.position).unwrap_or_default();
349
350                // Only track non-zero positions
351                if !quantity.is_zero() {
352                    tracker.insert(contract_id, quantity);
353                    position_count += 1;
354                }
355            }
356            Ok(ibapi::accounts::PositionUpdate::PositionEnd) => {
357                break;
358            }
359            Err(e) => {
360                tracing::warn!("Error receiving position update: {}", e);
361            }
362        }
363    }
364
365    tracing::debug!(
366        "Initialized tracking for {} existing positions",
367        position_count
368    );
369
370    Ok(())
371}
372
373/// Subscribe to real-time position updates for detecting external position changes (e.g., option exercises).
374///
375/// This spawns a background task to track position changes and generate position status reports
376/// for external changes.
377///
378/// # Errors
379///
380/// Returns an error if subscription fails.
381pub async fn subscribe_positions(
382    client: &Arc<Client>,
383    account_id: AccountId,
384    position_tracker: PositionTracker,
385    instrument_provider: Arc<crate::providers::instruments::InteractiveBrokersInstrumentProvider>,
386    session_tasks: &TaskGroup,
387) -> anyhow::Result<()> {
388    let raw_account_id = raw_ib_account_code(&account_id);
389    let subscription = client
390        .positions()
391        .await
392        .context("Failed to subscribe to positions")?;
393    let mut subscription = subscription.filter_data();
394
395    tracing::debug!("Subscribed to position updates for account: {}", account_id);
396
397    let exec_sender = get_exec_event_sender();
398    let clock = get_atomic_clock_realtime();
399    let client_for_instruments = Arc::clone(client);
400
401    // Spawn background task to handle position updates
402    let future = async move {
403        while let Some(result) = subscription.next().await {
404            match result {
405                Ok(ibapi::accounts::PositionUpdate::Position(position)) => {
406                    if position.account != raw_account_id {
407                        continue;
408                    }
409
410                    let contract_id = position.contract.contract_id;
411                    let new_quantity =
412                        Decimal::from_f64_retain(position.position).unwrap_or_default();
413
414                    // Check if this is an external position change
415                    if let Some((is_external, old_quantity)) =
416                        check_external_position_change(&position_tracker, contract_id, new_quantity)
417                            .await
418                        && is_external
419                    {
420                        tracing::warn!(
421                            "External position change detected (likely option exercise): \
422                                Contract ID {}, quantity change: {} -> {}",
423                            contract_id,
424                            old_quantity,
425                            new_quantity
426                        );
427
428                        match instrument_provider
429                            .get_instrument(&client_for_instruments, &position.contract)
430                            .await
431                        {
432                            Ok(Some(instrument)) => {
433                                let instrument_id = instrument.id();
434                                let position_side = if new_quantity.is_zero() {
435                                    PositionSide::Flat
436                                } else if new_quantity > Decimal::ZERO {
437                                    PositionSide::Long
438                                } else {
439                                    PositionSide::Short
440                                };
441
442                                let quantity = Quantity::new(
443                                    new_quantity.abs().to_f64().unwrap_or(0.0),
444                                    instrument.size_precision(),
445                                );
446
447                                let avg_px_open = if position.average_cost > 0.0 {
448                                    let price_magnifier = instrument_provider
449                                        .get_price_magnifier(&instrument_id)
450                                        as f64;
451                                    let multiplier = instrument.multiplier().as_f64();
452                                    let converted_avg_cost =
453                                        position.average_cost / (multiplier * price_magnifier);
454                                    let price_precision = instrument.price_precision();
455                                    Some(
456                                        Decimal::from_f64_retain(converted_avg_cost)
457                                            .map(|d| d.round_dp(price_precision as u32))
458                                            .unwrap_or_default(),
459                                    )
460                                } else {
461                                    None
462                                };
463
464                                let ts_init = clock.get_time_ns();
465
466                                let report = PositionStatusReport::new(
467                                    account_id,
468                                    instrument_id,
469                                    position_side,
470                                    quantity,
471                                    ts_init,
472                                    ts_init,
473                                    None,
474                                    None,
475                                    avg_px_open,
476                                );
477                                let event = ExecutionEvent::Report(ExecutionReport::Position(
478                                    Box::new(report),
479                                ));
480
481                                if exec_sender.send(event).is_err() {
482                                    tracing::warn!(
483                                        "Failed to send position status report for external change"
484                                    );
485                                } else {
486                                    if new_quantity.is_zero() {
487                                        position_tracker.lock().await.remove(&contract_id);
488                                    }
489
490                                    tracing::info!(
491                                        "Generated position status report for external change (likely option exercise)"
492                                    );
493                                }
494                            }
495                            Ok(None) => {
496                                tracing::warn!(
497                                    "Instrument not found for external position contract ID: {}",
498                                    contract_id
499                                );
500                            }
501                            Err(e) => {
502                                tracing::warn!(
503                                    "Failed to resolve external position contract ID {}: {}",
504                                    contract_id,
505                                    e
506                                );
507                            }
508                        }
509                    }
510                }
511                Ok(ibapi::accounts::PositionUpdate::PositionEnd) => {
512                    break;
513                }
514                Err(e) => {
515                    tracing::warn!("Error receiving position update: {}", e);
516                }
517            }
518        }
519    };
520    session_tasks
521        .spawn(future)
522        .context("Failed to register IB position task")?;
523
524    Ok(())
525}
526
527/// Parse IB account summary to Nautilus AccountBalance.
528fn parse_account_summary_to_balance(summary: &AccountSummary) -> anyhow::Result<AccountBalance> {
529    let currency = parse_currency(&summary.currency)?;
530    let balance = parse_balance_decimal(&summary.value)?;
531
532    match summary.tag.as_str() {
533        AccountSummaryTags::SETTLED_CASH | AccountSummaryTags::TOTAL_CASH_VALUE => {
534            // Cash balance - free equals total for settled cash
535            AccountBalance::from_total_and_locked(balance, Decimal::ZERO, currency)
536                .map_err(Into::into)
537        }
538        AccountSummaryTags::NET_LIQUIDATION => {
539            // Net liquidation - represents total equity
540            // Free would be calculated from available funds
541            AccountBalance::from_total_and_locked(balance, Decimal::ZERO, currency)
542                .map_err(Into::into)
543        }
544        AccountSummaryTags::BUYING_POWER | AccountSummaryTags::AVAILABLE_FUNDS => {
545            // Available funds - this is the free amount
546            AccountBalance::from_total_and_free(balance, balance, currency).map_err(Into::into)
547        }
548        _ => {
549            // Default: treat as total balance
550            AccountBalance::from_total_and_locked(balance, Decimal::ZERO, currency)
551                .map_err(Into::into)
552        }
553    }
554}
555
556fn parse_balance_decimal(value: &str) -> anyhow::Result<Decimal> {
557    value
558        .parse::<Decimal>()
559        .context(format!("Failed to parse balance value: {}", value))
560}
561
562fn parse_currency(currency: &str) -> anyhow::Result<Currency> {
563    anyhow::ensure!(!currency.is_empty(), "Account summary currency was empty");
564    Ok(Currency::from(currency))
565}
566
567#[cfg(test)]
568mod tests {
569    use ibapi::accounts::AccountSummary;
570    use nautilus_model::types::{AccountBalance, Currency, MarginBalance, Money};
571    use rstest::rstest;
572    use rust_decimal::Decimal;
573
574    use super::{
575        AccountSummaryTags, check_external_position_change, create_position_tracker,
576        merge_account_summary_balance, merge_account_summary_margin, parse_currency,
577    };
578
579    fn margin_summary(tag: &str, value: &str, currency: &str) -> AccountSummary {
580        AccountSummary {
581            account: "DU123".to_string(),
582            tag: tag.to_string(),
583            value: value.to_string(),
584            currency: currency.to_string(),
585        }
586    }
587
588    /// Verifies the IB avg cost to Nautilus price conversion formula used in position parsing.
589    /// Python: converted_avg_cost = avg_cost / (multiplier * price_magnifier)
590    #[rstest]
591    fn test_ib_avg_cost_to_price_conversion() {
592        let avg_cost = 100.0;
593        let multiplier = 10.0;
594        let price_magnifier = 2.0;
595        let converted = avg_cost / (multiplier * price_magnifier);
596        assert_eq!(converted, 5.0);
597
598        let avg_cost2 = 1_500_000.0;
599        let multiplier2 = 50.0;
600        let price_magnifier2 = 10;
601        let converted2 = avg_cost2 / (multiplier2 * (price_magnifier2 as f64));
602        assert_eq!(converted2, 3000.0);
603    }
604
605    #[rstest]
606    fn test_parse_currency_rejects_empty_string() {
607        let result = parse_currency("");
608        assert!(result.is_err());
609        assert_eq!(
610            result.unwrap_err().to_string(),
611            "Account summary currency was empty",
612        );
613    }
614
615    #[rstest]
616    #[tokio::test]
617    async fn test_external_position_change_reports_tracked_zero_close() {
618        let tracker = create_position_tracker();
619        tracker.lock().await.insert(42, Decimal::new(5, 0));
620
621        let change = check_external_position_change(&tracker, 42, Decimal::ZERO).await;
622
623        assert_eq!(change, Some((true, Decimal::new(5, 0))));
624        assert_eq!(
625            tracker.lock().await.get(&42).copied(),
626            Some(Decimal::new(5, 0))
627        );
628    }
629
630    #[rstest]
631    fn test_net_liquidation_merge_clamps_free_to_total() {
632        let existing = AccountBalance::from_total_and_free(
633            "120.00".parse().unwrap(),
634            "120.00".parse().unwrap(),
635            Currency::USD(),
636        )
637        .unwrap();
638
639        let merged = merge_account_summary_balance(
640            &existing,
641            AccountSummaryTags::NET_LIQUIDATION,
642            "100.00",
643            "USD",
644        )
645        .unwrap()
646        .unwrap();
647
648        assert_eq!(merged.total.as_decimal(), "100.00".parse().unwrap());
649        assert_eq!(merged.locked.as_decimal(), "0.00".parse().unwrap());
650        assert_eq!(merged.free.as_decimal(), "100.00".parse().unwrap());
651    }
652
653    #[rstest]
654    fn test_merge_account_summary_margin_combines_init_and_maint() {
655        // Regression: `INIT_MARGIN_REQ` and `MAINT_MARGIN_REQ` arrive as separate
656        // summary entries. The merge must land in a single `MarginBalance` per
657        // currency so neither half overwrites the other once the account-wide
658        // store keys by `Currency`.
659        let mut margins: Vec<MarginBalance> = Vec::new();
660
661        merge_account_summary_margin(
662            &mut margins,
663            &margin_summary(AccountSummaryTags::INIT_MARGIN_REQ, "500.00", "USD"),
664        );
665        merge_account_summary_margin(
666            &mut margins,
667            &margin_summary(AccountSummaryTags::MAINT_MARGIN_REQ, "250.00", "USD"),
668        );
669
670        assert_eq!(margins.len(), 1);
671        let margin = &margins[0];
672        assert!(margin.instrument_id.is_none());
673        assert_eq!(margin.currency, Currency::USD());
674        assert_eq!(margin.initial, Money::from("500.00 USD"));
675        assert_eq!(margin.maintenance, Money::from("250.00 USD"));
676    }
677
678    #[rstest]
679    fn test_merge_account_summary_margin_order_independent() {
680        // Arrival order should not matter.
681        let mut margins: Vec<MarginBalance> = Vec::new();
682
683        merge_account_summary_margin(
684            &mut margins,
685            &margin_summary(AccountSummaryTags::MAINT_MARGIN_REQ, "250.00", "USD"),
686        );
687        merge_account_summary_margin(
688            &mut margins,
689            &margin_summary(AccountSummaryTags::INIT_MARGIN_REQ, "500.00", "USD"),
690        );
691
692        assert_eq!(margins.len(), 1);
693        let margin = &margins[0];
694        assert_eq!(margin.initial, Money::from("500.00 USD"));
695        assert_eq!(margin.maintenance, Money::from("250.00 USD"));
696    }
697
698    #[rstest]
699    fn test_merge_account_summary_margin_separates_currencies() {
700        let mut margins: Vec<MarginBalance> = Vec::new();
701
702        merge_account_summary_margin(
703            &mut margins,
704            &margin_summary(AccountSummaryTags::INIT_MARGIN_REQ, "500.00", "USD"),
705        );
706        merge_account_summary_margin(
707            &mut margins,
708            &margin_summary(AccountSummaryTags::INIT_MARGIN_REQ, "400.00", "EUR"),
709        );
710
711        assert_eq!(margins.len(), 2);
712        let usd = margins
713            .iter()
714            .find(|m| m.currency == Currency::USD())
715            .unwrap();
716        let eur = margins
717            .iter()
718            .find(|m| m.currency == Currency::EUR())
719            .unwrap();
720        assert_eq!(usd.initial, Money::from("500.00 USD"));
721        assert_eq!(eur.initial, Money::from("400.00 EUR"));
722    }
723}