Skip to content

Commit 30af50a

Browse files
authored
Add Firstrade cash reserve policy
1 parent 58ab193 commit 30af50a

9 files changed

Lines changed: 264 additions & 3 deletions

.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: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,19 @@ 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["firstrade_execution_policy"] = {
172+
"reserved_cash_floor_usd": float(settings.reserved_cash_floor_usd or 0.0),
173+
"reserved_cash_ratio": float(settings.reserved_cash_ratio or 0.0),
174+
}
175+
return runtime_metadata
176+
177+
165178
def run_strategy_cycle(
166179
*,
167180
runtime_settings: PlatformRuntimeSettings | None = None,
@@ -221,7 +234,10 @@ def run_strategy_cycle(
221234
evaluation.decision,
222235
snapshot=snapshot,
223236
strategy_profile=settings.strategy_profile,
224-
runtime_metadata=getattr(evaluation, "metadata", None),
237+
runtime_metadata=_runtime_metadata_with_execution_policy(
238+
getattr(evaluation, "metadata", None),
239+
settings=settings,
240+
),
225241
)
226242
plan = substitute_small_safe_haven_targets_with_cash(
227243
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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import math
34
import os
45
from dataclasses import dataclass
56
from pathlib import Path
@@ -23,6 +24,8 @@
2324
from us_equity_strategies import get_strategy_catalog
2425

2526
DEFAULT_ACCOUNT_REGION = "US"
27+
DEFAULT_RESERVED_CASH_FLOOR_USD = 0.0
28+
DEFAULT_RESERVED_CASH_RATIO = 0.0
2629
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
2730

2831

@@ -42,6 +45,8 @@ class PlatformRuntimeSettings:
4245
run_strategy_on_http: bool
4346
live_order_ack: bool
4447
max_order_notional_usd: float | None
48+
reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD
49+
reserved_cash_ratio: float = DEFAULT_RESERVED_CASH_RATIO
4550
persist_strategy_runs: bool = False
4651
safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD
4752
debug_position_snapshot: bool = False
@@ -113,6 +118,14 @@ def load_platform_runtime_settings(
113118
os.environ,
114119
"FIRSTRADE_MAX_ORDER_NOTIONAL_USD",
115120
),
121+
reserved_cash_floor_usd=_resolve_non_negative_float_env(
122+
"FIRSTRADE_MIN_RESERVED_CASH_USD",
123+
default=DEFAULT_RESERVED_CASH_FLOOR_USD,
124+
),
125+
reserved_cash_ratio=_resolve_ratio_env(
126+
"FIRSTRADE_RESERVED_CASH_RATIO",
127+
default=DEFAULT_RESERVED_CASH_RATIO,
128+
),
116129
safe_haven_cash_substitute_threshold_usd=(
117130
max(0.0, safe_haven_cash_substitute_threshold_usd)
118131
if safe_haven_cash_substitute_threshold_usd is not None
@@ -172,6 +185,24 @@ def _qqqi_income_ratio_env() -> float | None:
172185
return value
173186

174187

188+
def _resolve_non_negative_float_env(name: str, *, default: float) -> float:
189+
value = resolve_optional_float_env(os.environ, name)
190+
if value is None:
191+
return float(default)
192+
if not math.isfinite(value):
193+
raise ValueError(f"{name} must be finite, got {value}")
194+
if value < 0:
195+
raise ValueError(f"{name} must be non-negative, got {value}")
196+
return float(value)
197+
198+
199+
def _resolve_ratio_env(name: str, *, default: float) -> float:
200+
value = _resolve_non_negative_float_env(name, default=default)
201+
if value > 1.0:
202+
raise ValueError(f"{name} must be in [0,1], got {value}")
203+
return value
204+
205+
175206
def _runtime_execution_window_trading_days_env(strategy_profile: str) -> int | None:
176207
raw_value = os.getenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS")
177208
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_rebalance_service.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from types import SimpleNamespace
55

66
from application.firstrade_client import FirstradeCredentials
7-
from application.rebalance_service import run_strategy_cycle
7+
from application.rebalance_service import _runtime_metadata_with_execution_policy, run_strategy_cycle
88
from notifications.telegram import I18N, build_translator, render_cycle_summary
99
from quant_platform_kit.strategy_contracts import PositionTarget, StrategyDecision
1010
from runtime_config_support import PlatformRuntimeSettings
@@ -36,6 +36,32 @@ def _runtime_settings_with_persistence(**overrides) -> PlatformRuntimeSettings:
3636
return PlatformRuntimeSettings(**values)
3737

3838

39+
def test_runtime_metadata_uses_platform_execution_policy_over_strategy_metadata():
40+
metadata = {
41+
"signal": "ok",
42+
"firstrade_execution_policy": {
43+
"reserved_cash_floor_usd": 1.0,
44+
"reserved_cash_ratio": 0.0,
45+
},
46+
}
47+
48+
result = _runtime_metadata_with_execution_policy(
49+
metadata,
50+
settings=_runtime_settings_with_persistence(
51+
reserved_cash_floor_usd=250.0,
52+
reserved_cash_ratio=0.03,
53+
),
54+
)
55+
56+
assert result == {
57+
"signal": "ok",
58+
"firstrade_execution_policy": {
59+
"reserved_cash_floor_usd": 250.0,
60+
"reserved_cash_ratio": 0.03,
61+
},
62+
}
63+
64+
3965
class FakeFirstradeClient:
4066
def __init__(self, _credentials, *, live_trading_enabled=False):
4167
self.live_trading_enabled = live_trading_enabled

0 commit comments

Comments
 (0)