Skip to content

feat: Add YTD profit/loss and net transfers sensors; correct YTD window - #12

Merged
steynovich merged 13 commits into
mainfrom
feat/ytd-sensors
Aug 4, 2026
Merged

steynovich merged 13 commits into
mainfrom
feat/ytd-sensors

Conversation

@steynovich

Copy link
Copy Markdown
Owner

Summary

Adds two currency-denominated year-to-date sensors, and corrects the existing YTD percentage sensor, which was reporting a trailing 12-month figure.

While investigating which YTD datapoints Saxo exposes, probing two live accounts turned up a defect: StandardPeriod=Year is a trailing 12-month window, not year-to-date.

Period Window returned (probed 2026-08-04) Span Anchored to period start?
Month 2026-07-06 .. 2026-08-03 28d No
Quarter 2026-05-05 .. 2026-08-03 90d No
Year 2025-08-04 .. 2026-08-03 364d No

So ytd_investment_performance has been reporting rolling-1-year performance:

Before (trailing 12m) After (true YTD)
Account 1 17.83% 9.32%
Account 2 29.38% 20.27%

Changes

Added

  • sensor.saxo_{clientid}_ytd_profit_loss — YTD profit/loss in account currency
  • sensor.saxo_{clientid}_ytd_cash_transfer — YTD net deposits and withdrawals

Fixed

  • ytd_investment_performance now uses an explicit FromDate/ToDate window anchored to 1 January. The API rejects FromDate without ToDate, so both are sent.
  • _get_period_dates used naive datetime.now() instead of dt_util.now()

Changed

  • The trailing Year request is dropped; the January-anchored request takes its slot, so the refresh stays at four API calls
  • Month/Quarter requests trimmed to the KeyFigures field group
  • Removed unused get_performance_v4, get_performance_v4_ytd, get_performance_v4_month, get_performance_v4_quarter (referenced only by their own tests)

Breaking change

The YTD percentage sensor's value changes on upgrade (17.83% → 9.32% on a test account). Its entity_id is unchanged, so dashboards and automations keep working, but long-term statistics recorded before the upgrade are trailing-12-month figures — historical graphs will show a discontinuity at the upgrade point. Repointing in place was chosen deliberately over renaming, to avoid breaking existing dashboards.

Design notes

  • None, not 0.0, when data is missing. The two new sensors go unavailable rather than reporting zero — on a currency sensor, 0.0 reads as "you earned nothing this year" rather than "no data". This deliberately diverges from the surrounding metrics' 0.0 defaults.
  • last_reset on ytd_cash_transfer. It inherits state_class="total" + device_class=MONETARY, but unlike its all-time sibling this metric zeroes every 1 January. Without last_reset the recorder would log that drop as a large negative delta into the entity's cumulative statistics, permanently. Implemented as a property so it re-anchors at the year boundary without a restart.

Parsing rules (verified against live responses)

  • Balance.YearlyProfitLoss returns per-calendar-year buckets, values per-year and not cumulative — the current-year bucket is selected by matching the year, not by assuming a single element
  • Balance.CashTransfer is cumulative within the requested window and starts at zero, so the last numeric value is the net transferred

Cross-window consistency checks came back exact (1.000000) on both accounts for the current-year bucket and for YTD net transfers computed two independent ways.

Known issue (pre-existing, not addressed here)

Month and Quarter sensors are trailing windows too (rolling ~28 and ~90 days), so they are not month-to-date and quarter-to-date and their from/thru attributes are inaccurate. Documented under Known Issues in the CHANGELOG and caveated in the README; correcting them needs two more API calls and was deferred.

Testing

  • 636 passing (up from 634; net of 21 tests removed with the dead helpers and 23 added)
  • ruff check clean, ruff format --check clean, mypy --strict clean on 11 source files
  • Each of the 6 implementation tasks was independently reviewed, plus a whole-branch review before merge

Not yet verified against the Saxo web platform — the design rests on ratio evidence, since the probe deliberately never printed balances. Worth confirming the three YTD values after deploy.

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.
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.
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.
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.
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.
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.
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.
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.
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).
…E 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.
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.
@steynovich
steynovich merged commit 66b3807 into main Aug 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant