Skip to content

Commit d110b8e

Browse files
authored
feat: Introduce capability-driven architecture by removing placeholder broker services and simplifying registration. (#296)
This allows that when running only with brokers like 'Bitvavo', the features that are not supported, like 'Dividends' are hidden.
1 parent e66563c commit d110b8e

File tree

18 files changed

+162
-214
lines changed

18 files changed

+162
-214
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ _This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) a
1919
### Changed
2020

2121
- **Configuration:** Different UI/UX improvements
22+
- **Brokers:** Hidden not supported broker capabilities
23+
- When a broker does not support a certain feature, it will not be shown in the UI
2224

2325
### Fixed
2426

docs/ARCHITECTURE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@ class BrokerRegistry:
8989
- Dynamic broker registration
9090
- Configuration validation on startup
9191

92+
### Capability-Driven Architecture
93+
94+
The system uses a capability-based discovery mechanism. Brokers are not required to implement all service interfaces; they only implement those that they actually support.
95+
96+
**Key Features**:
97+
- **Dynamic Discovery**: The `BrokerRegistry` tracks which services each broker provides.
98+
- **Graceful Adaptation**: Aggregator services automatically skip brokers that don't support a requested feature.
99+
- **Adaptive UI**: The frontend dynamically hides navigation links (e.g., "Dividends", "Fees") if the currently selected portfolio doesn't support them.
100+
- **Lean Implementation**: Reduces boilerplate by eliminating "hollow" services that only return empty data.
101+
92102
### Exception Management
93103

94104
Professional exception hierarchy with structured error handling:

