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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,7 @@ FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS=false
FIRSTRADE_RUN_STRATEGY_ON_HTTP=false
FIRSTRADE_LIVE_ORDER_ACK=false
FIRSTRADE_MAX_ORDER_NOTIONAL_USD=
FIRSTRADE_MIN_RESERVED_CASH_USD=0
FIRSTRADE_RESERVED_CASH_RATIO=0
FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD=1000
FIRSTRADE_SMOKE_SYMBOL=SPY
4 changes: 4 additions & 0 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ jobs:
FIRSTRADE_RUN_STRATEGY_ON_HTTP: ${{ vars.FIRSTRADE_RUN_STRATEGY_ON_HTTP }}
FIRSTRADE_LIVE_ORDER_ACK: ${{ vars.FIRSTRADE_LIVE_ORDER_ACK }}
FIRSTRADE_MAX_ORDER_NOTIONAL_USD: ${{ vars.FIRSTRADE_MAX_ORDER_NOTIONAL_USD }}
FIRSTRADE_MIN_RESERVED_CASH_USD: ${{ vars.FIRSTRADE_MIN_RESERVED_CASH_USD }}
FIRSTRADE_RESERVED_CASH_RATIO: ${{ vars.FIRSTRADE_RESERVED_CASH_RATIO }}
FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }}
FIRSTRADE_SMOKE_SYMBOL: ${{ vars.FIRSTRADE_SMOKE_SYMBOL }}
FIRSTRADE_FEATURE_SNAPSHOT_PATH: ${{ vars.FIRSTRADE_FEATURE_SNAPSHOT_PATH }}
Expand Down Expand Up @@ -414,6 +416,8 @@ jobs:
add_optional_env FIRSTRADE_RUN_STRATEGY_ON_HTTP
add_optional_env FIRSTRADE_LIVE_ORDER_ACK
add_optional_env FIRSTRADE_MAX_ORDER_NOTIONAL_USD
add_optional_env FIRSTRADE_MIN_RESERVED_CASH_USD
add_optional_env FIRSTRADE_RESERVED_CASH_RATIO
add_optional_env FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD
add_optional_env FIRSTRADE_SMOKE_SYMBOL
add_optional_env FIRSTRADE_FEATURE_SNAPSHOT_PATH
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ 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 | Optional single-order cap for strategy-generated orders. Unset means no platform-side notional cap |
| `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. |
| `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. |
| `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 @@ -176,6 +178,7 @@ all of these gates:
- `FIRSTRADE_ENABLE_LIVE_TRADING=true`
- `FIRSTRADE_LIVE_ORDER_ACK=true`
- order value at or below `FIRSTRADE_MAX_ORDER_NOTIONAL_USD` when that optional cap is set
- `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

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

策略闭环生成的是整数股限价单。如果设置了 `FIRSTRADE_MAX_ORDER_NOTIONAL_USD`
Expand Down
18 changes: 17 additions & 1 deletion application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,19 @@ def publish_log(text: str) -> None:
return True


def _runtime_metadata_with_execution_policy(
metadata: Mapping[str, Any] | None,
*,
settings: PlatformRuntimeSettings,
) -> dict[str, Any]:
runtime_metadata = dict(metadata or {})
runtime_metadata["firstrade_execution_policy"] = {
"reserved_cash_floor_usd": float(settings.reserved_cash_floor_usd or 0.0),
"reserved_cash_ratio": float(settings.reserved_cash_ratio or 0.0),
}
return runtime_metadata


