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: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@ FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP=false
FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS=false
FIRSTRADE_RUN_STRATEGY_ON_HTTP=false
FIRSTRADE_LIVE_ORDER_ACK=false
FIRSTRADE_MAX_ORDER_NOTIONAL_USD=25
FIRSTRADE_MAX_ORDER_NOTIONAL_USD=
FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD=1000
FIRSTRADE_SMOKE_SYMBOL=SPY
18 changes: 8 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ commit credentials.
| `FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS` | Optional | Include compact symbol/quantity/market-value positions in `/session-check` funds snapshots. Defaults to `false` |
| `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_MAX_ORDER_NOTIONAL_USD` | Optional | Optional single-order cap for strategy-generated orders. Unset means no platform-side notional cap |
| `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 @@ -128,16 +128,15 @@ Dry-run order preview for a tiny notional buy:
--preview-order \
--symbol YOUR_SYMBOL \
--side buy \
--notional-usd 5 \
--max-notional-usd 25
--notional-usd 5
```

Live order validation requires all of the following:

- `FIRSTRADE_ENABLE_LIVE_TRADING=true`
- `--live-order`
- `--yes-i-understand-unofficial-api-risk`
- order notional at or below `--max-notional-usd`
- order notional at or below `--max-notional-usd` when that optional cap is set

Example shape:

