Skip to content

Commit 6fc23ef

Browse files
Pigbibicursoragent
andauthored
Enable Firstrade notional DCA execution for IBIT smart DCA (#147)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2a07f53 commit 6fc23ef

16 files changed

Lines changed: 575 additions & 39 deletions

application/execution_service.py

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ class ExecutionCycleResult:
137137

138138
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
139139
SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0
140+
MIN_NOTIONAL_BUY_USD = 1.0
140141
SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS = frozenset({"TQQQ", "SOXL"})
141142
SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL = {
142143
"SOXX": 0.90,
@@ -443,6 +444,38 @@ def _submit_order(
443444
}
444445

445446

447+
def _submit_notional_buy_order(
448+
execution_port: ExecutionPort,
449+
*,
450+
symbol: str,
451+
notional_usd: float,
452+
max_notional_usd: float | None,
453+
) -> dict[str, Any]:
454+
metadata = {"notional_usd": round(float(notional_usd), 2)}
455+
if max_notional_usd is not None:
456+
metadata["max_notional_usd"] = float(max_notional_usd)
457+
report = execution_port.submit_order(
458+
OrderIntent(
459+
symbol=symbol,
460+
side="buy",
461+
quantity=0.0,
462+
order_type="market",
463+
time_in_force="day",
464+
metadata=metadata,
465+
)
466+
)
467+
return {
468+
"symbol": report.symbol,
469+
"side": report.side,
470+
"quantity": report.quantity,
471+
"order_type": "market",
472+
"notional_usd": round(float(notional_usd), 2),
473+
"status": report.status,
474+
"broker_order_id": report.broker_order_id,
475+
"raw_payload": report.raw_payload,
476+
}
477+
478+
446479
def execute_value_target_plan(
447480
*,
448481
plan: dict[str, Any],
@@ -455,6 +488,7 @@ def execute_value_target_plan(
455488
max_order_notional_usd: float | None = None,
456489
safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD,
457490
cash_only_execution: bool = True,
491+
notional_buy_execution: bool = False,
458492
) -> ExecutionCycleResult:
459493
del dry_run_only # ExecutionPort owns preview vs live submission.
460494
plan = substitute_small_safe_haven_targets_with_cash(
@@ -466,12 +500,13 @@ def execute_value_target_plan(
466500
dict(plan.get("allocation") or {}),
467501
portfolio=plan_portfolio,
468502
)
469-
plan = _apply_small_account_whole_share_compatibility(
470-
plan,
471-
market_data_port=market_data_port,
472-
limit_buy_premium=limit_buy_premium,
473-
limit_buy_premium_by_symbol=limit_buy_premium_by_symbol,
474-
)
503+
if not notional_buy_execution:
504+
plan = _apply_small_account_whole_share_compatibility(
505+
plan,
506+
market_data_port=market_data_port,
507+
limit_buy_premium=limit_buy_premium,
508+
limit_buy_premium_by_symbol=limit_buy_premium_by_symbol,
509+
)
475510
allocation = dict(plan.get("allocation") or {})
476511
portfolio = dict(plan.get("portfolio") or {})
477512
execution = dict(plan.get("execution") or {})
@@ -578,12 +613,16 @@ def execute_value_target_plan(
578613
buy_budget = min(float(delta_value), investable_cash)
579614
if order_notional_cap is not None:
580615
buy_budget = min(buy_budget, order_notional_cap)
581-
limit_price = _limit_buy_price(
582-
symbol, price, limit_buy_premium, limit_buy_premium_by_symbol
583-
)
584-
quantity = _floor_quantity(buy_budget / limit_price) if limit_price > 0 else 0
585-
if quantity > 0:
586-
estimated_buy_cost += quantity * limit_price
616+
if notional_buy_execution:
617+
if buy_budget >= MIN_NOTIONAL_BUY_USD:
618+
estimated_buy_cost += buy_budget
619+
else:
620+
limit_price = _limit_buy_price(
621+
symbol, price, limit_buy_premium, limit_buy_premium_by_symbol
622+
)
623+
quantity = _floor_quantity(buy_budget / limit_price) if limit_price > 0 else 0
624+
if quantity > 0:
625+
estimated_buy_cost += quantity * limit_price
587626
if estimated_buy_cost > investable_cash:
588627
_buys_blocked_reason = "pending_sell_release"
589628
for symbol, _delta_value, _price in buy_deltas:
@@ -611,6 +650,27 @@ def execute_value_target_plan(
611650
buy_budget = min(float(delta_value), investable_cash)
612651
if order_notional_cap is not None:
613652
buy_budget = min(buy_budget, order_notional_cap)
653+
if notional_buy_execution:
654+
if buy_budget < MIN_NOTIONAL_BUY_USD:
655+
skipped.append(
656+
{
657+
"symbol": symbol,
658+
"reason": "buy_notional_below_minimum",
659+
"notional_usd": round(buy_budget, 2),
660+
"min_notional_usd": MIN_NOTIONAL_BUY_USD,
661+
}
662+
)
663+
continue
664+
submitted.append(
665+
_submit_notional_buy_order(
666+
execution_port,
667+
symbol=symbol,
668+
notional_usd=buy_budget,
669+
max_notional_usd=max_order_notional_usd,
670+
)
671+
)
672+
investable_cash = max(0.0, investable_cash - buy_budget)
673+
continue
614674
limit_price = _limit_buy_price(symbol, price, limit_buy_premium, limit_buy_premium_by_symbol)
615675
quantity = _floor_quantity(buy_budget / limit_price) if limit_price > 0 else 0
616676
if quantity <= 0:

application/rebalance_service.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
)
6262
from quant_platform_kit.strategy_contracts import build_strategy_evaluation_inputs
6363
from runtime_config_support import IBIT_SMART_DCA_PROFILE, PlatformRuntimeSettings, load_platform_runtime_settings
64+
from runtime_execution_policy import dca_execution_unsupported_reason, notional_buy_execution_enabled
6465
from us_equity_strategies.signals import resolve_external_market_signal_inputs
6566
from strategy_runtime import load_strategy_runtime
6667

@@ -352,6 +353,38 @@ def _runtime_metadata_with_execution_policy(
352353
return runtime_metadata
353354

354355

356+
def _unsupported_strategy_execution_result(
357+
*,
358+
settings: PlatformRuntimeSettings,
359+
skip_reason: str,
360+
strategy_plugin_signals,
361+
strategy_plugin_error: str | None,
362+
translator: Callable[..., str],
363+
) -> dict[str, Any]:
364+
skipped_orders = [{"reason": skip_reason, "strategy_profile": settings.strategy_profile}]
365+
result = {
366+
"ok": True,
367+
"status": "skipped",
368+
"skip_reason": skip_reason,
369+
"api_kind": "unofficial-reverse-engineered",
370+
"strategy_profile": settings.strategy_profile,
371+
"strategy_display_name": settings.strategy_display_name,
372+
"dry_run_only": settings.dry_run_only,
373+
"live_trading_enabled": settings.live_trading_enabled,
374+
"submitted_orders": [],
375+
"skipped_orders": skipped_orders,
376+
"action_done": False,
377+
"strategy_run_stage": "NO_ACTION",
378+
**empty_strategy_plugin_alert_report_fields(),
379+
}
380+
return attach_strategy_plugin_result(
381+
result,
382+
signals=strategy_plugin_signals,
383+
error=strategy_plugin_error,
384+
translator=translator,
385+
)
386+
387+
355388
def run_strategy_cycle(
356389
*,
357390
runtime_settings: PlatformRuntimeSettings | None = None,
@@ -374,6 +407,15 @@ def log_message(message: str) -> None:
374407
settings.strategy_plugin_mounts_json,
375408
strategy_profile=settings.strategy_profile,
376409
)
410+
unsupported_reason = dca_execution_unsupported_reason(settings.strategy_profile)
411+
if unsupported_reason is not None:
412+
return _unsupported_strategy_execution_result(
413+
settings=settings,
414+
skip_reason=unsupported_reason,
415+
strategy_plugin_signals=strategy_plugin_signals,
416+
strategy_plugin_error=strategy_plugin_error,
417+
translator=translator,
418+
)
377419
resolved_credentials = credentials or FirstradeCredentials.from_env(env_reader)
378420
store = state_store or build_gcs_state_store_from_env(env_reader)
379421
persist_strategy_runs = bool(settings.persist_strategy_runs and store is not None)
@@ -566,6 +608,7 @@ def log_message(message: str) -> None:
566608
max_order_notional_usd=settings.max_order_notional_usd,
567609
safe_haven_cash_substitute_threshold_usd=settings.safe_haven_cash_substitute_threshold_usd,
568610
cash_only_execution=settings.cash_only_execution,
611+
notional_buy_execution=notional_buy_execution_enabled(settings.strategy_profile),
569612
)
570613
submitted_orders = list(execution_result.submitted_orders)
571614
skipped_orders = list(execution_result.skipped_orders)

application/runtime_broker_adapters.py

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -288,26 +288,34 @@ def build_portfolio_port(self) -> PortfolioPort:
288288

289289
def build_execution_port(self) -> ExecutionPort:
290290
def submit(order_intent) -> ExecutionReport:
291-
request = StockOrderRequest(
292-
account=self.account,
293-
symbol=order_intent.symbol,
294-
side=order_intent.side,
295-
quantity=int(order_intent.quantity),
296-
price_type=str(order_intent.order_type or "market").lower(),
297-
duration=str(order_intent.time_in_force or "day").lower(),
298-
limit_price=order_intent.limit_price,
299-
max_notional_usd=(
300-
float(max_notional)
301-
if (
302-
max_notional := (getattr(order_intent, "metadata", {}) or {}).get(
303-
"max_notional_usd",
304-
self.max_order_notional_usd,
305-
)
306-
)
307-
is not None
308-
else None
309-
),
310-
)
291+
metadata = dict(getattr(order_intent, "metadata", {}) or {})
292+
notional_usd = metadata.get("notional_usd")
293+
max_notional = metadata.get("max_notional_usd", self.max_order_notional_usd)
294+
if notional_usd is not None:
295+
request = StockOrderRequest(
296+
account=self.account,
297+
symbol=order_intent.symbol,
298+
side=order_intent.side,
299+
notional_usd=float(notional_usd),
300+
price_type="market",
301+
duration=str(order_intent.time_in_force or "day").lower(),
302+
max_notional_usd=(
303+
float(max_notional) if max_notional is not None else None
304+
),
305+
)
306+
else:
307+
request = StockOrderRequest(
308+
account=self.account,
309+
symbol=order_intent.symbol,
310+
side=order_intent.side,
311+
quantity=int(order_intent.quantity),
312+
price_type=str(order_intent.order_type or "market").lower(),
313+
duration=str(order_intent.time_in_force or "day").lower(),
314+
limit_price=order_intent.limit_price,
315+
max_notional_usd=(
316+
float(max_notional) if max_notional is not None else None
317+
),
318+
)
311319
raw = self.client.place_stock_order(
312320
request,
313321
dry_run=not self.live_orders,
@@ -316,7 +324,7 @@ def submit(order_intent) -> ExecutionReport:
316324
return ExecutionReport(
317325
symbol=request.symbol,
318326
side=request.side,
319-
quantity=float(request.quantity or 0),
327+
quantity=float(request.quantity or request.notional_usd or 0),
320328
status="previewed" if not self.live_orders else "submitted",
321329
raw_payload=raw,
322330
)

entrypoints/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Cloud Run and HTTP entrypoint helpers for FirstradePlatform."""

entrypoints/cloud_run.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Cloud Run request helpers for FirstradePlatform."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime
6+
7+
import pandas_market_calendars as mcal
8+
import pytz
9+
10+
11+
def is_market_open_now(*, calendar_name="NYSE", timezone_name="America/New_York"):
12+
"""Return whether the US equity regular session is open right now."""
13+
try:
14+
calendar = mcal.get_calendar(calendar_name)
15+
market_tz = pytz.timezone(timezone_name)
16+
now_market = datetime.now(market_tz)
17+
schedule = calendar.schedule(start_date=now_market.date(), end_date=now_market.date())
18+
if schedule.empty:
19+
return False
20+
return calendar.open_at_time(schedule, now_market)
21+
except Exception as exc:
22+
return False, exc

main.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,15 @@
2727
finalize_runtime_report,
2828
persist_runtime_report,
2929
)
30+
from entrypoints.cloud_run import is_market_open_now
3031
from runtime_config_support import (
3132
PlatformRuntimeSettings,
3233
_runtime_target_enabled_env,
3334
load_platform_runtime_settings,
3435
)
36+
37+
MARKET_CALENDAR = os.getenv("FIRSTRADE_MARKET_CALENDAR", "NYSE")
38+
MARKET_TIMEZONE = os.getenv("FIRSTRADE_MARKET_TIMEZONE", "America/New_York")
3539
from strategy_registry import get_platform_profile_status_matrix
3640

3741
app = Flask(__name__)
@@ -255,6 +259,47 @@ def _persist_runtime_report(report: dict[str, Any]) -> str | None:
255259
return getattr(persisted, "gcs_uri", None) or getattr(persisted, "local_path", None)
256260

257261

262+
def _force_strategy_run_env() -> bool:
263+
return _flag("FIRSTRADE_FORCE_RUN")
264+
265+
266+
def _market_open_skip_payload(*, market_open: bool, error: Exception | None = None) -> dict[str, Any]:
267+
payload: dict[str, Any] = {
268+
"ok": True,
269+
"status": "skipped",
270+
"skip_reason": "market_closed",
271+
"action_done": False,
272+
"submitted_orders": [],
273+
"skipped_orders": [{"reason": "market_closed"}],
274+
}
275+
if error is not None:
276+
payload["market_hours_check_error"] = f"{type(error).__name__}: {error}"
277+
if _force_strategy_run_env() and not market_open:
278+
payload["market_hours_bypass_requested"] = True
279+
return payload
280+
281+
282+
def _should_skip_for_market_hours() -> tuple[bool, dict[str, Any] | None]:
283+
if _force_strategy_run_env():
284+
return False, None
285+
market_open = is_market_open_now(
286+
calendar_name=MARKET_CALENDAR,
287+
timezone_name=MARKET_TIMEZONE,
288+
)
289+
error = None
290+
if isinstance(market_open, tuple):
291+
market_open, error = market_open
292+
if market_open:
293+
return False, None
294+
print(
295+
"Firstrade strategy run skipped: outside US equity regular session.",
296+
flush=True,
297+
)
298+
if error is not None:
299+
print(f"Firstrade market hours check failed: {error}", flush=True)
300+
return True, _market_open_skip_payload(market_open=False, error=error)
301+
302+
258303
def _run_strategy_cycle_with_report(
259304
*,
260305
dry_run_override: bool | None = None,
@@ -419,6 +464,9 @@ def run_strategy():
419464
)
420465
if not _runtime_target_enabled_env():
421466
return jsonify({"ok": True, "status": "skipped", "skip_reason": "runtime_target_disabled"}), 200
467+
skip_for_market, skip_payload = _should_skip_for_market_hours()
468+
if skip_for_market and skip_payload is not None:
469+
return jsonify(skip_payload), 200
422470
try:
423471
result = _run_strategy_cycle_with_report()
424472
return jsonify(result), _strategy_result_http_status(result)
@@ -451,6 +499,9 @@ def run_strategy():
451499
@app.post("/dry-run")
452500
@app.get("/dry-run")
453501
def dry_run():
502+
skip_for_market, skip_payload = _should_skip_for_market_hours()
503+
if skip_for_market and skip_payload is not None:
504+
return jsonify(skip_payload), 200
454505
try:
455506
return jsonify(
456507
_run_strategy_cycle_with_report(

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ authors = [
1414
]
1515
dependencies = [
1616
"firstrade==0.0.39",
17-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@aee8121d530c2e92c72b68aee434bf174b3b9c85",
18-
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@b2fa659304c02cc19f7c82e86b0ce36ef592846a",
17+
"pandas_market_calendars",
18+
"pytz",
19+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@dfdbef6b58ab46f357d67800510bb9e8c4a01182",
20+
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@5537c0ad2ce0d34113381d4af19656b0bcdd82ec",
1921
"google-cloud-storage",
2022
"requests",
2123
]
@@ -25,6 +27,7 @@ py-modules = [
2527
"decision_mapper",
2628
"main",
2729
"runtime_config_support",
30+
"runtime_execution_policy",
2831
"strategy_loader",
2932
"strategy_registry",
3033
"strategy_runtime",
@@ -33,6 +36,7 @@ py-modules = [
3336
[tool.setuptools.packages.find]
3437
include = [
3538
"application*",
39+
"entrypoints*",
3640
"notifications*",
3741
]
3842

0 commit comments

Comments
 (0)