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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ FIRSTRADE_RUN_SMOKE_ON_HTTP=false
FIRSTRADE_RUN_STRATEGY_ON_HTTP=false
FIRSTRADE_LIVE_ORDER_ACK=false
FIRSTRADE_MAX_ORDER_NOTIONAL_USD=25
FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD=1000
FIRSTRADE_SMOKE_SYMBOL=SPY
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ commit credentials.
| `FIRSTRADE_RUN_STRATEGY_ON_HTTP` | Optional | Must be `true` before `/run` performs strategy evaluation and order routing |
| `FIRSTRADE_LIVE_ORDER_ACK` | Optional | Must be `true` before `/run` can submit live orders |
| `FIRSTRADE_MAX_ORDER_NOTIONAL_USD` | Optional | Single-order cap for strategy-generated orders, default `25` |
| `FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD` | Optional | Safe-haven/cash-sweep target values below this USD amount are kept as cash instead of buying BOXX/BIL. Default `1000`. |

## Local Validation

Expand Down Expand Up @@ -243,6 +244,7 @@ HTTP 策略闭环实盘还必须额外满足:
- `FIRSTRADE_DRY_RUN_ONLY=false`
- `FIRSTRADE_LIVE_ORDER_ACK=true`
- 单笔金额不超过 `FIRSTRADE_MAX_ORDER_NOTIONAL_USD`
- `BOXX`/`BIL` 等避险现金替代标的目标金额低于 `FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD` 时保留现金,默认门槛 `1000` USD

策略闭环生成的是整数股限价单。如果 `FIRSTRADE_MAX_ORDER_NOTIONAL_USD`
低于目标标的当前价格,本轮会跳过该订单,而不是放大金额。
Expand Down
49 changes: 49 additions & 0 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,54 @@ class ExecutionCycleResult:
action_done: bool


DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0


def _floor_quantity(quantity: float) -> int:
return max(0, int(float(quantity or 0.0)))


def _safe_haven_cash_symbols(*, portfolio: dict[str, Any], allocation: dict[str, Any]) -> tuple[str, ...]:
symbols: list[str] = []
for symbol in allocation.get("safe_haven_symbols", ()):
normalized = str(symbol or "").strip().upper()
if normalized:
symbols.append(normalized)
cash_sweep_symbol = str(portfolio.get("cash_sweep_symbol") or "").strip().upper()
if cash_sweep_symbol:
symbols.append(cash_sweep_symbol)
return tuple(dict.fromkeys(symbols))


def substitute_small_safe_haven_targets_with_cash(
plan: dict[str, Any],
*,
threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD,
) -> dict[str, Any]:
"""Return a plan whose small safe-haven target values are left as cash."""
threshold = max(0.0, float(threshold_usd or 0.0))
if threshold <= 0.0:
return dict(plan or {})

adjusted_plan = dict(plan or {})
allocation = dict(adjusted_plan.get("allocation") or {})
portfolio = dict(adjusted_plan.get("portfolio") or {})
targets = {
str(symbol).strip().upper(): float(value or 0.0)
for symbol, value in dict(allocation.get("targets") or {}).items()
}
changed = False
for symbol in _safe_haven_cash_symbols(portfolio=portfolio, allocation=allocation):
target_value = float(targets.get(symbol, 0.0) or 0.0)
if 0.0 < target_value < threshold:
targets[symbol] = 0.0
changed = True
if changed:
allocation["targets"] = targets
adjusted_plan["allocation"] = allocation
return adjusted_plan


def _quote_price(market_data_port: MarketDataPort, symbol: str) -> float | None:
try:
price = float(market_data_port.get_quote(symbol).last_price)
Expand Down Expand Up @@ -67,8 +111,13 @@ def execute_value_target_plan(
limit_sell_discount: float = 0.995,
limit_buy_premium: float = 1.005,
max_order_notional_usd: float = 25.0,
safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD,
) -> ExecutionCycleResult:
del dry_run_only # ExecutionPort owns preview vs live submission.
plan = substitute_small_safe_haven_targets_with_cash(
plan,
threshold_usd=safe_haven_cash_substitute_threshold_usd,
)
allocation = dict(plan.get("allocation") or {})
portfolio = dict(plan.get("portfolio") or {})
execution = dict(plan.get("execution") or {})
Expand Down
10 changes: 9 additions & 1 deletion application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@

import pandas as pd

