Skip to content

Commit e1ec144

Browse files
committed
Add Firstrade session check persistence
1 parent 48b1ad8 commit e1ec144

11 files changed

Lines changed: 662 additions & 16 deletions

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,16 @@ GLOBAL_TELEGRAM_CHAT_ID=
2424

2525
# Runtime safety controls.
2626
FIRSTRADE_COOKIE_DIR=.runtime/firstrade-cookies
27+
FIRSTRADE_REUSE_SESSION=false
28+
FIRSTRADE_SESSION_CACHE_TTL_SECONDS=21600
29+
FIRSTRADE_PERSIST_SESSION_CACHE=false
30+
FIRSTRADE_GCS_STATE_BUCKET=
31+
FIRSTRADE_STATE_PREFIX=firstrade-platform
32+
FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT=false
2733
FIRSTRADE_ENABLE_LIVE_TRADING=false
2834
FIRSTRADE_RUN_SMOKE_ON_HTTP=false
35+
FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP=false
36+
FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS=false
2937
FIRSTRADE_RUN_STRATEGY_ON_HTTP=false
3038
FIRSTRADE_LIVE_ORDER_ACK=false
3139
FIRSTRADE_MAX_ORDER_NOTIONAL_USD=25

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ jobs:
5050
FIRSTRADE_SMOKE_SYMBOL: ${{ vars.FIRSTRADE_SMOKE_SYMBOL }}
5151
FIRSTRADE_FEATURE_SNAPSHOT_PATH: ${{ vars.FIRSTRADE_FEATURE_SNAPSHOT_PATH }}
5252
FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH }}
53+
FIRSTRADE_GCS_STATE_BUCKET: ${{ vars.FIRSTRADE_GCS_STATE_BUCKET }}
54+
FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT: ${{ vars.FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT }}
55+
FIRSTRADE_PERSIST_SESSION_CACHE: ${{ vars.FIRSTRADE_PERSIST_SESSION_CACHE }}
56+
FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP: ${{ vars.FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP }}
57+
FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS: ${{ vars.FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS }}
58+
FIRSTRADE_STATE_PREFIX: ${{ vars.FIRSTRADE_STATE_PREFIX }}
5359
FIRSTRADE_STRATEGY_CONFIG_PATH: ${{ vars.FIRSTRADE_STRATEGY_CONFIG_PATH }}
5460
FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON }}
5561
FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }}
@@ -394,8 +400,14 @@ jobs:
394400
add_optional_env FIRSTRADE_DRY_RUN_ONLY
395401
add_optional_env FIRSTRADE_REUSE_SESSION
396402
add_optional_env FIRSTRADE_SESSION_CACHE_TTL_SECONDS
403+
add_optional_env FIRSTRADE_PERSIST_SESSION_CACHE
404+
add_optional_env FIRSTRADE_GCS_STATE_BUCKET
405+
add_optional_env FIRSTRADE_STATE_PREFIX
406+
add_optional_env FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT
397407
add_optional_env FIRSTRADE_ENABLE_LIVE_TRADING
398408
add_optional_env FIRSTRADE_RUN_SMOKE_ON_HTTP
409+
add_optional_env FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP
410+
add_optional_env FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS
399411
add_optional_env FIRSTRADE_RUN_STRATEGY_ON_HTTP
400412
add_optional_env FIRSTRADE_LIVE_ORDER_ACK
401413
add_optional_env FIRSTRADE_MAX_ORDER_NOTIONAL_USD

README.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ commit credentials.
7373
| `FIRSTRADE_DRY_RUN_ONLY` | Optional | Defaults to `true` for platform runtime |
7474
| `FIRSTRADE_REUSE_SESSION` | Optional | Reuse cached Firstrade session headers inside the same warm runtime instance before logging in again. Defaults to `false` |
7575
| `FIRSTRADE_SESSION_CACHE_TTL_SECONDS` | Optional | Max age for local session header reuse when `FIRSTRADE_REUSE_SESSION=true`. Defaults to `21600` |
76+
| `FIRSTRADE_PERSIST_SESSION_CACHE` | Optional | Persist Firstrade session headers to the configured GCS state bucket when `FIRSTRADE_REUSE_SESSION=true`. Defaults to `false` |
77+
| `FIRSTRADE_GCS_STATE_BUCKET` | Optional | GCS bucket for runtime state JSON, including persisted session cache and account funds snapshots |
78+
| `FIRSTRADE_STATE_PREFIX` | Optional | Object prefix within `FIRSTRADE_GCS_STATE_BUCKET`, default `firstrade-platform` |
79+
| `FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT` | Optional | Persist compact masked account funds snapshots from `/session-check`. Defaults to `false` |
7680
| `ACCOUNT_PREFIX` | Optional | Alert/log prefix, default `FIRSTRADE` |
7781
| `ACCOUNT_REGION` | Optional | Runtime account scope, default `US` |
7882
| `NOTIFY_LANG` | Optional | Notification language, `en` or `zh` |
@@ -81,6 +85,8 @@ commit credentials.
8185
| `FIRSTRADE_COOKIE_DIR` | Optional | Cookie cache directory, default `.runtime/firstrade-cookies` |
8286
| `FIRSTRADE_ENABLE_LIVE_TRADING` | Optional | Must be `true` before any live order can be submitted |
8387
| `FIRSTRADE_RUN_SMOKE_ON_HTTP` | Optional | Must be `true` before `/smoke` performs a real login/quote |
88+
| `FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP` | Optional | Must be `true` before `/session-check` performs a read-only login/session/account-state check |
89+
| `FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS` | Optional | Include compact symbol/quantity/market-value positions in `/session-check` funds snapshots. Defaults to `false` |
8490
| `FIRSTRADE_RUN_STRATEGY_ON_HTTP` | Optional | Must be `true` before `/run` performs strategy evaluation and order routing |
8591
| `FIRSTRADE_LIVE_ORDER_ACK` | Optional | Must be `true` before `/run` can submit live orders |
8692
| `FIRSTRADE_MAX_ORDER_NOTIONAL_USD` | Optional | Single-order cap for strategy-generated orders, default `25` |
@@ -175,11 +181,21 @@ The strategy execution service uses whole-share limit orders for generated
175181
strategy orders. If the notional cap is below the current price of a target
176182
symbol, that order is skipped instead of being enlarged.
177183

