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 ADDED
@@ -0,0 +1,3 @@
1
+ """optionda — terminal options desk (MODEL marks, frozen IV)."""
2
+
3
+ __version__ = "0.1.0"
@@ -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