Skip to content

Commit ffc984f

Browse files
Pigbibicursoragent
andcommitted
feat(runtime): add CASH_ONLY_EXECUTION setting (default true)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8d2dd29 commit ffc984f

9 files changed

Lines changed: 64 additions & 17 deletions

File tree

.github/workflows/sync-cloud-run-env.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ jobs:
120120
INCOME_LAYER_ENABLED: ${{ vars.INCOME_LAYER_ENABLED }}
121121
INCOME_LAYER_START_USD: ${{ vars.INCOME_LAYER_START_USD }}
122122
INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }}
123+
CASH_ONLY_EXECUTION: ${{ vars.CASH_ONLY_EXECUTION }}
123124
DCA_MODE: ${{ vars.DCA_MODE }}
124125
DCA_BASE_INVESTMENT_USD: ${{ vars.DCA_BASE_INVESTMENT_USD }}
125126
IBIT_ZSCORE_EXIT_ENABLED: ${{ vars.IBIT_ZSCORE_EXIT_ENABLED }}
@@ -597,6 +598,7 @@ jobs:
597598
add_optional_env INCOME_LAYER_ENABLED
598599
add_optional_env INCOME_LAYER_START_USD
599600
add_optional_env INCOME_LAYER_MAX_RATIO
601+
add_optional_env CASH_ONLY_EXECUTION
600602
add_optional_env DCA_MODE
601603
add_optional_env DCA_BASE_INVESTMENT_USD
602604
add_optional_env IBIT_ZSCORE_EXIT_ENABLED