178-
`FIRSTRADE_REUSE_SESSION=true` reduces repeated login attempts while the same
179-
Cloud Run instance stays warm. It stores the current session headers only in the
180-
container-local cookie directory and tries that session before calling Firstrade
181-
login again. A cold start, new revision, expired session, or broker-side
182-
invalidation still falls back to a fresh login.
184+
`FIRSTRADE_REUSE_SESSION=true` reduces repeated login attempts by trying cached
185+
session headers before calling Firstrade login again. By default this cache is
186+
container-local. When `FIRSTRADE_PERSIST_SESSION_CACHE=true` and
187+
`FIRSTRADE_GCS_STATE_BUCKET` is set, the same cache is also written to GCS so a
188+
cold start can try the last known session first. Expired sessions, new broker
189+
sessions from another device, or broker-side invalidation still fall back to a
190+
fresh login.
191+
192+
`/session-check` is a read-only route for session keepalive experiments and
193+
account-state persistence. It connects to Firstrade, selects the account, reads
194+
balances, optionally reads positions, and returns a compact masked funds
195+
snapshot. With `FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT=true`, it writes the snapshot
196+
to `accounts/<masked-account>/funds/latest.json` plus a timestamped history path
197+
under the configured GCS prefix. Raw account IDs and login secrets are not
198+
included in the snapshot.
183199

184200
## Cloud Run Shape
185201

@@ -190,6 +206,8 @@ invalidation still falls back to a fresh login.
190206
- `/probe` health metadata only
191207
- `/profiles` shared US equity strategy matrix
192208
- `/smoke` login + quote only when `FIRSTRADE_RUN_SMOKE_ON_HTTP=true`
209+
- `/session-check` read-only session/account-state check only when
210+
`FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP=true`
193211
- `/run` strategy evaluation + guarded order routing only when
194212
`FIRSTRADE_RUN_STRATEGY_ON_HTTP=true`
195213

application/firstrade_client.py

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from time import time
1414
from typing import Any, Callable
1515

16+
from application.state_persistence import GcsStateStore
17+
1618

1719
class FirstradePlatformError(RuntimeError):
1820
"""Base error for platform integration failures."""
@@ -42,6 +44,9 @@ class FirstradeCredentials:
4244
cookie_dir: str = ".runtime/firstrade-cookies"
4345
reuse_session: bool = False
4446
session_cache_ttl_seconds: int = 21_600
47+
persist_session_cache: bool = False
48+
gcs_state_bucket: str = ""
49+
gcs_state_prefix: str = "firstrade-platform"
4550
debug: bool = False
4651

4752
@classmethod
@@ -63,6 +68,13 @@ def from_env(cls, env: Callable[[str, str | None], str | None] = os.getenv) -> "
6368
env("FIRSTRADE_SESSION_CACHE_TTL_SECONDS", "21600"),
6469
default=21_600,
6570
),
71+
persist_session_cache=(env("FIRSTRADE_PERSIST_SESSION_CACHE", "false") or "")
72+
.strip()
73+
.lower()
74+
== "true",
75+
gcs_state_bucket=(env("FIRSTRADE_GCS_STATE_BUCKET", "") or "").strip(),
76+
gcs_state_prefix=env("FIRSTRADE_STATE_PREFIX", "firstrade-platform")
77+
or "firstrade-platform",
6678
debug=(env("FIRSTRADE_DEBUG", "false") or "").lower() == "true",
6779
)
6880

