diff --git a/fin_defs/__init__.py b/fin_defs/__init__.py index a27afde..ab9c79c 100644 --- a/fin_defs/__init__.py +++ b/fin_defs/__init__.py @@ -27,7 +27,7 @@ import enforce_typing from ._version import __version__ -__all__ = ['__version__'] +__all__ = ['__version__', 'StockExchange', 'AssetAmount', 'Asset', 'ExchangeRateSample'] def parse_id_attr_key_value_pair(attr_datum: str) -> tuple[str, str | int] | None: @@ -113,8 +113,8 @@ class Asset: return f'X:{self.ccxt_symbol}USD' # Else - msg = f'Unknown self type: {self}' - raise Exception(msg) + msg = f'Unknown type: {type(self).__name__}' + raise TypeError(msg) @staticmethod def from_polygon_id(polygon_id: str) -> 'Asset': @@ -160,8 +160,8 @@ class Asset: if isinstance(self, UnknownAsset): return 'unknown:XXX' - msg = f'Unsupported asset type: {self:?}' - raise ValueError(msg) + msg = f'Unsupported asset type: {type(self).__name__}' + raise TypeError(msg) @staticmethod def from_string_id(asset_id: str) -> 'Asset': @@ -184,9 +184,7 @@ class Asset: if m := re.match(r'^(\w+)(?:\.(\w+))?$', ticker): exchange = EXCHANGES_BY_IDS[m.group(2) or 'NYSE'] # TODO? return Stock(m.group(1), exchange, **attrs) - if prefix == 'currency': - return FiatCurrency(ticker, **attrs) - if prefix == 'fiat': + if prefix in {'currency','fiat'}: return FiatCurrency(ticker, **attrs) if prefix == 'index': return Index(ticker, **attrs) @@ -209,6 +207,9 @@ class Asset: class UnknownAsset(Asset): pass + @abc.abstractmethod + def raw_short_name(self) -> str: + return '???' @enforce_typing.enforce_types @dataclasses.dataclass(frozen=True) @@ -255,7 +256,7 @@ class CryptoCurrency(Currency): coingecko_id: str | None = None def __post_init__(self) -> None: - if not re.match('^.*$', self.ccxt_symbol): + if not re.match(r'^\S.*\S$', self.ccxt_symbol): msg = f'ccxt_symbol was not in correct format: {self.ccxt_symbol}' raise ValueError(msg) @@ -276,8 +277,8 @@ class Stock(Asset): raise ValueError(msg) @staticmethod - def from_polygon_id(polygon_id: str, stock_exchange: StockExchange) -> 'Stock': - return Stock(polygon_id, stock_exchange) + def from_polygon_id(polygon_id: str) -> 'Stock': + return Stock(polygon_id, NYSE) # TODO: Add support for non-NYSE exchanges. def raw_short_name(self) -> str: return self.ticker @@ -470,9 +471,6 @@ if True: def add_by_id(exchange: StockExchange, key: str): exchange_id = getattr(exchange, key) if exchange_id is not None: - if exchange_id in EXCHANGES_BY_IDS: - msg = f'Exchange {exchange_id} is already present' - raise ValueError(msg) EXCHANGES_BY_IDS[exchange_id] = exchange for exchange in EXCHANGES: @@ -518,23 +516,25 @@ class AssetAmount: return self.human_readable_str() def human_readable_str(self) -> str: - specificity = '2' if self.amount >= THREE_DECIMALS_UNDER_THIS_AMOUNT else '3' + abs_amount = abs(self.amount) + specificity = '2' if abs_amount >= THREE_DECIMALS_UNDER_THIS_AMOUNT else '3' prefix = ASSET_PREFIX.get(self.asset, '') - return ('{}{:.' + specificity + 'f} {}').format( - prefix, - self.amount, - self.asset.raw_short_name(), + return ('{sign}{prefix}{amount:.' + specificity + 'f} {name}').format( + sign='-' if self.amount < 0 else '', + prefix=prefix, + amount=abs_amount, + name=self.asset.raw_short_name(), ) def __mul__(self, other: Decimal) -> 'AssetAmount': if not isinstance(other, Decimal): - msg = f'other must be decimal, but was {type(other)}' + msg = f'other must be Decimal, but was {type(other).__name__}' raise TypeError(msg) return AssetAmount(self.asset, self.amount * other) def __add__(self, other: 'AssetAmount') -> 'AssetAmount': if not isinstance(other, AssetAmount): - msg = f'other must be AssetAmount, but was {type(other)}' + msg = f'other must be AssetAmount, but was {type(other).__name__}' raise TypeError(msg) if self.is_zero(): return other @@ -549,8 +549,9 @@ class AssetAmount: return AssetAmount(self.asset, self.amount + other.amount) def __sub__(self, other: 'AssetAmount') -> 'AssetAmount': + # TODO: Implement using add? if not isinstance(other, AssetAmount): - msg = f'other must be AssetAmount, but was {type(other)}' + msg = f'other must be AssetAmount, but was {type(other).__name__}' raise TypeError(msg) if self.is_zero(): return -other @@ -576,7 +577,7 @@ class AssetAmount: def cmp(self, other) -> Decimal: if not isinstance(other, AssetAmount): - msg = f'other must be AssetAmount, but was {type(other)}' + msg = f'other must be AssetAmount, but was {type(other).__name__}' raise TypeError(msg) if self.is_zero() or other.is_zero(): return self.amount - other.amount diff --git a/test/test_asset_amount.py b/test/test_asset_amount.py index 28109f8..0ba5155 100644 --- a/test/test_asset_amount.py +++ b/test/test_asset_amount.py @@ -1,8 +1,92 @@ +import pytest + from decimal import Decimal import fin_defs +USD_0 = fin_defs.AssetAmount(fin_defs.USD, Decimal(0)) +USD_10 = fin_defs.AssetAmount(fin_defs.USD, Decimal(10)) +USD_11 = fin_defs.AssetAmount(fin_defs.USD, Decimal(11)) +USD_20 = fin_defs.AssetAmount(fin_defs.USD, Decimal(20)) +USD_21 = fin_defs.AssetAmount(fin_defs.USD, Decimal(21)) + +DKK_0 = fin_defs.AssetAmount(fin_defs.DKK, Decimal(0)) +DKK_21 = fin_defs.AssetAmount(fin_defs.DKK, Decimal(21)) + +def test_add_asset(): + assert USD_10 + USD_11 == USD_21 + assert USD_10 + USD_0 == USD_10 + assert DKK_0 + USD_10 == USD_10 + +def test_sub_asset(): + assert USD_21 - USD_10 == USD_11 + assert USD_10 - USD_0 == USD_10 + assert DKK_0 - USD_10 == -USD_10 + +def test_div_asset(): + assert USD_20 / USD_10 == 2 + assert USD_10 / USD_10 == 1 + assert USD_10 / USD_20 == 0.5 + +def test_div_amount(): + assert USD_20 / Decimal(2) == USD_10 + +def test_mult(): + assert USD_10 * Decimal(2) == USD_20 + +def test_negate(): + assert USD_20 + (-USD_10) == USD_10 + +def test_compare(): + assert USD_10 < USD_20 + assert USD_10 <= USD_20 + assert not (USD_10 > USD_20) + assert not (USD_10 >= USD_20) + +def test_compare_zero(): + assert DKK_0 < USD_20 + assert DKK_0 <= USD_20 + assert not (DKK_0 > USD_20) + assert not (DKK_0 >= USD_20) + +def test_is_zero(): + assert USD_0.is_zero() + assert not USD_10.is_zero() def test_str(): - amount = fin_defs.AssetAmount(fin_defs.USD, Decimal(10)) - assert str(amount) == '$10.00 USD' + assert str(USD_0) == '$0.000 USD' + assert str(USD_10) == '$10.00 USD' + assert str(USD_11) == '$11.00 USD' + assert str(-USD_10) == '-$10.00 USD' + +def test_mul_wrong(): + with pytest.raises(TypeError, match='other must be Decimal, but was AssetAmount'): + assert USD_20 * USD_10 + +def test_add_wrong_type(): + with pytest.raises(TypeError, match='other must be AssetAmount, but was Decimal'): + assert USD_20 + Decimal(1) + +def test_add_wrong_asset(): + with pytest.raises(ValueError, match='AssetAmount must have same asset, but: fiat:USD != fiat:DKK'): + assert USD_20 + DKK_21 + +def test_sub_wrong_type(): + with pytest.raises(TypeError, match='other must be AssetAmount, but was Decimal'): + assert USD_20 - Decimal(1) + +def test_sub_wrong_asset(): + with pytest.raises(ValueError, match='AssetAmount must have same asset, but: fiat:USD != fiat:DKK'): + assert USD_20 - DKK_21 + +def test_cmp_wrong_type(): + with pytest.raises(TypeError, match='other must be AssetAmount, but was Decimal'): + assert USD_20 < Decimal(1) + +def test_cmp_wrong_asset(): + with pytest.raises(ValueError, match='AssetAmount must have same asset, but: fiat:USD != fiat:DKK'): + assert USD_20 < DKK_21 + +def test_div_wrong_asset (): + with pytest.raises(ValueError, match='AssetAmount must have same asset, but: fiat:USD != fiat:DKK'): + assert USD_21 / DKK_21 diff --git a/test/test_currency.py b/test/test_currency.py new file mode 100644 index 0000000..247e17b --- /dev/null +++ b/test/test_currency.py @@ -0,0 +1,11 @@ +import pytest + +import fin_defs + +def test_from_currency_symbol(): + assert fin_defs.FiatCurrency.from_currency_symbol('$') == fin_defs.USD + assert fin_defs.FiatCurrency.from_currency_symbol('TEST') is None + +def test_currency_symbol(): + assert fin_defs.USD.to_currency_symbol() == '$' + diff --git a/test/test_data.py b/test/test_data.py index b9a153f..94dbc21 100644 --- a/test/test_data.py +++ b/test/test_data.py @@ -2,24 +2,29 @@ import pytest import fin_defs -VALID_TICKERS = ['TEST123', '123', 'TEST.EUR'] -BAD_TICKERS = ['TEST:EUR', 'EUR:TEST'] +VALID_STOCK_TICKERS = ['TEST123', '123', 'TEST.EUR'] +BAD_STOCK_TICKERS = ['TEST:EUR', 'EUR:TEST'] -@pytest.mark.parametrize('ticker', VALID_TICKERS) +@pytest.mark.parametrize('ticker', VALID_STOCK_TICKERS) def test_valid_tickers(ticker: str): - fin_defs.Stock(ticker, exchange=fin_defs.EXCHANGES_BY_IDS['NYSE']) + asset = fin_defs.Stock(ticker, exchange=fin_defs.EXCHANGES_BY_IDS['NYSE']) + assert asset.names() is not None + assert asset.to_polygon_id() is not None -@pytest.mark.parametrize('ticker', BAD_TICKERS) + +@pytest.mark.parametrize('ticker', BAD_STOCK_TICKERS) def test_bad_tickers(ticker: str): with pytest.raises(ValueError, match='ticker was not in correct format:.*'): fin_defs.Stock(ticker, exchange=fin_defs.EXCHANGES_BY_IDS['NYSE']) -@pytest.mark.parametrize('ticker', BAD_TICKERS) +@pytest.mark.parametrize('ticker', BAD_STOCK_TICKERS) def test_crypto_tickers(ticker): - fin_defs.CryptoCurrency(ticker, 'not-known') + asset = fin_defs.CryptoCurrency(ticker, 'not-known') + assert asset.names() is not None + assert asset.to_polygon_id().startswith('X:') def test_str(): diff --git a/test/test_exchange_rate_sample.py b/test/test_exchange_rate_sample.py new file mode 100644 index 0000000..2769aa8 --- /dev/null +++ b/test/test_exchange_rate_sample.py @@ -0,0 +1,32 @@ +import pytest + +import datetime +from decimal import Decimal +import fin_defs + +NOW = datetime.datetime.now() +THEN = NOW - datetime.timedelta(days=1) + +SAMPLE_AVERAGE = fin_defs.ExchangeRateSample(NOW, + (fin_defs.FiatCurrency.DKK,fin_defs.FiatCurrency.USD), + _average = Decimal(1)) + +SAMPLE_HIGH_LOW = fin_defs.ExchangeRateSample(NOW, + (fin_defs.FiatCurrency.DKK,fin_defs.FiatCurrency.USD), + high = Decimal(1), low=Decimal('0.5')) + +def test_sample_average(): + assert SAMPLE_AVERAGE.average == 1 + assert SAMPLE_HIGH_LOW.average == Decimal('0.75') + + +def test_invert_sample(): + inverted = SAMPLE_HIGH_LOW.invert_exchange_rate() + assert inverted.asset_pair == (fin_defs.FiatCurrency.USD,fin_defs.FiatCurrency.DKK) + assert inverted.high == 2 + assert inverted.low == 1 + assert inverted.average == 1.5 + + +def test_replace_timestamp(): + assert SAMPLE_HIGH_LOW.replace_timestamp(THEN).timestamp == THEN diff --git a/test/test_ids.py b/test/test_ids.py index 6b88681..c254b46 100644 --- a/test/test_ids.py +++ b/test/test_ids.py @@ -2,17 +2,32 @@ import pytest import fin_defs +NVO_ID = 'stock:NVO.NYSE{nordnet_id=16256554}' +NVO = fin_defs.Asset.from_string_id(NVO_ID) + VALID_IDS = [ 'stock:NVO.NYSE', - 'stock:NVO.NYSE{nordnet_id=16256554}', + NVO_ID, 'currency:USD', 'fiat:USD', 'index:NDX', 'crypto:BTC', 'crypto:BTC{coingecko_id=bitcoin}', + 'crypto:MPC', + 'crypto:LOLCOIN', 'commodity:ALUMINUM', + 'unknown:XXX', ] + +ASSETS = list(fin_defs.WELL_KNOWN_SYMBOLS.values()) + [fin_defs.Asset.from_string_id(vid) for vid in VALID_IDS] + + +ASSETS_POLYGON_PRESERVES_FULL_ID = frozenset(a for a in ASSETS if not isinstance(a, + fin_defs.CryptoCurrency + | fin_defs.Commodity + | fin_defs.UnknownAsset)) - frozenset([NVO]) + INVALID_IDS = [ 'NVO', 'NVO.NYSE', @@ -20,6 +35,11 @@ INVALID_IDS = [ 'fiat:TEST:TEST', 'index:TEST:TEST', 'commodity:TEST:TEST', + 'crypto: ', + '!!!!', + '::::', + '', + ' ', ] @@ -36,15 +56,15 @@ def test_parse_attr(): def test_from_nordnet(): - derp = fin_defs.Asset.from_string_id('stock:NVO.NYSE{nordnet_id=16256554}') - assert isinstance(derp, fin_defs.Stock) - assert derp.ticker == 'NVO' - assert derp.nordnet_id == 16256554 + asset = NVO + assert isinstance(asset, fin_defs.Stock) + assert asset.ticker == 'NVO' + assert asset.nordnet_id == 16256554 -@pytest.mark.parametrize('asset_id', VALID_IDS) -def test_from_string_id_shortcut(asset_id: str): - assert fin_defs.Asset.from_string_id(asset_id) +@pytest.mark.parametrize('asset', ASSETS) +def test_raw_short_name(asset: fin_defs.Asset): + assert asset.raw_short_name() is not None @pytest.mark.parametrize('asset_id', VALID_IDS) @@ -58,18 +78,25 @@ def test_from_string_id_invalid(asset_id: str): fin_defs.Asset.from_string_id(asset_id) -@pytest.mark.parametrize('asset', fin_defs.WELL_KNOWN_SYMBOLS.values()) -def test_to_from_string_id_shortcut(asset: fin_defs.Asset): - assert fin_defs.Asset.from_string_id(asset.to_string_id()) == asset - - -@pytest.mark.parametrize('asset', fin_defs.WELL_KNOWN_SYMBOLS.values()) +@pytest.mark.parametrize('asset', ASSETS) def test_to_from_string_id(asset: fin_defs.Asset): assert fin_defs.Asset.from_string_id(asset.to_string_id()) == asset -@pytest.mark.parametrize('asset', fin_defs.WELL_KNOWN_SYMBOLS.values()) -def test_to_from_polygon_id(asset: fin_defs.Asset): - if isinstance(asset, fin_defs.CryptoCurrency): +@pytest.mark.parametrize('asset', ASSETS) +def test_to_from_polygon_id_works(asset: fin_defs.Asset): + if isinstance(asset, fin_defs.Commodity | fin_defs.UnknownAsset): return + assert fin_defs.Asset.from_polygon_id(asset.to_polygon_id()) is not None + +@pytest.mark.parametrize('asset', ASSETS_POLYGON_PRESERVES_FULL_ID) +def test_to_from_polygon_id_preserves_id(asset: fin_defs.Asset): assert fin_defs.Asset.from_polygon_id(asset.to_polygon_id()) == asset + +def test_to_polygon_id_fail_for_commodity(): + with pytest.raises(TypeError, match='Unknown type: Commodity'): + assert fin_defs.Commodity.CORN.to_polygon_id() + +def test_to_string_id_wrong_type(): + with pytest.raises(TypeError, match='Unsupported asset type: int'): + assert fin_defs.Asset.to_string_id(1)