diff --git a/custom_components/advanced_trading_wallet/__init__.py b/custom_components/advanced_trading_wallet/__init__.py index b05aea5..c087b46 100644 --- a/custom_components/advanced_trading_wallet/__init__.py +++ b/custom_components/advanced_trading_wallet/__init__.py @@ -1,3 +1,4 @@ +from datetime import timedelta from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType @@ -26,22 +27,33 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b # If global coordinator doesn't exist, create it if "coordinator" not in hass.data[DOMAIN]: + update_interval = config_entry.options.get( + "update_interval", DEFAULT_SCAN_INTERVAL + ) coordinator = ATWCoordinator( hass, - preferred_currency=config_entry.data.get("preferred_currency", "usd"), - update_interval=config_entry.data.get( - "update_interval", DEFAULT_SCAN_INTERVAL - ), + preferred_currency=config_entry.data.get("preferred_currency", "USD"), + update_interval=update_interval, ) hass.data[DOMAIN]["coordinator"] = coordinator LOGGER.debug("Created global coordinator") + await coordinator.data_store.async_load() await coordinator.async_config_entry_first_refresh() else: coordinator = hass.data[DOMAIN]["coordinator"] + # Update coordinator's update interval if changed + update_interval = config_entry.options.get( + "update_interval", DEFAULT_SCAN_INTERVAL + ) + await coordinator.async_set_update_interval(timedelta(minutes=update_interval)) # Store per-entry data - hass.data[DOMAIN][config_entry.entry_id] = { + entry_id = config_entry.entry_id + entry_data = { "api_provider": config_entry.data.get("api_provider", DEFAULT_API_PROVIDER), + "preferred_currency": config_entry.data.get( + "preferred_currency", "USD" + ).upper(), "stocks_to_track": config_entry.data.get("stocks_to_track", ""), "crypto_to_track": config_entry.data.get("crypto_to_track", ""), "stock_amount_owned": config_entry.data.get("stock_amount_owned", 0), @@ -50,6 +62,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b "crypto_purchase_price": config_entry.data.get("crypto_purchase_price", 0), } + # Update entry_data with stored data if available + stored_entry_data = coordinator.data_store.get_entry_data(entry_id) + if stored_entry_data: + entry_data.update(stored_entry_data) + + hass.data[DOMAIN][entry_id] = entry_data + # Update coordinator's list of symbols and API providers coordinator.update_symbols(hass.data[DOMAIN]) @@ -58,9 +77,28 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b # Pass the coordinator when setting up services await async_setup_services(hass, coordinator) + # Add update listener for options + config_entry.async_on_unload( + config_entry.add_update_listener(async_options_updated) + ) + return True +async def async_options_updated(hass: HomeAssistant, config_entry: ConfigEntry): + """Handle options update.""" + # Retrieve the coordinator + coordinator = hass.data[DOMAIN]["coordinator"] + + # Get the new update_interval from options + update_interval = config_entry.options.get("update_interval", DEFAULT_SCAN_INTERVAL) + new_interval = timedelta(minutes=update_interval) + LOGGER.debug(f"Options updated: new update_interval={new_interval}") + + # Update the coordinator's update_interval + await coordinator.async_set_update_interval(new_interval) + + async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( @@ -75,6 +113,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # If no other entries remain, remove the coordinator and services if len(hass.data[DOMAIN]) == 1: # Only 'coordinator' remains + await coordinator.async_close() hass.data[DOMAIN].pop("coordinator") await async_unload_services(hass) diff --git a/custom_components/advanced_trading_wallet/api.py b/custom_components/advanced_trading_wallet/api.py index ed28d1b..a6a6a73 100644 --- a/custom_components/advanced_trading_wallet/api.py +++ b/custom_components/advanced_trading_wallet/api.py @@ -21,6 +21,7 @@ def __init__(self, hass: HomeAssistant, api_provider: str): self.api_provider = api_provider self.crumb = None self.cookies = None + self.session = aiohttp.ClientSession() async def get_stock_data(self, stock_symbol: str): """Fetch stock data asynchronously.""" @@ -43,19 +44,16 @@ async def _fetch_yahoo_crumb(self): url = GET_CRUMB_URL headers = YAHOO_HEADERS - async with aiohttp.ClientSession() as session: - async with session.get(url, headers=headers) as response: - if response.status == 200: - text = await response.text() - self.crumb = text.strip() - self.cookies = response.cookies - LOGGER.debug(f"Fetched Yahoo Finance crumb: {self.crumb}") - return True - else: - LOGGER.error( - f"Failed to fetch Yahoo Finance crumb: {response.status}" - ) - return False + async with self.session.get(url, headers=headers) as response: + if response.status == 200: + text = await response.text() + self.crumb = text.strip() + self.cookies = response.cookies + LOGGER.debug(f"Fetched Yahoo Finance crumb: {self.crumb}") + return True + else: + LOGGER.error(f"Failed to fetch Yahoo Finance crumb: {response.status}") + return False async def _fetch_yahoo_stock(self, stock_symbol: str): """Fetch stock data with crumb handling.""" @@ -63,73 +61,50 @@ async def _fetch_yahoo_stock(self, stock_symbol: str): await self._fetch_yahoo_crumb() url = f"{YAHOO_FINANCE_BASE_URL}{stock_symbol}&crumb={self.crumb}" - async with aiohttp.ClientSession(cookies=self.cookies) as session: - async with session.get(url, headers=YAHOO_HEADERS) as response: - if response.status == 429: - retry_after = int( - response.headers.get("Retry-After", DEFAULT_RETRY_AFTER) - ) - LOGGER.warning( - f"Rate limit hit for {stock_symbol}. Retrying after {retry_after} seconds." - ) - await asyncio.sleep(retry_after) - return None - if response.status == 200: - json_data = await response.json() - LOGGER.debug(f"Stock data for {stock_symbol}: {json_data}") - return json_data - else: - LOGGER.error(f"Failed to fetch stock data: {response.status}") - return None + async with self.session.get( + url, headers=YAHOO_HEADERS, cookies=self.cookies + ) as response: + if response.status == 429: + retry_after = int( + response.headers.get("Retry-After", DEFAULT_RETRY_AFTER) + ) + LOGGER.warning( + f"Rate limit hit for {stock_symbol}. Retrying after {retry_after} seconds." + ) + await asyncio.sleep(retry_after) + return None + if response.status == 200: + json_data = await response.json() + LOGGER.debug(f"Stock data for {stock_symbol}: {json_data}") + return json_data + else: + LOGGER.error(f"Failed to fetch stock data: {response.status}") + return None async def _fetch_coingecko_crypto(self, crypto_symbol: str, currency: str = "usd"): - url = f"{COINGECKO_BASE_URL}/coins/markets?vs_currency={currency}&ids={crypto_symbol}" - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - if response.status == 429: - retry_after = int( - response.headers.get("Retry-After", DEFAULT_RETRY_AFTER) - ) - LOGGER.warning( - f"Rate limit hit for {crypto_symbol}. Retrying after {retry_after} seconds." - ) - await asyncio.sleep(retry_after) - return None - if response.status == 200: - json_data = await response.json() - LOGGER.debug(f"Crypto data for {crypto_symbol}: {json_data}") - return json_data - else: - LOGGER.error(f"Failed to fetch crypto data: {response.status}") - return None - - async def fetch_autocomplete(self, query: str, asset_type: str): - """Fetch autocomplete suggestions from relevant APIs.""" - if asset_type == "Stock" and self.api_provider == "Yahoo Finance": - url = f"{YAHOO_FINANCE_BASE_URL}/lookup/autocomplete?q={query}" - elif asset_type == "Crypto" and self.api_provider == "CoinGecko": - url = f"{COINGECKO_BASE_URL}/search?query={query}" - else: - raise ValueError("Invalid API provider") - - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - if response.status == 429: - retry_after = int( - response.headers.get("Retry-After", DEFAULT_RETRY_AFTER) - ) - LOGGER.warning( - f"Rate limit hit. Retrying after {retry_after} seconds." - ) - await asyncio.sleep(retry_after) - return None - if response.status == 200: - return await response.json() - else: - LOGGER.error( - f"Failed to fetch autocomplete data: {response.status}" - ) - return None + crypto_symbol_lower = crypto_symbol.lower() + url = f"{COINGECKO_BASE_URL}/coins/markets?vs_currency={currency}&ids={crypto_symbol_lower}" + async with self.session.get(url) as response: + if response.status == 429: + retry_after = int( + response.headers.get("Retry-After", DEFAULT_RETRY_AFTER) + ) + LOGGER.warning( + f"Rate limit hit for {crypto_symbol}. Retrying after {retry_after} seconds." + ) + await asyncio.sleep(retry_after) + return None + if response.status == 200: + json_data = await response.json() + if json_data: + # Retrieve the symbol from the response + symbol = json_data[0].get("symbol", crypto_symbol).upper() + json_data[0]["symbol"] = symbol + LOGGER.debug(f"Crypto data for {crypto_symbol}: {json_data}") + return json_data + else: + LOGGER.error(f"Failed to fetch crypto data: {response.status}") + return None async def get_stock_historical_data(self, stock_symbol: str, interval: str): """Fetch historical stock data asynchronously.""" @@ -138,53 +113,52 @@ async def get_stock_historical_data(self, stock_symbol: str, interval: str): url = f"{YAHOO_FINANCE_HISTORICAL_URL}{stock_symbol}?interval={interval}" - async with aiohttp.ClientSession(cookies=self.cookies) as session: - async with session.get(url, headers=YAHOO_HEADERS) as response: - if response.status == 429: - retry_after = int( - response.headers.get("Retry-After", DEFAULT_RETRY_AFTER) - ) - LOGGER.warning( - f"Rate limit hit for {stock_symbol}. Retrying after {retry_after} seconds." - ) - await asyncio.sleep(retry_after) - return None - if response.status == 200: - json_data = await response.json() - LOGGER.debug(f"Historical data for {stock_symbol}: {json_data}") - return json_data - else: - LOGGER.error( - f"Failed to fetch stock historical data: {response.status}" - ) - return None + async with self.session.get( + url, headers=YAHOO_HEADERS, cookies=self.cookies + ) as response: + if response.status == 429: + retry_after = int( + response.headers.get("Retry-After", DEFAULT_RETRY_AFTER) + ) + LOGGER.warning( + f"Rate limit hit for {stock_symbol}. Retrying after {retry_after} seconds." + ) + await asyncio.sleep(retry_after) + return None + if response.status == 200: + json_data = await response.json() + LOGGER.debug(f"Historical data for {stock_symbol}: {json_data}") + return json_data + else: + LOGGER.error( + f"Failed to fetch stock historical data: {response.status}" + ) + return None async def get_crypto_historical_data(self, crypto_symbol: str, interval: str): """Fetch historical crypto data asynchronously.""" - url = f"{COINGECKO_BASE_URL}/coins/{crypto_symbol}/market_chart?vs_currency=usd&days={interval}" + crypto_symbol_lower = crypto_symbol.lower() + url = f"{COINGECKO_BASE_URL}/coins/{crypto_symbol_lower}/market_chart?vs_currency=usd&days={interval}" LOGGER.debug(f"Requesting crypto historical data from {url}") - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - if response.status == 429: - retry_after = int( - response.headers.get("Retry-After", DEFAULT_RETRY_AFTER) - ) - LOGGER.warning( - f"Rate limit hit for {crypto_symbol}. Retrying after {retry_after} seconds." - ) - await asyncio.sleep(retry_after) - return None - if response.status == 200: - json_data = await response.json() - LOGGER.debug( - f"Crypto historical data for {crypto_symbol}: {json_data}" - ) - return json_data - else: - LOGGER.error( - f"Failed to fetch crypto historical data: {response.status}" - ) - return None + async with self.session.get(url) as response: + if response.status == 429: + retry_after = int( + response.headers.get("Retry-After", DEFAULT_RETRY_AFTER) + ) + LOGGER.warning( + f"Rate limit hit for {crypto_symbol}. Retrying after {retry_after} seconds." + ) + await asyncio.sleep(retry_after) + return None + if response.status == 200: + json_data = await response.json() + LOGGER.debug(f"Crypto historical data for {crypto_symbol}: {json_data}") + return json_data + else: + LOGGER.error( + f"Failed to fetch crypto historical data: {response.status}" + ) + return None async def close(self): """Close the aiohttp session.""" diff --git a/custom_components/advanced_trading_wallet/config_flow.py b/custom_components/advanced_trading_wallet/config_flow.py index dab31cf..e7daa60 100644 --- a/custom_components/advanced_trading_wallet/config_flow.py +++ b/custom_components/advanced_trading_wallet/config_flow.py @@ -1,6 +1,6 @@ from homeassistant import config_entries import voluptuous as vol -from .const import DOMAIN, API_PROVIDERS +from .const import DOMAIN, API_PROVIDERS, DEFAULT_SCAN_INTERVAL class StockCryptoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -13,6 +13,7 @@ def __init__(self): self.asset_type = None self.stock_data = None self.crypto_data = None + self.preferred_currency = None async def async_step_user(self, user_input=None): """Handle the first step of the config flow, selecting the API provider.""" @@ -21,6 +22,10 @@ async def async_step_user(self, user_input=None): if user_input is not None: # Store the API provider and proceed to the asset type selection self.api_provider = user_input["api_provider"] + # Store preferred currency in uppercase + self.preferred_currency = user_input.get( + "preferred_currency", "USD" + ).upper() return await self.async_step_select_asset_type() # Show the form to select the API provider @@ -31,7 +36,7 @@ async def async_step_user(self, user_input=None): vol.Required("api_provider", default=API_PROVIDERS[0]): vol.In( API_PROVIDERS ), - vol.Optional("preferred_currency"): str, + vol.Optional("preferred_currency", default="USD"): str, } ), errors=errors, @@ -66,78 +71,136 @@ async def async_step_select_asset_type(self, user_input=None): async def async_step_select_stock(self, user_input=None): """Step for users to input stock data.""" + errors = {} + + data_schema = vol.Schema( + { + vol.Required("stocks_to_track"): str, + vol.Optional("stock_amount_owned", default="0.0"): str, + vol.Optional("stock_purchase_price", default="0.0"): str, + } + ) + if user_input is not None: + try: + stock_amount_owned = float(user_input.get("stock_amount_owned", "0.0")) + stock_purchase_price = float( + user_input.get("stock_purchase_price", "0.0") + ) + except ValueError: + errors["base"] = "invalid_number" + return self.async_show_form( + step_id="select_stock", + data_schema=data_schema, + errors=errors, + ) + self.stock_data = { "stocks_to_track": user_input.get("stocks_to_track"), - "stock_amount_owned": user_input.get("stock_amount_owned"), - "stock_purchase_price": user_input.get("stock_purchase_price"), + "stock_amount_owned": stock_amount_owned, + "stock_purchase_price": stock_purchase_price, } - return await self.async_create_entry(user_input) + return self.async_create_entry( + title=f"Stock: {self.stock_data['stocks_to_track']}", + data={ + "api_provider": self.api_provider, + "preferred_currency": self.preferred_currency, + "stocks_to_track": self.stock_data["stocks_to_track"], + "stock_amount_owned": self.stock_data["stock_amount_owned"], + "stock_purchase_price": self.stock_data["stock_purchase_price"], + "crypto_to_track": "", + "crypto_display_symbol": "", + "crypto_amount_owned": 0.0, + "crypto_purchase_price": 0.0, + }, + ) return self.async_show_form( step_id="select_stock", - data_schema=vol.Schema( - { - vol.Required("stocks_to_track"): str, - vol.Optional("stock_amount_owned"): vol.Coerce(float), - vol.Optional("stock_purchase_price"): vol.Coerce(float), - } - ), + data_schema=data_schema, + errors=errors, ) async def async_step_select_crypto(self, user_input=None): """Step for users to input crypto data.""" + errors = {} + + data_schema = vol.Schema( + { + vol.Required("crypto_to_track"): str, + vol.Optional("crypto_amount_owned", default="0.0"): str, + vol.Optional("crypto_purchase_price", default="0.0"): str, + } + ) + if user_input is not None: + try: + crypto_symbol_input = user_input.get("crypto_to_track") + crypto_symbol = crypto_symbol_input.lower().strip() + crypto_display_symbol = crypto_symbol.upper() + crypto_amount_owned = float( + user_input.get("crypto_amount_owned", "0.0") + ) + crypto_purchase_price = float( + user_input.get("crypto_purchase_price", "0.0") + ) + except ValueError: + errors["base"] = "invalid_number" + return self.async_show_form( + step_id="select_crypto", + data_schema=data_schema, + errors=errors, + ) + self.crypto_data = { - "crypto_to_track": user_input.get("crypto_to_track"), - "crypto_amount_owned": user_input.get("crypto_amount_owned"), - "crypto_purchase_price": user_input.get("crypto_purchase_price"), + "crypto_to_track": crypto_symbol, + "crypto_display_symbol": crypto_display_symbol, + "crypto_amount_owned": crypto_amount_owned, + "crypto_purchase_price": crypto_purchase_price, } - return await self.async_create_entry(user_input) + return self.async_create_entry( + title=f"Crypto: {self.crypto_data['crypto_to_track']}", + data={ + "api_provider": self.api_provider, + "preferred_currency": self.preferred_currency, + "crypto_to_track": self.crypto_data["crypto_to_track"], + "crypto_display_symbol": self.crypto_data["crypto_display_symbol"], + "crypto_amount_owned": self.crypto_data["crypto_amount_owned"], + "crypto_purchase_price": self.crypto_data["crypto_purchase_price"], + "stocks_to_track": "", + "stock_amount_owned": 0.0, + "stock_purchase_price": 0.0, + }, + ) return self.async_show_form( step_id="select_crypto", - data_schema=vol.Schema( - { - vol.Required("crypto_to_track"): str, - vol.Optional("crypto_amount_owned"): vol.Coerce(float), - vol.Optional("crypto_purchase_price"): vol.Coerce(float), - } - ), + data_schema=data_schema, + errors=errors, ) - async def async_create_entry(self, user_input=None): - """Create the final configuration entry.""" - title = "" - if self.asset_type == "Stock": - title = f"Stock: {self.stock_data.get('stocks_to_track', 'Unknown')}" - elif self.asset_type == "Crypto": - title = f"Crypto: {self.crypto_data.get('crypto_to_track', 'Unknown')}" - - return super().async_create_entry( - title=title, - data={ - "api_provider": self.api_provider, - "preferred_currency": user_input.get("preferred_currency", "usd"), - "stocks_to_track": self.stock_data.get("stocks_to_track", "") - if self.stock_data - else "", - "crypto_to_track": self.crypto_data.get("crypto_to_track", "") - if self.crypto_data - else "", - "stock_amount_owned": self.stock_data.get("stock_amount_owned", 0) - if self.stock_data - else 0, - "stock_purchase_price": self.stock_data.get("stock_purchase_price", 0) - if self.stock_data - else 0, - "crypto_amount_owned": self.crypto_data.get("crypto_amount_owned", 0) - if self.crypto_data - else 0, - "crypto_purchase_price": self.crypto_data.get( - "crypto_purchase_price", 0 - ) - if self.crypto_data - else 0, - }, + @staticmethod + def async_get_options_flow(config_entry): + return StockCryptoOptionsFlowHandler() + + +class StockCryptoOptionsFlowHandler(config_entries.OptionsFlow): + """Handle options flow for Advanced Trading Wallet.""" + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options_schema = vol.Schema( + { + vol.Optional( + "update_interval", + default=self.config_entry.options.get( + "update_interval", DEFAULT_SCAN_INTERVAL + ), + ): vol.All(vol.Coerce(int), vol.Range(min=1)), + } ) + + return self.async_show_form(step_id="init", data_schema=options_schema) diff --git a/custom_components/advanced_trading_wallet/const.py b/custom_components/advanced_trading_wallet/const.py index a36b810..41da442 100644 --- a/custom_components/advanced_trading_wallet/const.py +++ b/custom_components/advanced_trading_wallet/const.py @@ -1,4 +1,5 @@ import logging +from homeassistant.components.sensor import SensorDeviceClass # Domain name for the integration DOMAIN = "advanced_trading_wallet" @@ -19,7 +20,8 @@ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "accept-encoding": "gzip, deflate, br", "accept-language": "en-US,en;q=0.9", - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" + "Chrome/91.0.4472.124 Safari/537.36", } # Default retry after hitting the rate limit @@ -27,13 +29,13 @@ # Historical data intervals DEFAULT_HISTORICAL_INTERVALS = ["1d", "5d", "1wk", "1mo", "1y", "5y"] -DEFAULT_HISTORICAL_INTERVAL = "1wk" # Default interval for historical data +DEFAULT_HISTORICAL_INTERVAL = "1wk" # Available platforms (sensor, etc.) PLATFORMS = ["sensor"] # Default values -DEFAULT_SCAN_INTERVAL = 10 # minutes +DEFAULT_SCAN_INTERVAL = 10 DEFAULT_API_PROVIDER = "Yahoo Finance" # Services @@ -41,42 +43,161 @@ SERVICE_GET_HISTORICAL_DATA = "get_historical_data" SERVICE_BUY_STOCKS = "buy_stocks" SERVICE_SELL_STOCKS = "sell_stocks" -SERVICE_BUY_CRIPTO = "buy_stocks" -SERVICE_SELL_CRIPTO = "sell_stocks" +SERVICE_BUY_CRYPTO = "buy_crypto" +SERVICE_SELL_CRYPTO = "sell_crypto" # Attributes for services ATTR_STOCK_SYMBOL = "stock_symbol" ATTR_CRYPTO_SYMBOL = "crypto_symbol" # Device classes -STOCK_SENSOR_TYPES = [ - "Stock Price", - "Market Cap", - "Day High", - "Day Low", - "Post Market Price", - "Post Market Change", - "Bid", - "Ask", - "52-Week High", - "52-Week Low", - "Dividend Rate", - "Earnings Per Share (EPS)", - "Price to Earnings (PE) Ratio", - "Volume", - "Shares Outstanding", - "Book Value", +SENSOR_TYPES_STOCK = [ + { + "name": "Stock Price", + "key": "regularMarketPrice", + "device_class": SensorDeviceClass.MONETARY, + }, + { + "name": "Market Cap", + "key": "marketCap", + "device_class": SensorDeviceClass.MONETARY, + }, + { + "name": "Day High", + "key": "regularMarketDayHigh", + "device_class": SensorDeviceClass.MONETARY, + }, + { + "name": "Day Low", + "key": "regularMarketDayLow", + "device_class": SensorDeviceClass.MONETARY, + }, + {"name": "Bid", "key": "bid", "device_class": SensorDeviceClass.MONETARY}, + {"name": "Ask", "key": "ask", "device_class": SensorDeviceClass.MONETARY}, + { + "name": "52-Week High", + "key": "fiftyTwoWeekHigh", + "device_class": SensorDeviceClass.MONETARY, + }, + { + "name": "52-Week Low", + "key": "fiftyTwoWeekLow", + "device_class": SensorDeviceClass.MONETARY, + }, + { + "name": "Dividend Rate", + "key": "dividendRate", + "device_class": SensorDeviceClass.MONETARY, + }, + { + "name": "Earnings Per Share (EPS)", + "key": "epsTrailingTwelveMonths", + "device_class": SensorDeviceClass.MONETARY, + }, + {"name": "Price to Earnings (PE) Ratio", "key": "trailingPE", "device_class": None}, + {"name": "Volume", "key": "regularMarketVolume", "device_class": None}, + { + "name": "Average Volume (3M)", + "key": "averageDailyVolume3Month", + "device_class": None, + }, + { + "name": "Average Volume (10D)", + "key": "averageDailyVolume10Day", + "device_class": None, + "unit": "Shares", + }, + {"name": "Shares Outstanding", "key": "sharesOutstanding", "device_class": None}, + { + "name": "Book Value", + "key": "bookValue", + "device_class": SensorDeviceClass.MONETARY, + }, + {"name": "Market State", "key": "marketState", "device_class": None}, + {"name": "Currency", "key": "currency", "device_class": None}, + {"name": "Display Name", "key": "displayName", "device_class": None}, + {"name": "Symbol", "key": "symbol", "device_class": None}, + {"name": "Short Name", "key": "shortName", "device_class": None}, + { + "name": "Average Analyst Rating", + "key": "averageAnalystRating", + "device_class": None, + }, + {"name": "Bid Size", "key": "bidSize", "device_class": None, "unit": "Shares"}, + {"name": "Ask Size", "key": "askSize", "device_class": None, "unit": "Shares"}, + { + "name": "50-Day Average", + "key": "fiftyDayAverage", + "device_class": SensorDeviceClass.MONETARY, + "unit": "USD", + }, + { + "name": "200-Day Average", + "key": "twoHundredDayAverage", + "device_class": SensorDeviceClass.MONETARY, + "unit": "USD", + }, + { + "name": "Dividend Yield", + "key": "dividendYield", + "device_class": None, + "unit": "%", + }, + { + "name": "Historical Stock Data", + "key": "historical_stock_data", + "device_class": None, + }, ] -CRYPTO_SENSOR_TYPES = [ - "Crypto Price", - "Market Cap", - "24h High", - "24h Low", - "ATH (All Time High)", - "ATL (All Time Low)", - "Circulating Supply", - "Price Change (24h)", +# Configuration for available crypto sensors (CoinGecko) +SENSOR_TYPES_CRYPTO = [ + { + "name": "Crypto Price", + "key": "current_price", + "device_class": SensorDeviceClass.MONETARY, + }, + { + "name": "Market Cap", + "key": "market_cap", + "device_class": SensorDeviceClass.MONETARY, + }, + {"name": "Market Cap Rank", "key": "market_cap_rank", "device_class": None}, + {"name": "24h Volume", "key": "total_volume", "device_class": None}, + {"name": "24h High", "key": "high_24h", "device_class": SensorDeviceClass.MONETARY}, + {"name": "24h Low", "key": "low_24h", "device_class": SensorDeviceClass.MONETARY}, + { + "name": "ATH (All Time High)", + "key": "ath", + "device_class": SensorDeviceClass.MONETARY, + }, + { + "name": "ATL (All Time Low)", + "key": "atl", + "device_class": SensorDeviceClass.MONETARY, + }, + {"name": "Circulating Supply", "key": "circulating_supply", "device_class": None}, + { + "name": "Price Change (24h)", + "key": "price_change_percentage_24h", + "device_class": None, + }, + { + "name": "Fully Diluted Valuation", + "key": "fully_diluted_valuation", + "device_class": SensorDeviceClass.MONETARY, + }, + { + "name": "Market Cap Change 24h", + "key": "market_cap_change_24h", + "device_class": SensorDeviceClass.MONETARY, + }, + {"name": "Total Supply", "key": "total_supply", "device_class": None}, + { + "name": "Historical Crypto Data", + "key": "historical_crypto_data", + "device_class": None, + }, ] # Logger declaration diff --git a/custom_components/advanced_trading_wallet/coordinator.py b/custom_components/advanced_trading_wallet/coordinator.py index cd5c4df..3cad6ad 100644 --- a/custom_components/advanced_trading_wallet/coordinator.py +++ b/custom_components/advanced_trading_wallet/coordinator.py @@ -1,8 +1,48 @@ from datetime import timedelta from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .api import ATWAPIClient +from homeassistant.helpers.storage import Store from homeassistant.core import HomeAssistant +from .api import ATWAPIClient from .const import DOMAIN, LOGGER, DEFAULT_SCAN_INTERVAL, DEFAULT_API_PROVIDER +from homeassistant.helpers.update_coordinator import UpdateFailed + + +class ATWDataStore: + """Class to manage the storage of transaction data.""" + + def __init__(self, hass: HomeAssistant, version: int = 1): + self.store = Store(hass, version, f"{DOMAIN}_transaction_data") + self._data = {} + + async def async_load(self): + """Load the data from storage.""" + data = await self.store.async_load() + if data is not None: + self._data = data + else: + self._data = {} + + async def async_save(self): + """Save the data to storage.""" + await self.store.async_save(self._data) + + def get_entry_data(self, entry_id): + """Get data for a specific entry.""" + return self._data.get(entry_id, {}) + + def set_entry_data(self, entry_id, data): + """Set data for a specific entry.""" + self._data[entry_id] = data + + def update_entry_data(self, entry_id, key, value): + """Update a specific key in entry data.""" + if entry_id not in self._data: + self._data[entry_id] = {} + self._data[entry_id][key] = value + + def get_all_data(self): + """Get all stored data.""" + return self._data class ATWCoordinator(DataUpdateCoordinator): @@ -16,11 +56,16 @@ def __init__( ): """Initialize the coordinator.""" self.hass = hass - self.preferred_currency = preferred_currency + self.preferred_currency = preferred_currency.upper() self.stocks = {} self.crypto = {} self.transactions = {"stocks": {}, "crypto": {}} self.historical_data = {} + self.data_store = ATWDataStore(hass) + self.api_clients = {} + LOGGER.debug( + f"Initializing coordinator with update_interval={update_interval} minutes" + ) super().__init__( hass, LOGGER, @@ -28,6 +73,21 @@ def __init__( update_interval=timedelta(minutes=update_interval), ) + async def async_set_update_interval(self, new_interval: timedelta): + """Set a new update interval and reschedule updates.""" + LOGGER.debug(f"Setting new update interval: {new_interval}") + self.update_interval = new_interval + # Reschedule the update + if self._unsub_refresh: + self._unsub_refresh() + self._schedule_refresh() + + async def async_close(self): + """Close any open sessions or resources.""" + for client in self.api_clients.values(): + await client.close() + self.api_clients.clear() + def update_symbols(self, data): """Update the list of symbols and their API providers based on current entries.""" stocks = {} @@ -50,48 +110,75 @@ def update_symbols(self, data): f"Updated symbols to track: Stocks={self.stocks}, Crypto={self.crypto}" ) - def add_entry_coordinator(self, entry_coordinator): - """Add an entry-specific coordinator to be tracked globally.""" - self.entry_coordinators.append(entry_coordinator) - async def _async_update_data(self): """Fetch data for all symbols.""" data = {} - # Fetch data for stocks grouped by API provider - stocks_by_provider = {} - for symbol, api_provider in self.stocks.items(): - stocks_by_provider.setdefault(api_provider, []).append(symbol) - - for api_provider, symbols in stocks_by_provider.items(): - api_client = ATWAPIClient(self.hass, api_provider) - for symbol in symbols: - try: - stock_data = await api_client.get_stock_data(symbol) - if stock_data: - data[symbol] = stock_data - else: - LOGGER.warning(f"No data received for stock: {symbol}") - except Exception as e: - LOGGER.error(f"Error fetching data for stock {symbol}: {e}") - - # Similar for cryptocurrencies - crypto_by_provider = {} - for symbol, api_provider in self.crypto.items(): - crypto_by_provider.setdefault(api_provider, []).append(symbol) - - for api_provider, symbols in crypto_by_provider.items(): - api_client = ATWAPIClient(self.hass, api_provider) - for symbol in symbols: - try: - crypto_data = await api_client.get_crypto_data(symbol) - if crypto_data: - data[symbol] = crypto_data - else: - LOGGER.warning(f"No data received for crypto: {symbol}") - except Exception as e: - LOGGER.error(f"Error fetching data for crypto {symbol}: {e}") - - return data + try: + # Fetch data for stocks grouped by API provider + stocks_by_provider = {} + for symbol, api_provider in self.stocks.items(): + stocks_by_provider.setdefault(api_provider, []).append(symbol) + + for api_provider, symbols in stocks_by_provider.items(): + if api_provider not in self.api_clients: + self.api_clients[api_provider] = ATWAPIClient( + self.hass, api_provider + ) + api_client = self.api_clients[api_provider] + for symbol in symbols: + try: + stock_data = await api_client.get_stock_data(symbol) + if stock_data: + data[symbol] = stock_data + else: + LOGGER.warning(f"No data received for stock: {symbol}") + # Retain previous data if available + if self.data and symbol in self.data: + data[symbol] = self.data[symbol] + except Exception as e: + LOGGER.error(f"Error fetching data for stock {symbol}: {e}") + # Retain previous data if available + if self.data and symbol in self.data: + data[symbol] = self.data[symbol] + + # Fetch data for cryptocurrencies grouped by API provider + crypto_by_provider = {} + for symbol, api_provider in self.crypto.items(): + crypto_by_provider.setdefault(api_provider, []).append(symbol) + + for api_provider, symbols in crypto_by_provider.items(): + if api_provider not in self.api_clients: + self.api_clients[api_provider] = ATWAPIClient( + self.hass, api_provider + ) + api_client = self.api_clients[api_provider] + for symbol in symbols: + try: + crypto_data = await api_client.get_crypto_data(symbol) + if crypto_data: + data[symbol] = crypto_data + else: + LOGGER.warning(f"No data received for crypto: {symbol}") + # Retain previous data if available + if self.data and symbol in self.data: + data[symbol] = self.data[symbol] + except Exception as e: + LOGGER.error(f"Error fetching data for crypto {symbol}: {e}") + # Retain previous data if available + if self.data and symbol in self.data: + data[symbol] = self.data[symbol] + + if not data: + # If new data is empty, retain the previous data + LOGGER.warning("No new data fetched, retaining previous data.") + return self.data or {} + else: + # Update the stored data + self.data = data + return data + except Exception as e: + LOGGER.error(f"Error in _async_update_data: {e}") + raise UpdateFailed(f"Error fetching data: {e}") from e async def fetch_historical_data( self, asset_symbol: str, asset_type: str, interval: str @@ -110,7 +197,9 @@ async def fetch_historical_data( LOGGER.error(f"Invalid asset type: {asset_type}") return - api_client = ATWAPIClient(self.hass, api_provider) + if api_provider not in self.api_clients: + self.api_clients[api_provider] = ATWAPIClient(self.hass, api_provider) + api_client = self.api_clients[api_provider] try: if asset_type == "stock": @@ -125,7 +214,7 @@ async def fetch_historical_data( if data: # Store the fetched data self.historical_data[asset_symbol] = data - LOGGER.info(f"Historical data retrieved for {asset_symbol}: {data}") + LOGGER.info(f"Historical data retrieved for {asset_symbol}") else: LOGGER.warning( f"No data returned for {asset_symbol} at interval {interval}" @@ -134,7 +223,7 @@ async def fetch_historical_data( except Exception as e: LOGGER.error(f"Error fetching historical data for {asset_symbol}: {e}") - def buy_stock(self, stock_symbol: str, amount: float, purchase_price: float): + async def buy_stock(self, stock_symbol: str, amount: float, purchase_price: float): """Log a stock purchase and update per-entry data.""" # Loop through entries to find the one that contains the stock for entry_id, entry_data in self.hass.data[DOMAIN].items(): @@ -160,6 +249,10 @@ def buy_stock(self, stock_symbol: str, amount: float, purchase_price: float): entry_data["stock_amount_owned"] = total_amount entry_data["stock_purchase_price"] = new_purchase_price + # Update the data store + self.data_store.set_entry_data(entry_id, entry_data) + await self.data_store.async_save() + LOGGER.debug( f"Updated entry {entry_id} for stock {stock_symbol}: amount={total_amount}, purchase_price={new_purchase_price}" ) @@ -167,7 +260,7 @@ def buy_stock(self, stock_symbol: str, amount: float, purchase_price: float): else: LOGGER.warning(f"Stock symbol {stock_symbol} not found in any entry.") - def sell_stock(self, stock_symbol: str, amount: float): + async def sell_stock(self, stock_symbol: str, amount: float): """Log a stock sale and update per-entry data.""" # Loop through entries to find the one that contains the stock for entry_id, entry_data in self.hass.data[DOMAIN].items(): @@ -188,6 +281,11 @@ def sell_stock(self, stock_symbol: str, amount: float): # Update the entry data new_amount = current_amount - amount entry_data["stock_amount_owned"] = new_amount + + # Update the data store + self.data_store.set_entry_data(entry_id, entry_data) + await self.data_store.async_save() + LOGGER.debug( f"Updated entry {entry_id} for stock {stock_symbol}: amount={new_amount}" ) @@ -195,7 +293,9 @@ def sell_stock(self, stock_symbol: str, amount: float): else: LOGGER.warning(f"Stock symbol {stock_symbol} not found in any entry.") - def buy_crypto(self, crypto_symbol: str, amount: float, purchase_price: float): + async def buy_crypto( + self, crypto_symbol: str, amount: float, purchase_price: float + ): """Log a cryptocurrency purchase and update per-entry data.""" # Loop through entries to find the one that contains the crypto for entry_id, entry_data in self.hass.data[DOMAIN].items(): @@ -221,6 +321,10 @@ def buy_crypto(self, crypto_symbol: str, amount: float, purchase_price: float): entry_data["crypto_amount_owned"] = total_amount entry_data["crypto_purchase_price"] = new_purchase_price + # Update the data store + self.data_store.set_entry_data(entry_id, entry_data) + await self.data_store.async_save() + LOGGER.debug( f"Updated entry {entry_id} for crypto {crypto_symbol}: amount={total_amount}, purchase_price={new_purchase_price}" ) @@ -230,7 +334,7 @@ def buy_crypto(self, crypto_symbol: str, amount: float, purchase_price: float): f"Cryptocurrency symbol {crypto_symbol} not found in any entry." ) - def sell_crypto(self, crypto_symbol: str, amount: float): + async def sell_crypto(self, crypto_symbol: str, amount: float): """Log a cryptocurrency sale and update per-entry data.""" # Loop through entries to find the one that contains the crypto for entry_id, entry_data in self.hass.data[DOMAIN].items(): @@ -251,11 +355,101 @@ def sell_crypto(self, crypto_symbol: str, amount: float): # Update the entry data new_amount = current_amount - amount entry_data["crypto_amount_owned"] = new_amount + + # Update the data store + self.data_store.set_entry_data(entry_id, entry_data) + await self.data_store.async_save() + LOGGER.debug( f"Updated entry {entry_id} for crypto {crypto_symbol}: amount={new_amount}" ) break - else: - LOGGER.warning( - f"Cryptocurrency symbol {crypto_symbol} not found in any entry." - ) + else: + LOGGER.warning( + f"Cryptocurrency symbol {crypto_symbol} not found in any entry." + ) + + def calculate_total_investment(self): + """Calculate the total amount invested in stocks and crypto.""" + total_investment = 0 + # Iterate over all entries + for entry_id, entry_data in self.hass.data[DOMAIN].items(): + if entry_id in ("coordinator", "portfolio_sensors_created"): + continue + if not isinstance(entry_data, dict): + continue + # Sum the total investment for stocks + stock_amount_owned = entry_data.get("stock_amount_owned", 0) + stock_purchase_price = entry_data.get("stock_purchase_price", 0) + total_investment += stock_amount_owned * stock_purchase_price + + # Sum the total investment for cryptos + crypto_amount_owned = entry_data.get("crypto_amount_owned", 0) + crypto_purchase_price = entry_data.get("crypto_purchase_price", 0) + total_investment += crypto_amount_owned * crypto_purchase_price + + return total_investment + + def calculate_total_value(self): + """Calculate the total current value of stocks and crypto.""" + total_value = 0 + # Iterate over all entries + for entry_id, entry_data in self.hass.data[DOMAIN].items(): + if entry_id in ("coordinator", "portfolio_sensors_created"): + continue + if not isinstance(entry_data, dict): + continue + # Calculate stock values + stock_amount_owned = entry_data.get("stock_amount_owned", 0) + for stock_symbol in entry_data.get("stocks_to_track", "").split(","): + stock_symbol = stock_symbol.strip() + if not stock_symbol: + continue + + # Fetch stock price from coordinator data + stock_data = self.data.get(stock_symbol) + if stock_data and "quoteResponse" in stock_data: + stock_info = stock_data["quoteResponse"]["result"][0] + stock_price = stock_info.get("regularMarketPrice") + if stock_price is None: + # Handle pre/post market prices + market_state = stock_info.get("marketState") + if market_state in ["PRE", "PREPRE"]: + stock_price = stock_info.get("preMarketPrice") + elif market_state in ["POST", "POSTPOST"]: + stock_price = stock_info.get("postMarketPrice") + if stock_price is not None: + total_value += stock_price * stock_amount_owned + + # Calculate crypto values + crypto_amount_owned = entry_data.get("crypto_amount_owned", 0) + for crypto_symbol in entry_data.get("crypto_to_track", "").split(","): + crypto_symbol = crypto_symbol.strip() + if not crypto_symbol: + continue + + # Fetch crypto price from coordinator data + crypto_data = self.data.get(crypto_symbol) + if crypto_data: + crypto_info = ( + crypto_data[0] if isinstance(crypto_data, list) else crypto_data + ) + crypto_price = crypto_info.get("current_price") + if crypto_price is not None: + total_value += crypto_price * crypto_amount_owned + + return total_value + + def calculate_percentage_change(self): + """Calculate the percentage change of the portfolio.""" + total_investment = self.calculate_total_investment() + total_value = self.calculate_total_value() + if total_investment == 0: + return 0 + return ((total_value - total_investment) / total_investment) * 100 + + def calculate_total_variation(self): + """Calculate the total variation (profit or loss) of the portfolio.""" + total_investment = self.calculate_total_investment() + total_value = self.calculate_total_value() + return total_value - total_investment diff --git a/custom_components/advanced_trading_wallet/manifest.json b/custom_components/advanced_trading_wallet/manifest.json index 6a4d529..cc0d54c 100644 --- a/custom_components/advanced_trading_wallet/manifest.json +++ b/custom_components/advanced_trading_wallet/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/ad-ha/atw/issues", "requirements": [], - "version": "0.1.0" + "version": "0.1.2" } diff --git a/custom_components/advanced_trading_wallet/sensor.py b/custom_components/advanced_trading_wallet/sensor.py index 88dde35..3c0b62f 100644 --- a/custom_components/advanced_trading_wallet/sensor.py +++ b/custom_components/advanced_trading_wallet/sensor.py @@ -1,167 +1,19 @@ -import datetime +import locale +import homeassistant.util.dt as dt_util from homeassistant.components.sensor import SensorEntity, SensorDeviceClass from homeassistant.helpers.restore_state import RestoreEntity -from .const import DOMAIN, LOGGER, DEFAULT_API_PROVIDER - -# Configuration for the available stock sensors (Yahoo Finance) -SENSOR_TYPES_STOCK = [ - { - "name": "Stock Price", - "key": "regularMarketPrice", - "device_class": SensorDeviceClass.MONETARY, - }, - { - "name": "Market Cap", - "key": "marketCap", - "device_class": SensorDeviceClass.MONETARY, - }, - { - "name": "Day High", - "key": "regularMarketDayHigh", - "device_class": SensorDeviceClass.MONETARY, - }, - { - "name": "Day Low", - "key": "regularMarketDayLow", - "device_class": SensorDeviceClass.MONETARY, - }, - { - "name": "Post Market Price", - "key": "postMarketPrice", - "device_class": SensorDeviceClass.MONETARY, - }, - { - "name": "Post Market Change", - "key": "postMarketChange", - "device_class": SensorDeviceClass.MONETARY, - }, - {"name": "Bid", "key": "bid", "device_class": SensorDeviceClass.MONETARY}, - {"name": "Ask", "key": "ask", "device_class": SensorDeviceClass.MONETARY}, - { - "name": "52-Week High", - "key": "fiftyTwoWeekHigh", - "device_class": SensorDeviceClass.MONETARY, - }, - { - "name": "52-Week Low", - "key": "fiftyTwoWeekLow", - "device_class": SensorDeviceClass.MONETARY, - }, - { - "name": "Dividend Rate", - "key": "dividendRate", - "device_class": SensorDeviceClass.MONETARY, - }, - { - "name": "Earnings Per Share (EPS)", - "key": "epsTrailingTwelveMonths", - "device_class": SensorDeviceClass.MONETARY, - }, - {"name": "Price to Earnings (PE) Ratio", "key": "trailingPE", "device_class": None}, - {"name": "Volume", "key": "regularMarketVolume", "device_class": None}, - { - "name": "Average Volume (3M)", - "key": "averageDailyVolume3Month", - "device_class": None, - }, - { - "name": "Average Volume (10D)", - "key": "averageDailyVolume10Day", - "device_class": None, - "unit": "Shares", - }, - {"name": "Shares Outstanding", "key": "sharesOutstanding", "device_class": None}, - { - "name": "Book Value", - "key": "bookValue", - "device_class": SensorDeviceClass.MONETARY, - }, - {"name": "Market State", "key": "marketState", "device_class": None}, - {"name": "Currency", "key": "currency", "device_class": None}, - {"name": "Display Name", "key": "displayName", "device_class": None}, - {"name": "Symbol", "key": "symbol", "device_class": None}, - {"name": "Short Name", "key": "shortName", "device_class": None}, - { - "name": "Average Analyst Rating", - "key": "averageAnalystRating", - "device_class": None, - }, - {"name": "Bid Size", "key": "bidSize", "device_class": None, "unit": "Shares"}, - {"name": "Ask Size", "key": "askSize", "device_class": None, "unit": "Shares"}, - { - "name": "50-Day Average", - "key": "fiftyDayAverage", - "device_class": SensorDeviceClass.MONETARY, - "unit": "USD", - }, - { - "name": "200-Day Average", - "key": "twoHundredDayAverage", - "device_class": SensorDeviceClass.MONETARY, - "unit": "USD", - }, - { - "name": "Dividend Yield", - "key": "dividendYield", - "device_class": None, - "unit": "%", - }, - { - "name": "Historical Stock Data", - "key": "historical_stock_data", - "device_class": None, - }, -] - -# Configuration for available crypto sensors (CoinGecko) -SENSOR_TYPES_CRYPTO = [ - { - "name": "Crypto Price", - "key": "current_price", - "device_class": SensorDeviceClass.MONETARY, - }, - { - "name": "Market Cap", - "key": "market_cap", - "device_class": SensorDeviceClass.MONETARY, - }, - {"name": "Market Cap Rank", "key": "market_cap_rank", "device_class": None}, - {"name": "24h Volume", "key": "total_volume", "device_class": None}, - {"name": "24h High", "key": "high_24h", "device_class": SensorDeviceClass.MONETARY}, - {"name": "24h Low", "key": "low_24h", "device_class": SensorDeviceClass.MONETARY}, - { - "name": "ATH (All Time High)", - "key": "ath", - "device_class": SensorDeviceClass.MONETARY, - }, - { - "name": "ATL (All Time Low)", - "key": "atl", - "device_class": SensorDeviceClass.MONETARY, - }, - {"name": "Circulating Supply", "key": "circulating_supply", "device_class": None}, - { - "name": "Price Change (24h)", - "key": "price_change_percentage_24h", - "device_class": None, - }, - { - "name": "Fully Diluted Valuation", - "key": "fully_diluted_valuation", - "device_class": SensorDeviceClass.MONETARY, - }, - { - "name": "Market Cap Change 24h", - "key": "market_cap_change_24h", - "device_class": SensorDeviceClass.MONETARY, - }, - {"name": "Total Supply", "key": "total_supply", "device_class": None}, - { - "name": "Historical Crypto Data", - "key": "historical_crypto_data", - "device_class": None, - }, -] +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import ( + DOMAIN, + LOGGER, + DEFAULT_API_PROVIDER, + SENSOR_TYPES_STOCK, + SENSOR_TYPES_CRYPTO, +) +from .coordinator import ATWCoordinator + +# Set the locale to the system's default +locale.setlocale(locale.LC_ALL, "") async def async_setup_entry(hass, entry, async_add_entities): @@ -184,7 +36,7 @@ async def async_setup_entry(hass, entry, async_add_entities): ATWSensor( coordinator, stock_symbol, - f"{stock_symbol} {sensor_type['name']}", + sensor_type["name"], sensor_type["key"], sensor_type.get("device_class"), coordinator.preferred_currency, @@ -213,7 +65,7 @@ async def async_setup_entry(hass, entry, async_add_entities): ATWSensor( coordinator, crypto_symbol, - f"{crypto_symbol} {sensor_type['name']}", + sensor_type["name"], sensor_type["key"], sensor_type.get("device_class"), coordinator.preferred_currency, @@ -250,41 +102,41 @@ async def async_setup_entry(hass, entry, async_add_entities): await coordinator.async_request_refresh() -class ATWSensor(SensorEntity, RestoreEntity): +class ATWSensor(CoordinatorEntity, SensorEntity): """Generic sensor for stock/crypto data.""" def __init__( self, coordinator, symbol, - name, + sensor_name, data_key, device_class=None, preferred_currency=None, api_provider=None, ): """Initialize the sensor.""" - self.coordinator = coordinator - self._name = name + super().__init__(coordinator) + self._name = f"{symbol.upper()} {sensor_name}" self._symbol = symbol + self._sensor_name = sensor_name self._data_key = data_key self._device_class = device_class - self._preferred_currency = preferred_currency + self._preferred_currency = preferred_currency.upper() self._api_provider = api_provider self._state = None self._attr_device_class = device_class + self._last_updated = None @property def native_value(self): """Return the state of the sensor.""" - if not self.coordinator.data: - LOGGER.warning(f"No data available for {self._symbol}") - return None - data = self.coordinator.data.get(self._symbol) if not data: - LOGGER.warning(f"No data for symbol: {self._symbol}") - return None + LOGGER.warning( + f"No data for symbol: {self._symbol}, using last known value." + ) + return self._state # Return last known state if self._api_provider == "Yahoo Finance": data = data.get("quoteResponse", {}).get("result", [{}])[0] @@ -292,14 +144,31 @@ def native_value(self): data = data[0] if isinstance(data, list) else data else: LOGGER.error(f"Unknown API provider for {self._symbol}") - return None + return self._state # Return last known state - raw_value = data.get(self._data_key) - if isinstance(raw_value, (int, float)): - return raw_value + if self._data_key == "regularMarketPrice": + # Handle stock price based on market state + raw_value = self.get_stock_price(data) + else: + raw_value = data.get(self._data_key) + + if raw_value is not None: + self._state = raw_value + self._last_updated = dt_util.utcnow() + else: + LOGGER.warning(f"No data for {self._symbol}: {self._data_key}") + # Do not update self._state if data is None - LOGGER.warning(f"Invalid data format for {self._symbol}: {raw_value}") - return raw_value + return self._state + + def get_stock_price(self, data): + market_state = data.get("marketState") + if market_state in ["PRE", "PREPRE"] and "preMarketPrice" in data: + return data["preMarketPrice"] + elif market_state in ["POST", "POSTPOST"] and "postMarketPrice" in data: + return data["postMarketPrice"] + else: + return data.get("regularMarketPrice") @property def name(self): @@ -317,14 +186,26 @@ def extra_state_attributes(self): raw_value = self.native_value if isinstance(raw_value, (int, float)): - formatted_value = f"{raw_value:,.2f}" + # Determine the number of decimal places based on asset type and sensor type + if "amount" in self._name.lower(): + decimal_places = 4 + elif "crypto" in self._name.lower(): + decimal_places = 8 + elif "percentage" in self._name.lower(): + decimal_places = 2 + else: + decimal_places = 2 + formatted_value = locale.format_string( + f"%.{decimal_places}f", raw_value, grouping=True + ) else: formatted_value = raw_value return { "formatted_value": formatted_value, - "symbol": self._symbol, + "symbol": self._symbol.upper(), "api_provider": self._api_provider, + "last_updated": self._last_updated, } @property @@ -332,30 +213,41 @@ def native_unit_of_measurement(self): """Return the unit of measurement.""" if self._device_class == SensorDeviceClass.MONETARY: return self._preferred_currency - return None + elif "percentage" in self._name.lower(): + return "%" + else: + return None @property def device_info(self): """Return the device info.""" return { "identifiers": {(DOMAIN, self._symbol)}, - "name": f"Asset: {self._symbol}", + "name": f"Asset: {self._symbol.upper()}", "manufacturer": "Advanced Trading Wallet", } - async def async_update(self): - """Update the sensor.""" - await self.coordinator.async_request_refresh() + @property + def available(self): + """Return True if sensor data is available.""" + data = self.coordinator.data.get(self._symbol) + if not data: + return False + if self._api_provider == "Yahoo Finance": + data = data.get("quoteResponse", {}).get("result", [{}])[0] + elif self._api_provider == "CoinGecko": + data = data[0] if isinstance(data, list) else data + return self._data_key in data or self._data_key == "regularMarketPrice" -class StockAmountSensor(SensorEntity, RestoreEntity): +class StockAmountSensor(CoordinatorEntity, SensorEntity): """Sensor for stock total amount owned.""" def __init__(self, coordinator, stock_symbol, entry_data, config_entry_id): """Initialize the sensor.""" - self.coordinator = coordinator + super().__init__(coordinator) self._stock_symbol = stock_symbol - self._name = f"{stock_symbol} Total Amount" + self._name = f"{stock_symbol.upper()} Total Amount" self._state = None self.entry_data = entry_data self.config_entry_id = config_entry_id @@ -366,7 +258,7 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the current state.""" # Access per-entry amount owned self._state = self.entry_data.get("stock_amount_owned", 0) @@ -377,6 +269,16 @@ def unique_id(self): """Return a unique ID for the sensor.""" return f"{self._stock_symbol}_{self.config_entry_id}_total_amount" + @property + def extra_state_attributes(self): + """Return extra state attributes, including formatted value.""" + raw_value = self.native_value + formatted_value = locale.format_string("%.4f", raw_value, grouping=True) + return { + "formatted_value": formatted_value, + "symbol": self._stock_symbol.upper(), + } + @property def device_info(self): """Return the device info for the Portfolio.""" @@ -387,17 +289,18 @@ def device_info(self): } -class StockPurchasePriceSensor(SensorEntity, RestoreEntity): +class StockPurchasePriceSensor(CoordinatorEntity, SensorEntity): """Sensor for stock purchase price.""" def __init__(self, coordinator, stock_symbol, entry_data, config_entry_id): """Initialize the sensor.""" - self.coordinator = coordinator + super().__init__(coordinator) self._stock_symbol = stock_symbol - self._name = f"{stock_symbol} Purchase Price" + self._name = f"{stock_symbol.upper()} Purchase Price" self._state = None self.entry_data = entry_data self.config_entry_id = config_entry_id + self._preferred_currency = coordinator.preferred_currency @property def name(self): @@ -405,16 +308,31 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the current state.""" self._state = self.entry_data.get("stock_purchase_price", 0) return self._state + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return self._preferred_currency + @property def unique_id(self): """Return a unique ID for the sensor.""" return f"{self._stock_symbol}_{self.config_entry_id}_purchase_price" + @property + def extra_state_attributes(self): + """Return extra state attributes, including formatted value.""" + raw_value = self.native_value + formatted_value = locale.format_string("%.2f", raw_value, grouping=True) + return { + "formatted_value": formatted_value, + "symbol": self._stock_symbol.upper(), + } + @property def device_info(self): """Return the device info for the Portfolio.""" @@ -425,14 +343,14 @@ def device_info(self): } -class CryptoAmountSensor(SensorEntity, RestoreEntity): +class CryptoAmountSensor(CoordinatorEntity, SensorEntity): """Sensor for crypto total amount owned.""" def __init__(self, coordinator, crypto_symbol, entry_data, config_entry_id): """Initialize the sensor.""" - self.coordinator = coordinator + super().__init__(coordinator) self._crypto_symbol = crypto_symbol - self._name = f"{crypto_symbol} Total Amount" + self._name = f"{crypto_symbol.upper()} Total Amount" self._state = None self.entry_data = entry_data self.config_entry_id = config_entry_id @@ -443,7 +361,7 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the current state.""" self._state = self.entry_data.get("crypto_amount_owned", 0) return self._state @@ -453,6 +371,16 @@ def unique_id(self): """Return a unique ID for the sensor.""" return f"{self._crypto_symbol}_{self.config_entry_id}_total_amount" + @property + def extra_state_attributes(self): + """Return extra state attributes, including formatted value.""" + raw_value = self.native_value + formatted_value = locale.format_string("%.4f", raw_value, grouping=True) + return { + "formatted_value": formatted_value, + "symbol": self._crypto_symbol.upper(), + } + @property def device_info(self): """Return the device info for the Portfolio.""" @@ -463,17 +391,18 @@ def device_info(self): } -class CryptoPurchasePriceSensor(SensorEntity, RestoreEntity): +class CryptoPurchasePriceSensor(CoordinatorEntity, SensorEntity): """Sensor for crypto purchase price.""" def __init__(self, coordinator, crypto_symbol, entry_data, config_entry_id): """Initialize the sensor.""" - self.coordinator = coordinator + super().__init__(coordinator) self._crypto_symbol = crypto_symbol - self._name = f"{crypto_symbol} Purchase Price" + self._name = f"{crypto_symbol.upper()} Purchase Price" self._state = None self.entry_data = entry_data self.config_entry_id = config_entry_id + self._preferred_currency = coordinator.preferred_currency @property def name(self): @@ -481,16 +410,31 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the current state.""" self._state = self.entry_data.get("crypto_purchase_price", 0) return self._state + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return self._preferred_currency + @property def unique_id(self): """Return a unique ID for the sensor.""" return f"{self._crypto_symbol}_{self.config_entry_id}_purchase_price" + @property + def extra_state_attributes(self): + """Return extra state attributes, including formatted value.""" + raw_value = self.native_value + formatted_value = locale.format_string("%.8f", raw_value, grouping=True) + return { + "formatted_value": formatted_value, + "symbol": self._crypto_symbol.upper(), + } + @property def device_info(self): """Return the device info for the Portfolio.""" @@ -501,15 +445,16 @@ def device_info(self): } -class TotalPortfolioValueSensor(SensorEntity, RestoreEntity): +class TotalPortfolioValueSensor(CoordinatorEntity, SensorEntity): """Sensor to track the total value of stocks and crypto in the portfolio.""" def __init__(self, hass, coordinator): """Initialize the sensor.""" self.hass = hass - self.coordinator = coordinator + super().__init__(coordinator) self._name = "Total Portfolio Value" self._state = None + self._preferred_currency = coordinator.preferred_currency @property def name(self): @@ -517,69 +462,23 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the current state.""" - total_value = 0 - - # Fetch per-entry data from hass.data - for entry_id, entry_data in self.hass.data[DOMAIN].items(): - if entry_id in ("coordinator", "portfolio_sensors_created"): - continue - if not isinstance(entry_data, dict): - LOGGER.warning( - f"Unexpected data type for entry_id '{entry_id}': {type(entry_data)}" - ) - continue - # Calculate stock values - stock_amount_owned = entry_data.get("stock_amount_owned", 0) - for stock_symbol in entry_data.get("stocks_to_track", "").split(","): - stock_symbol = stock_symbol.strip() - if not stock_symbol: - continue - - # Generate the correct entity ID - entity_id = f"sensor.{stock_symbol.replace('-', '_')}_stock_price" - - stock_price_sensor = self.hass.states.get(entity_id) - - if stock_price_sensor and stock_price_sensor.state not in ( - None, - "unknown", - ): - try: - stock_price = float(stock_price_sensor.state) - total_value += stock_price * stock_amount_owned - except ValueError as e: - LOGGER.warning( - f"Error converting values for {stock_symbol}: {e}" - ) - - # Calculate crypto values - crypto_amount_owned = entry_data.get("crypto_amount_owned", 0) - for crypto_symbol in entry_data.get("crypto_to_track", "").split(","): - crypto_symbol = crypto_symbol.strip() - if not crypto_symbol: - continue - - # Generate the correct entity ID - entity_id = f"sensor.{crypto_symbol.replace('-', '_')}_crypto_price" - - crypto_price_sensor = self.hass.states.get(entity_id) + self._state = self.coordinator.calculate_total_value() + return self._state - if crypto_price_sensor and crypto_price_sensor.state not in ( - None, - "unknown", - ): - try: - crypto_price = float(crypto_price_sensor.state) - total_value += crypto_price * crypto_amount_owned - except ValueError as e: - LOGGER.warning( - f"Error converting values for {crypto_symbol}: {e}" - ) + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return self._preferred_currency - self._state = total_value - return self._state + @property + def extra_state_attributes(self): + """Return extra state attributes, including formatted value.""" + formatted_value = locale.format_string("%.2f", self._state, grouping=True) + return { + "formatted_value": formatted_value, + } @property def unique_id(self): @@ -596,15 +495,16 @@ def device_info(self): } -class TotalStocksValueSensor(SensorEntity, RestoreEntity): +class TotalStocksValueSensor(CoordinatorEntity, SensorEntity): """Sensor to track the total value of stocks in the portfolio.""" def __init__(self, hass, coordinator): """Initialize the sensor.""" self.hass = hass - self.coordinator = coordinator + super().__init__(coordinator) self._name = "Total Stocks Value" self._state = None + self._preferred_currency = coordinator.preferred_currency @property def name(self): @@ -612,46 +512,50 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the current state.""" total_stocks_value = 0 - - # Fetch per-entry data from hass.data + # Iterate over all entries to calculate stock values for entry_id, entry_data in self.hass.data[DOMAIN].items(): if entry_id in ("coordinator", "portfolio_sensors_created"): continue if not isinstance(entry_data, dict): - LOGGER.warning( - f"Unexpected data type for entry_id '{entry_id}': {type(entry_data)}" - ) continue - # Calculate stock values stock_amount_owned = entry_data.get("stock_amount_owned", 0) for stock_symbol in entry_data.get("stocks_to_track", "").split(","): stock_symbol = stock_symbol.strip() if not stock_symbol: continue - - # Generate the correct entity ID - entity_id = f"sensor.{stock_symbol.replace('-', '_')}_stock_price" - - stock_price_sensor = self.hass.states.get(entity_id) - - if stock_price_sensor and stock_price_sensor.state not in ( - None, - "unknown", - ): - try: - stock_price = float(stock_price_sensor.state) + # Fetch stock price from coordinator data + stock_data = self.coordinator.data.get(stock_symbol) + if stock_data and "quoteResponse" in stock_data: + stock_info = stock_data["quoteResponse"]["result"][0] + stock_price = stock_info.get("regularMarketPrice") + if stock_price is None: + # Handle pre/post market prices + market_state = stock_info.get("marketState") + if market_state in ["PRE", "PREPRE"]: + stock_price = stock_info.get("preMarketPrice") + elif market_state in ["POST", "POSTPOST"]: + stock_price = stock_info.get("postMarketPrice") + if stock_price is not None: total_stocks_value += stock_price * stock_amount_owned - except ValueError as e: - LOGGER.warning( - f"Error converting values for {stock_symbol}: {e}" - ) - self._state = total_stocks_value return self._state + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return self._preferred_currency + + @property + def extra_state_attributes(self): + """Return extra state attributes, including formatted value.""" + formatted_value = locale.format_string("%.2f", self._state, grouping=True) + return { + "formatted_value": formatted_value, + } + @property def unique_id(self): """Return a unique ID for the sensor.""" @@ -667,15 +571,16 @@ def device_info(self): } -class TotalCryptoValueSensor(SensorEntity, RestoreEntity): +class TotalCryptoValueSensor(CoordinatorEntity, SensorEntity): """Sensor to track the total value of crypto in the portfolio.""" def __init__(self, hass, coordinator): """Initialize the sensor.""" self.hass = hass - self.coordinator = coordinator + super().__init__(coordinator) self._name = "Total Crypto Value" self._state = None + self._preferred_currency = coordinator.preferred_currency @property def name(self): @@ -683,46 +588,45 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the current state.""" total_crypto_value = 0 - - # Fetch per-entry data from hass.data + # Iterate over all entries to calculate crypto values for entry_id, entry_data in self.hass.data[DOMAIN].items(): if entry_id in ("coordinator", "portfolio_sensors_created"): continue if not isinstance(entry_data, dict): - LOGGER.warning( - f"Unexpected data type for entry_id '{entry_id}': {type(entry_data)}" - ) continue - # Calculate crypto values crypto_amount_owned = entry_data.get("crypto_amount_owned", 0) for crypto_symbol in entry_data.get("crypto_to_track", "").split(","): crypto_symbol = crypto_symbol.strip() if not crypto_symbol: continue - - # Generate the correct entity ID - entity_id = f"sensor.{crypto_symbol.replace('-', '_')}_crypto_price" - - crypto_price_sensor = self.hass.states.get(entity_id) - - if crypto_price_sensor and crypto_price_sensor.state not in ( - None, - "unknown", - ): - try: - crypto_price = float(crypto_price_sensor.state) + # Fetch crypto price from coordinator data + crypto_data = self.coordinator.data.get(crypto_symbol) + if crypto_data: + crypto_info = ( + crypto_data[0] if isinstance(crypto_data, list) else crypto_data + ) + crypto_price = crypto_info.get("current_price") + if crypto_price is not None: total_crypto_value += crypto_price * crypto_amount_owned - except ValueError as e: - LOGGER.warning( - f"Error converting values for {crypto_symbol}: {e}" - ) - self._state = total_crypto_value return self._state + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return self._preferred_currency + + @property + def extra_state_attributes(self): + """Return extra state attributes, including formatted value.""" + formatted_value = locale.format_string("%.2f", self._state, grouping=True) + return { + "formatted_value": formatted_value, + } + @property def unique_id(self): """Return a unique ID for the sensor.""" @@ -738,15 +642,16 @@ def device_info(self): } -class TotalInvestmentSensor(SensorEntity, RestoreEntity): +class TotalInvestmentSensor(CoordinatorEntity, SensorEntity): """Sensor to track the total investment value (stocks and crypto) in the portfolio.""" def __init__(self, hass, coordinator): """Initialize the sensor.""" self.hass = hass - self.coordinator = coordinator + super().__init__(coordinator) self._name = "Total Investment" self._state = None + self._preferred_currency = coordinator.preferred_currency @property def name(self): @@ -754,31 +659,23 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the current state.""" - total_investment = 0 - - # Iterate over all entries - for entry_id, entry_data in self.hass.data[DOMAIN].items(): - if entry_id in ("coordinator", "portfolio_sensors_created"): - continue - if not isinstance(entry_data, dict): - LOGGER.warning( - f"Unexpected data type for entry_id '{entry_id}': {type(entry_data)}" - ) - continue - # Sum the total investment for stocks - stock_amount_owned = entry_data.get("stock_amount_owned", 0) - stock_purchase_price = entry_data.get("stock_purchase_price", 0) - total_investment += stock_amount_owned * stock_purchase_price + self._state = self.coordinator.calculate_total_investment() + return self._state - # Sum the total investment for cryptos - crypto_amount_owned = entry_data.get("crypto_amount_owned", 0) - crypto_purchase_price = entry_data.get("crypto_purchase_price", 0) - total_investment += crypto_amount_owned * crypto_purchase_price + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return self._preferred_currency - self._state = total_investment - return self._state + @property + def extra_state_attributes(self): + """Return extra state attributes, including formatted value.""" + formatted_value = locale.format_string("%.2f", self._state, grouping=True) + return { + "formatted_value": formatted_value, + } @property def unique_id(self): @@ -795,13 +692,13 @@ def device_info(self): } -class PercentageChangeSensor(SensorEntity, RestoreEntity): +class PercentageChangeSensor(CoordinatorEntity, SensorEntity): """Sensor to track the percentage change in the portfolio value.""" def __init__(self, hass, coordinator): """Initialize the sensor.""" self.hass = hass - self.coordinator = coordinator + super().__init__(coordinator) self._name = "Percentage Change" self._state = None @@ -811,82 +708,23 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the current state.""" - total_investment = 0 - total_value = 0 - - # Iterate over all entries - for entry_id, entry_data in self.hass.data[DOMAIN].items(): - if entry_id in ("coordinator", "portfolio_sensors_created"): - continue - if not isinstance(entry_data, dict): - LOGGER.warning( - f"Unexpected data type for entry_id '{entry_id}': {type(entry_data)}" - ) - continue - # Sum the total investment for stocks - stock_amount_owned = entry_data.get("stock_amount_owned", 0) - stock_purchase_price = entry_data.get("stock_purchase_price", 0) - total_investment += stock_amount_owned * stock_purchase_price - - # Sum the total investment for cryptos - crypto_amount_owned = entry_data.get("crypto_amount_owned", 0) - crypto_purchase_price = entry_data.get("crypto_purchase_price", 0) - total_investment += crypto_amount_owned * crypto_purchase_price - - # Calculate stock values - for stock_symbol in entry_data.get("stocks_to_track", "").split(","): - stock_symbol = stock_symbol.strip() - if not stock_symbol: - continue - - # Generate the correct entity ID - entity_id = f"sensor.{stock_symbol.replace('-', '_')}_stock_price" - - stock_price_sensor = self.hass.states.get(entity_id) - - if stock_price_sensor and stock_price_sensor.state not in ( - None, - "unknown", - ): - try: - stock_price = float(stock_price_sensor.state) - total_value += stock_price * stock_amount_owned - except ValueError as e: - LOGGER.warning( - f"Error converting values for {stock_symbol}: {e}" - ) - - # Calculate crypto values - for crypto_symbol in entry_data.get("crypto_to_track", "").split(","): - crypto_symbol = crypto_symbol.strip() - if not crypto_symbol: - continue + self._state = self.coordinator.calculate_percentage_change() + return self._state - # Generate the correct entity ID - entity_id = f"sensor.{crypto_symbol.replace('-', '_')}_crypto_price" - - crypto_price_sensor = self.hass.states.get(entity_id) - - if crypto_price_sensor and crypto_price_sensor.state not in ( - None, - "unknown", - ): - try: - crypto_price = float(crypto_price_sensor.state) - total_value += crypto_price * crypto_amount_owned - except ValueError as e: - LOGGER.warning( - f"Error converting values for {crypto_symbol}: {e}" - ) - - if total_investment == 0: - self._state = 0 # Avoid division by zero - else: - self._state = ((total_value - total_investment) / total_investment) * 100 + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return "%" - return self._state + @property + def extra_state_attributes(self): + """Return extra state attributes, including formatted value.""" + formatted_value = locale.format_string("%.2f", self._state, grouping=True) + return { + "formatted_value": formatted_value, + } @property def unique_id(self): @@ -903,15 +741,16 @@ def device_info(self): } -class TotalVariationSensor(SensorEntity, RestoreEntity): +class TotalVariationSensor(CoordinatorEntity, SensorEntity): """Sensor to track the total variation (increase/decrease) in portfolio value.""" def __init__(self, hass, coordinator): """Initialize the sensor.""" self.hass = hass - self.coordinator = coordinator + super().__init__(coordinator) self._name = "Total Variation" self._state = None + self._preferred_currency = coordinator.preferred_currency @property def name(self): @@ -919,78 +758,23 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the current state.""" - total_investment = 0 - total_value = 0 - - # Iterate over all entries - for entry_id, entry_data in self.hass.data[DOMAIN].items(): - if entry_id in ("coordinator", "portfolio_sensors_created"): - continue - if not isinstance(entry_data, dict): - LOGGER.warning( - f"Unexpected data type for entry_id '{entry_id}': {type(entry_data)}" - ) - continue - # Sum the total investment for stocks - stock_amount_owned = entry_data.get("stock_amount_owned", 0) - stock_purchase_price = entry_data.get("stock_purchase_price", 0) - total_investment += stock_amount_owned * stock_purchase_price - - # Sum the total investment for cryptos - crypto_amount_owned = entry_data.get("crypto_amount_owned", 0) - crypto_purchase_price = entry_data.get("crypto_purchase_price", 0) - total_investment += crypto_amount_owned * crypto_purchase_price - - # Calculate stock values - for stock_symbol in entry_data.get("stocks_to_track", "").split(","): - stock_symbol = stock_symbol.strip() - if not stock_symbol: - continue - - # Generate the correct entity ID - entity_id = f"sensor.{stock_symbol.replace('-', '_')}_stock_price" - - stock_price_sensor = self.hass.states.get(entity_id) - - if stock_price_sensor and stock_price_sensor.state not in ( - None, - "unknown", - ): - try: - stock_price = float(stock_price_sensor.state) - total_value += stock_price * stock_amount_owned - except ValueError as e: - LOGGER.warning( - f"Error converting values for {stock_symbol}: {e}" - ) - - # Calculate crypto values - for crypto_symbol in entry_data.get("crypto_to_track", "").split(","): - crypto_symbol = crypto_symbol.strip() - if not crypto_symbol: - continue - - # Generate the correct entity ID - entity_id = f"sensor.{crypto_symbol.replace('-', '_')}_crypto_price" - - crypto_price_sensor = self.hass.states.get(entity_id) + self._state = self.coordinator.calculate_total_variation() + return self._state - if crypto_price_sensor and crypto_price_sensor.state not in ( - None, - "unknown", - ): - try: - crypto_price = float(crypto_price_sensor.state) - total_value += crypto_price * crypto_amount_owned - except ValueError as e: - LOGGER.warning( - f"Error converting values for {crypto_symbol}: {e}" - ) + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return self._preferred_currency - self._state = total_value - total_investment - return self._state + @property + def extra_state_attributes(self): + """Return extra state attributes, including formatted value.""" + formatted_value = locale.format_string("%.2f", self._state, grouping=True) + return { + "formatted_value": formatted_value, + } @property def unique_id(self): diff --git a/custom_components/advanced_trading_wallet/services.py b/custom_components/advanced_trading_wallet/services.py index 384947f..244cdba 100644 --- a/custom_components/advanced_trading_wallet/services.py +++ b/custom_components/advanced_trading_wallet/services.py @@ -15,40 +15,19 @@ async def async_setup_services( async def handle_refresh_data(service_call: ServiceCall) -> None: """Handle the refresh data service.""" - entry_id = service_call.data.get("entry_id") - coordinator = hass.data[DOMAIN].get(entry_id) - if not coordinator: - LOGGER.error(f"Coordinator not found for entry_id: {entry_id}") - return await coordinator.async_request_refresh() async def handle_get_historical_data(service_call: ServiceCall) -> None: """Handle the get historical data service.""" try: - entry_id = service_call.data.get("entry_id") - - # Automatically pick the first available entry_id if not provided - if not entry_id: - entry_id = list(hass.data[DOMAIN].keys())[0] - LOGGER.warning(f"No entry_id provided. Defaulting to: {entry_id}") - asset_symbol = service_call.data["asset_symbol"] asset_type = service_call.data["asset_type"] interval = service_call.data.get("interval", DEFAULT_HISTORICAL_INTERVAL) - # Fetch the coordinator using the entry_id - coordinator = hass.data[DOMAIN].get(entry_id) - - if coordinator: - # Fetch the historical data for the requested asset - historical_data = await coordinator.fetch_historical_data( - asset_symbol, asset_type, interval - ) - LOGGER.info( - f"Historical data for {asset_symbol} over {interval}: {historical_data}" - ) - else: - LOGGER.error(f"Coordinator not found for entry_id: {entry_id}") + await coordinator.fetch_historical_data(asset_symbol, asset_type, interval) + LOGGER.info( + f"Historical data for {asset_symbol} over {interval} fetched successfully." + ) except KeyError as key_err: LOGGER.error(f"Missing required service data: {key_err}") @@ -62,15 +41,15 @@ async def handle_get_historical_data(service_call: ServiceCall) -> None: async def handle_buy_stock(service_call: ServiceCall) -> None: """Handle the buy_stock service.""" stock_symbol = service_call.data.get("stock_symbol") - amount = service_call.data.get("amount") - purchase_price = service_call.data.get("purchase_price") + amount = float(service_call.data.get("amount")) + purchase_price = float(service_call.data.get("purchase_price")) LOGGER.debug( f"Service call to buy stock: {stock_symbol}, amount: {amount}, purchase price: {purchase_price}" ) try: - coordinator.buy_stock(stock_symbol, amount, purchase_price) + await coordinator.buy_stock(stock_symbol, amount, purchase_price) await coordinator.async_request_refresh() except Exception as e: LOGGER.error(f"Error in buy_stock service: {e}") @@ -78,12 +57,12 @@ async def handle_buy_stock(service_call: ServiceCall) -> None: async def handle_sell_stock(service_call: ServiceCall): """Handle a stock sale.""" stock_symbol = service_call.data.get("stock_symbol") - amount = service_call.data.get("amount") + amount = float(service_call.data.get("amount")) LOGGER.debug(f"Service call to sell stock: {stock_symbol}, amount: {amount}") try: - coordinator.sell_stock(stock_symbol, amount) + await coordinator.sell_stock(stock_symbol, amount) await coordinator.async_request_refresh() except ValueError as e: LOGGER.error(f"Error in sell_stock service: {e}") @@ -93,15 +72,15 @@ async def handle_sell_stock(service_call: ServiceCall): async def handle_buy_crypto(service_call: ServiceCall): """Handle a cryptocurrency purchase.""" crypto_symbol = service_call.data.get("crypto_symbol") - amount = service_call.data.get("amount") - purchase_price = service_call.data.get("purchase_price") + amount = float(service_call.data.get("amount")) + purchase_price = float(service_call.data.get("purchase_price")) LOGGER.debug( f"Service call to buy crypto: {crypto_symbol}, amount: {amount}, purchase price: {purchase_price}" ) try: - coordinator.buy_crypto(crypto_symbol, amount, purchase_price) + await coordinator.buy_crypto(crypto_symbol, amount, purchase_price) await coordinator.async_request_refresh() except Exception as e: LOGGER.error(f"Error in buy_crypto service: {e}") @@ -109,17 +88,17 @@ async def handle_buy_crypto(service_call: ServiceCall): async def handle_sell_crypto(service_call: ServiceCall): """Handle a cryptocurrency sale.""" crypto_symbol = service_call.data.get("crypto_symbol") - amount = service_call.data.get("amount") + amount = float(service_call.data.get("amount")) LOGGER.debug(f"Service call to sell crypto: {crypto_symbol}, amount: {amount}") try: - coordinator.sell_crypto(crypto_symbol, amount) + await coordinator.sell_crypto(crypto_symbol, amount) await coordinator.async_request_refresh() except ValueError as e: LOGGER.error(f"Error in sell_crypto service: {e}") except Exception as e: - LOGGER.error(f"Un expected error in sell_crypto service: {e}") + LOGGER.error(f"Unexpected error in sell_crypto service: {e}") # Register services hass.services.async_register(DOMAIN, "refresh_data", handle_refresh_data) @@ -141,5 +120,5 @@ async def async_unload_services(hass: HomeAssistant) -> None: hass.services.async_remove(DOMAIN, "get_historical_data") hass.services.async_remove(DOMAIN, "buy_stock") hass.services.async_remove(DOMAIN, "sell_stock") - hass.services.async_remove(DOMAIN, "buy_cripto") + hass.services.async_remove(DOMAIN, "buy_crypto") hass.services.async_remove(DOMAIN, "sell_crypto") diff --git a/custom_components/advanced_trading_wallet/translations/en.json b/custom_components/advanced_trading_wallet/translations/en.json index 8761ec7..ccc4fe1 100644 --- a/custom_components/advanced_trading_wallet/translations/en.json +++ b/custom_components/advanced_trading_wallet/translations/en.json @@ -42,12 +42,11 @@ }, "options": { "step": { - "user": { + "init": { "title": "Adjust Scan Interval", "description": "Set how often you want to scan for stock and cryptocurrency updates.", "data": { - "scan_interval": "Scan Interval (minutes)", - "charging_scan_interval": "Charging Scan Interval (minutes)" + "update_interval": "Update Interval (minutes)" } } } diff --git a/custom_components/advanced_trading_wallet/translations/es.json b/custom_components/advanced_trading_wallet/translations/es.json index 4db00e7..ce7b9ba 100644 --- a/custom_components/advanced_trading_wallet/translations/es.json +++ b/custom_components/advanced_trading_wallet/translations/es.json @@ -42,12 +42,11 @@ }, "options": { "step": { - "user": { + "init": { "title": "Ajustar Intervalo de Escaneo", "description": "Establece con quĆ© frecuencia deseas escanear actualizaciones de acciones y criptomonedas.", "data": { - "scan_interval": "Intervalo de Escaneo (minutos)", - "charging_scan_interval": "Intervalo de Escaneo para Carga (minutos)" + "update_interval": "Intervalo de ActualizaciĆ³n (minutos)" } } }