Skip to content

Commit 6047a54

Browse files
committed
Add platform cash reserve policy
1 parent 58ab193 commit 6047a54

8 files changed

Lines changed: 220 additions & 2 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,7 @@ FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS=false
3939
FIRSTRADE_RUN_STRATEGY_ON_HTTP=false
4040
FIRSTRADE_LIVE_ORDER_ACK=false
4141
FIRSTRADE_MAX_ORDER_NOTIONAL_USD=
42+
FIRSTRADE_MIN_RESERVED_CASH_USD=0
43+
FIRSTRADE_RESERVED_CASH_RATIO=0
4244
FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD=1000
4345
FIRSTRADE_SMOKE_SYMBOL=SPY

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ jobs:
4646
FIRSTRADE_RUN_STRATEGY_ON_HTTP: ${{ vars.FIRSTRADE_RUN_STRATEGY_ON_HTTP }}
4747
FIRSTRADE_LIVE_ORDER_ACK: ${{ vars.FIRSTRADE_LIVE_ORDER_ACK }}
4848
FIRSTRADE_MAX_ORDER_NOTIONAL_USD: ${{ vars.FIRSTRADE_MAX_ORDER_NOTIONAL_USD }}
49+
FIRSTRADE_MIN_RESERVED_CASH_USD: ${{ vars.FIRSTRADE_MIN_RESERVED_CASH_USD }}
50+
FIRSTRADE_RESERVED_CASH_RATIO: ${{ vars.FIRSTRADE_RESERVED_CASH_RATIO }}
4951
FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }}
5052
FIRSTRADE_SMOKE_SYMBOL: ${{ vars.FIRSTRADE_SMOKE_SYMBOL }}
5153
FIRSTRADE_FEATURE_SNAPSHOT_PATH: ${{ vars.FIRSTRADE_FEATURE_SNAPSHOT_PATH }}
@@ -414,6 +416,8 @@ jobs:
414416
add_optional_env FIRSTRADE_RUN_STRATEGY_ON_HTTP
415417
add_optional_env FIRSTRADE_LIVE_ORDER_ACK
416418
add_optional_env FIRSTRADE_MAX_ORDER_NOTIONAL_USD
419+
add_optional_env FIRSTRADE_MIN_RESERVED_CASH_USD
420+
add_optional_env FIRSTRADE_RESERVED_CASH_RATIO
417421
add_optional_env FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD
418422
add_optional_env FIRSTRADE_SMOKE_SYMBOL
419423
add_optional_env FIRSTRADE_FEATURE_SNAPSHOT_PATH

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ commit credentials.
9292
| `FIRSTRADE_RUN_STRATEGY_ON_HTTP` | Optional | Must be `true` before `/run` performs strategy evaluation and order routing |
9393
| `FIRSTRADE_LIVE_ORDER_ACK` | Optional | Must be `true` before `/run` can submit live orders |
9494
| `FIRSTRADE_MAX_ORDER_NOTIONAL_USD` | Optional | Optional single-order cap for strategy-generated orders. Unset means no platform-side notional cap |
95+
| `FIRSTRADE_MIN_RESERVED_CASH_USD` | Optional | Platform-level minimum cash reserve in USD. Defaults to `0`; the effective reserve is the max of this floor, `FIRSTRADE_RESERVED_CASH_RATIO * total equity`, and any strategy-provided reserve. |
96+
| `FIRSTRADE_RESERVED_CASH_RATIO` | Optional | Platform-level minimum cash reserve ratio in `[0,1]`. Defaults to `0`; it can raise but not lower a strategy-provided reserve. |
9597
| `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`. |
9698

9799
## Local Validation
@@ -176,6 +178,7 @@ all of these gates:
176178
- `FIRSTRADE_ENABLE_LIVE_TRADING=true`
177179
- `FIRSTRADE_LIVE_ORDER_ACK=true`
178180
- order value at or below `FIRSTRADE_MAX_ORDER_NOTIONAL_USD` when that optional cap is set
181+
- `FIRSTRADE_MIN_RESERVED_CASH_USD` / `FIRSTRADE_RESERVED_CASH_RATIO` may set a platform-level minimum cash reserve; defaults are `0`, and the effective reserve is the max of platform floor, platform ratio, and strategy reserve
179182

