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
40 changes: 37 additions & 3 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class ExecutionCycleResult:


DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0


def _floor_quantity(quantity: float) -> int:
Expand Down Expand Up @@ -90,6 +91,16 @@ def _safe_haven_cash_symbols(*, portfolio: dict[str, Any], allocation: dict[str,
return tuple(dict.fromkeys(symbols))


def _positive_target_total(targets: dict[str, Any]) -> float:
total = 0.0
for value in dict(targets or {}).values():
try:
total += max(0.0, float(value or 0.0))
except (TypeError, ValueError):
continue
return total


def substitute_small_safe_haven_targets_with_cash(
plan: dict[str, Any],
*,
Expand Down Expand Up @@ -134,17 +145,22 @@ def _apply_small_account_whole_share_compatibility(
) -> dict[str, Any]:
adjusted_plan = dict(plan or {})
allocation = dict(adjusted_plan.get("allocation") or {})
portfolio = dict(adjusted_plan.get("portfolio") or {})
targets = dict(allocation.get("targets") or {})
candidate_symbols = tuple(
dict.fromkeys(
tuple(allocation.get("risk_symbols", ()))
str(symbol or "").strip().upper()
for symbol in tuple(allocation.get("risk_symbols", ()))
+ tuple(allocation.get("income_symbols", ()))
if str(symbol or "").strip()
)
)
if not candidate_symbols:
safe_haven_symbols = set(allocation.get("safe_haven_symbols", ()))
safe_haven_symbols = set(_safe_haven_cash_symbols(portfolio=portfolio, allocation=allocation))
candidate_symbols = tuple(
symbol for symbol in targets if symbol not in safe_haven_symbols
str(symbol or "").strip().upper()
for symbol in targets
if str(symbol or "").strip().upper() not in safe_haven_symbols
)
prices = {}
for symbol in candidate_symbols:
Expand All @@ -157,9 +173,27 @@ def _apply_small_account_whole_share_compatibility(
symbols=candidate_symbols,
quantity_step=1.0,
)
safe_haven_symbols = _safe_haven_cash_symbols(portfolio=portfolio, allocation=allocation)
remaining_non_safe_targets = [
symbol
for symbol in candidate_symbols
if float(adjusted_targets.get(str(symbol or "").strip().upper(), 0.0) or 0.0) > 0.0
]
Comment on lines +177 to +181

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize candidate symbols before checking remaining targets

If risk_symbols/income_symbols arrive in a different case than the target keys, this lookup misses still-buyable non-safe targets after project_unbuyable_value_targets_to_cash normalizes target keys. In a mixed case plan such as risk_symbols=("soxl", "soxx") with uppercase targets where only SOXX is below one share, both lookups return zero here, so the code incorrectly treats all non-safe targets as gone and zeros the safe-haven target even though SOXL remains buyable. Normalize symbol for the adjusted_targets.get(...) check.

Useful? React with 👍 / 👎.

safe_haven_substituted: list[str] = []
if (
substituted
and not remaining_non_safe_targets
and _positive_target_total(adjusted_targets) <= SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD
):
Comment on lines +183 to +187

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor disabled safe-haven cash substitution

When FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD is set to 0 (or otherwise below the BOXX/BIL target), the earlier helper intentionally leaves safe-haven targets unchanged, but this new branch still zeros them whenever a risk/income target was projected away and the adjusted total is <= $2,000. For example, with threshold 0, an unbuyable SOXX target and a $1,099.90 BOXX cash-sweep target, execution skips the BOXX buy anyway, so operators can no longer opt out of keeping safe-haven targets as cash via the documented setting.

Useful? React with 👍 / 👎.

for symbol in safe_haven_symbols:
if float(adjusted_targets.get(symbol, 0.0) or 0.0) > 0.0:
adjusted_targets[symbol] = 0.0
safe_haven_substituted.append(symbol)
allocation["targets"] = adjusted_targets
if substituted:
allocation["small_account_whole_share_substituted_symbols"] = substituted
if safe_haven_substituted:
allocation["small_account_safe_haven_cash_substituted_symbols"] = tuple(safe_haven_substituted)
adjusted_plan["allocation"] = allocation
return adjusted_plan

Expand Down
89 changes: 89 additions & 0 deletions tests/test_execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,92 @@ def test_execute_value_target_plan_projects_unbuyable_value_target_to_zero():
("sell", "SOXX", 1.0),
("buy", "SOXL", 1.0),
]


def test_execute_value_target_plan_keeps_safe_haven_cash_when_only_risk_target_is_unbuyable():
execution_port = FakeExecutionPort()
result = execute_value_target_plan(
plan={
"allocation": {
"strategy_symbols": ("SOXL", "SOXX", "BOXX"),
"risk_symbols": ("SOXL", "SOXX"),
"safe_haven_symbols": ("BOXX",),
"targets": {"SOXL": 0.0, "SOXX": 194.10, "BOXX": 1099.90},
},
"portfolio": {
"market_values": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0},
"sellable_quantities": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0},
"liquid_cash": 1294.00,
"cash_sweep_symbol": "BOXX",
},
"execution": {"current_min_trade": 100.0, "investable_cash": 1255.18},
},
market_data_port=FakeMarketDataPort({"SOXL": 175.0, "SOXX": 525.0, "BOXX": 116.83}),
execution_port=execution_port,
dry_run_only=True,
max_order_notional_usd=2000.0,
safe_haven_cash_substitute_threshold_usd=1000.0,
)

