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
2 changes: 2 additions & 0 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ jobs:
INCOME_LAYER_ENABLED: ${{ vars.INCOME_LAYER_ENABLED }}
INCOME_LAYER_START_USD: ${{ vars.INCOME_LAYER_START_USD }}
INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }}
CASH_ONLY_EXECUTION: ${{ vars.CASH_ONLY_EXECUTION }}
DCA_MODE: ${{ vars.DCA_MODE }}
DCA_BASE_INVESTMENT_USD: ${{ vars.DCA_BASE_INVESTMENT_USD }}
IBIT_ZSCORE_EXIT_ENABLED: ${{ vars.IBIT_ZSCORE_EXIT_ENABLED }}
Expand Down Expand Up @@ -597,6 +598,7 @@ jobs:
add_optional_env INCOME_LAYER_ENABLED
add_optional_env INCOME_LAYER_START_USD
add_optional_env INCOME_LAYER_MAX_RATIO
add_optional_env CASH_ONLY_EXECUTION
add_optional_env DCA_MODE
add_optional_env DCA_BASE_INVESTMENT_USD
add_optional_env IBIT_ZSCORE_EXIT_ENABLED
Expand Down
5 changes: 3 additions & 2 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ def execute_value_target_plan(
limit_buy_premium_by_symbol: dict[str, float] | None = None,
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,
) -> ExecutionCycleResult:
del dry_run_only # ExecutionPort owns preview vs live submission.
plan = substitute_small_safe_haven_targets_with_cash(
Expand Down Expand Up @@ -571,7 +572,7 @@ def execute_value_target_plan(

buy_deltas = [item for item in tradable_deltas if item[1] > 0]
buys_blocked_reason: str | None = None
if buy_deltas and pending_sell_release_symbols:
if cash_only_execution and buy_deltas and pending_sell_release_symbols:
estimated_buy_cost = 0.0
for symbol, delta_value, price in buy_deltas:
buy_budget = min(float(delta_value), investable_cash)
Expand All @@ -594,7 +595,7 @@ def execute_value_target_plan(
}
)
buy_deltas = []
elif buy_deltas and raw_liquid_cash < 0.0:
elif cash_only_execution and buy_deltas and raw_liquid_cash < 0.0:
buys_blocked_reason = "negative_cash"
for symbol, _delta_value, _price in buy_deltas:
skipped.append(
Expand Down
4 changes: 4 additions & 0 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ def _runtime_metadata_with_execution_policy(
runtime_metadata["firstrade_execution_policy"] = {
"reserved_cash_floor_usd": float(settings.reserved_cash_floor_usd or 0.0),
"reserved_cash_ratio": float(settings.reserved_cash_ratio or 0.0),
"cash_only_execution": bool(settings.cash_only_execution),
}
return runtime_metadata

Expand Down Expand Up @@ -396,6 +397,7 @@ def log_message(message: str) -> None:
live_orders=not settings.dry_run_only,
live_order_ack=settings.live_order_ack,
max_order_notional_usd=settings.max_order_notional_usd,
cash_only_execution=settings.cash_only_execution,
)
market_data_port = broker_adapters.build_market_data_port()
portfolio_port = broker_adapters.build_portfolio_port()
Expand Down Expand Up @@ -434,6 +436,7 @@ def log_message(message: str) -> None:
plan,
threshold_usd=settings.safe_haven_cash_substitute_threshold_usd,
)
plan.setdefault("execution", {})["cash_only_execution"] = bool(settings.cash_only_execution)
run_period = resolve_strategy_run_period(
now=now,
plan=plan,
Expand Down Expand Up @@ -562,6 +565,7 @@ def log_message(message: str) -> None:
limit_buy_premium_by_symbol=LIMIT_BUY_PREMIUM_BY_SYMBOL,
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,
)
submitted_orders = list(execution_result.submitted_orders)
skipped_orders = list(execution_result.skipped_orders)
Expand Down
33 changes: 24 additions & 9 deletions application/runtime_broker_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,19 @@ def _resolve_total_equity(
buying_power: float | None,
position_market_value: float,
prefer_cash_plus_positions: bool = False,
cash_only_execution: bool = True,
) -> tuple[float, str]:
del buying_power # Cash-only: never use margin buying power for strategy equity.
if cash_balance is not None:
combined_value = float(cash_balance) + max(0.0, float(position_market_value))
if prefer_cash_plus_positions:
return combined_value, "cash_plus_positions"
if combined_value > 0.0:
return combined_value, "cash_plus_positions"
if cash_only_execution:
if cash_balance is not None:
combined_value = float(cash_balance) + max(0.0, float(position_market_value))
if prefer_cash_plus_positions:
return combined_value, "cash_plus_positions"
if combined_value > 0.0:
return combined_value, "cash_plus_positions"
elif buying_power is not None:
combined_value = float(buying_power) + max(0.0, float(position_market_value))
if prefer_cash_plus_positions or combined_value > 0.0:
return combined_value, "buying_power_plus_positions"

balance_total = _first_numeric_by_keyword_groups(balances, _TOTAL_EQUITY_KEYWORD_GROUPS)
if balance_total is not None:
Expand All @@ -120,6 +125,7 @@ class FirstradeBrokerAdapters:
live_orders: bool = False
live_order_ack: bool = False
max_order_notional_usd: float | None = None
cash_only_execution: bool = True

def normalize_symbol(self, symbol: str) -> str:
value = str(symbol or "").strip().upper()
Expand Down Expand Up @@ -247,26 +253,33 @@ def build_portfolio_snapshot(self) -> PortfolioSnapshot:
)
)
cash_balance = _first_numeric_by_keyword_groups(balances, _CASH_BALANCE_KEYWORD_GROUPS)
buying_power = cash_balance
reported_buying_power = _first_numeric_by_keyword_groups(balances, _BUYING_POWER_KEYWORD_GROUPS)
buying_power = cash_balance if self.cash_only_execution else (
reported_buying_power if reported_buying_power is not None else cash_balance
)
position_market_value = sum(position.market_value for position in positions)
total_equity, total_equity_source = _resolve_total_equity(
balances=balances,
cash_balance=cash_balance,
buying_power=buying_power,
position_market_value=position_market_value,
prefer_cash_plus_positions=bool(managed),
cash_only_execution=self.cash_only_execution,
)
return PortfolioSnapshot(
as_of=self.clock(),
total_equity=float(total_equity),
buying_power=float(cash_balance or 0.0),
buying_power=float(buying_power or 0.0),
cash_balance=cash_balance,
positions=tuple(positions),
metadata={
"broker": "firstrade",
"account_hash": self.account_hash or mask_account_id(self.account),
"api_kind": "unofficial-reverse-engineered",
"total_equity_source": total_equity_source,
"cash_only_execution": self.cash_only_execution,
"market_currency_cash": cash_balance,
"available_funds": reported_buying_power,
},
)

