# SPDX-License-Identifier: GPLv3-or-later
# Copyright © 2025 pygaindalf Rui Pinheiro
from pydantic import Field, ValidatorFunctionWrapHandler, model_validator, PrivateAttr, field_validator, ValidationInfo, TypeAdapter
from pydantic_core import PydanticUseDefault
from typing import override, Any, ClassVar, Self, MutableMapping
from iso4217 import Currency
from ...util.helpers import script_info
from .instance_store import InstanceStoreEntityMixin
from .entity import Entity
from . import AutomaticNamedEntity
[docs]
class Instrument(InstanceStoreEntityMixin, AutomaticNamedEntity):
# MARK: Fields
isin : str | None = Field(default=None, min_length=1, description="International Securities Identification Number (ISIN) of the instrument.")
ticker : str | None = Field(default=None, min_length=1, description="Ticker symbol of the instrument, used for trading and identification.")
currency : Currency = Field(description="The currency in which the instrument is denominated.")
# MARK: Instance Store Behaviour
BY_ISIN : 'ClassVar[MutableMapping[str, Instrument]]' = dict()
BY_TICKER : 'ClassVar[MutableMapping[str, Instrument]]' = dict()
if script_info.is_unit_test():
@classmethod
@override
def reset_state(cls) -> None:
super().reset_state()
cls.BY_ISIN.clear()
cls.BY_TICKER.clear()
[docs]
@classmethod
def instance(cls, isin : str | None = None, ticker: str | None = None) -> 'Instrument | None':
if not isinstance(isin, (str, type(None))) or not isinstance(ticker, (str, type(None))):
raise TypeError(f"Expected 'isin' and 'ticker' to be str or None, got {type(isin).__name__} and {type(ticker).__name__}.")
elif not isin and not ticker:
return None
# Check if an instance already exists for the given identifiers
by_isin = cls.BY_ISIN.get(isin, None) if isin else None
by_ticker = cls.BY_TICKER.get(ticker, None) if ticker else None
# Sanity check that the instruments match the identifiers
if by_isin is not None and by_isin.isin != isin:
raise ValueError(f"ISIN '{isin}' does not match existing instance with ISIN '{by_isin.isin}'.")
if by_ticker is not None and by_ticker.ticker != ticker:
raise ValueError(f"Ticker '{ticker}' does not match existing instance with ticker '{by_ticker.ticker}'.")
# If both identifiers are provided, ensure they match
# Return the existing instance if found
if by_isin is not None and by_ticker is not None:
if by_isin is not by_ticker:
raise ValueError(f"Conflicting instances found for ISIN '{isin}' and ticker '{ticker}'.")
return by_isin
elif by_isin is not None:
return by_isin
elif by_ticker is not None:
return by_ticker
else:
return None
@classmethod
@override
def _instance_store_search(cls, **kwargs) -> 'Instrument | None':
isin = kwargs.get('isin', None)
ticker = kwargs.get('ticker', None)
return cls.instance(isin=isin, ticker=ticker)
@classmethod
@override
def _instance_store_add(cls, instance: Entity) -> None:
"""
Add an instance to the store.
This method is called when a new instance is created.
"""
if not isinstance(instance, cls):
raise TypeError(f"Expected an instance of {cls.__name__}, got {type(instance).__name__}.")
if instance.isin:
cls.BY_ISIN[instance.isin] = instance
if instance.ticker:
cls.BY_TICKER[instance.ticker] = instance
# MARK: Model Validation
@model_validator(mode='before')
@classmethod
def _validate_model_before(cls, values: Any) -> Any:
"""
Validate the identifiers of the instrument.
Ensures that at least one identifier (ISIN or ticker) is provided.
"""
if values is None:
raise PydanticUseDefault()
if isinstance(values, Instrument):
return values
if not isinstance(values, dict):
raise TypeError(f"Expected a dict or Instrument instance, got {type(values).__name__}.")
# Identifiers
cls._validate_identifiers(values)
return values
@classmethod
def _validate_identifiers(cls, values: dict[str, Any]) -> None:
isin = values.get('isin' , None)
ticker = values.get('ticker', None)
if not isin and not ticker:
raise ValueError("At least one identifier (ISIN or ticker) must be provided.")
# MARK: Instance Name
[docs]
@classmethod
@override
def calculate_instance_name_from_dict(cls, data : dict[str, Any]) -> str:
if (identifier := data.get('isin', None)) is None and (identifier := data.get('ticker', None)) is None:
raise ValueError(f"{cls.__name__} must have either 'isin' or 'ticker' field in the data to generate a name for the instance.")
return identifier
@property
@override
def instance_name(self) -> str:
return self.__class__.calculate_instance_name_from_dict(self.__dict__)