diff --git a/CHANGELOG.md b/CHANGELOG.md index c79bed4..244a668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **YTD Profit/Loss sensor**: Year-to-date profit/loss in the account's base currency (`sensor.saxo_{clientid}_ytd_profit_loss`) +- **YTD Net Transfers sensor**: Year-to-date net deposits and withdrawals (`sensor.saxo_{clientid}_ytd_cash_transfer`) + +### Fixed +- **YTD Investment Performance now measures year-to-date**: the sensor previously used Saxo's `StandardPeriod=Year`, which is a *trailing 12-month* window rather than year-to-date. It now uses an explicit window anchored to 1 January. + + **This changes the reported value.** On a test account the sensor read 17.83% (trailing 12 months) where true year-to-date was 9.32%. The `from`/`thru` attributes already claimed a 1 January start, so they were previously inaccurate; they are now correct. + + Long-term statistics recorded for this entity before the upgrade are trailing-12-month figures, so historical graphs will show a discontinuity at the upgrade point. The `entity_id` is unchanged — dashboards and automations continue to work. + +### Changed +- Performance data no longer fetches the trailing `Year` window; the January-anchored request takes its place, keeping the refresh at four API calls +- `Month` and `Quarter` performance requests trimmed to the `KeyFigures` field group +- Removed unused `get_performance_v4`, `get_performance_v4_ytd`, `get_performance_v4_month` and `get_performance_v4_quarter` client methods + +### Known Issues +- **Month and Quarter Investment Performance are also trailing windows**, not month-to-date and quarter-to-date: `StandardPeriod=Month` returns a rolling ~28 days and `Quarter` a rolling ~90 days. Their `from`/`thru` attributes are therefore inaccurate. Correcting these is deferred; see `docs/superpowers/specs/2026-08-04-ytd-sensors-design.md`. + ## [2.9.0-beta.2] - 2026-04-17 ### Added diff --git a/README.md b/README.md index 677558c..bea3c83 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@ [![HACS Action](https://github.com/steynovich/ha-saxo-portfolio/actions/workflows/hacs.yml/badge.svg)](https://github.com/steynovich/ha-saxo-portfolio/actions/workflows/hacs.yml) [![Hassfest](https://github.com/steynovich/ha-saxo-portfolio/actions/workflows/hassfest.yml/badge.svg)](https://github.com/steynovich/ha-saxo-portfolio/actions/workflows/hassfest.yml) -A **Platinum-grade** Home Assistant integration for monitoring your Saxo Bank portfolio through their OpenAPI. Features OAuth 2.0 authentication, intelligent update scheduling based on market hours, automatic entity naming based on your Saxo Client ID, and comprehensive portfolio monitoring with nine dedicated sensors and seven diagnostic entities. +A **Platinum-grade** Home Assistant integration for monitoring your Saxo Bank portfolio through their OpenAPI. Features OAuth 2.0 authentication, intelligent update scheduling based on market hours, automatic entity naming based on your Saxo Client ID, and comprehensive portfolio monitoring with eleven dedicated sensors and seven diagnostic entities. ## Features - 🔐 **Enterprise-Grade Security**: OAuth 2.0 with Home Assistant credential management, encrypted token storage, and comprehensive data masking -- 💰 **Nine Portfolio Sensors**: Real-time balance, performance metrics, and cash transfer tracking from multiple Saxo API endpoints +- 💰 **Eleven Portfolio Sensors**: Real-time balance, performance metrics, and cash transfer tracking from multiple Saxo API endpoints - 📊 **Seven Diagnostic Sensors**: Built-in monitoring for integration health, account identification, token expiry, and market status - ⚡ **Smart Performance Caching**: Performance data updates hourly while balance data remains real-time for optimal API usage - 📈 **Long-Term Statistics**: Performance sensors support Home Assistant statistics for historical tracking and trend analysis @@ -26,7 +26,7 @@ A **Platinum-grade** Home Assistant integration for monitoring your Saxo Bank po ## Supported Sensors -The integration provides **nine comprehensive sensors** that automatically use your Saxo Client ID for unique entity naming: +The integration provides **eleven comprehensive sensors** that automatically use your Saxo Client ID for unique entity naming: ### Balance & Portfolio Sensors - **Cash Balance**: Available cash in your Saxo portfolio (`sensor.saxo_{clientid}_cash_balance`) @@ -37,9 +37,11 @@ The integration provides **nine comprehensive sensors** that automatically use y - **Accumulated Profit/Loss**: All-time performance tracking from Saxo's historical API (`sensor.saxo_{clientid}_accumulated_profit_loss`) - **Investment Performance**: Overall portfolio return percentage (all-time) from performance timeseries (`sensor.saxo_{clientid}_investment_performance`) - **YTD Investment Performance**: Year-to-Date portfolio return percentage (`sensor.saxo_{clientid}_ytd_investment_performance`) -- **Month Investment Performance**: Month-to-Date portfolio return percentage (`sensor.saxo_{clientid}_month_investment_performance`) -- **Quarter Investment Performance**: Quarter-to-Date portfolio return percentage (`sensor.saxo_{clientid}_quarter_investment_performance`) +- **Month Investment Performance**: Rolling ~28-day portfolio return percentage (despite the name, not aligned to the calendar month — see Known Issues in CHANGELOG.md) (`sensor.saxo_{clientid}_month_investment_performance`) +- **Quarter Investment Performance**: Rolling ~90-day portfolio return percentage (despite the name, not aligned to the calendar quarter — see Known Issues in CHANGELOG.md) (`sensor.saxo_{clientid}_quarter_investment_performance`) - **Cash Transfer Balance**: Latest cash transfer value tracking deposits and withdrawals (`sensor.saxo_{clientid}_cash_transfer_balance`) +- **YTD Profit/Loss**: Year-to-Date profit/loss in your account currency (`sensor.saxo_{clientid}_ytd_profit_loss`) +- **YTD Net Transfers**: Year-to-Date net deposits and withdrawals (`sensor.saxo_{clientid}_ytd_cash_transfer`) **Long-Term Statistics**: All performance sensors support Home Assistant's long-term statistics system with `state_class: measurement`, enabling: - Historical data retention beyond the default 10-day recorder purge period @@ -121,7 +123,7 @@ The integration provides **nine comprehensive sensors** that automatically use y ## Entities Created -The integration automatically creates **sixteen entities** using your Saxo Client ID: +The integration automatically creates **eighteen entities** using your Saxo Client ID: ### Portfolio Sensors (Example: Client ID "123456") - `sensor.saxo_123456_cash_balance` - Available cash balance @@ -130,9 +132,11 @@ The integration automatically creates **sixteen entities** using your Saxo Clien - `sensor.saxo_123456_accumulated_profit_loss` - All-time profit/loss performance - `sensor.saxo_123456_investment_performance` - Overall portfolio return percentage (all-time) - `sensor.saxo_123456_ytd_investment_performance` - Year-to-Date portfolio return percentage -- `sensor.saxo_123456_month_investment_performance` - Month-to-Date portfolio return percentage -- `sensor.saxo_123456_quarter_investment_performance` - Quarter-to-Date portfolio return percentage +- `sensor.saxo_123456_month_investment_performance` - Rolling ~28-day portfolio return percentage (not calendar month-to-date; see Known Issues in CHANGELOG.md) +- `sensor.saxo_123456_quarter_investment_performance` - Rolling ~90-day portfolio return percentage (not calendar quarter-to-date; see Known Issues in CHANGELOG.md) - `sensor.saxo_123456_cash_transfer_balance` - Latest cash transfer balance +- `sensor.saxo_123456_ytd_profit_loss` - Year-to-Date profit/loss +- `sensor.saxo_123456_ytd_cash_transfer` - Year-to-Date net deposits and withdrawals ### Diagnostic Sensors (Example: Client ID "123456") - `sensor.saxo_123456_client_id` - Saxo Client ID identifier for troubleshooting diff --git a/custom_components/saxo_portfolio/api/saxo_client.py b/custom_components/saxo_portfolio/api/saxo_client.py index 92fa0d5..c0ae847 100644 --- a/custom_components/saxo_portfolio/api/saxo_client.py +++ b/custom_components/saxo_portfolio/api/saxo_client.py @@ -458,15 +458,25 @@ async def get_performance(self, client_key: str) -> dict[str, Any]: raise APIError("Failed to fetch performance data") async def get_performance_v4_batch( - self, client_key: str + self, + client_key: str, + *, + ytd_from: str, + ytd_to: str, ) -> dict[str, dict[str, Any]]: """Get all performance timeseries data from Saxo v4 performance API. - Fetches AllTime, Year, Month, and Quarter performance data with delays - between calls to prevent rate limiting. + Fetches AllTime, year-to-date, Month and Quarter performance data with + delays between calls to prevent rate limiting. + + Note that StandardPeriod=Year is a *trailing 12 month* window, not + year-to-date, so the YTD entry uses an explicit FromDate/ToDate range + anchored to 1 January. The API rejects FromDate without ToDate. Args: client_key: Client key for the request + ytd_from: Start of the year-to-date window, ISO date (YYYY-MM-DD) + ytd_to: End of the year-to-date window, ISO date (YYYY-MM-DD) Returns: Dictionary with keys: 'alltime', 'ytd', 'month', 'quarter' @@ -477,41 +487,64 @@ async def get_performance_v4_batch( APIError: For other API errors """ - periods = [ - ("AllTime", "alltime"), - ("Year", "ytd"), - ("Month", "month"), - ("Quarter", "quarter"), + specs: list[tuple[str, dict[str, str]]] = [ + ( + "alltime", + { + "ClientKey": client_key, + "StandardPeriod": "AllTime", + "FieldGroups": "Balance_CashTransfer,KeyFigures", + }, + ), + ( + "ytd", + { + "ClientKey": client_key, + "FromDate": ytd_from, + "ToDate": ytd_to, + "FieldGroups": ( + "Balance_CashTransfer,Balance_YearlyProfitLoss,KeyFigures" + ), + }, + ), + ( + "month", + { + "ClientKey": client_key, + "StandardPeriod": "Month", + "FieldGroups": "KeyFigures", + }, + ), + ( + "quarter", + { + "ClientKey": client_key, + "StandardPeriod": "Quarter", + "FieldGroups": "KeyFigures", + }, + ), ] results: dict[str, dict[str, Any]] = {} - for i, (standard_period, key) in enumerate(periods): + for i, (key, params) in enumerate(specs): try: - params = { - "ClientKey": client_key, - "StandardPeriod": standard_period, - "FieldGroups": "Balance_CashTransfer,KeyFigures", - } - response = await self._make_request(API_PERFORMANCE_V4_ENDPOINT, params) # Validate response structure if not isinstance(response, dict): - raise APIError( - f"Invalid performance v4 {standard_period} response format" - ) + raise APIError(f"Invalid performance v4 {key} response format") _LOGGER.debug( "Performance v4 %s API response structure: %s", - standard_period, + key, list(response.keys()) if response else "empty", ) results[key] = response # Add delay between calls (except after last one) to prevent rate limiting - if i < len(periods) - 1: + if i < len(specs) - 1: await asyncio.sleep(0.5) except AuthenticationError, RateLimitError: @@ -519,179 +552,13 @@ async def get_performance_v4_batch( except Exception as e: _LOGGER.error( "Error fetching performance v4 %s data: %s", - standard_period, + key, type(e).__name__, ) - raise APIError(f"Failed to fetch performance v4 {standard_period} data") + raise APIError(f"Failed to fetch performance v4 {key} data") return results - async def get_performance_v4(self, client_key: str) -> dict[str, Any]: - """Get performance timeseries data from Saxo v4 performance API. - - Args: - client_key: Client key for the request - - Returns: - Performance timeseries data containing ReturnFraction and CashTransfer - - Raises: - AuthenticationError: For authentication failures - APIError: For other API errors - - """ - try: - params = { - "ClientKey": client_key, - "StandardPeriod": "AllTime", - "FieldGroups": "Balance_CashTransfer,KeyFigures", - } - - response = await self._make_request(API_PERFORMANCE_V4_ENDPOINT, params) - - # Validate response structure - if not isinstance(response, dict): - raise APIError("Invalid performance v4 response format") - - _LOGGER.debug( - "Performance v4 API response structure: %s", - list(response.keys()) if response else "empty", - ) - - return response - - except AuthenticationError, RateLimitError: - raise - except Exception as e: - _LOGGER.error("Error fetching performance v4 data: %s", type(e).__name__) - raise APIError("Failed to fetch performance v4 data") - - async def get_performance_v4_ytd(self, client_key: str) -> dict[str, Any]: - """Fetch YTD performance timeseries data using v4 API. - - Args: - client_key: Client key for the request - - Returns: - YTD Performance timeseries data containing ReturnFraction and CashTransfer - - Raises: - AuthenticationError: For authentication failures - APIError: For other API errors - - """ - try: - params = { - "ClientKey": client_key, - "StandardPeriod": "Year", - "FieldGroups": "Balance_CashTransfer,KeyFigures", - } - - response = await self._make_request(API_PERFORMANCE_V4_ENDPOINT, params) - - # Validate response structure - if not isinstance(response, dict): - raise APIError("Invalid performance v4 YTD response format") - - _LOGGER.debug( - "Performance v4 YTD API response structure: %s", - list(response.keys()) if response else "empty", - ) - - return response - - except AuthenticationError, RateLimitError: - raise - except Exception as e: - _LOGGER.error( - "Error fetching performance v4 YTD data: %s", type(e).__name__ - ) - raise APIError("Failed to fetch performance v4 YTD data") - - async def get_performance_v4_month(self, client_key: str) -> dict[str, Any]: - """Fetch Month performance timeseries data using v4 API. - - Args: - client_key: Client key for the request - - Returns: - Month Performance timeseries data containing ReturnFraction and CashTransfer - - Raises: - AuthenticationError: For authentication failures - APIError: For other API errors - - """ - try: - params = { - "ClientKey": client_key, - "StandardPeriod": "Month", - "FieldGroups": "Balance_CashTransfer,KeyFigures", - } - - response = await self._make_request(API_PERFORMANCE_V4_ENDPOINT, params) - - # Validate response structure - if not isinstance(response, dict): - raise APIError("Invalid performance v4 Month response format") - - _LOGGER.debug( - "Performance v4 Month API response structure: %s", - list(response.keys()) if response else "empty", - ) - - return response - - except AuthenticationError, RateLimitError: - raise - except Exception as e: - _LOGGER.error( - "Error fetching performance v4 Month data: %s", type(e).__name__ - ) - raise APIError("Failed to fetch performance v4 Month data") - - async def get_performance_v4_quarter(self, client_key: str) -> dict[str, Any]: - """Fetch Quarter performance timeseries data using v4 API. - - Args: - client_key: Client key for the request - - Returns: - Quarter Performance timeseries data containing ReturnFraction and CashTransfer - - Raises: - AuthenticationError: For authentication failures - APIError: For other API errors - - """ - try: - params = { - "ClientKey": client_key, - "StandardPeriod": "Quarter", - "FieldGroups": "Balance_CashTransfer,KeyFigures", - } - - response = await self._make_request(API_PERFORMANCE_V4_ENDPOINT, params) - - # Validate response structure - if not isinstance(response, dict): - raise APIError("Invalid performance v4 Quarter response format") - - _LOGGER.debug( - "Performance v4 Quarter API response structure: %s", - list(response.keys()) if response else "empty", - ) - - return response - - except AuthenticationError, RateLimitError: - raise - except Exception as e: - _LOGGER.error( - "Error fetching performance v4 Quarter data: %s", type(e).__name__ - ) - raise APIError("Failed to fetch performance v4 Quarter data") - async def get_net_positions(self) -> dict[str, Any]: """Get net positions from Saxo API. diff --git a/custom_components/saxo_portfolio/coordinator.py b/custom_components/saxo_portfolio/coordinator.py index 6112866..5db8fcd 100644 --- a/custom_components/saxo_portfolio/coordinator.py +++ b/custom_components/saxo_portfolio/coordinator.py @@ -295,6 +295,8 @@ def _build_performance_defaults(self) -> dict[str, Any]: "quarter_investment_performance_percentage", 0.0 ), "cash_transfer_balance": cache.get("cash_transfer_balance", 0.0), + "ytd_profit_loss": cache.get("ytd_profit_loss"), + "ytd_cash_transfer": cache.get("ytd_cash_transfer"), "client_id": cache.get("client_id", "unknown"), "account_id": cache.get("account_id", "unknown"), "client_name": cache.get("client_name", "unknown"), @@ -379,16 +381,21 @@ async def _fetch_performance_metrics( # v4 batch — four periods in one call try: await asyncio.sleep(0.5) - v4_batch = await client.get_performance_v4_batch(client_key) + now = dt_util.now() + v4_batch = await client.get_performance_v4_batch( + client_key, + ytd_from=f"{now.year:04d}-01-01", + ytd_to=now.date().isoformat(), + ) result.update(self._extract_v4_batch_metrics(v4_batch)) _LOGGER.debug( "Retrieved batched performance v4 data - AllTime: %s%%, YTD: %s%%, " - "Month: %s%%, Quarter: %s%%, CashTransfer: %s", + "Month: %s%%, Quarter: %s%%, YTD currency metrics present: %s", result["investment_performance_percentage"], result["ytd_investment_performance_percentage"], result["month_investment_performance_percentage"], result["quarter_investment_performance_percentage"], - result["cash_transfer_balance"], + result.get("ytd_profit_loss") is not None, ) except Exception as perf_v4_e: _LOGGER.debug( @@ -396,11 +403,36 @@ async def _fetch_performance_metrics( type(perf_v4_e).__name__, ) + @staticmethod + def _current_year_bucket(series: list[dict[str, Any]]) -> float | None: + """Value of the calendar-year bucket matching the current year. + + ``YearlyProfitLoss`` returns one bucket per calendar year. Match on the + year rather than assuming a single-element list, so a response spanning + a year boundary cannot select the wrong bucket. + """ + current_year = str(dt_util.now().year) + for point in series: + if str(point.get("Date", "")).startswith(current_year): + value = point.get("Value") + if isinstance(value, int | float): + return float(value) + return None + + @staticmethod + def _last_series_value(series: list[dict[str, Any]]) -> float | None: + """Last numeric value of a TimeValuePair series, or None.""" + for point in reversed(series): + value = point.get("Value") + if isinstance(value, int | float): + return float(value) + return None + @staticmethod def _extract_v4_batch_metrics( v4_batch: dict[str, dict[str, Any]], ) -> dict[str, Any]: - """Parse the four-period v4 performance batch into flat metrics.""" + """Parse the v4 performance batch into flat metrics.""" metrics: dict[str, Any] = {} alltime = v4_batch.get("alltime", {}) @@ -423,6 +455,17 @@ def _extract_v4_batch_metrics( ) metrics[result_key] = period_return * 100.0 + # Currency-denominated YTD metrics, from the Jan-1 anchored window. + # These default to None rather than 0.0: on a money sensor a zero reads + # as "you earned nothing this year" rather than "no data". + ytd_balance = v4_batch.get("ytd", {}).get("Balance", {}) + metrics["ytd_profit_loss"] = SaxoCoordinator._current_year_bucket( + ytd_balance.get("YearlyProfitLoss", []) + ) + metrics["ytd_cash_transfer"] = SaxoCoordinator._last_series_value( + ytd_balance.get("CashTransfer", []) + ) + return metrics async def _fetch_positions_data_safely( @@ -1208,6 +1251,30 @@ def get_cash_transfer_balance(self) -> float: return 0.0 return float(self.data.get("cash_transfer_balance", 0.0)) + def get_ytd_profit_loss(self) -> float | None: + """Get year-to-date profit/loss in the account's base currency. + + Returns: + YTD profit/loss, or None when unavailable + + """ + if not self.data: + return None + value = self.data.get("ytd_profit_loss") + return float(value) if isinstance(value, int | float) else None + + def get_ytd_cash_transfer(self) -> float | None: + """Get year-to-date net deposits/withdrawals. + + Returns: + YTD net cash transferred, or None when unavailable + + """ + if not self.data: + return None + value = self.data.get("ytd_cash_transfer") + return float(value) if isinstance(value, int | float) else None + def get_ytd_investment_performance_percentage(self) -> float: """Get YTD investment performance percentage from v4 performance API. diff --git a/custom_components/saxo_portfolio/icons.json b/custom_components/saxo_portfolio/icons.json index 7a7a7b9..b64d4c7 100644 --- a/custom_components/saxo_portfolio/icons.json +++ b/custom_components/saxo_portfolio/icons.json @@ -33,6 +33,12 @@ "cash_transfer_balance": { "default": "mdi:bank-transfer" }, + "ytd_profit_loss": { + "default": "mdi:trending-up" + }, + "ytd_cash_transfer": { + "default": "mdi:bank-transfer" + }, "client_id": { "default": "mdi:identifier" }, diff --git a/custom_components/saxo_portfolio/sensor.py b/custom_components/saxo_portfolio/sensor.py index b8ff0b2..0ba0c73 100644 --- a/custom_components/saxo_portfolio/sensor.py +++ b/custom_components/saxo_portfolio/sensor.py @@ -16,6 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import ( ATTRIBUTION, @@ -151,8 +152,6 @@ def available(self) -> bool: # but hasn't recorded a successful update time yet return True - from homeassistant.util import dt as dt_util - # Calculate how long it's been since last successful update # Ensure both timestamps are timezone-aware for comparison current_time = dt_util.utcnow() @@ -318,6 +317,8 @@ async def async_setup_entry( SaxoYTDInvestmentPerformanceSensor(coordinator), SaxoMonthInvestmentPerformanceSensor(coordinator), SaxoQuarterInvestmentPerformanceSensor(coordinator), + SaxoYTDProfitLossSensor(coordinator), + SaxoYTDCashTransferSensor(coordinator), # Diagnostic sensors SaxoClientIDSensor(coordinator), SaxoAccountIDSensor(coordinator), @@ -576,7 +577,7 @@ def _get_period_dates(self) -> dict[str, str] | None: """ time_period = self._get_time_period() - now = datetime.now() + now = dt_util.now() if time_period == "Year": # Year-to-date: January 1st to today @@ -643,6 +644,79 @@ def available(self) -> bool: return "cash_transfer_balance" in (self.coordinator.data or {}) +class SaxoYTDProfitLossSensor(SaxoSensorBase): + """Representation of a Saxo Portfolio YTD Profit/Loss sensor.""" + + def __init__(self, coordinator: SaxoCoordinator) -> None: + """Initialize the sensor.""" + super().__init__( + coordinator, + "ytd_profit_loss", + unit_of_measurement=coordinator.get_currency(), + ) + self._attr_state_class = "measurement" + self._attr_suggested_display_precision = 2 + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + if not self.coordinator.data: + return None + return self.coordinator.get_ytd_profit_loss() + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return extra attributes for the sensor.""" + attributes = super().extra_state_attributes + + if self.coordinator.data: + attributes["currency"] = self.coordinator.get_currency() + + return attributes + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if not super().available: + return False + + return self.coordinator.get_ytd_profit_loss() is not None + + +class SaxoYTDCashTransferSensor(SaxoBalanceSensorBase): + """Representation of a Saxo Portfolio YTD Net Transfers sensor.""" + + def __init__(self, coordinator: SaxoCoordinator) -> None: + """Initialize the sensor.""" + super().__init__( + coordinator, + "ytd_cash_transfer", + "get_ytd_cash_transfer", + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if not super().available: + return False + + return self.coordinator.get_ytd_cash_transfer() is not None + + @property + def last_reset(self) -> datetime: + """Return the start of the current year. + + Unlike its all-time sibling, this metric's source window (the + Jan-1 anchored FromDate/ToDate range) resets to zero every 1 + January. Recorder only zeroes its long-term-statistics reference + point when this attribute *changes*, so it must be recomputed on + every access (not cached at __init__ time) to re-anchor at the + year boundary without requiring a restart. + """ + now = dt_util.now() + return dt_util.start_of_local_day(date(now.year, 1, 1)) + + class SaxoYTDInvestmentPerformanceSensor(SaxoPerformanceSensorBase): """Representation of a Saxo Portfolio YTD Investment Performance sensor.""" diff --git a/custom_components/saxo_portfolio/strings.json b/custom_components/saxo_portfolio/strings.json index 946c7d2..9a733c0 100644 --- a/custom_components/saxo_portfolio/strings.json +++ b/custom_components/saxo_portfolio/strings.json @@ -83,6 +83,12 @@ "ytd_investment_performance": { "name": "YTD Investment Performance" }, + "ytd_profit_loss": { + "name": "YTD Profit/Loss" + }, + "ytd_cash_transfer": { + "name": "YTD Net Transfers" + }, "month_investment_performance": { "name": "Month Investment Performance" }, diff --git a/custom_components/saxo_portfolio/translations/da.json b/custom_components/saxo_portfolio/translations/da.json index 451766a..3bf3229 100644 --- a/custom_components/saxo_portfolio/translations/da.json +++ b/custom_components/saxo_portfolio/translations/da.json @@ -79,6 +79,12 @@ "ytd_investment_performance": { "name": "YTD Investment Performance" }, + "ytd_profit_loss": { + "name": "YTD Profit/Loss" + }, + "ytd_cash_transfer": { + "name": "YTD Net Transfers" + }, "month_investment_performance": { "name": "Month Investment Performance" }, diff --git a/custom_components/saxo_portfolio/translations/de.json b/custom_components/saxo_portfolio/translations/de.json index 08e0f2c..b903b52 100644 --- a/custom_components/saxo_portfolio/translations/de.json +++ b/custom_components/saxo_portfolio/translations/de.json @@ -79,6 +79,12 @@ "ytd_investment_performance": { "name": "YTD Investment Performance" }, + "ytd_profit_loss": { + "name": "YTD Profit/Loss" + }, + "ytd_cash_transfer": { + "name": "YTD Net Transfers" + }, "month_investment_performance": { "name": "Month Investment Performance" }, diff --git a/custom_components/saxo_portfolio/translations/en.json b/custom_components/saxo_portfolio/translations/en.json index b66b6ee..9d75f50 100644 --- a/custom_components/saxo_portfolio/translations/en.json +++ b/custom_components/saxo_portfolio/translations/en.json @@ -83,6 +83,12 @@ "ytd_investment_performance": { "name": "YTD Investment Performance" }, + "ytd_profit_loss": { + "name": "YTD Profit/Loss" + }, + "ytd_cash_transfer": { + "name": "YTD Net Transfers" + }, "month_investment_performance": { "name": "Month Investment Performance" }, diff --git a/custom_components/saxo_portfolio/translations/es.json b/custom_components/saxo_portfolio/translations/es.json index e9ce763..cb4e27d 100644 --- a/custom_components/saxo_portfolio/translations/es.json +++ b/custom_components/saxo_portfolio/translations/es.json @@ -79,6 +79,12 @@ "ytd_investment_performance": { "name": "YTD Investment Performance" }, + "ytd_profit_loss": { + "name": "YTD Profit/Loss" + }, + "ytd_cash_transfer": { + "name": "YTD Net Transfers" + }, "month_investment_performance": { "name": "Month Investment Performance" }, diff --git a/custom_components/saxo_portfolio/translations/fi.json b/custom_components/saxo_portfolio/translations/fi.json index 5ec064c..87920c8 100644 --- a/custom_components/saxo_portfolio/translations/fi.json +++ b/custom_components/saxo_portfolio/translations/fi.json @@ -79,6 +79,12 @@ "ytd_investment_performance": { "name": "YTD Investment Performance" }, + "ytd_profit_loss": { + "name": "YTD Profit/Loss" + }, + "ytd_cash_transfer": { + "name": "YTD Net Transfers" + }, "month_investment_performance": { "name": "Month Investment Performance" }, diff --git a/custom_components/saxo_portfolio/translations/fr.json b/custom_components/saxo_portfolio/translations/fr.json index f8cae84..af3db94 100644 --- a/custom_components/saxo_portfolio/translations/fr.json +++ b/custom_components/saxo_portfolio/translations/fr.json @@ -79,6 +79,12 @@ "ytd_investment_performance": { "name": "YTD Investment Performance" }, + "ytd_profit_loss": { + "name": "YTD Profit/Loss" + }, + "ytd_cash_transfer": { + "name": "YTD Net Transfers" + }, "month_investment_performance": { "name": "Month Investment Performance" }, diff --git a/custom_components/saxo_portfolio/translations/it.json b/custom_components/saxo_portfolio/translations/it.json index b8d844e..5ace51e 100644 --- a/custom_components/saxo_portfolio/translations/it.json +++ b/custom_components/saxo_portfolio/translations/it.json @@ -79,6 +79,12 @@ "ytd_investment_performance": { "name": "YTD Investment Performance" }, + "ytd_profit_loss": { + "name": "YTD Profit/Loss" + }, + "ytd_cash_transfer": { + "name": "YTD Net Transfers" + }, "month_investment_performance": { "name": "Month Investment Performance" }, diff --git a/custom_components/saxo_portfolio/translations/nb.json b/custom_components/saxo_portfolio/translations/nb.json index 536de6b..98c372e 100644 --- a/custom_components/saxo_portfolio/translations/nb.json +++ b/custom_components/saxo_portfolio/translations/nb.json @@ -79,6 +79,12 @@ "ytd_investment_performance": { "name": "YTD Investment Performance" }, + "ytd_profit_loss": { + "name": "YTD Profit/Loss" + }, + "ytd_cash_transfer": { + "name": "YTD Net Transfers" + }, "month_investment_performance": { "name": "Month Investment Performance" }, diff --git a/custom_components/saxo_portfolio/translations/nl.json b/custom_components/saxo_portfolio/translations/nl.json index 012518e..54e7750 100644 --- a/custom_components/saxo_portfolio/translations/nl.json +++ b/custom_components/saxo_portfolio/translations/nl.json @@ -79,6 +79,12 @@ "ytd_investment_performance": { "name": "YTD Investment Performance" }, + "ytd_profit_loss": { + "name": "YTD Profit/Loss" + }, + "ytd_cash_transfer": { + "name": "YTD Net Transfers" + }, "month_investment_performance": { "name": "Month Investment Performance" }, diff --git a/custom_components/saxo_portfolio/translations/pt.json b/custom_components/saxo_portfolio/translations/pt.json index 0530097..c4adda8 100644 --- a/custom_components/saxo_portfolio/translations/pt.json +++ b/custom_components/saxo_portfolio/translations/pt.json @@ -79,6 +79,12 @@ "ytd_investment_performance": { "name": "YTD Investment Performance" }, + "ytd_profit_loss": { + "name": "YTD Profit/Loss" + }, + "ytd_cash_transfer": { + "name": "YTD Net Transfers" + }, "month_investment_performance": { "name": "Month Investment Performance" }, diff --git a/custom_components/saxo_portfolio/translations/sv.json b/custom_components/saxo_portfolio/translations/sv.json index e71dac3..ef10f4c 100644 --- a/custom_components/saxo_portfolio/translations/sv.json +++ b/custom_components/saxo_portfolio/translations/sv.json @@ -79,6 +79,12 @@ "ytd_investment_performance": { "name": "YTD Investment Performance" }, + "ytd_profit_loss": { + "name": "YTD Profit/Loss" + }, + "ytd_cash_transfer": { + "name": "YTD Net Transfers" + }, "month_investment_performance": { "name": "Month Investment Performance" }, diff --git a/docs/superpowers/plans/2026-08-04-ytd-sensors.md b/docs/superpowers/plans/2026-08-04-ytd-sensors.md new file mode 100644 index 0000000..9d44a98 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-ytd-sensors.md @@ -0,0 +1,1162 @@ +# YTD Sensors Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add two currency-denominated YTD sensors (profit/loss, net transfers) and repoint the existing YTD percentage sensor to a genuine January-1 window. + +**Architecture:** The Saxo v4 performance endpoint's `StandardPeriod=Year` is a trailing 12-month window, not year-to-date. A `FromDate`/`ToDate` pair anchored to January 1 replaces it in the existing four-call batch, so all three YTD values come from one response at no extra API cost. Sensors read the parsed values through coordinator getters. + +**Tech Stack:** Home Assistant custom integration, Python 3.14+, aiohttp via HA's shared websession, pytest, ruff, mypy (strict). + +## Global Constraints + +- Python 3.14+ only. The codebase uses PEP 758 unparenthesized `except A, B:` syntax — the `venv/` directory is a stale 3.13 environment and **cannot** run the tests. +- Run tests with `uv run --extra dev pytest`. Baseline before this plan: **634 passed**. +- Lint and type checks must stay clean: `uv run --extra dev ruff check custom_components/`, `uv run --extra dev ruff format custom_components/`, `uv run --extra dev mypy custom_components/` (strict, 11 source files). +- Sanitized logging — never log tokens, client IDs, or monetary amounts. New monetary values must not be added to log statements, not even at DEBUG. +- All entities use `_attr_has_entity_name = True` with `_attr_translation_key`; user-facing strings live in `strings.json` and the 11 files under `translations/`, icons in `icons.json`. +- Rate limiting: keep the 0.5s delay between batched API calls. +- Spec: `docs/superpowers/specs/2026-08-04-ytd-sensors-design.md` + +--- + +## File Structure + +| File | Responsibility | Change | +|---|---|---| +| `custom_components/saxo_portfolio/api/saxo_client.py` | HTTP calls to Saxo | Delete 4 dead helpers; batch takes `(key, params)` specs incl. Jan-1 window | +| `custom_components/saxo_portfolio/coordinator.py` | Fetch orchestration, parsing, getters | Parse 2 new metrics, repoint YTD %, drop `Year`, 2 new getters | +| `custom_components/saxo_portfolio/sensor.py` | Entity definitions | 2 new sensor classes + registration | +| `custom_components/saxo_portfolio/strings.json` + `translations/*.json` | Entity names | 2 new keys × 12 files | +| `custom_components/saxo_portfolio/icons.json` | Entity icons | 2 new keys | +| `tests/unit/test_saxo_client.py` | Client tests | Delete 21 dead tests; add batch spec tests | +| `tests/unit/test_coordinator.py` | Coordinator tests | Extraction + getter tests | +| `tests/unit/test_sensor_coverage.py` | Sensor tests | New sensor tests | +| `README.md`, `CHANGELOG.md` | Docs | Sensor list + repoint warning | + +--- + +### Task 1: Remove dead v4 client helpers + +`get_performance_v4`, `get_performance_v4_ytd`, `get_performance_v4_month` and +`get_performance_v4_quarter` are referenced only by their own tests — no production +code calls them. Removing them first means Task 2 refactors a clean file instead of +adding a fifth near-duplicate. + +**Files:** +- Modify: `custom_components/saxo_portfolio/api/saxo_client.py:529-694` +- Modify: `tests/unit/test_saxo_client.py:1261-1575` (delete), `:12-14` (docstring) + +**Interfaces:** +- Consumes: nothing +- Produces: `saxo_client.py` containing exactly one v4 method, `get_performance_v4_batch(client_key: str) -> dict[str, dict[str, Any]]` (signature unchanged in this task) + +- [ ] **Step 1: Confirm the four methods are unreferenced** + +```bash +grep -rn "get_performance_v4_ytd\|get_performance_v4_month\|get_performance_v4_quarter\|get_performance_v4(" \ + custom_components/ | grep -v "def get_performance_v4" +``` + +Expected: no output. If anything prints, STOP — a caller exists and this task's premise is wrong. + +- [ ] **Step 2: Delete the four methods** + +In `custom_components/saxo_portfolio/api/saxo_client.py`, delete from the line +` async def get_performance_v4(self, client_key: str) -> dict[str, Any]:` +(line 529) through the blank line immediately before +` async def get_net_positions(self) -> dict[str, Any]:` (line 695). + +The result must read: + +```python + return results + + async def get_net_positions(self) -> dict[str, Any]: +``` + +- [ ] **Step 3: Delete their tests** + +In `tests/unit/test_saxo_client.py`, delete from the separator comment block +preceding `# Endpoint: get_performance_v4` (line 1261) through the last line of +`TestGetPerformanceV4Quarter` (line 1575). The file must go straight from the end of +`TestGetPerformanceV4Batch` into: + +```python +# --------------------------------------------------------------------------- +# Endpoint: get_net_positions +# --------------------------------------------------------------------------- +``` + +- [ ] **Step 4: Update the module docstring** + +Replace lines 12-14 of `tests/unit/test_saxo_client.py`: + +```python +- All endpoint methods: get_account_balance, get_client_details, get_performance, + get_performance_v4_batch, get_net_positions +``` + +- [ ] **Step 5: Run the suite** + +Run: `uv run --extra dev pytest -q` +Expected: `613 passed` (634 minus the 21 deleted tests). Any failure means a +still-live reference was removed. + +- [ ] **Step 6: Lint, format, type-check** + +```bash +uv run --extra dev ruff format custom_components/ tests/ +uv run --extra dev ruff check custom_components/ tests/ +uv run --extra dev mypy custom_components/ +``` + +Expected: all clean. + +- [ ] **Step 7: Commit** + +```bash +git add custom_components/saxo_portfolio/api/saxo_client.py tests/unit/test_saxo_client.py +git commit -m "refactor: Drop unused single-period v4 performance helpers + +get_performance_v4, _ytd, _month and _quarter were referenced only by +their own tests; get_performance_v4_batch is the sole production caller +of the v4 endpoint." +``` + +--- + +### Task 2: Batch fetches a January-1 anchored window + +Replace the StandardPeriod-only loop with `(key, params)` specs so one entry can be +date-ranged. The trailing `Year` call is dropped — Task 4 repoints its only consumer. +The caller supplies the dates so the client stays clock-free and trivially testable. + +Making the new arguments required breaks that sole caller, so this task also applies +Task 4's Step 4 call-site edit; otherwise the branch carries a commit where mypy fails. + +**Files:** +- Modify: `custom_components/saxo_portfolio/api/saxo_client.py:460-527` +- Test: `tests/unit/test_saxo_client.py` (class `TestGetPerformanceV4Batch`) + +**Interfaces:** +- Consumes: `get_performance_v4_batch` from Task 1 +- Produces: `get_performance_v4_batch(client_key: str, *, ytd_from: str, ytd_to: str) -> dict[str, dict[str, Any]]`, returning keys `alltime`, `ytd`, `month`, `quarter`. `ytd_from`/`ytd_to` are ISO dates (`YYYY-MM-DD`). Task 4 calls this. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/test_saxo_client.py` inside `class TestGetPerformanceV4Batch`: + +```python + @pytest.mark.asyncio + async def test_ytd_spec_uses_date_range(self): + """YTD entry must use FromDate/ToDate, not StandardPeriod.""" + captured: list[dict] = [] + + async def mock_make_request(endpoint, params=None): + captured.append(dict(params or {})) + return {"KeyFigures": {}} + + client = _make_client(session=MagicMock()) + with ( + patch.object(client, "_make_request", side_effect=mock_make_request), + patch( + "custom_components.saxo_portfolio.api.saxo_client.asyncio.sleep", + new_callable=AsyncMock, + ), + ): + await client.get_performance_v4_batch( + "ck", ytd_from="2026-01-01", ytd_to="2026-08-04" + ) + + assert len(captured) == 4 + ytd_params = captured[1] + assert ytd_params["FromDate"] == "2026-01-01" + assert ytd_params["ToDate"] == "2026-08-04" + assert "StandardPeriod" not in ytd_params + assert "Balance_YearlyProfitLoss" in ytd_params["FieldGroups"] + assert "Balance_CashTransfer" in ytd_params["FieldGroups"] + + @pytest.mark.asyncio + async def test_trailing_year_period_not_requested(self): + """StandardPeriod=Year is a trailing 12m window and must not be fetched.""" + captured: list[dict] = [] + + async def mock_make_request(endpoint, params=None): + captured.append(dict(params or {})) + return {"KeyFigures": {}} + + client = _make_client(session=MagicMock()) + with ( + patch.object(client, "_make_request", side_effect=mock_make_request), + patch( + "custom_components.saxo_portfolio.api.saxo_client.asyncio.sleep", + new_callable=AsyncMock, + ), + ): + await client.get_performance_v4_batch( + "ck", ytd_from="2026-01-01", ytd_to="2026-08-04" + ) + + periods = [p.get("StandardPeriod") for p in captured] + assert "Year" not in periods + assert periods == ["AllTime", None, "Month", "Quarter"] + + @pytest.mark.asyncio + async def test_month_quarter_request_keyfigures_only(self): + """Month/Quarter Balance data has no reader; don't fetch it.""" + captured: list[dict] = [] + + async def mock_make_request(endpoint, params=None): + captured.append(dict(params or {})) + return {"KeyFigures": {}} + + client = _make_client(session=MagicMock()) + with ( + patch.object(client, "_make_request", side_effect=mock_make_request), + patch( + "custom_components.saxo_portfolio.api.saxo_client.asyncio.sleep", + new_callable=AsyncMock, + ), + ): + await client.get_performance_v4_batch( + "ck", ytd_from="2026-01-01", ytd_to="2026-08-04" + ) + + assert captured[2]["FieldGroups"] == "KeyFigures" + assert captured[3]["FieldGroups"] == "KeyFigures" +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --extra dev pytest tests/unit/test_saxo_client.py::TestGetPerformanceV4Batch -q` +Expected: FAIL — `TypeError: get_performance_v4_batch() got an unexpected keyword argument 'ytd_from'` + +- [ ] **Step 3: Replace the method body** + +Replace `get_performance_v4_batch` in `custom_components/saxo_portfolio/api/saxo_client.py` (lines 460-527) with: + +```python + async def get_performance_v4_batch( + self, + client_key: str, + *, + ytd_from: str, + ytd_to: str, + ) -> dict[str, dict[str, Any]]: + """Get all performance timeseries data from Saxo v4 performance API. + + Fetches AllTime, year-to-date, Month and Quarter performance data with + delays between calls to prevent rate limiting. + + Note that StandardPeriod=Year is a *trailing 12 month* window, not + year-to-date, so the YTD entry uses an explicit FromDate/ToDate range + anchored to 1 January. The API rejects FromDate without ToDate. + + Args: + client_key: Client key for the request + ytd_from: Start of the year-to-date window, ISO date (YYYY-MM-DD) + ytd_to: End of the year-to-date window, ISO date (YYYY-MM-DD) + + Returns: + Dictionary with keys: 'alltime', 'ytd', 'month', 'quarter' + Each containing performance timeseries data + + Raises: + AuthenticationError: For authentication failures + APIError: For other API errors + + """ + specs: list[tuple[str, dict[str, str]]] = [ + ( + "alltime", + { + "ClientKey": client_key, + "StandardPeriod": "AllTime", + "FieldGroups": "Balance_CashTransfer,KeyFigures", + }, + ), + ( + "ytd", + { + "ClientKey": client_key, + "FromDate": ytd_from, + "ToDate": ytd_to, + "FieldGroups": ( + "Balance_CashTransfer,Balance_YearlyProfitLoss,KeyFigures" + ), + }, + ), + ( + "month", + { + "ClientKey": client_key, + "StandardPeriod": "Month", + "FieldGroups": "KeyFigures", + }, + ), + ( + "quarter", + { + "ClientKey": client_key, + "StandardPeriod": "Quarter", + "FieldGroups": "KeyFigures", + }, + ), + ] + + results: dict[str, dict[str, Any]] = {} + + for i, (key, params) in enumerate(specs): + try: + response = await self._make_request(API_PERFORMANCE_V4_ENDPOINT, params) + + # Validate response structure + if not isinstance(response, dict): + raise APIError(f"Invalid performance v4 {key} response format") + + _LOGGER.debug( + "Performance v4 %s API response structure: %s", + key, + list(response.keys()) if response else "empty", + ) + + results[key] = response + + # Add delay between calls (except after last one) to prevent rate limiting + if i < len(specs) - 1: + await asyncio.sleep(0.5) + + except AuthenticationError, RateLimitError: + raise + except Exception as e: + _LOGGER.error( + "Error fetching performance v4 %s data: %s", + key, + type(e).__name__, + ) + raise APIError(f"Failed to fetch performance v4 {key} data") + + return results +``` + +Note the unparenthesized `except AuthenticationError, RateLimitError:` — that is +PEP 758 syntax and matches the rest of this file. Do not "fix" it. + +- [ ] **Step 4: Run the batch tests** + +Run: `uv run --extra dev pytest tests/unit/test_saxo_client.py::TestGetPerformanceV4Batch -q` +Expected: the three new tests PASS. Pre-existing tests in this class that call +`get_performance_v4_batch("ck")` without the new keyword arguments will now FAIL. + +- [ ] **Step 5: Update the pre-existing batch tests** + +Every call to `client.get_performance_v4_batch("ck")` or `("ck_123")` in that class +needs the new keyword arguments. Find them: + +```bash +grep -n 'get_performance_v4_batch(' tests/unit/test_saxo_client.py +``` + +Change each call site to pass the dates, e.g.: + +```python + result = await client.get_performance_v4_batch( + "ck_123", ytd_from="2026-01-01", ytd_to="2026-08-04" + ) +``` + +`test_success_all_periods` asserts on the four returned keys — those key names are +unchanged (`alltime`, `ytd`, `month`, `quarter`), so only the call signature moves. +`test_delays_between_calls` still expects `mock_sleep.call_count == 3`; the batch is +still four calls. + +- [ ] **Step 6: Run the full suite** + +Run: `uv run --extra dev pytest -q` +Expected: `616 passed` (613 + 3 new). The coordinator tests still pass because +`tests/unit/test_coordinator.py` mocks `get_performance_v4_batch` with `AsyncMock`, +which accepts any signature. + +- [ ] **Step 7: Lint, format, type-check** + +```bash +uv run --extra dev ruff format custom_components/ tests/ +uv run --extra dev ruff check custom_components/ tests/ +uv run --extra dev mypy custom_components/ +``` + +Expected: all clean. + +- [ ] **Step 8: Commit** + +```bash +git add custom_components/saxo_portfolio/api/saxo_client.py tests/unit/test_saxo_client.py +git commit -m "feat: Fetch a January-1 anchored window in the v4 batch + +StandardPeriod=Year is a trailing 12-month window, not year-to-date. +Replace it with an explicit FromDate/ToDate range (the API rejects +FromDate alone) and trim Month/Quarter to KeyFigures, which is all +anything reads. Still four requests per refresh." +``` + +--- + +### Task 3: Parse the new YTD metrics + +Pure-function work on `_extract_v4_batch_metrics`, a `@staticmethod` — the easiest +place to pin the parsing rules down with tests. + +**Files:** +- Modify: `custom_components/saxo_portfolio/coordinator.py:399-426` +- Test: `tests/unit/test_coordinator.py` (class `TestExtractV4BatchMetrics`) + +**Interfaces:** +- Consumes: the `ytd` response shape produced by Task 2 +- Produces: `_extract_v4_batch_metrics()` additionally returns `ytd_profit_loss: float | None` and `ytd_cash_transfer: float | None`; `ytd_investment_performance_percentage` now derives from the Jan-1 window. Two new private statics: `_current_year_bucket(series) -> float | None`, `_last_series_value(series) -> float | None`. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/test_coordinator.py` inside `class TestExtractV4BatchMetrics`: + +```python + def test_ytd_profit_loss_and_transfers(self): + """YTD currency metrics come from the Jan-1 anchored response.""" + v4_batch = { + "alltime": { + "KeyFigures": {"ReturnFraction": 0.32}, + "Balance": {"CashTransfer": [{"Value": 500}, {"Value": 1000}]}, + }, + "ytd": { + "KeyFigures": {"ReturnFraction": 0.09}, + "Balance": { + "YearlyProfitLoss": [ + {"Date": "2026-12-31", "Value": 1234.56}, + ], + "CashTransfer": [ + {"Date": "2026-01-02", "Value": 0}, + {"Date": "2026-04-01", "Value": 250.0}, + ], + }, + }, + "month": {"KeyFigures": {"ReturnFraction": 0.02}}, + "quarter": {"KeyFigures": {"ReturnFraction": 0.03}}, + } + with patch( + "custom_components.saxo_portfolio.coordinator.dt_util.now" + ) as mock_now: + mock_now.return_value = datetime(2026, 8, 4, 12, 0) + metrics = SaxoCoordinator._extract_v4_batch_metrics(v4_batch) + + assert metrics["ytd_profit_loss"] == pytest.approx(1234.56) + assert metrics["ytd_cash_transfer"] == pytest.approx(250.0) + assert metrics["ytd_investment_performance_percentage"] == pytest.approx(9.0) + + def test_ytd_profit_loss_picks_current_year_bucket(self): + """A multi-year bucket list must select the current calendar year.""" + v4_batch = { + "ytd": { + "Balance": { + "YearlyProfitLoss": [ + {"Date": "2025-12-31", "Value": 999.0}, + {"Date": "2026-12-31", "Value": 111.0}, + ] + } + } + } + with patch( + "custom_components.saxo_portfolio.coordinator.dt_util.now" + ) as mock_now: + mock_now.return_value = datetime(2026, 8, 4, 12, 0) + metrics = SaxoCoordinator._extract_v4_batch_metrics(v4_batch) + + assert metrics["ytd_profit_loss"] == pytest.approx(111.0) + + def test_ytd_metrics_none_when_absent(self): + """Missing YTD balance data yields None, not 0.0.""" + v4_batch = {"ytd": {"KeyFigures": {"ReturnFraction": 0.09}}} + metrics = SaxoCoordinator._extract_v4_batch_metrics(v4_batch) + + assert metrics["ytd_profit_loss"] is None + assert metrics["ytd_cash_transfer"] is None + + def test_ytd_profit_loss_none_when_no_matching_year(self): + """A bucket list without the current year yields None.""" + v4_batch = { + "ytd": {"Balance": {"YearlyProfitLoss": [{"Date": "2024-12-31", "Value": 5.0}]}} + } + with patch( + "custom_components.saxo_portfolio.coordinator.dt_util.now" + ) as mock_now: + mock_now.return_value = datetime(2026, 8, 4, 12, 0) + metrics = SaxoCoordinator._extract_v4_batch_metrics(v4_batch) + + assert metrics["ytd_profit_loss"] is None + + def test_ytd_cash_transfer_skips_non_numeric(self): + """Non-numeric trailing entries are skipped, not returned.""" + v4_batch = { + "ytd": { + "Balance": { + "CashTransfer": [ + {"Date": "2026-01-02", "Value": 100.0}, + {"Date": "2026-04-01", "Value": None}, + ] + } + } + } + metrics = SaxoCoordinator._extract_v4_batch_metrics(v4_batch) + + assert metrics["ytd_cash_transfer"] == pytest.approx(100.0) +``` + +`datetime` and `patch` are already imported at the top of this test file — no import +changes needed. + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --extra dev pytest tests/unit/test_coordinator.py::TestExtractV4BatchMetrics -q` +Expected: FAIL with `KeyError: 'ytd_profit_loss'` + +- [ ] **Step 3: Implement the parsing** + +In `custom_components/saxo_portfolio/coordinator.py`, replace `_extract_v4_batch_metrics` (lines 399-426) with: + +```python + @staticmethod + def _current_year_bucket(series: list[dict[str, Any]]) -> float | None: + """Value of the calendar-year bucket matching the current year. + + ``YearlyProfitLoss`` returns one bucket per calendar year. Match on the + year rather than assuming a single-element list, so a response spanning + a year boundary cannot select the wrong bucket. + """ + current_year = str(dt_util.now().year) + for point in series: + if str(point.get("Date", "")).startswith(current_year): + value = point.get("Value") + if isinstance(value, int | float): + return float(value) + return None + + @staticmethod + def _last_series_value(series: list[dict[str, Any]]) -> float | None: + """Last numeric value of a TimeValuePair series, or None.""" + for point in reversed(series): + value = point.get("Value") + if isinstance(value, int | float): + return float(value) + return None + + @staticmethod + def _extract_v4_batch_metrics( + v4_batch: dict[str, dict[str, Any]], + ) -> dict[str, Any]: + """Parse the v4 performance batch into flat metrics.""" + metrics: dict[str, Any] = {} + + alltime = v4_batch.get("alltime", {}) + alltime_return = alltime.get("KeyFigures", {}).get("ReturnFraction", 0.0) + metrics["investment_performance_percentage"] = alltime_return * 100.0 + + cash_transfer_list = alltime.get("Balance", {}).get("CashTransfer", []) + if cash_transfer_list: + metrics["cash_transfer_balance"] = cash_transfer_list[-1].get("Value", 0.0) + + for period_key, result_key in ( + ("ytd", "ytd_investment_performance_percentage"), + ("month", "month_investment_performance_percentage"), + ("quarter", "quarter_investment_performance_percentage"), + ): + period_return = ( + v4_batch.get(period_key, {}) + .get("KeyFigures", {}) + .get("ReturnFraction", 0.0) + ) + metrics[result_key] = period_return * 100.0 + + # Currency-denominated YTD metrics, from the Jan-1 anchored window. + # These default to None rather than 0.0: on a money sensor a zero reads + # as "you earned nothing this year" rather than "no data". + ytd_balance = v4_batch.get("ytd", {}).get("Balance", {}) + metrics["ytd_profit_loss"] = SaxoCoordinator._current_year_bucket( + ytd_balance.get("YearlyProfitLoss", []) + ) + metrics["ytd_cash_transfer"] = SaxoCoordinator._last_series_value( + ytd_balance.get("CashTransfer", []) + ) + + return metrics +``` + +- [ ] **Step 4: Run the tests** + +Run: `uv run --extra dev pytest tests/unit/test_coordinator.py::TestExtractV4BatchMetrics -q` +Expected: PASS, including the pre-existing `test_full_response` and +`test_empty_response` (they assert on keys this change does not remove). + +- [ ] **Step 5: Run the full suite** + +Run: `uv run --extra dev pytest -q` +Expected: `621 passed` (616 + 5 new). + +- [ ] **Step 6: Lint, format, type-check** + +```bash +uv run --extra dev ruff format custom_components/ tests/ +uv run --extra dev ruff check custom_components/ tests/ +uv run --extra dev mypy custom_components/ +``` + +Expected: all clean. + +- [ ] **Step 7: Commit** + +```bash +git add custom_components/saxo_portfolio/coordinator.py tests/unit/test_coordinator.py +git commit -m "feat: Parse YTD profit/loss and net transfers from the Jan-1 window + +Adds ytd_profit_loss (current calendar-year YearlyProfitLoss bucket) and +ytd_cash_transfer (last CashTransfer value, cumulative within the window). +Both default to None rather than 0.0 so missing data cannot render as a +plausible-looking zero on a currency sensor." +``` + +--- + +### Task 4: Wire the coordinator to the new window + +Supply the dates, carry the new keys through defaults and cache, and expose getters. + +**Files:** +- Modify: `custom_components/saxo_portfolio/coordinator.py:280-301` (defaults), `:379-397` (call site), `:1200-1220` (getters) +- Test: `tests/unit/test_coordinator.py` + +**Interfaces:** +- Consumes: `get_performance_v4_batch(client_key, *, ytd_from, ytd_to)` from Task 2; the metric keys from Task 3 +- Produces: `SaxoCoordinator.get_ytd_profit_loss() -> float | None` and `SaxoCoordinator.get_ytd_cash_transfer() -> float | None`. Task 5 calls both. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/test_coordinator.py` (top level, alongside the other coordinator test classes): + +```python +class TestYtdGetters: + """Tests for the YTD currency getters.""" + + def test_get_ytd_profit_loss(self): + """YTD profit/loss is returned from data.""" + coord = _bare_coordinator() + coord.data = {"ytd_profit_loss": 1234.56} + assert coord.get_ytd_profit_loss() == pytest.approx(1234.56) + + def test_get_ytd_profit_loss_none_when_missing(self): + """Missing key returns None, not 0.0.""" + coord = _bare_coordinator() + coord.data = {} + assert coord.get_ytd_profit_loss() is None + + def test_get_ytd_profit_loss_none_without_data(self): + """No data returns None.""" + coord = _bare_coordinator() + coord.data = None + assert coord.get_ytd_profit_loss() is None + + def test_get_ytd_cash_transfer(self): + """YTD net transfers is returned from data.""" + coord = _bare_coordinator() + coord.data = {"ytd_cash_transfer": 250.0} + assert coord.get_ytd_cash_transfer() == pytest.approx(250.0) + + def test_get_ytd_cash_transfer_none_when_missing(self): + """Missing key returns None, not 0.0.""" + coord = _bare_coordinator() + coord.data = {} + assert coord.get_ytd_cash_transfer() is None +``` + +And a test that the batch is called with a January-1 anchor — add it to the existing +`class TestFetchPerformanceMetrics` (line ~530), following that class's style: a real +coordinator from `_bare_coordinator()` and an `AsyncMock()` client. + +```python + async def test_batch_called_with_january_first_anchor(self): + """The YTD window must start on 1 January of the current year.""" + coord = _bare_coordinator() + client = AsyncMock() + client.get_performance = AsyncMock(return_value={}) + client.get_performance_v4_batch = AsyncMock( + return_value={"alltime": {}, "ytd": {}, "month": {}, "quarter": {}} + ) + result: dict = {} + + with patch( + "custom_components.saxo_portfolio.coordinator.dt_util.now" + ) as mock_now: + mock_now.return_value = datetime(2026, 8, 4, 12, 0) + await coord._fetch_performance_metrics(client, "ck1", result) + + kwargs = client.get_performance_v4_batch.call_args.kwargs + assert kwargs["ytd_from"] == "2026-01-01" + assert kwargs["ytd_to"] == "2026-08-04" +``` + +Patching `dt_util.now` here also affects `_current_year_bucket`, which is what the +2026 dates in the mocked response rely on. `datetime`, `patch` and `AsyncMock` are +already imported in this file. + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --extra dev pytest tests/unit/test_coordinator.py::TestYtdGetters -q` +Expected: FAIL with `AttributeError: does not have the attribute 'get_ytd_profit_loss'` + +- [ ] **Step 3: Add the new keys to the defaults** + +In `_build_performance_defaults` (line 280), add two entries to the returned dict, +after `"cash_transfer_balance"`: + +```python + "ytd_profit_loss": cache.get("ytd_profit_loss"), + "ytd_cash_transfer": cache.get("ytd_cash_transfer"), +``` + +`cache.get()` without a default returns `None`, which is intentional — unlike the +surrounding metrics these must not fall back to `0.0`. + +- [ ] **Step 4: Pass the dates at the call site** + +In `_fetch_performance_metrics` (around line 380), replace: + +```python + v4_batch = await client.get_performance_v4_batch(client_key) +``` + +with: + +```python + now = dt_util.now() + v4_batch = await client.get_performance_v4_batch( + client_key, + ytd_from=f"{now.year:04d}-01-01", + ytd_to=now.date().isoformat(), + ) +``` + +- [ ] **Step 5: Keep monetary values out of the logs** + +The debug statement immediately below that call logs percentages and +`cash_transfer_balance`. Do **not** add `ytd_profit_loss` or `ytd_cash_transfer` to +it — they are monetary amounts and the project forbids logging balances. Change the +trailing part of the existing call to log presence only: + +```python + _LOGGER.debug( + "Retrieved batched performance v4 data - AllTime: %s%%, YTD: %s%%, " + "Month: %s%%, Quarter: %s%%, YTD currency metrics present: %s", + result["investment_performance_percentage"], + result["ytd_investment_performance_percentage"], + result["month_investment_performance_percentage"], + result["quarter_investment_performance_percentage"], + result.get("ytd_profit_loss") is not None, + ) +``` + +Note this also drops `cash_transfer_balance` from the log line, which was a monetary +amount being logged at DEBUG. + +- [ ] **Step 6: Add the getters** + +In `custom_components/saxo_portfolio/coordinator.py`, after `get_cash_transfer_balance` (line 1209): + +```python + def get_ytd_profit_loss(self) -> float | None: + """Get year-to-date profit/loss in the account's base currency. + + Returns: + YTD profit/loss, or None when unavailable + + """ + if not self.data: + return None + value = self.data.get("ytd_profit_loss") + return float(value) if isinstance(value, int | float) else None + + def get_ytd_cash_transfer(self) -> float | None: + """Get year-to-date net deposits/withdrawals. + + Returns: + YTD net cash transferred, or None when unavailable + + """ + if not self.data: + return None + value = self.data.get("ytd_cash_transfer") + return float(value) if isinstance(value, int | float) else None +``` + +- [ ] **Step 7: Run the tests** + +Run: `uv run --extra dev pytest tests/unit/test_coordinator.py -q` +Expected: PASS. + +- [ ] **Step 8: Run the full suite** + +Run: `uv run --extra dev pytest -q` +Expected: `627 passed` (621 + 6 new). + +- [ ] **Step 9: Lint, format, type-check** + +```bash +uv run --extra dev ruff format custom_components/ tests/ +uv run --extra dev ruff check custom_components/ tests/ +uv run --extra dev mypy custom_components/ +``` + +Expected: all clean. + +- [ ] **Step 10: Commit** + +```bash +git add custom_components/saxo_portfolio/coordinator.py tests/unit/test_coordinator.py +git commit -m "feat: Anchor the YTD performance window to 1 January + +Coordinator owns the clock and passes the window to the client. Adds +get_ytd_profit_loss/get_ytd_cash_transfer getters returning float | None, +and stops logging monetary amounts at DEBUG." +``` + +--- + +### Task 5: The two new sensors + +Both entities plus everything a user sees: names in 12 JSON files, icons, README. +Shipping a sensor without its `strings.json` entry would surface a raw translation +key in the UI, so those belong in this task. + +**Files:** +- Modify: `custom_components/saxo_portfolio/sensor.py:311-329` (registration), append two classes near `sensor.py:644` +- Modify: `custom_components/saxo_portfolio/strings.json`, `icons.json`, `translations/{da,de,en,es,fi,fr,it,nb,nl,pt,sv}.json` +- Modify: `README.md:37-42` and `:130-135` +- Test: `tests/unit/test_sensor_coverage.py` + +**Interfaces:** +- Consumes: `get_ytd_profit_loss()`, `get_ytd_cash_transfer()` from Task 4 +- Produces: entities `ytd_profit_loss` and `ytd_cash_transfer` (unique IDs `saxo_{client_id}_ytd_profit_loss` / `_ytd_cash_transfer`) + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/test_sensor_coverage.py`. Extend the `coord` fixture (line ~51) +with the two new return values first: + +```python + c.get_ytd_profit_loss.return_value = 1234.56 + c.get_ytd_cash_transfer.return_value = 250.0 +``` + +and add `"ytd_profit_loss": 1234.56, "ytd_cash_transfer": 250.0` to the `c.data` +dict in that fixture (around line 63). Then add: + +```python +class TestYTDCurrencySensors: + def test_ytd_profit_loss_value(self, coord): + sensor = SaxoYTDProfitLossSensor(coord) + type(sensor).coordinator = PropertyMock(return_value=coord) + assert sensor.native_value == pytest.approx(1234.56) + + def test_ytd_profit_loss_state_class(self, coord): + sensor = SaxoYTDProfitLossSensor(coord) + assert sensor._attr_state_class == "measurement" + + def test_ytd_profit_loss_unavailable_when_none(self, coord): + coord.get_ytd_profit_loss.return_value = None + sensor = SaxoYTDProfitLossSensor(coord) + type(sensor).coordinator = PropertyMock(return_value=coord) + assert sensor.native_value is None + assert sensor.available is False + + def test_ytd_profit_loss_currency_attr(self, coord): + sensor = SaxoYTDProfitLossSensor(coord) + type(sensor).coordinator = PropertyMock(return_value=coord) + assert sensor.extra_state_attributes["currency"] == coord.get_currency() + + def test_ytd_cash_transfer_value(self, coord): + sensor = SaxoYTDCashTransferSensor(coord) + type(sensor).coordinator = PropertyMock(return_value=coord) + assert sensor.native_value == pytest.approx(250.0) + + def test_ytd_cash_transfer_state_class(self, coord): + sensor = SaxoYTDCashTransferSensor(coord) + assert sensor._attr_state_class == "total" + + def test_ytd_cash_transfer_unavailable_when_none(self, coord): + coord.get_ytd_cash_transfer.return_value = None + sensor = SaxoYTDCashTransferSensor(coord) + type(sensor).coordinator = PropertyMock(return_value=coord) + assert sensor.native_value is None + assert sensor.available is False +``` + +Add both class names to the `from custom_components.saxo_portfolio.sensor import (...)` +block at the top of the file. + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --extra dev pytest tests/unit/test_sensor_coverage.py::TestYTDCurrencySensors -q` +Expected: FAIL with `ImportError: cannot import name 'SaxoYTDProfitLossSensor'` + +- [ ] **Step 3: Add the sensor classes** + +In `custom_components/saxo_portfolio/sensor.py`, after `SaxoCashTransferBalanceSensor` (ends line 643): + +```python +class SaxoYTDProfitLossSensor(SaxoSensorBase): + """Representation of a Saxo Portfolio YTD Profit/Loss sensor.""" + + def __init__(self, coordinator: SaxoCoordinator) -> None: + """Initialize the sensor.""" + super().__init__( + coordinator, + "ytd_profit_loss", + unit_of_measurement=coordinator.get_currency(), + ) + self._attr_state_class = "measurement" + self._attr_suggested_display_precision = 2 + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + if not self.coordinator.data: + return None + return self.coordinator.get_ytd_profit_loss() + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return extra attributes for the sensor.""" + attributes = super().extra_state_attributes + + if self.coordinator.data: + attributes["currency"] = self.coordinator.get_currency() + + return attributes + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if not super().available: + return False + + return self.coordinator.get_ytd_profit_loss() is not None + + +class SaxoYTDCashTransferSensor(SaxoBalanceSensorBase): + """Representation of a Saxo Portfolio YTD Net Transfers sensor.""" + + def __init__(self, coordinator: SaxoCoordinator) -> None: + """Initialize the sensor.""" + super().__init__( + coordinator, + "ytd_cash_transfer", + "get_ytd_cash_transfer", + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if not super().available: + return False + + return self.coordinator.get_ytd_cash_transfer() is not None +``` + +- [ ] **Step 4: Register them** + +In `async_setup_entry` (line 311), add to the `entities` list after +`SaxoQuarterInvestmentPerformanceSensor(coordinator),`: + +```python + SaxoYTDProfitLossSensor(coordinator), + SaxoYTDCashTransferSensor(coordinator), +``` + +- [ ] **Step 5: Add names and icons** + +In `custom_components/saxo_portfolio/icons.json`, inside `entity.sensor`, after the +`cash_transfer_balance` entry: + +```json + "ytd_profit_loss": { + "default": "mdi:trending-up" + }, + "ytd_cash_transfer": { + "default": "mdi:bank-transfer" + }, +``` + +In `custom_components/saxo_portfolio/strings.json` **and each of the 11 files** in +`custom_components/saxo_portfolio/translations/`, inside `entity.sensor`, after the +`ytd_investment_performance` entry: + +```json + "ytd_profit_loss": { + "name": "YTD Profit/Loss" + }, + "ytd_cash_transfer": { + "name": "YTD Net Transfers" + }, +``` + +The non-English files already carry English names for the existing YTD key, so use +the same English strings rather than inventing translations. + +Verify every file parses and got both keys: + +```bash +python3 -c " +import json, pathlib +files = ['custom_components/saxo_portfolio/strings.json'] + \ + sorted(str(p) for p in pathlib.Path('custom_components/saxo_portfolio/translations').glob('*.json')) +for f in files: + d = json.load(open(f)) + s = d.get('entity', {}).get('sensor', {}) + missing = [k for k in ('ytd_profit_loss','ytd_cash_transfer') if k not in s] + print(('OK ' if not missing else 'MISS ') + f, missing or '') +" +``` + +Expected: 12 lines, all `OK`. + +- [ ] **Step 6: Update the README** + +In `README.md`, add to the performance sensor list (after line 42): + +```markdown +- **YTD Profit/Loss**: Year-to-Date profit/loss in your account currency (`sensor.saxo_{clientid}_ytd_profit_loss`) +- **YTD Net Transfers**: Year-to-Date net deposits and withdrawals (`sensor.saxo_{clientid}_ytd_cash_transfer`) +``` + +and to the example entity list (after line 135): + +```markdown +- `sensor.saxo_123456_ytd_profit_loss` - Year-to-Date profit/loss +- `sensor.saxo_123456_ytd_cash_transfer` - Year-to-Date net deposits and withdrawals +``` + +- [ ] **Step 7: Run the tests** + +Run: `uv run --extra dev pytest tests/unit/test_sensor_coverage.py -q` +Expected: PASS. + +- [ ] **Step 8: Run the full suite** + +Run: `uv run --extra dev pytest -q` +Expected: `634 passed` (627 + 7 new). Note `tests/integration/test_sensor_creation.py` +and `tests/contract/test_sensor_contract.py` may assert on the number of created +entities — if either fails, update the expected count to include the two new sensors. + +- [ ] **Step 9: Lint, format, type-check** + +```bash +uv run --extra dev ruff format custom_components/ tests/ +uv run --extra dev ruff check custom_components/ tests/ +uv run --extra dev mypy custom_components/ +``` + +Expected: all clean. + +- [ ] **Step 10: Commit** + +```bash +git add custom_components/saxo_portfolio/sensor.py \ + custom_components/saxo_portfolio/strings.json \ + custom_components/saxo_portfolio/icons.json \ + custom_components/saxo_portfolio/translations/ \ + tests/unit/test_sensor_coverage.py README.md +git commit -m "feat: Add YTD profit/loss and YTD net transfers sensors + +Two currency-denominated year-to-date sensors reading the Jan-1 anchored +window. Both go unavailable rather than reporting 0.0 when data is missing." +``` + +--- + +### Task 6: Document the repoint + +The YTD percentage sensor changes value on upgrade. That needs to be findable by a +user who notices, so it doesn't read as a regression. + +**Files:** +- Modify: `CHANGELOG.md:8` (the `## [Unreleased]` section) + +**Interfaces:** +- Consumes: everything above +- Produces: nothing consumed downstream + +- [ ] **Step 1: Write the changelog entry** + +In `CHANGELOG.md`, replace the bare `## [Unreleased]` heading (line 8) with: + +```markdown +## [Unreleased] + +### Added +- **YTD Profit/Loss sensor**: Year-to-date profit/loss in the account's base currency (`sensor.saxo_{clientid}_ytd_profit_loss`) +- **YTD Net Transfers sensor**: Year-to-date net deposits and withdrawals (`sensor.saxo_{clientid}_ytd_cash_transfer`) + +### Fixed +- **YTD Investment Performance now measures year-to-date**: the sensor previously used Saxo's `StandardPeriod=Year`, which is a *trailing 12-month* window rather than year-to-date. It now uses an explicit window anchored to 1 January. + + **This changes the reported value.** On a test account the sensor read 17.83% (trailing 12 months) where true year-to-date was 9.32%. The `from`/`thru` attributes already claimed a 1 January start, so they were previously inaccurate; they are now correct. + + Long-term statistics recorded for this entity before the upgrade are trailing-12-month figures, so historical graphs will show a discontinuity at the upgrade point. The `entity_id` is unchanged — dashboards and automations continue to work. + +### Changed +- Performance data no longer fetches the trailing `Year` window; the January-anchored request takes its place, keeping the refresh at four API calls +- `Month` and `Quarter` performance requests trimmed to the `KeyFigures` field group +- Removed unused `get_performance_v4`, `get_performance_v4_ytd`, `get_performance_v4_month` and `get_performance_v4_quarter` client methods + +### Known Issues +- **Month and Quarter Investment Performance are also trailing windows**, not month-to-date and quarter-to-date: `StandardPeriod=Month` returns a rolling ~28 days and `Quarter` a rolling ~90 days. Their `from`/`thru` attributes are therefore inaccurate. Correcting these is deferred; see `docs/superpowers/specs/2026-08-04-ytd-sensors-design.md`. +``` + +- [ ] **Step 2: Full verification sweep** + +```bash +uv run --extra dev pytest -q +uv run --extra dev ruff format --check custom_components/ tests/ +uv run --extra dev ruff check custom_components/ tests/ +uv run --extra dev mypy custom_components/ +``` + +Expected: `634 passed`, `already formatted`, `All checks passed!`, `Success: no issues found in 11 source files`. + +- [ ] **Step 3: Confirm the API call count did not grow** + +```bash +grep -c '"ClientKey": client_key' custom_components/saxo_portfolio/api/saxo_client.py +``` + +Expected: `4` (one per batch spec). Together with the v3 `get_performance` call and +`get_client_details`, the performance refresh is unchanged in request count. + +- [ ] **Step 4: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs: Changelog for YTD sensors and the YTD window correction" +``` + +--- + +## Post-Deploy Verification + +The design rests on ratio evidence rather than absolute amounts — the probe script +deliberately never printed balances. Once deployed, confirm against the Saxo web +platform: + +1. `sensor.saxo_{id}_ytd_profit_loss` matches the platform's year-to-date P/L. +2. `sensor.saxo_{id}_ytd_cash_transfer` matches net deposits since 1 January. +3. `sensor.saxo_{id}_ytd_investment_performance` matches the platform's YTD return + (expected ≈9.32% and ≈20.27% for the two accounts as of 2026-08-04). + +The probe script used during design is in the session scratchpad and can be re-run +with `--storage --verify` against the HA host if any value looks wrong. diff --git a/docs/superpowers/specs/2026-08-04-ytd-sensors-design.md b/docs/superpowers/specs/2026-08-04-ytd-sensors-design.md new file mode 100644 index 0000000..2ad25b9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-ytd-sensors-design.md @@ -0,0 +1,193 @@ +# YTD Sensors — Design + +Date: 2026-08-04 +Status: Approved for planning + +## Problem + +The integration exposes one YTD sensor (`ytd_investment_performance`, a percentage). +Two currency-denominated YTD datapoints are missing: profit/loss for the year and +net deposits/withdrawals for the year. + +While investigating available datapoints, a defect surfaced: **`StandardPeriod=Year` +is a trailing 12-month window, not year-to-date.** The existing "YTD" sensor reports +rolling-1-year performance. + +## Evidence + +Probed both live accounts on 2026-08-04 against `/hist/v4/performance/timeseries` +with `FieldGroups=Balance,KeyFigures,TimeWeighted`. + +### StandardPeriod windows are trailing, not to-date + +| Period | Window returned | Span | Anchored to period start? | +|---|---|---|---| +| Month | 2026-07-06 .. 2026-08-03 | 28d | No — MTD starts 2026-07-01 | +| Quarter | 2026-05-05 .. 2026-08-03 | 90d | No — QTD starts 2026-07-01 | +| Year | 2025-08-04 .. 2026-08-03 | 364d | No — YTD starts 2026-01-01 | +| AllTime | inception .. 2026-08-03 | — | n/a | + +Identical on both accounts. Impact on the shipped sensor: + +| | Displayed (trailing 12m) | True YTD | +|---|---|---| +| Account 1 | 17.83% | 9.32% | +| Account 2 | 29.38% | 20.27% | + +### FromDate requires ToDate + +`FromDate` alone returns `HTTP 400 InvalidQueryParameters`. With both, the window is +correctly anchored: `FromDate=2026-01-01&ToDate=2026-08-04` → `2026-01-02 .. 2026-08-03`, +n=152. + +### Series semantics + +- `Balance.CashTransfer` is **cumulative within the requested window**, starting at + zero at window start. Under `Year` it showed 179 zeros then 81 identical values + (`sum/last = 81.0`, exactly the nonzero count) — a step function from a single deposit. +- `Balance.YearlyProfitLoss` is **per-calendar-year buckets**, not cumulative + (AllTime account 1: `[0.321, 1.0, 0.789]` for 2024/2025/2026 — non-monotone). +- The current-year bucket is window-independent: earlier years get clipped by a + narrower window, but the current year has nothing to clip. + +### Cross-window consistency (both accounts, exact) + +``` +YearlyProfitLoss[2026] FromDate / AllTime = 1.000000 +YearlyProfitLoss[2026] Year / AllTime = 1.000000 +CashTransfer YTD FromDate-last / AllTime-delta = 1.000000 +(both routes zero this year? False) +``` + +The transfer check was meaningful — real transfers occurred this year, so it is not a +zero-equals-zero false pass. + +### Incidental + +- `PerformanceFraction` is identical to `ReturnFraction` in every response — not a + distinct datapoint. +- `TotalGrossAccruals` is empty; `SecurityTransfer` is all zeros on both accounts. +- `/port/v1/clients/me` carries no `Currency` key; the currency unit comes from the + balance endpoint via `get_currency()` (`coordinator.py:881`). + +## Scope + +In scope: + +1. New sensor: YTD profit/loss (currency). +2. New sensor: YTD net deposits/withdrawals (currency). +3. Repoint `ytd_investment_performance` to a genuine Jan-1 window, in place. + +Out of scope: Month/Quarter remain trailing windows. Their mislabelling is recorded +here but deferred to separate work. No Group A KeyFigures sensors (drawdown, Sharpe, +trade counts) — not requested. + +## Design + +### Net API cost: zero + +Once the YTD percentage is repointed, nothing reads the trailing-`Year` response — +it is that metric's sole source. The `Year` call is dropped and the Jan-1 call takes +its slot. The batch stays at four requests per 2h performance refresh. + +All three YTD values come from the single Jan-1-anchored response, so no date +arithmetic against the AllTime series is needed. + +### A. API layer — `api/saxo_client.py` + +`get_performance_v4_batch()` iterates StandardPeriods only. Refactor to iterate +`(key, params)` specs so one entry can be date-ranged, preserving the existing 0.5s +inter-call stagger and single error path. + +| Key | Params | FieldGroups | +|---|---|---| +| `alltime` | `StandardPeriod=AllTime` | `Balance_CashTransfer,KeyFigures` | +| `ytd` | `FromDate=-01-01`, `ToDate=` | `Balance_CashTransfer,Balance_YearlyProfitLoss,KeyFigures` | +| `month` | `StandardPeriod=Month` | `KeyFigures` | +| `quarter` | `StandardPeriod=Quarter` | `KeyFigures` | + +Month and Quarter trim to `KeyFigures`; nothing reads their Balance data. + +Delete `get_performance_v4`, `get_performance_v4_ytd`, `get_performance_v4_month`, +`get_performance_v4_quarter`. All four are dead production code — referenced only by +their own tests — and a date-ranged fifth variant would compound the duplication. + +### B. Coordinator — `coordinator.py` + +`_extract_v4_batch_metrics()` gains three reads from the `ytd` response: + +- `ytd_investment_performance_percentage` = `KeyFigures.ReturnFraction × 100` + (repointed from the trailing window) +- `ytd_profit_loss` = the `Balance.YearlyProfitLoss` bucket whose `Date` year matches + the current year. Matched by year rather than assuming `n == 1`, so a response + spanning a year boundary cannot silently select the wrong bucket. +- `ytd_cash_transfer` = last value of `Balance.CashTransfer` + +The `Year` entry is removed from the batch and from `_extract_v4_batch_metrics()`. + +Both `FromDate` and `ToDate` derive from `dt_util.now()` (HA local time), not the +configured market timezone — that setting can be `"any"` (`coordinator.py:151`), and +a user's YTD should follow their wall clock. `ToDate` is the current local date. + +Unlike the existing metrics, the two new keys default to `None` rather than `0.0` in +`_build_performance_defaults()`. On a currency sensor, `0.0` reads as "you earned +nothing this year" rather than "no data". The existing 0.0 defaults are left alone. + +New getters `get_ytd_profit_loss()` and `get_ytd_cash_transfer()` return `float | None`. + +### C. Sensors — `sensor.py` + +- `SaxoYTDProfitLossSensor(SaxoSensorBase)` — unit from `get_currency()`, + `state_class="measurement"`, matching its sibling `SaxoAccumulatedProfitLossSensor` + (`sensor.py:406`). +- `SaxoYTDCashTransferSensor(SaxoBalanceSensorBase)` — matching + `SaxoCashTransferBalanceSensor` (`sensor.py:624`), the same kind of cumulative balance. + +Both override `available` keyed on their data key being present, following the +existing convention, and return `None` when their value is `None`. +`SaxoBalanceSensorBase` already short-circuits on `None` (`sensor.py:223-224`), so +the `None`-default decision needs no base-class change. + +### D. Presentation + +New `ytd_profit_loss` and `ytd_cash_transfer` keys in `strings.json`, `icons.json`, +and the 11 files under `translations/`. Locale files already carry English names for +the existing YTD key; new keys follow that pattern rather than inventing translations. + +### E. Tests + +- Coordinator: extraction of both new metrics; year-matching selects the correct + bucket; missing bucket yields `None`; absent `ytd` response degrades gracefully. +- Client: batch emits the four expected specs with correct params, including + `ToDate`; the 0.5s stagger still applies. +- Remove the test classes for the four deleted helpers. +- Update `tests/unit/test_sensor_coverage.py` and the contract tests, which enumerate + the sensor set. + +### F. Docs + +README sensor list, and a CHANGELOG entry flagging the repoint prominently: the YTD +percentage drops from 17.83% to 9.32% on account 1 at upgrade, and recorded long-term +statistics for that entity become a mix of trailing-12m history and true YTD going +forward. The discontinuity is inherent to repointing in place. + +## Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| YTD window source | `FromDate`/`ToDate` call | Only way to get a true Jan-1 anchor; `ToDate` is mandatory | +| Existing YTD entity | Repoint in place | Keeps `entity_id`; dashboards and automations keep working | +| Trailing-`Year` call | Drop | No reader once repointed; keeps net API cost at zero | +| Missing-data value | `None` → unavailable | `0.0` on a currency sensor is misleading | +| Month/Quarter mislabel | Deferred | Recorded above; correcting all three needs 2 more calls | + +## Risks + +- **Statistics discontinuity** on the repointed entity. Accepted; mitigated by a + CHANGELOG note. +- **Year-boundary behaviour** around 1 January: the Jan-1 window is one day wide and + `YearlyProfitLoss` may hold two buckets. Year-matching handles selection; the + narrow window is correct, not a bug. +- **Inference on bucket equality** rests on the 1.000000 ratios above rather than on + absolute amounts, which were deliberately not disclosed. Verifiable post-deploy + against the Saxo web platform. diff --git a/pyproject.toml b/pyproject.toml index 861dbc1..3bab989 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,11 @@ include = ["custom_components*"] [tool.ruff] target-version = "py314" +# Ruff >=0.16 formats Python code blocks inside Markdown. Design docs quote +# deliberately partial snippets (class methods shown without their class, +# fragments of larger literals), which formatting would rewrite into code +# that no longer matches the source it documents. +extend-exclude = ["docs/"] [tool.ruff.lint] select = [ diff --git a/tests/integration/test_sensor_creation.py b/tests/integration/test_sensor_creation.py index d17fdfc..3192619 100644 --- a/tests/integration/test_sensor_creation.py +++ b/tests/integration/test_sensor_creation.py @@ -210,8 +210,8 @@ async def test_sensor_platform_setup_with_known_client_name( call_args = mock_add_entities.call_args sensors = call_args[0][0] # First argument (entities list) - # Should create 16 sensors total (position sensors disabled) - assert len(sensors) == 16 + # Should create 18 sensors total (position sensors disabled) + assert len(sensors) == 18 # Should create expected sensor classes sensor_classes = [type(sensor).__name__ for sensor in sensors] @@ -225,6 +225,8 @@ async def test_sensor_platform_setup_with_known_client_name( "SaxoYTDInvestmentPerformanceSensor", "SaxoMonthInvestmentPerformanceSensor", "SaxoQuarterInvestmentPerformanceSensor", + "SaxoYTDProfitLossSensor", + "SaxoYTDCashTransferSensor", "SaxoClientIDSensor", "SaxoAccountIDSensor", "SaxoNameSensor", diff --git a/tests/unit/test_coordinator.py b/tests/unit/test_coordinator.py index a887661..001137d 100644 --- a/tests/unit/test_coordinator.py +++ b/tests/unit/test_coordinator.py @@ -388,6 +388,97 @@ def test_empty_cash_transfer_list(self): metrics = SaxoCoordinator._extract_v4_batch_metrics(v4_batch) assert "cash_transfer_balance" not in metrics + def test_ytd_profit_loss_and_transfers(self): + """YTD currency metrics come from the Jan-1 anchored response.""" + v4_batch = { + "alltime": { + "KeyFigures": {"ReturnFraction": 0.32}, + "Balance": {"CashTransfer": [{"Value": 500}, {"Value": 1000}]}, + }, + "ytd": { + "KeyFigures": {"ReturnFraction": 0.09}, + "Balance": { + "YearlyProfitLoss": [ + {"Date": "2026-12-31", "Value": 1234.56}, + ], + "CashTransfer": [ + {"Date": "2026-01-02", "Value": 0}, + {"Date": "2026-04-01", "Value": 250.0}, + ], + }, + }, + "month": {"KeyFigures": {"ReturnFraction": 0.02}}, + "quarter": {"KeyFigures": {"ReturnFraction": 0.03}}, + } + with patch( + "custom_components.saxo_portfolio.coordinator.dt_util.now" + ) as mock_now: + mock_now.return_value = datetime(2026, 8, 4, 12, 0) + metrics = SaxoCoordinator._extract_v4_batch_metrics(v4_batch) + + assert metrics["ytd_profit_loss"] == pytest.approx(1234.56) + assert metrics["ytd_cash_transfer"] == pytest.approx(250.0) + assert metrics["ytd_investment_performance_percentage"] == pytest.approx(9.0) + + def test_ytd_profit_loss_picks_current_year_bucket(self): + """A multi-year bucket list must select the current calendar year.""" + v4_batch = { + "ytd": { + "Balance": { + "YearlyProfitLoss": [ + {"Date": "2025-12-31", "Value": 999.0}, + {"Date": "2026-12-31", "Value": 111.0}, + ] + } + } + } + with patch( + "custom_components.saxo_portfolio.coordinator.dt_util.now" + ) as mock_now: + mock_now.return_value = datetime(2026, 8, 4, 12, 0) + metrics = SaxoCoordinator._extract_v4_batch_metrics(v4_batch) + + assert metrics["ytd_profit_loss"] == pytest.approx(111.0) + + def test_ytd_metrics_none_when_absent(self): + """Missing YTD balance data yields None, not 0.0.""" + v4_batch = {"ytd": {"KeyFigures": {"ReturnFraction": 0.09}}} + metrics = SaxoCoordinator._extract_v4_batch_metrics(v4_batch) + + assert metrics["ytd_profit_loss"] is None + assert metrics["ytd_cash_transfer"] is None + + def test_ytd_profit_loss_none_when_no_matching_year(self): + """A bucket list without the current year yields None.""" + v4_batch = { + "ytd": { + "Balance": {"YearlyProfitLoss": [{"Date": "2024-12-31", "Value": 5.0}]} + } + } + with patch( + "custom_components.saxo_portfolio.coordinator.dt_util.now" + ) as mock_now: + mock_now.return_value = datetime(2026, 8, 4, 12, 0) + metrics = SaxoCoordinator._extract_v4_batch_metrics(v4_batch) + + assert metrics["ytd_profit_loss"] is None + + def test_ytd_cash_transfer_skips_non_numeric(self): + """Non-numeric trailing entries are skipped, not returned.""" + v4_batch = { + "ytd": { + "Balance": { + "CashTransfer": [ + {"Date": "2026-01-02", "Value": 100.0}, + {"Date": "2026-04-01", "Value": None}, + ] + } + } + } + metrics = SaxoCoordinator._extract_v4_batch_metrics(v4_batch) + + assert metrics["ytd_cash_transfer"] == pytest.approx(100.0) + # --------------------------------------------------------------------------- # _fetch_performance_data_safely @@ -581,6 +672,26 @@ async def test_v4_failure_v3_succeeds(self): assert result["ytd_earnings_percentage"] == 50.0 assert "investment_performance_percentage" not in result + async def test_batch_called_with_january_first_anchor(self): + """The YTD window must start on 1 January of the current year.""" + coord = _bare_coordinator() + client = AsyncMock() + client.get_performance = AsyncMock(return_value={}) + client.get_performance_v4_batch = AsyncMock( + return_value={"alltime": {}, "ytd": {}, "month": {}, "quarter": {}} + ) + result: dict = {} + + with patch( + "custom_components.saxo_portfolio.coordinator.dt_util.now" + ) as mock_now: + mock_now.return_value = datetime(2026, 8, 4, 12, 0) + await coord._fetch_performance_metrics(client, "ck1", result) + + kwargs = client.get_performance_v4_batch.call_args.kwargs + assert kwargs["ytd_from"] == "2026-01-01" + assert kwargs["ytd_to"] == "2026-08-04" + # --------------------------------------------------------------------------- # _fetch_positions_data_safely @@ -1778,6 +1889,40 @@ def test_is_startup_phase(self): assert coord.is_startup_phase is False +class TestYtdGetters: + """Tests for the YTD currency getters.""" + + def test_get_ytd_profit_loss(self): + """YTD profit/loss is returned from data.""" + coord = _bare_coordinator() + coord.data = {"ytd_profit_loss": 1234.56} + assert coord.get_ytd_profit_loss() == pytest.approx(1234.56) + + def test_get_ytd_profit_loss_none_when_missing(self): + """Missing key returns None, not 0.0.""" + coord = _bare_coordinator() + coord.data = {} + assert coord.get_ytd_profit_loss() is None + + def test_get_ytd_profit_loss_none_without_data(self): + """No data returns None.""" + coord = _bare_coordinator() + coord.data = None + assert coord.get_ytd_profit_loss() is None + + def test_get_ytd_cash_transfer(self): + """YTD net transfers is returned from data.""" + coord = _bare_coordinator() + coord.data = {"ytd_cash_transfer": 250.0} + assert coord.get_ytd_cash_transfer() == pytest.approx(250.0) + + def test_get_ytd_cash_transfer_none_when_missing(self): + """Missing key returns None, not 0.0.""" + coord = _bare_coordinator() + coord.data = {} + assert coord.get_ytd_cash_transfer() is None + + # --------------------------------------------------------------------------- # mark_sensors_initialized / mark_setup_complete # --------------------------------------------------------------------------- diff --git a/tests/unit/test_saxo_client.py b/tests/unit/test_saxo_client.py index 51b9bab..e8e4618 100644 --- a/tests/unit/test_saxo_client.py +++ b/tests/unit/test_saxo_client.py @@ -10,8 +10,7 @@ - _handle_rate_limited: retry vs max retries - _compute_timeout_backoff and _compute_client_error_backoff - All endpoint methods: get_account_balance, get_client_details, get_performance, - get_performance_v4_batch, get_performance_v4, get_performance_v4_ytd, - get_performance_v4_month, get_performance_v4_quarter, get_net_positions + get_performance_v4_batch, get_net_positions """ from __future__ import annotations @@ -1156,7 +1155,9 @@ async def mock_make_request(endpoint, params=None): new_callable=AsyncMock, ), ): - result = await client.get_performance_v4_batch("ck_123") + result = await client.get_performance_v4_batch( + "ck_123", ytd_from="2026-01-01", ytd_to="2026-08-04" + ) assert "alltime" in result assert "ytd" in result @@ -1184,7 +1185,9 @@ async def mock_make_request(endpoint, params=None): new_callable=AsyncMock, ) as mock_sleep, ): - await client.get_performance_v4_batch("ck") + await client.get_performance_v4_batch( + "ck", ytd_from="2026-01-01", ytd_to="2026-08-04" + ) # Sleep should be called 3 times (between 4 calls) assert mock_sleep.call_count == 3 @@ -1204,7 +1207,9 @@ async def test_auth_error_propagates(self): ), pytest.raises(AuthenticationError), ): - await client.get_performance_v4_batch("ck") + await client.get_performance_v4_batch( + "ck", ytd_from="2026-01-01", ytd_to="2026-08-04" + ) @pytest.mark.asyncio async def test_rate_limit_error_propagates(self): @@ -1219,7 +1224,9 @@ async def test_rate_limit_error_propagates(self): ), pytest.raises(RateLimitError), ): - await client.get_performance_v4_batch("ck") + await client.get_performance_v4_batch( + "ck", ytd_from="2026-01-01", ytd_to="2026-08-04" + ) @pytest.mark.asyncio async def test_non_dict_response_raises(self): @@ -1230,10 +1237,12 @@ async def test_non_dict_response_raises(self): client, "_make_request", new_callable=AsyncMock, return_value="not_dict" ), pytest.raises( - APIError, match="Failed to fetch performance v4 AllTime data" + APIError, match="Failed to fetch performance v4 alltime data" ), ): - await client.get_performance_v4_batch("ck") + await client.get_performance_v4_batch( + "ck", ytd_from="2026-01-01", ytd_to="2026-08-04" + ) @pytest.mark.asyncio async def test_error_on_second_period(self): @@ -1254,322 +1263,89 @@ async def mock_make_request(endpoint, params=None): "custom_components.saxo_portfolio.api.saxo_client.asyncio.sleep", new_callable=AsyncMock, ), - pytest.raises(APIError, match="Failed to fetch performance v4 Year data"), - ): - await client.get_performance_v4_batch("ck") - - -# --------------------------------------------------------------------------- -# Endpoint: get_performance_v4 -# --------------------------------------------------------------------------- - - -class TestGetPerformanceV4: - """Tests for get_performance_v4.""" - - @pytest.mark.asyncio - async def test_success(self): - """Should return AllTime performance data.""" - data = {"KeyFigures": {"ReturnFraction": 0.15}} - client = _make_client(session=MagicMock()) - with patch.object( - client, "_make_request", new_callable=AsyncMock, return_value=data + pytest.raises(APIError, match="Failed to fetch performance v4 ytd data"), ): - result = await client.get_performance_v4("ck") - assert result == data - - @pytest.mark.asyncio - async def test_correct_params(self): - """Should call with StandardPeriod=AllTime and correct FieldGroups.""" - client = _make_client(session=MagicMock()) - with patch.object( - client, "_make_request", new_callable=AsyncMock, return_value={} - ) as mock_req: - await client.get_performance_v4("ck_123") - args, _ = mock_req.call_args - # _make_request(endpoint, params) - params is second positional arg - params = args[1] - assert params["StandardPeriod"] == "AllTime" - assert params["ClientKey"] == "ck_123" - assert "Balance_CashTransfer" in params["FieldGroups"] - - @pytest.mark.asyncio - async def test_auth_error_propagates(self): - """AuthenticationError should propagate.""" - client = _make_client(session=MagicMock()) - with ( - patch.object( - client, - "_make_request", - new_callable=AsyncMock, - side_effect=AuthenticationError("auth"), - ), - pytest.raises(AuthenticationError), - ): - await client.get_performance_v4("ck") - - @pytest.mark.asyncio - async def test_rate_limit_error_propagates(self): - """RateLimitError should propagate.""" - client = _make_client(session=MagicMock()) - with ( - patch.object( - client, - "_make_request", - new_callable=AsyncMock, - side_effect=RateLimitError("rate"), - ), - pytest.raises(RateLimitError), - ): - await client.get_performance_v4("ck") - - @pytest.mark.asyncio - async def test_non_dict_raises(self): - """Non-dict response should raise APIError.""" - client = _make_client(session=MagicMock()) - with ( - patch.object( - client, "_make_request", new_callable=AsyncMock, return_value=[1] - ), - pytest.raises(APIError, match="Failed to fetch performance v4 data"), - ): - await client.get_performance_v4("ck") - - @pytest.mark.asyncio - async def test_unexpected_error_wraps(self): - """Unexpected exceptions wrapped in APIError.""" - client = _make_client(session=MagicMock()) - with ( - patch.object( - client, - "_make_request", - new_callable=AsyncMock, - side_effect=ValueError("x"), - ), - pytest.raises(APIError, match="Failed to fetch performance v4 data"), - ): - await client.get_performance_v4("ck") - - -# --------------------------------------------------------------------------- -# Endpoint: get_performance_v4_ytd -# --------------------------------------------------------------------------- - - -class TestGetPerformanceV4Ytd: - """Tests for get_performance_v4_ytd.""" - - @pytest.mark.asyncio - async def test_success(self): - """Should return YTD performance data.""" - data = {"KeyFigures": {"ReturnFraction": 0.05}} - client = _make_client(session=MagicMock()) - with patch.object( - client, "_make_request", new_callable=AsyncMock, return_value=data - ): - result = await client.get_performance_v4_ytd("ck") - assert result == data + await client.get_performance_v4_batch( + "ck", ytd_from="2026-01-01", ytd_to="2026-08-04" + ) @pytest.mark.asyncio - async def test_correct_params(self): - """Should call with StandardPeriod=Year.""" - client = _make_client(session=MagicMock()) - with patch.object( - client, "_make_request", new_callable=AsyncMock, return_value={} - ) as mock_req: - await client.get_performance_v4_ytd("ck") - args, _ = mock_req.call_args - assert args[1]["StandardPeriod"] == "Year" + async def test_ytd_spec_uses_date_range(self): + """YTD entry must use FromDate/ToDate, not StandardPeriod.""" + captured: list[dict] = [] - @pytest.mark.asyncio - async def test_auth_error_propagates(self): - """AuthenticationError should propagate.""" - client = _make_client(session=MagicMock()) - with ( - patch.object( - client, - "_make_request", - new_callable=AsyncMock, - side_effect=AuthenticationError("a"), - ), - pytest.raises(AuthenticationError), - ): - await client.get_performance_v4_ytd("ck") + async def mock_make_request(endpoint, params=None): + captured.append(dict(params or {})) + return {"KeyFigures": {}} - @pytest.mark.asyncio - async def test_rate_limit_error_propagates(self): - """RateLimitError should propagate.""" client = _make_client(session=MagicMock()) with ( - patch.object( - client, - "_make_request", + patch.object(client, "_make_request", side_effect=mock_make_request), + patch( + "custom_components.saxo_portfolio.api.saxo_client.asyncio.sleep", new_callable=AsyncMock, - side_effect=RateLimitError("r"), - ), - pytest.raises(RateLimitError), - ): - await client.get_performance_v4_ytd("ck") - - @pytest.mark.asyncio - async def test_non_dict_raises(self): - """Non-dict response should raise APIError.""" - client = _make_client(session=MagicMock()) - with ( - patch.object( - client, "_make_request", new_callable=AsyncMock, return_value="str" ), - pytest.raises(APIError, match="Failed to fetch performance v4 YTD data"), ): - await client.get_performance_v4_ytd("ck") - - -# --------------------------------------------------------------------------- -# Endpoint: get_performance_v4_month -# --------------------------------------------------------------------------- - - -class TestGetPerformanceV4Month: - """Tests for get_performance_v4_month.""" + await client.get_performance_v4_batch( + "ck", ytd_from="2026-01-01", ytd_to="2026-08-04" + ) - @pytest.mark.asyncio - async def test_success(self): - """Should return Month performance data.""" - data = {"KeyFigures": {"ReturnFraction": 0.02}} - client = _make_client(session=MagicMock()) - with patch.object( - client, "_make_request", new_callable=AsyncMock, return_value=data - ): - result = await client.get_performance_v4_month("ck") - assert result == data + assert len(captured) == 4 + ytd_params = captured[1] + assert ytd_params["FromDate"] == "2026-01-01" + assert ytd_params["ToDate"] == "2026-08-04" + assert "StandardPeriod" not in ytd_params + assert "Balance_YearlyProfitLoss" in ytd_params["FieldGroups"] + assert "Balance_CashTransfer" in ytd_params["FieldGroups"] @pytest.mark.asyncio - async def test_correct_params(self): - """Should call with StandardPeriod=Month.""" - client = _make_client(session=MagicMock()) - with patch.object( - client, "_make_request", new_callable=AsyncMock, return_value={} - ) as mock_req: - await client.get_performance_v4_month("ck") - args, _ = mock_req.call_args - assert args[1]["StandardPeriod"] == "Month" + async def test_trailing_year_period_not_requested(self): + """StandardPeriod=Year is a trailing 12m window and must not be fetched.""" + captured: list[dict] = [] - @pytest.mark.asyncio - async def test_auth_error_propagates(self): - """AuthenticationError should propagate.""" - client = _make_client(session=MagicMock()) - with ( - patch.object( - client, - "_make_request", - new_callable=AsyncMock, - side_effect=AuthenticationError("a"), - ), - pytest.raises(AuthenticationError), - ): - await client.get_performance_v4_month("ck") + async def mock_make_request(endpoint, params=None): + captured.append(dict(params or {})) + return {"KeyFigures": {}} - @pytest.mark.asyncio - async def test_rate_limit_error_propagates(self): - """RateLimitError should propagate.""" client = _make_client(session=MagicMock()) with ( - patch.object( - client, - "_make_request", + patch.object(client, "_make_request", side_effect=mock_make_request), + patch( + "custom_components.saxo_portfolio.api.saxo_client.asyncio.sleep", new_callable=AsyncMock, - side_effect=RateLimitError("r"), ), - pytest.raises(RateLimitError), ): - await client.get_performance_v4_month("ck") - - @pytest.mark.asyncio - async def test_non_dict_raises(self): - """Non-dict response should raise APIError.""" - client = _make_client(session=MagicMock()) - with ( - patch.object( - client, "_make_request", new_callable=AsyncMock, return_value=42 - ), - pytest.raises(APIError, match="Failed to fetch performance v4 Month data"), - ): - await client.get_performance_v4_month("ck") - - -# --------------------------------------------------------------------------- -# Endpoint: get_performance_v4_quarter -# --------------------------------------------------------------------------- - + await client.get_performance_v4_batch( + "ck", ytd_from="2026-01-01", ytd_to="2026-08-04" + ) -class TestGetPerformanceV4Quarter: - """Tests for get_performance_v4_quarter.""" + periods = [p.get("StandardPeriod") for p in captured] + assert "Year" not in periods + assert periods == ["AllTime", None, "Month", "Quarter"] @pytest.mark.asyncio - async def test_success(self): - """Should return Quarter performance data.""" - data = {"KeyFigures": {"ReturnFraction": 0.04}} - client = _make_client(session=MagicMock()) - with patch.object( - client, "_make_request", new_callable=AsyncMock, return_value=data - ): - result = await client.get_performance_v4_quarter("ck") - assert result == data + async def test_month_quarter_request_keyfigures_only(self): + """Month/Quarter Balance data has no reader; don't fetch it.""" + captured: list[dict] = [] - @pytest.mark.asyncio - async def test_correct_params(self): - """Should call with StandardPeriod=Quarter.""" - client = _make_client(session=MagicMock()) - with patch.object( - client, "_make_request", new_callable=AsyncMock, return_value={} - ) as mock_req: - await client.get_performance_v4_quarter("ck") - args, _ = mock_req.call_args - assert args[1]["StandardPeriod"] == "Quarter" - - @pytest.mark.asyncio - async def test_auth_error_propagates(self): - """AuthenticationError should propagate.""" - client = _make_client(session=MagicMock()) - with ( - patch.object( - client, - "_make_request", - new_callable=AsyncMock, - side_effect=AuthenticationError("a"), - ), - pytest.raises(AuthenticationError), - ): - await client.get_performance_v4_quarter("ck") + async def mock_make_request(endpoint, params=None): + captured.append(dict(params or {})) + return {"KeyFigures": {}} - @pytest.mark.asyncio - async def test_rate_limit_error_propagates(self): - """RateLimitError should propagate.""" client = _make_client(session=MagicMock()) with ( - patch.object( - client, - "_make_request", + patch.object(client, "_make_request", side_effect=mock_make_request), + patch( + "custom_components.saxo_portfolio.api.saxo_client.asyncio.sleep", new_callable=AsyncMock, - side_effect=RateLimitError("r"), ), - pytest.raises(RateLimitError), ): - await client.get_performance_v4_quarter("ck") + await client.get_performance_v4_batch( + "ck", ytd_from="2026-01-01", ytd_to="2026-08-04" + ) - @pytest.mark.asyncio - async def test_non_dict_raises(self): - """Non-dict response should raise APIError.""" - client = _make_client(session=MagicMock()) - with ( - patch.object( - client, "_make_request", new_callable=AsyncMock, return_value=None - ), - pytest.raises( - APIError, match="Failed to fetch performance v4 Quarter data" - ), - ): - await client.get_performance_v4_quarter("ck") + assert captured[2]["FieldGroups"] == "KeyFigures" + assert captured[3]["FieldGroups"] == "KeyFigures" # --------------------------------------------------------------------------- diff --git a/tests/unit/test_sensor_coverage.py b/tests/unit/test_sensor_coverage.py index d2246d8..eda99e6 100644 --- a/tests/unit/test_sensor_coverage.py +++ b/tests/unit/test_sensor_coverage.py @@ -9,6 +9,7 @@ import pytest from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import EntityCategory +from homeassistant.util import dt as dt_util from custom_components.saxo_portfolio.coordinator import PositionData, SaxoCoordinator from custom_components.saxo_portfolio.sensor import ( @@ -32,7 +33,9 @@ SaxoTimezoneSensor, SaxoTokenExpirySensor, SaxoTotalValueSensor, + SaxoYTDCashTransferSensor, SaxoYTDInvestmentPerformanceSensor, + SaxoYTDProfitLossSensor, _setup_position_listener, async_setup_entry, ) @@ -54,6 +57,8 @@ def coord(): c.get_month_investment_performance_percentage.return_value = 2.1 c.get_quarter_investment_performance_percentage.return_value = 3.45 c.get_cash_transfer_balance.return_value = 10000.0 + c.get_ytd_profit_loss.return_value = 1234.56 + c.get_ytd_cash_transfer.return_value = 250.0 c.get_account_id.return_value = "ACC456" c.last_update_success = True c.data = { @@ -63,6 +68,8 @@ def coord(): "ytd_earnings_percentage": 5.5, "investment_performance_percentage": 12.34, "cash_transfer_balance": 10000.0, + "ytd_profit_loss": 1234.56, + "ytd_cash_transfer": 250.0, } c.config_entry = MagicMock() c.config_entry.entry_id = "test_entry" @@ -109,8 +116,8 @@ async def test_setup_with_valid_client(self, coord): await async_setup_entry(MagicMock(), entry, add_entities) add_entities.assert_called_once() entities = add_entities.call_args[0][0] - # 16 base + 1 market data + 1 position = 18 - assert len(entities) >= 17 + # 18 base + 1 market data + 1 position = 20 + assert len(entities) >= 19 coord.mark_sensors_initialized.assert_called_once() @pytest.mark.asyncio @@ -131,7 +138,7 @@ async def test_setup_without_positions(self, coord): add_entities = MagicMock() await async_setup_entry(MagicMock(), entry, add_entities) entities = add_entities.call_args[0][0] - assert len(entities) == 16 # No position or market data sensors + assert len(entities) == 18 # No position or market data sensors class TestSetupPositionListener: @@ -776,3 +783,76 @@ def test_name(self, coord): sensor = SaxoPositionSensor(coord, "aapl_stock") assert sensor._attr_name == "Position AAPL" assert sensor._attr_has_entity_name is True + + +class TestYTDCurrencySensors: + def test_ytd_profit_loss_value(self, coord): + sensor = SaxoYTDProfitLossSensor(coord) + type(sensor).coordinator = PropertyMock(return_value=coord) + assert sensor.native_value == pytest.approx(1234.56) + + def test_ytd_profit_loss_state_class(self, coord): + sensor = SaxoYTDProfitLossSensor(coord) + assert sensor._attr_state_class == "measurement" + + def test_ytd_profit_loss_unavailable_when_none(self, coord): + coord.get_ytd_profit_loss.return_value = None + sensor = SaxoYTDProfitLossSensor(coord) + type(sensor).coordinator = PropertyMock(return_value=coord) + assert sensor.native_value is None + assert sensor.available is False + + def test_ytd_profit_loss_currency_attr(self, coord): + sensor = SaxoYTDProfitLossSensor(coord) + type(sensor).coordinator = PropertyMock(return_value=coord) + assert sensor.extra_state_attributes["currency"] == coord.get_currency() + + def test_ytd_cash_transfer_value(self, coord): + sensor = SaxoYTDCashTransferSensor(coord) + type(sensor).coordinator = PropertyMock(return_value=coord) + assert sensor.native_value == pytest.approx(250.0) + + def test_ytd_cash_transfer_state_class(self, coord): + sensor = SaxoYTDCashTransferSensor(coord) + assert sensor._attr_state_class == "total" + + def test_ytd_cash_transfer_unavailable_when_none(self, coord): + coord.get_ytd_cash_transfer.return_value = None + sensor = SaxoYTDCashTransferSensor(coord) + type(sensor).coordinator = PropertyMock(return_value=coord) + assert sensor.native_value is None + assert sensor.available is False + + def test_ytd_cash_transfer_last_reset_is_jan_1_current_year(self, coord): + sensor = SaxoYTDCashTransferSensor(coord) + type(sensor).coordinator = PropertyMock(return_value=coord) + last_reset = sensor.last_reset + + now = dt_util.now() + assert last_reset.year == now.year + assert last_reset.month == 1 + assert last_reset.day == 1 + assert last_reset.tzinfo is not None + + def test_ytd_cash_transfer_last_reset_is_recomputed_not_frozen(self, coord): + """last_reset must be a property re-derived from the current time. + + A value fixed at __init__ would go stale on 1 January until Home + Assistant restarts; simulating a year change must shift the + reported last_reset accordingly. + """ + sensor = SaxoYTDCashTransferSensor(coord) + type(sensor).coordinator = PropertyMock(return_value=coord) + + next_year = dt_util.now().year + 1 + future = dt_util.now().replace(year=next_year, month=1, day=2) + with patch( + "custom_components.saxo_portfolio.sensor.dt_util.now", + return_value=future, + ): + last_reset = sensor.last_reset + + assert last_reset.year == next_year + assert last_reset.month == 1 + assert last_reset.day == 1 + assert last_reset.tzinfo is not None