application/execution_service.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ def execute_value_target_plan(
454454
limit_buy_premium_by_symbol: dict[str, float] | None = None,
455455
max_order_notional_usd: float | None = None,
456456
safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD,
457+
cash_only_execution: bool = True,
457458
) -> ExecutionCycleResult:
458459
del dry_run_only # ExecutionPort owns preview vs live submission.
459460
plan = substitute_small_safe_haven_targets_with_cash(
@@ -571,7 +572,7 @@ def execute_value_target_plan(
571572

572573
buy_deltas = [item for item in tradable_deltas if item[1] > 0]
573574
buys_blocked_reason: str | None = None
574-
if buy_deltas and pending_sell_release_symbols:
575+
if cash_only_execution and buy_deltas and pending_sell_release_symbols:
575576
estimated_buy_cost = 0.0
576577
for symbol, delta_value, price in buy_deltas:
577578
buy_budget = min(float(delta_value), investable_cash)
@@ -594,7 +595,7 @@ def execute_value_target_plan(
594595
}
595596
)
596597
buy_deltas = []
597-
elif buy_deltas and raw_liquid_cash < 0.0:
598+
elif cash_only_execution and buy_deltas and raw_liquid_cash < 0.0:
598599
buys_blocked_reason = "negative_cash"
599600
for symbol, _delta_value, _price in buy_deltas:
600601
skipped.append(

application/rebalance_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ def _runtime_metadata_with_execution_policy(
347347
runtime_metadata["firstrade_execution_policy"] = {
348348
"reserved_cash_floor_usd": float(settings.reserved_cash_floor_usd or 0.0),
349349
"reserved_cash_ratio": float(settings.reserved_cash_ratio or 0.0),
350+
"cash_only_execution": bool(settings.cash_only_execution),
350351
}
351352
return runtime_metadata
352353

@@ -396,6 +397,7 @@ def log_message(message: str) -> None:
396397
live_orders=not settings.dry_run_only,
397398
live_order_ack=settings.live_order_ack,
398399
max_order_notional_usd=settings.max_order_notional_usd,
400+
cash_only_execution=settings.cash_only_execution,
399401
)
400402
market_data_port = broker_adapters.build_market_data_port()
401403
portfolio_port = broker_adapters.build_portfolio_port()
@@ -434,6 +436,7 @@ def log_message(message: str) -> None:
434436
plan,
435437
threshold_usd=settings.safe_haven_cash_substitute_threshold_usd,
436438
)
439+
plan.setdefault("execution", {})["cash_only_execution"] = bool(settings.cash_only_execution)
437440
run_period = resolve_strategy_run_period(
438441
now=now,
439442
plan=plan,
@@ -562,6 +565,7 @@ def log_message(message: str) -> None:
562565
limit_buy_premium_by_symbol=LIMIT_BUY_PREMIUM_BY_SYMBOL,
563566
max_order_notional_usd=settings.max_order_notional_usd,
564567
safe_haven_cash_substitute_threshold_usd=settings.safe_haven_cash_substitute_threshold_usd,
568+
cash_only_execution=settings.cash_only_execution,
565569
)
566570
submitted_orders = list(execution_result.submitted_orders)
567571
skipped_orders = list(execution_result.skipped_orders)

application/runtime_broker_adapters.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,19 @@ def _resolve_total_equity(
9090
buying_power: float | None,
9191
position_market_value: float,
9292
prefer_cash_plus_positions: bool = False,
93+
cash_only_execution: bool = True,
9394
) -> tuple[float, str]:
94-
del buying_power # Cash-only: never use margin buying power for strategy equity.
95-
if cash_balance is not None:
96-
combined_value = float(cash_balance) + max(0.0, float(position_market_value))
97-
if prefer_cash_plus_positions:
98-
return combined_value, "cash_plus_positions"
99-
if combined_value > 0.0:
100-
return combined_value, "cash_plus_positions"
95+
if cash_only_execution:
96+
if cash_balance is not None:
97+
combined_value = float(cash_balance) + max(0.0, float(position_market_value))
98+
if prefer_cash_plus_positions:
99+
return combined_value, "cash_plus_positions"
100+
if combined_value > 0.0:
101+
return combined_value, "cash_plus_positions"
102+
elif buying_power is not None:
103+
combined_value = float(buying_power) + max(0.0, float(position_market_value))
104+
if prefer_cash_plus_positions or combined_value > 0.0:
105+
return combined_value, "buying_power_plus_positions"
101106

102107
balance_total = _first_numeric_by_keyword_groups(balances, _TOTAL_EQUITY_KEYWORD_GROUPS)
103108
if balance_total is not None:
@@ -120,6 +125,7 @@ class FirstradeBrokerAdapters:
120125
live_orders: bool = False
121126
live_order_ack: bool = False
122127
max_order_notional_usd: float | None = None
128+
cash_only_execution: bool = True
123129

124130
def normalize_symbol(self, symbol: str) -> str:
125131
value = str(symbol or "").strip().upper()
@@ -247,26 +253,33 @@ def build_portfolio_snapshot(self) -> PortfolioSnapshot:
247253
)
248254
)
249255
cash_balance = _first_numeric_by_keyword_groups(balances, _CASH_BALANCE_KEYWORD_GROUPS)
250-
buying_power = cash_balance
256+
reported_buying_power = _first_numeric_by_keyword_groups(balances, _BUYING_POWER_KEYWORD_GROUPS)
257+
buying_power = cash_balance if self.cash_only_execution else (
258+
reported_buying_power if reported_buying_power is not None else cash_balance
259+
)
251260
position_market_value = sum(position.market_value for position in positions)
252261
total_equity, total_equity_source = _resolve_total_equity(
253262
balances=balances,
254263
cash_balance=cash_balance,
255264
buying_power=buying_power,
256265
position_market_value=position_market_value,
257266
prefer_cash_plus_positions=bool(managed),
267+
cash_only_execution=self.cash_only_execution,
258268
)
259269
return PortfolioSnapshot(
260270
as_of=self.clock(),
261271
total_equity=float(total_equity),
262-
buying_power=float(cash_balance or 0.0),
272+
buying_power=float(buying_power or 0.0),
263273
cash_balance=cash_balance,
264274
positions=tuple(positions),
265275
metadata={
266276
"broker": "firstrade",
267277
"account_hash": self.account_hash or mask_account_id(self.account),
268278
"api_kind": "unofficial-reverse-engineered",
269279
"total_equity_source": total_equity_source,
280+
"cash_only_execution": self.cash_only_execution,
281+
"market_currency_cash": cash_balance,
282+
"available_funds": reported_buying_power,
270283
},
271284
)
272285