Expand Down Expand Up @@ -321,6 +334,7 @@ def build_runtime_broker_adapters(
live_orders: bool = False,
live_order_ack: bool = False,
max_order_notional_usd: float | None = None,
cash_only_execution: bool = True,
) -> FirstradeBrokerAdapters:
return FirstradeBrokerAdapters(
client=client,
Expand All @@ -331,4 +345,5 @@ def build_runtime_broker_adapters(
live_orders=live_orders,
live_order_ack=live_order_ack,
max_order_notional_usd=max_order_notional_usd,
cash_only_execution=bool(cash_only_execution),
)
11 changes: 7 additions & 4 deletions decision_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@
from dataclasses import replace
from typing import Any

from us_equity_strategies.cash_only_equity import (
build_cash_only_portfolio_inputs_from_snapshot,
)
from us_equity_strategies.cash_only_equity import build_portfolio_inputs_from_snapshot
from quant_platform_kit.strategy_contracts import (
PositionTarget,
StrategyContractValidationError,
Expand Down Expand Up @@ -405,8 +403,13 @@ def map_strategy_decision_to_plan(
runtime_metadata: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
canonical_profile = resolve_canonical_profile(strategy_profile)
portfolio_inputs = build_cash_only_portfolio_inputs_from_snapshot(
raw_policy = (runtime_metadata or {}).get("firstrade_execution_policy")
cash_only_execution = True
if isinstance(raw_policy, Mapping):
cash_only_execution = bool(raw_policy.get("cash_only_execution", True))
portfolio_inputs = build_portfolio_inputs_from_snapshot(
snapshot,
cash_only_execution=cash_only_execution,
include_sellable_quantities=True,
)
normalized_decision, translated_annotations = _normalize_to_value_decision(
Expand Down
6 changes: 5 additions & 1 deletion notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ def format_small_account_whole_share_bootstrap_notes(
"equity": "净值",
"total_assets": "总资产(策略标的+现金)",
"buying_power": "可用现金",
"buying_power_margin": "购买力",
"reserved_cash": "预留现金",
"investable_cash": "可投资现金",
"cash_label": "现金",
Expand Down Expand Up @@ -352,6 +353,7 @@ def format_small_account_whole_share_bootstrap_notes(
"equity": "Equity",
"total_assets": "Total assets",
"buying_power": "Available cash",
"buying_power_margin": "Buying power",
"reserved_cash": "Reserved cash",
"investable_cash": "Investable cash",
"cash_label": "Cash",
Expand Down Expand Up @@ -717,7 +719,9 @@ def _format_generated_dashboard_lines(
if buying_power is None:
buying_power = _safe_float(portfolio.get("buying_power"))
if buying_power is not None:
lines.append(f" - {translator('buying_power')}: {_format_money(buying_power)}")
cash_only_execution = bool(execution.get("cash_only_execution", portfolio.get("cash_only_execution", True)))
buying_power_label = "buying_power" if cash_only_execution else "buying_power_margin"
lines.append(f" - {translator(buying_power_label)}: {_format_money(buying_power)}")
reserved_cash = _safe_float(execution.get("reserved_cash"))
if reserved_cash is not None:
lines.append(f" - {translator('reserved_cash')}: {_format_money(reserved_cash)}")
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ flask
gunicorn
firstrade==0.0.39
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b821e8c318e15d40f925c84a007ae335a3415cd5
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@6772cb1
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@07232b0
google-cloud-storage
google-auth
requests
Expand Down
7 changes: 7 additions & 0 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class PlatformRuntimeSettings:
live_order_ack: bool
max_order_notional_usd: float | None
runtime_target_enabled: bool = True
cash_only_execution: bool = True
reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD
reserved_cash_ratio: float = DEFAULT_RESERVED_CASH_RATIO
persist_strategy_runs: bool = False
Expand Down Expand Up @@ -164,6 +165,7 @@ def load_platform_runtime_settings(
tg_chat_id=os.getenv("GLOBAL_TELEGRAM_CHAT_ID"),
dry_run_only=dry_run_only,
runtime_target_enabled=_runtime_target_enabled_env(),
cash_only_execution=_resolve_cash_only_execution_env(),
live_trading_enabled=resolve_bool_value(os.getenv("FIRSTRADE_ENABLE_LIVE_TRADING")),
run_strategy_on_http=resolve_bool_value(os.getenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP")),
live_order_ack=resolve_bool_value(os.getenv("FIRSTRADE_LIVE_ORDER_ACK")),
Expand Down Expand Up @@ -368,6 +370,11 @@ def _runtime_target_enabled_env() -> bool:
return True if value is None else value


def _resolve_cash_only_execution_env(name: str = "CASH_ONLY_EXECUTION") -> bool:
value = _optional_bool_env(name)
return True if value is None else value


def _optional_ratio_env(name: str) -> float | None:
value = resolve_optional_float_env(os.environ, name)
if value is None:
Expand Down
11 changes: 11 additions & 0 deletions tests/test_runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ def test_reserved_cash_policy_loads_from_env(monkeypatch):
assert settings.reserved_cash_ratio == 0.025


def test_cash_only_execution_defaults_true_and_loads_override(monkeypatch):
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json("tqqq_growth_income"))

default_settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
assert default_settings.cash_only_execution is True

monkeypatch.setenv("CASH_ONLY_EXECUTION", "false")
disabled_settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
assert disabled_settings.cash_only_execution is False


def test_income_layer_overrides_load_from_env(monkeypatch):
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json("tqqq_growth_income"))
monkeypatch.setenv("INCOME_LAYER_ENABLED", "false")
Expand Down
Loading