Skip to content

Commit 0dcb23f

Browse files
committed
Configure Firstrade execution window retries
1 parent d320071 commit 0dcb23f

10 files changed

Lines changed: 194 additions & 13 deletions

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ FIRSTRADE_ACCOUNT=
1616
# Shared US equity strategy runtime.
1717
STRATEGY_PROFILE=
1818
FIRSTRADE_DRY_RUN_ONLY=true
19+
FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS=
1920
ACCOUNT_PREFIX=FIRSTRADE
2021
ACCOUNT_REGION=US
2122
NOTIFY_LANG=en

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ jobs:
5959
FIRSTRADE_STATE_PREFIX: ${{ vars.FIRSTRADE_STATE_PREFIX }}
6060
FIRSTRADE_STRATEGY_CONFIG_PATH: ${{ vars.FIRSTRADE_STRATEGY_CONFIG_PATH }}
6161
FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON }}
62+
FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }}
6263
FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }}
6364
INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }}
6465
QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }}
@@ -419,6 +420,7 @@ jobs:
419420
add_optional_env FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH
420421
add_optional_env FIRSTRADE_STRATEGY_CONFIG_PATH
421422
add_optional_env FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON
423+
add_optional_env FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS
422424
add_optional_env FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS
423425
add_optional_env INCOME_THRESHOLD_USD
424426
add_optional_env QQQI_INCOME_RATIO

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ commit credentials.
7171
| `FIRSTRADE_ACCOUNT` | Optional | Required when multiple accounts are returned |
7272
| `STRATEGY_PROFILE` | Yes for runtime | Shared US equity strategy profile |
7373
| `FIRSTRADE_DRY_RUN_ONLY` | Optional | Defaults to `true` for platform runtime |
74+
| `FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS` | Optional | Override the supported strategy runtime execution window in trading days. Unset uses the strategy default |
7475
| `FIRSTRADE_REUSE_SESSION` | Optional | Try cached Firstrade session headers before logging in again. Defaults to `false` |
7576
| `FIRSTRADE_SESSION_CACHE_TTL_SECONDS` | Optional | Max age for local session header reuse when `FIRSTRADE_REUSE_SESSION=true`. Defaults to `21600` |
7677
| `FIRSTRADE_PERSIST_SESSION_CACHE` | Optional | Persist Firstrade session headers to the configured GCS state bucket when `FIRSTRADE_REUSE_SESSION=true`. Defaults to `false` |

application/rebalance_service.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"sell_quantity_zero",
5454
}
5555
)
56+
TERMINAL_FUNDING_BLOCK_SKIP_REASONS = frozenset({"insufficient_cash_for_whole_share"})
5657

5758

5859
def _utcnow() -> datetime:
@@ -71,6 +72,15 @@ def _execution_blocking_skips(skipped_orders: list[dict[str, Any]]) -> list[dict
7172
]
7273

7374

