66
77from __future__ import annotations
88
9+ import json
910import os
1011from dataclasses import dataclass
1112from pathlib import Path
13+ from time import time
1214from 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+
107124def 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." )
0 commit comments