Skip to content

Commit fceee54

Browse files
committed
Add safe-haven cash substitution threshold
1 parent e5ea694 commit fceee54

11 files changed

Lines changed: 140 additions & 4 deletions

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ jobs:
3131
IBKR_RECONCILIATION_OUTPUT_PATH: ${{ vars.IBKR_RECONCILIATION_OUTPUT_PATH }}
3232
IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }}
3333
IBKR_PAPER_LIQUIDATE_ONLY: ${{ vars.IBKR_PAPER_LIQUIDATE_ONLY }}
34+
IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }}
3435
# Strategy-owned defaults continue to come from UsEquityStrategies; this workflow only syncs platform/runtime inputs.
3536
EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }}
3637
IB_GATEWAY_ZONE: ${{ vars.IB_GATEWAY_ZONE }}
@@ -324,6 +325,12 @@ jobs:
324325
remove_env_vars+=("IBKR_PAPER_LIQUIDATE_ONLY")
325326
fi
326327
328+
if [ -n "${IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD:-}" ]; then
329+
env_pairs+=("IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD=${IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD}")
330+
else
331+
remove_env_vars+=("IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD")
332+
fi
333+
327334
if [ -n "${EXECUTION_REPORT_GCS_URI:-}" ]; then
328335
env_pairs+=("EXECUTION_REPORT_GCS_URI=${EXECUTION_REPORT_GCS_URI}")
329336
else

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac
104104
| `IBKR_FEATURE_SNAPSHOT_PATH` | Conditionally required | Required for snapshot-backed profiles such as `russell_1000_multi_factor_defensive`, `tech_communication_pullback_enhancement`, and `mega_cap_leader_rotation_top50_balanced`. Path to the latest feature snapshot file (`.csv`, `.json`, `.jsonl`, `.parquet`). |
105105
| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | No | Optional IBKR-side strategy plugin mount JSON. The plugin artifact controls mode; platform config must not set `mode`. |
106106
| `IBKR_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for limit buys; defaults to `50.0`. |
107+
| `IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD` | No | Safe-haven target values below this USD amount are kept as cash instead of buying BOXX/BIL. Defaults to `1000.0`. |
107108
| `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Yes for Cloud Run | Secret Manager secret name for account-group config JSON. Recommended production source. |
108109
| `IB_ACCOUNT_GROUP_CONFIG_JSON` | No | Local/dev JSON fallback for account-group config. Not recommended for production Cloud Run. |
109110
| `TELEGRAM_TOKEN` | Yes | Telegram bot token. For Cloud Run, prefer a Secret Manager reference instead of a literal env var. |
@@ -218,7 +219,7 @@ Recommended setup:
218219
- `STRATEGY_PROFILE` (set explicitly to one enabled profile, such as `soxl_soxx_trend_income`)
219220
- `ACCOUNT_GROUP` (recommended: `paper`)
220221
- `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`
221-
- Optional: `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON`
222+
- Optional: `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON`, `IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`
222223
- `GLOBAL_TELEGRAM_CHAT_ID`
223224
- `NOTIFY_LANG`
224225
- **Repository Secrets**
@@ -340,6 +341,7 @@ IBKR 账户
340341
| `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive``tech_communication_pullback_enhancement``mega_cap_leader_rotation_top50_balanced` 等快照策略需要。指向最新特征快照文件(`.csv``.json``.jsonl``.parquet`)。 |
341342
| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` || 可选的 IBKR 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`|
342343
| `IBKR_MIN_ORDER_NOTIONAL_USD` || 限价买入的最小名义金额;默认 `50.0`|
344+
| `IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD` || `BOXX`/`BIL` 等避险标的目标金额低于该 USD 门槛时保留现金,不买入。默认 `1000.0`|
343345
| `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Cloud Run 建议必填 | 账号组配置 JSON 在 Secret Manager 里的密钥名。生产环境推荐使用。 |
344346
| `IB_ACCOUNT_GROUP_CONFIG_JSON` || 本地开发用的账号组配置 JSON fallback。不建议在生产 Cloud Run 直接使用。 |
345347
| `TELEGRAM_TOKEN` || Telegram 机器人 Token。Cloud Run 上更推荐走 Secret Manager 引用,不要直接写成明文 env。 |
@@ -420,7 +422,7 @@ IB_GATEWAY_IP_MODE=internal
420422
- `STRATEGY_PROFILE`(显式设置为任一已启用 profile,例如 `soxl_soxx_trend_income`
421423
- `ACCOUNT_GROUP`(建议设为 `paper`
422424
- `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`
423-
- 可选:`IBKR_STRATEGY_PLUGIN_MOUNTS_JSON`
425+
- 可选:`IBKR_STRATEGY_PLUGIN_MOUNTS_JSON``IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`
424426
- `GLOBAL_TELEGRAM_CHAT_ID`
425427
- `NOTIFY_LANG`
426428
- **仓库级 Secrets**

application/execution_service.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,36 @@ def _sell_order_quantity(
510510
)
511511

512512

513+
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
514+
515+
516+
def _apply_safe_haven_cash_substitution_to_weights(
517+
target_weights: dict[str, float],
518+
*,
519+
safe_haven_symbols: tuple[str, ...],
520+
investable: float,
521+
threshold_usd: float,
522+
) -> tuple[dict[str, float], tuple[str, ...]]:
523+
threshold = max(0.0, float(threshold_usd or 0.0))
524+
adjusted = {
525+
str(symbol).strip().upper(): float(weight or 0.0)
526+
for symbol, weight in dict(target_weights or {}).items()
527+
}
528+
if threshold <= 0.0:
529+
return adjusted, ()
530+
531+
substituted: list[str] = []
532+
for symbol in safe_haven_symbols:
533+
normalized = str(symbol or "").strip().upper()
534+
if not normalized:
535+
continue
536+
target_notional = max(0.0, float(investable or 0.0) * float(adjusted.get(normalized, 0.0) or 0.0))
537+
if 0.0 < target_notional < threshold:
538+
adjusted[normalized] = 0.0
539+
substituted.append(normalized)
540+
return adjusted, tuple(dict.fromkeys(substituted))
541+
542+
513543
def _finalize_result(trade_logs, execution_summary, *, return_summary: bool):
514544
if return_summary:
515545
return trade_logs, execution_summary
@@ -539,6 +569,7 @@ def execute_rebalance(
539569
sell_settle_delay_sec,
540570
quantity_step=1.0,
541571
min_order_notional=50.0,
572+
safe_haven_cash_substitute_threshold_usd=DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD,
542573
execution_lock_dir=None,
543574
return_summary=False,
544575
):
@@ -578,6 +609,10 @@ def execute_rebalance(
578609
"execution_status": "not_started",
579610
"no_op_reason": None,
580611
"cash_reserve_dollars": 0.0,
612+
"safe_haven_cash_substitute_threshold_usd": float(
613+
max(0.0, float(safe_haven_cash_substitute_threshold_usd or 0.0))
614+
),
615+
"safe_haven_cash_substituted_symbols": [],
581616
"residual_cash_estimate": float(account_values.get("buying_power", 0.0) or 0.0),
582617
"current_stock_weight": 0.0,
583618
"current_safe_haven_weight": 0.0,
@@ -594,6 +629,20 @@ def execute_rebalance(
594629

595630
reserved = equity * cash_reserve_ratio
596631
investable = equity - reserved
632+
target_weights, substituted_safe_haven_symbols = _apply_safe_haven_cash_substitution_to_weights(
633+
target_weights,
634+
safe_haven_symbols=safe_haven_symbols,
635+
investable=investable,
636+
threshold_usd=safe_haven_cash_substitute_threshold_usd,
637+
)
638+
execution_summary["safe_haven_cash_substituted_symbols"] = list(substituted_safe_haven_symbols)
639+
if safe_haven_symbols:
640+
execution_summary["realized_safe_haven_weight"] = float(
641+
sum(
642+
float(target_weights.get(str(symbol or "").strip().upper(), 0.0) or 0.0)
643+
for symbol in safe_haven_symbols
644+
)
645+
)
597646
threshold = equity * rebalance_threshold_ratio
598647
order_quantity_step = float(quantity_step or 1.0)
599648
minimum_order_notional = max(0.0, float(min_order_notional or 0.0))

application/runtime_broker_adapters.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class IBKRRuntimeBrokerAdapters:
4040
limit_buy_premium: float
4141
quantity_step: float
4242
min_order_notional: float
43+
safe_haven_cash_substitute_threshold_usd: float
4344
sell_settle_delay_sec: float
4445
separator: str
4546
strategy_display_name: str
@@ -160,6 +161,7 @@ def execute_rebalance(
160161
limit_buy_premium=self.limit_buy_premium,
161162
quantity_step=self.quantity_step,
162163
min_order_notional=self.min_order_notional,
164+
safe_haven_cash_substitute_threshold_usd=self.safe_haven_cash_substitute_threshold_usd,
163165
sell_settle_delay_sec=self.sell_settle_delay_sec,
164166
return_summary=True,
165167
)
@@ -245,6 +247,7 @@ def build_runtime_broker_adapters(
245247
limit_buy_premium: float,
246248
quantity_step: float,
247249
min_order_notional: float,
250+
safe_haven_cash_substitute_threshold_usd: float,
248251
sell_settle_delay_sec: float,
249252
separator: str,
250253
strategy_display_name: str,
@@ -280,6 +283,7 @@ def build_runtime_broker_adapters(
280283
limit_buy_premium=float(limit_buy_premium),
281284
quantity_step=float(quantity_step),
282285
min_order_notional=float(min_order_notional),
286+
safe_haven_cash_substitute_threshold_usd=float(safe_haven_cash_substitute_threshold_usd),
283287
sell_settle_delay_sec=float(sell_settle_delay_sec),
284288
separator=str(separator or ""),
285289
strategy_display_name=str(strategy_display_name or ""),

main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ def build_broker_adapters():
327327
limit_buy_premium=LIMIT_BUY_PREMIUM,
328328
quantity_step=RUNTIME_SETTINGS.quantity_step,
329329
min_order_notional=RUNTIME_SETTINGS.min_order_notional,
330+
safe_haven_cash_substitute_threshold_usd=RUNTIME_SETTINGS.safe_haven_cash_substitute_threshold_usd,
330331
sell_settle_delay_sec=SELL_SETTLE_DELAY_SEC,
331332
separator=SEPARATOR,
332333
strategy_display_name=strategy_display_name,

runtime_config_support.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from us_equity_strategies import get_strategy_catalog
2525

2626
DEFAULT_ACCOUNT_GROUP = "default"
27+
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
2728

2829

2930
@dataclass(frozen=True)
@@ -59,6 +60,7 @@ class PlatformRuntimeSettings:
5960
dry_run_only: bool
6061
quantity_step: float = 1.0
6162
min_order_notional: float = 50.0
63+
safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD
6264
account_group: str = DEFAULT_ACCOUNT_GROUP
6365
service_name: str | None = None
6466
account_ids: tuple[str, ...] = ()
@@ -151,6 +153,14 @@ def load_platform_runtime_settings(
151153
"IBKR_MIN_ORDER_NOTIONAL_USD",
152154
default=50.0,
153155
),
156+
safe_haven_cash_substitute_threshold_usd=max(
157+
0.0,
158+
resolve_float_env(
159+
os.environ,
160+
"IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD",
161+
default=DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD,
162+
),
163+
),
154164
account_group=account_group,
155165
service_name=group_config.service_name,
156166
account_ids=group_config.account_ids,

scripts/print_strategy_switch_env_plan.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ def build_switch_plan(profile: str) -> dict[str, object]:
6767
"ACCOUNT_GROUP",
6868
"IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME",
6969
]
70-
optional_env = ["IBKR_DRY_RUN_ONLY"]
70+
optional_env = [
71+
"IBKR_DRY_RUN_ONLY",
72+
"IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD",
73+
]
7174
remove_if_present: list[str] = []
7275
notes = [
7376
"Keep ACCOUNT_GROUP and IB account-group config aligned with the current service identity.",

tests/test_connect_timeout_alert.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ def run(self, *args, **kwargs):
124124
dry_run_only=False,
125125
quantity_step=1,
126126
min_order_notional=0.0,
127+
safe_haven_cash_substitute_threshold_usd=1000.0,
127128
runtime_target={},
128129
notify_lang="en",
129130
tg_token=None,

tests/test_execution_service.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,57 @@ def fake_fetch_quote_snapshots(_ib, symbols):
470470
assert summary["target_vs_current"]
471471

472472

473+
def test_execute_rebalance_keeps_small_safe_haven_target_as_cash(monkeypatch, tmp_path):
474+
class FakeIB:
475+
def openTrades(self):
476+
return []
477+
478+
def fills(self):
479+
return []
480+
481+
def accountValues(self):
482+
return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="1500")]
483+
484+
monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None)
485+
486+
trade_logs, summary = execute_rebalance(
487+
FakeIB(),
488+
{"VOO": 0.5, "BOXX": 0.5},
489+
{},
490+
{"equity": 1500.0, "buying_power": 1500.0},
491+
fetch_quote_snapshots=lambda _ib, symbols: {
492+
symbol: SimpleNamespace(last_price=100.0) for symbol in symbols
493+
},
494+
submit_order_intent=lambda *_args, **_kwargs: SimpleNamespace(broker_order_id="1", status="Submitted"),
495+
order_intent_cls=OrderIntent,
496+
translator=translate,
497+
strategy_symbols=["VOO", "BOXX"],
498+
strategy_profile="tech_communication_pullback_enhancement",
499+
signal_metadata=_signal_metadata(
500+
{"VOO": 0.5, "BOXX": 0.5},
501+
risk_symbols=("VOO",),
502+
safe_haven_symbols=("BOXX",),
503+
trade_date="2026-04-01",
504+
snapshot_as_of="2026-03-31",
505+
),
506+
dry_run_only=True,
507+
cash_reserve_ratio=0.0,
508+
rebalance_threshold_ratio=0.01,
509+
limit_buy_premium=1.0,
510+
sell_settle_delay_sec=0,
511+
execution_lock_dir=tmp_path,
512+
return_summary=True,
513+
safe_haven_cash_substitute_threshold_usd=1000.0,
514+
)
515+
516+
assert any("DRY_RUN buy VOO" in log for log in trade_logs)
517+
assert not any(order["symbol"] == "BOXX" for order in summary["orders_submitted"])
518+
assert summary["safe_haven_cash_substituted_symbols"] == ["BOXX"]
519+
assert summary["realized_safe_haven_weight"] == 0.0
520+
boxx_row = next(row for row in summary["target_vs_current"] if row["symbol"] == "BOXX")
521+
assert boxx_row["target_weight"] == 0.0
522+
523+
473524
def test_execute_rebalance_sells_cash_sweep_symbol_when_buying_power_is_short(monkeypatch, tmp_path):
474525
class FakeIB:
475526
def __init__(self):
@@ -645,7 +696,7 @@ def accountValues(self):
645696
FakeIB(),
646697
{"VOO": 0.6, "BOXX": 0.4},
647698
{},
648-
{"equity": 1000.0, "buying_power": 1000.0},
699+
{"equity": 3000.0, "buying_power": 3000.0},
649700
fetch_quote_snapshots=lambda *_args, **_kwargs: {},
650701
submit_order_intent=lambda *_args, **_kwargs: None,
651702
order_intent_cls=OrderIntent,

tests/test_runtime_config_support.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77

88
from runtime_config_support import (
9+
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD,
910
load_platform_runtime_settings,
1011
parse_account_group_configs,
1112
)
@@ -121,6 +122,7 @@ def test_load_platform_runtime_settings_uses_minimal_group_config(monkeypatch):
121122
assert settings.dry_run_only is False
122123
assert settings.quantity_step == 1.0
123124
assert settings.min_order_notional == 50.0
125+
assert settings.safe_haven_cash_substitute_threshold_usd == DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD
124126
assert settings.account_group == "paper"
125127
assert settings.service_name is None
126128
assert settings.account_ids == ()
@@ -198,11 +200,13 @@ def test_load_platform_runtime_settings_uses_whole_share_quantity_step(monkeypat
198200
monkeypatch.setenv("ACCOUNT_GROUP", "paper")
199201
monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON)
200202
monkeypatch.setenv("IBKR_MIN_ORDER_NOTIONAL_USD", "5")
203+
monkeypatch.setenv("IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD", "750")
201204

202205
settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
203206

204207
assert settings.quantity_step == 1.0
205208
assert settings.min_order_notional == 5.0
209+
assert settings.safe_haven_cash_substitute_threshold_usd == 750.0
206210

207211

208212
def test_load_platform_runtime_settings_reads_ibkr_strategy_plugin_mounts(monkeypatch):
@@ -427,6 +431,7 @@ def test_print_strategy_switch_env_plan_for_tqqq_growth_income():
427431
assert plan["requires_strategy_config_path"] is False
428432
assert json.loads(plan["set_env"]["RUNTIME_TARGET_JSON"])["strategy_profile"] == "tqqq_growth_income"
429433
assert "ACCOUNT_GROUP" in plan["keep_env"]
434+
assert "IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD" in plan["optional_env"]
430435
assert "IBKR_FEATURE_SNAPSHOT_PATH" in plan["remove_if_present"]
431436

432437

0 commit comments

Comments
 (0)