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.
- msts_trader-0.1.0/LICENSE +19 -0
- msts_trader-0.1.0/PKG-INFO +183 -0
- msts_trader-0.1.0/README.md +152 -0
- msts_trader-0.1.0/msts_trader/__init__.py +1 -0
- msts_trader-0.1.0/msts_trader/__main__.py +258 -0
- msts_trader-0.1.0/msts_trader/csv_parser.py +62 -0
- msts_trader-0.1.0/msts_trader/diff.py +138 -0
- msts_trader-0.1.0/msts_trader/fill_log.py +23 -0
- msts_trader-0.1.0/msts_trader/keychain.py +38 -0
- msts_trader-0.1.0/msts_trader/market_hours.py +77 -0
- msts_trader-0.1.0/msts_trader/models.py +61 -0
- msts_trader-0.1.0/msts_trader/tasty.py +172 -0
- msts_trader-0.1.0/msts_trader.egg-info/PKG-INFO +183 -0
- msts_trader-0.1.0/msts_trader.egg-info/SOURCES.txt +18 -0
- msts_trader-0.1.0/msts_trader.egg-info/dependency_links.txt +1 -0
- msts_trader-0.1.0/msts_trader.egg-info/entry_points.txt +2 -0
- msts_trader-0.1.0/msts_trader.egg-info/requires.txt +10 -0
- msts_trader-0.1.0/msts_trader.egg-info/top_level.txt +1 -0
- msts_trader-0.1.0/pyproject.toml +48 -0
- msts_trader-0.1.0/setup.cfg +4 -0
|
@@ -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))
|