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
84 changes: 72 additions & 12 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand All @@ -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(
Expand All @@ -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 {})
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
43 changes: 43 additions & 0 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
50 changes: 29 additions & 21 deletions application/runtime_broker_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down
1 change: 1 addition & 0 deletions entrypoints/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Cloud Run and HTTP entrypoint helpers for FirstradePlatform."""
22 changes: 22 additions & 0 deletions entrypoints/cloud_run.py
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand All @@ -25,6 +27,7 @@ py-modules = [
"decision_mapper",
"main",
"runtime_config_support",
"runtime_execution_policy",
"strategy_loader",
"strategy_registry",
"strategy_runtime",
Expand All @@ -33,6 +36,7 @@ py-modules = [
[tool.setuptools.packages.find]
include = [
"application*",
"entrypoints*",
"notifications*",
]

Expand Down
Loading
Loading