@@ -321,6 +334,7 @@ def build_runtime_broker_adapters(
321334
live_orders: bool = False,
322335
live_order_ack: bool = False,
323336
max_order_notional_usd: float | None = None,
337+
cash_only_execution: bool = True,
324338
) -> FirstradeBrokerAdapters:
325339
return FirstradeBrokerAdapters(
326340
client=client,
@@ -331,4 +345,5 @@ def build_runtime_broker_adapters(
331345
live_orders=live_orders,
332346
live_order_ack=live_order_ack,
333347
max_order_notional_usd=max_order_notional_usd,
348+
cash_only_execution=bool(cash_only_execution),
334349
)

decision_mapper.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
from dataclasses import replace
55
from typing import Any
66

7-
from us_equity_strategies.cash_only_equity import (
8-
build_cash_only_portfolio_inputs_from_snapshot,
9-
)
7+
from us_equity_strategies.cash_only_equity import build_portfolio_inputs_from_snapshot
108
from quant_platform_kit.strategy_contracts import (
119
PositionTarget,
1210
StrategyContractValidationError,
@@ -405,8 +403,13 @@ def map_strategy_decision_to_plan(
405403
runtime_metadata: Mapping[str, Any] | None = None,
406404
) -> dict[str, Any]:
407405
canonical_profile = resolve_canonical_profile(strategy_profile)
408-
portfolio_inputs = build_cash_only_portfolio_inputs_from_snapshot(
406+
raw_policy = (runtime_metadata or {}).get("firstrade_execution_policy")
407+
cash_only_execution = True
408+
if isinstance(raw_policy, Mapping):
409+
cash_only_execution = bool(raw_policy.get("cash_only_execution", True))
410+
portfolio_inputs = build_portfolio_inputs_from_snapshot(
409411
snapshot,
412+
cash_only_execution=cash_only_execution,
410413
include_sellable_quantities=True,
411414
)
412415
normalized_decision, translated_annotations = _normalize_to_value_decision(

notifications/telegram.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ def format_small_account_whole_share_bootstrap_notes(
186186
"equity": "净值",
187187
"total_assets": "总资产(策略标的+现金)",
188188
"buying_power": "可用现金",
189+
"buying_power_margin": "购买力",
189190
"reserved_cash": "预留现金",
190191
"investable_cash": "可投资现金",
191192
"cash_label": "现金",
@@ -352,6 +353,7 @@ def format_small_account_whole_share_bootstrap_notes(
352353
"equity": "Equity",
353354
"total_assets": "Total assets",
354355
"buying_power": "Available cash",
356+
"buying_power_margin": "Buying power",
355357
"reserved_cash": "Reserved cash",
356358
"investable_cash": "Investable cash",
357359
"cash_label": "Cash",
@@ -717,7 +719,9 @@ def _format_generated_dashboard_lines(
717719
if buying_power is None:
718720
buying_power = _safe_float(portfolio.get("buying_power"))
719721
if buying_power is not None:
720-
lines.append(f" - {translator('buying_power')}: {_format_money(buying_power)}")
722+
cash_only_execution = bool(execution.get("cash_only_execution", portfolio.get("cash_only_execution", True)))
723+
buying_power_label = "buying_power" if cash_only_execution else "buying_power_margin"
724+
lines.append(f" - {translator(buying_power_label)}: {_format_money(buying_power)}")
721725
reserved_cash = _safe_float(execution.get("reserved_cash"))
722726
if reserved_cash is not None:
723727
lines.append(f" - {translator('reserved_cash')}: {_format_money(reserved_cash)}")

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ flask
22
gunicorn
33
firstrade==0.0.39
44
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b821e8c318e15d40f925c84a007ae335a3415cd5
5-
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@6772cb1
5+
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@07232b0
66
google-cloud-storage
77
google-auth
88
requests

runtime_config_support.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class PlatformRuntimeSettings:
4747
live_order_ack: bool
4848
max_order_notional_usd: float | None
4949
runtime_target_enabled: bool = True
50+
cash_only_execution: bool = True
5051
reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD
5152
reserved_cash_ratio: float = DEFAULT_RESERVED_CASH_RATIO
5253
persist_strategy_runs: bool = False
@@ -164,6 +165,7 @@ def load_platform_runtime_settings(
164165
tg_chat_id=os.getenv("GLOBAL_TELEGRAM_CHAT_ID"),
165166
dry_run_only=dry_run_only,
166167
runtime_target_enabled=_runtime_target_enabled_env(),
168+
cash_only_execution=_resolve_cash_only_execution_env(),
167169
live_trading_enabled=resolve_bool_value(os.getenv("FIRSTRADE_ENABLE_LIVE_TRADING")),
168170
run_strategy_on_http=resolve_bool_value(os.getenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP")),
169171
live_order_ack=resolve_bool_value(os.getenv("FIRSTRADE_LIVE_ORDER_ACK")),
@@ -368,6 +370,11 @@ def _runtime_target_enabled_env() -> bool:
368370
return True if value is None else value
369371

370372

373+
def _resolve_cash_only_execution_env(name: str = "CASH_ONLY_EXECUTION") -> bool:
374+
value = _optional_bool_env(name)
375+
return True if value is None else value
376+
377+
371378
def _optional_ratio_env(name: str) -> float | None:
372379
value = resolve_optional_float_env(os.environ, name)
373380
if value is None:

tests/test_runtime_config_support.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,17 @@ def test_reserved_cash_policy_loads_from_env(monkeypatch):
101101
assert settings.reserved_cash_ratio == 0.025
102102

103103

104+
def test_cash_only_execution_defaults_true_and_loads_override(monkeypatch):
105+
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json("tqqq_growth_income"))
106+
107+
default_settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
108+
assert default_settings.cash_only_execution is True
109+
110+
monkeypatch.setenv("CASH_ONLY_EXECUTION", "false")
111+
disabled_settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
112+
assert disabled_settings.cash_only_execution is False
113+
114+
104115
def test_income_layer_overrides_load_from_env(monkeypatch):
105116
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json("tqqq_growth_income"))
106117
monkeypatch.setenv("INCOME_LAYER_ENABLED", "false")

0 commit comments

Comments
 (0)