Skip to content

Commit acd3bd0

Browse files
authored
Fix Firstrade buying-power sizing and dry-run isolation (#235)
1 parent 2ad6039 commit acd3bd0

12 files changed

Lines changed: 758 additions & 103 deletions

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

Lines changed: 270 additions & 64 deletions
Large diffs are not rendered by default.

application/execution_service.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass
6+
from math import floor
67
from typing import Any
78

89
from quant_platform_kit.common.order_status import compute_confirmed_sell_release_value
@@ -139,6 +140,7 @@ class ExecutionCycleResult:
139140
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
140141
SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0
141142
MIN_NOTIONAL_BUY_USD = 1.0
143+
NOTIONAL_BUY_CASH_UTILIZATION_RATIO = 0.98
142144
_ACCEPTED_ORDER_STATUSES = frozenset(
143145
{"accepted", "filled", "partiallyfilled", "previewed", "submitted"}
144146
)
@@ -567,6 +569,19 @@ def _submit_notional_buy_order(
567569
}
568570

569571

572+
def _apply_notional_cash_buffer(*, buy_budget: float, investable_cash: float) -> float:
573+
budget = max(0.0, float(buy_budget or 0.0))
574+
available = max(0.0, float(investable_cash or 0.0))
575+
if budget + 0.005 < available:
576+
return budget
577+
buffered_available = floor(
578+
available * NOTIONAL_BUY_CASH_UTILIZATION_RATIO * 100
579+
) / 100
580+
if budget >= MIN_NOTIONAL_BUY_USD and available >= MIN_NOTIONAL_BUY_USD:
581+
buffered_available = max(buffered_available, MIN_NOTIONAL_BUY_USD)
582+
return min(budget, buffered_available)
583+
584+
570585
def _order_submission_accepted(order: dict[str, Any]) -> bool:
571586
status = "".join(
572587
ch for ch in str(order.get("status") or "").strip().lower() if ch.isalnum()
@@ -770,6 +785,10 @@ def execute_value_target_plan(
770785
if order_notional_cap is not None:
771786
buy_budget = min(buy_budget, order_notional_cap)
772787
if notional_buy_execution:
788+
buy_budget = _apply_notional_cash_buffer(
789+
buy_budget=buy_budget,
790+
investable_cash=investable_cash,
791+
)
773792
if buy_budget >= MIN_NOTIONAL_BUY_USD:
774793
estimated_buy_cost += buy_budget
775794
elif float(delta_value) >= MIN_NOTIONAL_BUY_USD:
@@ -820,6 +839,10 @@ def execute_value_target_plan(
820839
if order_notional_cap is not None:
821840
buy_budget = min(buy_budget, order_notional_cap)
822841
if notional_buy_execution:
842+
buy_budget = _apply_notional_cash_buffer(
843+
buy_budget=buy_budget,
844+
investable_cash=investable_cash,
845+
)
823846
if buy_budget < MIN_NOTIONAL_BUY_USD:
824847
skipped.append(
825848
{

application/runtime_broker_adapters.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,21 @@ def _positive_or_none(value: float | None) -> float | None:
110110
return resolved if resolved > 0.0 else None
111111

112112

113+
def _resolve_buying_power(
114+
*,
115+
cash_balance: float | None,
116+
reported_buying_power: float | None,
117+
cash_only_execution: bool,
118+
) -> float | None:
119+
if not cash_only_execution:
120+
return reported_buying_power if reported_buying_power is not None else cash_balance
121+
if cash_balance is None:
122+
return None
123+
if reported_buying_power is None:
124+
return cash_balance
125+
return max(0.0, min(float(cash_balance), float(reported_buying_power)))
126+
127+
113128
def _resolve_total_equity(
114129
*,
115130
balances,
@@ -281,8 +296,10 @@ def build_portfolio_snapshot(self) -> PortfolioSnapshot:
281296
)
282297
cash_balance = _first_numeric_by_keyword_groups(balances, _CASH_BALANCE_KEYWORD_GROUPS)
283298
reported_buying_power = _first_numeric_by_keyword_groups(balances, _BUYING_POWER_KEYWORD_GROUPS)
284-
buying_power = cash_balance if self.cash_only_execution else (
285-
reported_buying_power if reported_buying_power is not None else cash_balance
299+
buying_power = _resolve_buying_power(
300+
cash_balance=cash_balance,
301+
reported_buying_power=reported_buying_power,
302+
cash_only_execution=self.cash_only_execution,
286303
)
287304
position_market_value = sum(position.market_value for position in positions)
288305
total_equity, total_equity_source = _resolve_total_equity(

application/strategy_run_persistence.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,16 @@ def resolve_strategy_run_period(
8585
return f"{now.year:04d}-{now.month:02d}"
8686

8787

88-
def strategy_run_state_key(*, account: str, strategy_profile: str, run_period: str) -> str:
88+
def strategy_run_state_key(
89+
*,
90+
account: str,
91+
strategy_profile: str,
92+
run_period: str,
93+
dry_run_only: bool = False,
94+
) -> str:
95+
prefix = "strategy-runs/dry-run" if dry_run_only else "strategy-runs"
8996
return (
90-
f"strategy-runs/{safe_key(account)}/{safe_key(strategy_profile)}/"
97+
f"{prefix}/{safe_key(account)}/{safe_key(strategy_profile)}/"
9198
f"{safe_key(run_period)}/latest.json"
9299
)
93100

@@ -99,10 +106,12 @@ def strategy_run_history_key(
99106
run_period: str,
100107
stage: str,
101108
now: datetime,
109+
dry_run_only: bool = False,
102110
) -> str:
103111
stamp = now.strftime("%Y%m%dT%H%M%SZ")
112+
prefix = "strategy-runs/dry-run" if dry_run_only else "strategy-runs"
104113
return (
105-
f"strategy-runs/{safe_key(account)}/{safe_key(strategy_profile)}/"
114+
f"{prefix}/{safe_key(account)}/{safe_key(strategy_profile)}/"
106115
f"{safe_key(run_period)}/history/{now:%Y/%m/%d}/{stamp}-{safe_key(stage)}.json"
107116
)
108117

@@ -189,11 +198,13 @@ def persist_strategy_run_state(
189198
strategy_profile = str(state.get("strategy_profile") or "unknown")
190199
run_period = str(state.get("run_period") or f"{as_of.year:04d}-{as_of.month:02d}")
191200
stage = str(state.get("stage") or "UNKNOWN")
201+
dry_run_only = bool(state.get("dry_run_only"))
192202
store.write_json(
193203
strategy_run_state_key(
194204
account=account,
195205
strategy_profile=strategy_profile,
196206
run_period=run_period,
207+
dry_run_only=dry_run_only,
197208
),
198209
dict(state),
199210
)
@@ -204,6 +215,7 @@ def persist_strategy_run_state(
204215
run_period=run_period,
205216
stage=stage,
206217
now=as_of,
218+
dry_run_only=dry_run_only,
207219
),
208220
dict(state),
209221
)

entrypoints/cloud_run.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ def is_market_open_now(*, calendar_name="NYSE", timezone_name="America/New_York"
1717
schedule = calendar.schedule(start_date=now_market.date(), end_date=now_market.date())
1818
if schedule.empty:
1919
return False, None
20-
return calendar.open_at_time(schedule, now_market), None
20+
try:
21+
return calendar.open_at_time(schedule, now_market), None
22+
except ValueError as exc:
23+
if "not covered by the schedule" not in str(exc):
24+
raise
25+
return False, None
2126
except Exception as exc:
2227
return False, exc

scripts/reconcile_cloud_runtime.py

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
44
This script keeps the runtime logic minimal and explicit:
55
- reconcile Cloud Run traffic to the latest ready revision and verify commit-sha
6-
- delete only explicit legacy session-check Cloud Scheduler jobs
7-
8-
It deliberately does not touch probe/precheck bridge jobs or unknown schedulers.
6+
- delete only explicit legacy Cloud Scheduler jobs
97
"""
108
from __future__ import annotations
119

@@ -52,9 +50,28 @@ def _first_target(plan: Mapping[str, Any]) -> Mapping[str, Any]:
5250
def _resolve_context(env: Mapping[str, str] = os.environ) -> tuple[RuntimeContext, dict[str, Any]]:
5351
plan = _parse_sync_plan(str(env.get("SYNC_PLAN_JSON", "") or ""))
5452
target = _first_target(plan)
53+
configured_service = str(env.get("CLOUD_RUN_SERVICE", "") or "").strip()
54+
targets = plan.get("targets")
55+
if configured_service and isinstance(targets, list) and targets:
56+
matching_targets = [
57+
candidate
58+
for candidate in targets
59+
if isinstance(candidate, Mapping)
60+
and configured_service
61+
in {
62+
str(candidate.get("service_name") or "").strip(),
63+
str(candidate.get("service") or "").strip(),
64+
str(candidate.get("cloud_run_service") or "").strip(),
65+
}
66+
]
67+
if len(matching_targets) != 1:
68+
raise ValueError(
69+
f"CLOUD_RUN_SERVICE {configured_service} does not match any sync-plan target"
70+
)
71+
target = matching_targets[0]
5572

5673
service_name = (
57-
str(env.get("CLOUD_RUN_SERVICE", "") or "").strip()
74+
configured_service
5875
or str(target.get("service_name") or "").strip()
5976
or str(target.get("service") or "").strip()
6077
or str(target.get("cloud_run_service") or "").strip()
@@ -256,11 +273,18 @@ def reconcile_traffic(
256273
time.sleep(5)
257274

258275

259-
def _legacy_session_check_jobs(service_name: str) -> list[str]:
276+
def _legacy_scheduler_jobs(service_name: str) -> list[str]:
260277
candidates = [f"{service_name}-session-check-scheduler"]
261278
alias = service_name.removesuffix("-service")
262279
if alias and alias != service_name:
263-
candidates.append(f"{alias}-session-check-scheduler")
280+
candidates.extend(
281+
[
282+
f"{alias}-session-check-scheduler",
283+
f"{alias}-probe-scheduler",
284+
f"{alias}-precheck-scheduler",
285+
]
286+
)
287+
candidates.append("firstrade-monitor-dispatcher-scheduler")
264288
seen: list[str] = []
265289
for candidate in candidates:
266290
if candidate not in seen:
@@ -273,9 +297,53 @@ def cleanup_legacy_scheduler_jobs(
273297
*,
274298
run_gcloud: RunGcloud = _run_gcloud,
275299
) -> None:
276-
ctx, _plan = _resolve_context(env)
300+
ctx, plan = _resolve_context(env)
277301
deleted: list[str] = []
278-
for job_name in _legacy_session_check_jobs(ctx.service_name):
302+
legacy_jobs = _legacy_scheduler_jobs(ctx.service_name)
303+
dispatcher_job = "firstrade-monitor-dispatcher-scheduler"
304+
direct_jobs = (
305+
f"{ctx.service_name}-probe-scheduler",
306+
f"{ctx.service_name}-precheck-scheduler",
307+
)
308+
targets = plan.get("targets")
309+
has_single_sync_target = not str(env.get("SYNC_PLAN_JSON", "") or "").strip() or (
310+
isinstance(targets, list) and len(targets) == 1
311+
)
312+
migration_confirmed = (
313+
str(env.get("DIRECT_MONITOR_MIGRATION_COMPLETE") or "").strip() == "true"
314+
)
315+
current_sync_confirmed = (
316+
str(env.get("DIRECT_MONITOR_SCHEDULERS_RECONCILED") or "").strip().lower()
317+
== "true"
318+
)
319+
direct_jobs_exist = (
320+
migration_confirmed
321+
and current_sync_confirmed
322+
and has_single_sync_target
323+
and all(
324+
run_gcloud(
325+
[
326+
"scheduler",
327+
"jobs",
328+
"describe",
329+
job_name,
330+
"--project",
331+
ctx.project_id,
332+
"--location",
333+
ctx.scheduler_location,
334+
]
335+
).returncode
336+
== 0
337+
for job_name in direct_jobs
338+
)
339+
)
340+
if dispatcher_job in legacy_jobs and not direct_jobs_exist:
341+
legacy_jobs.remove(dispatcher_job)
342+
print(
343+
f"Keeping legacy Cloud Scheduler job {dispatcher_job} until direct monitor jobs exist."
344+
)
345+
346+
for job_name in legacy_jobs:
279347
result = run_gcloud(
280348
[
281349
"scheduler",
@@ -317,7 +385,7 @@ def build_parser() -> argparse.ArgumentParser:
317385
subparsers = parser.add_subparsers(dest="command", required=True)
318386
subparsers.add_parser("traffic", help="reconcile Cloud Run traffic")
319387
subparsers.add_parser(
320-
"scheduler-cleanup", help="delete explicit legacy session-check scheduler jobs"
388+
"scheduler-cleanup", help="delete explicit legacy scheduler jobs"
321389
)
322390
return parser
323391

tests/test_cloud_run_entrypoint.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from entrypoints import cloud_run
2+
3+
4+
def test_market_hours_after_close_is_closed_without_calendar_error(monkeypatch):
5+
class FakeSchedule:
6+
empty = False
7+
8+
class FakeCalendar:
9+
def schedule(self, **_kwargs):
10+
return FakeSchedule()
11+
12+
def open_at_time(self, _schedule, _now):
13+
raise ValueError("The provided timestamp is not covered by the schedule")
14+
15+
monkeypatch.setattr(cloud_run.mcal, "get_calendar", lambda _name: FakeCalendar())
16+
17+
assert cloud_run.is_market_open_now() == (False, None)

tests/test_execution_service.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from datetime import datetime, timezone
55

66
from application.execution_service import (
7+
_apply_notional_cash_buffer,
78
execute_value_target_plan,
89
substitute_small_safe_haven_targets_with_cash,
910
)
@@ -582,6 +583,33 @@ def test_execute_value_target_plan_uses_notional_buy_when_enabled():
582583
assert result.execution_notes == ()
583584

584585

586+
def test_notional_buy_keeps_cash_buffer_when_order_would_use_all_available_cash():
587+
execution_port = FakeExecutionPort()
588+
result = execute_value_target_plan(
589+
plan={
590+
"allocation": {"targets": {"IBIT": 150.0}},
591+
"portfolio": {
592+
"market_values": {"IBIT": 70.0},
593+
"quantities": {"IBIT": 2.0},
594+
"liquid_cash": 80.0,
595+
"total_equity": 150.0,
596+
},
597+
"execution": {"current_min_trade": 1.0, "investable_cash": 80.0},
598+
},
599+
market_data_port=FakeMarketDataPort({"IBIT": 35.0}),
600+
execution_port=execution_port,
601+
dry_run_only=False,
602+
notional_buy_execution=True,
603+
)
604+
605+
assert result.action_done is True
606+
assert execution_port.orders[0].metadata["notional_usd"] == 78.4
607+
608+
609+
def test_notional_cash_buffer_preserves_minimum_eligible_order():
610+
assert _apply_notional_cash_buffer(buy_budget=1.02, investable_cash=1.02) == 1.0
611+
612+
585613
def test_execute_value_target_plan_routes_rejected_notional_buy_to_skipped_orders():
586614
class RejectedExecutionPort(FakeExecutionPort):
587615
def submit_order(self, order_intent) -> ExecutionReport:
@@ -622,7 +650,7 @@ def submit_order(self, order_intent) -> ExecutionReport:
622650
assert result.action_done is False
623651
assert result.submitted_orders == ()
624652
assert result.skipped_orders[0]["reason"] == "fractional_trading_disclosure_required"
625-
assert result.skipped_orders[0]["notional_usd"] == 80.0
653+
assert result.skipped_orders[0]["notional_usd"] == 78.4
626654
assert result.execution_notes == ()
627655

628656

0 commit comments

Comments
 (0)