tastytrade 0.2.0 → 0.3.0
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.
- checksums.yaml +4 -4
- data/.claude/commands/plan.md +13 -0
- data/.claude/commands/release-pr.md +12 -0
- data/CHANGELOG.md +170 -0
- data/README.md +424 -3
- data/ROADMAP.md +17 -17
- data/lib/tastytrade/cli/history_formatter.rb +304 -0
- data/lib/tastytrade/cli/orders.rb +749 -0
- data/lib/tastytrade/cli/positions_formatter.rb +114 -0
- data/lib/tastytrade/cli.rb +701 -12
- data/lib/tastytrade/cli_helpers.rb +111 -14
- data/lib/tastytrade/client.rb +7 -0
- data/lib/tastytrade/file_store.rb +83 -0
- data/lib/tastytrade/instruments/equity.rb +42 -0
- data/lib/tastytrade/models/account.rb +160 -2
- data/lib/tastytrade/models/account_balance.rb +46 -0
- data/lib/tastytrade/models/buying_power_effect.rb +61 -0
- data/lib/tastytrade/models/live_order.rb +272 -0
- data/lib/tastytrade/models/order_response.rb +106 -0
- data/lib/tastytrade/models/order_status.rb +84 -0
- data/lib/tastytrade/models/trading_status.rb +200 -0
- data/lib/tastytrade/models/transaction.rb +151 -0
- data/lib/tastytrade/models.rb +6 -0
- data/lib/tastytrade/order.rb +191 -0
- data/lib/tastytrade/order_validator.rb +355 -0
- data/lib/tastytrade/session.rb +26 -1
- data/lib/tastytrade/session_manager.rb +43 -14
- data/lib/tastytrade/version.rb +1 -1
- data/lib/tastytrade.rb +43 -0
- data/spec/exe/tastytrade_spec.rb +1 -1
- data/spec/spec_helper.rb +72 -0
- data/spec/tastytrade/cli/positions_spec.rb +267 -0
- data/spec/tastytrade/cli_auth_spec.rb +5 -0
- data/spec/tastytrade/cli_env_login_spec.rb +199 -0
- data/spec/tastytrade/cli_helpers_spec.rb +3 -26
- data/spec/tastytrade/cli_orders_spec.rb +168 -0
- data/spec/tastytrade/cli_status_spec.rb +153 -164
- data/spec/tastytrade/file_store_spec.rb +126 -0
- data/spec/tastytrade/models/account_balance_spec.rb +103 -0
- data/spec/tastytrade/models/account_order_history_spec.rb +229 -0
- data/spec/tastytrade/models/account_order_management_spec.rb +271 -0
- data/spec/tastytrade/models/account_place_order_spec.rb +125 -0
- data/spec/tastytrade/models/account_spec.rb +86 -15
- data/spec/tastytrade/models/buying_power_effect_spec.rb +250 -0
- data/spec/tastytrade/models/live_order_json_spec.rb +144 -0
- data/spec/tastytrade/models/live_order_spec.rb +295 -0
- data/spec/tastytrade/models/order_response_spec.rb +96 -0
- data/spec/tastytrade/models/order_status_spec.rb +113 -0
- data/spec/tastytrade/models/trading_status_spec.rb +260 -0
- data/spec/tastytrade/models/transaction_spec.rb +236 -0
- data/spec/tastytrade/order_edge_cases_spec.rb +163 -0
- data/spec/tastytrade/order_spec.rb +201 -0
- data/spec/tastytrade/order_validator_spec.rb +347 -0
- data/spec/tastytrade/session_env_spec.rb +169 -0
- data/spec/tastytrade/session_manager_spec.rb +43 -33
- data/vcr_implementation_plan.md +403 -0
- data/vcr_implementation_research.md +330 -0
- metadata +50 -18
- data/lib/tastytrade/keyring_store.rb +0 -72
- data/spec/tastytrade/keyring_store_spec.rb +0 -168
@@ -0,0 +1,403 @@
|
|
1
|
+
# VCR Implementation Plan for Tastytrade Gem
|
2
|
+
|
3
|
+
## Executive Summary
|
4
|
+
Implement VCR cassettes to replace existing mocks, accounting for Tastytrade sandbox's market-hours-only order routing behavior.
|
5
|
+
|
6
|
+
## Phase 1: Foundation Setup (Week 1)
|
7
|
+
|
8
|
+
### 1.1 Credential Management
|
9
|
+
```bash
|
10
|
+
# Create .env.test (add to .gitignore)
|
11
|
+
TASTYTRADE_SANDBOX_USERNAME=your_username
|
12
|
+
TASTYTRADE_SANDBOX_PASSWORD=your_password
|
13
|
+
TASTYTRADE_SANDBOX_ACCOUNT=your_account
|
14
|
+
TASTYTRADE_ENVIRONMENT=sandbox
|
15
|
+
```
|
16
|
+
|
17
|
+
### 1.2 Enhanced VCR Configuration
|
18
|
+
```ruby
|
19
|
+
# spec/spec_helper.rb
|
20
|
+
require 'dotenv'
|
21
|
+
Dotenv.load('.env.test') if File.exist?('.env.test')
|
22
|
+
|
23
|
+
VCR.configure do |config|
|
24
|
+
config.cassette_library_dir = "spec/fixtures/vcr_cassettes"
|
25
|
+
config.hook_into :webmock
|
26
|
+
config.configure_rspec_metadata!
|
27
|
+
|
28
|
+
# Recording mode management
|
29
|
+
vcr_mode = ENV['VCR_MODE'] =~ /rec/i ? :all : :once
|
30
|
+
vcr_mode = :none if ENV['CI'] # Prevent recording in CI
|
31
|
+
|
32
|
+
config.default_cassette_options = {
|
33
|
+
record: vcr_mode,
|
34
|
+
match_requests_on: [:method, :uri, :body],
|
35
|
+
allow_playback_repeats: true,
|
36
|
+
record_on_error: false
|
37
|
+
}
|
38
|
+
|
39
|
+
# Enhanced sensitive data filtering
|
40
|
+
config.filter_sensitive_data('<SANDBOX_USERNAME>') { ENV['TASTYTRADE_SANDBOX_USERNAME'] }
|
41
|
+
config.filter_sensitive_data('<SANDBOX_PASSWORD>') { ENV['TASTYTRADE_SANDBOX_PASSWORD'] }
|
42
|
+
config.filter_sensitive_data('<SANDBOX_ACCOUNT>') { ENV['TASTYTRADE_SANDBOX_ACCOUNT'] }
|
43
|
+
|
44
|
+
# Filter dynamic/sensitive data from responses
|
45
|
+
config.before_record do |interaction|
|
46
|
+
if interaction.response.body
|
47
|
+
body = interaction.response.body
|
48
|
+
|
49
|
+
# Filter account numbers (format: 5WX12345)
|
50
|
+
body.gsub!(/5W[A-Z0-9]{6,8}/, '<ACCOUNT_NUMBER>')
|
51
|
+
|
52
|
+
# Filter session tokens
|
53
|
+
body.gsub!(/"session-token":"[^"]+/, '"session-token":"<SESSION_TOKEN>')
|
54
|
+
body.gsub!(/"remember-token":"[^"]+/, '"remember-token":"<REMEMBER_TOKEN>')
|
55
|
+
|
56
|
+
# Filter personal information
|
57
|
+
body.gsub!(/"email":"[^"]+/, '"email":"<EMAIL>')
|
58
|
+
body.gsub!(/"external-id":"[^"]+/, '"external-id":"<EXTERNAL_ID>')
|
59
|
+
|
60
|
+
# Add recording metadata
|
61
|
+
interaction.response.headers['X-VCR-Recorded-At'] = Time.current.iso8601
|
62
|
+
interaction.response.headers['X-VCR-Market-Status'] = market_open? ? 'open' : 'closed'
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Ignore certain dynamic parameters
|
67
|
+
config.before_playback do |interaction|
|
68
|
+
# Normalize timestamps in URLs if needed
|
69
|
+
interaction.request.uri.gsub!(/timestamp=\d+/, 'timestamp=NORMALIZED')
|
70
|
+
end
|
71
|
+
end
|
72
|
+
```
|
73
|
+
|
74
|
+
### 1.3 Market Hours Helper
|
75
|
+
```ruby
|
76
|
+
# spec/support/market_hours_helper.rb
|
77
|
+
module MarketHoursHelper
|
78
|
+
def market_open?(time = Time.current)
|
79
|
+
# Convert to ET (Eastern Time)
|
80
|
+
et_time = time.in_time_zone('America/New_York')
|
81
|
+
|
82
|
+
# Check if weekend
|
83
|
+
return false if et_time.saturday? || et_time.sunday?
|
84
|
+
|
85
|
+
# Check if US market holiday (simplified - expand as needed)
|
86
|
+
holidays = [
|
87
|
+
Date.new(et_time.year, 1, 1), # New Year's Day
|
88
|
+
Date.new(et_time.year, 7, 4), # Independence Day
|
89
|
+
Date.new(et_time.year, 12, 25), # Christmas
|
90
|
+
# Add more holidays as needed
|
91
|
+
]
|
92
|
+
return false if holidays.include?(et_time.to_date)
|
93
|
+
|
94
|
+
# Check market hours (9:30 AM - 4:00 PM ET)
|
95
|
+
market_open = et_time.change(hour: 9, min: 30)
|
96
|
+
market_close = et_time.change(hour: 16, min: 0)
|
97
|
+
|
98
|
+
et_time >= market_open && et_time <= market_close
|
99
|
+
end
|
100
|
+
|
101
|
+
def skip_outside_market_hours
|
102
|
+
unless market_open? || VCR.current_cassette
|
103
|
+
skip "Test requires market hours or existing cassette"
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def next_market_open_time
|
108
|
+
time = Time.current.in_time_zone('America/New_York')
|
109
|
+
|
110
|
+
# If it's before 9:30 AM on a weekday, return today's open
|
111
|
+
if !time.saturday? && !time.sunday? && time.hour < 9 || (time.hour == 9 && time.min < 30)
|
112
|
+
return time.change(hour: 9, min: 30)
|
113
|
+
end
|
114
|
+
|
115
|
+
# Otherwise, find next weekday
|
116
|
+
loop do
|
117
|
+
time = time.tomorrow
|
118
|
+
break if !time.saturday? && !time.sunday?
|
119
|
+
end
|
120
|
+
|
121
|
+
time.change(hour: 9, min: 30)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
RSpec.configure do |config|
|
126
|
+
config.include MarketHoursHelper
|
127
|
+
end
|
128
|
+
```
|
129
|
+
|
130
|
+
## Phase 2: Proof of Concept - Session Class (Week 1)
|
131
|
+
|
132
|
+
### 2.1 Convert Session Tests
|
133
|
+
```ruby
|
134
|
+
# spec/tastytrade/session_vcr_spec.rb
|
135
|
+
require 'spec_helper'
|
136
|
+
|
137
|
+
RSpec.describe Tastytrade::Session, :vcr do
|
138
|
+
let(:sandbox_credentials) do
|
139
|
+
{
|
140
|
+
username: ENV['TASTYTRADE_SANDBOX_USERNAME'],
|
141
|
+
password: ENV['TASTYTRADE_SANDBOX_PASSWORD'],
|
142
|
+
is_test: true
|
143
|
+
}
|
144
|
+
end
|
145
|
+
|
146
|
+
describe "#login" do
|
147
|
+
context "with valid credentials" do
|
148
|
+
it "authenticates successfully" do
|
149
|
+
session = described_class.new(**sandbox_credentials).login
|
150
|
+
|
151
|
+
expect(session.session_token).not_to be_nil
|
152
|
+
expect(session.user).to be_a(Tastytrade::Models::User)
|
153
|
+
expect(session.user.email).not_to be_nil
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
context "with invalid credentials" do
|
158
|
+
it "raises InvalidCredentialsError" do
|
159
|
+
invalid_creds = sandbox_credentials.merge(password: 'wrong_password')
|
160
|
+
|
161
|
+
expect {
|
162
|
+
described_class.new(**invalid_creds).login
|
163
|
+
}.to raise_error(Tastytrade::InvalidCredentialsError)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
describe "#validate" do
|
169
|
+
let(:session) { described_class.new(**sandbox_credentials).login }
|
170
|
+
|
171
|
+
it "validates active session" do
|
172
|
+
expect(session.validate).to be true
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
describe "#destroy" do
|
177
|
+
let(:session) { described_class.new(**sandbox_credentials).login }
|
178
|
+
|
179
|
+
it "destroys the session" do
|
180
|
+
session.destroy
|
181
|
+
expect(session.session_token).to be_nil
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
```
|
186
|
+
|
187
|
+
### 2.2 Recording Schedule
|
188
|
+
```markdown
|
189
|
+
# Recording Schedule
|
190
|
+
|
191
|
+
## Initial Recording Session
|
192
|
+
- **Date**: [Schedule a weekday]
|
193
|
+
- **Time**: 10:00 AM - 3:00 PM ET (avoiding market open/close volatility)
|
194
|
+
- **Checklist**:
|
195
|
+
- [ ] Verify sandbox credentials work
|
196
|
+
- [ ] Market is open
|
197
|
+
- [ ] No US market holidays
|
198
|
+
- [ ] VCR_MODE=rec environment variable set
|
199
|
+
|
200
|
+
## Recording Commands
|
201
|
+
```bash
|
202
|
+
# Record all cassettes
|
203
|
+
VCR_MODE=rec bundle exec rspec spec/tastytrade/session_vcr_spec.rb
|
204
|
+
|
205
|
+
# Verify cassettes work
|
206
|
+
bundle exec rspec spec/tastytrade/session_vcr_spec.rb
|
207
|
+
```
|
208
|
+
|
209
|
+
## Phase 3: Order-Related Tests (Week 2)
|
210
|
+
|
211
|
+
### 3.1 Order Placement Tests with Market Hours
|
212
|
+
```ruby
|
213
|
+
# spec/tastytrade/models/account_place_order_vcr_spec.rb
|
214
|
+
RSpec.describe "Order Placement", :vcr do
|
215
|
+
include MarketHoursHelper
|
216
|
+
|
217
|
+
let(:session) { sandbox_session } # From helper
|
218
|
+
let(:account) { session.accounts.first }
|
219
|
+
|
220
|
+
describe "placing orders" do
|
221
|
+
context "during market hours" do
|
222
|
+
before { skip_outside_market_hours }
|
223
|
+
|
224
|
+
it "places a limit order" do
|
225
|
+
order = Tastytrade::Order.new(
|
226
|
+
type: Tastytrade::OrderType::LIMIT,
|
227
|
+
time_in_force: Tastytrade::OrderTimeInForce::DAY,
|
228
|
+
legs: build_spy_leg(100, "Buy to Open"),
|
229
|
+
price: 430.00
|
230
|
+
)
|
231
|
+
|
232
|
+
response = account.place_order(session, order)
|
233
|
+
|
234
|
+
expect(response).to be_a(Tastytrade::Models::OrderResponse)
|
235
|
+
expect(response.order_id).not_to be_nil
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
context "order validation (market independent)" do
|
240
|
+
it "validates order without placing" do
|
241
|
+
order = Tastytrade::Order.new(
|
242
|
+
type: Tastytrade::OrderType::LIMIT,
|
243
|
+
time_in_force: Tastytrade::OrderTimeInForce::DAY,
|
244
|
+
legs: build_spy_leg(100, "Buy to Open"),
|
245
|
+
price: 430.00
|
246
|
+
)
|
247
|
+
|
248
|
+
# Dry run doesn't require market hours
|
249
|
+
response = account.place_order(session, order, dry_run: true)
|
250
|
+
|
251
|
+
expect(response.buying_power_effect).not_to be_nil
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
private
|
257
|
+
|
258
|
+
def build_spy_leg(quantity, action)
|
259
|
+
Tastytrade::OrderLeg.new(
|
260
|
+
action: action,
|
261
|
+
symbol: "SPY",
|
262
|
+
quantity: quantity
|
263
|
+
)
|
264
|
+
end
|
265
|
+
end
|
266
|
+
```
|
267
|
+
|
268
|
+
## Phase 4: Gradual Migration (Weeks 3-4)
|
269
|
+
|
270
|
+
### 4.1 Parallel Testing Strategy
|
271
|
+
```ruby
|
272
|
+
# spec/spec_helper.rb
|
273
|
+
RSpec.configure do |config|
|
274
|
+
# Run VCR tests only when marked
|
275
|
+
config.around(:each) do |example|
|
276
|
+
if example.metadata[:vcr]
|
277
|
+
example.run
|
278
|
+
elsif example.metadata[:use_mocks]
|
279
|
+
# Keep existing mock behavior
|
280
|
+
VCR.turned_off { example.run }
|
281
|
+
else
|
282
|
+
# Default to mocks for now
|
283
|
+
VCR.turned_off { example.run }
|
284
|
+
end
|
285
|
+
end
|
286
|
+
end
|
287
|
+
```
|
288
|
+
|
289
|
+
### 4.2 Migration Checklist
|
290
|
+
- [ ] Session tests converted
|
291
|
+
- [ ] Client HTTP tests converted
|
292
|
+
- [ ] Account model tests converted
|
293
|
+
- [ ] Balance retrieval tests converted
|
294
|
+
- [ ] Order placement tests converted
|
295
|
+
- [ ] Order history tests converted
|
296
|
+
- [ ] Position tests converted
|
297
|
+
- [ ] CLI tests converted (mock the API client layer)
|
298
|
+
|
299
|
+
## Phase 5: CI/CD Integration (Week 4)
|
300
|
+
|
301
|
+
### 5.1 GitHub Actions Configuration
|
302
|
+
```yaml
|
303
|
+
# .github/workflows/test.yml
|
304
|
+
name: Tests
|
305
|
+
on: [push, pull_request]
|
306
|
+
|
307
|
+
jobs:
|
308
|
+
test:
|
309
|
+
runs-on: ubuntu-latest
|
310
|
+
env:
|
311
|
+
VCR_MODE: none # Never record in CI
|
312
|
+
TASTYTRADE_SANDBOX_USERNAME: ${{ secrets.TASTYTRADE_SANDBOX_USERNAME }}
|
313
|
+
TASTYTRADE_SANDBOX_PASSWORD: ${{ secrets.TASTYTRADE_SANDBOX_PASSWORD }}
|
314
|
+
TASTYTRADE_SANDBOX_ACCOUNT: ${{ secrets.TASTYTRADE_SANDBOX_ACCOUNT }}
|
315
|
+
|
316
|
+
steps:
|
317
|
+
- uses: actions/checkout@v4
|
318
|
+
with:
|
319
|
+
fetch-depth: 1 # Shallow clone for speed
|
320
|
+
|
321
|
+
- name: Set up Ruby
|
322
|
+
uses: ruby/setup-ruby@v1
|
323
|
+
with:
|
324
|
+
ruby-version: '3.2'
|
325
|
+
bundler-cache: true
|
326
|
+
|
327
|
+
- name: Run tests
|
328
|
+
run: bundle exec rspec
|
329
|
+
```
|
330
|
+
|
331
|
+
### 5.2 Cassette Maintenance
|
332
|
+
```ruby
|
333
|
+
# lib/tasks/vcr.rake
|
334
|
+
namespace :vcr do
|
335
|
+
desc "Delete cassettes older than 30 days"
|
336
|
+
task :clean_old do
|
337
|
+
Dir.glob("spec/fixtures/vcr_cassettes/**/*.yml").each do |cassette|
|
338
|
+
if File.mtime(cassette) < 30.days.ago
|
339
|
+
puts "Deleting old cassette: #{cassette}"
|
340
|
+
File.delete(cassette)
|
341
|
+
end
|
342
|
+
end
|
343
|
+
end
|
344
|
+
|
345
|
+
desc "Re-record all cassettes (run during market hours)"
|
346
|
+
task :refresh_all do
|
347
|
+
unless market_open?
|
348
|
+
puts "ERROR: Market is closed. Run during market hours (9:30 AM - 4:00 PM ET)"
|
349
|
+
puts "Next market open: #{next_market_open_time}"
|
350
|
+
exit 1
|
351
|
+
end
|
352
|
+
|
353
|
+
ENV['VCR_MODE'] = 'rec'
|
354
|
+
system('bundle exec rspec --tag vcr')
|
355
|
+
end
|
356
|
+
|
357
|
+
desc "Show cassette statistics"
|
358
|
+
task :stats do
|
359
|
+
cassettes = Dir.glob("spec/fixtures/vcr_cassettes/**/*.yml")
|
360
|
+
total_size = cassettes.sum { |f| File.size(f) }
|
361
|
+
|
362
|
+
puts "Total cassettes: #{cassettes.count}"
|
363
|
+
puts "Total size: #{(total_size / 1024.0 / 1024.0).round(2)} MB"
|
364
|
+
puts "Oldest cassette: #{cassettes.min_by { |f| File.mtime(f) }}"
|
365
|
+
puts "Newest cassette: #{cassettes.max_by { |f| File.mtime(f) }}"
|
366
|
+
end
|
367
|
+
end
|
368
|
+
```
|
369
|
+
|
370
|
+
## Phase 6: Documentation (Week 5)
|
371
|
+
|
372
|
+
### 6.1 Developer Guide
|
373
|
+
Create `docs/vcr_testing.md`:
|
374
|
+
- How to record new cassettes
|
375
|
+
- Market hours requirements
|
376
|
+
- Credential setup
|
377
|
+
- Troubleshooting guide
|
378
|
+
- Best practices
|
379
|
+
|
380
|
+
### 6.2 Git Configuration
|
381
|
+
```gitignore
|
382
|
+
# .gitignore
|
383
|
+
.env.test
|
384
|
+
.env.local
|
385
|
+
|
386
|
+
# .gitattributes
|
387
|
+
spec/fixtures/vcr_cassettes/**/*.yml -diff
|
388
|
+
```
|
389
|
+
|
390
|
+
## Success Metrics
|
391
|
+
- [ ] All tests pass with VCR cassettes
|
392
|
+
- [ ] CI build time reduced by >50%
|
393
|
+
- [ ] Tests work offline
|
394
|
+
- [ ] No sensitive data in cassettes
|
395
|
+
- [ ] Documentation complete
|
396
|
+
- [ ] Team trained on VCR workflow
|
397
|
+
|
398
|
+
## Risk Mitigation
|
399
|
+
1. **Market Hours Dependency**: Use strict `:once` recording mode
|
400
|
+
2. **Sensitive Data Leaks**: Multiple layers of filtering
|
401
|
+
3. **Cassette Bloat**: Regular cleanup tasks
|
402
|
+
4. **API Changes**: Monthly cassette refresh schedule
|
403
|
+
5. **Team Adoption**: Comprehensive documentation and helpers
|