180183
The strategy execution service uses whole-share limit orders for generated
181184
strategy orders. If the notional cap is below the current price of a target
@@ -324,6 +327,7 @@ HTTP 策略闭环实盘还必须额外满足:
324327
- `FIRSTRADE_DRY_RUN_ONLY=false`
325328
- `FIRSTRADE_LIVE_ORDER_ACK=true`
326329
- 如果设置了 `FIRSTRADE_MAX_ORDER_NOTIONAL_USD`,单笔金额不超过该上限
330+
- `FIRSTRADE_MIN_RESERVED_CASH_USD` / `FIRSTRADE_RESERVED_CASH_RATIO` 可设置平台级最低预留现金;默认都是 `0`,实际预留取平台下限、平台比例和策略预留中的最大值
327331
- `BOXX`/`BIL` 等避险现金替代标的目标金额低于 `FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD` 时保留现金,默认门槛 `1000` USD
328332

329333
策略闭环生成的是整数股限价单。如果设置了 `FIRSTRADE_MAX_ORDER_NOTIONAL_USD`

application/rebalance_service.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,22 @@ def publish_log(text: str) -> None:
162162
return True
163163

164164

165+
def _runtime_metadata_with_execution_policy(
166+
metadata: Mapping[str, Any] | None,
167+
*,
168+
settings: PlatformRuntimeSettings,
169+
) -> dict[str, Any]:
170+
runtime_metadata = dict(metadata or {})
171+
runtime_metadata.setdefault(
172+
"firstrade_execution_policy",
173+
{
174+
"reserved_cash_floor_usd": float(settings.reserved_cash_floor_usd or 0.0),
175+
"reserved_cash_ratio": float(settings.reserved_cash_ratio or 0.0),
176+
},
177+
)
178+
return runtime_metadata
179+
180+
165181
def run_strategy_cycle(
166182
*,
167183
runtime_settings: PlatformRuntimeSettings | None = None,
@@ -221,7 +237,10 @@ def run_strategy_cycle(
221237
evaluation.decision,
222238
snapshot=snapshot,
223239
strategy_profile=settings.strategy_profile,
224-
runtime_metadata=getattr(evaluation, "metadata", None),
240+
runtime_metadata=_runtime_metadata_with_execution_policy(
241+
getattr(evaluation, "metadata", None),
242+
settings=settings,
243+
),
225244
)
226245
plan = substitute_small_safe_haven_targets_with_cash(
227246
plan,

decision_mapper.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from collections.abc import Mapping
4+
from dataclasses import replace
45
from typing import Any
56

67
from quant_platform_kit.strategy_contracts import (
@@ -34,6 +35,50 @@ def _default_threshold_value(total_equity: float) -> float:
3435
return max(_DEFAULT_MIN_TRADE_FLOOR, float(total_equity) * _DEFAULT_REBALANCE_THRESHOLD_RATIO)
3536

3637

38+
def _resolve_platform_reserved_cash(
39+
*,
40+
total_equity: float,
41+
runtime_metadata: Mapping[str, Any] | None,
42+
) -> float:
43+
raw_policy = (runtime_metadata or {}).get("firstrade_execution_policy")
44+
if not isinstance(raw_policy, Mapping):
45+
return 0.0
46+
reserved_cash_floor_usd = max(0.0, float(raw_policy.get("reserved_cash_floor_usd", 0.0) or 0.0))
47+
reserved_cash_ratio = float(raw_policy.get("reserved_cash_ratio", 0.0) or 0.0)
48+
reserved_cash_ratio = max(0.0, min(1.0, reserved_cash_ratio))
49+
return max(reserved_cash_floor_usd, max(0.0, float(total_equity)) * reserved_cash_ratio)
50+
51+
52+
def _apply_reserved_cash_policy(
53+
annotations: ValueTargetExecutionAnnotations,
54+
*,
55+
portfolio_inputs,
56+
runtime_metadata: Mapping[str, Any] | None,
57+
) -> ValueTargetExecutionAnnotations:
58+
reserved_cash = max(
59+
float(annotations.reserved_cash or 0.0),
60+
_resolve_platform_reserved_cash(
61+
total_equity=float(portfolio_inputs.total_equity),
62+
runtime_metadata=runtime_metadata,
63+
),
64+
)
65+
base_investable_cash = annotations.investable_cash
66+
if base_investable_cash is None:
67+
base_investable_cash = max(
68+
0.0,
69+
float(portfolio_inputs.liquid_cash) - float(annotations.reserved_cash or 0.0),
70+
)
71+
investable_cash = min(
72+
max(0.0, float(base_investable_cash)),
73+
max(0.0, float(portfolio_inputs.liquid_cash) - reserved_cash),
74+
)
75+
return replace(
76+
annotations,
77+
reserved_cash=reserved_cash,
78+
investable_cash=investable_cash,
79+
)
80+
81+
3782
def _build_hold_current_value_decision(portfolio_inputs, *, diagnostics: Mapping[str, Any]) -> StrategyDecision:
3883
positions = []
3984
for symbol, market_value in sorted(portfolio_inputs.market_values.items()):
@@ -210,6 +255,11 @@ def map_strategy_decision_to_plan(
210255
normalized_decision,
211256
portfolio_inputs=portfolio_inputs,
212257
)
258+
annotations = _apply_reserved_cash_policy(
259+
annotations,
260+
portfolio_inputs=portfolio_inputs,
261+
runtime_metadata=runtime_metadata,
262+
)
213263
plan = build_value_target_runtime_plan(
214264
normalized_decision,
215265
strategy_profile=canonical_profile,

runtime_config_support.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
from us_equity_strategies import get_strategy_catalog
2424

2525
DEFAULT_ACCOUNT_REGION = "US"
26+
DEFAULT_RESERVED_CASH_FLOOR_USD = 0.0
27+
DEFAULT_RESERVED_CASH_RATIO = 0.0
2628
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
2729

2830

@@ -42,6 +44,8 @@ class PlatformRuntimeSettings:
4244
run_strategy_on_http: bool
4345
live_order_ack: bool
4446
max_order_notional_usd: float | None
47+
reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD
48+
reserved_cash_ratio: float = DEFAULT_RESERVED_CASH_RATIO
4549
persist_strategy_runs: bool = False
4650
safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD
4751
debug_position_snapshot: bool = False
@@ -113,6 +117,14 @@ def load_platform_runtime_settings(
113117
os.environ,
114118
"FIRSTRADE_MAX_ORDER_NOTIONAL_USD",
115119
),
120+
reserved_cash_floor_usd=_resolve_non_negative_float_env(
121+
"FIRSTRADE_MIN_RESERVED_CASH_USD",
122+
default=DEFAULT_RESERVED_CASH_FLOOR_USD,
123+
),
124+
reserved_cash_ratio=_resolve_ratio_env(
125+
"FIRSTRADE_RESERVED_CASH_RATIO",
126+
default=DEFAULT_RESERVED_CASH_RATIO,
127+
),
116128
safe_haven_cash_substitute_threshold_usd=(
117129
max(0.0, safe_haven_cash_substitute_threshold_usd)
118130
if safe_haven_cash_substitute_threshold_usd is not None
@@ -172,6 +184,22 @@ def _qqqi_income_ratio_env() -> float | None:
172184
return value
173185

174186

187+
def _resolve_non_negative_float_env(name: str, *, default: float) -> float:
188+
value = resolve_optional_float_env(os.environ, name)
189+
if value is None:
190+
return float(default)
191+
if value < 0:
192+
raise ValueError(f"{name} must be non-negative, got {value}")
193+
return float(value)
194+
195+
196+
def _resolve_ratio_env(name: str, *, default: float) -> float:
197+
value = _resolve_non_negative_float_env(name, default=default)
198+
if value > 1.0:
199+
raise ValueError(f"{name} must be in [0,1], got {value}")
200+
return value
201+
202+
175203
def _runtime_execution_window_trading_days_env(strategy_profile: str) -> int | None:
176204
raw_value = os.getenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS")
177205
env_name = "FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS"

tests/test_decision_mapper.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime, timezone
4+
5+
from quant_platform_kit.common.models import PortfolioSnapshot, Position
6+
from quant_platform_kit.strategy_contracts import PositionTarget, StrategyDecision
7+
8+
from decision_mapper import map_strategy_decision_to_plan
9+
10+
11+
def test_applies_platform_reserved_cash_policy_to_weight_decision():
12+
decision = StrategyDecision(
13+
positions=(
14+
PositionTarget(symbol="AAPL", target_weight=0.5),
15+
PositionTarget(symbol="MSFT", target_weight=0.5),
16+
),
17+
diagnostics={"signal_description": "risk on"},
18+
)
19+
snapshot = PortfolioSnapshot(
20+
as_of=datetime.now(timezone.utc),
21+
total_equity=20000.0,
22+
buying_power=4000.0,
23+
positions=(Position(symbol="AAPL", quantity=1, market_value=1000.0),),
24+
)
25+
26+
plan = map_strategy_decision_to_plan(
27+
decision,
28+
snapshot=snapshot,
29+
strategy_profile="mega_cap_leader_rotation_top50_balanced",
30+
runtime_metadata={
31+
"firstrade_execution_policy": {
32+
"reserved_cash_floor_usd": 1500.0,
33+
"reserved_cash_ratio": 0.03,
34+
}
35+
},
36+
)
37+
38+
assert plan["execution"]["reserved_cash"] == 1500.0
39+
assert plan["execution"]["investable_cash"] == 2500.0
40+
41+
42+
def test_platform_reserved_cash_policy_does_not_lower_strategy_reserve():
43+
decision = StrategyDecision(
44+
positions=(PositionTarget(symbol="AAA", target_value=5000.0),),
45+
diagnostics={
46+
"execution_annotations": {
47+
"trade_threshold_value": 100.0,
48+
"reserved_cash": 1200.0,
49+
}
50+
},
51+
)
52+
snapshot = PortfolioSnapshot(
53+
as_of=datetime.now(timezone.utc),
54+
total_equity=10000.0,
55+
buying_power=3000.0,
56+
positions=(),
57+
)
58+
59+
plan = map_strategy_decision_to_plan(
60+
decision,
61+
snapshot=snapshot,
62+
strategy_profile="tqqq_growth_income",
63+
runtime_metadata={
64+
"firstrade_execution_policy": {
65+
"reserved_cash_floor_usd": 150.0,
66+
"reserved_cash_ratio": 0.03,
67+
}
68+
},
69+
)
70+
71+
assert plan["execution"]["reserved_cash"] == 1200.0
72+
assert plan["execution"]["investable_cash"] == 1800.0

tests/test_runtime_config_support.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@
22

33
import pytest
44

5-
from runtime_config_support import _runtime_execution_window_trading_days_env
5+
from runtime_config_support import (
6+
_resolve_ratio_env,
7+
_runtime_execution_window_trading_days_env,
8+
load_platform_runtime_settings,
9+
)
10+
11+
12+
def _target_json(profile="mega_cap_leader_rotation_top50_balanced") -> str:
13+
return (
14+
'{"platform_id":"firstrade","strategy_profile":"'
15+
+ profile
16+
+ '","dry_run_only":true,"execution_mode":"paper"}'
17+
)
618

719

820
def test_runtime_execution_window_uses_generic_env(monkeypatch):
@@ -33,6 +45,33 @@ def test_runtime_execution_window_keeps_legacy_tech_env(monkeypatch):
3345
)
3446

3547

48+
def test_reserved_cash_policy_defaults_to_zero(monkeypatch):
49+
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json())
50+
51+
settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
52+
53+
assert settings.reserved_cash_floor_usd == 0.0
54+
assert settings.reserved_cash_ratio == 0.0
55+
56+
57+
def test_reserved_cash_policy_loads_from_env(monkeypatch):
58+
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json())
59+
monkeypatch.setenv("FIRSTRADE_MIN_RESERVED_CASH_USD", "250")
60+
monkeypatch.setenv("FIRSTRADE_RESERVED_CASH_RATIO", "0.025")
61+
62+
settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
63+
64+
assert settings.reserved_cash_floor_usd == 250.0
65+
assert settings.reserved_cash_ratio == 0.025
66+
67+
68+
def test_reserved_cash_ratio_rejects_invalid_env(monkeypatch):
69+
monkeypatch.setenv("FIRSTRADE_RESERVED_CASH_RATIO", "1.25")
70+
71+
with pytest.raises(ValueError, match="FIRSTRADE_RESERVED_CASH_RATIO"):
72+
_resolve_ratio_env("FIRSTRADE_RESERVED_CASH_RATIO", default=0.0)
73+
74+
3675
@pytest.mark.parametrize("raw_value", ["0", "-1", "abc"])
3776
def test_runtime_execution_window_rejects_invalid_generic_env(monkeypatch, raw_value):
3877
monkeypatch.setenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", raw_value)

0 commit comments

Comments
 (0)