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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ FIRSTRADE_ACCOUNT=
# Shared US equity strategy runtime.
STRATEGY_PROFILE=
FIRSTRADE_DRY_RUN_ONLY=true
FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS=
ACCOUNT_PREFIX=FIRSTRADE
ACCOUNT_REGION=US
NOTIFY_LANG=en
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ jobs:
FIRSTRADE_STATE_PREFIX: ${{ vars.FIRSTRADE_STATE_PREFIX }}
FIRSTRADE_STRATEGY_CONFIG_PATH: ${{ vars.FIRSTRADE_STRATEGY_CONFIG_PATH }}
FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON }}
FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }}
FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }}
INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }}
QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }}
Expand Down Expand Up @@ -419,6 +420,7 @@ jobs:
add_optional_env FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH
add_optional_env FIRSTRADE_STRATEGY_CONFIG_PATH
add_optional_env FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON
add_optional_env FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS
add_optional_env FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS
add_optional_env INCOME_THRESHOLD_USD
add_optional_env QQQI_INCOME_RATIO
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ commit credentials.
| `FIRSTRADE_ACCOUNT` | Optional | Required when multiple accounts are returned |
| `STRATEGY_PROFILE` | Yes for runtime | Shared US equity strategy profile |
| `FIRSTRADE_DRY_RUN_ONLY` | Optional | Defaults to `true` for platform runtime |
| `FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS` | Optional | Override the supported strategy runtime execution window in trading days. Unset uses the strategy default |
| `FIRSTRADE_REUSE_SESSION` | Optional | Try cached Firstrade session headers before logging in again. Defaults to `false` |
| `FIRSTRADE_SESSION_CACHE_TTL_SECONDS` | Optional | Max age for local session header reuse when `FIRSTRADE_REUSE_SESSION=true`. Defaults to `21600` |
| `FIRSTRADE_PERSIST_SESSION_CACHE` | Optional | Persist Firstrade session headers to the configured GCS state bucket when `FIRSTRADE_REUSE_SESSION=true`. Defaults to `false` |
Expand Down
19 changes: 18 additions & 1 deletion application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"sell_quantity_zero",
}
)
TERMINAL_FUNDING_BLOCK_SKIP_REASONS = frozenset({"insufficient_cash_for_whole_share"})


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


def _is_terminal_funding_block(blocking_skips: list[dict[str, Any]]) -> bool:
if not blocking_skips:
return False
return all(
str(item.get("reason") or "") in TERMINAL_FUNDING_BLOCK_SKIP_REASONS
for item in blocking_skips
)


