Skip to content

Commit 9135829

Browse files
authored
Keep safe haven cash for unbuyable small accounts (#110)
1 parent 8009033 commit 9135829

3 files changed

Lines changed: 104 additions & 4 deletions

File tree

application/execution_service.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,17 @@ def _sell_order_quantity(
547547

548548

549549
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
550+
SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0
551+
552+
553+
def _positive_target_total(targets: dict[str, Any]) -> float:
554+
total = 0.0
555+
for value in dict(targets or {}).values():
556+
try:
557+
total += max(0.0, float(value or 0.0))
558+
except (TypeError, ValueError):
559+
continue
560+
return total
550561

551562

552563
def _apply_safe_haven_cash_substitution_to_weights(
@@ -651,6 +662,7 @@ def execute_rebalance(
651662
),
652663
"safe_haven_cash_substituted_symbols": [],
653664
"small_account_whole_share_substituted_symbols": [],
665+
"small_account_safe_haven_cash_substituted_symbols": [],
654666
"residual_cash_estimate": float(account_values.get("buying_power", 0.0) or 0.0),
655667
"current_stock_weight": 0.0,
656668
"current_safe_haven_weight": 0.0,
@@ -726,11 +738,17 @@ def execute_rebalance(
726738

727739
target_mv = {symbol: investable * weight for symbol, weight in target_weights.items()}
728740
small_account_candidate_symbols = tuple(
729-
dict.fromkeys(tuple(allocation["risk_symbols"]) + tuple(allocation["income_symbols"]))
741+
dict.fromkeys(
742+
str(symbol or "").strip().upper()
743+
for symbol in tuple(allocation["risk_symbols"]) + tuple(allocation["income_symbols"])
744+
if str(symbol or "").strip()
745+
)
730746
)
731747
if not small_account_candidate_symbols:
732748
small_account_candidate_symbols = tuple(
733-
symbol for symbol in target_mv if symbol not in safe_haven_symbols
749+
str(symbol or "").strip().upper()
750+
for symbol in target_mv
751+
if str(symbol or "").strip().upper() not in safe_haven_symbols
734752
)
735753
target_mv, small_account_substituted_symbols = project_unbuyable_value_targets_to_cash(
736754
target_mv,
@@ -740,9 +758,36 @@ def execute_rebalance(
740758
)
741759
for symbol in small_account_substituted_symbols:
742760
target_weights[symbol] = 0.0
761+
remaining_non_safe_targets = [
762+
symbol
763+
for symbol in small_account_candidate_symbols
764+
if float(target_mv.get(str(symbol or "").strip().upper(), 0.0) or 0.0) > 0.0
765+
]
766+
small_account_safe_haven_cash_substituted_symbols: list[str] = []
767+
if (
768+
small_account_substituted_symbols
769+
and not remaining_non_safe_targets
770+
and _positive_target_total(target_mv) <= SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD
771+
):
772+
for symbol in safe_haven_symbols:
773+
normalized_symbol = str(symbol or "").strip().upper()
774+
if float(target_mv.get(normalized_symbol, 0.0) or 0.0) > 0.0:
775+
target_mv[normalized_symbol] = 0.0
776+
target_weights[normalized_symbol] = 0.0
777+
small_account_safe_haven_cash_substituted_symbols.append(normalized_symbol)
778+
if safe_haven_symbols:
779+
execution_summary["realized_safe_haven_weight"] = float(
780+
sum(
781+
float(target_weights.get(str(symbol or "").strip().upper(), 0.0) or 0.0)
782+
for symbol in safe_haven_symbols
783+
)
784+
)
743785
execution_summary["small_account_whole_share_substituted_symbols"] = list(
744786
small_account_substituted_symbols
745787
)
788+
execution_summary["small_account_safe_haven_cash_substituted_symbols"] = (
789+
small_account_safe_haven_cash_substituted_symbols
790+
)
746791
trade_logs = []
747792
target_hash = _build_target_hash(target_weights)
748793
execution_summary["target_vs_current"] = _build_target_diff_rows(target_weights, current_mv, equity)

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
flask
22
gunicorn
3-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@6f71766356beb0b1517a539dcacfe1c6d2719ef8
4-
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@2918e1485561a50ad14abcd0bb05a09bd755b8c4
3+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@190edb21c5fe0c8e0efba66bfd8bacfdd00b3f77
4+
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@ead0c075f5b47ec1d05d3a45cfcdda58217fe086
55
pandas
66
numpy
77
requests

tests/test_execution_service.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,61 @@ def accountValues(self):
223223
assert summary["orders_submitted"][1]["quantity"] == 2
224224

225225

226+
def test_execute_rebalance_keeps_safe_haven_cash_when_only_risk_target_is_unbuyable(tmp_path, monkeypatch):
227+
class FakeIB:
228+
def openTrades(self):
229+
return []
230+
231+
def fills(self):
232+
return []
233+
234+
def accountValues(self):
235+
return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="1294.00")]
236+
237+
prices = {"SOXL": 175.0, "SOXX": 525.0, "BOXX": 116.83}
238+
submitted = []
239+
monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None)
240+
241+
_trade_logs, summary = execute_rebalance(
242+
FakeIB(),
243+
{},
244+
{},
245+
{"equity": 1294.0, "buying_power": 1294.0},
246+
fetch_quote_snapshots=lambda _ib, symbols: {
247+
symbol: SimpleNamespace(last_price=prices[symbol]) for symbol in symbols
248+
},
249+
submit_order_intent=lambda _ib, intent: submitted.append(intent) or SimpleNamespace(
250+
broker_order_id="dry-run",
251+
status="Submitted",
252+
),
253+
order_intent_cls=OrderIntent,
254+
translator=translate,
255+
strategy_symbols=["SOXL", "SOXX", "BOXX"],
256+
strategy_profile="soxl_soxx_trend_income",
257+
signal_metadata=_signal_metadata(
258+
{"SOXL": 0.0, "SOXX": 0.15, "BOXX": 0.85},
259+
risk_symbols=("SOXL", "SOXX"),
260+
safe_haven_symbols=("BOXX",),
261+
trade_date="2026-05-26",
262+
),
263+
dry_run_only=True,
264+
cash_reserve_ratio=0.03,
265+
rebalance_threshold_ratio=0.01,
266+
limit_buy_premium=1.0,
267+
quantity_step=1.0,
268+
sell_settle_delay_sec=0,
269+
execution_lock_dir=tmp_path,
270+
return_summary=True,
271+
)
272+
273+
assert submitted == []
274+
assert summary["small_account_whole_share_substituted_symbols"] == ["SOXX"]
275+
assert summary["small_account_safe_haven_cash_substituted_symbols"] == ["BOXX"]
276+
assert summary["realized_safe_haven_weight"] == 0.0
277+
boxx_row = next(row for row in summary["target_vs_current"] if row["symbol"] == "BOXX")
278+
assert boxx_row["target_weight"] == 0.0
279+
280+
226281
def test_execute_rebalance_routes_order_to_single_account_id(monkeypatch, tmp_path):
227282
class FakeIB:
228283
def openTrades(self):

0 commit comments

Comments
 (0)