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
78 changes: 77 additions & 1 deletion docs/platform_repo_boundaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<Broker>RebalanceRuntime` and `<Broker>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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion src/quant_platform_kit/common/execution_translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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:
Expand Down
46 changes: 46 additions & 0 deletions src/quant_platform_kit/common/port_adapters.py
Original file line number Diff line number Diff line change
@@ -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)
32 changes: 29 additions & 3 deletions src/quant_platform_kit/common/runtime_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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 = {
Expand All @@ -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"]),
Expand Down
63 changes: 63 additions & 0 deletions tests/test_common_port_adapters.py
Original file line number Diff line number Diff line change
@@ -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()
15 changes: 12 additions & 3 deletions tests/test_strategy_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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"),
Expand All @@ -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()
Expand Down Expand Up @@ -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}},
},
)()

Expand All @@ -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)

Expand Down