Skip to content

Commit fd914ae

Browse files
committed
Remove default Firstrade order notional cap
1 parent 1fb9d9e commit fd914ae

11 files changed

Lines changed: 133 additions & 37 deletions

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,6 @@ FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP=false
3737
FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS=false
3838
FIRSTRADE_RUN_STRATEGY_ON_HTTP=false
3939
FIRSTRADE_LIVE_ORDER_ACK=false
40-
FIRSTRADE_MAX_ORDER_NOTIONAL_USD=25
40+
FIRSTRADE_MAX_ORDER_NOTIONAL_USD=
4141
FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD=1000
4242
FIRSTRADE_SMOKE_SYMBOL=SPY

README.md

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ commit credentials.
9090
| `FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS` | Optional | Include compact symbol/quantity/market-value positions in `/session-check` funds snapshots. Defaults to `false` |
9191
| `FIRSTRADE_RUN_STRATEGY_ON_HTTP` | Optional | Must be `true` before `/run` performs strategy evaluation and order routing |
9292
| `FIRSTRADE_LIVE_ORDER_ACK` | Optional | Must be `true` before `/run` can submit live orders |
93-
| `FIRSTRADE_MAX_ORDER_NOTIONAL_USD` | Optional | Single-order cap for strategy-generated orders, default `25` |
93+
| `FIRSTRADE_MAX_ORDER_NOTIONAL_USD` | Optional | Optional single-order cap for strategy-generated orders. Unset means no platform-side notional cap |
9494
| `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`. |
9595

9696
## Local Validation
@@ -128,16 +128,15 @@ Dry-run order preview for a tiny notional buy:
128128
--preview-order \
129129
--symbol YOUR_SYMBOL \
130130
--side buy \
131-
--notional-usd 5 \
132-
--max-notional-usd 25
131+
--notional-usd 5
133132
```
134133

135134
Live order validation requires all of the following:
136135

137136
- `FIRSTRADE_ENABLE_LIVE_TRADING=true`
138137
- `--live-order`
139138
- `--yes-i-understand-unofficial-api-risk`
140-
- order notional at or below `--max-notional-usd`
139+
- order notional at or below `--max-notional-usd` when that optional cap is set
141140

142141
Example shape:
143142

@@ -148,7 +147,6 @@ FIRSTRADE_ENABLE_LIVE_TRADING=true \
148147
--symbol YOUR_SYMBOL \
149148
--side buy \
150149
--notional-usd 5 \
151-
--max-notional-usd 25 \
152150
--yes-i-understand-unofficial-api-risk
153151
```
154152

@@ -176,7 +174,7 @@ all of these gates:
176174
- `FIRSTRADE_DRY_RUN_ONLY=false`
177175
- `FIRSTRADE_ENABLE_LIVE_TRADING=true`
178176
- `FIRSTRADE_LIVE_ORDER_ACK=true`
179-
- order value at or below `FIRSTRADE_MAX_ORDER_NOTIONAL_USD`
177+
- order value at or below `FIRSTRADE_MAX_ORDER_NOTIONAL_USD` when that optional cap is set
180178

181179
The strategy execution service uses whole-share limit orders for generated
182180
strategy orders. If the notional cap is below the current price of a target
@@ -298,18 +296,18 @@ Firstrade 登录、账户/行情读取、下单转换、安全闸和部署 wirin
298296
- 设置 `FIRSTRADE_ENABLE_LIVE_TRADING=true`
299297
- CLI 使用 `--live-order`
300298
- CLI 使用 `--yes-i-understand-unofficial-api-risk`
301-
- 金额不超过 `--max-notional-usd`
299+
- 如果设置了 `--max-notional-usd`,金额不超过该上限
302300

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

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

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

314312
请不要把 Firstrade 登录凭据、MFA secret、cookie 文件提交到 Git。`.env`
315313
`.runtime/``ft_cookies*.json` 已经在 `.gitignore` 中。