from application.execution_service import execute_value_target_plan
from application.execution_service import (
execute_value_target_plan,
substitute_small_safe_haven_targets_with_cash,
)
from application.firstrade_client import (
FirstradeBrokerClient,
FirstradeCredentials,
Expand Down Expand Up @@ -192,6 +195,10 @@ def run_strategy_cycle(
strategy_profile=settings.strategy_profile,
runtime_metadata=getattr(evaluation, "metadata", None),
)
plan = substitute_small_safe_haven_targets_with_cash(
plan,
threshold_usd=settings.safe_haven_cash_substitute_threshold_usd,
)
execution_result = execute_value_target_plan(
plan=plan,
market_data_port=market_data_port,
Expand All @@ -200,6 +207,7 @@ def run_strategy_cycle(
limit_sell_discount=LIMIT_SELL_DISCOUNT,
limit_buy_premium=LIMIT_BUY_PREMIUM,
max_order_notional_usd=settings.max_order_notional_usd,
safe_haven_cash_substitute_threshold_usd=settings.safe_haven_cash_substitute_threshold_usd,
)
result = {
"ok": True,
Expand Down
11 changes: 11 additions & 0 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from us_equity_strategies import get_strategy_catalog

DEFAULT_ACCOUNT_REGION = "US"
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0


@dataclass(frozen=True)
Expand All @@ -41,6 +42,7 @@ class PlatformRuntimeSettings:
run_strategy_on_http: bool
live_order_ack: bool
max_order_notional_usd: float
safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD
debug_position_snapshot: bool = False
income_threshold_usd: float | None = None
qqqi_income_ratio: float | None = None
Expand All @@ -67,6 +69,10 @@ def load_platform_runtime_settings(
) -> PlatformRuntimeSettings:
dry_run_only = resolve_bool_value(os.getenv("FIRSTRADE_DRY_RUN_ONLY", "true"))
account_prefix = os.getenv("ACCOUNT_PREFIX", "FIRSTRADE")
safe_haven_cash_substitute_threshold_usd = resolve_optional_float_env(
os.environ,
"FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD",
)
runtime_target = _resolve_runtime_target(dry_run_only=dry_run_only)
strategy_definition = resolve_strategy_definition(
runtime_target.strategy_profile,
Expand Down Expand Up @@ -105,6 +111,11 @@ def load_platform_runtime_settings(
resolve_optional_float_env(os.environ, "FIRSTRADE_MAX_ORDER_NOTIONAL_USD")
or 25.0
),
safe_haven_cash_substitute_threshold_usd=(
max(0.0, safe_haven_cash_substitute_threshold_usd)
if safe_haven_cash_substitute_threshold_usd is not None
else DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD
),
debug_position_snapshot=resolve_bool_value(os.getenv("FIRSTRADE_DEBUG_POSITION_SNAPSHOT")),
income_threshold_usd=resolve_optional_float_env(os.environ, "INCOME_THRESHOLD_USD"),
qqqi_income_ratio=_qqqi_income_ratio_env(),
Expand Down
41 changes: 40 additions & 1 deletion tests/test_execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
from dataclasses import dataclass
from datetime import datetime, timezone

from application.execution_service import execute_value_target_plan
from application.execution_service import (
execute_value_target_plan,
substitute_small_safe_haven_targets_with_cash,
)
from quant_platform_kit.common.models import ExecutionReport, QuoteSnapshot


Expand Down Expand Up @@ -89,3 +92,39 @@ def test_execute_value_target_plan_skips_when_cap_cannot_buy_one_share():
assert result.skipped_orders == (
{"symbol": "SPY", "reason": "buy_quantity_zero", "max_order_notional_usd": 25.0},
)


def test_execute_value_target_plan_leaves_small_safe_haven_target_as_cash():
execution_port = FakeExecutionPort()
plan = {
"allocation": {
"targets": {"AAA": 1500.0, "BOXX": 750.0},
"safe_haven_symbols": ("BOXX",),
},
"portfolio": {
"market_values": {"AAA": 0.0, "BOXX": 0.0},
"sellable_quantities": {},
"liquid_cash": 2500.0,
"cash_sweep_symbol": "BOXX",
},
"execution": {"current_min_trade": 1.0, "investable_cash": 2500.0},
}

result = execute_value_target_plan(
plan=plan,
market_data_port=FakeMarketDataPort({"AAA": 100.0, "BOXX": 100.0}),
execution_port=execution_port,
dry_run_only=True,
max_order_notional_usd=2500.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", "AAA", 15.0),
]
adjusted_plan = substitute_small_safe_haven_targets_with_cash(
plan,
threshold_usd=1000.0,
)
assert adjusted_plan["allocation"]["targets"]["BOXX"] == 0.0