optionda 0.1.0__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.
- optionda/__init__.py +3 -0
- optionda/add_resolve.py +111 -0
- optionda/batch.py +237 -0
- optionda/cli.py +945 -0
- optionda/config.py +61 -0
- optionda/credentials.py +78 -0
- optionda/display/__init__.py +3 -0
- optionda/display/table.py +459 -0
- optionda/engine.py +252 -0
- optionda/journal.py +235 -0
- optionda/market/__init__.py +3 -0
- optionda/market/alpaca.py +351 -0
- optionda/market/iv_mid.py +67 -0
- optionda/market/router.py +127 -0
- optionda/market/yahoo.py +234 -0
- optionda/models.py +110 -0
- optionda/occ.py +221 -0
- optionda/paths.py +15 -0
- optionda/pricing/__init__.py +3 -0
- optionda/pricing/bs.py +159 -0
- optionda/pricing/surface.py +307 -0
- optionda/shellenv.py +126 -0
- optionda/store.py +248 -0
- optionda-0.1.0.dist-info/METADATA +156 -0
- optionda-0.1.0.dist-info/RECORD +29 -0
- optionda-0.1.0.dist-info/WHEEL +5 -0
- optionda-0.1.0.dist-info/entry_points.txt +2 -0
- optionda-0.1.0.dist-info/licenses/LICENSE +21 -0
- optionda-0.1.0.dist-info/top_level.txt +1 -0
optionda/__init__.py
ADDED
optionda/add_resolve.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from optionda.batch import read_batch_lines
|
|
6
|
+
from optionda.occ import OccError, parse_leg_line
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def split_semi_separated(text: str) -> list[str]:
|
|
10
|
+
"""Split 'A; B; C' into position lines (keeps spaces inside each part)."""
|
|
11
|
+
parts = [p.strip() for p in text.replace("\n", ";").split(";")]
|
|
12
|
+
return [p for p in parts if p and not p.startswith("#")]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def read_interactive_lines(
|
|
16
|
+
*,
|
|
17
|
+
prompt_print=print,
|
|
18
|
+
line_input=input,
|
|
19
|
+
) -> list[str]:
|
|
20
|
+
"""Read pasted lines until a blank line or EOF (Ctrl+Z Enter on Windows)."""
|
|
21
|
+
prompt_print("Paste positions (one per line): ROOT YYMMDD STRIKE C|P xQTY @ cost")
|
|
22
|
+
prompt_print("Finish with an empty line, or Ctrl+Z then Enter (Windows).")
|
|
23
|
+
prompt_print("")
|
|
24
|
+
lines: list[str] = []
|
|
25
|
+
while True:
|
|
26
|
+
try:
|
|
27
|
+
line = line_input()
|
|
28
|
+
except EOFError:
|
|
29
|
+
break
|
|
30
|
+
if not line.strip():
|
|
31
|
+
break
|
|
32
|
+
if line.strip().startswith("#"):
|
|
33
|
+
continue
|
|
34
|
+
lines.append(line.strip())
|
|
35
|
+
return lines
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _validated_line(token: str) -> str:
|
|
39
|
+
"""Validate parseability but keep the original text (preserves @ cost)."""
|
|
40
|
+
parse_leg_line(token)
|
|
41
|
+
return token.strip()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_add_lines(items: list[str]) -> list[str]:
|
|
45
|
+
"""Normalize CLI tokens into one or more position lines.
|
|
46
|
+
|
|
47
|
+
Supports:
|
|
48
|
+
- single/multiple OCC symbols (with optional @ cost)
|
|
49
|
+
- one human line split across argv: INTC 261016 140 C @ 5.20
|
|
50
|
+
- semicolon-separated in one argv
|
|
51
|
+
- file path or '-' (stdin) for multi-line batch
|
|
52
|
+
|
|
53
|
+
Original line text is preserved so trailing '@ cost' survives.
|
|
54
|
+
"""
|
|
55
|
+
if not items:
|
|
56
|
+
raise ValueError("no positions provided")
|
|
57
|
+
|
|
58
|
+
if len(items) == 1:
|
|
59
|
+
token = items[0]
|
|
60
|
+
if token == "-" or Path(token).is_file():
|
|
61
|
+
lines = read_batch_lines(token)
|
|
62
|
+
if not lines:
|
|
63
|
+
raise ValueError("no positions to add (empty input)")
|
|
64
|
+
return lines
|
|
65
|
+
if ";" in token:
|
|
66
|
+
parts = split_semi_separated(token)
|
|
67
|
+
if not parts:
|
|
68
|
+
raise ValueError("no positions to add")
|
|
69
|
+
return parts
|
|
70
|
+
try:
|
|
71
|
+
return [_validated_line(token)]
|
|
72
|
+
except OccError as exc:
|
|
73
|
+
raise ValueError(str(exc)) from exc
|
|
74
|
+
|
|
75
|
+
# Multiple argv tokens: either many OCCs, or one spaced human line
|
|
76
|
+
joined = " ".join(items)
|
|
77
|
+
if ";" in joined:
|
|
78
|
+
parts = split_semi_separated(joined)
|
|
79
|
+
if parts:
|
|
80
|
+
return parts
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
return [_validated_line(joined)]
|
|
84
|
+
except OccError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
lines: list[str] = []
|
|
88
|
+
for token in items:
|
|
89
|
+
try:
|
|
90
|
+
lines.append(_validated_line(token))
|
|
91
|
+
except OccError as exc:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"could not parse {token!r} (also not a single human line: {joined!r})"
|
|
94
|
+
) from exc
|
|
95
|
+
return lines
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def looks_like_field_add(
|
|
99
|
+
items: list[str],
|
|
100
|
+
underlying: str | None,
|
|
101
|
+
expiry: str | None,
|
|
102
|
+
strike: float | None,
|
|
103
|
+
option_type: str | None,
|
|
104
|
+
) -> bool:
|
|
105
|
+
return (
|
|
106
|
+
not items
|
|
107
|
+
and underlying is not None
|
|
108
|
+
and expiry is not None
|
|
109
|
+
and strike is not None
|
|
110
|
+
and option_type is not None
|
|
111
|
+
)
|
optionda/batch.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rich import box
|
|
8
|
+
from rich.console import Console, Group
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from optionda.engine import freeze_iv_for_position
|
|
15
|
+
from optionda.models import Position, Side
|
|
16
|
+
from optionda.occ import OccError, parse_leg_line, require_entry, resolve_qty
|
|
17
|
+
from optionda.store import AccountStore, StoreError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class BatchRow:
|
|
22
|
+
status: str # ok | merge | fail
|
|
23
|
+
label: str
|
|
24
|
+
occ: str = ""
|
|
25
|
+
iv: float | None = None
|
|
26
|
+
source: str = ""
|
|
27
|
+
detail: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class BatchResult:
|
|
32
|
+
ok: int = 0
|
|
33
|
+
merged: int = 0
|
|
34
|
+
failed: int = 0
|
|
35
|
+
skipped: int = 0
|
|
36
|
+
errors: list[str] = field(default_factory=list)
|
|
37
|
+
rows: list[BatchRow] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_batch_lines(source: str | Path) -> list[str]:
|
|
41
|
+
if source == "-":
|
|
42
|
+
import sys
|
|
43
|
+
|
|
44
|
+
text = sys.stdin.read()
|
|
45
|
+
else:
|
|
46
|
+
text = Path(source).read_text(encoding="utf-8")
|
|
47
|
+
lines: list[str] = []
|
|
48
|
+
for line in text.splitlines():
|
|
49
|
+
s = line.strip()
|
|
50
|
+
if not s or s.startswith("#"):
|
|
51
|
+
continue
|
|
52
|
+
lines.append(s)
|
|
53
|
+
return lines
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def short_path(path: Path) -> str:
|
|
57
|
+
try:
|
|
58
|
+
home = Path.home()
|
|
59
|
+
resolved = path.resolve()
|
|
60
|
+
if resolved.is_relative_to(home):
|
|
61
|
+
return "~/" + resolved.relative_to(home).as_posix()
|
|
62
|
+
except (OSError, ValueError):
|
|
63
|
+
pass
|
|
64
|
+
return str(path)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def merge_detail(outcome) -> str:
|
|
68
|
+
pos = outcome.position
|
|
69
|
+
bits = [f"qty {outcome.previous_qty:g}→{pos.qty:g}"]
|
|
70
|
+
if (
|
|
71
|
+
outcome.previous_entry is not None
|
|
72
|
+
and pos.entry_premium is not None
|
|
73
|
+
):
|
|
74
|
+
bits.append(
|
|
75
|
+
f"cost {outcome.previous_entry:g}→{pos.entry_premium:g}"
|
|
76
|
+
)
|
|
77
|
+
elif pos.entry_premium is not None:
|
|
78
|
+
bits.append(f"cost={pos.entry_premium:g}")
|
|
79
|
+
return " ".join(bits)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def ok_detail(pos: Position) -> str:
|
|
83
|
+
cost = (
|
|
84
|
+
f" cost={pos.entry_premium:g}"
|
|
85
|
+
if pos.entry_premium is not None
|
|
86
|
+
else ""
|
|
87
|
+
)
|
|
88
|
+
return f"qty={pos.qty:g}{cost}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render_batch_summary(result: BatchResult, *, book: Path | None = None) -> Panel:
|
|
92
|
+
table = Table(
|
|
93
|
+
box=box.SIMPLE_HEAD,
|
|
94
|
+
show_header=True,
|
|
95
|
+
header_style="bold",
|
|
96
|
+
pad_edge=False,
|
|
97
|
+
expand=True,
|
|
98
|
+
border_style="dim",
|
|
99
|
+
)
|
|
100
|
+
table.add_column("Status", width=6)
|
|
101
|
+
table.add_column("OCC", ratio=2)
|
|
102
|
+
table.add_column("IV*", justify="right", width=8)
|
|
103
|
+
table.add_column("Src", width=8)
|
|
104
|
+
table.add_column("Note", style="dim", ratio=1)
|
|
105
|
+
|
|
106
|
+
for row in result.rows:
|
|
107
|
+
if row.status == "ok":
|
|
108
|
+
st = Text("ok", style="bold green")
|
|
109
|
+
iv = f"{row.iv * 100:.1f}%" if row.iv is not None else "—"
|
|
110
|
+
note = row.detail
|
|
111
|
+
elif row.status == "merge":
|
|
112
|
+
st = Text("merge", style="bold cyan")
|
|
113
|
+
iv = f"{row.iv * 100:.1f}%" if row.iv is not None else "—"
|
|
114
|
+
note = row.detail
|
|
115
|
+
elif row.status == "skip":
|
|
116
|
+
st = Text("skip", style="bold yellow")
|
|
117
|
+
iv = "—"
|
|
118
|
+
note = row.detail
|
|
119
|
+
else:
|
|
120
|
+
st = Text("fail", style="bold red")
|
|
121
|
+
iv = "—"
|
|
122
|
+
note = row.detail
|
|
123
|
+
table.add_row(st, row.occ or row.label, iv, row.source or "—", note)
|
|
124
|
+
|
|
125
|
+
counts = Text.assemble(
|
|
126
|
+
("ok ", "dim"),
|
|
127
|
+
(str(result.ok), "bold green"),
|
|
128
|
+
(" merge ", "dim"),
|
|
129
|
+
(str(result.merged), "bold cyan" if result.merged else "dim"),
|
|
130
|
+
(" fail ", "dim"),
|
|
131
|
+
(str(result.failed), "bold red" if result.failed else "dim"),
|
|
132
|
+
)
|
|
133
|
+
footer_bits: list = [counts]
|
|
134
|
+
if book is not None:
|
|
135
|
+
footer_bits.append(Text(f"book {short_path(book)}", style="dim"))
|
|
136
|
+
|
|
137
|
+
return Panel(
|
|
138
|
+
Group(table, *footer_bits),
|
|
139
|
+
title="add",
|
|
140
|
+
title_align="left",
|
|
141
|
+
border_style="cyan",
|
|
142
|
+
box=box.SQUARE,
|
|
143
|
+
padding=(0, 1),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def add_batch(
|
|
148
|
+
store: AccountStore,
|
|
149
|
+
lines: list[str],
|
|
150
|
+
*,
|
|
151
|
+
qty: float = 1.0,
|
|
152
|
+
side: Side = "long",
|
|
153
|
+
iv: float | None = None,
|
|
154
|
+
entry: float | None = None,
|
|
155
|
+
home: Path | None = None,
|
|
156
|
+
console: Console | None = None,
|
|
157
|
+
) -> BatchResult:
|
|
158
|
+
out = BatchResult()
|
|
159
|
+
con = console or Console()
|
|
160
|
+
store.require_current()
|
|
161
|
+
total = len(lines)
|
|
162
|
+
|
|
163
|
+
with Progress(
|
|
164
|
+
SpinnerColumn(style="cyan"),
|
|
165
|
+
TextColumn("[cyan]{task.description}[/cyan]"),
|
|
166
|
+
BarColumn(bar_width=28, complete_style="cyan", finished_style="green"),
|
|
167
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
168
|
+
TextColumn("•"),
|
|
169
|
+
TextColumn("{task.completed}/{task.total}"),
|
|
170
|
+
TimeElapsedColumn(),
|
|
171
|
+
console=con,
|
|
172
|
+
transient=True, # clear bar when done — summary panel follows
|
|
173
|
+
) as progress:
|
|
174
|
+
task = progress.add_task(f"adding 0/{total}", total=total)
|
|
175
|
+
for index, line in enumerate(lines, start=1):
|
|
176
|
+
short = line if len(line) <= 36 else line[:33] + "…"
|
|
177
|
+
progress.update(task, description=f"adding {index}/{total} {short}")
|
|
178
|
+
try:
|
|
179
|
+
leg = parse_leg_line(line)
|
|
180
|
+
cost = require_entry(leg.entry, entry)
|
|
181
|
+
line_qty = resolve_qty(leg.qty, qty)
|
|
182
|
+
parts = leg.parts
|
|
183
|
+
draft = Position(
|
|
184
|
+
occ_symbol=parts.occ_symbol,
|
|
185
|
+
underlying=parts.underlying,
|
|
186
|
+
expiry=parts.expiry,
|
|
187
|
+
strike=parts.strike,
|
|
188
|
+
option_type=parts.option_type,
|
|
189
|
+
qty=line_qty,
|
|
190
|
+
side=side,
|
|
191
|
+
iv_frozen=iv if iv is not None else 0.01,
|
|
192
|
+
iv_as_of=datetime.now(timezone.utc),
|
|
193
|
+
entry_premium=cost,
|
|
194
|
+
)
|
|
195
|
+
draft = freeze_iv_for_position(draft, iv=iv, home=home)
|
|
196
|
+
outcome = store.add_position(None, draft)
|
|
197
|
+
pos = outcome.position
|
|
198
|
+
if outcome.merged:
|
|
199
|
+
out.merged += 1
|
|
200
|
+
out.rows.append(
|
|
201
|
+
BatchRow(
|
|
202
|
+
status="merge",
|
|
203
|
+
label=line,
|
|
204
|
+
occ=pos.occ_symbol,
|
|
205
|
+
iv=pos.iv_frozen,
|
|
206
|
+
source=pos.iv_source or "market",
|
|
207
|
+
detail=merge_detail(outcome),
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
out.ok += 1
|
|
212
|
+
out.rows.append(
|
|
213
|
+
BatchRow(
|
|
214
|
+
status="ok",
|
|
215
|
+
label=line,
|
|
216
|
+
occ=pos.occ_symbol,
|
|
217
|
+
iv=pos.iv_frozen,
|
|
218
|
+
source=pos.iv_source or "market",
|
|
219
|
+
detail=ok_detail(pos),
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
except StoreError as exc:
|
|
223
|
+
msg = str(exc)
|
|
224
|
+
out.failed += 1
|
|
225
|
+
out.errors.append(f"{line}: {msg}")
|
|
226
|
+
out.rows.append(
|
|
227
|
+
BatchRow(status="fail", label=line, occ=line, detail=msg)
|
|
228
|
+
)
|
|
229
|
+
except (OccError, Exception) as exc: # noqa: BLE001
|
|
230
|
+
out.failed += 1
|
|
231
|
+
out.errors.append(f"{line}: {exc}")
|
|
232
|
+
out.rows.append(
|
|
233
|
+
BatchRow(status="fail", label=line, occ=line, detail=str(exc))
|
|
234
|
+
)
|
|
235
|
+
progress.advance(task)
|
|
236
|
+
|
|
237
|
+
return out
|