investments 2.6.9

Helps you with managing your investments
Documentation
use std::collections::{HashSet, HashMap, BTreeMap};
use std::fs::File;
use std::io::Read;

use chrono::Duration;
use num_traits::FromPrimitive;
use serde::Deserialize;
use serde::de::{Deserializer, Error};

use crate::brokers::Broker;
use crate::core::{GenericResult, EmptyResult};
use crate::formatting;
use crate::localities::{self, Country, Jurisdiction};
use crate::taxes::{TaxExemption, TaxPaymentDay, TaxPaymentDaySpec, TaxRemapping};
use crate::types::{Date, Decimal};
use crate::util::{self, DecimalRestrictions};

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct Config {
    #[serde(skip)]
    pub db_path: String,
    #[serde(skip, default = "default_expire_time")]
    pub cache_expire_time: Duration,

    #[serde(default)]
    pub deposits: Vec<DepositConfig>,
    pub notify_deposit_closing_days: Option<u32>,

    #[serde(default)]
    pub portfolios: Vec<PortfolioConfig>,
    pub brokers: Option<BrokersConfig>,

    #[serde(default)]
    pub tax_rates: TaxRates,

    #[serde(default)]
    pub metrics: MetricsConfig,

    pub alphavantage: Option<AlphaVantageConfig>,
    pub finnhub: Option<FinnhubConfig>,
    pub twelvedata: Option<TwelveDataConfig>,
}

impl Config {
    #[cfg(test)]
    pub fn mock() -> Config {
        Config {
            db_path: s!("/mock"),
            cache_expire_time: default_expire_time(),

            deposits: Vec::new(),
            notify_deposit_closing_days: None,

            portfolios: Vec::new(),
            brokers: Some(BrokersConfig::mock()),
            tax_rates: Default::default(),
            metrics: Default::default(),

            alphavantage: None,
            finnhub: None,
            twelvedata: None,
        }
    }

    pub fn load(path: &str) -> GenericResult<Config> {
        let mut data = Vec::new();
        File::open(path)?.read_to_end(&mut data)?;

        let mut config: Config = serde_yaml::from_slice(&data)?;

        for deposit in &config.deposits {
            if deposit.open_date > deposit.close_date {
                return Err!(
                    "Invalid {:?} deposit dates: {} -> {}",
                    deposit.name, formatting::format_date(deposit.open_date),
                    formatting::format_date(deposit.close_date));
            }

            for &(date, _amount) in &deposit.contributions {
                if date < deposit.open_date || date > deposit.close_date {
                    return Err!(
                        "Invalid {:?} deposit contribution date: {}",
                        deposit.name, formatting::format_date(date));
                }
            }
        }

        {
            let mut portfolio_names = HashSet::new();

            for portfolio in &config.portfolios {
                if !portfolio_names.insert(&portfolio.name) {
                    return Err!("Duplicate portfolio name: {:?}", portfolio.name);
                }

                if let Some(ref currency) = portfolio.currency {
                    match currency.as_str() {
                        "RUB" | "USD" => (),
                        _ => return Err!("Unsupported portfolio currency: {}", currency),
                    };
                }

                for (symbol, mapping) in &portfolio.symbol_remapping {
                    if portfolio.symbol_remapping.get(mapping).is_some() {
                        return Err!(
                            "Invalid symbol remapping configuration: Recursive {} symbol",
                            symbol);
                    }
                }

                validate_performance_merging_configuration(&portfolio.merge_performance)?;

                if
                    matches!(portfolio.tax_payment_day_spec, TaxPaymentDaySpec::OnClose(_)) &&
                    portfolio.broker.jurisdiction() != Jurisdiction::Russia
                {
                    return Err!("On close tax payment date is only available for brokers with Russia jurisdiction")
                }

                if !portfolio.tax_exemptions.is_empty() && portfolio.broker.jurisdiction() != Jurisdiction::Russia {
                    return Err!("Tax exemptions are only supported for brokers with Russia jurisdiction")
                }
            }
        }

        for portfolio in &mut config.portfolios {
            portfolio.statements = shellexpand::tilde(&portfolio.statements).to_string();
        }

        for &tax_rates in &[
            &config.tax_rates.trading,
            &config.tax_rates.dividends,
            &config.tax_rates.interest,
        ] {
            for (&year, &tax_rate) in tax_rates {
                if year < 0 {
                    return Err!("Invalid tax rate year: {}", year);
                } else if tax_rate < dec!(0) || tax_rate > dec!(100) {
                    return Err!("Invalid tax rate: {}", tax_rate);
                }
            }
        }

        validate_performance_merging_configuration(&config.metrics.merge_performance)?;

        Ok(config)
    }

