From b9b78252425759f93429dfc044999764c14fc817 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:18:24 +0800 Subject: [PATCH] refine runtime port adapters --- docs/platform_repo_boundaries.md | 78 ++++++++++++++++++- .../common/execution_translation.py | 10 ++- .../common/port_adapters.py | 46 +++++++++++ .../common/runtime_inputs.py | 32 +++++++- tests/test_common_port_adapters.py | 63 +++++++++++++++ tests/test_strategy_contracts.py | 15 +++- 6 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 src/quant_platform_kit/common/port_adapters.py create mode 100644 tests/test_common_port_adapters.py diff --git a/docs/platform_repo_boundaries.md b/docs/platform_repo_boundaries.md index ce9ee92..5b52fe4 100644 --- a/docs/platform_repo_boundaries.md +++ b/docs/platform_repo_boundaries.md @@ -59,6 +59,75 @@ They should own: - account or region selection - current platform-specific strategy implementations +Inside a platform runtime repository, prefer these local boundaries before +considering any shared-library extraction: + +- entrypoint / request handler +- cycle orchestrator (`rebalance_service.py`) +- execution service (`execution_service.py`) +- notification renderer / publisher + +Prefer wiring these boundaries through small dependency bundles such as +`RebalanceRuntime` and `RebalanceConfig` instead of passing a +long flat list of callables into the orchestrator. + +When a runtime already has a controlled cutover window, prefer removing the old +flat callable entrypoint entirely instead of carrying both shapes in parallel. +Keeping `runtime/config` and legacy one-off call signatures alive at the same +time usually leaks compatibility branches back into execution and notification +code. + +When a dependency already matches a shared interface, entrypoints should adapt +it to the shared port first, for example: + +- `MarketDataPort` +- `NotificationPort` +- `PortfolioPort` +- `ExecutionPort` + +`QuantPlatformKit.common.port_adapters` exists for this lightweight binding +layer. Quote loaders, history fetchers, and broker-specific notification +senders should usually be wrapped at the entrypoint and then passed inward as +ports. Keep broker-specific closures local, but keep the orchestrator surface +small and explicit. + +For account reads, prefer normalizing broker-native payloads into +`PortfolioSnapshot` at the entrypoint or adapter edge. If a strategy contract +still needs `account_state`, derive it locally from the snapshot instead of +letting raw broker account dictionaries flow through the orchestrator. + +For order submission, prefer adapting broker submitters to `ExecutionPort`. +If one broker still needs post-submit polling or alert fan-out, keep that as a +small edge callback or observer near the adapter instead of pushing broker +order-monitoring details back into the execution orchestrator. +Polling code should emit structured order lifecycle events, with rendering and +Telegram delivery handled by the notification publisher side. +When one platform has several related edge callbacks, prefer grouping them in a +small local adapter-builder module instead of scattering helper closures across +`main.py`. +The same rule applies to broker adapter glue such as market-data normalization, +portfolio snapshot loading, and execution-port binding: keep those builders in +one local adapter module instead of mixing them into runtime control flow. +Token refresh, broker login/context creation, and initial indicator bootstrap +should follow the same pattern: keep them in a local runtime bootstrap builder +instead of embedding that startup sequence directly in `main.py`. +Structured runtime logging, report construction, and report persistence should +also be grouped in a local reporting builder so the entrypoint keeps only the +run control flow instead of the logging/report transport details. +Strategy-side input assembly, benchmark/history selection, and decision-to-plan +mapping should likewise live in a local strategy adapter builder instead of +being mixed into the entrypoint's runtime wiring. +When several local builders already exist, it is reasonable to add one thin +runtime composer that assembles them into the broker runtime/config objects, so +`main.py` keeps only environment loading and request/run control flow. +If tests or local tooling still patch a few top-level helpers in `main.py`, +keep those helpers as thin delegators into the local builders or composer +rather than letting orchestration logic drift back into the entrypoint. + +This keeps runtime-specific sequencing in the deployment repo without forcing +order-routing, notification formatting, and HTTP/report assembly into the same +module. + They should **not** try to become: - a giant shared package for every broker @@ -105,9 +174,12 @@ This is acceptable because each platform still has different runtime constraints Do **not** try to prematurely centralize: - all runtime env parsing -- all notification wording - all strategy execution entrypoints +Notification delivery and renderer extraction inside one platform repo is still +worth doing. The warning here is specifically about forcing all brokers to share +one wording/template layer before their execution payloads have converged. + That kind of refactor usually makes the code harder to read before there is enough real sharing to justify it. ## Practical rule of thumb @@ -123,6 +195,10 @@ If a piece of code answers: - **what is reusable strategy logic independent of one platform's runtime wiring?** - that is a future strategy-repository candidate +- **how should this platform publish logs / Telegram / runtime reports for one cycle?** + - keep the transport and final wording in the platform repo, but feed it with + structured strategy diagnostics instead of parsing human-readable strings + ## Current recommended next step Do **not** start with a large strategy split. diff --git a/src/quant_platform_kit/common/execution_translation.py b/src/quant_platform_kit/common/execution_translation.py index ab5fc29..a74b330 100644 --- a/src/quant_platform_kit/common/execution_translation.py +++ b/src/quant_platform_kit/common/execution_translation.py @@ -32,6 +32,14 @@ def build_value_target_portfolio_inputs_from_snapshot( include_sellable_quantities: bool = False, liquid_cash: float | None = None, ) -> ValueTargetPortfolioInputs: + metadata = getattr(snapshot, "metadata", {}) or {} + raw_sellable_quantities = metadata.get("sellable_quantities") if isinstance(metadata, Mapping) else None + resolved_sellable_quantities: dict[str, int] = {} + if isinstance(raw_sellable_quantities, Mapping): + resolved_sellable_quantities = { + str(symbol): int(quantity) + for symbol, quantity in raw_sellable_quantities.items() + } market_values: dict[str, float] = {} quantities: dict[str, int] = {} sellable_quantities: dict[str, int] | None = ( @@ -43,7 +51,7 @@ def build_value_target_portfolio_inputs_from_snapshot( market_values[symbol] = float(position.market_value) quantities[symbol] = quantity if sellable_quantities is not None: - sellable_quantities[symbol] = quantity + sellable_quantities[symbol] = int(resolved_sellable_quantities.get(symbol, quantity)) resolved_liquid_cash = liquid_cash if resolved_liquid_cash is None: diff --git a/src/quant_platform_kit/common/port_adapters.py b/src/quant_platform_kit/common/port_adapters.py new file mode 100644 index 0000000..c5d2eac --- /dev/null +++ b/src/quant_platform_kit/common/port_adapters.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from .models import ExecutionReport, OrderIntent, PortfolioSnapshot, PriceSeries, QuoteSnapshot +from .ports import ExecutionPort, MarketDataPort, NotificationPort, PortfolioPort + + +@dataclass(frozen=True) +class CallableNotificationPort(NotificationPort): + sender: Callable[[str], None] + + def send_text(self, message: str) -> None: + self.sender(message) + + +@dataclass(frozen=True) +class CallablePortfolioPort(PortfolioPort): + loader: Callable[[], PortfolioSnapshot] + + def get_portfolio_snapshot(self) -> PortfolioSnapshot: + return self.loader() + + +@dataclass(frozen=True) +class CallableExecutionPort(ExecutionPort): + submitter: Callable[[OrderIntent], ExecutionReport] + + def submit_order(self, order: OrderIntent) -> ExecutionReport: + return self.submitter(order) + + +@dataclass(frozen=True) +class CallableMarketDataPort(MarketDataPort): + quote_loader: Callable[[str], QuoteSnapshot] + price_series_loader: Callable[[str], PriceSeries] | None = None + + def get_price_series(self, symbol: str, *, start=None, end=None) -> PriceSeries: + del start, end + if self.price_series_loader is None: + raise NotImplementedError("This CallableMarketDataPort does not provide historical price series.") + return self.price_series_loader(symbol) + + def get_quote(self, symbol: str) -> QuoteSnapshot: + return self.quote_loader(symbol) diff --git a/src/quant_platform_kit/common/runtime_inputs.py b/src/quant_platform_kit/common/runtime_inputs.py index 3054e05..87b55dd 100644 --- a/src/quant_platform_kit/common/runtime_inputs.py +++ b/src/quant_platform_kit/common/runtime_inputs.py @@ -21,6 +21,15 @@ def build_account_state_from_portfolio_snapshot( strategy_symbols: Iterable[str] = (), liquid_cash: float | None = None, ) -> dict[str, Any]: + metadata = getattr(snapshot, "metadata", {}) or {} + raw_sellable_quantities = metadata.get("sellable_quantities") if isinstance(metadata, Mapping) else None + resolved_sellable_quantities: dict[str, int] = {} + if isinstance(raw_sellable_quantities, Mapping): + resolved_sellable_quantities = { + str(symbol).strip().upper(): int(quantity) + for symbol, quantity in raw_sellable_quantities.items() + if str(symbol).strip() + } normalized_symbols = _normalize_symbols(strategy_symbols) filter_enabled = bool(normalized_symbols) @@ -44,12 +53,11 @@ def build_account_state_from_portfolio_snapshot( quantity = int(position.quantity) quantities[symbol] = quantity - sellable_quantities[symbol] = quantity + sellable_quantities[symbol] = int(resolved_sellable_quantities.get(symbol, quantity)) market_values[symbol] = float(position.market_value) resolved_liquid_cash = liquid_cash if resolved_liquid_cash is None: - metadata = getattr(snapshot, "metadata", {}) or {} resolved_liquid_cash = metadata.get("cash_available_for_trading") if resolved_liquid_cash is None: resolved_liquid_cash = getattr(snapshot, "buying_power", None) @@ -58,13 +66,21 @@ def build_account_state_from_portfolio_snapshot( if resolved_liquid_cash is None: resolved_liquid_cash = 0.0 - return { + account_state = { "available_cash": float(resolved_liquid_cash), "market_values": market_values, "quantities": quantities, "sellable_quantities": sellable_quantities, "total_strategy_equity": float(snapshot.total_equity), } + raw_cash_by_currency = metadata.get("cash_by_currency") if isinstance(metadata, Mapping) else None + if isinstance(raw_cash_by_currency, Mapping): + account_state["cash_by_currency"] = { + str(currency).strip().upper(): float(amount) + for currency, amount in raw_cash_by_currency.items() + if str(currency).strip() + } + return account_state def build_portfolio_snapshot_from_account_state( @@ -97,6 +113,7 @@ def build_portfolio_snapshot_from_account_state( snapshot_metadata = dict(metadata or {}) if normalized_symbols: snapshot_metadata.setdefault("strategy_symbols", normalized_symbols) + snapshot_metadata.setdefault("cash_available_for_trading", available_cash) raw_cash_by_currency = account_state.get("cash_by_currency") if isinstance(raw_cash_by_currency, Mapping): cash_by_currency = { @@ -106,6 +123,15 @@ def build_portfolio_snapshot_from_account_state( } if cash_by_currency: snapshot_metadata.setdefault("cash_by_currency", cash_by_currency) + raw_sellable_quantities = account_state.get("sellable_quantities") + if isinstance(raw_sellable_quantities, Mapping): + sellable_quantities = { + str(symbol).strip().upper(): int(quantity) + for symbol, quantity in raw_sellable_quantities.items() + if str(symbol).strip() + } + if sellable_quantities: + snapshot_metadata.setdefault("sellable_quantities", sellable_quantities) return PortfolioSnapshot( as_of=as_of or datetime.now(timezone.utc), total_equity=float(account_state["total_strategy_equity"]), diff --git a/tests/test_common_port_adapters.py b/tests/test_common_port_adapters.py new file mode 100644 index 0000000..972a65e --- /dev/null +++ b/tests/test_common_port_adapters.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import unittest + +from quant_platform_kit.common.models import PricePoint, PriceSeries, QuoteSnapshot +from quant_platform_kit.common.port_adapters import CallableMarketDataPort + + +class CallableMarketDataPortTests(unittest.TestCase): + def test_get_quote_delegates_to_loader(self) -> None: + port = CallableMarketDataPort( + quote_loader=lambda symbol: QuoteSnapshot( + symbol=symbol, + as_of=datetime(2026, 4, 21, tzinfo=timezone.utc), + last_price=123.45, + ) + ) + + snapshot = port.get_quote("SOXL") + + self.assertEqual(snapshot.symbol, "SOXL") + self.assertEqual(snapshot.last_price, 123.45) + + def test_get_price_series_delegates_when_loader_is_available(self) -> None: + expected = PriceSeries( + symbol="SOXX", + currency="USD", + points=( + PricePoint( + as_of=datetime(2026, 4, 21, tzinfo=timezone.utc), + close=200.0, + ), + ), + ) + port = CallableMarketDataPort( + quote_loader=lambda symbol: QuoteSnapshot( + symbol=symbol, + as_of=datetime(2026, 4, 21, tzinfo=timezone.utc), + last_price=123.45, + ), + price_series_loader=lambda symbol: expected, + ) + + series = port.get_price_series("SOXX") + + self.assertEqual(series, expected) + + def test_get_price_series_raises_when_not_configured(self) -> None: + port = CallableMarketDataPort( + quote_loader=lambda symbol: QuoteSnapshot( + symbol=symbol, + as_of=datetime(2026, 4, 21, tzinfo=timezone.utc), + last_price=123.45, + ) + ) + + with self.assertRaises(NotImplementedError): + port.get_price_series("SOXL") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_strategy_contracts.py b/tests/test_strategy_contracts.py index 2054d5f..ac69092 100644 --- a/tests/test_strategy_contracts.py +++ b/tests/test_strategy_contracts.py @@ -311,7 +311,11 @@ def test_build_account_state_from_portfolio_snapshot_filters_strategy_symbols(se Position(symbol="BOXX", quantity=10, market_value=5000.0), Position(symbol="QQQ", quantity=99, market_value=9999.0), ), - metadata={"cash_available_for_trading": 8000.0}, + metadata={ + "cash_available_for_trading": 8000.0, + "cash_by_currency": {"usd": 8000.0, "sgd": 350.0}, + "sellable_quantities": {"TQQQ": 3, "BOXX": 10}, + }, ) account_state = build_account_state_from_portfolio_snapshot( @@ -330,9 +334,10 @@ def test_build_account_state_from_portfolio_snapshot_filters_strategy_symbols(se ) self.assertEqual( account_state["sellable_quantities"], - {"TQQQ": 5, "BOXX": 10, "QQQI": 0}, + {"TQQQ": 3, "BOXX": 10, "QQQI": 0}, ) self.assertEqual(account_state["total_strategy_equity"], 50000.0) + self.assertEqual(account_state["cash_by_currency"], {"USD": 8000.0, "SGD": 350.0}) def test_build_portfolio_snapshot_from_account_state_keeps_strategy_symbol_order(self) -> None: snapshot = build_portfolio_snapshot_from_account_state( @@ -341,6 +346,7 @@ def test_build_portfolio_snapshot_from_account_state_keeps_strategy_symbol_order "cash_by_currency": {"usd": 1500.0, "sgd": 350.0}, "market_values": {"QQQI": 300.0, "TQQQ": 1200.0, "QQQ": 8000.0}, "quantities": {"QQQI": 10, "TQQQ": 3, "QQQ": 99}, + "sellable_quantities": {"QQQI": 8, "TQQQ": 2, "QQQ": 99}, "total_strategy_equity": 3000.0, }, strategy_symbols=("TQQQ", "QQQI", "BOXX"), @@ -353,7 +359,9 @@ def test_build_portfolio_snapshot_from_account_state_keeps_strategy_symbol_order self.assertEqual([position.symbol for position in snapshot.positions], ["TQQQ", "QQQI"]) self.assertEqual(snapshot.metadata["account_hash"], "acct-001") self.assertEqual(snapshot.metadata["strategy_symbols"], ("TQQQ", "QQQI", "BOXX")) + self.assertEqual(snapshot.metadata["cash_available_for_trading"], 1500.0) self.assertEqual(snapshot.metadata["cash_by_currency"], {"USD": 1500.0, "SGD": 350.0}) + self.assertEqual(snapshot.metadata["sellable_quantities"], {"QQQI": 8, "TQQQ": 2, "QQQ": 99}) def test_build_strategy_evaluation_inputs_only_keeps_available_inputs(self) -> None: snapshot = object() @@ -791,6 +799,7 @@ def test_build_value_target_portfolio_inputs_from_snapshot_supports_sellable_qua type("Pos", (), {"symbol": "TQQQ", "quantity": 10, "market_value": 5000.0})(), type("Pos", (), {"symbol": "BOXX", "quantity": 20, "market_value": 2000.0})(), ), + "metadata": {"sellable_quantities": {"TQQQ": 7, "BOXX": 19}}, }, )() @@ -801,7 +810,7 @@ def test_build_value_target_portfolio_inputs_from_snapshot_supports_sellable_qua self.assertEqual(inputs.market_values["TQQQ"], 5000.0) self.assertEqual(inputs.quantities["BOXX"], 20) - self.assertEqual(inputs.sellable_quantities, {"TQQQ": 10, "BOXX": 20}) + self.assertEqual(inputs.sellable_quantities, {"TQQQ": 7, "BOXX": 19}) self.assertEqual(inputs.total_equity, 25000.0) self.assertEqual(inputs.liquid_cash, 6000.0)