75+
def _is_terminal_funding_block(blocking_skips: list[dict[str, Any]]) -> bool:
76+
if not blocking_skips:
77+
return False
78+
return all(
79+
str(item.get("reason") or "") in TERMINAL_FUNDING_BLOCK_SKIP_REASONS
80+
for item in blocking_skips
81+
)
82+
83+
7484
def _series_from_price_history(market_data_port, symbol: str) -> pd.Series:
7585
series = market_data_port.get_price_series(symbol)
7686
index = pd.DatetimeIndex([pd.Timestamp(point.as_of) for point in series.points])
@@ -304,6 +314,8 @@ def run_strategy_cycle(
304314
skipped_orders = list(execution_result.skipped_orders)
305315
blocking_skips = _execution_blocking_skips(skipped_orders)
306316
execution_blocked = bool(blocking_skips)
317+
funding_blocked = _is_terminal_funding_block(blocking_skips)
318+
terminal_funding_block = funding_blocked and not execution_result.action_done
307319
result = {
308320
"ok": not execution_blocked,
309321
"api_kind": "unofficial-reverse-engineered",
@@ -324,14 +336,19 @@ def run_strategy_cycle(
324336
}
325337
if execution_blocked:
326338
result["execution_blocked"] = True
339+
result["execution_block_retryable"] = not terminal_funding_block
327340
result["execution_blocking_skips"] = blocking_skips
328341
result["error"] = "Strategy execution blocked; see execution_blocking_skips."
342+
if funding_blocked:
343+
result["funding_blocked"] = True
329344
if strategy_run_persistence_error:
330345
result["strategy_run_persistence_error"] = strategy_run_persistence_error
331346
if persist_strategy_runs:
332347
stage = "DRY_RUN_COMPLETED"
333348
if not settings.dry_run_only:
334-
if execution_blocked and execution_result.action_done:
349+
if terminal_funding_block and not execution_result.action_done:
350+
stage = "FUNDING_BLOCKED"
351+
elif execution_blocked and execution_result.action_done:
335352
stage = "PARTIAL_SUBMITTED"
336353
elif execution_blocked:
337354
stage = "EXECUTION_BLOCKED"

application/strategy_run_persistence.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from application.state_persistence import GcsStateStore
1212

13-
LIVE_TERMINAL_STAGES = frozenset({"SUBMITTED", "RECONCILED", "COMPLETED"})
13+
LIVE_TERMINAL_STAGES = frozenset({"SUBMITTED", "FUNDING_BLOCKED", "RECONCILED", "COMPLETED"})
1414

1515

1616
def utcnow() -> datetime:

runtime_config_support.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -173,19 +173,17 @@ def _qqqi_income_ratio_env() -> float | None:
173173

174174

175175
def _runtime_execution_window_trading_days_env(strategy_profile: str) -> int | None:
176-
if strategy_profile != "tech_communication_pullback_enhancement":
177-
return None
178-
raw_value = os.getenv("FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS")
176+
raw_value = os.getenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS")
177+
env_name = "FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS"
178+
if raw_value is None and strategy_profile == "tech_communication_pullback_enhancement":
179+
raw_value = os.getenv("FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS")
180+
env_name = "FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS"
179181
if raw_value is None or not str(raw_value).strip():
180182
return None
181183
try:
182184
value = int(str(raw_value).strip())
183185
except ValueError as exc:
184-
raise ValueError(
185-
"FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS must be a positive integer"
186-
) from exc
186+
raise ValueError(f"{env_name} must be a positive integer") from exc
187187
if value <= 0:
188-
raise ValueError(
189-
"FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS must be a positive integer"
190-
)
188+
raise ValueError(f"{env_name} must be a positive integer")
191189
return value

strategy_runtime.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,10 @@ def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSett
148148
overrides["income_threshold_usd"] = runtime_settings.income_threshold_usd
149149
if runtime_settings.qqqi_income_ratio is not None:
150150
overrides["qqqi_income_ratio"] = runtime_settings.qqqi_income_ratio
151-
if profile == "tech_communication_pullback_enhancement":
151+
if profile in {
152+
"mega_cap_leader_rotation_top50_balanced",
153+
"tech_communication_pullback_enhancement",
154+
}:
152155
if runtime_settings.runtime_execution_window_trading_days is not None:
153156
overrides["runtime_execution_window_trading_days"] = (
154157
runtime_settings.runtime_execution_window_trading_days
@@ -187,4 +190,3 @@ def load_strategy_runtime(
187190
merged_runtime_config=merged_runtime_config,
188191
logger=logger,
189192
)
190-

tests/test_rebalance_service.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,9 +262,76 @@ def test_run_strategy_cycle_persists_live_execution_blocked_without_terminal_sta
262262
assert result["action_done"] is False
263263
assert result["ok"] is False
264264
assert result["execution_blocked"] is True
265+
assert result["execution_block_retryable"] is True
265266
assert latest_payload["stage"] == "EXECUTION_BLOCKED"
266267

267268

269+
def test_run_strategy_cycle_persists_live_funding_block_as_terminal(monkeypatch):
270+
store = FakeStateStore()
271+
settings = _runtime_settings_with_persistence(
272+
dry_run_only=False,
273+
live_trading_enabled=True,
274+
live_order_ack=True,
275+
persist_strategy_runs=True,
276+
max_order_notional_usd=None,
277+
)
278+
279+
class FundingBlockedClient(FakeFirstradeClient):
280+
def get_balances(self, _account):
281+
return {"total_value": "150.00", "cash": "50.00", "buying_power": "50.00"}
282+
283+
def get_quote(self, _account, symbol):
284+
return {"symbol": symbol, "last": "100.00", "bid": "99.90", "ask": "100.10"}
285+
286+
class FundingBlockedRuntime(FakeStrategyRuntime):
287+
def evaluate(self, **inputs):
288+
assert "portfolio_snapshot" in inputs
289+
return SimpleNamespace(
290+
decision=StrategyDecision(
291+
positions=(
292+
PositionTarget(symbol="AAA", target_value=150.0, role="risk"),
293+
),
294+
diagnostics={"execution_annotations": {"trade_threshold_value": 1.0}},
295+
),
296+
metadata={"strategy_profile": self.profile},
297+
)
298+
299+
monkeypatch.setattr(
300+
"application.rebalance_service.load_strategy_runtime",
301+
lambda *_args, **_kwargs: FundingBlockedRuntime(),
302+
)
303+
304+
result = run_strategy_cycle(
305+
runtime_settings=settings,
306+
credentials=FirstradeCredentials(username="user", password="pass"),
307+
client_factory=FundingBlockedClient,
308+
state_store=store,
309+
env_reader=lambda _name, default=None: default,
310+
)
311+
312+
latest_payload = store.writes[-2][1]
313+
assert result["action_done"] is False
314+
assert result["ok"] is False
315+
assert result["execution_blocked"] is True
316+
assert result["execution_block_retryable"] is False
317+
assert result["funding_blocked"] is True
318+
assert result["skipped_orders"][0]["reason"] == "insufficient_cash_for_whole_share"
319+
assert latest_payload["stage"] == "FUNDING_BLOCKED"
320+
321+
write_count = len(store.writes)
322+
second_result = run_strategy_cycle(
323+
runtime_settings=settings,
324+
credentials=FirstradeCredentials(username="user", password="pass"),
325+
client_factory=FundingBlockedClient,
326+
state_store=store,
327+
env_reader=lambda _name, default=None: default,
328+
)
329+
330+
assert second_result["idempotency_skipped"] is True
331+
assert second_result["existing_strategy_run_stage"] == "FUNDING_BLOCKED"
332+
assert len(store.writes) == write_count
333+
334+
268335
def test_run_strategy_cycle_persists_live_partial_submission_as_non_terminal(monkeypatch):
269336
store = FakeStateStore()
270337
settings = _runtime_settings_with_persistence(
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from runtime_config_support import _runtime_execution_window_trading_days_env
6+
7+
8+
def test_runtime_execution_window_uses_generic_env(monkeypatch):
9+
monkeypatch.setenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", "7")
10+
monkeypatch.setenv("FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", "3")
11+
12+
assert (
13+
_runtime_execution_window_trading_days_env("mega_cap_leader_rotation_top50_balanced")
14+
== 7
15+
)
16+
assert (
17+
_runtime_execution_window_trading_days_env("tech_communication_pullback_enhancement")
18+
== 7
19+
)
20+
21+
22+
def test_runtime_execution_window_keeps_legacy_tech_env(monkeypatch):
23+
monkeypatch.delenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", raising=False)
24+
monkeypatch.setenv("FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", "5")
25+
26+
assert (
27+
_runtime_execution_window_trading_days_env("tech_communication_pullback_enhancement")
28+
== 5
29+
)
30+
assert (
31+
_runtime_execution_window_trading_days_env("mega_cap_leader_rotation_top50_balanced")
32+
is None
33+
)
34+
35+
36+
@pytest.mark.parametrize("raw_value", ["0", "-1", "abc"])
37+
def test_runtime_execution_window_rejects_invalid_generic_env(monkeypatch, raw_value):
38+
monkeypatch.setenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", raw_value)
39+
40+
with pytest.raises(
41+
ValueError,
42+
match="FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS",
43+
):
44+
_runtime_execution_window_trading_days_env("mega_cap_leader_rotation_top50_balanced")

tests/test_strategy_runtime.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from __future__ import annotations
2+
3+
from runtime_config_support import PlatformRuntimeSettings
4+
from strategy_runtime import _build_runtime_overrides
5+
6+
7+
def _runtime_settings(**overrides) -> PlatformRuntimeSettings:
8+
values = {
9+
"project_id": None,
10+
"account_prefix": "FIRSTRADE",
11+
"account_region": "US",
12+
"strategy_profile": "mega_cap_leader_rotation_top50_balanced",
13+
"strategy_display_name": "Mega Cap Leader Rotation Top 50 Balanced",
14+
"strategy_domain": "us_equity",
15+
"notify_lang": "en",
16+
"tg_token": None,
17+
"tg_chat_id": None,
18+
"dry_run_only": True,
19+
"live_trading_enabled": False,
20+
"run_strategy_on_http": False,
21+
"live_order_ack": False,
22+
"max_order_notional_usd": None,
23+
}
24+
values.update(overrides)
25+
return PlatformRuntimeSettings(**values)
26+
27+
28+
def test_runtime_execution_window_override_applies_to_mega_strategy():
29+
settings = _runtime_settings(runtime_execution_window_trading_days=7)
30+
31+
assert _build_runtime_overrides(
32+
"mega_cap_leader_rotation_top50_balanced",
33+
settings,
34+
) == {"runtime_execution_window_trading_days": 7}
35+
36+
37+
def test_runtime_execution_window_override_applies_to_tech_strategy():
38+
settings = _runtime_settings(runtime_execution_window_trading_days=7)
39+
40+
assert _build_runtime_overrides(
41+
"tech_communication_pullback_enhancement",
42+
settings,
43+
) == {"runtime_execution_window_trading_days": 7}
44+
45+
46+
def test_runtime_execution_window_override_ignores_other_profiles():
47+
settings = _runtime_settings(runtime_execution_window_trading_days=7)
48+
49+
assert _build_runtime_overrides("global_etf_rotation", settings) == {}

0 commit comments

Comments
 (0)