Skip to content

Commit fd4b7bf

Browse files
authored
feat: Indicator of last update for each broker (#325)
* feat: Indicator of last update for each broker Until now only DEGIRO was showing an indicator of when was the last update. Now this indicator shows the information of all enabled brokers. The indicator has been also improved to show cleaner information * fix: Fixed sidebar horizontal separator
1 parent e15a740 commit fd4b7bf

File tree

14 files changed

+608
-72
lines changed

14 files changed

+608
-72
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ _This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) a
1313
### Changed
1414

1515
- Internal refactor to simplify the authentication processes across different brokers
16+
- Last update indicator now shows information for all the brokers
1617

1718
### Fixed
1819

src/stonks_overwatch/core/factories/broker_registry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
PortfolioServiceInterface,
2121
TransactionServiceInterface,
2222
)
23+
from stonks_overwatch.core.interfaces.update_service import AbstractUpdateService
2324
from stonks_overwatch.core.service_types import ServiceType
2425
from stonks_overwatch.utils.core.logger import StonksLogger
2526
from stonks_overwatch.utils.core.singleton import singleton
@@ -51,6 +52,7 @@ class BrokerRegistry:
5152
ServiceType.FEE: FeeServiceInterface,
5253
ServiceType.ACCOUNT: AccountServiceInterface,
5354
ServiceType.AUTHENTICATION: AuthenticationServiceInterface,
55+
ServiceType.UPDATE: AbstractUpdateService,
5456
}
5557

5658
def __init__(self):

src/stonks_overwatch/core/interfaces/update_service.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import os
22
import time as core_time
33
from abc import ABC, abstractmethod
4+
from datetime import datetime
45
from typing import Optional
56

67
from django.db.utils import OperationalError
8+
from django.utils import timezone
79

