Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`)
Expand All @@ -37,9 +37,11 @@ The integration provides **nine comprehensive sensors** that automatically use y
- **Accumulated Profit/Loss**: All-time performance tracking from Saxo's historical API (`sensor.saxo_{clientid}_accumulated_profit_loss`)
- **Investment Performance**: Overall portfolio return percentage (all-time) from performance timeseries (`sensor.saxo_{clientid}_investment_performance`)
- **YTD Investment Performance**: Year-to-Date portfolio return percentage (`sensor.saxo_{clientid}_ytd_investment_performance`)
- **Month Investment Performance**: Month-to-Date portfolio return percentage (`sensor.saxo_{clientid}_month_investment_performance`)
- **Quarter Investment Performance**: Quarter-to-Date portfolio return percentage (`sensor.saxo_{clientid}_quarter_investment_performance`)
- **Month Investment Performance**: Rolling ~28-day portfolio return percentage (despite the name, not aligned to the calendar month — see Known Issues in CHANGELOG.md) (`sensor.saxo_{clientid}_month_investment_performance`)
- **Quarter Investment Performance**: Rolling ~90-day portfolio return percentage (despite the name, not aligned to the calendar quarter — see Known Issues in CHANGELOG.md) (`sensor.saxo_{clientid}_quarter_investment_performance`)
- **Cash Transfer Balance**: Latest cash transfer value tracking deposits and withdrawals (`sensor.saxo_{clientid}_cash_transfer_balance`)
- **YTD Profit/Loss**: Year-to-Date profit/loss in your account currency (`sensor.saxo_{clientid}_ytd_profit_loss`)
- **YTD Net Transfers**: Year-to-Date net deposits and withdrawals (`sensor.saxo_{clientid}_ytd_cash_transfer`)

**Long-Term Statistics**: All performance sensors support Home Assistant's long-term statistics system with `state_class: measurement`, enabling:
- Historical data retention beyond the default 10-day recorder purge period
Expand Down Expand Up @@ -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
Expand All @@ -130,9 +132,11 @@ The integration automatically creates **sixteen entities** using your Saxo Clien
- `sensor.saxo_123456_accumulated_profit_loss` - All-time profit/loss performance
- `sensor.saxo_123456_investment_performance` - Overall portfolio return percentage (all-time)
- `sensor.saxo_123456_ytd_investment_performance` - Year-to-Date portfolio return percentage
- `sensor.saxo_123456_month_investment_performance` - Month-to-Date portfolio return percentage
- `sensor.saxo_123456_quarter_investment_performance` - Quarter-to-Date portfolio return percentage
- `sensor.saxo_123456_month_investment_performance` - Rolling ~28-day portfolio return percentage (not calendar month-to-date; see Known Issues in CHANGELOG.md)
- `sensor.saxo_123456_quarter_investment_performance` - Rolling ~90-day portfolio return percentage (not calendar quarter-to-date; see Known Issues in CHANGELOG.md)
- `sensor.saxo_123456_cash_transfer_balance` - Latest cash transfer balance
- `sensor.saxo_123456_ytd_profit_loss` - Year-to-Date profit/loss
- `sensor.saxo_123456_ytd_cash_transfer` - Year-to-Date net deposits and withdrawals

### Diagnostic Sensors (Example: Client ID "123456")
- `sensor.saxo_123456_client_id` - Saxo Client ID identifier for troubleshooting
Expand Down
243 changes: 55 additions & 188 deletions custom_components/saxo_portfolio/api/saxo_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -477,221 +487,78 @@ 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:
raise
except Exception as e:
_LOGGER.error(
"Error fetching performance v4 %s data: %s",
standard_period,
key,
type(e).__name__,
)
raise APIError(f"Failed to fetch performance v4 {standard_period} data")
raise APIError(f"Failed to fetch performance v4 {key} data")

return results

async def get_performance_v4(self, client_key: str) -> dict[str, Any]:
"""Get performance timeseries data from Saxo v4 performance API.

Args:
client_key: Client key for the request

Returns:
Performance timeseries data containing ReturnFraction and CashTransfer

Raises:
AuthenticationError: For authentication failures
APIError: For other API errors

"""
try:
params = {
"ClientKey": client_key,
"StandardPeriod": "AllTime",
"FieldGroups": "Balance_CashTransfer,KeyFigures",
}

response = await self._make_request(API_PERFORMANCE_V4_ENDPOINT, params)

# Validate response structure
if not isinstance(response, dict):
raise APIError("Invalid performance v4 response format")

_LOGGER.debug(
"Performance v4 API response structure: %s",
list(response.keys()) if response else "empty",
)

return response

except AuthenticationError, RateLimitError:
raise
except Exception as e:
_LOGGER.error("Error fetching performance v4 data: %s", type(e).__name__)
raise APIError("Failed to fetch performance v4 data")

async def get_performance_v4_ytd(self, client_key: str) -> dict[str, Any]:
"""Fetch YTD performance timeseries data using v4 API.

Args:
client_key: Client key for the request

Returns:
YTD Performance timeseries data containing ReturnFraction and CashTransfer

Raises:
AuthenticationError: For authentication failures
APIError: For other API errors

"""
try:
params = {
"ClientKey": client_key,
"StandardPeriod": "Year",
"FieldGroups": "Balance_CashTransfer,KeyFigures",
}

response = await self._make_request(API_PERFORMANCE_V4_ENDPOINT, params)

# Validate response structure
if not isinstance(response, dict):
raise APIError("Invalid performance v4 YTD response format")

_LOGGER.debug(
"Performance v4 YTD API response structure: %s",
list(response.keys()) if response else "empty",
)

return response

except AuthenticationError, RateLimitError:
raise
except Exception as e:
_LOGGER.error(
"Error fetching performance v4 YTD data: %s", type(e).__name__
)
raise APIError("Failed to fetch performance v4 YTD data")

async def get_performance_v4_month(self, client_key: str) -> dict[str, Any]:
"""Fetch Month performance timeseries data using v4 API.

Args:
client_key: Client key for the request

Returns:
Month Performance timeseries data containing ReturnFraction and CashTransfer

Raises:
AuthenticationError: For authentication failures
APIError: For other API errors

"""
try:
params = {
"ClientKey": client_key,
"StandardPeriod": "Month",
"FieldGroups": "Balance_CashTransfer,KeyFigures",
}

response = await self._make_request(API_PERFORMANCE_V4_ENDPOINT, params)

# Validate response structure
if not isinstance(response, dict):
raise APIError("Invalid performance v4 Month response format")

_LOGGER.debug(
"Performance v4 Month API response structure: %s",
list(response.keys()) if response else "empty",
)

return response

except AuthenticationError, RateLimitError:
raise
except Exception as e:
_LOGGER.error(
"Error fetching performance v4 Month data: %s", type(e).__name__
)
raise APIError("Failed to fetch performance v4 Month data")

async def get_performance_v4_quarter(self, client_key: str) -> dict[str, Any]:
"""Fetch Quarter performance timeseries data using v4 API.

Args:
client_key: Client key for the request

Returns:
Quarter Performance timeseries data containing ReturnFraction and CashTransfer

Raises:
AuthenticationError: For authentication failures
APIError: For other API errors

"""
try:
params = {
"ClientKey": client_key,
"StandardPeriod": "Quarter",
"FieldGroups": "Balance_CashTransfer,KeyFigures",
}

response = await self._make_request(API_PERFORMANCE_V4_ENDPOINT, params)

# Validate response structure
if not isinstance(response, dict):
raise APIError("Invalid performance v4 Quarter response format")

_LOGGER.debug(
"Performance v4 Quarter API response structure: %s",
list(response.keys()) if response else "empty",
)

return response

except AuthenticationError, RateLimitError:
raise
except Exception as e:
_LOGGER.error(
"Error fetching performance v4 Quarter data: %s", type(e).__name__
)
raise APIError("Failed to fetch performance v4 Quarter data")

async def get_net_positions(self) -> dict[str, Any]:
"""Get net positions from Saxo API.

Expand Down
Loading
Loading