assert result.action_done is False
assert execution_port.orders == []


def test_execute_value_target_plan_uses_cash_sweep_symbol_for_small_safe_haven_cash():
execution_port = FakeExecutionPort()
result = execute_value_target_plan(
plan={
"allocation": {
"strategy_symbols": ("SOXX", "BOXX"),
"risk_symbols": ("SOXX",),
"targets": {"SOXX": 194.10, "BOXX": 1099.90},
},
"portfolio": {
"market_values": {"SOXX": 0.0, "BOXX": 0.0},
"sellable_quantities": {"SOXX": 0.0, "BOXX": 0.0},
"liquid_cash": 1294.00,
"cash_sweep_symbol": "BOXX",
},
"execution": {"current_min_trade": 100.0, "investable_cash": 1255.18},
},
market_data_port=FakeMarketDataPort({"SOXX": 525.0, "BOXX": 116.83}),
execution_port=execution_port,
dry_run_only=True,
max_order_notional_usd=2000.0,
safe_haven_cash_substitute_threshold_usd=1000.0,
)

assert result.action_done is False
assert execution_port.orders == []


def test_execute_value_target_plan_keeps_safe_haven_when_mixed_case_risk_target_remains_buyable():
execution_port = FakeExecutionPort()
result = execute_value_target_plan(
plan={
"allocation": {
"strategy_symbols": ("SOXL", "SOXX", "BOXX"),
"risk_symbols": ("soxl", "soxx"),
"safe_haven_symbols": ("BOXX",),
"targets": {"SOXL": 500.0, "SOXX": 194.10, "BOXX": 1000.0},
},
"portfolio": {
"market_values": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0},
"sellable_quantities": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0},
"liquid_cash": 2000.0,
"cash_sweep_symbol": "BOXX",
},
"execution": {"current_min_trade": 100.0, "investable_cash": 2000.0},
},
market_data_port=FakeMarketDataPort({"SOXL": 100.0, "SOXX": 525.0, "BOXX": 100.0}),
execution_port=execution_port,
dry_run_only=True,
max_order_notional_usd=2000.0,
safe_haven_cash_substitute_threshold_usd=1000.0,
)

assert result.action_done is True
assert [(order.side, order.symbol, order.quantity) for order in execution_port.orders] == [
("buy", "BOXX", 10.0),
("buy", "SOXL", 5.0),
]