def _series_from_price_history(market_data_port, symbol: str) -> pd.Series:
series = market_data_port.get_price_series(symbol)
index = pd.DatetimeIndex([pd.Timestamp(point.as_of) for point in series.points])
Expand Down Expand Up @@ -304,6 +314,8 @@ def run_strategy_cycle(
skipped_orders = list(execution_result.skipped_orders)
blocking_skips = _execution_blocking_skips(skipped_orders)
execution_blocked = bool(blocking_skips)
funding_blocked = _is_terminal_funding_block(blocking_skips)
terminal_funding_block = funding_blocked and not execution_result.action_done
result = {
"ok": not execution_blocked,
"api_kind": "unofficial-reverse-engineered",
Expand All @@ -324,14 +336,19 @@ def run_strategy_cycle(
}
if execution_blocked:
result["execution_blocked"] = True
result["execution_block_retryable"] = not terminal_funding_block
result["execution_blocking_skips"] = blocking_skips
result["error"] = "Strategy execution blocked; see execution_blocking_skips."
if funding_blocked:
result["funding_blocked"] = True
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:
if execution_blocked and execution_result.action_done:
if terminal_funding_block and not execution_result.action_done:
stage = "FUNDING_BLOCKED"
elif execution_blocked and execution_result.action_done:
stage = "PARTIAL_SUBMITTED"
elif execution_blocked:
stage = "EXECUTION_BLOCKED"
Expand Down
2 changes: 1 addition & 1 deletion application/strategy_run_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from application.state_persistence import GcsStateStore

LIVE_TERMINAL_STAGES = frozenset({"SUBMITTED", "RECONCILED", "COMPLETED"})
LIVE_TERMINAL_STAGES = frozenset({"SUBMITTED", "FUNDING_BLOCKED", "RECONCILED", "COMPLETED"})


def utcnow() -> datetime:
Expand Down
16 changes: 7 additions & 9 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,19 +173,17 @@ def _qqqi_income_ratio_env() -> float | None:


def _runtime_execution_window_trading_days_env(strategy_profile: str) -> int | None:
if strategy_profile != "tech_communication_pullback_enhancement":
return None
raw_value = os.getenv("FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS")
raw_value = os.getenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS")
env_name = "FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS"
if raw_value is None and strategy_profile == "tech_communication_pullback_enhancement":

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 Treat empty generic env as missing before legacy fallback

For tech_communication_pullback_enhancement, the legacy fallback is only used when FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS is None, not when it is present-but-empty. With the new .env.example line, teams commonly end up with FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS= in local/env files; in that case this branch is skipped, FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS is ignored, and the runtime window override silently disappears. This breaks the intended backward-compatible fallback path for existing tech deployments.

Useful? React with 👍 / 👎.

raw_value = os.getenv("FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS")
env_name = "FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS"
if raw_value is None or not str(raw_value).strip():
return None
try:
value = int(str(raw_value).strip())
except ValueError as exc:
raise ValueError(
"FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS must be a positive integer"
) from exc
raise ValueError(f"{env_name} must be a positive integer") from exc
if value <= 0:
raise ValueError(
"FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS must be a positive integer"
)
raise ValueError(f"{env_name} must be a positive integer")
return value
6 changes: 4 additions & 2 deletions strategy_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,10 @@ def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSett
overrides["income_threshold_usd"] = runtime_settings.income_threshold_usd
if runtime_settings.qqqi_income_ratio is not None:
overrides["qqqi_income_ratio"] = runtime_settings.qqqi_income_ratio
if profile == "tech_communication_pullback_enhancement":
if profile in {
"mega_cap_leader_rotation_top50_balanced",
"tech_communication_pullback_enhancement",
}:
if runtime_settings.runtime_execution_window_trading_days is not None:
overrides["runtime_execution_window_trading_days"] = (
runtime_settings.runtime_execution_window_trading_days
Expand Down Expand Up @@ -187,4 +190,3 @@ def load_strategy_runtime(
merged_runtime_config=merged_runtime_config,
logger=logger,
)

67 changes: 67 additions & 0 deletions tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,9 +262,76 @@ def test_run_strategy_cycle_persists_live_execution_blocked_without_terminal_sta
assert result["action_done"] is False
assert result["ok"] is False
assert result["execution_blocked"] is True
assert result["execution_block_retryable"] is True
assert latest_payload["stage"] == "EXECUTION_BLOCKED"


def test_run_strategy_cycle_persists_live_funding_block_as_terminal(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=None,
)

class FundingBlockedClient(FakeFirstradeClient):
def get_balances(self, _account):
return {"total_value": "150.00", "cash": "50.00", "buying_power": "50.00"}

def get_quote(self, _account, symbol):
return {"symbol": symbol, "last": "100.00", "bid": "99.90", "ask": "100.10"}

class FundingBlockedRuntime(FakeStrategyRuntime):
def evaluate(self, **inputs):
assert "portfolio_snapshot" in inputs
return SimpleNamespace(
decision=StrategyDecision(
positions=(
PositionTarget(symbol="AAA", target_value=150.0, role="risk"),
),
diagnostics={"execution_annotations": {"trade_threshold_value": 1.0}},
),
metadata={"strategy_profile": self.profile},
)

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

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

latest_payload = store.writes[-2][1]
assert result["action_done"] is False
assert result["ok"] is False
assert result["execution_blocked"] is True
assert result["execution_block_retryable"] is False
assert result["funding_blocked"] is True
assert result["skipped_orders"][0]["reason"] == "insufficient_cash_for_whole_share"
assert latest_payload["stage"] == "FUNDING_BLOCKED"

write_count = len(store.writes)
second_result = run_strategy_cycle(
runtime_settings=settings,
credentials=FirstradeCredentials(username="user", password="pass"),
client_factory=FundingBlockedClient,
state_store=store,
env_reader=lambda _name, default=None: default,
)

assert second_result["idempotency_skipped"] is True
assert second_result["existing_strategy_run_stage"] == "FUNDING_BLOCKED"
assert len(store.writes) == write_count


def test_run_strategy_cycle_persists_live_partial_submission_as_non_terminal(monkeypatch):
store = FakeStateStore()
settings = _runtime_settings_with_persistence(
Expand Down
44 changes: 44 additions & 0 deletions tests/test_runtime_config_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations

import pytest

from runtime_config_support import _runtime_execution_window_trading_days_env


def test_runtime_execution_window_uses_generic_env(monkeypatch):
monkeypatch.setenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", "7")
monkeypatch.setenv("FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", "3")

assert (
_runtime_execution_window_trading_days_env("mega_cap_leader_rotation_top50_balanced")
== 7
)
assert (
_runtime_execution_window_trading_days_env("tech_communication_pullback_enhancement")
== 7
)


def test_runtime_execution_window_keeps_legacy_tech_env(monkeypatch):
monkeypatch.delenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", raising=False)
monkeypatch.setenv("FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", "5")

assert (
_runtime_execution_window_trading_days_env("tech_communication_pullback_enhancement")
== 5
)
assert (
_runtime_execution_window_trading_days_env("mega_cap_leader_rotation_top50_balanced")
is None
)


@pytest.mark.parametrize("raw_value", ["0", "-1", "abc"])
def test_runtime_execution_window_rejects_invalid_generic_env(monkeypatch, raw_value):
monkeypatch.setenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", raw_value)

with pytest.raises(
ValueError,
match="FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS",
):
_runtime_execution_window_trading_days_env("mega_cap_leader_rotation_top50_balanced")
49 changes: 49 additions & 0 deletions tests/test_strategy_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

from runtime_config_support import PlatformRuntimeSettings
from strategy_runtime import _build_runtime_overrides


def _runtime_settings(**overrides) -> PlatformRuntimeSettings:
values = {
"project_id": None,
"account_prefix": "FIRSTRADE",
"account_region": "US",
"strategy_profile": "mega_cap_leader_rotation_top50_balanced",
"strategy_display_name": "Mega Cap Leader Rotation Top 50 Balanced",
"strategy_domain": "us_equity",
"notify_lang": "en",
"tg_token": None,
"tg_chat_id": None,
"dry_run_only": True,
"live_trading_enabled": False,
"run_strategy_on_http": False,
"live_order_ack": False,
"max_order_notional_usd": None,
}
values.update(overrides)
return PlatformRuntimeSettings(**values)


def test_runtime_execution_window_override_applies_to_mega_strategy():
settings = _runtime_settings(runtime_execution_window_trading_days=7)

assert _build_runtime_overrides(
"mega_cap_leader_rotation_top50_balanced",
settings,
) == {"runtime_execution_window_trading_days": 7}


def test_runtime_execution_window_override_applies_to_tech_strategy():
settings = _runtime_settings(runtime_execution_window_trading_days=7)

assert _build_runtime_overrides(
"tech_communication_pullback_enhancement",
settings,
) == {"runtime_execution_window_trading_days": 7}


def test_runtime_execution_window_override_ignores_other_profiles():
settings = _runtime_settings(runtime_execution_window_trading_days=7)

assert _build_runtime_overrides("global_etf_rotation", settings) == {}