Skip to content

Commit 48b1ad8

Browse files
authored
Add Firstrade session reuse and align notifications (#11)
1 parent 0fb5e18 commit 48b1ad8

8 files changed

Lines changed: 239 additions & 24 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ jobs:
3939
FIRSTRADE_ACCOUNT: ${{ vars.FIRSTRADE_ACCOUNT }}
4040
FIRSTRADE_COOKIE_DIR: ${{ vars.FIRSTRADE_COOKIE_DIR }}
4141
FIRSTRADE_DRY_RUN_ONLY: ${{ vars.FIRSTRADE_DRY_RUN_ONLY }}
42+
FIRSTRADE_REUSE_SESSION: ${{ vars.FIRSTRADE_REUSE_SESSION }}
43+
FIRSTRADE_SESSION_CACHE_TTL_SECONDS: ${{ vars.FIRSTRADE_SESSION_CACHE_TTL_SECONDS }}
4244
FIRSTRADE_ENABLE_LIVE_TRADING: ${{ vars.FIRSTRADE_ENABLE_LIVE_TRADING }}
4345
FIRSTRADE_RUN_SMOKE_ON_HTTP: ${{ vars.FIRSTRADE_RUN_SMOKE_ON_HTTP }}
4446
FIRSTRADE_RUN_STRATEGY_ON_HTTP: ${{ vars.FIRSTRADE_RUN_STRATEGY_ON_HTTP }}
@@ -390,6 +392,8 @@ jobs:
390392
add_optional_env FIRSTRADE_ACCOUNT
391393
add_optional_env FIRSTRADE_COOKIE_DIR
392394
add_optional_env FIRSTRADE_DRY_RUN_ONLY
395+
add_optional_env FIRSTRADE_REUSE_SESSION
396+
add_optional_env FIRSTRADE_SESSION_CACHE_TTL_SECONDS
393397
add_optional_env FIRSTRADE_ENABLE_LIVE_TRADING
394398
add_optional_env FIRSTRADE_RUN_SMOKE_ON_HTTP
395399
add_optional_env FIRSTRADE_RUN_STRATEGY_ON_HTTP

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ 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_REUSE_SESSION` | Optional | Reuse cached Firstrade session headers inside the same warm runtime instance before logging in again. Defaults to `false` |
75+
| `FIRSTRADE_SESSION_CACHE_TTL_SECONDS` | Optional | Max age for local session header reuse when `FIRSTRADE_REUSE_SESSION=true`. Defaults to `21600` |
7476
| `ACCOUNT_PREFIX` | Optional | Alert/log prefix, default `FIRSTRADE` |
7577
| `ACCOUNT_REGION` | Optional | Runtime account scope, default `US` |
7678
| `NOTIFY_LANG` | Optional | Notification language, `en` or `zh` |
@@ -173,6 +175,12 @@ The strategy execution service uses whole-share limit orders for generated
173175
strategy orders. If the notional cap is below the current price of a target
174176
symbol, that order is skipped instead of being enlarged.
175177

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.
183+
176184
## Cloud Run Shape
177185

178186
`main.py` exposes:

application/execution_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ def _submit_order(
185185
"symbol": report.symbol,
186186
"side": report.side,
187187
"quantity": report.quantity,
188+
"order_type": "limit",
189+
"limit_price": round(float(limit_price), 2),
188190
"status": report.status,
189191
"broker_order_id": report.broker_order_id,
190192
"raw_payload": report.raw_payload,

application/firstrade_client.py

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66

77
from __future__ import annotations
88

9+
import json
910
import os
1011
from dataclasses import dataclass
1112
from pathlib import Path
13+
from time import time
1214
from typing import Any, Callable
1315

1416

@@ -38,6 +40,8 @@ class FirstradeCredentials:
3840
mfa_secret: str = ""
3941
mfa_code: str = ""
4042
cookie_dir: str = ".runtime/firstrade-cookies"
43+
reuse_session: bool = False
44+
session_cache_ttl_seconds: int = 21_600
4145
debug: bool = False
4246

4347
@classmethod
@@ -54,6 +58,11 @@ def from_env(cls, env: Callable[[str, str | None], str | None] = os.getenv) -> "
5458
mfa_code=env("FIRSTRADE_MFA_CODE", "") or "",
5559
cookie_dir=env("FIRSTRADE_COOKIE_DIR", ".runtime/firstrade-cookies")
5660
or ".runtime/firstrade-cookies",
61+
reuse_session=(env("FIRSTRADE_REUSE_SESSION", "false") or "").strip().lower() == "true",
62+
session_cache_ttl_seconds=_coerce_positive_int(
63+
env("FIRSTRADE_SESSION_CACHE_TTL_SECONDS", "21600"),
64+
default=21_600,
65+
),
5766
debug=(env("FIRSTRADE_DEBUG", "false") or "").lower() == "true",
5867
)
5968

@@ -104,6 +113,14 @@ def _coerce_positive_float(value: float | None, field: str) -> float | None:
104113
return coerced
105114

106115

116+
def _coerce_positive_int(value: str | None, *, default: int) -> int:
117+
try:
118+
coerced = int(str(value or "").strip())
119+
except ValueError:
120+
return default
121+
return coerced if coerced > 0 else default
122+
123+
107124
def validate_stock_order(
108125
request: StockOrderRequest,
109126
*,
@@ -188,6 +205,7 @@ def __init__(
188205
self._ohlc_factory = ohlc_factory
189206
self.session: Any | None = None
190207
self.account_data: Any | None = None
208+
self.session_reused = False
191209

192210
def connect(self) -> "FirstradeBrokerClient":
193211
self.credentials.require_login_fields()
@@ -201,16 +219,14 @@ def connect(self) -> "FirstradeBrokerClient":
201219

202220
cookie_dir = Path(self.credentials.cookie_dir)
203221
cookie_dir.mkdir(parents=True, exist_ok=True)
204-
session = session_factory(
205-
username=self.credentials.username,
206-
password=self.credentials.password,
207-
pin=self.credentials.pin,
208-
email=self.credentials.email,
209-
phone=self.credentials.phone,
210-
mfa_secret=self.credentials.mfa_secret,
211-
profile_path=str(cookie_dir),
212-
debug=self.credentials.debug,
213-
)
222+
session = self._build_session(session_factory, cookie_dir)
223+
if self.credentials.reuse_session and self._try_cached_session(
224+
session,
225+
account_data_factory=account_data_factory,
226+
cookie_dir=cookie_dir,
227+
):
228+
return self
229+
214230
needs_mfa_code = bool(session.login())
215231
if needs_mfa_code:
216232
if not self.credentials.mfa_code:
@@ -220,8 +236,90 @@ def connect(self) -> "FirstradeBrokerClient":
220236
session.login_two(self.credentials.mfa_code)
221237
self.session = session
222238
self.account_data = account_data_factory(session)
239+
self.session_reused = False
240+
self._save_session_cache(cookie_dir)
223241
return self
224242

243+
def _build_session(self, session_factory: Callable[..., Any], cookie_dir: Path) -> Any:
244+
return session_factory(
245+
username=self.credentials.username,
246+
password=self.credentials.password,
247+
pin=self.credentials.pin,
248+
email=self.credentials.email,
249+
phone=self.credentials.phone,
250+
mfa_secret=self.credentials.mfa_secret,
251+
profile_path=str(cookie_dir),
252+
debug=self.credentials.debug,
253+
)
254+
255+
def _session_cache_path(self, cookie_dir: Path) -> Path:
256+
safe_username = "".join(ch for ch in self.credentials.username if ch.isalnum() or ch in ("-", "_"))
257+
return cookie_dir / f"ft_session{safe_username}.json"
258+
259+
def _load_session_cache(self, cookie_dir: Path) -> dict[str, Any] | None:
260+
path = self._session_cache_path(cookie_dir)
261+
try:
262+
payload = json.loads(path.read_text())
263+
except (OSError, json.JSONDecodeError):
264+
return None
265+
if not isinstance(payload, dict):
266+
return None
267+
try:
268+
saved_at = float(payload.get("saved_at") or 0.0)
269+
except (TypeError, ValueError):
270+
return None
271+
ttl = max(1, int(self.credentials.session_cache_ttl_seconds or 1))
272+
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
277+
278+
def _try_cached_session(
279+
self,
280+
session: Any,
281+
*,
282+
account_data_factory: Callable[[Any], Any],
283+
cookie_dir: Path,
284+
) -> bool:
285+
payload = self._load_session_cache(cookie_dir)
286+
if not payload:
287+
return False
288+
try:
289+
from firstrade import urls
290+
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"])
295+
account_data = account_data_factory(session)
296+
except Exception:
297+
try:
298+
self._session_cache_path(cookie_dir).unlink()
299+
except OSError:
300+
pass
301+
return False
302+
self.session = session
303+
self.account_data = account_data
304+
self.session_reused = True
305+
return True
306+
307+
def _save_session_cache(self, cookie_dir: Path) -> None:
308+
if not self.credentials.reuse_session or self.session is None:
309+
return
310+
headers = getattr(getattr(self.session, "session", None), "headers", {}) or {}
311+
payload = {
312+
"ftat": headers.get("ftat"),
313+
"sid": headers.get("sid"),
314+
"saved_at": time(),
315+
}
316+
if not payload["ftat"] or not payload["sid"]:
317+
return
318+
try:
319+
self._session_cache_path(cookie_dir).write_text(json.dumps(payload), encoding="utf-8")
320+
except OSError:
321+
return
322+
225323
def require_connected(self) -> tuple[Any, Any]:
226324
if self.session is None or self.account_data is None:
227325
raise FirstradePlatformError("Firstrade client is not connected.")

application/rebalance_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ def run_strategy_cycle(
147147
live_trading_enabled=settings.live_trading_enabled,
148148
client_factory=client_factory,
149149
)
150+
print(f"Firstrade session reused={bool(getattr(client, 'session_reused', False))}", flush=True)
150151
account = client.select_account(env_reader("FIRSTRADE_ACCOUNT", "") or None)
151152
strategy_runtime = load_strategy_runtime(
152153
settings.strategy_profile,
@@ -210,6 +211,7 @@ def run_strategy_cycle(
210211
"strategy_display_name": strategy_runtime.display_name,
211212
"dry_run_only": settings.dry_run_only,
212213
"live_trading_enabled": settings.live_trading_enabled,
214+
"session_reused": bool(getattr(client, "session_reused", False)),
213215
"portfolio": plan.get("portfolio", {}),
214216
"allocation": plan.get("allocation", {}),
215217
"execution": plan.get("execution", {}),

notifications/telegram.py

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"investable_cash": "可投资现金",
3434
"holdings_title": "💼 策略持仓",
3535
"holding_line": "{symbol}: {market_value} / {quantity}",
36+
"quantity_share": "{quantity}股",
3637
"quantity_shares": "{quantity}股",
3738
"signal_label": "信号",
3839
"separator": SEPARATOR,
@@ -43,10 +44,15 @@
4344
"market_status_line": "📊 市场状态: {status}",
4445
"signal_line": "🎯 信号: {signal}",
4546
"target_diff_summary": "调仓变化: {details}",
46-
"dry_run_buy_order": "🧪 模拟买单: {symbol} {quantity}",
47-
"dry_run_sell_order": "🧪 模拟卖单: {symbol} {quantity}",
48-
"submitted_buy_order": "已提交买单: {symbol} {quantity}",
49-
"submitted_sell_order": "已提交卖单: {symbol} {quantity}",
47+
"order_logs_title": "🧾 执行明细",
48+
"dry_run_order": "🧪 模拟{order_type}{side} {symbol}: {quantity}{price}",
49+
"submitted_order": "{icon} 已提交{order_type}{side} {symbol}: {quantity}{price}{order_id}",
50+
"order_type_limit": "限价",
51+
"order_type_market": "市价",
52+
"side_buy": "买入",
53+
"side_sell": "卖出",
54+
"order_price_suffix": " @ ${price}",
55+
"order_id_suffix": "(订单号: {order_id})",
5056
"no_order_submitted": "未下单: 原因={reason}",
5157
"no_rebalance_needed": "✅ 无需调仓",
5258
"no_trades": "✅ 无需调仓",
@@ -105,6 +111,7 @@
105111
"investable_cash": "Investable cash",
106112
"holdings_title": "💼 Strategy Holdings",
107113
"holding_line": "{symbol}: {market_value} / {quantity}",
114+
"quantity_share": "{quantity} share",
108115
"quantity_shares": "{quantity} shares",
109116
"signal_label": "Signal",
110117
"separator": SEPARATOR,
@@ -115,10 +122,15 @@
115122
"market_status_line": "📊 Market: {status}",
116123
"signal_line": "🎯 Signal: {signal}",
117124
"target_diff_summary": "Target changes: {details}",
118-
"dry_run_buy_order": "🧪 Dry-run buy: {symbol} {quantity}",
119-
"dry_run_sell_order": "🧪 Dry-run sell: {symbol} {quantity}",
120-
"submitted_buy_order": "Submitted buy: {symbol} {quantity}",
121-
"submitted_sell_order": "Submitted sell: {symbol} {quantity}",
125+
"order_logs_title": "🧾 Execution details",
126+
"dry_run_order": "🧪 Dry-run {order_type} {side} {symbol}: {quantity}{price}",
127+
"submitted_order": "{icon} Submitted {order_type} {side} {symbol}: {quantity}{price}{order_id}",
128+
"order_type_limit": "limit",
129+
"order_type_market": "market",
130+
"side_buy": "buy",
131+
"side_sell": "sell",
132+
"order_price_suffix": " @ ${price}",
133+
"order_id_suffix": " (ID: {order_id})",
122134
"no_order_submitted": "No order submitted: reason={reason}",
123135
"no_rebalance_needed": "✅ No rebalance needed",
124136
"no_trades": "✅ No rebalance needed",
@@ -211,6 +223,11 @@ def _format_money(value: Any) -> str:
211223
return "$0.00" if number is None else f"${number:,.2f}"
212224

213225

226+
def _format_price(value: Any) -> str:
227+
number = _safe_float(value)
228+
return "" if number is None else f"{number:,.2f}"
229+
230+
214231
def _format_quantity(value: Any) -> str:
215232
number = _safe_float(value)
216233
if number is None:
@@ -221,7 +238,9 @@ def _format_quantity(value: Any) -> str:
221238

222239

223240
def _format_shares(value: Any, *, translator: Callable[..., str]) -> str:
224-
return translator("quantity_shares", quantity=_format_quantity(value))
241+
quantity = _format_quantity(value)
242+
key = "quantity_share" if quantity == "1" else "quantity_shares"
243+
return translator(key, quantity=quantity)
225244

226245

227246
def _parse_detail_kwargs(text: str) -> dict[str, str]:
@@ -448,13 +467,39 @@ def _format_order_lines(
448467
for order in submitted:
449468
side = str(order.get("side") or "").lower()
450469
symbol = str(order.get("symbol") or "").upper()
451-
side_key = "buy" if side == "buy" else "sell"
452-
mode_key = "dry_run" if dry_run_only else "submitted"
470+
raw_payload = dict(order.get("raw_payload") or {})
471+
order_type = str(order.get("order_type") or raw_payload.get("price_type") or "limit").lower()
472+
if order_type not in {"limit", "market"}:
473+
order_type = "limit"
474+
price = _format_price(order.get("limit_price") or raw_payload.get("limit_price") or raw_payload.get("price"))
475+
price_suffix = translator("order_price_suffix", price=price) if price else ""
476+
side_key = "side_buy" if side == "buy" else "side_sell"
477+
order_type_key = "order_type_limit" if order_type == "limit" else "order_type_market"
478+
quantity = _format_shares(order.get("quantity"), translator=translator)
479+
if dry_run_only:
480+
lines.append(
481+
translator(
482+
"dry_run_order",
483+
order_type=translator(order_type_key),
484+
side=translator(side_key),
485+
symbol=symbol,
486+
quantity=quantity,
487+
price=price_suffix,
488+
)
489+
)
490+
continue
491+
order_id = str(order.get("broker_order_id") or raw_payload.get("order_id") or "").strip()
492+
order_id_suffix = translator("order_id_suffix", order_id=order_id) if order_id else ""
453493
lines.append(
454494
translator(
455-
f"{mode_key}_{side_key}_order",
495+
"submitted_order",
496+
icon="📈" if side == "buy" else "📉",
497+
order_type=translator(order_type_key),
498+
side=translator(side_key),
456499
symbol=symbol,
457-
quantity=_format_shares(order.get("quantity"), translator=translator),
500+
quantity=quantity,
501+
price=price_suffix,
502+
order_id=order_id_suffix,
458503
)
459504
)
460505
return lines
@@ -515,8 +560,10 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str:
515560
lines.append(SEPARATOR)
516561
lines.extend(target_diff_lines)
517562
if submitted:
563+
lines.append(translator("order_logs_title"))
518564
lines.extend(_format_order_lines(submitted, dry_run_only=dry_run_only, translator=translator))
519565
elif skipped and has_rebalance_attempt:
566+
lines.append(translator("order_logs_title"))
520567
reason = _format_skipped_reason(skipped, translator=translator)
521568
lines.append(translator("no_order_submitted", reason=reason))
522569
else:

0 commit comments

Comments
 (0)