810
from stonks_overwatch.config.base_config import BaseConfig
911
from stonks_overwatch.constants import BrokerName
@@ -132,6 +134,45 @@ def _retry_database_operation(self, operation, *args, max_retries=3, delay=0.1,
132134

133135
raise last_exception
134136

137+
def _record_sync(self, success: bool = True) -> None:
138+
"""
139+
Record that a sync attempt completed for this broker.
140+
141+
Should be called at the end of update_all() to track the last time the broker
142+
data was refreshed from the external API, regardless of whether new data existed.
143+
144+
Args:
145+
success: True if the sync completed without errors, False otherwise
146+
"""
147+
from stonks_overwatch.core.models import BrokerSyncLog
148+
149+
try:
150+
BrokerSyncLog.objects.create(
151+
broker_name=self.broker_name,
152+
synced_at=timezone.now(),
153+
success=success,
154+
)
155+
except Exception as e:
156+
self.logger.error("Failed to record sync log for %s: %s", self.broker_name, str(e))
157+
158+
def get_last_sync(self) -> Optional[datetime]:
159+
"""
160+
Return the last time this broker was successfully synced with the external API.
161+
162+
Returns:
163+
Datetime of the last successful sync, or None if no sync has been recorded yet
164+
"""
165+
from stonks_overwatch.core.models import BrokerSyncLog
166+
167+
try:
168+
entry = (
169+
BrokerSyncLog.objects.filter(broker_name=self.broker_name, success=True).order_by("-synced_at").first()
170+
)
171+
return entry.synced_at if entry else None
172+
except Exception as e:
173+
self.logger.error("Failed to get last sync for %s: %s", self.broker_name, str(e))
174+
return None
175+
135176
@abstractmethod
136177
def update_all(self):
137178
pass

src/stonks_overwatch/core/models.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,32 @@
11
from django.db import models
2+
from django.utils import timezone
23

34
from stonks_overwatch.utils.core.logger import StonksLogger
45

56

7+
class BrokerSyncLog(models.Model):
8+
"""
9+
Tracks the last time each broker's data was successfully refreshed from the external API.
10+
11+
This provides accurate "last refreshed" timestamps independent of when the most
12+
recent transaction or business event occurred.
13+
"""
14+
15+
class Meta:
16+
db_table = "broker_sync_log"
17+
verbose_name = "Broker Sync Log"
18+
verbose_name_plural = "Broker Sync Logs"
19+
get_latest_by = "synced_at"
20+
21+
broker_name = models.CharField(max_length=50, db_index=True)
22+
synced_at = models.DateTimeField(default=timezone.now)
23+
success = models.BooleanField(default=True)
24+
25+
def __str__(self) -> str:
26+
status = "success" if self.success else "failed"
27+
return f"{self.broker_name}: {self.synced_at} ({status})"
28+
29+
630
class GlobalConfiguration(models.Model):
731
"""
832
Model for storing global application configuration settings.

src/stonks_overwatch/core/registry_setup.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from stonks_overwatch.services.brokers.bitvavo.services.transaction_service import (
4848
TransactionsService as BitvavoTransactionService,
4949
)
50+
from stonks_overwatch.services.brokers.bitvavo.services.update_service import UpdateService as BitvavoUpdateService
5051
from stonks_overwatch.services.brokers.degiro.services.account_service import (
5152
AccountOverviewService as DeGiroAccountService,
5253
)
@@ -64,6 +65,7 @@
6465
from stonks_overwatch.services.brokers.degiro.services.transaction_service import (
6566
TransactionsService as DeGiroTransactionService,
6667
)
68+
from stonks_overwatch.services.brokers.degiro.services.update_service import UpdateService as DeGiroUpdateService
6769
from stonks_overwatch.services.brokers.ibkr.services.account_overview import (
6870
AccountOverviewService as IbkrAccountOverviewService,
6971
)
@@ -86,6 +88,7 @@
8688
from stonks_overwatch.services.brokers.ibkr.services.transactions import (
8789
TransactionsService as IbkrTransactionService,
8890
)
91+
from stonks_overwatch.services.brokers.ibkr.services.update_service import UpdateService as IbkrUpdateService
8992

9093
# Configuration-driven broker definitions
9194
BROKER_CONFIGS: Dict[BrokerName, Dict[str, Any]] = {
@@ -99,6 +102,7 @@
99102
ServiceType.FEE: DeGiroFeeService,
100103
ServiceType.ACCOUNT: DeGiroAccountService,
101104
ServiceType.AUTHENTICATION: DegiroAuthenticationService,
105+
ServiceType.UPDATE: DeGiroUpdateService,
102106
},
103107
},
104108
BrokerName.BITVAVO: {
@@ -110,6 +114,7 @@
110114
ServiceType.FEE: BitvavoFeeService,
111115
ServiceType.ACCOUNT: BitvavoAccountService,
112116
ServiceType.AUTHENTICATION: BitvavoAuthenticationService,
117+
ServiceType.UPDATE: BitvavoUpdateService,
113118
},
114119
},
115120
BrokerName.IBKR: {
@@ -120,6 +125,7 @@
120125
ServiceType.DIVIDEND: IbkrDividendsService,
121126
ServiceType.ACCOUNT: IbkrAccountOverviewService,
122127
ServiceType.AUTHENTICATION: IbkrAuthenticationService,
128+
ServiceType.UPDATE: IbkrUpdateService,
123129
},
124130
},
125131
}

src/stonks_overwatch/core/service_types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ class ServiceType(Enum):
1818
FEE = "fee"
1919
ACCOUNT = "account"
2020
AUTHENTICATION = "authentication"
21+
UPDATE = "update"
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import django.db.models.deletion
2+
import django.utils.timezone
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("stonks_overwatch", "0009_globalconfiguration"),
9+
]
10+
11+
operations = [
12+
migrations.CreateModel(
13+
name="BrokerSyncLog",
14+
fields=[
15+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
16+
("broker_name", models.CharField(db_index=True, max_length=50)),
17+
("synced_at", models.DateTimeField(default=django.utils.timezone.now)),
18+
("success", models.BooleanField(default=True)),
19+
],
20+
options={
21+
"verbose_name": "Broker Sync Log",
22+
"verbose_name_plural": "Broker Sync Logs",
23+
"db_table": "broker_sync_log",
24+
"get_latest_by": "synced_at",
25+
},
26+
),
27+
]

src/stonks_overwatch/services/brokers/bitvavo/services/update_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,11 @@ def update_all(self):
5959
self.update_transactions()
6060
self.update_portfolio()
6161
self.update_assets()
62+
self._record_sync(success=True)
6263
except Exception as error:
6364
self.logger.error("Cannot Update Portfolio!")
6465
self.logger.error("Exception: %s", str(error), exc_info=True)
66+
self._record_sync(success=False)
6567

6668
def update_portfolio(self):
6769
self._log_message("Updating Portfolio Data....")

src/stonks_overwatch/services/brokers/degiro/services/update_service.py

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,6 @@
2626
DeGiroUpcomingPayments,
2727
)
2828
from stonks_overwatch.services.brokers.degiro.repositories.product_info_repository import ProductInfoRepository
29-
from stonks_overwatch.services.brokers.degiro.repositories.product_quotations_repository import (
30-
ProductQuotationsRepository,
31-
)
3229
from stonks_overwatch.services.brokers.degiro.repositories.transactions_repository import TransactionsRepository
3330
from stonks_overwatch.services.brokers.degiro.services.helper import is_non_tradeable_product
3431
from stonks_overwatch.services.brokers.degiro.services.portfolio_service import PortfolioService
@@ -89,20 +86,6 @@ def __init__(
8986
)
9087
self.yfinance_client = YFinanceClient()
9188

92-
def get_last_import(self) -> datetime:
93-
last_cash_movement = self._get_last_cash_movement_import()
94-
last_transaction = self._get_last_transactions_import()
95-
last_quotation = ProductQuotationsRepository.get_last_update()
96-
97-
# Filter out None values to handle cases where no data exists yet
98-
non_null_dates = [date for date in [last_cash_movement, last_transaction, last_quotation] if date is not None]
99-
100-
if not non_null_dates:
101-
# If no data exists yet, return a timezone-aware default date
102-
return django_timezone.now()
103-
104-
return max(non_null_dates)
105-
10689
def _get_last_cash_movement_import(self) -> datetime:
10790
last_movement = CashMovementsRepository.get_last_movement()
10891
return self._ensure_timezone_aware_datetime(last_movement)
@@ -154,9 +137,11 @@ def update_all(self):
154137
self.update_company_profile()
155138
self.update_yfinance()
156139
self.update_dividends()
140+
self._record_sync(success=True)
157141
except Exception as error:
158142
self.logger.error("Cannot Update Portfolio!")
159143
self.logger.error("Exception: %s", str(error), exc_info=True)
144+
self._record_sync(success=False)
160145

161146
def update_account(self):
162147
"""Update the Account DB data. Only does it if the data is older than today."""

src/stonks_overwatch/services/brokers/ibkr/services/update_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ def update_all(self):
5151
try:
5252
self.update_portfolio()
5353
self.update_transactions()
54+
self._record_sync(success=True)
5455
except Exception as error:
5556
self.logger.error("Cannot Update Portfolio!")
5657
self.logger.error("Exception: %s", str(error), exc_info=True)
58+
self._record_sync(success=False)
5759

5860
def update_portfolio(self):
5961
"""Updating the Portfolio is an expensive and time-consuming task.

0 commit comments

Comments
 (0)