Skip to content

Commit 6847f5c

Browse files
committed
Clean up Firstrade state docs and payload helpers
1 parent aeb8025 commit 6847f5c

4 files changed

Lines changed: 137 additions & 144 deletions

File tree

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ 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` |
74+
| `FIRSTRADE_REUSE_SESSION` | Optional | Try cached Firstrade session headers 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` |
7676
| `FIRSTRADE_PERSIST_SESSION_CACHE` | Optional | Persist Firstrade session headers to the configured GCS state bucket when `FIRSTRADE_REUSE_SESSION=true`. Defaults to `false` |
7777
| `FIRSTRADE_GCS_STATE_BUCKET` | Optional | GCS bucket for runtime state JSON, including persisted session cache and account funds snapshots |
@@ -214,6 +214,31 @@ included in the snapshot.
214214
With the default environment, `/run` previews orders only. It can submit live
215215
orders only when every live-trading gate above is enabled.
216216

217+
## Runtime State And Schedulers
218+
219+
The deployed Firstrade runtime keeps trading disabled unless the explicit live
220+
order gates are changed:
221+
222+
- `FIRSTRADE_DRY_RUN_ONLY=true`
223+
- `FIRSTRADE_RUN_STRATEGY_ON_HTTP=false`
224+
- `FIRSTRADE_ENABLE_LIVE_TRADING=false`
225+
- `FIRSTRADE_LIVE_ORDER_ACK=false`
226+
227+
For session keepalive tests, create a private GCS bucket, grant the Cloud Run
228+
runtime service account object read/write access, and set:
229+
230+
- `FIRSTRADE_REUSE_SESSION=true`
231+
- `FIRSTRADE_PERSIST_SESSION_CACHE=true`
232+
- `FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT=true`
233+
- `FIRSTRADE_GCS_STATE_BUCKET=<bucket-name>`
234+
- `FIRSTRADE_STATE_PREFIX=firstrade-platform`
235+
- `FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP=true`
236+
237+
The `/session-check` scheduler can safely run more often than the strategy
238+
scheduler because it is read-only. A typical test schedule is every 30 minutes
239+
during US regular market hours. The route logs `session_reused=true|false` and
240+
writes the latest masked funds snapshot plus timestamped history to GCS.
241+
217242
## License And Upstream Compliance
218243

219244
This repository is MIT licensed. The upstream `firstrade` package is also MIT
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Helpers for normalizing Firstrade account payload shapes."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Mapping
6+
from typing import Any
7+
8+
9+
def float_or_none(value: Any) -> float | None:
10+
if value in (None, ""):
11+
return None
12+
try:
13+
return float(str(value).replace(",", ""))
14+
except (TypeError, ValueError):
15+
return None
16+
17+
18+
def flatten_values(payload: Any, prefix: str = "") -> dict[str, Any]:
19+
values: dict[str, Any] = {}
20+
if isinstance(payload, Mapping):
21+
for key, value in payload.items():
22+
child_key = f"{prefix}.{key}" if prefix else str(key)
23+
values.update(flatten_values(value, child_key))
24+
elif isinstance(payload, list):
25+
for index, value in enumerate(payload):
26+
values.update(flatten_values(value, f"{prefix}.{index}"))
27+
else:
28+
values[prefix] = payload
29+
return values
30+
31+
32+
def first_numeric_by_keywords(payload: Any, keywords: tuple[str, ...]) -> float | None:
33+
for key, value in flatten_values(payload).items():
34+
key_lower = key.lower()
35+
if all(keyword in key_lower for keyword in keywords):
36+
number = float_or_none(value)
37+
if number is not None:
38+
return number
39+
return None
40+
41+
42+
def selected_numeric_metrics(payload: Any, keywords: tuple[str, ...]) -> dict[str, float]:
43+
metrics: dict[str, float] = {}
44+
for key, value in flatten_values(payload).items():
45+
key_lower = key.lower()
46+
if not any(keyword in key_lower for keyword in keywords):
47+
continue
48+
number = float_or_none(value)
49+
if number is not None:
50+
metrics[key] = number
51+
return metrics
52+
53+
54+
def iter_position_rows(payload: Any) -> list[Mapping[str, Any]]:
55+
if isinstance(payload, Mapping):
56+
for key in ("items", "positions", "data", "result"):
57+
value = payload.get(key)
58+
if isinstance(value, list):
59+
return [row for row in value if isinstance(row, Mapping)]
60+
if "symbol" in payload:
61+
return [payload]
62+
if isinstance(payload, list):
63+
return [row for row in payload if isinstance(row, Mapping)]
64+
return []
65+
66+
67+
def get_first(row: Mapping[str, Any], *keys: str) -> Any:
68+
for key in keys:
69+
if key in row:
70+
return row[key]
71+
lower_map = {str(key).lower(): value for key, value in row.items()}
72+
for key in keys:
73+
lowered = key.lower()
74+
if lowered in lower_map:
75+
return lower_map[lowered]
76+
return None