@@ -195,6 +207,7 @@ def __init__(
195207
order_factory: Callable[[Any], Any] | None = None,
196208
quote_factory: Callable[[Any, str, str], Any] | None = None,
197209
ohlc_factory: Callable[[Any, str, str], Any] | None = None,
210+
session_cache_store: GcsStateStore | None = None,
198211
) -> None:
199212
self.credentials = credentials
200213
self.live_trading_enabled = live_trading_enabled
@@ -203,6 +216,7 @@ def __init__(
203216
self._order_factory = order_factory
204217
self._quote_factory = quote_factory
205218
self._ohlc_factory = ohlc_factory
219+
self._session_cache_store = session_cache_store
206220
self.session: Any | None = None
207221
self.account_data: Any | None = None
208222
self.session_reused = False
@@ -261,19 +275,31 @@ def _load_session_cache(self, cookie_dir: Path) -> dict[str, Any] | None:
261275
try:
262276
payload = json.loads(path.read_text())
263277
except (OSError, json.JSONDecodeError):
278+
payload = None
279+
if self._is_valid_session_cache_payload(payload):
280+
return payload
281+
store = self._session_state_store()
282+
if store is None:
264283
return None
265-
if not isinstance(payload, dict):
284+
try:
285+
persisted_payload = store.read_json(self._session_state_key())
286+
except Exception:
266287
return None
288+
if self._is_valid_session_cache_payload(persisted_payload):
289+
return persisted_payload
290+
return None
291+
292+
def _is_valid_session_cache_payload(self, payload: Any) -> bool:
293+
if not isinstance(payload, dict):
294+
return False
267295
try:
268296
saved_at = float(payload.get("saved_at") or 0.0)
269297
except (TypeError, ValueError):
270-
return None
298+
return False
271299
ttl = max(1, int(self.credentials.session_cache_ttl_seconds or 1))
272300
if saved_at <= 0.0 or (time() - saved_at) > ttl:
273-
return None
274-
if not payload.get("ftat") or not payload.get("sid"):
275-
return None
276-
return payload
301+
return False
302+
return bool(payload.get("ftat") and payload.get("sid"))
277303

278304
def _try_cached_session(
279305
self,
@@ -288,10 +314,18 @@ def _try_cached_session(
288314
try:
289315
from firstrade import urls
290316

291-
session.session.headers.update(urls.session_headers())
292-
session.session.headers["access-token"] = urls.access_token()
293-
session.session.headers["ftat"] = str(payload["ftat"])
294-
session.session.headers["sid"] = str(payload["sid"])
317+
if hasattr(session, "build_session_from_tokens"):
318+
session.build_session_from_tokens(payload)
319+
else:
320+
session.session.headers.update(urls.session_headers())
321+
session.session.headers["access-token"] = str(
322+
payload.get("access-token") or urls.access_token()
323+
)
324+
session.session.headers["ftat"] = str(payload["ftat"])
325+
session.session.headers["sid"] = str(payload["sid"])
326+
cookies = payload.get("cookies")
327+
if isinstance(cookies, dict) and hasattr(session.session, "cookies"):
328+
session.session.cookies.update(cookies)
295329
account_data = account_data_factory(session)
296330
except Exception:
297331
try:
@@ -302,24 +336,55 @@ def _try_cached_session(
302336
self.session = session
303337
self.account_data = account_data
304338
self.session_reused = True
339+
self._save_session_cache(cookie_dir)
305340
return True
306341

307342
def _save_session_cache(self, cookie_dir: Path) -> None:
308343
if not self.credentials.reuse_session or self.session is None:
309344
return
310-
headers = getattr(getattr(self.session, "session", None), "headers", {}) or {}
345+
session_obj = getattr(self.session, "session", None)
346+
headers = getattr(session_obj, "headers", {}) or {}
347+
cookies = {}
348+
if hasattr(session_obj, "cookies"):
349+
try:
350+
cookies = session_obj.cookies.get_dict()
351+
except Exception:
352+
cookies = {}
311353
payload = {
354+
"access-token": headers.get("access-token"),
312355
"ftat": headers.get("ftat"),
313356
"sid": headers.get("sid"),
357+
"cookies": cookies,
314358
"saved_at": time(),
315359
}
316360
if not payload["ftat"] or not payload["sid"]:
317361
return
318362
try:
319363
self._session_cache_path(cookie_dir).write_text(json.dumps(payload), encoding="utf-8")
320364
except OSError:
365+
pass
366+
store = self._session_state_store()
367+
if store is None:
368+
return
369+
try:
370+
store.write_json(self._session_state_key(), payload)
371+
except Exception:
321372
return
322373

374+
def _session_state_store(self) -> GcsStateStore | None:
375+
if self._session_cache_store is not None:
376+
return self._session_cache_store
377+
if not self.credentials.persist_session_cache or not self.credentials.gcs_state_bucket:
378+
return None
379+
return GcsStateStore(
380+
bucket=self.credentials.gcs_state_bucket,
381+
prefix=self.credentials.gcs_state_prefix,
382+
)
383+
384+
def _session_state_key(self) -> str:
385+
safe_username = "".join(ch for ch in self.credentials.username if ch.isalnum() or ch in ("-", "_"))
386+
return f"sessions/{safe_username or 'unknown'}/latest.json"
387+
323388
def require_connected(self) -> tuple[Any, Any]:
324389
if self.session is None or self.account_data is None:
325390
raise FirstradePlatformError("Firstrade client is not connected.")

0 commit comments

Comments
 (0)