Skip to content

Commit 2a88d12

Browse files
committed
Persist Firstrade strategy run state
1 parent 699099c commit 2a88d12

7 files changed

Lines changed: 415 additions & 0 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ FIRSTRADE_PERSIST_SESSION_CACHE=false
3030
FIRSTRADE_GCS_STATE_BUCKET=
3131
FIRSTRADE_STATE_PREFIX=firstrade-platform
3232
FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT=false
33+
FIRSTRADE_PERSIST_STRATEGY_RUNS=false
3334
FIRSTRADE_ENABLE_LIVE_TRADING=false
3435
FIRSTRADE_RUN_SMOKE_ON_HTTP=false
3536
FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP=false

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ jobs:
5252
FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH }}
5353
FIRSTRADE_GCS_STATE_BUCKET: ${{ vars.FIRSTRADE_GCS_STATE_BUCKET }}
5454
FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT: ${{ vars.FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT }}
55+
FIRSTRADE_PERSIST_STRATEGY_RUNS: ${{ vars.FIRSTRADE_PERSIST_STRATEGY_RUNS }}
5556
FIRSTRADE_PERSIST_SESSION_CACHE: ${{ vars.FIRSTRADE_PERSIST_SESSION_CACHE }}
5657
FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP: ${{ vars.FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP }}
5758
FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS: ${{ vars.FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS }}
@@ -404,6 +405,7 @@ jobs:
404405
add_optional_env FIRSTRADE_GCS_STATE_BUCKET
405406
add_optional_env FIRSTRADE_STATE_PREFIX
406407
add_optional_env FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT
408+
add_optional_env FIRSTRADE_PERSIST_STRATEGY_RUNS
407409
add_optional_env FIRSTRADE_ENABLE_LIVE_TRADING
408410
add_optional_env FIRSTRADE_RUN_SMOKE_ON_HTTP
409411
add_optional_env FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ commit credentials.
7777
| `FIRSTRADE_GCS_STATE_BUCKET` | Optional | GCS bucket for runtime state JSON, including persisted session cache and account funds snapshots |
7878
| `FIRSTRADE_STATE_PREFIX` | Optional | Object prefix within `FIRSTRADE_GCS_STATE_BUCKET`, default `firstrade-platform` |
7979
| `FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT` | Optional | Persist compact masked account funds snapshots from `/session-check`. Defaults to `false` |
80+
| `FIRSTRADE_PERSIST_STRATEGY_RUNS` | Optional | Persist `/run` strategy state, plans, and submitted/skipped order results to GCS. Defaults to `false` |
8081
| `ACCOUNT_PREFIX` | Optional | Alert/log prefix, default `FIRSTRADE` |
8182
| `ACCOUNT_REGION` | Optional | Runtime account scope, default `US` |
8283
| `NOTIFY_LANG` | Optional | Notification language, `en` or `zh` |
@@ -197,6 +198,15 @@ to `accounts/<masked-account>/funds/latest.json` plus a timestamped history path
197198
under the configured GCS prefix. Raw account IDs and login secrets are not
198199
included in the snapshot.
199200

201+
When `FIRSTRADE_PERSIST_STRATEGY_RUNS=true` and a GCS state bucket is configured,
202+
`/run` writes strategy state to
203+
`strategy-runs/<masked-account>/<strategy-profile>/<yyyy-mm>/latest.json` plus a
204+
timestamped history path. The record includes the planned targets, compact
205+
portfolio snapshot, evaluation metadata, submitted orders, skipped orders, and
206+
stage (`ORDERS_PLANNED`, `DRY_RUN_COMPLETED`, or `SUBMITTED`). For live runs,
207+
an existing terminal record in the same account/profile/month blocks duplicate
208+
order submission.
209+
200210
## Cloud Run Shape
201211