application/runtime_broker_adapters.py

Lines changed: 23 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@
22

33
from __future__ import annotations
44

5-
from collections.abc import Callable, Mapping
5+
from collections.abc import Callable
66
from dataclasses import dataclass
77
from datetime import datetime, timezone
8-
from typing import Any
98

109
import pandas as pd
1110

11+
from application.account_payload_utils import (
12+
first_numeric_by_keywords,
13+
float_or_none,
14+
get_first,
15+
iter_position_rows,
16+
)
1217
from application.firstrade_client import (
1318
FirstradeBrokerClient,
1419
StockOrderRequest,
@@ -34,65 +39,6 @@ def _utcnow() -> datetime:
3439
return datetime.now(timezone.utc)
3540

3641

37-
def _float_or_none(value: Any) -> float | None:
38-
if value in (None, ""):
39-
return None
40-
try:
41-
return float(str(value).replace(",", ""))
42-
except (TypeError, ValueError):
43-
return None
44-
45-
46-
def _flatten_values(payload: Any, prefix: str = "") -> dict[str, Any]:
47-
values: dict[str, Any] = {}
48-
if isinstance(payload, Mapping):
49-
for key, value in payload.items():
50-
child_key = f"{prefix}.{key}" if prefix else str(key)
51-
values.update(_flatten_values(value, child_key))
52-
elif isinstance(payload, list):
53-
for index, value in enumerate(payload):
54-
values.update(_flatten_values(value, f"{prefix}.{index}"))
55-
else:
56-
values[prefix] = payload
57-
return values
58-
59-
60-
def _first_numeric_by_keywords(payload: Any, keywords: tuple[str, ...]) -> float | None:
61-
flat = _flatten_values(payload)
62-
for key, value in flat.items():
63-
key_lower = key.lower()
64-
if all(keyword in key_lower for keyword in keywords):
65-
number = _float_or_none(value)
66-
if number is not None:
67-
return number
68-
return None
69-
70-
71-
def _iter_position_rows(payload: Any) -> list[Mapping[str, Any]]:
72-
if isinstance(payload, Mapping):
73-
for key in ("items", "positions", "data", "result"):
74-
value = payload.get(key)
75-
if isinstance(value, list):
76-
return [row for row in value if isinstance(row, Mapping)]
77-
if "symbol" in payload:
78-
return [payload]
79-
if isinstance(payload, list):
80-
return [row for row in payload if isinstance(row, Mapping)]
81-
return []
82-
83-
84-
def _get_first(row: Mapping[str, Any], *keys: str) -> Any:
85-
for key in keys:
86-
if key in row:
87-
return row[key]
88-
lower_map = {str(key).lower(): value for key, value in row.items()}
89-
for key in keys:
90-
lowered = key.lower()
91-
if lowered in lower_map:
92-
return lower_map[lowered]
93-
return None
94-
95-
9642
@dataclass(frozen=True)
9743
class FirstradeBrokerAdapters:
9844
client: FirstradeBrokerClient
@@ -120,15 +66,15 @@ def load_quote(symbol: str) -> QuoteSnapshot:
12066
if cached is not None:
12167
return cached
12268
payload = self.client.get_quote(self.account, normalized)
123-
price = _float_or_none(payload.get("last"))
69+
price = float_or_none(payload.get("last"))
12470
if price is None:
12571
raise ValueError(f"Firstrade quote did not include a numeric last price for {normalized}.")
12672
snapshot = QuoteSnapshot(
12773
symbol=normalized,
12874
as_of=self.clock(),
12975
last_price=price,
130-
bid_price=_float_or_none(payload.get("bid")),
131-
ask_price=_float_or_none(payload.get("ask")),
76+
bid_price=float_or_none(payload.get("bid")),
77+
ask_price=float_or_none(payload.get("ask")),
13278
currency="USD",
13379
)
13480
quote_cache[normalized] = snapshot
@@ -150,7 +96,7 @@ def load_price_series(symbol: str) -> PriceSeries:
15096
PricePoint(
15197
as_of=datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc),
15298
close=close,
153-
volume=_float_or_none(candle[5] if len(candle) > 5 else None),
99+
volume=float_or_none(candle[5] if len(candle) > 5 else None),
154100
)
155101
)
156102
if not points:
@@ -189,45 +135,45 @@ def build_price_history(self, market_data_port: MarketDataPort, symbol: str):
189135
def build_portfolio_snapshot(self) -> PortfolioSnapshot:
190136
balances = self.client.get_balances(self.account)
191137
positions_payload = self.client.get_positions(self.account)
192-
rows = _iter_position_rows(positions_payload)
138+
rows = iter_position_rows(positions_payload)
193139
positions = []
194140
managed = set(self.strategy_symbols)
195141
for row in rows:
196-
raw_symbol = _get_first(row, "symbol", "ticker", "security_symbol")
142+
raw_symbol = get_first(row, "symbol", "ticker", "security_symbol")
197143
if not raw_symbol:
198144
continue
199145
symbol = self.normalize_symbol(raw_symbol)
200146
if managed and symbol not in managed:
201147
continue
202-
quantity = _float_or_none(_get_first(row, "quantity", "shares", "qty"))
148+
quantity = float_or_none(get_first(row, "quantity", "shares", "qty"))
203149
if quantity is None:
204150
continue
205151
positions.append(
206152
Position(
207153
symbol=symbol,
208154
quantity=quantity,
209-
market_value=_float_or_none(
210-
_get_first(row, "market_value", "marketValue", "value", "current_value")
155+
market_value=float_or_none(
156+
get_first(row, "market_value", "marketValue", "value", "current_value")
211157
)
212158
or 0.0,
213-
average_cost=_float_or_none(
214-
_get_first(row, "average_cost", "avg_cost", "cost_basis", "averagePrice")
159+
average_cost=float_or_none(
160+
get_first(row, "average_cost", "avg_cost", "cost_basis", "averagePrice")
215161
),
216162
currency="USD",
217163
account_id=mask_account_id(self.account),
218164
)
219165
)
220166
total_equity = (
221-
_first_numeric_by_keywords(balances, ("total", "value"))
222-
or _first_numeric_by_keywords(balances, ("equity",))
167+
first_numeric_by_keywords(balances, ("total", "value"))
168+
or first_numeric_by_keywords(balances, ("equity",))
223169
or sum(position.market_value for position in positions)
224170
)
225171
return PortfolioSnapshot(
226172
as_of=self.clock(),
227173
total_equity=float(total_equity or 0.0),
228-
buying_power=_first_numeric_by_keywords(balances, ("buying",))
229-
or _first_numeric_by_keywords(balances, ("bp",)),
230-
cash_balance=_first_numeric_by_keywords(balances, ("cash",)),
174+
buying_power=first_numeric_by_keywords(balances, ("buying",))
175+
or first_numeric_by_keywords(balances, ("bp",)),
176+
cash_balance=first_numeric_by_keywords(balances, ("cash",)),
231177
positions=tuple(positions),
232178
metadata={
233179
"broker": "firstrade",

application/session_check_service.py

Lines changed: 12 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
from datetime import datetime, timezone
88
from typing import Any
99

10+
from application.account_payload_utils import (
11+
float_or_none,
12+
get_first,
13+
iter_position_rows,
14+
selected_numeric_metrics,
15+
)
1016
from application.firstrade_client import (
1117
FirstradeBrokerClient,
1218
FirstradeCredentials,
@@ -40,78 +46,18 @@ def _safe_key(value: str) -> str:
4046
return "".join(ch if ch.isalnum() else "_" for ch in str(value or "")) or "unknown"
4147

4248

43-
def _float_or_none(value: Any) -> float | None:
44-
if value in (None, ""):
45-
return None
46-
try:
47-
return float(str(value).replace(",", ""))
48-
except (TypeError, ValueError):
49-
return None
50-
51-
52-
def _flatten_values(payload: Any, prefix: str = "") -> dict[str, Any]:
53-
values: dict[str, Any] = {}
54-
if isinstance(payload, Mapping):
55-
for key, value in payload.items():
56-
child_key = f"{prefix}.{key}" if prefix else str(key)
57-
values.update(_flatten_values(value, child_key))
58-
elif isinstance(payload, list):
59-
for index, value in enumerate(payload):
60-
values.update(_flatten_values(value, f"{prefix}.{index}"))
61-
else:
62-
values[prefix] = payload
63-
return values
64-
65-
66-
def _selected_balance_metrics(payload: Any) -> dict[str, float]:
67-
metrics: dict[str, float] = {}
68-
for key, value in _flatten_values(payload).items():
69-
lowered = key.lower()
70-
if not any(keyword in lowered for keyword in BALANCE_KEYWORDS):
71-
continue
72-
number = _float_or_none(value)
73-
if number is not None:
74-
metrics[key] = number
75-
return metrics
76-
77-
78-
def _iter_position_rows(payload: Any) -> list[Mapping[str, Any]]:
79-
if isinstance(payload, Mapping):
80-
for key in ("items", "positions", "data", "result"):
81-
value = payload.get(key)
82-
if isinstance(value, list):
83-
return [row for row in value if isinstance(row, Mapping)]
84-
if "symbol" in payload:
85-
return [payload]
86-
if isinstance(payload, list):
87-
return [row for row in payload if isinstance(row, Mapping)]
88-
return []
89-
90-
91-
def _get_first(row: Mapping[str, Any], *keys: str) -> Any:
92-
for key in keys:
93-
if key in row:
94-
return row[key]
95-
lower_map = {str(key).lower(): value for key, value in row.items()}
96-
for key in keys:
97-
lowered = key.lower()
98-
if lowered in lower_map:
99-
return lower_map[lowered]
100-
return None
101-
102-
10349
def _compact_positions(payload: Any) -> list[dict[str, Any]]:
10450
positions: list[dict[str, Any]] = []
105-
for row in _iter_position_rows(payload):
106-
symbol = _get_first(row, "symbol", "ticker", "security_symbol")
51+
for row in iter_position_rows(payload):
52+
symbol = get_first(row, "symbol", "ticker", "security_symbol")
10753
if not symbol:
10854
continue
10955
positions.append(
11056
{
11157
"symbol": str(symbol).strip().upper(),
112-
"quantity": _float_or_none(_get_first(row, "quantity", "shares", "qty")),
113-
"market_value": _float_or_none(
114-
_get_first(row, "market_value", "marketValue", "value", "current_value")
58+
"quantity": float_or_none(get_first(row, "quantity", "shares", "qty")),
59+
"market_value": float_or_none(
60+
get_first(row, "market_value", "marketValue", "value", "current_value")
11561
),
11662
}
11763
)
@@ -133,7 +79,7 @@ def build_account_funds_snapshot(
13379
"account": mask_account_id(account),
13480
"session_reused": session_reused,
13581
"account_summaries": account_summaries,
136-
"balance_metrics": _selected_balance_metrics(balances),
82+
"balance_metrics": selected_numeric_metrics(balances, BALANCE_KEYWORDS),
13783
}
13884
if positions_payload is not None:
13985
snapshot["positions"] = _compact_positions(positions_payload)

0 commit comments

Comments
 (0)