Expand All @@ -148,7 +147,6 @@ FIRSTRADE_ENABLE_LIVE_TRADING=true \
--symbol YOUR_SYMBOL \
--side buy \
--notional-usd 5 \
--max-notional-usd 25 \
--yes-i-understand-unofficial-api-risk
```

Expand Down Expand Up @@ -176,7 +174,7 @@ all of these gates:
- `FIRSTRADE_DRY_RUN_ONLY=false`
- `FIRSTRADE_ENABLE_LIVE_TRADING=true`
- `FIRSTRADE_LIVE_ORDER_ACK=true`
- order value at or below `FIRSTRADE_MAX_ORDER_NOTIONAL_USD`
- order value at or below `FIRSTRADE_MAX_ORDER_NOTIONAL_USD` when that optional cap is set

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 @@ -298,18 +296,18 @@ Firstrade 登录、账户/行情读取、下单转换、安全闸和部署 wirin
- 设置 `FIRSTRADE_ENABLE_LIVE_TRADING=true`
- CLI 使用 `--live-order`
- CLI 使用 `--yes-i-understand-unofficial-api-risk`
- 金额不超过 `--max-notional-usd`
- 如果设置了 `--max-notional-usd`,金额不超过该上限

HTTP 策略闭环实盘还必须额外满足:

- `FIRSTRADE_RUN_STRATEGY_ON_HTTP=true`
- `FIRSTRADE_DRY_RUN_ONLY=false`
- `FIRSTRADE_LIVE_ORDER_ACK=true`
- 单笔金额不超过 `FIRSTRADE_MAX_ORDER_NOTIONAL_USD`
- 如果设置了 `FIRSTRADE_MAX_ORDER_NOTIONAL_USD`,单笔金额不超过该上限
- `BOXX`/`BIL` 等避险现金替代标的目标金额低于 `FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD` 时保留现金,默认门槛 `1000` USD

策略闭环生成的是整数股限价单。如果 `FIRSTRADE_MAX_ORDER_NOTIONAL_USD`
低于目标标的当前价格,本轮会跳过该订单,而不是放大金额。
策略闭环生成的是整数股限价单。如果设置了 `FIRSTRADE_MAX_ORDER_NOTIONAL_USD`
且它低于目标标的当前价格,本轮会跳过该订单,而不是放大金额。

请不要把 Firstrade 登录凭据、MFA secret、cookie 文件提交到 Git。`.env`、
`.runtime/` 和 `ft_cookies*.json` 已经在 `.gitignore` 中。
Expand Down
38 changes: 29 additions & 9 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,17 @@ def _sell_budget(
target_value: float,
sellable_quantity: float,
price: float,
order_notional_cap: float,
order_notional_cap: float | None,
) -> float:
sellable_notional = max(0.0, float(sellable_quantity or 0.0)) * max(0.0, float(price or 0.0))
if sellable_notional <= 0.0:
return 0.0
value_delta_budget = max(0.0, abs(float(delta_value or 0.0)))
position_budget = max(0.0, sellable_notional - max(0.0, float(target_value or 0.0)))
return min(max(value_delta_budget, position_budget), sellable_notional, max(0.0, float(order_notional_cap or 0.0)))
budget = min(max(value_delta_budget, position_budget), sellable_notional)
if order_notional_cap is not None:
budget = min(budget, max(0.0, float(order_notional_cap or 0.0)))
return budget


def _safe_haven_cash_symbols(*, portfolio: dict[str, Any], allocation: dict[str, Any]) -> tuple[str, ...]:
Expand Down Expand Up @@ -168,8 +171,11 @@ def _submit_order(
side: str,
quantity: int,
limit_price: float,
max_notional_usd: float,
max_notional_usd: float | None,
) -> dict[str, Any]:
metadata = {}
if max_notional_usd is not None:
metadata["max_notional_usd"] = float(max_notional_usd)
report = execution_port.submit_order(
OrderIntent(
symbol=symbol,
Expand All @@ -178,7 +184,7 @@ def _submit_order(
order_type="limit",
limit_price=round(float(limit_price), 2),
time_in_force="day",
metadata={"max_notional_usd": float(max_notional_usd)},
metadata=metadata,
)
)
return {
Expand All @@ -201,7 +207,7 @@ def execute_value_target_plan(
dry_run_only: bool,
limit_sell_discount: float = 0.995,
limit_buy_premium: float = 1.005,
max_order_notional_usd: float = 25.0,
max_order_notional_usd: float | None = None,
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.
Expand Down Expand Up @@ -234,7 +240,11 @@ def execute_value_target_plan(
0.0,
float(execution.get("investable_cash") or portfolio.get("liquid_cash") or 0.0),
)
order_notional_cap = max(0.0, float(max_order_notional_usd or 0.0))
order_notional_cap = (
max(0.0, float(max_order_notional_usd))
if max_order_notional_usd is not None and float(max_order_notional_usd) > 0.0
else None
)

submitted: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
Expand Down Expand Up @@ -275,7 +285,11 @@ def execute_value_target_plan(
{
"symbol": symbol,
"reason": "sell_quantity_zero",
"max_order_notional_usd": round(order_notional_cap, 2),
**(
{"max_order_notional_usd": round(order_notional_cap, 2)}
if order_notional_cap is not None
else {}
),
}
)
continue
Expand All @@ -292,14 +306,20 @@ def execute_value_target_plan(
continue

for symbol, delta_value, price in [item for item in tradable_deltas if item[1] > 0]:
buy_budget = min(float(delta_value), investable_cash, order_notional_cap)
buy_budget = min(float(delta_value), investable_cash)
if order_notional_cap is not None:
buy_budget = min(buy_budget, order_notional_cap)
quantity = _floor_quantity(buy_budget / price)
if quantity <= 0:
skipped.append(
{
"symbol": symbol,
"reason": "buy_quantity_zero",
"max_order_notional_usd": round(order_notional_cap, 2),
**(
{"max_order_notional_usd": round(order_notional_cap, 2)}
if order_notional_cap is not None
else {}
),
}
)
continue
Expand Down
8 changes: 4 additions & 4 deletions application/firstrade_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ class StockOrderRequest:
duration: str = "day"
limit_price: float | None = None
stop_price: float | None = None
max_notional_usd: float = 25.0
max_notional_usd: float | None = None


def is_live_trading_enabled(env: Callable[[str, str | None], str | None] = os.getenv) -> bool:
Expand Down Expand Up @@ -156,7 +156,7 @@ def validate_stock_order(
if request.quantity is not None and int(request.quantity) <= 0:
raise FirstradeSafetyError("quantity must be a positive integer.")
notional_usd = _coerce_positive_float(request.notional_usd, "notional_usd")
max_notional_usd = _coerce_positive_float(request.max_notional_usd, "max_notional_usd") or 25.0
max_notional_usd = _coerce_positive_float(request.max_notional_usd, "max_notional_usd")

price_type = str(request.price_type or "").strip().lower()
if price_type not in {"market", "limit", "stop", "stop_limit"}:
Expand All @@ -169,7 +169,7 @@ def validate_stock_order(
raise FirstradeSafetyError("Notional orders are restricted to buy-side validation.")
if price_type != "market":
raise FirstradeSafetyError("Notional validation only supports market preview/orders.")
if notional_usd > max_notional_usd:
if max_notional_usd is not None and notional_usd > max_notional_usd:
raise FirstradeSafetyError(
f"notional_usd {notional_usd:.2f} exceeds max_notional_usd {max_notional_usd:.2f}."
)
Expand All @@ -190,7 +190,7 @@ def validate_stock_order(
if request.limit_price is None:
raise FirstradeSafetyError("Live quantity orders must use a limit price for local notional checks.")
estimated_notional = int(request.quantity) * float(request.limit_price)
if estimated_notional > max_notional_usd:
if max_notional_usd is not None and estimated_notional > max_notional_usd:
raise FirstradeSafetyError(
f"estimated order notional {estimated_notional:.2f} exceeds max_notional_usd "
f"{max_notional_usd:.2f}."
Expand Down
5 changes: 4 additions & 1 deletion application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,11 @@ def run_strategy_cycle(
if strategy_run_persistence_error:
result["strategy_run_persistence_error"] = strategy_run_persistence_error
if persist_strategy_runs:
stage = "DRY_RUN_COMPLETED"
if not settings.dry_run_only:
stage = "SUBMITTED" if execution_result.action_done else "NO_ACTION"
completed_state = build_strategy_run_state(
stage="DRY_RUN_COMPLETED" if settings.dry_run_only else "SUBMITTED",
stage=stage,
account=masked_account,
strategy_profile=strategy_runtime.profile,
strategy_display_name=strategy_runtime.display_name,
Expand Down
17 changes: 11 additions & 6 deletions application/runtime_broker_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class FirstradeBrokerAdapters:
clock: Callable[[], datetime] = _utcnow
live_orders: bool = False
live_order_ack: bool = False
max_order_notional_usd: float = 25.0
max_order_notional_usd: float | None = None

def normalize_symbol(self, symbol: str) -> str:
value = str(symbol or "").strip().upper()
Expand Down Expand Up @@ -195,11 +195,16 @@ def submit(order_intent) -> ExecutionReport:
price_type=str(order_intent.order_type or "market").lower(),
duration=str(order_intent.time_in_force or "day").lower(),
limit_price=order_intent.limit_price,
max_notional_usd=float(
(getattr(order_intent, "metadata", {}) or {}).get(
"max_notional_usd",
self.max_order_notional_usd,
max_notional_usd=(
float(max_notional)
if (
max_notional := (getattr(order_intent, "metadata", {}) or {}).get(
"max_notional_usd",
self.max_order_notional_usd,
)
)
is not None
else None
),
)
raw = self.client.place_stock_order(
Expand Down Expand Up @@ -227,7 +232,7 @@ def build_runtime_broker_adapters(
clock: Callable[[], datetime] = _utcnow,
live_orders: bool = False,
live_order_ack: bool = False,
max_order_notional_usd: float = 25.0,
max_order_notional_usd: float | None = None,
) -> FirstradeBrokerAdapters:
return FirstradeBrokerAdapters(
client=client,
Expand Down
8 changes: 4 additions & 4 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class PlatformRuntimeSettings:
live_trading_enabled: bool
run_strategy_on_http: bool
live_order_ack: bool
max_order_notional_usd: float
max_order_notional_usd: float | None
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 @@ -109,9 +109,9 @@ def load_platform_runtime_settings(
run_strategy_on_http=resolve_bool_value(os.getenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP")),
live_order_ack=resolve_bool_value(os.getenv("FIRSTRADE_LIVE_ORDER_ACK")),
persist_strategy_runs=resolve_bool_value(os.getenv("FIRSTRADE_PERSIST_STRATEGY_RUNS")),
max_order_notional_usd=(
resolve_optional_float_env(os.environ, "FIRSTRADE_MAX_ORDER_NOTIONAL_USD")
or 25.0
max_order_notional_usd=resolve_optional_float_env(
os.environ,
"FIRSTRADE_MAX_ORDER_NOTIONAL_USD",
),
safe_haven_cash_substitute_threshold_usd=(
max(0.0, safe_haven_cash_substitute_threshold_usd)
Expand Down
2 changes: 1 addition & 1 deletion scripts/firstrade_smoke_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--duration", choices=["day", "day_ext", "overnight", "gt90"], default="day")
parser.add_argument("--limit-price", type=float)
parser.add_argument("--stop-price", type=float)
parser.add_argument("--max-notional-usd", type=float, default=25.0)
parser.add_argument("--max-notional-usd", type=float, default=None)
parser.add_argument(
"--yes-i-understand-unofficial-api-risk",
action="store_true",
Expand Down
26 changes: 25 additions & 1 deletion tests/test_execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def submit_order(self, order_intent) -> ExecutionReport:
status="previewed",
raw_payload={
"limit_price": order_intent.limit_price,
"max_notional_usd": order_intent.metadata["max_notional_usd"],
"max_notional_usd": order_intent.metadata.get("max_notional_usd"),
},
)

Expand Down Expand Up @@ -118,6 +118,30 @@ def test_execute_value_target_plan_skips_when_cap_cannot_buy_one_share():
)


def test_execute_value_target_plan_has_no_default_order_notional_cap():
execution_port = FakeExecutionPort()
result = execute_value_target_plan(
plan={
"allocation": {"targets": {"SPY": 500.0}},
"portfolio": {
"market_values": {"SPY": 0.0},
"sellable_quantities": {},
"liquid_cash": 500.0,
},
"execution": {"current_min_trade": 1.0, "investable_cash": 500.0},
},
market_data_port=FakeMarketDataPort({"SPY": 100.0}),
execution_port=execution_port,
dry_run_only=True,
)

assert result.action_done is True
assert [(order.side, order.symbol, order.quantity) for order in execution_port.orders] == [
("buy", "SPY", 5.0),
]
assert execution_port.orders[0].metadata == {}


def test_execute_value_target_plan_leaves_small_safe_haven_target_as_cash():
execution_port = FakeExecutionPort()
plan = {
Expand Down
18 changes: 18 additions & 0 deletions tests/test_firstrade_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,24 @@ def test_notional_live_order_rejects_size_above_local_cap():
)


def test_live_order_has_no_default_notional_cap_when_unset():
request = StockOrderRequest(
account="12345678",
symbol="SPY",
side="buy",
quantity=10,
price_type="limit",
limit_price=100,
)

validate_stock_order(
request,
dry_run=False,
live_trading_enabled=True,
explicit_live_ack=True,
)


def test_live_order_requires_environment_gate_and_ack():
request = StockOrderRequest(
account="12345678",
Expand Down
28 changes: 28 additions & 0 deletions tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,34 @@ def fake_client_factory(*args, **kwargs):
assert store.writes == []


def test_run_strategy_cycle_persists_live_no_action_without_duplicate_terminal_stage(monkeypatch):
store = FakeStateStore()
settings = _runtime_settings_with_persistence(
dry_run_only=False,
live_trading_enabled=True,
live_order_ack=True,
persist_strategy_runs=True,
max_order_notional_usd=1.0,
)

monkeypatch.setattr(
"application.rebalance_service.load_strategy_runtime",
lambda *_args, **_kwargs: FakeStrategyRuntime(),
)

result = run_strategy_cycle(
runtime_settings=settings,
credentials=FirstradeCredentials(username="user", password="pass"),
client_factory=FakeFirstradeClient,
state_store=store,
env_reader=lambda _name, default=None: default,
)

latest_payload = store.writes[-2][1]
assert result["action_done"] is False
assert latest_payload["stage"] == "NO_ACTION"


def test_render_cycle_summary_formats_skipped_orders_in_unified_chinese_template():
message = render_cycle_summary(
{
Expand Down