tradier-api-client 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ """
2
+ Tradier REST and Streaming client
3
+ """
4
+ import logging
5
+
6
+ from tradier_api_client.rest import RestClient
7
+ from tradier_api_client.streaming.streaming_client import StreamingClient
8
+
9
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
10
+
11
+ __version__ = "0.1.1"
12
+ __all__ = ["StreamingClient", "RestClient"]
@@ -0,0 +1,21 @@
1
+ """
2
+ Common decorators
3
+ """
4
+ import functools
5
+
6
+
7
+ def is_authenticated():
8
+ """
9
+ Checks whether the api_key is set on the object
10
+ """
11
+
12
+ def decorator(method):
13
+ @functools.wraps(method)
14
+ def wrapper(self, *args, **kwargs):
15
+ if not getattr(self, "authenticated", None):
16
+ raise Exception("API key is not set, API call will fail")
17
+ return method(self, *args, **kwargs)
18
+
19
+ return wrapper
20
+
21
+ return decorator
@@ -0,0 +1,103 @@
1
+ """
2
+ EndDec util
3
+ """
4
+ import os
5
+ import string
6
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
7
+ from cryptography.hazmat.primitives import hashes
8
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
9
+
10
+
11
+ class EncDec:
12
+ """
13
+ EndDec util
14
+ """
15
+
16
+ def __init__(self):
17
+ # Base62 alphabet: only alphanumeric characters.
18
+ self.BASE62_ALPHABET = string.digits + string.ascii_uppercase + string.ascii_lowercase # 0-9, A-Z, a-z
19
+
20
+ def base62_encode(self, data: bytes) -> str:
21
+ """
22
+ EndDec util
23
+ """
24
+ num = int.from_bytes(data, 'big')
25
+ if num == 0:
26
+ return self.BASE62_ALPHABET[0]
27
+ base = len(self.BASE62_ALPHABET)
28
+ encoded = []
29
+ while num:
30
+ num, rem = divmod(num, base)
31
+ encoded.append(self.BASE62_ALPHABET[rem])
32
+ encoded.reverse()
33
+ return ''.join(encoded)
34
+
35
+ def base62_decode(self, s: str) -> bytes:
36
+ """
37
+ EndDec util
38
+ """
39
+ base = len(self.BASE62_ALPHABET)
40
+ num = 0
41
+ for char in s:
42
+ num = num * base + self.BASE62_ALPHABET.index(char)
43
+ # Calculate the number of bytes needed.
44
+ byte_length = (num.bit_length() + 7) // 8
45
+ return num.to_bytes(byte_length, 'big')
46
+
47
+ def derive_key(self, secret: str, salt: bytes) -> bytes:
48
+ """
49
+ EndDec util
50
+ """
51
+ # Derive a 32-byte key using PBKDF2HMAC (AES-256 needs 32 bytes).
52
+ kdf = PBKDF2HMAC(
53
+ algorithm=hashes.SHA256(),
54
+ length=32,
55
+ salt=salt,
56
+ iterations=100000,
57
+ )
58
+ return kdf.derive(secret.encode())
59
+
60
+ def encrypt_base62(self, plaintext: str, secret: str) -> str:
61
+ """
62
+ EndDec util
63
+ """
64
+ # Generate a random 16-byte salt.
65
+ salt = os.urandom(16)
66
+ key = self.derive_key(secret, salt)
67
+ aesgcm = AESGCM(key)
68
+ # Generate a random 12-byte nonce (recommended for AESGCM).
69
+ nonce = os.urandom(12)
70
+ ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)
71
+
72
+ # Combine salt + nonce + ciphertext.
73
+ encrypted_bytes = salt + nonce + ciphertext
74
+
75
+ # Encode using Base62.
76
+ encrypted_b62 = self.base62_encode(encrypted_bytes)
77
+
78
+ # Enforce the length constraint.
79
+ if len(encrypted_b62) >= 255:
80
+ raise ValueError("Encrypted string exceeds the length limit of 255 characters.")
81
+
82
+ return encrypted_b62
83
+
84
+ def decrypt_base62(self, encrypted_b62: str, secret: str) -> str:
85
+ """
86
+ EndDec util
87
+ """
88
+ encrypted_bytes = self.base62_decode(encrypted_b62)
89
+
90
+ # Extract salt (first 16 bytes), nonce (next 12 bytes), and the ciphertext.
91
+ salt = encrypted_bytes[:16]
92
+ nonce = encrypted_bytes[16:28]
93
+ ciphertext = encrypted_bytes[28:]
94
+ key = self.derive_key(secret, salt)
95
+ aesgcm = AESGCM(key)
96
+ plaintext = aesgcm.decrypt(nonce, ciphertext, None)
97
+ return plaintext.decode('utf-8')
98
+
99
+
100
+ if __name__ == '__main__':
101
+ encrypted = EncDec().encrypt_base62("yunind7.tradier", "Xjyrmnfg@321")
102
+ print(encrypted)
103
+ print(EncDec().decrypt_base62(encrypted, "Xjyrmnfg@321"))
@@ -0,0 +1,41 @@
1
+ """
2
+ General utility functions for the Tradier API client.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Optional
8
+
9
+
10
+ def log_for_level(
11
+ logger,
12
+ level: int,
13
+ message: str,
14
+ *,
15
+ exc: Optional[BaseException] = None,
16
+ exc_info: Any = None,
17
+ stack_info: bool = False,
18
+ extra: Optional[dict] = None,
19
+ ):
20
+ """Log *message* if *logger* is enabled for *level*.
21
+
22
+ This is a tiny helper to avoid building/logging messages when the level is disabled.
23
+
24
+ Args:
25
+ logger: A ``logging.Logger`` instance.
26
+ level: A stdlib logging level (e.g. ``logging.INFO``).
27
+ message: The message string to log.
28
+ exc: Optional exception instance to attach (equivalent to ``exc_info=exc``).
29
+ exc_info: Passed through to ``logger.log(..., exc_info=...)``. If both ``exc`` and
30
+ ``exc_info`` are provided, ``exc_info`` wins.
31
+ stack_info: Passed through to the logger.
32
+ extra: Passed through to the logger.
33
+ """
34
+ if not logger or not logger.isEnabledFor(level):
35
+ return
36
+
37
+ # Support passing an exception instance directly.
38
+ if exc_info is None and exc is not None:
39
+ exc_info = exc
40
+
41
+ logger.log(level, message, exc_info=exc_info, stack_info=stack_info, extra=extra)
@@ -0,0 +1,6 @@
1
+ """
2
+ Tradier Rest Client
3
+ """
4
+ from .rest_client import RestClient
5
+
6
+ __all__ = ["RestClient"]
@@ -0,0 +1,4 @@
1
+ from .options import OptionsWrapper
2
+ from .orders import OrderWrapper
3
+
4
+ __all__ = ['OptionsWrapper', 'OrderWrapper']
@@ -0,0 +1,454 @@
1
+ """
2
+ Options utility classes and functions
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from datetime import date, datetime
8
+ from decimal import Decimal, ROUND_HALF_UP
9
+ from functools import partial
10
+ from typing import List, Optional, Callable, TypedDict
11
+ from typing import Literal, Dict, Any, Iterable, Tuple
12
+
13
+ from tradier_api_client.rest import RestClient
14
+
15
+ _OCC_RE = re.compile(r'^([A-Z0-9]{1,6})(\d{6})([CP])(\d{8})$')
16
+
17
+
18
+ def parse_occ(symbol_or_list):
19
+ """
20
+ Parse OCC option symbol(s) like 'BB260116C00001500'.
21
+
22
+ Returns a list of dicts with:
23
+ - underlying (str)
24
+ - expiration (datetime.date)
25
+ - option_type ('call'|'put')
26
+ - strike (float)
27
+ """
28
+ symbols = symbol_or_list if isinstance(symbol_or_list, (list, tuple)) else [symbol_or_list]
29
+ out = []
30
+ for s in symbols:
31
+ s = s.strip().strip('"').strip("'")
32
+ m = _OCC_RE.match(s)
33
+ if not m:
34
+ raise ValueError(f"Not a valid OCC option symbol: {s}")
35
+
36
+ root, yymmdd, cp, strike8 = m.groups()
37
+
38
+ yy = int(yymmdd[:2])
39
+ mm = int(yymmdd[2:4])
40
+ dd = int(yymmdd[4:6])
41
+ # OCC uses YY; modern options are 20YY. Adjust if you ever need 19YY.
42
+ yyyy = 2000 + yy
43
+
44
+ expiration = date(yyyy, mm, dd)
45
+ option_type = 'call' if cp == 'C' else 'put'
46
+ # Strike is 8 digits with 3 implied decimals
47
+ strike = int(strike8) / 1000.0
48
+
49
+ out.append({
50
+ 'underlying': root.rstrip(),
51
+ 'expiration': expiration,
52
+ 'option_type': option_type,
53
+ 'strike': strike,
54
+ })
55
+ return out
56
+
57
+
58
+ # --- tiny helpers ---
59
+
60
+ def _coerce_date(d: Optional[object]) -> Optional[date]:
61
+ if d is None:
62
+ return None
63
+ if isinstance(d, date):
64
+ return d
65
+ if isinstance(d, str):
66
+ return datetime.strptime(d, "%Y-%m-%d").date()
67
+ raise TypeError(f"Unsupported date type: {type(d)}")
68
+
69
+
70
+ class OptionDict(TypedDict):
71
+ """
72
+ Options Dict
73
+ """
74
+ underlying: str
75
+ expiration: date # or: date | str if you allow strings pre-coercion
76
+ option_type: Literal["call", "put"]
77
+ strike: float
78
+
79
+
80
+ def _single_dict_to_occ(option_dict: OptionDict):
81
+ u = str(option_dict["underlying"]).upper()
82
+ if not (1 <= len(u) <= 6) or not u.isalnum():
83
+ raise ValueError(f"Bad underlying: {u!r}")
84
+
85
+ # expiration -> YYMMDD
86
+ dt = _coerce_date(option_dict["expiration"])
87
+ yymmdd = f"{dt.year % 100:02d}{dt.month:02d}{dt.day:02d}"
88
+
89
+ # option type -> C/P
90
+ t0 = str(option_dict["option_type"]).strip().lower()
91
+ if t0 not in ("call", "put", "c", "p"):
92
+ raise ValueError(f"Bad option_type: {option_dict['option_type']!r}")
93
+ cp = "C" if t0.startswith("c") else "P"
94
+
95
+ # strike -> 8 digits, 3 implied decimals
96
+ # use Decimal to avoid float rounding surprises
97
+ strike_scaled = (Decimal(str(option_dict["strike"])) * Decimal("1000")).quantize(Decimal("1"),
98
+ rounding=ROUND_HALF_UP)
99
+ n = int(strike_scaled)
100
+ if n < 0 or n > 99999999:
101
+ raise ValueError(f"Bad strike after scaling x1000: {n}")
102
+ strike8 = f"{n:08d}"
103
+ return f"{u}{yymmdd}{cp}{strike8}"
104
+
105
+
106
+ def parse_dict(dict_or_list_of_dict: OptionDict | list[OptionDict]) -> str | list[str]:
107
+ """
108
+ Convert {'underlying':'BB','expiration':'2025-10-31','option_type':'call','strike':8.5}
109
+ -> 'BB251031C00008500'
110
+ """
111
+ # underlying
112
+ if len(dict_or_list_of_dict) == 0:
113
+ return []
114
+ if isinstance(dict_or_list_of_dict, dict):
115
+ dict_or_list_of_dict = [dict_or_list_of_dict]
116
+ return [_single_dict_to_occ(o) for o in dict_or_list_of_dict]
117
+
118
+
119
+ def _apply(options: List[OptionDict], predicates: List[Callable[[OptionDict], bool]]) -> List[OptionDict]:
120
+ if not predicates:
121
+ return list(options)
122
+ return [o for o in options if all(p(o) for p in predicates)]
123
+
124
+
125
+ # --- single convenience filter ---
126
+ def filter_options_occ(
127
+ options: list[str],
128
+ *,
129
+ # your original ask
130
+ before: Optional[object] = None, # expire strictly before this date
131
+ min_strike: Optional[float] = None, # strike strictly greater than this
132
+
133
+ # additional practical filters (all optional)
134
+ option_type: Optional[str] = None, # 'call' or 'put'
135
+ exp_start: Optional[object] = None, # inclusive start of expiration range
136
+ exp_end: Optional[object] = None, # inclusive end of expiration range
137
+ strike_min: Optional[float] = None, # inclusive strike lower bound
138
+ strike_max: Optional[float] = None, # inclusive strike upper bound
139
+ underlying: Optional[str] = None, # exact underlying match (case-insensitive)
140
+ underlying_prefix: Optional[str] = None, # underlying startswith (case-insensitive)
141
+
142
+ # price-aware helpers
143
+ price: Optional[float] = None, # current/assumed spot price
144
+ strike_within_pct: Optional[float] = None, # keep strikes within +/- this pct of price (e.g., 0.05)
145
+ moneyness: Optional[str] = None, # 'ITM' | 'ATM' | 'OTM' (requires price)
146
+ atm_tol_pct: float = 0.01, # ATM tolerance (default 1%)
147
+ ):
148
+ """
149
+ Apply zero or more filters to a list/iterable of OCC option symbols. Returns a new list.
150
+
151
+ This function takes in and returns a list of OCC options.
152
+
153
+ Examples:
154
+ filter_options_occ(data, before="2026-01-31", min_strike=4.0)
155
+ filter_options_occ(data, option_type="call", exp_start="2026-01-01", exp_end="2026-03-31")
156
+ filter_options_occ(data, underlying="BB", price=5.0, strike_within_pct=0.10)
157
+ filter_options_occ(data, price=5.0, moneyness="ITM")
158
+ """
159
+ options_dicts = parse_occ(options)
160
+ _to_pass = {arg: val for arg, val in locals().items() if arg not in ['options', 'options_dicts']}
161
+ filtered_options = filter_options(options=options_dicts, **_to_pass)
162
+ return parse_dict(filtered_options)
163
+
164
+
165
+ def filter_options(
166
+ options: List[OptionDict],
167
+ *,
168
+ # your original ask
169
+ before: Optional[object] = None, # expire strictly before this date
170
+ min_strike: Optional[float] = None, # strike strictly greater than this
171
+
172
+ # additional practical filters (all optional)
173
+ option_type: Optional[str] = None, # 'call' or 'put'
174
+ exp_start: Optional[object] = None, # inclusive start of expiration range
175
+ exp_end: Optional[object] = None, # inclusive end of expiration range
176
+ strike_min: Optional[float] = None, # inclusive strike lower bound
177
+ strike_max: Optional[float] = None, # inclusive strike upper bound
178
+ underlying: Optional[str] = None, # exact underlying match (case-insensitive)
179
+ underlying_prefix: Optional[str] = None, # underlying startswith (case-insensitive)
180
+
181
+ # price-aware helpers
182
+ price: Optional[float] = None, # current/assumed spot price
183
+ strike_within_pct: Optional[float] = None, # keep strikes within +/- this pct of price (e.g., 0.05)
184
+ moneyness: Optional[str] = None, # 'ITM' | 'ATM' | 'OTM' (requires price)
185
+ atm_tol_pct: float = 0.01, # ATM tolerance (default 1%)
186
+ ) -> List[OptionDict]:
187
+ """
188
+ Apply zero or more filters to a list/iterable of option dicts. Returns a new list.
189
+
190
+ Examples:
191
+ filter_options(data, before="2026-01-31", min_strike=4.0)
192
+ filter_options(data, option_type="call", exp_start="2026-01-01", exp_end="2026-03-31")
193
+ filter_options(data, underlying="BB", price=5.0, strike_within_pct=0.10)
194
+ filter_options(data, price=5.0, moneyness="ITM")
195
+ """
196
+ predicates: List[Callable[[OptionDict], bool]] = []
197
+
198
+ # --- expiration filters ---
199
+ b = _coerce_date(before)
200
+ if b is not None:
201
+ predicates.append(lambda o: o["expiration"] < b)
202
+
203
+ s = _coerce_date(exp_start)
204
+ e = _coerce_date(exp_end)
205
+ if s is not None and e is not None:
206
+ predicates.append(lambda o: s <= o["expiration"] <= e)
207
+ elif s is not None:
208
+ predicates.append(lambda o: o["expiration"] >= s)
209
+ elif e is not None:
210
+ predicates.append(lambda o: o["expiration"] <= e)
211
+
212
+ # --- strike filters ---
213
+ if min_strike is not None:
214
+ predicates.append(lambda o: float(o["strike"]) > float(min_strike))
215
+ if strike_min is not None:
216
+ predicates.append(lambda o: float(o["strike"]) >= float(strike_min))
217
+ if strike_max is not None:
218
+ predicates.append(lambda o: float(o["strike"]) <= float(strike_max))
219
+
220
+ # +/- pct around price, if requested
221
+ if price is not None and strike_within_pct is not None:
222
+ if strike_within_pct < 0:
223
+ raise ValueError("strike_within_pct must be non-negative.")
224
+ lo, hi = price * (1 - strike_within_pct), price * (1 + strike_within_pct)
225
+
226
+ def _strike_within_pct(o: OptionDict, _lo: float, _hi: float):
227
+ return _lo <= float(o["strike"]) <= _hi
228
+
229
+ predicates.append(partial(_strike_within_pct, _lo=lo, _hi=hi))
230
+
231
+ # --- option type ---
232
+ if option_type is not None:
233
+ t = option_type.lower()
234
+ if t not in ("call", "put"):
235
+ raise ValueError("option_type must be 'call' or 'put'.")
236
+
237
+ def _option_type_filter(o: OptionDict, _t):
238
+ return str(o["option_type"]).lower() == _t
239
+
240
+ predicates.append(partial(_option_type_filter, _t=t))
241
+
242
+ # --- underlying symbol filters ---
243
+ if underlying is not None:
244
+ eq = underlying.lower()
245
+
246
+ def _underlying_filter(o: OptionDict, _eq):
247
+ return str(o["underlying"]).lower() == _eq
248
+
249
+ predicates.append(partial(_underlying_filter, _eq=eq))
250
+ if underlying_prefix is not None:
251
+ sw = underlying_prefix.lower()
252
+
253
+ def _underlying_prefix_filter(o: OptionDict, _sw):
254
+ return str(o["underlying"]).lower().startswith(_sw)
255
+
256
+ predicates.append(partial(_underlying_prefix_filter, _sw=sw))
257
+
258
+ # --- moneyness (needs price) ---
259
+ if moneyness is not None:
260
+ if price is None:
261
+ raise ValueError("moneyness filter requires 'price'.")
262
+ cat = moneyness.upper()
263
+ if cat not in {"ITM", "ATM", "OTM"}:
264
+ raise ValueError("moneyness must be one of {'ITM','ATM','OTM'}.")
265
+
266
+ def _moneyness(o: OptionDict, _cat=cat, _price=price, _atm_tol_pct=atm_tol_pct):
267
+ k = float(o["strike"])
268
+ _t = str(o["option_type"]).lower()
269
+ atm = abs(k - _price) / _price <= _atm_tol_pct
270
+ if _cat == "ATM":
271
+ return atm
272
+ itm = (k < _price) if _t == "call" else (k > _price)
273
+ return itm if _cat == "ITM" else (not itm and not atm)
274
+
275
+ predicates.append(_moneyness)
276
+
277
+ return _apply(options, predicates)
278
+
279
+
280
+ # --- minimal usage examples ---
281
+ # filtered = filter_options(parsed_options, before="2026-01-31", min_strike=4.0)
282
+ # weeklies = filter_options(parsed_options, exp_start="2026-01-01", exp_end="2026-01-31", option_type="call")
283
+ # near_atm = filter_options(parsed_options, price=5.00, strike_within_pct=0.05)
284
+ # itm_calls = filter_options(parsed_options, option_type="call", price=5.00, moneyness="ITM")
285
+
286
+
287
+ def filter_for_tradable_options(
288
+ quotes: Dict[str, Any],
289
+ side: Literal["buy", "sell"],
290
+ *,
291
+ min_bid: float | None = None,
292
+ max_ask: float | None = None,
293
+ require_size: bool = True,
294
+ min_size: int = 1,
295
+ max_age_sec: int | None = None, # ignore stale quotes unless None
296
+ now_sec: int | None = None # defaults to quotes['client_timestamp'] if present
297
+ ) -> Dict[str, float]:
298
+ """
299
+ Return {symbol: price} where price is the ask (buy) or bid (sell), insertion-ordered
300
+ by best price (asc for buy, desc for sell).
301
+
302
+ - side='buy' -> keep quotes with ask <= max_ask
303
+ - side='sell' -> keep quotes with bid >= min_bid
304
+ - require_size enforces ask/bid size >= min_size
305
+ - max_age_sec filters by ask_date/bid_date recency (ms in payload)
306
+ """
307
+ # Pull the list of quotes (Tradier may return a single quote dict or a list)
308
+ raw = quotes.get("quotes", {}).get("quote", [])
309
+ items: Iterable[Dict[str, Any]] = raw if isinstance(raw, list) else [raw]
310
+
311
+ # Time baseline for staleness checks
312
+ if now_sec is None:
313
+ now_sec = int(quotes.get("client_timestamp") or 0) or None # allow None if missing
314
+
315
+ out: list[Tuple[str, float]] = []
316
+
317
+ for q in items:
318
+ if not q or q.get("type") != "option":
319
+ continue
320
+
321
+ if side == "buy":
322
+ price = q.get("ask")
323
+ limit_ok = (max_ask is None) or (price is not None and float(price) <= float(max_ask))
324
+ size_ok = (not require_size) or int(q.get("asksize") or 0) >= min_size
325
+ date_ms = q.get("ask_date")
326
+ else: # 'sell'
327
+ price = q.get("bid")
328
+ limit_ok = (min_bid is None) or (price is not None and float(price) >= float(min_bid))
329
+ size_ok = (not require_size) or int(q.get("bidsize") or 0) >= min_size
330
+ date_ms = q.get("bid_date")
331
+
332
+ if price is None or not limit_ok or not size_ok:
333
+ continue
334
+
335
+ fresh_ok = True
336
+ if max_age_sec is not None and now_sec is not None and date_ms:
337
+ fresh_ok = (now_sec - (int(date_ms) // 1000)) <= max_age_sec
338
+
339
+ if not fresh_ok:
340
+ continue
341
+
342
+ out.append((q["symbol"], float(price)))
343
+
344
+ # Sort to be execution-friendly
345
+ out.sort(key=(lambda t: t[1]), reverse=(side == "sell"))
346
+
347
+ # Preserve ordering by insertion (Python 3.7+)
348
+ return {sym: px for sym, px in out}
349
+
350
+
351
+ def filter_for_tradable_options_strike_plus_bid(
352
+ quotes: Dict[str, Any],
353
+ occ_symbols: List[str],
354
+ *,
355
+ target_price: float,
356
+ require_size: bool = True,
357
+ min_size: int = 1,
358
+ max_age_sec: int | None = None,
359
+ now_sec: int | None = None,
360
+ ) -> Dict[str, float]:
361
+ """
362
+ Return {symbol: bid} for options in `occ_symbols` whose (strike + bid) >= target_price.
363
+ Results are insertion-ordered by (strike+bid) desc, then bid desc.
364
+
365
+ Requires a `parse_options_occ(occ_symbols)` function that returns a list[dict] like:
366
+ {'underlying': ..., 'expiration': 'YYYY-MM-DD', 'option_type': 'call'|'put', 'strike': float}
367
+ """
368
+ # Map OCC -> strike using the parsed list (assumes order matches input)
369
+ parsed = parse_occ(occ_symbols)
370
+ strike_by_symbol = {sym: float(info["strike"]) for sym, info in zip(occ_symbols, parsed)}
371
+
372
+ # Pull quotes list (Tradier can return a single dict)
373
+ raw = quotes.get("quotes", {}).get("quote", [])
374
+ items: Iterable[Dict[str, Any]] = raw if isinstance(raw, list) else [raw]
375
+
376
+ # Time baseline for staleness checks
377
+ if now_sec is None:
378
+ now_sec = int(quotes.get("client_timestamp") or 0) or None
379
+
380
+ selected: List[Tuple[str, float, float]] = [] # (symbol, bid, total)
381
+
382
+ occ_set = set(occ_symbols)
383
+ for q in items:
384
+ if not q or q.get("type") != "option":
385
+ continue
386
+
387
+ sym = q.get("symbol")
388
+ if sym not in occ_set:
389
+ continue # only keep your candidate list
390
+
391
+ bid = q.get("bid")
392
+ if bid is None:
393
+ continue
394
+
395
+ # Size check (on the bid side for selling)
396
+ if require_size and int(q.get("bidsize") or 0) < min_size:
397
+ continue
398
+
399
+ # Freshness check (bid_date is ms since epoch)
400
+ if max_age_sec is not None and now_sec is not None:
401
+ bid_ms = q.get("bid_date")
402
+ if not bid_ms or (now_sec - (int(bid_ms) // 1000)) > max_age_sec:
403
+ continue
404
+
405
+ strike = strike_by_symbol.get(sym)
406
+ if strike is None:
407
+ continue
408
+
409
+ total = float(strike) + float(bid)
410
+ if total >= float(target_price):
411
+ selected.append((sym, float(bid), total))
412
+
413
+ # Sort by total desc, then bid desc
414
+ selected.sort(key=lambda t: (t[2], t[1]), reverse=True)
415
+
416
+ # Return in that order -> {symbol: bid}
417
+ return {sym: bid for sym, bid, _ in selected}
418
+
419
+
420
+ class OptionsWrapper:
421
+ """
422
+ Wrapper class for options
423
+ """
424
+
425
+ def __init__(self, rest_client: RestClient):
426
+ self.rest_client = rest_client
427
+
428
+ def get_call_options_occ_symbols_list(self, underlying_symbol: str):
429
+ """
430
+ Get the list of OCC call options symbols for a given underlying symbol.
431
+ :param underlying_symbol:
432
+ """
433
+ response = self.rest_client.lookup_options_symbols(underlying_symbol)
434
+ if response and 'symbols' in response and response['symbols'] and 'options' in response['symbols'][0] and \
435
+ response['symbols'][0]['options']:
436
+ call_options_symbols = response['symbols'][0]['options']
437
+ option_side_position = len(underlying_symbol) + 6
438
+ call_options_symbols = list(filter(lambda x: x[option_side_position] == 'C', call_options_symbols))
439
+ return call_options_symbols
440
+ return []
441
+
442
+ def get_put_options_occ_symbols_list(self, underlying_symbol: str):
443
+ """
444
+ Get the list of OCC put options symbols for a given underlying symbol.
445
+ :param underlying_symbol:
446
+ """
447
+ response = self.rest_client.lookup_options_symbols(underlying_symbol)
448
+ if response and 'symbols' in response and response['symbols'] and 'options' in response['symbols'][0] and \
449
+ response['symbols'][0]['options']:
450
+ call_options_symbols = response['symbols'][0]['options']
451
+ option_side_position = len(underlying_symbol) + 6
452
+ call_options_symbols = list(filter(lambda x: x[option_side_position] == 'P', call_options_symbols))
453
+ return call_options_symbols
454
+ return []