202212
`main.py` exposes:
@@ -230,6 +240,7 @@ runtime service account object read/write access, and set:
230240
- `FIRSTRADE_REUSE_SESSION=true`
231241
- `FIRSTRADE_PERSIST_SESSION_CACHE=true`
232242
- `FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT=true`
243+
- `FIRSTRADE_PERSIST_STRATEGY_RUNS=true`
233244
- `FIRSTRADE_GCS_STATE_BUCKET=<bucket-name>`
234245
- `FIRSTRADE_STATE_PREFIX=firstrade-platform`
235246
- `FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP=true`

application/rebalance_service.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import os
1212
from collections.abc import Callable, Mapping
13+
from datetime import datetime, timezone
1314
from typing import Any
1415

1516
import pandas as pd
@@ -24,6 +25,14 @@
2425
mask_account_id,
2526
)
2627
from application.runtime_broker_adapters import build_runtime_broker_adapters
28+
from application.state_persistence import GcsStateStore, build_gcs_state_store_from_env
29+
from application.strategy_run_persistence import (
30+
build_strategy_run_state,
31+
is_duplicate_live_run,
32+
persist_strategy_run_state,
33+
read_latest_strategy_run_state,
34+
resolve_strategy_run_period,
35+
)
2736
from decision_mapper import map_strategy_decision_to_plan
2837
from notifications.telegram import build_sender, build_translator, render_cycle_summary
2938
from quant_platform_kit.common.runtime_inputs import (
@@ -38,6 +47,10 @@
3847
LIMIT_BUY_PREMIUM = 1.005
3948

4049

50+
def _utcnow() -> datetime:
51+
return datetime.now(timezone.utc)
52+
53+
4154
def get_project_id() -> str | None:
4255
return os.getenv("GOOGLE_CLOUD_PROJECT")
4356

@@ -137,11 +150,15 @@ def run_strategy_cycle(
137150
runtime_settings: PlatformRuntimeSettings | None = None,
138151
credentials: FirstradeCredentials | None = None,
139152
client_factory: Callable[..., FirstradeBrokerClient] = FirstradeBrokerClient,
153+
state_store: GcsStateStore | None = None,
140154
notification_sender: Callable[[str], None] | None = None,
141155
env_reader: Callable[[str, str | None], str | None] = os.getenv,
142156
) -> dict[str, Any]:
157+
now = _utcnow()
143158
settings = runtime_settings or load_platform_runtime_settings(project_id_resolver=get_project_id)
144159
resolved_credentials = credentials or FirstradeCredentials.from_env(env_reader)
160+
store = state_store or build_gcs_state_store_from_env(env_reader)
161+
persist_strategy_runs = bool(settings.persist_strategy_runs and store is not None)
145162
client = _connect_client(
146163
credentials=resolved_credentials,
147164
live_trading_enabled=settings.live_trading_enabled,
@@ -193,6 +210,70 @@ def run_strategy_cycle(
193210
plan,
194211
threshold_usd=settings.safe_haven_cash_substitute_threshold_usd,
195212
)
213+
run_period = resolve_strategy_run_period(
214+
now=now,
215+
plan=plan,
216+
evaluation_metadata=getattr(evaluation, "metadata", None),
217+
)
218+
masked_account = mask_account_id(account)
219+
existing_run = None
220+
if persist_strategy_runs and not settings.dry_run_only:
221+
existing_run = read_latest_strategy_run_state(
222+
store=store,
223+
account=masked_account,
224+
strategy_profile=strategy_runtime.profile,
225+
run_period=run_period,
226+
)
227+
if is_duplicate_live_run(existing_run):
228+
return {
229+
"ok": True,
230+
"api_kind": "unofficial-reverse-engineered",
231+
"account": masked_account,
232+
"strategy_profile": strategy_runtime.profile,
233+
"strategy_display_name": strategy_runtime.display_name,
234+
"dry_run_only": settings.dry_run_only,
235+
"live_trading_enabled": settings.live_trading_enabled,
236+
"session_reused": bool(getattr(client, "session_reused", False)),
237+
"strategy_run_period": run_period,
238+
"strategy_run_persisted": False,
239+
"idempotency_skipped": True,
240+
"existing_strategy_run_stage": existing_run.get("stage"),
241+
"existing_strategy_run_as_of": existing_run.get("as_of"),
242+
"submitted_orders": [],
243+
"skipped_orders": [
244+
{
245+
"reason": "duplicate_live_strategy_run",
246+
"run_period": run_period,
247+
}
248+
],
249+
"action_done": False,
250+
}
251+
strategy_run_persisted = False
252+
strategy_run_persistence_error = None
253+
if persist_strategy_runs:
254+
planned_state = build_strategy_run_state(
255+
stage="ORDERS_PLANNED",
256+
account=masked_account,
257+
strategy_profile=strategy_runtime.profile,
258+
strategy_display_name=strategy_runtime.display_name,
259+
run_period=run_period,
260+
dry_run_only=settings.dry_run_only,
261+
live_trading_enabled=settings.live_trading_enabled,
262+
session_reused=bool(getattr(client, "session_reused", False)),
263+
portfolio_snapshot=plan.get("portfolio", {}),
264+
evaluation_metadata=getattr(evaluation, "metadata", None),
265+
plan=plan,
266+
now=now,
267+
)
268+
try:
269+
strategy_run_persisted = persist_strategy_run_state(
270+
store=store,
271+
state=planned_state,
272+
now=now,
273+
)
274+
except Exception as exc:
275+
strategy_run_persisted = False
276+
strategy_run_persistence_error = f"{type(exc).__name__}: {exc}"
196277
execution_result = execute_value_target_plan(
197278
plan=plan,
198279
market_data_port=market_data_port,
@@ -212,13 +293,44 @@ def run_strategy_cycle(
212293
"dry_run_only": settings.dry_run_only,
213294
"live_trading_enabled": settings.live_trading_enabled,
214295
"session_reused": bool(getattr(client, "session_reused", False)),
296+
"strategy_run_period": run_period,
297+
"strategy_run_persisted": strategy_run_persisted,
215298
"portfolio": plan.get("portfolio", {}),
216299
"allocation": plan.get("allocation", {}),
217300
"execution": plan.get("execution", {}),
218301
"submitted_orders": list(execution_result.submitted_orders),
219302
"skipped_orders": list(execution_result.skipped_orders),
220303
"action_done": execution_result.action_done,
221304
}
305+
if strategy_run_persistence_error:
306+
result["strategy_run_persistence_error"] = strategy_run_persistence_error
307+
if persist_strategy_runs:
308+
completed_state = build_strategy_run_state(
309+
stage="DRY_RUN_COMPLETED" if settings.dry_run_only else "SUBMITTED",
310+
account=masked_account,
311+
strategy_profile=strategy_runtime.profile,
312+
strategy_display_name=strategy_runtime.display_name,
313+
run_period=run_period,
314+
dry_run_only=settings.dry_run_only,
315+
live_trading_enabled=settings.live_trading_enabled,
316+
session_reused=bool(getattr(client, "session_reused", False)),
317+
portfolio_snapshot=plan.get("portfolio", {}),
318+
evaluation_metadata=getattr(evaluation, "metadata", None),
319+
plan=plan,
320+
submitted_orders=list(execution_result.submitted_orders),
321+
skipped_orders=list(execution_result.skipped_orders),
322+
action_done=execution_result.action_done,
323+
now=now,
324+
)
325+
try:
326+
result["strategy_run_persisted"] = persist_strategy_run_state(
327+
store=store,
328+
state=completed_state,
329+
now=now,
330+
)
331+
except Exception as exc:
332+
result["strategy_run_persisted"] = False
333+
result["strategy_run_persistence_error"] = f"{type(exc).__name__}: {exc}"
222334
try:
223335
result["notification_sent"] = _publish_cycle_notification(
224336
result,

0 commit comments

Comments
 (0)