docs/ARCHITECTURE_BROKERS.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,12 +346,22 @@ BROKER_CONFIGS = {
346346
# ServiceType.DIVIDEND: NewBrokerDividendService,
347347
# ServiceType.FEE: NewBrokerFeeService,
348348
},
349-
"supports_complete_registration": True, # Set to False if missing required services
350349
},
351350
}
352351
```
353352

354-
### 5. Add Credential Support (Optional)
353+
### 5. Dynamic Capabilities Support
354+
355+
Stonks Overwatch uses a capability-based architecture. You only need to register the services that your broker actually supports.
356+
357+
- **Required**: `ServiceType.PORTFOLIO` is the only strictly required service.
358+
- **Optional**: `TRANSACTION`, `ACCOUNT`, `DEPOSIT`, `DIVIDEND`, `FEE`, and `AUTHENTICATION` are optional.
359+
360+
The application automatically handles missing services:
361+
1. **Backend**: Aggregator services skip brokers that don't provide the requested service.
362+
2. **Frontend**: The sidebar navigation dynamically hides links (e.g., "Dividends", "Fees") if the currently selected portfolio doesn't support them.
363+
364+
### 6. Add Credential Support (Optional)
355365

356366
If your broker needs credential update functionality, add it to the BrokerFactory mapping:
357367

src/stonks_overwatch/config/config.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from stonks_overwatch.config.base_config import BaseConfig
66
from stonks_overwatch.constants import BrokerName
77
from stonks_overwatch.core.factories.broker_factory import BrokerFactory
8+
from stonks_overwatch.core.service_types import ServiceType
89
from stonks_overwatch.services.models import PortfolioId
910
from stonks_overwatch.utils.core.logger import StonksLogger
1011
from stonks_overwatch.utils.core.logger_constants import LOGGER_CONFIG, TAG_CONFIG
@@ -81,6 +82,28 @@ def _is_broker_enabled(self, broker_name: BrokerName) -> bool:
8182
config = self.get_broker_config(broker_name)
8283
return config.is_enabled() if config else False
8384

85+
def get_capabilities(self, portfolio_id: PortfolioId) -> list[ServiceType]:
86+
"""
87+
Get the capabilities (service types) for a portfolio.
88+
89+
Args:
90+
portfolio_id: The portfolio to get capabilities for
91+
92+
Returns:
93+
List of supported ServiceType enums
94+
"""
95+
if portfolio_id == PortfolioId.ALL:
96+
capabilities = set()
97+
for broker_name in self._factory.get_available_brokers():
98+
if self._is_broker_enabled(broker_name):
99+
capabilities.update(self._factory.get_broker_capabilities(broker_name))
100+
return sorted(capabilities, key=lambda x: x.value)
101+
102+
if portfolio_id.broker_name:
103+
return self._factory.get_broker_capabilities(portfolio_id.broker_name)
104+
105+
return []
106+
84107
def __eq__(self, value: object) -> bool:
85108
if isinstance(value, Config):
86109
if self.base_currency != value.base_currency:

src/stonks_overwatch/core/aggregators/base_aggregator.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,10 @@ def _collect_broker_data(self, selected_portfolio: PortfolioId, method_name: str
172172
"""
173173
enabled_brokers = self._get_enabled_brokers(selected_portfolio)
174174
if not enabled_brokers:
175-
raise DataAggregationException("Failed to find any enabled broker")
175+
self._logger.debug(
176+
f"No brokers enabled for service {self._service_type.value} with selection {selected_portfolio}"
177+
)
178+
return {}
176179

177180
broker_data = {}
178181
broker_errors = {}

src/stonks_overwatch/core/registry_setup.py

Lines changed: 16 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,6 @@
4040
BitvavoAuthenticationService = None
4141

4242
from stonks_overwatch.services.brokers.bitvavo.services.deposit_service import DepositsService as BitvavoDepositService
43-
from stonks_overwatch.services.brokers.bitvavo.services.dividends_service import (
44-
DividendsService as BitvavoDividendsService,
45-
)
4643
from stonks_overwatch.services.brokers.bitvavo.services.fee_service import FeeService as BitvavoFeeService
4744
from stonks_overwatch.services.brokers.bitvavo.services.portfolio_service import (
4845
PortfolioService as BitvavoPortfolioService,
@@ -80,11 +77,9 @@
8077
logger.warning(f"Could not import IbkrAuthenticationService: {e}")
8178
IbkrAuthenticationService = None
8279

83-
from stonks_overwatch.services.brokers.ibkr.services.deposit_service import DepositsService as IbkrDepositService
8480
from stonks_overwatch.services.brokers.ibkr.services.dividends import (
8581
DividendsService as IbkrDividendsService,
8682
)
87-
from stonks_overwatch.services.brokers.ibkr.services.fee_service import FeeService as IbkrFeeService
8883
from stonks_overwatch.services.brokers.ibkr.services.portfolio import (
8984
PortfolioService as IbkrPortfolioService,
9085
)
@@ -105,33 +100,27 @@
105100
ServiceType.ACCOUNT: DeGiroAccountService,
106101
ServiceType.AUTHENTICATION: DegiroAuthenticationService,
107102
},
108-
"supports_complete_registration": True,
109103
},
110104
BrokerName.BITVAVO: {
111105
"config": BitvavoConfig,
112106
"services": {
113107
ServiceType.PORTFOLIO: BitvavoPortfolioService,
114108
ServiceType.TRANSACTION: BitvavoTransactionService,
115109
ServiceType.DEPOSIT: BitvavoDepositService,
116-
ServiceType.DIVIDEND: BitvavoDividendsService,
117110
ServiceType.FEE: BitvavoFeeService,
118111
ServiceType.ACCOUNT: BitvavoAccountService,
119112
ServiceType.AUTHENTICATION: BitvavoAuthenticationService,
120113
},
121-
"supports_complete_registration": True,
122114
},
123115
BrokerName.IBKR: {
124116
"config": IbkrConfig,
125117
"services": {
126118
ServiceType.PORTFOLIO: IbkrPortfolioService,
127119
ServiceType.TRANSACTION: IbkrTransactionService,
128-
ServiceType.DEPOSIT: IbkrDepositService,
129120
ServiceType.DIVIDEND: IbkrDividendsService,
130-
ServiceType.FEE: IbkrFeeService,
131121
ServiceType.ACCOUNT: IbkrAccountOverviewService,
132122
ServiceType.AUTHENTICATION: IbkrAuthenticationService,
133123
},
134-
"supports_complete_registration": True, # Now supports all required services
135124
},
136125
}
137126

@@ -177,24 +166,15 @@ def register_all_brokers() -> None:
177166
try:
178167
config_class = broker_config["config"]
179168
services = broker_config["services"]
180-
supports_complete = broker_config.get("supports_complete_registration", False)
181-
182-
if supports_complete:
183-
# Use complete registration for brokers with all required services
184-
registry.register_complete_broker(
185-
broker_name,
186-
config_class,
187-
**{service_type.value: service_class for service_type, service_class in services.items()},
188-
)
189-
logger.debug(f"Registered {broker_name} broker using complete registration")
190-
else:
191-
# Use separate registration for brokers missing required services
192-
registry.register_broker_config(broker_name, config_class)
193-
registry.register_broker_services(
194-
broker_name,
195-
**{service_type.value: service_class for service_type, service_class in services.items()},
196-
)
197-
logger.debug(f"Registered {broker_name} broker using separate registration")
169+
170+
# Register configuration and services together
171+
# This is cleaner and includes automatic rollback on failure
172+
registry.register_complete_broker(
173+
broker_name,
174+
config_class,
175+
**{service_type.value: service_class for service_type, service_class in services.items()},
176+
)
177+
logger.debug(f"Registered {broker_name} broker successfully")
198178

199179
successfully_registered.append(broker_name)
200180

@@ -256,22 +236,13 @@ def ensure_registry_initialized() -> None:
256236
try:
257237
config_class = broker_config["config"]
258238
services = broker_config["services"]
259-
supports_complete = broker_config.get("supports_complete_registration", False)
260-
261-
if supports_complete:
262-
registry.register_complete_broker(
263-
broker_name,
264-
config_class,
265-
**{service_type.value: service_class for service_type, service_class in services.items()},
266-
)
267-
logger.debug(f"Registered {broker_name} broker successfully")
268-
else:
269-
registry.register_broker_config(broker_name, config_class)
270-
registry.register_broker_services(
271-
broker_name,
272-
**{service_type.value: service_class for service_type, service_class in services.items()},
273-
)
274-
logger.debug(f"Registered {broker_name} broker successfully (separate registration)")
239+
240+
registry.register_complete_broker(
241+
broker_name,
242+
config_class,
243+
**{service_type.value: service_class for service_type, service_class in services.items()},
244+
)
245+
logger.debug(f"Registered {broker_name} broker successfully")
275246

276247
except Exception as e:
277248
logger.warning(f"Failed to register {broker_name}: {e}")

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

Lines changed: 0 additions & 20 deletions
This file was deleted.

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

Lines changed: 0 additions & 73 deletions
This file was deleted.

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

Lines changed: 0 additions & 59 deletions
This file was deleted.

0 commit comments

Comments
 (0)