msts-trader 0.1.0__tar.gz

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,19 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+
17
+ Copyright 2026 markudevelop
18
+
19
+ Full text: https://www.apache.org/licenses/LICENSE-2.0.txt
@@ -0,0 +1,183 @@
1
+ Metadata-Version: 2.4
2
+ Name: msts-trader
3
+ Version: 0.1.0
4
+ Summary: Paste a target-weights CSV, preview the rebalance, execute it on your Tastytrade account.
5
+ Author: markudevelop
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/markudevelop/msts-trader
8
+ Project-URL: Issues, https://github.com/markudevelop/msts-trader/issues
9
+ Keywords: trading,tastytrade,rebalance,portfolio,cli
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Financial and Insurance Industry
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: tastytrade==11.0.4
22
+ Requires-Dist: click>=8.1
23
+ Requires-Dist: rich>=13.7
24
+ Requires-Dist: keyring>=24
25
+ Requires-Dist: pydantic>=2.5
26
+ Requires-Dist: python-dateutil>=2.9
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8; extra == "dev"
29
+ Requires-Dist: ruff>=0.5; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # msts-trader
33
+
34
+ Paste a target-weights CSV, preview the rebalance, execute it on your Tastytrade account.
35
+
36
+ ```
37
+ $ msts-trader
38
+ Paste CSV (ticker,weight), then Ctrl+D:
39
+ ticker,weight
40
+ SPY,0.42
41
+ GLD,0.18
42
+ SHV,0.20
43
+ EEM,0.20
44
+ ^D
45
+ ✓ loaded 4 targets.
46
+
47
+ Account 5W****** · NAV $48,213.42 · cash $2,150.00 · BP $46,290.00
48
+ Market: open · closes in 23 min
49
+
50
+ Rebalance preview
51
+ ┃ Symbol ┃ Current % ┃ Target % ┃ Δ $ ┃ Action ┃ Note ┃
52
+ ┃ SPY ┃ 18.2% ┃ 42.0% ┃ +$11k ┃ BUY 22.00 @ ~$521.34 ┃ ┃
53
+ ┃ EEM ┃ 31.5% ┃ 20.0% ┃ -$5k ┃ SELL 119.00 @ ~$47.21 ┃ ┃
54
+ ...
55
+
56
+ Execute 4 orders? [y/N]: y
57
+ [1/4] SPY BUY 22.00 @ MKT ... ROUTED id=4f8...
58
+ ...
59
+
60
+ Done. sent: 4 · failed: 0 · log: ~/.msts-trader/fills/
61
+ ```
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ pip install msts-trader
67
+ ```
68
+
69
+ Python ≥3.11 required.
70
+
71
+ Install from source (until a PyPI release lands):
72
+
73
+ ```bash
74
+ git clone https://github.com/markudevelop/msts-trader.git
75
+ cd msts-trader
76
+ pip install -e .
77
+ ```
78
+
79
+ ## One-time setup
80
+
81
+ You need Tastytrade OAuth credentials. **This is your app, not ours** — we never see your keys.
82
+
83
+ 1. Sign in at https://developer.tastytrade.com → **My Apps**
84
+ 2. Create an OAuth application — copy the **provider secret**
85
+ 3. Run their OAuth authorization flow to obtain a **refresh token**
86
+ 4. Look up your **account number** in the Tastytrade web dashboard (optional — leave blank to auto-pick your first account)
87
+ 5. Run:
88
+
89
+ ```bash
90
+ msts-trader login
91
+ ```
92
+
93
+ Paste the three values when prompted. They are stored in your OS keychain
94
+ (macOS Keychain / Windows Credential Manager / libsecret). The app never
95
+ writes them to disk in plaintext.
96
+
97
+ ## Daily usage
98
+
99
+ 1. Get your CSV. On a supported weights site, click **Copy CSV**. Or build one yourself:
100
+
101
+ ```csv
102
+ ticker,weight
103
+ SPY,0.42
104
+ GLD,0.18
105
+ EEM,0.20
106
+ SHV,0.20
107
+ ```
108
+
109
+ - `weight` is a fraction (0–1), not a percent.
110
+ - Sum should be ≤ 1.0 (the remainder is held as cash).
111
+ - Comments starting with `#` are ignored.
112
+
113
+ 2. Run:
114
+
115
+ ```bash
116
+ msts-trader
117
+ ```
118
+
119
+ 3. Paste the CSV, hit `Ctrl+D` (`Ctrl+Z` then Enter on Windows).
120
+ 4. Review the preview table carefully.
121
+ 5. Type `y` to execute, anything else to cancel.
122
+
123
+ ### Useful flags
124
+
125
+ ```bash
126
+ msts-trader rebalance --dry-run # preview only, never sends
127
+ msts-trader rebalance --yes # skip the confirm prompt
128
+ msts-trader rebalance --threshold 0.02 # tighter rebalance (default 4%)
129
+ msts-trader rebalance --csv-file targets.csv # read from a file instead of stdin
130
+ ```
131
+
132
+ ### Other commands
133
+
134
+ ```bash
135
+ msts-trader status # show NAV, positions, market status
136
+ msts-trader logout # clear stored creds
137
+ msts-trader --version # print version
138
+ ```
139
+
140
+ ## What it does
141
+
142
+ - Parses your CSV into `{ticker: target_weight}`.
143
+ - Pulls live NAV, cash, buying power, and current positions from Tastytrade.
144
+ - Quotes every relevant symbol via the Tastytrade market-data API.
145
+ - Computes the dollar delta per ticker, skips anything within the drift
146
+ threshold (default 4% of NAV).
147
+ - Sells tickers no longer in your targets.
148
+ - Sizes buys at the current quote, rounded to 2 decimals (Tastytrade
149
+ fractional shares on MARKET orders).
150
+ - Shows the full plan and waits for `y` before sending anything.
151
+ - Submits MARKET DAY orders. Logs results to `~/.msts-trader/fills/`.
152
+
153
+ ## What it does NOT do (v1)
154
+
155
+ - Pre-market or after-hours execution — refuses to send outside 09:30–16:00 ET.
156
+ - Shorting — negative weights are rejected.
157
+ - Options, futures, crypto.
158
+ - Multi-account or per-strategy ledger.
159
+ - Margin-aware uniform scaling (warns instead; Tastytrade's own BP
160
+ pre-flight will scale down at submit if needed).
161
+ - Automatic CSV polling. You paste each rebalance manually.
162
+
163
+ ## Security
164
+
165
+ - Your Tastytrade OAuth credentials live only in your OS keychain on your
166
+ own machine. The app does not phone home, does not log credentials, and
167
+ is not connected to any service operated by the author.
168
+ - The author of this app cannot view, recover, or revoke your
169
+ Tastytrade access. Revoke via your own Tastytrade OAuth app dashboard
170
+ if a refresh token leaks.
171
+ - Trades are user-initiated: every execution requires you to paste a CSV
172
+ and confirm with `y`. There is no background trading loop.
173
+
174
+ ## Disclaimer
175
+
176
+ This tool sends real orders to your live brokerage account. You are
177
+ responsible for the CSV you paste and the rebalance you confirm. Past
178
+ performance of any signal source is not indicative of future results.
179
+ The author makes no warranty of any kind; use at your own risk.
180
+
181
+ ## License
182
+
183
+ Apache-2.0.
@@ -0,0 +1,152 @@
1
+ # msts-trader
2
+
3
+ Paste a target-weights CSV, preview the rebalance, execute it on your Tastytrade account.
4
+
5
+ ```
6
+ $ msts-trader
7
+ Paste CSV (ticker,weight), then Ctrl+D:
8
+ ticker,weight
9
+ SPY,0.42
10
+ GLD,0.18
11
+ SHV,0.20
12
+ EEM,0.20
13
+ ^D
14
+ ✓ loaded 4 targets.
15
+
16
+ Account 5W****** · NAV $48,213.42 · cash $2,150.00 · BP $46,290.00
17
+ Market: open · closes in 23 min
18
+
19
+ Rebalance preview
20
+ ┃ Symbol ┃ Current % ┃ Target % ┃ Δ $ ┃ Action ┃ Note ┃
21
+ ┃ SPY ┃ 18.2% ┃ 42.0% ┃ +$11k ┃ BUY 22.00 @ ~$521.34 ┃ ┃
22
+ ┃ EEM ┃ 31.5% ┃ 20.0% ┃ -$5k ┃ SELL 119.00 @ ~$47.21 ┃ ┃
23
+ ...
24
+
25
+ Execute 4 orders? [y/N]: y
26
+ [1/4] SPY BUY 22.00 @ MKT ... ROUTED id=4f8...
27
+ ...
28
+
29
+ Done. sent: 4 · failed: 0 · log: ~/.msts-trader/fills/
30
+ ```
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install msts-trader
36
+ ```
37
+
38
+ Python ≥3.11 required.
39
+
40
+ Install from source (until a PyPI release lands):
41
+
42
+ ```bash
43
+ git clone https://github.com/markudevelop/msts-trader.git
44
+ cd msts-trader
45
+ pip install -e .
46
+ ```
47
+
48
+ ## One-time setup
49
+
50
+ You need Tastytrade OAuth credentials. **This is your app, not ours** — we never see your keys.
51
+
52
+ 1. Sign in at https://developer.tastytrade.com → **My Apps**
53
+ 2. Create an OAuth application — copy the **provider secret**
54
+ 3. Run their OAuth authorization flow to obtain a **refresh token**
55
+ 4. Look up your **account number** in the Tastytrade web dashboard (optional — leave blank to auto-pick your first account)
56
+ 5. Run:
57
+
58
+ ```bash
59
+ msts-trader login
60
+ ```
61
+
62
+ Paste the three values when prompted. They are stored in your OS keychain
63
+ (macOS Keychain / Windows Credential Manager / libsecret). The app never
64
+ writes them to disk in plaintext.
65
+
66
+ ## Daily usage
67
+
68
+ 1. Get your CSV. On a supported weights site, click **Copy CSV**. Or build one yourself:
69
+
70
+ ```csv
71
+ ticker,weight
72
+ SPY,0.42
73
+ GLD,0.18
74
+ EEM,0.20
75
+ SHV,0.20
76
+ ```
77
+
78
+ - `weight` is a fraction (0–1), not a percent.
79
+ - Sum should be ≤ 1.0 (the remainder is held as cash).
80
+ - Comments starting with `#` are ignored.
81
+
82
+ 2. Run:
83
+
84
+ ```bash
85
+ msts-trader
86
+ ```
87
+
88
+ 3. Paste the CSV, hit `Ctrl+D` (`Ctrl+Z` then Enter on Windows).
89
+ 4. Review the preview table carefully.
90
+ 5. Type `y` to execute, anything else to cancel.
91
+
92
+ ### Useful flags
93
+
94
+ ```bash
95
+ msts-trader rebalance --dry-run # preview only, never sends
96
+ msts-trader rebalance --yes # skip the confirm prompt
97
+ msts-trader rebalance --threshold 0.02 # tighter rebalance (default 4%)
98
+ msts-trader rebalance --csv-file targets.csv # read from a file instead of stdin
99
+ ```
100
+
101
+ ### Other commands
102
+
103
+ ```bash
104
+ msts-trader status # show NAV, positions, market status
105
+ msts-trader logout # clear stored creds
106
+ msts-trader --version # print version
107
+ ```
108
+
109
+ ## What it does
110
+
111
+ - Parses your CSV into `{ticker: target_weight}`.
112
+ - Pulls live NAV, cash, buying power, and current positions from Tastytrade.
113
+ - Quotes every relevant symbol via the Tastytrade market-data API.
114
+ - Computes the dollar delta per ticker, skips anything within the drift
115
+ threshold (default 4% of NAV).
116
+ - Sells tickers no longer in your targets.
117
+ - Sizes buys at the current quote, rounded to 2 decimals (Tastytrade
118
+ fractional shares on MARKET orders).
119
+ - Shows the full plan and waits for `y` before sending anything.
120
+ - Submits MARKET DAY orders. Logs results to `~/.msts-trader/fills/`.
121
+
122
+ ## What it does NOT do (v1)
123
+
124
+ - Pre-market or after-hours execution — refuses to send outside 09:30–16:00 ET.
125
+ - Shorting — negative weights are rejected.
126
+ - Options, futures, crypto.
127
+ - Multi-account or per-strategy ledger.
128
+ - Margin-aware uniform scaling (warns instead; Tastytrade's own BP
129
+ pre-flight will scale down at submit if needed).
130
+ - Automatic CSV polling. You paste each rebalance manually.
131
+
132
+ ## Security
133
+
134
+ - Your Tastytrade OAuth credentials live only in your OS keychain on your
135
+ own machine. The app does not phone home, does not log credentials, and
136
+ is not connected to any service operated by the author.
137
+ - The author of this app cannot view, recover, or revoke your
138
+ Tastytrade access. Revoke via your own Tastytrade OAuth app dashboard
139
+ if a refresh token leaks.
140
+ - Trades are user-initiated: every execution requires you to paste a CSV
141
+ and confirm with `y`. There is no background trading loop.
142
+
143
+ ## Disclaimer
144
+
145
+ This tool sends real orders to your live brokerage account. You are
146
+ responsible for the CSV you paste and the rebalance you confirm. Past
147
+ performance of any signal source is not indicative of future results.
148
+ The author makes no warranty of any kind; use at your own risk.
149
+
150
+ ## License
151
+
152
+ Apache-2.0.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,258 @@
1
+ """msts-trader CLI: paste a CSV, preview the rebalance, execute it on Tastytrade.
2
+
3
+ Subcommands:
4
+ login — store provider_secret + refresh_token + account_id in OS keychain
5
+ status — show NAV / positions / market status, no orders
6
+ rebalance — (default) paste CSV from stdin, preview, prompt, execute
7
+ logout — clear stored creds
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from decimal import Decimal
13
+
14
+ import click
15
+ from rich.console import Console
16
+ from rich.panel import Panel
17
+ from rich.prompt import Confirm, Prompt
18
+ from rich.table import Table
19
+
20
+ from . import __version__, fill_log
21
+ from .csv_parser import CSVParseError, parse_csv
22
+ from .diff import build_preview
23
+ from .keychain import CredsMissingError, clear_creds, load_creds, save_creds
24
+ from .market_hours import market_status
25
+ from .models import Side
26
+ from .tasty import Tasty
27
+
28
+ c = Console()
29
+
30
+
31
+ @click.group(invoke_without_command=True)
32
+ @click.version_option(__version__, prog_name="msts-trader")
33
+ @click.pass_context
34
+ def main(ctx: click.Context) -> None:
35
+ if ctx.invoked_subcommand is None:
36
+ ctx.invoke(rebalance)
37
+
38
+
39
+ @main.command()
40
+ def login() -> None:
41
+ """Store Tastytrade OAuth creds (provider_secret + refresh_token) in OS keychain."""
42
+ c.print(
43
+ Panel.fit(
44
+ "[bold]Tastytrade OAuth setup[/bold]\n\n"
45
+ "1. Sign in at [cyan]https://developer.tastytrade.com[/cyan]\n"
46
+ "2. Create an OAuth application → copy [bold]provider secret[/bold]\n"
47
+ "3. Run their authorization flow → copy [bold]refresh token[/bold]\n"
48
+ "4. Find your [bold]account number[/bold] in Tastytrade dashboard "
49
+ "(or leave blank to auto-pick first account)",
50
+ border_style="cyan",
51
+ )
52
+ )
53
+ provider_secret = Prompt.ask("provider secret", password=True)
54
+ refresh_token = Prompt.ask("refresh token", password=True)
55
+ account_id = Prompt.ask("account id (optional)", default="").strip() or None
56
+
57
+ try:
58
+ t = Tasty(provider_secret, refresh_token, account_id)
59
+ bal = t.balances()
60
+ except Exception as e:
61
+ c.print(f"[red]✗ login failed:[/red] {e}")
62
+ sys.exit(1)
63
+
64
+ save_creds(provider_secret, refresh_token, account_id or t.account_id)
65
+ c.print(
66
+ f"[green]✓ stored.[/green] account [bold]{t.account_id}[/bold] · "
67
+ f"NAV ${bal.nav:,.2f} · BP ${bal.buying_power:,.2f}"
68
+ )
69
+
70
+
71
+ @main.command()
72
+ def logout() -> None:
73
+ """Forget stored creds."""
74
+ clear_creds()
75
+ c.print("[green]✓ creds cleared from keychain.[/green]")
76
+
77
+
78
+ @main.command()
79
+ def status() -> None:
80
+ """Show account NAV, positions, market status. No orders."""
81
+ try:
82
+ ps, rt, aid = load_creds()
83
+ except CredsMissingError as e:
84
+ c.print(f"[red]{e}[/red]")
85
+ sys.exit(1)
86
+
87
+ t = Tasty(ps, rt, aid)
88
+ bal = t.balances()
89
+ pos = t.positions()
90
+ ms = market_status()
91
+
92
+ c.print(
93
+ f"\n[bold]Account[/bold] {t.account_id} · "
94
+ f"NAV [green]${bal.nav:,.2f}[/green] · "
95
+ f"cash ${bal.cash:,.2f} · BP ${bal.buying_power:,.2f}"
96
+ )
97
+ c.print(f"Market: [bold]{ms.status}[/bold]" + (f" · closes in {ms.minutes_to_close} min" if ms.minutes_to_close is not None else ""))
98
+
99
+ if not pos:
100
+ c.print("[yellow]No open positions.[/yellow]")
101
+ return
102
+
103
+ table = Table(show_header=True, header_style="bold", box=None)
104
+ table.add_column("Symbol")
105
+ table.add_column("Qty", justify="right")
106
+ table.add_column("Price", justify="right")
107
+ table.add_column("Value", justify="right")
108
+ table.add_column("% NAV", justify="right")
109
+ for p in sorted(pos.values(), key=lambda x: -x.market_value):
110
+ pct = (p.market_value / bal.nav * 100) if bal.nav else Decimal(0)
111
+ table.add_row(p.ticker, f"{p.quantity:.2f}", f"${p.price:,.2f}", f"${p.market_value:,.0f}", f"{pct:.1f}%")
112
+ c.print(table)
113
+
114
+
115
+ @main.command()
116
+ @click.option("--dry-run", is_flag=True, help="Preview only — never sends orders.")
117
+ @click.option("--yes", "-y", is_flag=True, help="Skip the confirm prompt (auto-execute).")
118
+ @click.option("--threshold", default=0.04, type=float, show_default=True, help="Drift threshold (fraction of NAV).")
119
+ @click.option("--csv-file", type=click.Path(exists=True, dir_okay=False), default=None, help="Read CSV from file instead of stdin.")
120
+ def rebalance(dry_run: bool, yes: bool, threshold: float, csv_file: str | None) -> None:
121
+ """Default command. Paste a ticker,weight CSV → preview → confirm → execute."""
122
+ try:
123
+ ps, rt, aid = load_creds()
124
+ except CredsMissingError as e:
125
+ c.print(f"[red]{e}[/red]")
126
+ sys.exit(1)
127
+
128
+ ms = market_status()
129
+ if ms.status == "closed" and not dry_run:
130
+ c.print(f"[red]Market closed. Next open: {ms.next_open}.[/red]")
131
+ c.print("[yellow]Re-run with --dry-run to preview, or wait until the next session.[/yellow]")
132
+ sys.exit(2)
133
+ if ms.status in ("premarket", "afterhours") and not dry_run:
134
+ c.print(f"[red]Market in {ms.status} session — v1 only supports RTH market orders.[/red]")
135
+ c.print("[yellow]Re-run during RTH (09:30–16:00 ET) or pass --dry-run.[/yellow]")
136
+ sys.exit(2)
137
+
138
+ if csv_file:
139
+ with open(csv_file, encoding="utf-8") as f:
140
+ csv_text = f.read()
141
+ else:
142
+ c.print("\n[bold cyan]Paste CSV (ticker,weight), then Ctrl+D (Unix) or Ctrl+Z+Enter (Windows):[/bold cyan]")
143
+ csv_text = sys.stdin.read()
144
+
145
+ try:
146
+ targets = parse_csv(csv_text)
147
+ except CSVParseError as e:
148
+ c.print(f"[red]✗ CSV parse error:[/red] {e}")
149
+ sys.exit(1)
150
+
151
+ c.print(f"[green]✓ loaded {len(targets)} targets.[/green]")
152
+
153
+ t = Tasty(ps, rt, aid)
154
+ bal = t.balances()
155
+ pos = t.positions()
156
+ universe = sorted({tg.ticker for tg in targets} | set(pos.keys()))
157
+ c.print(f"Quoting {len(universe)} symbols...", style="dim")
158
+ quotes = t.quote(universe)
159
+ # supplement with last-known position prices for tickers we already hold
160
+ for tk, p in pos.items():
161
+ quotes.setdefault(tk, p.price)
162
+
163
+ preview = build_preview(
164
+ targets=targets,
165
+ positions=pos,
166
+ nav=bal.nav,
167
+ cash=bal.cash,
168
+ buying_power=bal.buying_power,
169
+ quotes=quotes,
170
+ drift_threshold=Decimal(str(threshold)),
171
+ )
172
+
173
+ _render_preview(preview, t.account_id, ms)
174
+
175
+ if preview.has_blockers:
176
+ c.print("[red]✗ blockers present — refusing to execute.[/red]")
177
+ sys.exit(1)
178
+
179
+ if not preview.orders:
180
+ c.print("[green]Nothing to do — portfolio within drift on every ticker.[/green]")
181
+ return
182
+
183
+ if dry_run:
184
+ c.print("[yellow]--dry-run set, exiting without sending orders.[/yellow]")
185
+ return
186
+
187
+ if not yes and not Confirm.ask(f"\nExecute [bold]{len(preview.orders)}[/bold] orders?", default=False):
188
+ c.print("[red]Cancelled.[/red]")
189
+ sys.exit(0)
190
+
191
+ _execute(t, preview)
192
+
193
+
194
+ def _render_preview(preview, account_id: str, ms) -> None:
195
+ c.print(
196
+ f"\n[bold]Account[/bold] {account_id} · "
197
+ f"NAV [green]${preview.nav:,.2f}[/green] · "
198
+ f"cash ${preview.cash:,.2f} · BP ${preview.buying_power:,.2f}"
199
+ )
200
+ if ms.minutes_to_close is not None:
201
+ marker = "[red]" if ms.minutes_to_close < 5 else "[yellow]" if ms.minutes_to_close < 15 else "[green]"
202
+ c.print(f"Market: open · closes in {marker}{ms.minutes_to_close} min[/]")
203
+
204
+ table = Table(show_header=True, header_style="bold", title="Rebalance preview")
205
+ table.add_column("Symbol")
206
+ table.add_column("Current %", justify="right")
207
+ table.add_column("Target %", justify="right")
208
+ table.add_column("Δ $", justify="right")
209
+ table.add_column("Action", justify="left")
210
+ table.add_column("Note", justify="left", style="dim")
211
+
212
+ for row in preview.rows:
213
+ cur = f"{row.current_pct * 100:.1f}%"
214
+ tgt = f"{row.target_pct * 100:.1f}%"
215
+ delta = f"${row.delta_dollars:+,.0f}"
216
+ if row.order:
217
+ qty = f"{row.order.quantity:.2f}"
218
+ est_px = row.order.estimated_price or 0
219
+ action = (
220
+ f"[green]BUY {qty} @ ~${est_px:,.2f}[/green]"
221
+ if row.order.side == Side.BUY
222
+ else f"[red]SELL {qty} @ ~${est_px:,.2f}[/red]"
223
+ )
224
+ else:
225
+ action = "—"
226
+ table.add_row(row.ticker, cur, tgt, delta, action, row.note)
227
+ c.print(table)
228
+
229
+ for w in preview.warnings:
230
+ c.print(f"[yellow]⚠ {w}[/yellow]")
231
+ for b in preview.blockers:
232
+ c.print(f"[red]✗ {b}[/red]")
233
+
234
+
235
+ def _execute(t: Tasty, preview) -> None:
236
+ total = len(preview.orders)
237
+ sent = 0
238
+ failed = 0
239
+ for i, o in enumerate(preview.orders, 1):
240
+ c.print(f"[{i}/{total}] {o.ticker} {o.side.value} {o.quantity:.2f} @ MKT ...", end=" ")
241
+ try:
242
+ result = t.place_market(o, dry_run=False)
243
+ except Exception as e:
244
+ result = {"status": "error", "reason": str(e), "ticker": o.ticker}
245
+ status = result.get("status", "?")
246
+ if status == "error" or status == "skipped":
247
+ failed += 1
248
+ c.print(f"[red]{status.upper()}[/red] {result.get('reason', '')}")
249
+ else:
250
+ sent += 1
251
+ c.print(f"[green]{status.upper()}[/green] id={result.get('order_id', '?')}")
252
+ fill_log.append({"event": "order", **result, "side": o.side.value, "quantity": float(o.quantity)})
253
+
254
+ c.print(f"\n[bold]Done.[/bold] sent: {sent} · failed: {failed} · log: {fill_log.log_dir()}")
255
+
256
+
257
+ if __name__ == "__main__":
258
+ main()
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import io
5
+ from decimal import Decimal, InvalidOperation
6
+
7
+ from .models import Target
8
+
9
+
10
+ class CSVParseError(ValueError):
11
+ pass
12
+
13
+
14
+ REQUIRED_HEADERS = {"ticker", "weight"}
15
+
16
+
17
+ def parse_csv(text: str) -> list[Target]:
18
+ """Parse a `ticker,weight` CSV. Tolerates BOM, blank lines, comments, surrounding whitespace.
19
+
20
+ Comment lines start with `#` (e.g. trailing `# sig: ed25519:...`) and are ignored.
21
+ """
22
+ text = text.lstrip("").strip()
23
+ if not text:
24
+ raise CSVParseError("empty input")
25
+
26
+ lines = [ln for ln in text.splitlines() if ln.strip() and not ln.lstrip().startswith("#")]
27
+ if not lines:
28
+ raise CSVParseError("no data rows")
29
+
30
+ reader = csv.DictReader(io.StringIO("\n".join(lines)))
31
+ headers = {h.strip().lower() for h in (reader.fieldnames or [])}
32
+ missing = REQUIRED_HEADERS - headers
33
+ if missing:
34
+ raise CSVParseError(f"missing required columns: {sorted(missing)} — got {sorted(headers)}")
35
+
36
+ targets: list[Target] = []
37
+ seen: set[str] = set()
38
+ for i, row in enumerate(reader, start=2):
39
+ tkr = (row.get("ticker") or row.get("Ticker") or "").strip().upper()
40
+ raw_w = (row.get("weight") or row.get("Weight") or "").strip()
41
+ if not tkr:
42
+ continue
43
+ if tkr in seen:
44
+ raise CSVParseError(f"line {i}: duplicate ticker {tkr}")
45
+ try:
46
+ w = Decimal(raw_w)
47
+ except InvalidOperation:
48
+ raise CSVParseError(f"line {i}: weight {raw_w!r} is not a number")
49
+ if w < 0:
50
+ raise CSVParseError(f"line {i}: negative weight {w} for {tkr} (shorts unsupported in v1)")
51
+ if w > 1:
52
+ raise CSVParseError(f"line {i}: weight {w} > 1 for {tkr} (expected fraction, not percent)")
53
+ seen.add(tkr)
54
+ targets.append(Target(ticker=tkr, weight=w))
55
+
56
+ if not targets:
57
+ raise CSVParseError("no targets parsed (all rows blank?)")
58
+ return targets
59
+
60
+
61
+ def total_weight(targets: list[Target]) -> Decimal:
62
+ return sum((t.weight for t in targets), Decimal(0))