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.
- tradier_api_client/__init__.py +12 -0
- tradier_api_client/decorators.py +21 -0
- tradier_api_client/enc_dec.py +103 -0
- tradier_api_client/helper_functions.py +41 -0
- tradier_api_client/rest/__init__.py +6 -0
- tradier_api_client/rest/extensions/__init__.py +4 -0
- tradier_api_client/rest/extensions/options.py +454 -0
- tradier_api_client/rest/extensions/orders.py +475 -0
- tradier_api_client/rest/models/__init__.py +3 -0
- tradier_api_client/rest/models/orders.py +106 -0
- tradier_api_client/rest/models/orders_fixed.py +241 -0
- tradier_api_client/rest/rest_client.py +740 -0
- tradier_api_client/streaming/__init__.py +6 -0
- tradier_api_client/streaming/streaming_client.py +530 -0
- tradier_api_client/streaming/websocket_stream.py +136 -0
- tradier_api_client-0.1.1.dist-info/METADATA +203 -0
- tradier_api_client-0.1.1.dist-info/RECORD +19 -0
- tradier_api_client-0.1.1.dist-info/WHEEL +5 -0
- tradier_api_client-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -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,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 []
|