diff --git a/application/execution_service.py b/application/execution_service.py index 86be628..b70a42e 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -137,6 +137,7 @@ class ExecutionCycleResult: DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0 +MIN_NOTIONAL_BUY_USD = 1.0 SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS = frozenset({"TQQQ", "SOXL"}) SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL = { "SOXX": 0.90, @@ -443,6 +444,38 @@ def _submit_order( } +def _submit_notional_buy_order( + execution_port: ExecutionPort, + *, + symbol: str, + notional_usd: float, + max_notional_usd: float | None, +) -> dict[str, Any]: + metadata = {"notional_usd": round(float(notional_usd), 2)} + if max_notional_usd is not None: + metadata["max_notional_usd"] = float(max_notional_usd) + report = execution_port.submit_order( + OrderIntent( + symbol=symbol, + side="buy", + quantity=0.0, + order_type="market", + time_in_force="day", + metadata=metadata, + ) + ) + return { + "symbol": report.symbol, + "side": report.side, + "quantity": report.quantity, + "order_type": "market", + "notional_usd": round(float(notional_usd), 2), + "status": report.status, + "broker_order_id": report.broker_order_id, + "raw_payload": report.raw_payload, + } + + def execute_value_target_plan( *, plan: dict[str, Any], @@ -455,6 +488,7 @@ def execute_value_target_plan( max_order_notional_usd: float | None = None, safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, cash_only_execution: bool = True, + notional_buy_execution: bool = False, ) -> ExecutionCycleResult: del dry_run_only # ExecutionPort owns preview vs live submission. plan = substitute_small_safe_haven_targets_with_cash( @@ -466,12 +500,13 @@ def execute_value_target_plan( dict(plan.get("allocation") or {}), portfolio=plan_portfolio, ) - plan = _apply_small_account_whole_share_compatibility( - plan, - market_data_port=market_data_port, - limit_buy_premium=limit_buy_premium, - limit_buy_premium_by_symbol=limit_buy_premium_by_symbol, - ) + if not notional_buy_execution: + plan = _apply_small_account_whole_share_compatibility( + plan, + market_data_port=market_data_port, + limit_buy_premium=limit_buy_premium, + limit_buy_premium_by_symbol=limit_buy_premium_by_symbol, + ) allocation = dict(plan.get("allocation") or {}) portfolio = dict(plan.get("portfolio") or {}) execution = dict(plan.get("execution") or {}) @@ -578,12 +613,16 @@ def execute_value_target_plan( buy_budget = min(float(delta_value), investable_cash) if order_notional_cap is not None: buy_budget = min(buy_budget, order_notional_cap) - limit_price = _limit_buy_price( - symbol, price, limit_buy_premium, limit_buy_premium_by_symbol - ) - quantity = _floor_quantity(buy_budget / limit_price) if limit_price > 0 else 0 - if quantity > 0: - estimated_buy_cost += quantity * limit_price + if notional_buy_execution: + if buy_budget >= MIN_NOTIONAL_BUY_USD: + estimated_buy_cost += buy_budget + else: + limit_price = _limit_buy_price( + symbol, price, limit_buy_premium, limit_buy_premium_by_symbol + ) + quantity = _floor_quantity(buy_budget / limit_price) if limit_price > 0 else 0 + if quantity > 0: + estimated_buy_cost += quantity * limit_price if estimated_buy_cost > investable_cash: _buys_blocked_reason = "pending_sell_release" for symbol, _delta_value, _price in buy_deltas: @@ -611,6 +650,27 @@ def execute_value_target_plan( buy_budget = min(float(delta_value), investable_cash) if order_notional_cap is not None: buy_budget = min(buy_budget, order_notional_cap) + if notional_buy_execution: + if buy_budget < MIN_NOTIONAL_BUY_USD: + skipped.append( + { + "symbol": symbol, + "reason": "buy_notional_below_minimum", + "notional_usd": round(buy_budget, 2), + "min_notional_usd": MIN_NOTIONAL_BUY_USD, + } + ) + continue + submitted.append( + _submit_notional_buy_order( + execution_port, + symbol=symbol, + notional_usd=buy_budget, + max_notional_usd=max_order_notional_usd, + ) + ) + investable_cash = max(0.0, investable_cash - buy_budget) + continue limit_price = _limit_buy_price(symbol, price, limit_buy_premium, limit_buy_premium_by_symbol) quantity = _floor_quantity(buy_budget / limit_price) if limit_price > 0 else 0 if quantity <= 0: diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 271a511..81cdf21 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -61,6 +61,7 @@ ) from quant_platform_kit.strategy_contracts import build_strategy_evaluation_inputs from runtime_config_support import IBIT_SMART_DCA_PROFILE, PlatformRuntimeSettings, load_platform_runtime_settings +from runtime_execution_policy import dca_execution_unsupported_reason, notional_buy_execution_enabled from us_equity_strategies.signals import resolve_external_market_signal_inputs from strategy_runtime import load_strategy_runtime @@ -352,6 +353,38 @@ def _runtime_metadata_with_execution_policy( return runtime_metadata +def _unsupported_strategy_execution_result( + *, + settings: PlatformRuntimeSettings, + skip_reason: str, + strategy_plugin_signals, + strategy_plugin_error: str | None, + translator: Callable[..., str], +) -> dict[str, Any]: + skipped_orders = [{"reason": skip_reason, "strategy_profile": settings.strategy_profile}] + result = { + "ok": True, + "status": "skipped", + "skip_reason": skip_reason, + "api_kind": "unofficial-reverse-engineered", + "strategy_profile": settings.strategy_profile, + "strategy_display_name": settings.strategy_display_name, + "dry_run_only": settings.dry_run_only, + "live_trading_enabled": settings.live_trading_enabled, + "submitted_orders": [], + "skipped_orders": skipped_orders, + "action_done": False, + "strategy_run_stage": "NO_ACTION", + **empty_strategy_plugin_alert_report_fields(), + } + return attach_strategy_plugin_result( + result, + signals=strategy_plugin_signals, + error=strategy_plugin_error, + translator=translator, + ) + + def run_strategy_cycle( *, runtime_settings: PlatformRuntimeSettings | None = None, @@ -374,6 +407,15 @@ def log_message(message: str) -> None: settings.strategy_plugin_mounts_json, strategy_profile=settings.strategy_profile, ) + unsupported_reason = dca_execution_unsupported_reason(settings.strategy_profile) + if unsupported_reason is not None: + return _unsupported_strategy_execution_result( + settings=settings, + skip_reason=unsupported_reason, + strategy_plugin_signals=strategy_plugin_signals, + strategy_plugin_error=strategy_plugin_error, + translator=translator, + ) resolved_credentials = credentials or FirstradeCredentials.from_env(env_reader) store = state_store or build_gcs_state_store_from_env(env_reader) persist_strategy_runs = bool(settings.persist_strategy_runs and store is not None) @@ -566,6 +608,7 @@ def log_message(message: str) -> None: max_order_notional_usd=settings.max_order_notional_usd, safe_haven_cash_substitute_threshold_usd=settings.safe_haven_cash_substitute_threshold_usd, cash_only_execution=settings.cash_only_execution, + notional_buy_execution=notional_buy_execution_enabled(settings.strategy_profile), ) submitted_orders = list(execution_result.submitted_orders) skipped_orders = list(execution_result.skipped_orders) diff --git a/application/runtime_broker_adapters.py b/application/runtime_broker_adapters.py index cc9efa0..6a6c0a0 100644 --- a/application/runtime_broker_adapters.py +++ b/application/runtime_broker_adapters.py @@ -288,26 +288,34 @@ def build_portfolio_port(self) -> PortfolioPort: def build_execution_port(self) -> ExecutionPort: def submit(order_intent) -> ExecutionReport: - request = StockOrderRequest( - account=self.account, - symbol=order_intent.symbol, - side=order_intent.side, - quantity=int(order_intent.quantity), - price_type=str(order_intent.order_type or "market").lower(), - duration=str(order_intent.time_in_force or "day").lower(), - limit_price=order_intent.limit_price, - max_notional_usd=( - float(max_notional) - if ( - max_notional := (getattr(order_intent, "metadata", {}) or {}).get( - "max_notional_usd", - self.max_order_notional_usd, - ) - ) - is not None - else None - ), - ) + metadata = dict(getattr(order_intent, "metadata", {}) or {}) + notional_usd = metadata.get("notional_usd") + max_notional = metadata.get("max_notional_usd", self.max_order_notional_usd) + if notional_usd is not None: + request = StockOrderRequest( + account=self.account, + symbol=order_intent.symbol, + side=order_intent.side, + notional_usd=float(notional_usd), + price_type="market", + duration=str(order_intent.time_in_force or "day").lower(), + max_notional_usd=( + float(max_notional) if max_notional is not None else None + ), + ) + else: + request = StockOrderRequest( + account=self.account, + symbol=order_intent.symbol, + side=order_intent.side, + quantity=int(order_intent.quantity), + price_type=str(order_intent.order_type or "market").lower(), + duration=str(order_intent.time_in_force or "day").lower(), + limit_price=order_intent.limit_price, + max_notional_usd=( + float(max_notional) if max_notional is not None else None + ), + ) raw = self.client.place_stock_order( request, dry_run=not self.live_orders, @@ -316,7 +324,7 @@ def submit(order_intent) -> ExecutionReport: return ExecutionReport( symbol=request.symbol, side=request.side, - quantity=float(request.quantity or 0), + quantity=float(request.quantity or request.notional_usd or 0), status="previewed" if not self.live_orders else "submitted", raw_payload=raw, ) diff --git a/entrypoints/__init__.py b/entrypoints/__init__.py new file mode 100644 index 0000000..3312460 --- /dev/null +++ b/entrypoints/__init__.py @@ -0,0 +1 @@ +"""Cloud Run and HTTP entrypoint helpers for FirstradePlatform.""" diff --git a/entrypoints/cloud_run.py b/entrypoints/cloud_run.py new file mode 100644 index 0000000..0b750d6 --- /dev/null +++ b/entrypoints/cloud_run.py @@ -0,0 +1,22 @@ +"""Cloud Run request helpers for FirstradePlatform.""" + +from __future__ import annotations + +from datetime import datetime + +import pandas_market_calendars as mcal +import pytz + + +def is_market_open_now(*, calendar_name="NYSE", timezone_name="America/New_York"): + """Return whether the US equity regular session is open right now.""" + try: + calendar = mcal.get_calendar(calendar_name) + market_tz = pytz.timezone(timezone_name) + now_market = datetime.now(market_tz) + schedule = calendar.schedule(start_date=now_market.date(), end_date=now_market.date()) + if schedule.empty: + return False + return calendar.open_at_time(schedule, now_market) + except Exception as exc: + return False, exc diff --git a/main.py b/main.py index cc95df0..303a049 100644 --- a/main.py +++ b/main.py @@ -27,11 +27,15 @@ finalize_runtime_report, persist_runtime_report, ) +from entrypoints.cloud_run import is_market_open_now from runtime_config_support import ( PlatformRuntimeSettings, _runtime_target_enabled_env, load_platform_runtime_settings, ) + +MARKET_CALENDAR = os.getenv("FIRSTRADE_MARKET_CALENDAR", "NYSE") +MARKET_TIMEZONE = os.getenv("FIRSTRADE_MARKET_TIMEZONE", "America/New_York") from strategy_registry import get_platform_profile_status_matrix app = Flask(__name__) @@ -255,6 +259,47 @@ def _persist_runtime_report(report: dict[str, Any]) -> str | None: return getattr(persisted, "gcs_uri", None) or getattr(persisted, "local_path", None) +def _force_strategy_run_env() -> bool: + return _flag("FIRSTRADE_FORCE_RUN") + + +def _market_open_skip_payload(*, market_open: bool, error: Exception | None = None) -> dict[str, Any]: + payload: dict[str, Any] = { + "ok": True, + "status": "skipped", + "skip_reason": "market_closed", + "action_done": False, + "submitted_orders": [], + "skipped_orders": [{"reason": "market_closed"}], + } + if error is not None: + payload["market_hours_check_error"] = f"{type(error).__name__}: {error}" + if _force_strategy_run_env() and not market_open: + payload["market_hours_bypass_requested"] = True + return payload + + +def _should_skip_for_market_hours() -> tuple[bool, dict[str, Any] | None]: + if _force_strategy_run_env(): + return False, None + market_open = is_market_open_now( + calendar_name=MARKET_CALENDAR, + timezone_name=MARKET_TIMEZONE, + ) + error = None + if isinstance(market_open, tuple): + market_open, error = market_open + if market_open: + return False, None + print( + "Firstrade strategy run skipped: outside US equity regular session.", + flush=True, + ) + if error is not None: + print(f"Firstrade market hours check failed: {error}", flush=True) + return True, _market_open_skip_payload(market_open=False, error=error) + + def _run_strategy_cycle_with_report( *, dry_run_override: bool | None = None, @@ -419,6 +464,9 @@ def run_strategy(): ) if not _runtime_target_enabled_env(): return jsonify({"ok": True, "status": "skipped", "skip_reason": "runtime_target_disabled"}), 200 + skip_for_market, skip_payload = _should_skip_for_market_hours() + if skip_for_market and skip_payload is not None: + return jsonify(skip_payload), 200 try: result = _run_strategy_cycle_with_report() return jsonify(result), _strategy_result_http_status(result) @@ -451,6 +499,9 @@ def run_strategy(): @app.post("/dry-run") @app.get("/dry-run") def dry_run(): + skip_for_market, skip_payload = _should_skip_for_market_hours() + if skip_for_market and skip_payload is not None: + return jsonify(skip_payload), 200 try: return jsonify( _run_strategy_cycle_with_report( diff --git a/pyproject.toml b/pyproject.toml index 3c92f6b..d3c33f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,10 @@ authors = [ ] dependencies = [ "firstrade==0.0.39", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@aee8121d530c2e92c72b68aee434bf174b3b9c85", - "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@b2fa659304c02cc19f7c82e86b0ce36ef592846a", + "pandas_market_calendars", + "pytz", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@dfdbef6b58ab46f357d67800510bb9e8c4a01182", + "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@5537c0ad2ce0d34113381d4af19656b0bcdd82ec", "google-cloud-storage", "requests", ] @@ -25,6 +27,7 @@ py-modules = [ "decision_mapper", "main", "runtime_config_support", + "runtime_execution_policy", "strategy_loader", "strategy_registry", "strategy_runtime", @@ -33,6 +36,7 @@ py-modules = [ [tool.setuptools.packages.find] include = [ "application*", + "entrypoints*", "notifications*", ] diff --git a/requirements.txt b/requirements.txt index 3a274c9..cd641fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,10 @@ flask gunicorn firstrade==0.0.39 -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@7b6e3ce33e6563db4794fa7b865db9ec428dc478 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@608f491f4ef083c752ec29ea2669665d5de4a219 +pandas_market_calendars +pytz +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@dfdbef6b58ab46f357d67800510bb9e8c4a01182 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@5537c0ad2ce0d34113381d4af19656b0bcdd82ec google-cloud-storage google-auth requests diff --git a/runtime_execution_policy.py b/runtime_execution_policy.py new file mode 100644 index 0000000..2a57b30 --- /dev/null +++ b/runtime_execution_policy.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from quant_platform_kit.common.execution_capabilities import ( + FRACTIONAL_SHARE_EXECUTION_SKIP_REASON, + definition_requires_fractional_share_execution, + fractional_share_execution_unsupported_reason, +) +from quant_platform_kit.common.strategies import normalize_profile_name +from strategy_registry import PLATFORM_CAPABILITY_MATRIX, STRATEGY_CATALOG + + +def dca_execution_unsupported_reason(strategy_profile: str) -> str | None: + return fractional_share_execution_unsupported_reason( + strategy_profile, + strategy_catalog=STRATEGY_CATALOG, + capability_matrix=PLATFORM_CAPABILITY_MATRIX, + ) + + +def notional_buy_execution_enabled(strategy_profile: str) -> bool: + if dca_execution_unsupported_reason(strategy_profile) is not None: + return False + normalized_profile = normalize_profile_name(strategy_profile) + definition = STRATEGY_CATALOG.definitions.get(normalized_profile) + if definition is None: + return False + return definition_requires_fractional_share_execution(definition) + + +__all__ = ( + "FRACTIONAL_SHARE_EXECUTION_SKIP_REASON", + "dca_execution_unsupported_reason", + "notional_buy_execution_enabled", +) diff --git a/strategy_registry.py b/strategy_registry.py index 4bf0306..3d2c652 100644 --- a/strategy_registry.py +++ b/strategy_registry.py @@ -1,5 +1,8 @@ from __future__ import annotations +from quant_platform_kit.common.execution_capabilities import ( + FRACTIONAL_SHARE_EXECUTION_CAPABILITY, +) from quant_platform_kit.common.strategies import ( PlatformCapabilityMatrix, PlatformStrategyPolicy, @@ -44,7 +47,7 @@ "snapshot", } ), - supported_capabilities=frozenset(), + supported_capabilities=frozenset({FRACTIONAL_SHARE_EXECUTION_CAPABILITY}), ) ELIGIBLE_STRATEGY_PROFILES = derive_eligible_profiles_for_platform( STRATEGY_CATALOG, diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 1146a08..db31cbf 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -478,3 +478,93 @@ def test_execute_value_target_plan_keeps_safe_haven_when_mixed_case_risk_target_ ("buy", "BOXX", 10.0), ("buy", "SOXL", 5.0), ] + + +def test_execute_value_target_plan_uses_notional_buy_when_enabled(): + execution_port = FakeExecutionPort() + result = execute_value_target_plan( + plan={ + "allocation": {"targets": {"QQQM": 50.0}}, + "portfolio": { + "market_values": {"QQQM": 0.0}, + "liquid_cash": 100.0, + }, + "execution": {"current_min_trade": 1.0, "investable_cash": 100.0}, + }, + market_data_port=FakeMarketDataPort({"QQQM": 500.0}), + execution_port=execution_port, + dry_run_only=True, + notional_buy_execution=True, + ) + + assert result.action_done is True + assert len(execution_port.orders) == 1 + order = execution_port.orders[0] + assert order.side == "buy" + assert order.symbol == "QQQM" + assert order.order_type == "market" + assert order.metadata["notional_usd"] == 50.0 + + +def test_execute_value_target_plan_notional_buy_skips_below_minimum(): + execution_port = FakeExecutionPort() + result = execute_value_target_plan( + plan={ + "allocation": {"targets": {"QQQM": 0.5}}, + "portfolio": { + "market_values": {"QQQM": 0.0}, + "liquid_cash": 100.0, + }, + "execution": {"current_min_trade": 0.0, "investable_cash": 100.0}, + }, + market_data_port=FakeMarketDataPort({"QQQM": 500.0}), + execution_port=execution_port, + dry_run_only=True, + notional_buy_execution=True, + ) + + assert result.action_done is False + assert execution_port.orders == [] + assert result.skipped_orders == ( + { + "symbol": "QQQM", + "reason": "buy_notional_below_minimum", + "notional_usd": 0.5, + "min_notional_usd": 1.0, + }, + ) + + +def test_execute_value_target_plan_notional_buy_preserves_sub_share_budget(): + plan = { + "allocation": {"targets": {"SPY": 50.0}}, + "portfolio": { + "market_values": {"SPY": 0.0}, + "liquid_cash": 100.0, + }, + "execution": {"current_min_trade": 1.0, "investable_cash": 100.0}, + } + market_data_port = FakeMarketDataPort({"SPY": 500.0}) + + whole_share_port = FakeExecutionPort() + whole_share_result = execute_value_target_plan( + plan=plan, + market_data_port=market_data_port, + execution_port=whole_share_port, + dry_run_only=True, + notional_buy_execution=False, + ) + + notional_port = FakeExecutionPort() + notional_result = execute_value_target_plan( + plan=plan, + market_data_port=market_data_port, + execution_port=notional_port, + dry_run_only=True, + notional_buy_execution=True, + ) + + assert whole_share_result.action_done is False + assert whole_share_port.orders == [] + assert notional_result.action_done is True + assert notional_port.orders[0].metadata["notional_usd"] == 50.0 diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 20e65e6..727b9f2 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -64,6 +64,7 @@ def test_runtime_metadata_uses_platform_execution_policy_over_strategy_metadata( } + class FakeFirstradeClient: def __init__(self, _credentials, *, live_trading_enabled=False): self.live_trading_enabled = live_trading_enabled diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index a2dcb9b..72fc5da 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -58,6 +58,7 @@ def test_run_endpoint_is_disabled_without_explicit_http_gate(monkeypatch): def test_run_endpoint_calls_strategy_cycle_when_gate_enabled(monkeypatch): monkeypatch.setenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP", "true") + monkeypatch.setattr(main, "_should_skip_for_market_hours", lambda: (False, None)) monkeypatch.setattr(main, "_run_strategy_cycle_with_report", lambda **_kwargs: {"ok": True, "action_done": False}) client = main.app.test_client() @@ -67,8 +68,41 @@ def test_run_endpoint_calls_strategy_cycle_when_gate_enabled(monkeypatch): assert response.get_json() == {"ok": True, "action_done": False} +def test_run_endpoint_skips_when_market_closed(monkeypatch): + monkeypatch.setenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP", "true") + monkeypatch.setattr( + main, + "_should_skip_for_market_hours", + lambda: ( + True, + { + "ok": True, + "status": "skipped", + "skip_reason": "market_closed", + "action_done": False, + "submitted_orders": [], + "skipped_orders": [{"reason": "market_closed"}], + }, + ), + ) + monkeypatch.setattr( + main, + "_run_strategy_cycle_with_report", + lambda **_kwargs: (_ for _ in ()).throw(AssertionError("strategy cycle should not run")), + ) + client = main.app.test_client() + + response = client.post("/run") + + assert response.status_code == 200 + payload = response.get_json() + assert payload["skip_reason"] == "market_closed" + assert payload["submitted_orders"] == [] + + def test_run_endpoint_returns_200_for_terminal_funding_block(monkeypatch): monkeypatch.setenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP", "true") + monkeypatch.setattr(main, "_should_skip_for_market_hours", lambda: (False, None)) monkeypatch.setattr( main, "_run_strategy_cycle_with_report", diff --git a/tests/test_runtime_broker_adapters.py b/tests/test_runtime_broker_adapters.py index f361929..4a7c693 100644 --- a/tests/test_runtime_broker_adapters.py +++ b/tests/test_runtime_broker_adapters.py @@ -30,7 +30,13 @@ def get_positions(self, _account): return {"items": [{"symbol": "SPY", "quantity": "2", "market_value": "21.00"}]} def place_stock_order(self, request, dry_run=True): - return {"symbol": request.symbol, "dry_run": dry_run} + return { + "symbol": request.symbol, + "dry_run": dry_run, + "notional": request.notional_usd is not None, + "quantity": request.quantity, + "notional_usd": request.notional_usd, + } def test_runtime_adapters_build_quote_and_portfolio_ports(): @@ -182,3 +188,36 @@ def test_price_series_falls_back_to_history_when_quote_unavailable(): series = adapters.build_market_data_port().get_price_series("SPY") assert [point.close for point in series.points] == [10.0] + + +def test_execution_port_submits_notional_buy_from_metadata(): + captured = {} + + class CapturingClient(FakeClient): + def place_stock_order(self, request, dry_run=True, explicit_live_ack=False): + captured["request"] = request + return super().place_stock_order(request, dry_run=dry_run) + + from quant_platform_kit.common.models import OrderIntent + + adapters = build_runtime_broker_adapters( + client=CapturingClient(), + account="12345678", + ) + report = adapters.build_execution_port().submit_order( + OrderIntent( + symbol="QQQM", + side="buy", + quantity=0.0, + order_type="market", + metadata={"notional_usd": 50.0, "max_notional_usd": 100.0}, + ) + ) + + request = captured["request"] + assert request.notional_usd == 50.0 + assert request.quantity is None + assert request.price_type == "market" + assert request.max_notional_usd == 100.0 + assert report.quantity == 50.0 + assert report.status == "previewed" diff --git a/tests/test_runtime_execution_policy.py b/tests/test_runtime_execution_policy.py new file mode 100644 index 0000000..9a175f0 --- /dev/null +++ b/tests/test_runtime_execution_policy.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import importlib +import sys +import types + +import pytest + +from quant_platform_kit.common.execution_capabilities import ( + FRACTIONAL_SHARE_EXECUTION_CAPABILITY, + FRACTIONAL_SHARE_EXECUTION_SKIP_REASON, + fractional_share_execution_unsupported_reason, +) +from quant_platform_kit.common.strategies import ( + PlatformCapabilityMatrix, + StrategyCatalog, + StrategyDefinition, + US_EQUITY_DOMAIN, +) + +ROOT = __import__("pathlib").Path(__file__).resolve().parents[1] +QPK_SRC = ROOT.parent / "QuantPlatformKit" / "src" +if str(QPK_SRC) not in sys.path: + sys.path.insert(0, str(QPK_SRC)) +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +_DCA_DEFINITIONS = { + "nasdaq_sp500_smart_dca": StrategyDefinition( + profile="nasdaq_sp500_smart_dca", + domain="us_equity", + supported_platforms=frozenset({"firstrade"}), + compatible_capabilities=frozenset({FRACTIONAL_SHARE_EXECUTION_CAPABILITY}), + ), + "ibit_smart_dca": StrategyDefinition( + profile="ibit_smart_dca", + domain="us_equity", + supported_platforms=frozenset({"firstrade"}), + compatible_capabilities=frozenset({FRACTIONAL_SHARE_EXECUTION_CAPABILITY}), + ), + "tqqq_growth_income": StrategyDefinition( + profile="tqqq_growth_income", + domain="us_equity", + supported_platforms=frozenset({"firstrade"}), + compatible_capabilities=frozenset(), + ), + "global_etf_rotation": StrategyDefinition( + profile="global_etf_rotation", + domain="us_equity", + supported_platforms=frozenset({"firstrade"}), + compatible_capabilities=frozenset(), + ), +} +_FIRSTRADE_CAPABILITY_MATRIX = PlatformCapabilityMatrix( + platform_id="firstrade", + supported_domains=frozenset({US_EQUITY_DOMAIN}), + supported_target_modes=frozenset({"weight", "value"}), + supported_inputs=frozenset( + { + "benchmark_history", + "market_history", + "portfolio_snapshot", + "derived_indicators", + "feature_snapshot", + "indicators", + "account_state", + "snapshot", + } + ), + supported_capabilities=frozenset({FRACTIONAL_SHARE_EXECUTION_CAPABILITY}), +) +_FAKE_CATALOG = StrategyCatalog(definitions=_DCA_DEFINITIONS) + +_fake_registry = types.ModuleType("strategy_registry") +_fake_registry.PLATFORM_CAPABILITY_MATRIX = _FIRSTRADE_CAPABILITY_MATRIX +_fake_registry.STRATEGY_CATALOG = _FAKE_CATALOG +sys.modules["strategy_registry"] = _fake_registry + +runtime_execution_policy = importlib.import_module("runtime_execution_policy") +runtime_execution_policy = importlib.reload(runtime_execution_policy) +notional_buy_execution_enabled = runtime_execution_policy.notional_buy_execution_enabled + + +@pytest.mark.parametrize( + ("profile", "expected"), + [ + ("nasdaq_sp500_smart_dca", None), + ("ibit_smart_dca", None), + ("tqqq_growth_income", None), + ("global_etf_rotation", None), + ], +) +def test_fractional_share_execution_unsupported_reason_without_capability(profile: str, expected: str | None) -> None: + matrix_without_fractional = PlatformCapabilityMatrix( + platform_id="firstrade", + supported_domains=frozenset({US_EQUITY_DOMAIN}), + supported_target_modes=frozenset({"weight", "value"}), + supported_inputs=frozenset(), + supported_capabilities=frozenset(), + ) + dca_expected = ( + FRACTIONAL_SHARE_EXECUTION_SKIP_REASON + if profile in {"nasdaq_sp500_smart_dca", "ibit_smart_dca"} + else None + ) + assert ( + fractional_share_execution_unsupported_reason( + profile, + strategy_catalog=_FAKE_CATALOG, + capability_matrix=matrix_without_fractional, + ) + == dca_expected + ) + + +def test_dca_profiles_require_fractional_share_capability() -> None: + for profile in ("nasdaq_sp500_smart_dca", "ibit_smart_dca"): + definition = _FAKE_CATALOG.definitions[profile] + assert FRACTIONAL_SHARE_EXECUTION_CAPABILITY in definition.compatible_capabilities + + +def test_firstrade_capability_matrix_allows_dca_profiles() -> None: + for profile in ("nasdaq_sp500_smart_dca", "ibit_smart_dca"): + assert ( + fractional_share_execution_unsupported_reason( + profile, + strategy_catalog=_FAKE_CATALOG, + capability_matrix=_FIRSTRADE_CAPABILITY_MATRIX, + ) + is None + ) + assert notional_buy_execution_enabled(profile) is True + + +def test_notional_buy_execution_disabled_for_non_dca_profiles() -> None: + assert notional_buy_execution_enabled("global_etf_rotation") is False + assert notional_buy_execution_enabled("tqqq_growth_income") is False diff --git a/tests/test_strategy_registry.py b/tests/test_strategy_registry.py index 90c199c..6fa7009 100644 --- a/tests/test_strategy_registry.py +++ b/tests/test_strategy_registry.py @@ -22,3 +22,10 @@ def test_profile_status_matrix_reports_firstrade_without_bridge_metadata(): assert all(row["platform"] == FIRSTRADE_PLATFORM for row in rows) assert all("strategy_adapter_source_platform" not in row for row in rows) assert "global_etf_rotation" in get_supported_profiles_for_platform(FIRSTRADE_PLATFORM) + + +def test_firstrade_rollout_includes_smart_dca_profiles_with_fractional_execution(): + supported = get_supported_profiles_for_platform(FIRSTRADE_PLATFORM) + + assert "nasdaq_sp500_smart_dca" in supported + assert "ibit_smart_dca" in supported