def run_strategy_cycle(
*,
runtime_settings: PlatformRuntimeSettings | None = None,
Expand Down Expand Up @@ -221,7 +234,10 @@ def run_strategy_cycle(
evaluation.decision,
snapshot=snapshot,
strategy_profile=settings.strategy_profile,
runtime_metadata=getattr(evaluation, "metadata", None),
runtime_metadata=_runtime_metadata_with_execution_policy(
getattr(evaluation, "metadata", None),
settings=settings,
),
)
plan = substitute_small_safe_haven_targets_with_cash(
plan,
Expand Down
50 changes: 50 additions & 0 deletions decision_mapper.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from collections.abc import Mapping
from dataclasses import replace
from typing import Any

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


def _resolve_platform_reserved_cash(
*,
total_equity: float,
runtime_metadata: Mapping[str, Any] | None,
) -> float:
raw_policy = (runtime_metadata or {}).get("firstrade_execution_policy")
if not isinstance(raw_policy, Mapping):
return 0.0
reserved_cash_floor_usd = max(0.0, float(raw_policy.get("reserved_cash_floor_usd", 0.0) or 0.0))
reserved_cash_ratio = float(raw_policy.get("reserved_cash_ratio", 0.0) or 0.0)
reserved_cash_ratio = max(0.0, min(1.0, reserved_cash_ratio))
return max(reserved_cash_floor_usd, max(0.0, float(total_equity)) * reserved_cash_ratio)


def _apply_reserved_cash_policy(
annotations: ValueTargetExecutionAnnotations,
*,
portfolio_inputs,
runtime_metadata: Mapping[str, Any] | None,
) -> ValueTargetExecutionAnnotations:
reserved_cash = max(
float(annotations.reserved_cash or 0.0),
_resolve_platform_reserved_cash(
total_equity=float(portfolio_inputs.total_equity),
runtime_metadata=runtime_metadata,
),
)
base_investable_cash = annotations.investable_cash
if base_investable_cash is None:
base_investable_cash = max(
0.0,
float(portfolio_inputs.liquid_cash) - float(annotations.reserved_cash or 0.0),
)
investable_cash = min(
max(0.0, float(base_investable_cash)),
max(0.0, float(portfolio_inputs.liquid_cash) - reserved_cash),
)
return replace(
annotations,
reserved_cash=reserved_cash,
investable_cash=investable_cash,
)


def _build_hold_current_value_decision(portfolio_inputs, *, diagnostics: Mapping[str, Any]) -> StrategyDecision:
positions = []
for symbol, market_value in sorted(portfolio_inputs.market_values.items()):
Expand Down Expand Up @@ -210,6 +255,11 @@ def map_strategy_decision_to_plan(
normalized_decision,
portfolio_inputs=portfolio_inputs,
)
annotations = _apply_reserved_cash_policy(
annotations,
portfolio_inputs=portfolio_inputs,
runtime_metadata=runtime_metadata,
)
plan = build_value_target_runtime_plan(
normalized_decision,
strategy_profile=canonical_profile,
Expand Down
31 changes: 31 additions & 0 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import math
import os
from dataclasses import dataclass
from pathlib import Path
Expand All @@ -23,6 +24,8 @@
from us_equity_strategies import get_strategy_catalog

DEFAULT_ACCOUNT_REGION = "US"
DEFAULT_RESERVED_CASH_FLOOR_USD = 0.0
DEFAULT_RESERVED_CASH_RATIO = 0.0
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0


Expand All @@ -42,6 +45,8 @@ class PlatformRuntimeSettings:
run_strategy_on_http: bool
live_order_ack: bool
max_order_notional_usd: float | None
reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD
reserved_cash_ratio: float = DEFAULT_RESERVED_CASH_RATIO
persist_strategy_runs: bool = False
safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD
debug_position_snapshot: bool = False
Expand Down Expand Up @@ -113,6 +118,14 @@ def load_platform_runtime_settings(
os.environ,
"FIRSTRADE_MAX_ORDER_NOTIONAL_USD",
),
reserved_cash_floor_usd=_resolve_non_negative_float_env(
"FIRSTRADE_MIN_RESERVED_CASH_USD",
default=DEFAULT_RESERVED_CASH_FLOOR_USD,
),
reserved_cash_ratio=_resolve_ratio_env(
"FIRSTRADE_RESERVED_CASH_RATIO",
default=DEFAULT_RESERVED_CASH_RATIO,
),
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
Expand Down Expand Up @@ -172,6 +185,24 @@ def _qqqi_income_ratio_env() -> float | None:
return value


def _resolve_non_negative_float_env(name: str, *, default: float) -> float:
value = resolve_optional_float_env(os.environ, name)
if value is None:
return float(default)
if not math.isfinite(value):
raise ValueError(f"{name} must be finite, got {value}")
if value < 0:
raise ValueError(f"{name} must be non-negative, got {value}")
return float(value)
Comment on lines +194 to +196

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject non-finite reserved cash env values

_resolve_non_negative_float_env only checks < 0, so non-finite inputs like NaN/inf are accepted for FIRSTRADE_MIN_RESERVED_CASH_USD and FIRSTRADE_RESERVED_CASH_RATIO instead of failing fast. In production this can silently break execution policy: e.g., FIRSTRADE_RESERVED_CASH_RATIO=NaN passes validation and is later clamped in decision_mapper to an effective 1.0, which reserves all equity and drives buy budget to zero. Add an explicit finite-number guard before returning.

Useful? React with 👍 / 👎.



def _resolve_ratio_env(name: str, *, default: float) -> float:
value = _resolve_non_negative_float_env(name, default=default)
if value > 1.0:
raise ValueError(f"{name} must be in [0,1], got {value}")
return value


def _runtime_execution_window_trading_days_env(strategy_profile: str) -> int | None:
raw_value = os.getenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS")
env_name = "FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS"
Expand Down
72 changes: 72 additions & 0 deletions tests/test_decision_mapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from __future__ import annotations

from datetime import datetime, timezone

from quant_platform_kit.common.models import PortfolioSnapshot, Position
from quant_platform_kit.strategy_contracts import PositionTarget, StrategyDecision

from decision_mapper import map_strategy_decision_to_plan


def test_applies_platform_reserved_cash_policy_to_weight_decision():
decision = StrategyDecision(
positions=(
PositionTarget(symbol="AAPL", target_weight=0.5),
PositionTarget(symbol="MSFT", target_weight=0.5),
),
diagnostics={"signal_description": "risk on"},
)
snapshot = PortfolioSnapshot(
as_of=datetime.now(timezone.utc),
total_equity=20000.0,
buying_power=4000.0,
positions=(Position(symbol="AAPL", quantity=1, market_value=1000.0),),
)

plan = map_strategy_decision_to_plan(
decision,
snapshot=snapshot,
strategy_profile="mega_cap_leader_rotation_top50_balanced",
runtime_metadata={
"firstrade_execution_policy": {
"reserved_cash_floor_usd": 1500.0,
"reserved_cash_ratio": 0.03,
}
},
)

assert plan["execution"]["reserved_cash"] == 1500.0
assert plan["execution"]["investable_cash"] == 2500.0


def test_platform_reserved_cash_policy_does_not_lower_strategy_reserve():
decision = StrategyDecision(
positions=(PositionTarget(symbol="AAA", target_value=5000.0),),
diagnostics={
"execution_annotations": {
"trade_threshold_value": 100.0,
"reserved_cash": 1200.0,
}
},
)
snapshot = PortfolioSnapshot(
as_of=datetime.now(timezone.utc),
total_equity=10000.0,
buying_power=3000.0,
positions=(),
)

plan = map_strategy_decision_to_plan(
decision,
snapshot=snapshot,
strategy_profile="tqqq_growth_income",
runtime_metadata={
"firstrade_execution_policy": {
"reserved_cash_floor_usd": 150.0,
"reserved_cash_ratio": 0.03,
}
},
)

assert plan["execution"]["reserved_cash"] == 1200.0
assert plan["execution"]["investable_cash"] == 1800.0
28 changes: 27 additions & 1 deletion tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from types import SimpleNamespace

from application.firstrade_client import FirstradeCredentials
from application.rebalance_service import run_strategy_cycle
from application.rebalance_service import _runtime_metadata_with_execution_policy, run_strategy_cycle
from notifications.telegram import I18N, build_translator, render_cycle_summary
from quant_platform_kit.strategy_contracts import PositionTarget, StrategyDecision
from runtime_config_support import PlatformRuntimeSettings
Expand Down Expand Up @@ -36,6 +36,32 @@ def _runtime_settings_with_persistence(**overrides) -> PlatformRuntimeSettings:
return PlatformRuntimeSettings(**values)


def test_runtime_metadata_uses_platform_execution_policy_over_strategy_metadata():
metadata = {
"signal": "ok",
"firstrade_execution_policy": {
"reserved_cash_floor_usd": 1.0,
"reserved_cash_ratio": 0.0,
},
}

result = _runtime_metadata_with_execution_policy(
metadata,
settings=_runtime_settings_with_persistence(
reserved_cash_floor_usd=250.0,
reserved_cash_ratio=0.03,
),
)

assert result == {
"signal": "ok",
"firstrade_execution_policy": {
"reserved_cash_floor_usd": 250.0,
"reserved_cash_ratio": 0.03,
},
}


class FakeFirstradeClient:
def __init__(self, _credentials, *, live_trading_enabled=False):
self.live_trading_enabled = live_trading_enabled
Expand Down
Loading