From 6b08147ffed5f22f9d63c5b9e6fac2f7a2bd7ebe Mon Sep 17 00:00:00 2001 From: Steyn Huizinga Date: Tue, 4 Aug 2026 16:19:01 +0200 Subject: [PATCH 01/13] docs: Add YTD sensors design spec Adds design for two currency-denominated YTD sensors (profit/loss, net deposits/withdrawals) and repointing the existing YTD percentage sensor. Live API probing found StandardPeriod=Year is a trailing 12-month window, not year-to-date, so the shipped ytd_investment_performance sensor reports rolling-1-year performance (17.83% vs true YTD 9.32%). Month and Quarter are trailing too; correcting those is deferred. Net API cost is zero: the trailing-Year call has no reader once the YTD percentage is repointed, so the Jan-1 anchored call takes its slot. --- .../specs/2026-08-04-ytd-sensors-design.md | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-ytd-sensors-design.md 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..2093014 --- /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 all 12 translation files. 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. From b6b49e069a17332d2af46e1bdf43844d39666439 Mon Sep 17 00:00:00 2001 From: Steyn Huizinga Date: Tue, 4 Aug 2026 16:30:11 +0200 Subject: [PATCH 02/13] docs: Add YTD sensors implementation plan Six TDD tasks: remove dead v4 helpers, anchor the batch to 1 January, parse the two new metrics, wire the coordinator, add the sensors with translations, document the repoint. --- .../plans/2026-08-04-ytd-sensors.md | 1159 +++++++++++++++++ .../specs/2026-08-04-ytd-sensors-design.md | 4 +- 2 files changed, 1161 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-04-ytd-sensors.md 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..774cc11 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-ytd-sensors.md @@ -0,0 +1,1159 @@ +# 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 3 repoints its only consumer. +The caller supplies the dates so the client stays clock-free and trivially testable. + +**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 index 2093014..2ad25b9 100644 --- a/docs/superpowers/specs/2026-08-04-ytd-sensors-design.md +++ b/docs/superpowers/specs/2026-08-04-ytd-sensors-design.md @@ -151,8 +151,8 @@ 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 all 12 translation files. Locale files already carry English names for the -existing YTD key; new keys follow that pattern rather than inventing translations. +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 From 8d9fdd6e98fe4aea1dcdb4aed846c3ac532870e7 Mon Sep 17 00:00:00 2001 From: Steyn Huizinga Date: Tue, 4 Aug 2026 16:37:32 +0200 Subject: [PATCH 03/13] 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. --- .../saxo_portfolio/api/saxo_client.py | 166 --------- tests/unit/test_saxo_client.py | 316 +----------------- 2 files changed, 1 insertion(+), 481 deletions(-) diff --git a/custom_components/saxo_portfolio/api/saxo_client.py b/custom_components/saxo_portfolio/api/saxo_client.py index 92fa0d5..c1d3150 100644 --- a/custom_components/saxo_portfolio/api/saxo_client.py +++ b/custom_components/saxo_portfolio/api/saxo_client.py @@ -526,172 +526,6 @@ async def get_performance_v4_batch( 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/tests/unit/test_saxo_client.py b/tests/unit/test_saxo_client.py index 51b9bab..cb95877 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 @@ -1259,319 +1258,6 @@ async def mock_make_request(endpoint, params=None): 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 - ): - 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 - - @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" - - @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") - - @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("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.""" - - @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 - - @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" - - @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") - - @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("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 -# --------------------------------------------------------------------------- - - -class TestGetPerformanceV4Quarter: - """Tests for get_performance_v4_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 - - @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") - - @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("r"), - ), - pytest.raises(RateLimitError), - ): - await client.get_performance_v4_quarter("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=None - ), - pytest.raises( - APIError, match="Failed to fetch performance v4 Quarter data" - ), - ): - await client.get_performance_v4_quarter("ck") - - # --------------------------------------------------------------------------- # Endpoint: get_net_positions # --------------------------------------------------------------------------- From d88ebb2a4d97bbdf700e4d2c7b1d3a80bceeade4 Mon Sep 17 00:00:00 2001 From: Steyn Huizinga Date: Tue, 4 Aug 2026 16:44:31 +0200 Subject: [PATCH 04/13] 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. --- .../saxo_portfolio/api/saxo_client.py | 77 +++++++++---- tests/unit/test_saxo_client.py | 106 ++++++++++++++++-- 2 files changed, 153 insertions(+), 30 deletions(-) diff --git a/custom_components/saxo_portfolio/api/saxo_client.py b/custom_components/saxo_portfolio/api/saxo_client.py index c1d3150..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,10 +552,10 @@ 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 diff --git a/tests/unit/test_saxo_client.py b/tests/unit/test_saxo_client.py index cb95877..e8e4618 100644 --- a/tests/unit/test_saxo_client.py +++ b/tests/unit/test_saxo_client.py @@ -1155,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 @@ -1183,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 @@ -1203,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): @@ -1218,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): @@ -1229,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): @@ -1253,9 +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"), + pytest.raises(APIError, match="Failed to fetch performance v4 ytd data"), + ): + await client.get_performance_v4_batch( + "ck", ytd_from="2026-01-01", ytd_to="2026-08-04" + ) + + @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") + 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" # --------------------------------------------------------------------------- From 212d1e51c4f65be99ace4882dbff6cfaf4017c56 Mon Sep 17 00:00:00 2001 From: Steyn Huizinga Date: Tue, 4 Aug 2026 16:47:09 +0200 Subject: [PATCH 05/13] fix: Pass the January-1 anchored window to the v4 batch caller get_performance_v4_batch now requires ytd_from/ytd_to keyword arguments (previous commit); update its only caller in the coordinator so mypy stays clean on every commit. Computes the window from the current date: 1 January of the current year through today. --- custom_components/saxo_portfolio/coordinator.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/custom_components/saxo_portfolio/coordinator.py b/custom_components/saxo_portfolio/coordinator.py index 6112866..4777a13 100644 --- a/custom_components/saxo_portfolio/coordinator.py +++ b/custom_components/saxo_portfolio/coordinator.py @@ -379,7 +379,12 @@ 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%%, " From 3961c8ae116537debf196e46a698ec2ded7eb532 Mon Sep 17 00:00:00 2001 From: Steyn Huizinga Date: Tue, 4 Aug 2026 16:51:02 +0200 Subject: [PATCH 06/13] docs: Correct stale task cross-reference in YTD plan --- docs/superpowers/plans/2026-08-04-ytd-sensors.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-04-ytd-sensors.md b/docs/superpowers/plans/2026-08-04-ytd-sensors.md index 774cc11..9d44a98 100644 --- a/docs/superpowers/plans/2026-08-04-ytd-sensors.md +++ b/docs/superpowers/plans/2026-08-04-ytd-sensors.md @@ -129,9 +129,12 @@ 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 3 repoints its only consumer. +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`) From 0e34bbe1cdb89203c687de68ec701b681dd2e3f2 Mon Sep 17 00:00:00 2001 From: Steyn Huizinga Date: Tue, 4 Aug 2026 16:53:46 +0200 Subject: [PATCH 07/13] 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. --- .../saxo_portfolio/coordinator.py | 38 +++++++- tests/unit/test_coordinator.py | 91 +++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/custom_components/saxo_portfolio/coordinator.py b/custom_components/saxo_portfolio/coordinator.py index 4777a13..b36a777 100644 --- a/custom_components/saxo_portfolio/coordinator.py +++ b/custom_components/saxo_portfolio/coordinator.py @@ -401,11 +401,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", {}) @@ -428,6 +453,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( diff --git a/tests/unit/test_coordinator.py b/tests/unit/test_coordinator.py index a887661..00e45ef 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 From 9765e7187684cd04443b7ccfa24e0a6dd8a26f3a Mon Sep 17 00:00:00 2001 From: Steyn Huizinga Date: Tue, 4 Aug 2026 16:59:45 +0200 Subject: [PATCH 08/13] 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. --- .../saxo_portfolio/coordinator.py | 30 ++++++++++- tests/unit/test_coordinator.py | 54 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/custom_components/saxo_portfolio/coordinator.py b/custom_components/saxo_portfolio/coordinator.py index b36a777..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"), @@ -388,12 +390,12 @@ async def _fetch_performance_metrics( 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( @@ -1249,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/tests/unit/test_coordinator.py b/tests/unit/test_coordinator.py index 00e45ef..001137d 100644 --- a/tests/unit/test_coordinator.py +++ b/tests/unit/test_coordinator.py @@ -672,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 @@ -1869,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 # --------------------------------------------------------------------------- From 627ca9819964d35fb4c9b6e1da83586e6a1ad036 Mon Sep 17 00:00:00 2001 From: Steyn Huizinga Date: Tue, 4 Aug 2026 17:07:39 +0200 Subject: [PATCH 09/13] 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. --- README.md | 6 +- custom_components/saxo_portfolio/icons.json | 6 ++ custom_components/saxo_portfolio/sensor.py | 61 +++++++++++++++++++ custom_components/saxo_portfolio/strings.json | 6 ++ .../saxo_portfolio/translations/da.json | 6 ++ .../saxo_portfolio/translations/de.json | 6 ++ .../saxo_portfolio/translations/en.json | 6 ++ .../saxo_portfolio/translations/es.json | 6 ++ .../saxo_portfolio/translations/fi.json | 6 ++ .../saxo_portfolio/translations/fr.json | 6 ++ .../saxo_portfolio/translations/it.json | 6 ++ .../saxo_portfolio/translations/nb.json | 6 ++ .../saxo_portfolio/translations/nl.json | 6 ++ .../saxo_portfolio/translations/pt.json | 6 ++ .../saxo_portfolio/translations/sv.json | 6 ++ tests/integration/test_sensor_creation.py | 6 +- tests/unit/test_sensor_coverage.py | 51 +++++++++++++++- 17 files changed, 196 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 677558c..40cd816 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ The integration provides **nine comprehensive sensors** that automatically use y - **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`) - **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 @@ -133,6 +135,8 @@ The integration automatically creates **sixteen entities** using your Saxo Clien - `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_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/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..a7a1b7f 100644 --- a/custom_components/saxo_portfolio/sensor.py +++ b/custom_components/saxo_portfolio/sensor.py @@ -318,6 +318,8 @@ async def async_setup_entry( SaxoYTDInvestmentPerformanceSensor(coordinator), SaxoMonthInvestmentPerformanceSensor(coordinator), SaxoQuarterInvestmentPerformanceSensor(coordinator), + SaxoYTDProfitLossSensor(coordinator), + SaxoYTDCashTransferSensor(coordinator), # Diagnostic sensors SaxoClientIDSensor(coordinator), SaxoAccountIDSensor(coordinator), @@ -643,6 +645,65 @@ 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 + + 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/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_sensor_coverage.py b/tests/unit/test_sensor_coverage.py index d2246d8..f643c71 100644 --- a/tests/unit/test_sensor_coverage.py +++ b/tests/unit/test_sensor_coverage.py @@ -32,7 +32,9 @@ SaxoTimezoneSensor, SaxoTokenExpirySensor, SaxoTotalValueSensor, + SaxoYTDCashTransferSensor, SaxoYTDInvestmentPerformanceSensor, + SaxoYTDProfitLossSensor, _setup_position_listener, async_setup_entry, ) @@ -54,6 +56,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 +67,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 +115,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 +137,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 +782,42 @@ 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 From 2f7fd4f5256d6c7e870c70405d9b9c15c2ad0757 Mon Sep 17 00:00:00 2001 From: Steyn Huizinga Date: Tue, 4 Aug 2026 17:13:34 +0200 Subject: [PATCH 10/13] docs: Fix stale sensor-count references in README Three "nine sensors" mentions undercounted by two after the YTD profit/loss and YTD net transfers sensors were added, bringing the supported sensor total to eleven (3 balance + 8 performance/transfer). --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 40cd816..d4d2c52 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`) From 56313115ca4ef41529a4a08f2eb20442d8563e98 Mon Sep 17 00:00:00 2001 From: Steyn Huizinga Date: Tue, 4 Aug 2026 17:17:41 +0200 Subject: [PATCH 11/13] docs: Changelog for YTD sensors and the YTD window correction --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 From 5178f3a52866bfefcc71dd2ec3a95c6ee5321b2e Mon Sep 17 00:00:00 2001 From: Steyn Huizinga Date: Tue, 4 Aug 2026 17:34:15 +0200 Subject: [PATCH 12/13] fix: Anchor YTD cash transfer last_reset to 1 Jan; naive clock; README caveat Pre-merge review fixes: - SaxoYTDCashTransferSensor now exposes a last_reset property pinned to 1 January local midnight (via dt_util.now()/start_of_local_day), recomputed on every access. Without it, the annual reset of the Jan-1 anchored source window was being recorded as a permanent negative delta into the entity's long-term-statistics sum every 1 January. - _get_period_dates now uses dt_util.now() instead of the naive, process-local datetime.now(), matching the rest of the codebase and the CHANGELOG's claim that the from/thru attributes are correct. - README's Month/Quarter Investment Performance entries no longer assert a to-date window; both now note the trailing ~28/~90 day windows already documented as a known issue in CHANGELOG.md. --- README.md | 8 ++--- custom_components/saxo_portfolio/sensor.py | 19 ++++++++++-- tests/unit/test_sensor_coverage.py | 35 ++++++++++++++++++++++ 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d4d2c52..bea3c83 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ The integration provides **eleven comprehensive sensors** that automatically use - **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`) @@ -132,8 +132,8 @@ The integration automatically creates **eighteen entities** using your Saxo Clie - `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 diff --git a/custom_components/saxo_portfolio/sensor.py b/custom_components/saxo_portfolio/sensor.py index a7a1b7f..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() @@ -578,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 @@ -703,6 +702,20 @@ def available(self) -> bool: 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/tests/unit/test_sensor_coverage.py b/tests/unit/test_sensor_coverage.py index f643c71..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 ( @@ -821,3 +822,37 @@ def test_ytd_cash_transfer_unavailable_when_none(self, 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 From d4ad43afde566590b89a1b3d9b5be45ab3f1a7f1 Mon Sep 17 00:00:00 2001 From: Steyn Huizinga Date: Tue, 4 Aug 2026 17:55:42 +0200 Subject: [PATCH 13/13] build: Exclude docs/ from ruff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruff >=0.16 formats Python code blocks inside Markdown files. The design docs quote deliberately partial snippets — class methods shown without their class, fragments of larger literals — and formatting rewrites them into code that no longer matches the source they document. CI installs ruff unpinned, so this surfaced as a format-check failure without any source change. --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) 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 = [