application/execution_service.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,17 @@ def _sell_budget(
6565
target_value: float,
6666
sellable_quantity: float,
6767
price: float,
68-
order_notional_cap: float,
68+
order_notional_cap: float | None,
6969
) -> float:
7070
sellable_notional = max(0.0, float(sellable_quantity or 0.0)) * max(0.0, float(price or 0.0))
7171
if sellable_notional <= 0.0:
7272
return 0.0
7373
value_delta_budget = max(0.0, abs(float(delta_value or 0.0)))
7474
position_budget = max(0.0, sellable_notional - max(0.0, float(target_value or 0.0)))
75-
return min(max(value_delta_budget, position_budget), sellable_notional, max(0.0, float(order_notional_cap or 0.0)))
75+
budget = min(max(value_delta_budget, position_budget), sellable_notional)
76+
if order_notional_cap is not None:
77+
budget = min(budget, max(0.0, float(order_notional_cap or 0.0)))
78+
return budget
7679

7780

7881
def _safe_haven_cash_symbols(*, portfolio: dict[str, Any], allocation: dict[str, Any]) -> tuple[str, ...]:
@@ -168,8 +171,11 @@ def _submit_order(
168171
side: str,
169172
quantity: int,
170173
limit_price: float,
171-
max_notional_usd: float,
174+
max_notional_usd: float | None,
172175
) -> dict[str, Any]:
176+
metadata = {}
177+
if max_notional_usd is not None:
178+
metadata["max_notional_usd"] = float(max_notional_usd)
173179
report = execution_port.submit_order(
174180
OrderIntent(
175181
symbol=symbol,
@@ -178,7 +184,7 @@ def _submit_order(
178184
order_type="limit",
179185
limit_price=round(float(limit_price), 2),
180186
time_in_force="day",
181-
metadata={"max_notional_usd": float(max_notional_usd)},
187+
metadata=metadata,
182188
)
183189
)
184190
return {
@@ -201,7 +207,7 @@ def execute_value_target_plan(
201207
dry_run_only: bool,
202208
limit_sell_discount: float = 0.995,
203209
limit_buy_premium: float = 1.005,
204-
max_order_notional_usd: float = 25.0,
210+
max_order_notional_usd: float | None = None,
205211
safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD,
206212
) -> ExecutionCycleResult:
207213
del dry_run_only # ExecutionPort owns preview vs live submission.
@@ -234,7 +240,11 @@ def execute_value_target_plan(
234240
0.0,
235241
float(execution.get("investable_cash") or portfolio.get("liquid_cash") or 0.0),
236242
)
237-
order_notional_cap = max(0.0, float(max_order_notional_usd or 0.0))
243+
order_notional_cap = (
244+
max(0.0, float(max_order_notional_usd))
245+
if max_order_notional_usd is not None and float(max_order_notional_usd) > 0.0
246+
else None
247+
)
238248

239249
submitted: list[dict[str, Any]] = []
240250
skipped: list[dict[str, Any]] = []
@@ -275,7 +285,11 @@ def execute_value_target_plan(
275285
{
276286
"symbol": symbol,
277287
"reason": "sell_quantity_zero",
278-
"max_order_notional_usd": round(order_notional_cap, 2),
288+
**(
289+
{"max_order_notional_usd": round(order_notional_cap, 2)}
290+
if order_notional_cap is not None
291+
else {}
292+
),
279293
}
280294
)
281295
continue
@@ -292,14 +306,20 @@ def execute_value_target_plan(
292306
continue
293307

294308
for symbol, delta_value, price in [item for item in tradable_deltas if item[1] > 0]:
295-
buy_budget = min(float(delta_value), investable_cash, order_notional_cap)
309+
buy_budget = min(float(delta_value), investable_cash)
310+
if order_notional_cap is not None:
311+
buy_budget = min(buy_budget, order_notional_cap)
296312
quantity = _floor_quantity(buy_budget / price)
297313
if quantity <= 0:
298314
skipped.append(
299315
{
300316
"symbol": symbol,
301317
"reason": "buy_quantity_zero",
302-
"max_order_notional_usd": round(order_notional_cap, 2),
318+
**(
319+
{"max_order_notional_usd": round(order_notional_cap, 2)}
320+
if order_notional_cap is not None
321+
else {}
322+
),
303323
}
304324
)
305325
continue

application/firstrade_client.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ class StockOrderRequest:
103103
duration: str = "day"
104104
limit_price: float | None = None
105105
stop_price: float | None = None
106-
max_notional_usd: float = 25.0
106+
max_notional_usd: float | None = None
107107

108108

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

161161
price_type = str(request.price_type or "").strip().lower()
162162
if price_type not in {"market", "limit", "stop", "stop_limit"}:
@@ -169,7 +169,7 @@ def validate_stock_order(
169169
raise FirstradeSafetyError("Notional orders are restricted to buy-side validation.")
170170
if price_type != "market":
171171
raise FirstradeSafetyError("Notional validation only supports market preview/orders.")
172-
if notional_usd > max_notional_usd:
172+
if max_notional_usd is not None and notional_usd > max_notional_usd:
173173
raise FirstradeSafetyError(
174174
f"notional_usd {notional_usd:.2f} exceeds max_notional_usd {max_notional_usd:.2f}."
175175
)
@@ -190,7 +190,7 @@ def validate_stock_order(
190190
if request.limit_price is None:
191191
raise FirstradeSafetyError("Live quantity orders must use a limit price for local notional checks.")
192192
estimated_notional = int(request.quantity) * float(request.limit_price)
193-
if estimated_notional > max_notional_usd:
193+
if max_notional_usd is not None and estimated_notional > max_notional_usd:
194194
raise FirstradeSafetyError(
195195
f"estimated order notional {estimated_notional:.2f} exceeds max_notional_usd "
196196
f"{max_notional_usd:.2f}."

application/rebalance_service.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,8 +305,11 @@ def run_strategy_cycle(
305305
if strategy_run_persistence_error:
306306
result["strategy_run_persistence_error"] = strategy_run_persistence_error
307307
if persist_strategy_runs:
308+
stage = "DRY_RUN_COMPLETED"
309+
if not settings.dry_run_only:
310+
stage = "SUBMITTED" if execution_result.action_done else "NO_ACTION"
308311
completed_state = build_strategy_run_state(
309-
stage="DRY_RUN_COMPLETED" if settings.dry_run_only else "SUBMITTED",
312+
stage=stage,
310313
account=masked_account,
311314
strategy_profile=strategy_runtime.profile,
312315
strategy_display_name=strategy_runtime.display_name,

application/runtime_broker_adapters.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class FirstradeBrokerAdapters:
4848
clock: Callable[[], datetime] = _utcnow
4949
live_orders: bool = False
5050
live_order_ack: bool = False
51-
max_order_notional_usd: float = 25.0
51+
max_order_notional_usd: float | None = None
5252

5353
def normalize_symbol(self, symbol: str) -> str:
5454
value = str(symbol or "").strip().upper()
@@ -195,11 +195,16 @@ def submit(order_intent) -> ExecutionReport:
195195
price_type=str(order_intent.order_type or "market").lower(),
196196
duration=str(order_intent.time_in_force or "day").lower(),
197197
limit_price=order_intent.limit_price,
198-
max_notional_usd=float(
199-
(getattr(order_intent, "metadata", {}) or {}).get(
200-
"max_notional_usd",
201-
self.max_order_notional_usd,
198+
max_notional_usd=(
199+
float(max_notional)
200+
if (
201+
max_notional := (getattr(order_intent, "metadata", {}) or {}).get(
202+
"max_notional_usd",
203+
self.max_order_notional_usd,
204+
)
202205
)
206+
is not None
207+
else None
203208
),
204209
)
205210
raw = self.client.place_stock_order(
@@ -227,7 +232,7 @@ def build_runtime_broker_adapters(
227232
clock: Callable[[], datetime] = _utcnow,
228233
live_orders: bool = False,
229234
live_order_ack: bool = False,
230-
max_order_notional_usd: float = 25.0,
235+
max_order_notional_usd: float | None = None,
231236
) -> FirstradeBrokerAdapters:
232237
return FirstradeBrokerAdapters(
233238
client=client,

runtime_config_support.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ class PlatformRuntimeSettings:
4141
live_trading_enabled: bool
4242
run_strategy_on_http: bool
4343
live_order_ack: bool
44-
max_order_notional_usd: float
44+
max_order_notional_usd: float | None
4545
persist_strategy_runs: bool = False
4646
safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD
4747
debug_position_snapshot: bool = False
@@ -109,9 +109,9 @@ def load_platform_runtime_settings(
109109
run_strategy_on_http=resolve_bool_value(os.getenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP")),
110110
live_order_ack=resolve_bool_value(os.getenv("FIRSTRADE_LIVE_ORDER_ACK")),
111111
persist_strategy_runs=resolve_bool_value(os.getenv("FIRSTRADE_PERSIST_STRATEGY_RUNS")),
112-
max_order_notional_usd=(
113-
resolve_optional_float_env(os.environ, "FIRSTRADE_MAX_ORDER_NOTIONAL_USD")
114-
or 25.0
112+
max_order_notional_usd=resolve_optional_float_env(
113+
os.environ,
114+
"FIRSTRADE_MAX_ORDER_NOTIONAL_USD",
115115
),
116116
safe_haven_cash_substitute_threshold_usd=(
117117
max(0.0, safe_haven_cash_substitute_threshold_usd)

scripts/firstrade_smoke_check.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def build_parser() -> argparse.ArgumentParser:
6161
parser.add_argument("--duration", choices=["day", "day_ext", "overnight", "gt90"], default="day")
6262
parser.add_argument("--limit-price", type=float)
6363
parser.add_argument("--stop-price", type=float)
64-
parser.add_argument("--max-notional-usd", type=float, default=25.0)
64+
parser.add_argument("--max-notional-usd", type=float, default=None)
6565
parser.add_argument(
6666
"--yes-i-understand-unofficial-api-risk",
6767
action="store_true",

tests/test_execution_service.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def submit_order(self, order_intent) -> ExecutionReport:
3636
status="previewed",
3737
raw_payload={
3838
"limit_price": order_intent.limit_price,
39-
"max_notional_usd": order_intent.metadata["max_notional_usd"],
39+
"max_notional_usd": order_intent.metadata.get("max_notional_usd"),
4040
},
4141
)
4242

@@ -118,6 +118,30 @@ def test_execute_value_target_plan_skips_when_cap_cannot_buy_one_share():
118118
)
119119

120120

121+
def test_execute_value_target_plan_has_no_default_order_notional_cap():
122+
execution_port = FakeExecutionPort()
123+
result = execute_value_target_plan(
124+
plan={
125+
"allocation": {"targets": {"SPY": 500.0}},
126+
"portfolio": {
127+
"market_values": {"SPY": 0.0},
128+
"sellable_quantities": {},
129+
"liquid_cash": 500.0,
130+
},
131+
"execution": {"current_min_trade": 1.0, "investable_cash": 500.0},
132+
},
133+
market_data_port=FakeMarketDataPort({"SPY": 100.0}),
134+
execution_port=execution_port,
135+
dry_run_only=True,
136+
)
137+
138+
assert result.action_done is True
139+
assert [(order.side, order.symbol, order.quantity) for order in execution_port.orders] == [
140+
("buy", "SPY", 5.0),
141+
]
142+
assert execution_port.orders[0].metadata == {}
143+
144+
121145
def test_execute_value_target_plan_leaves_small_safe_haven_target_as_cash():
122146
execution_port = FakeExecutionPort()
123147
plan = {

tests/test_firstrade_client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,24 @@ def test_notional_live_order_rejects_size_above_local_cap():
105105
)
106106

107107

108+
def test_live_order_has_no_default_notional_cap_when_unset():
109+
request = StockOrderRequest(
110+
account="12345678",
111+
symbol="SPY",
112+
side="buy",
113+
quantity=10,
114+
price_type="limit",
115+
limit_price=100,
116+
)
117+
118+
validate_stock_order(
119+
request,
120+
dry_run=False,
121+
live_trading_enabled=True,
122+
explicit_live_ack=True,
123+
)
124+
125+
108126
def test_live_order_requires_environment_gate_and_ack():
109127
request = StockOrderRequest(
110128
account="12345678",

0 commit comments

Comments
 (0)