    pub fn get_tax_country(&self) -> Country {
        localities::russia(&self.tax_rates.trading, &self.tax_rates.dividends, &self.tax_rates.interest)
    }

    pub fn get_portfolio(&self, name: &str) -> GenericResult<&PortfolioConfig> {
        for portfolio in &self.portfolios {
            if portfolio.name == name {
                return Ok(portfolio)
            }
        }

        Err!("{:?} portfolio is not defined in the configuration file", name)
    }
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct DepositConfig {
    pub name: String,

    #[serde(deserialize_with = "deserialize_date")]
    pub open_date: Date,
    #[serde(deserialize_with = "deserialize_date")]
    pub close_date: Date,

    #[serde(default)]
    pub currency: Option<String>,
    pub amount: Decimal,
    pub interest: Decimal,
    #[serde(default)]
    pub capitalization: bool,
    #[serde(default, deserialize_with = "deserialize_cash_flows")]
    pub contributions: Vec<(Date, Decimal)>,
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct PortfolioConfig {
    pub name: String,
    pub broker: Broker,
    pub plan: Option<String>,

    pub statements: String,
    #[serde(default)]
    pub symbol_remapping: HashMap<String, String>,
    #[serde(default)]
    pub instrument_names: HashMap<String, String>,
    #[serde(default)]
    tax_remapping: Vec<TaxRemappingConfig>,

    pub currency: Option<String>,
    pub min_trade_volume: Option<Decimal>,
    pub min_cash_assets: Option<Decimal>,
    pub restrict_buying: Option<bool>,
    pub restrict_selling: Option<bool>,

    #[serde(default)]
    pub merge_performance: PerformanceMergingConfig,

    #[serde(default)]
    pub assets: Vec<AssetAllocationConfig>,

    #[serde(default, rename = "tax_payment_day", deserialize_with = "TaxPaymentDaySpec::deserialize")]
    tax_payment_day_spec: TaxPaymentDaySpec,

    #[serde(default)]
    pub tax_exemptions: Vec<TaxExemption>,

    #[serde(default, deserialize_with = "deserialize_cash_flows")]
    pub tax_deductions: Vec<(Date, Decimal)>,
}

impl PortfolioConfig {
    pub fn currency(&self) -> GenericResult<&str> {
        Ok(self.currency.as_ref().ok_or("The portfolio's currency is not specified in the config")?)
    }

    pub fn get_stock_symbols(&self) -> HashSet<String> {
        let mut symbols = HashSet::new();

        for asset in &self.assets {
            asset.get_stock_symbols(&mut symbols);
        }

        symbols
    }

    pub fn tax_payment_day(&self) -> TaxPaymentDay {
        TaxPaymentDay::new(self.broker.jurisdiction(), self.tax_payment_day_spec)
    }

    pub fn get_tax_remapping(&self) -> GenericResult<TaxRemapping> {
        let mut remapping = TaxRemapping::new();

        for config in &self.tax_remapping {
            remapping.add(config.date, &config.description, config.to_date)?;
        }

        Ok(remapping)
    }

    pub fn close_date() -> Date {
        util::today()
    }
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct TaxRemappingConfig {
    #[serde(deserialize_with = "deserialize_date")]
    pub date: Date,
    pub description: String,
    #[serde(deserialize_with = "deserialize_date")]
    pub to_date: Date,
}

#[derive(Deserialize, Debug)]
pub struct AssetAllocationConfig {
    pub name: String,
    pub symbol: Option<String>,

    #[serde(deserialize_with = "deserialize_weight")]
    pub weight: Decimal,
    pub restrict_buying: Option<bool>,
    pub restrict_selling: Option<bool>,

    pub assets: Option<Vec<AssetAllocationConfig>>,
}

impl AssetAllocationConfig {
    fn get_stock_symbols(&self, symbols: &mut HashSet<String>) {
        if let Some(ref symbol) = self.symbol {
            symbols.insert(symbol.to_owned());
        }

        if let Some(ref assets) = self.assets {
            for asset in assets {
                asset.get_stock_symbols(symbols);
            }
        }
    }
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct BrokersConfig {
    pub bcs: Option<BrokerConfig>,
    pub firstrade: Option<BrokerConfig>,
    pub interactive_brokers: Option<BrokerConfig>,
    pub open_broker: Option<BrokerConfig>,
    pub tinkoff: Option<BrokerConfig>,
}

impl BrokersConfig {
    #[cfg(test)]
    pub fn mock() -> BrokersConfig {
        BrokersConfig {
            bcs: Some(BrokerConfig::mock()),
            firstrade: Some(BrokerConfig::mock()),
            interactive_brokers: Some(BrokerConfig::mock()),
            open_broker: Some(BrokerConfig::mock()),
            tinkoff: Some(BrokerConfig::mock()),
        }
    }
}

#[derive(Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct BrokerConfig {
    pub deposit_commissions: HashMap<String, TransactionCommissionSpec>,
}

impl BrokerConfig {
    #[cfg(test)]
    pub fn mock() -> BrokerConfig {
        BrokerConfig {
            deposit_commissions: HashMap::new(),
        }
    }
}

#[derive(Deserialize, Default, Debug)]
#[serde(deny_unknown_fields)]
pub struct TaxRates {
    #[serde(default)]
    pub trading: BTreeMap<i32, Decimal>,
    #[serde(default)]
    pub dividends: BTreeMap<i32, Decimal>,
    #[serde(default)]
    pub interest: BTreeMap<i32, Decimal>,
}

#[derive(Deserialize, Default, Debug)]
#[serde(deny_unknown_fields)]
pub struct MetricsConfig {
    #[serde(default)]
    pub merge_performance: PerformanceMergingConfig,
}

#[derive(Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct TransactionCommissionSpec {
    pub fixed_amount: Decimal,
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct AlphaVantageConfig {
    pub api_key: String,
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct FinnhubConfig {
    pub token: String,
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct TwelveDataConfig {
    pub token: String,
}

fn default_expire_time() -> Duration {
    Duration::minutes(1)
}

fn deserialize_cash_flows<'de, D>(deserializer: D) -> Result<Vec<(Date, Decimal)>, D::Error>
    where D: Deserializer<'de>
{
    let deserialized: HashMap<String, String> = Deserialize::deserialize(deserializer)?;
    let mut cash_flows = Vec::new();

    for (date, amount) in deserialized {
        let date = util::parse_date(&date, "%d.%m.%Y").map_err(D::Error::custom)?;
        let amount = util::parse_decimal(&amount, DecimalRestrictions::StrictlyPositive).map_err(|_|
            D::Error::custom(format!("Invalid amount: {:?}", amount)))?;

        cash_flows.push((date, amount));
    }

    cash_flows.sort_by_key(|cash_flow| cash_flow.0);

    Ok(cash_flows)
}

fn deserialize_date<'de, D>(deserializer: D) -> Result<Date, D::Error>
    where D: Deserializer<'de>
{
    let date: String = Deserialize::deserialize(deserializer)?;
    Ok(util::parse_date(&date, "%d.%m.%Y").map_err(D::Error::custom)?)
}

fn deserialize_weight<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
    where D: Deserializer<'de>
{
    let weight: String = Deserialize::deserialize(deserializer)?;
    if !weight.ends_with('%') {
        return Err(D::Error::custom(format!("Invalid weight: {}", weight)));
    }

    let weight = match weight[..weight.len() - 1].parse::<u8>().ok() {
        Some(weight) if weight <= 100 => weight,
        _ => return Err(D::Error::custom(format!("Invalid weight: {}", weight))),
    };

    Ok(Decimal::from_u8(weight).unwrap() / dec!(100))
}

pub type PerformanceMergingConfig = HashMap<String, HashSet<String>>;

fn validate_performance_merging_configuration(config: &PerformanceMergingConfig) -> EmptyResult {
    let mut symbols_to_merge: HashSet<&str> = HashSet::new();

    for (master_symbol, slave_symbols) in config {
        if !symbols_to_merge.insert(master_symbol) {
            return Err!(
                "Invalid performance merging configuration: Duplicated {} symbol",
                master_symbol);
        }

        for slave_symbol in slave_symbols {
            if !symbols_to_merge.insert(slave_symbol) {
                return Err!(
                    "Invalid performance merging configuration: Duplicated {} symbol",
                    slave_symbol);
            }
        }
    }

    Ok(())
}