tastytrade 0.2.0 → 0.3.1

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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/plan.md +13 -0
  3. data/.claude/commands/release-pr.md +12 -0
  4. data/CHANGELOG.md +180 -0
  5. data/README.md +424 -3
  6. data/ROADMAP.md +17 -17
  7. data/lib/tastytrade/cli/history_formatter.rb +304 -0
  8. data/lib/tastytrade/cli/orders.rb +749 -0
  9. data/lib/tastytrade/cli/positions_formatter.rb +114 -0
  10. data/lib/tastytrade/cli.rb +701 -12
  11. data/lib/tastytrade/cli_helpers.rb +111 -14
  12. data/lib/tastytrade/client.rb +7 -0
  13. data/lib/tastytrade/file_store.rb +83 -0
  14. data/lib/tastytrade/instruments/equity.rb +42 -0
  15. data/lib/tastytrade/models/account.rb +160 -2
  16. data/lib/tastytrade/models/account_balance.rb +46 -0
  17. data/lib/tastytrade/models/buying_power_effect.rb +61 -0
  18. data/lib/tastytrade/models/live_order.rb +272 -0
  19. data/lib/tastytrade/models/order_response.rb +106 -0
  20. data/lib/tastytrade/models/order_status.rb +84 -0
  21. data/lib/tastytrade/models/trading_status.rb +200 -0
  22. data/lib/tastytrade/models/transaction.rb +151 -0
  23. data/lib/tastytrade/models.rb +6 -0
  24. data/lib/tastytrade/order.rb +191 -0
  25. data/lib/tastytrade/order_validator.rb +355 -0
  26. data/lib/tastytrade/session.rb +26 -1
  27. data/lib/tastytrade/session_manager.rb +43 -14
  28. data/lib/tastytrade/version.rb +1 -1
  29. data/lib/tastytrade.rb +43 -0
  30. data/spec/exe/tastytrade_spec.rb +1 -1
  31. data/spec/tastytrade/cli/positions_spec.rb +267 -0
  32. data/spec/tastytrade/cli_auth_spec.rb +5 -0
  33. data/spec/tastytrade/cli_env_login_spec.rb +199 -0
  34. data/spec/tastytrade/cli_helpers_spec.rb +3 -26
  35. data/spec/tastytrade/cli_orders_spec.rb +168 -0
  36. data/spec/tastytrade/cli_status_spec.rb +153 -164
  37. data/spec/tastytrade/file_store_spec.rb +126 -0
  38. data/spec/tastytrade/models/account_balance_spec.rb +103 -0
  39. data/spec/tastytrade/models/account_order_history_spec.rb +229 -0
  40. data/spec/tastytrade/models/account_order_management_spec.rb +271 -0
  41. data/spec/tastytrade/models/account_place_order_spec.rb +125 -0
  42. data/spec/tastytrade/models/account_spec.rb +86 -15
  43. data/spec/tastytrade/models/buying_power_effect_spec.rb +250 -0
  44. data/spec/tastytrade/models/live_order_json_spec.rb +144 -0
  45. data/spec/tastytrade/models/live_order_spec.rb +295 -0
  46. data/spec/tastytrade/models/order_response_spec.rb +96 -0
  47. data/spec/tastytrade/models/order_status_spec.rb +113 -0
  48. data/spec/tastytrade/models/trading_status_spec.rb +260 -0
  49. data/spec/tastytrade/models/transaction_spec.rb +236 -0
  50. data/spec/tastytrade/order_edge_cases_spec.rb +163 -0
  51. data/spec/tastytrade/order_spec.rb +201 -0
  52. data/spec/tastytrade/order_validator_spec.rb +347 -0
  53. data/spec/tastytrade/session_env_spec.rb +169 -0
  54. data/spec/tastytrade/session_manager_spec.rb +43 -33
  55. metadata +34 -18
  56. data/lib/tastytrade/keyring_store.rb +0 -72
  57. data/spec/tastytrade/keyring_store_spec.rb +0 -168
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Tastytrade::OrderLeg do
4
+ describe "#initialize" do
5
+ it "creates a valid order leg" do
6
+ leg = described_class.new(
7
+ action: Tastytrade::OrderAction::BUY_TO_OPEN,
8
+ symbol: "AAPL",
9
+ quantity: 100
10
+ )
11
+
12
+ expect(leg.action).to eq(Tastytrade::OrderAction::BUY_TO_OPEN)
13
+ expect(leg.symbol).to eq("AAPL")
14
+ expect(leg.quantity).to eq(100)
15
+ expect(leg.instrument_type).to eq("Equity")
16
+ end
17
+
18
+ it "validates action parameter" do
19
+ expect do
20
+ described_class.new(
21
+ action: "INVALID_ACTION",
22
+ symbol: "AAPL",
23
+ quantity: 100
24
+ )
25
+ end.to raise_error(ArgumentError, /Invalid action/)
26
+ end
27
+ end
28
+
29
+ describe "#to_api_params" do
30
+ it "converts to API format" do
31
+ leg = described_class.new(
32
+ action: Tastytrade::OrderAction::BUY_TO_OPEN,
33
+ symbol: "AAPL",
34
+ quantity: 100
35
+ )
36
+
37
+ params = leg.to_api_params
38
+ expect(params["action"]).to eq("Buy to Open")
39
+ expect(params["symbol"]).to eq("AAPL")
40
+ expect(params["quantity"]).to eq(100)
41
+ expect(params["instrument-type"]).to eq("Equity")
42
+ end
43
+ end
44
+ end
45
+
46
+ RSpec.describe Tastytrade::Order do
47
+ let(:leg) do
48
+ Tastytrade::OrderLeg.new(
49
+ action: Tastytrade::OrderAction::BUY_TO_OPEN,
50
+ symbol: "AAPL",
51
+ quantity: 100
52
+ )
53
+ end
54
+
55
+ describe "#initialize" do
56
+ it "creates a market order" do
57
+ order = described_class.new(
58
+ type: Tastytrade::OrderType::MARKET,
59
+ legs: leg
60
+ )
61
+
62
+ expect(order.type).to eq(Tastytrade::OrderType::MARKET)
63
+ expect(order.time_in_force).to eq(Tastytrade::OrderTimeInForce::DAY)
64
+ expect(order.legs).to eq([leg])
65
+ expect(order.price).to be_nil
66
+ end
67
+
68
+ it "creates a limit order with price" do
69
+ order = described_class.new(
70
+ type: Tastytrade::OrderType::LIMIT,
71
+ legs: leg,
72
+ price: 150.50
73
+ )
74
+
75
+ expect(order.type).to eq(Tastytrade::OrderType::LIMIT)
76
+ expect(order.price).to eq(BigDecimal("150.50"))
77
+ end
78
+
79
+ it "validates order type" do
80
+ expect do
81
+ described_class.new(
82
+ type: "INVALID_TYPE",
83
+ legs: leg
84
+ )
85
+ end.to raise_error(ArgumentError, /Invalid order type/)
86
+ end
87
+
88
+ it "validates time in force" do
89
+ expect do
90
+ described_class.new(
91
+ type: Tastytrade::OrderType::MARKET,
92
+ time_in_force: "INVALID_TIF",
93
+ legs: leg
94
+ )
95
+ end.to raise_error(ArgumentError, /Invalid time in force/)
96
+ end
97
+
98
+ it "requires price for limit orders" do
99
+ expect do
100
+ described_class.new(
101
+ type: Tastytrade::OrderType::LIMIT,
102
+ legs: leg
103
+ )
104
+ end.to raise_error(ArgumentError, /Price is required for limit orders/)
105
+ end
106
+
107
+ it "validates price is positive" do
108
+ expect do
109
+ described_class.new(
110
+ type: Tastytrade::OrderType::LIMIT,
111
+ legs: leg,
112
+ price: -10
113
+ )
114
+ end.to raise_error(ArgumentError, /Price must be greater than 0/)
115
+ end
116
+ end
117
+
118
+ describe "#market?" do
119
+ it "returns true for market orders" do
120
+ order = described_class.new(
121
+ type: Tastytrade::OrderType::MARKET,
122
+ legs: leg
123
+ )
124
+ expect(order.market?).to be true
125
+ end
126
+
127
+ it "returns false for limit orders" do
128
+ order = described_class.new(
129
+ type: Tastytrade::OrderType::LIMIT,
130
+ legs: leg,
131
+ price: 150
132
+ )
133
+ expect(order.market?).to be false
134
+ end
135
+ end
136
+
137
+ describe "#limit?" do
138
+ it "returns true for limit orders" do
139
+ order = described_class.new(
140
+ type: Tastytrade::OrderType::LIMIT,
141
+ legs: leg,
142
+ price: 150
143
+ )
144
+ expect(order.limit?).to be true
145
+ end
146
+
147
+ it "returns false for market orders" do
148
+ order = described_class.new(
149
+ type: Tastytrade::OrderType::MARKET,
150
+ legs: leg
151
+ )
152
+ expect(order.limit?).to be false
153
+ end
154
+ end
155
+
156
+ describe "#to_api_params" do
157
+ it "converts market order to API format" do
158
+ order = described_class.new(
159
+ type: Tastytrade::OrderType::MARKET,
160
+ legs: leg
161
+ )
162
+
163
+ params = order.to_api_params
164
+ expect(params["order-type"]).to eq("Market")
165
+ expect(params["time-in-force"]).to eq("Day")
166
+ expect(params["legs"]).to be_an(Array)
167
+ expect(params["legs"].first["action"]).to eq("Buy to Open")
168
+ expect(params).not_to have_key("price")
169
+ end
170
+
171
+ it "converts limit order to API format with price and price-effect" do
172
+ order = described_class.new(
173
+ type: Tastytrade::OrderType::LIMIT,
174
+ legs: leg,
175
+ price: 150.50
176
+ )
177
+
178
+ params = order.to_api_params
179
+ expect(params["order-type"]).to eq("Limit")
180
+ expect(params["price"]).to eq("150.5")
181
+ expect(params["price-effect"]).to eq("Debit") # BUY_TO_OPEN results in Debit
182
+ end
183
+
184
+ it "sets price-effect to Credit for sell orders" do
185
+ sell_leg = Tastytrade::OrderLeg.new(
186
+ action: Tastytrade::OrderAction::SELL_TO_CLOSE,
187
+ symbol: "AAPL",
188
+ quantity: 100
189
+ )
190
+
191
+ order = described_class.new(
192
+ type: Tastytrade::OrderType::LIMIT,
193
+ legs: sell_leg,
194
+ price: 150.50
195
+ )
196
+
197
+ params = order.to_api_params
198
+ expect(params["price-effect"]).to eq("Credit")
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Tastytrade::OrderValidator do
6
+ let(:session) { instance_double(Tastytrade::Session) }
7
+ let(:account) { instance_double(Tastytrade::Models::Account, account_number: "TEST123") }
8
+ let(:order) { instance_double(Tastytrade::Order) }
9
+ let(:validator) { described_class.new(session, account, order) }
10
+
11
+ describe "#validate!" do
12
+ let(:trading_status) { instance_double(Tastytrade::Models::TradingStatus) }
13
+ let(:leg) do
14
+ instance_double(
15
+ Tastytrade::OrderLeg,
16
+ symbol: "AAPL",
17
+ quantity: 100,
18
+ action: Tastytrade::OrderAction::BUY_TO_OPEN,
19
+ instrument_type: "Equity"
20
+ )
21
+ end
22
+
23
+ before do
24
+ allow(order).to receive(:legs).and_return([leg])
25
+ allow(order).to receive(:type).and_return(Tastytrade::OrderType::LIMIT)
26
+ allow(order).to receive(:limit?).and_return(true)
27
+ allow(order).to receive(:market?).and_return(false)
28
+ allow(order).to receive(:price).and_return(BigDecimal("150.00"))
29
+ allow(order).to receive(:time_in_force).and_return(Tastytrade::OrderTimeInForce::DAY)
30
+ allow(account).to receive(:get_trading_status).and_return(trading_status)
31
+ allow(trading_status).to receive(:restricted?).and_return(false)
32
+ allow(trading_status).to receive(:is_closing_only).and_return(false)
33
+ end
34
+
35
+ context "with valid order" do
36
+ before do
37
+ allow(Tastytrade::Instruments::Equity).to receive(:get).and_return(
38
+ instance_double(Tastytrade::Instruments::Equity, symbol: "AAPL")
39
+ )
40
+ end
41
+
42
+ it "passes validation" do
43
+ expect { validator.validate!(skip_dry_run: true) }.not_to raise_error
44
+ end
45
+
46
+ it "returns true" do
47
+ expect(validator.validate!(skip_dry_run: true)).to be true
48
+ end
49
+ end
50
+
51
+ context "with invalid symbol" do
52
+ before do
53
+ allow(Tastytrade::Instruments::Equity).to receive(:get)
54
+ .and_raise(StandardError, "Symbol not found")
55
+ end
56
+
57
+ it "raises OrderValidationError" do
58
+ expect { validator.validate!(skip_dry_run: true) }
59
+ .to raise_error(Tastytrade::OrderValidationError, /Invalid equity symbol/)
60
+ end
61
+ end
62
+
63
+ context "with invalid quantity" do
64
+ context "when quantity is zero" do
65
+ before do
66
+ allow(leg).to receive(:quantity).and_return(0)
67
+ allow(Tastytrade::Instruments::Equity).to receive(:get).and_return(
68
+ instance_double(Tastytrade::Instruments::Equity, symbol: "AAPL")
69
+ )
70
+ end
71
+
72
+ it "raises OrderValidationError" do
73
+ expect { validator.validate!(skip_dry_run: true) }
74
+ .to raise_error(Tastytrade::OrderValidationError, /must be at least 1/)
75
+ end
76
+ end
77
+
78
+ context "when quantity exceeds maximum" do
79
+ before do
80
+ allow(leg).to receive(:quantity).and_return(1_000_000)
81
+ allow(Tastytrade::Instruments::Equity).to receive(:get).and_return(
82
+ instance_double(Tastytrade::Instruments::Equity, symbol: "AAPL")
83
+ )
84
+ end
85
+
86
+ it "raises OrderValidationError" do
87
+ expect { validator.validate!(skip_dry_run: true) }
88
+ .to raise_error(Tastytrade::OrderValidationError, /exceeds maximum/)
89
+ end
90
+ end
91
+ end
92
+
93
+ context "with invalid price" do
94
+ context "when price is zero" do
95
+ before do
96
+ allow(order).to receive(:price).and_return(BigDecimal("0"))
97
+ allow(Tastytrade::Instruments::Equity).to receive(:get).and_return(
98
+ instance_double(Tastytrade::Instruments::Equity, symbol: "AAPL")
99
+ )
100
+ end
101
+
102
+ it "raises OrderValidationError" do
103
+ expect { validator.validate!(skip_dry_run: true) }
104
+ .to raise_error(Tastytrade::OrderValidationError, /Price must be greater than 0/)
105
+ end
106
+ end
107
+
108
+ context "when price is negative" do
109
+ before do
110
+ allow(order).to receive(:price).and_return(BigDecimal("-10"))
111
+ allow(Tastytrade::Instruments::Equity).to receive(:get).and_return(
112
+ instance_double(Tastytrade::Instruments::Equity, symbol: "AAPL")
113
+ )
114
+ end
115
+
116
+ it "raises OrderValidationError" do
117
+ expect { validator.validate!(skip_dry_run: true) }
118
+ .to raise_error(Tastytrade::OrderValidationError, /Price must be greater than 0/)
119
+ end
120
+ end
121
+ end
122
+
123
+ context "with account restrictions" do
124
+ before do
125
+ allow(Tastytrade::Instruments::Equity).to receive(:get).and_return(
126
+ instance_double(Tastytrade::Instruments::Equity, symbol: "AAPL")
127
+ )
128
+ end
129
+
130
+ context "when account is restricted" do
131
+ before do
132
+ allow(trading_status).to receive(:restricted?).and_return(true)
133
+ allow(trading_status).to receive(:active_restrictions)
134
+ .and_return(["Account Frozen", "Margin Call"])
135
+ end
136
+
137
+ it "raises OrderValidationError with restrictions" do
138
+ expect { validator.validate!(skip_dry_run: true) }
139
+ .to raise_error(Tastytrade::OrderValidationError, /Account has active restrictions/)
140
+ end
141
+ end
142
+
143
+ context "when account is closing only" do
144
+ before do
145
+ allow(trading_status).to receive(:is_closing_only).and_return(true)
146
+ end
147
+
148
+ it "raises OrderValidationError for opening orders" do
149
+ expect { validator.validate!(skip_dry_run: true) }
150
+ .to raise_error(Tastytrade::OrderValidationError, /restricted to closing orders only/)
151
+ end
152
+ end
153
+ end
154
+
155
+ context "with options order" do
156
+ let(:option_leg) do
157
+ instance_double(
158
+ Tastytrade::OrderLeg,
159
+ symbol: "AAPL 240119C150",
160
+ quantity: 1,
161
+ action: Tastytrade::OrderAction::BUY_TO_OPEN,
162
+ instrument_type: "Option"
163
+ )
164
+ end
165
+
166
+ before do
167
+ allow(order).to receive(:legs).and_return([option_leg])
168
+ end
169
+
170
+ context "when account lacks options permissions" do
171
+ before do
172
+ allow(trading_status).to receive(:can_trade_options?).and_return(false)
173
+ end
174
+
175
+ it "raises OrderValidationError" do
176
+ expect { validator.validate!(skip_dry_run: true) }
177
+ .to raise_error(Tastytrade::OrderValidationError, /does not have options trading permissions/)
178
+ end
179
+ end
180
+
181
+ context "when account has options permissions" do
182
+ before do
183
+ allow(trading_status).to receive(:can_trade_options?).and_return(true)
184
+ end
185
+
186
+ it "adds warning about option validation not implemented" do
187
+ validator.validate!(skip_dry_run: true)
188
+ expect(validator.warnings).to include(/Option symbol validation not yet implemented/)
189
+ end
190
+ end
191
+ end
192
+ end
193
+
194
+ describe "#dry_run_validate!" do
195
+ let(:dry_run_response) { instance_double(Tastytrade::Models::OrderResponse) }
196
+ let(:buying_power_effect) { instance_double(Tastytrade::Models::BuyingPowerEffect) }
197
+
198
+ before do
199
+ allow(account).to receive(:place_order).and_return(dry_run_response)
200
+ allow(dry_run_response).to receive(:errors).and_return([])
201
+ allow(dry_run_response).to receive(:warnings).and_return([])
202
+ allow(dry_run_response).to receive(:buying_power_effect).and_return(buying_power_effect)
203
+ end
204
+
205
+ context "when dry-run succeeds" do
206
+ before do
207
+ allow(buying_power_effect).to receive(:new_buying_power).and_return(BigDecimal("1000"))
208
+ allow(buying_power_effect).to receive(:current_buying_power).and_return(BigDecimal("2000"))
209
+ allow(buying_power_effect).to receive(:buying_power_change_amount).and_return(BigDecimal("100"))
210
+ allow(buying_power_effect).to receive(:buying_power_usage_percentage).and_return(BigDecimal("5"))
211
+ allow(buying_power_effect).to receive(:change_in_margin_requirement).and_return(nil)
212
+ end
213
+
214
+ it "returns the dry-run response" do
215
+ expect(validator.dry_run_validate!).to eq(dry_run_response)
216
+ end
217
+
218
+ it "does not add errors" do
219
+ validator.dry_run_validate!
220
+ expect(validator.errors).to be_empty
221
+ end
222
+ end
223
+
224
+ context "when dry-run returns errors" do
225
+ let(:api_errors) do
226
+ [
227
+ { "domain" => "order", "reason" => "Invalid symbol" },
228
+ { "domain" => "account", "reason" => "Insufficient funds" }
229
+ ]
230
+ end
231
+
232
+ before do
233
+ allow(dry_run_response).to receive(:errors).and_return(api_errors)
234
+ allow(buying_power_effect).to receive(:new_buying_power).and_return(BigDecimal("1000"))
235
+ allow(buying_power_effect).to receive(:current_buying_power).and_return(BigDecimal("2000"))
236
+ allow(buying_power_effect).to receive(:buying_power_change_amount).and_return(BigDecimal("100"))
237
+ allow(buying_power_effect).to receive(:buying_power_usage_percentage).and_return(BigDecimal("5"))
238
+ allow(buying_power_effect).to receive(:change_in_margin_requirement).and_return(nil)
239
+ end
240
+
241
+ it "formats and adds errors" do
242
+ validator.dry_run_validate!
243
+ expect(validator.errors).to include("order: Invalid symbol")
244
+ expect(validator.errors).to include("account: Insufficient funds")
245
+ end
246
+ end
247
+
248
+ context "when dry-run returns warnings" do
249
+ let(:warnings) { ["Order may be rejected during market hours", "Price outside NBBO"] }
250
+
251
+ before do
252
+ allow(dry_run_response).to receive(:warnings).and_return(warnings)
253
+ allow(buying_power_effect).to receive(:new_buying_power).and_return(BigDecimal("1000"))
254
+ allow(buying_power_effect).to receive(:current_buying_power).and_return(BigDecimal("2000"))
255
+ allow(buying_power_effect).to receive(:buying_power_change_amount).and_return(BigDecimal("100"))
256
+ allow(buying_power_effect).to receive(:buying_power_usage_percentage).and_return(BigDecimal("5"))
257
+ allow(buying_power_effect).to receive(:change_in_margin_requirement).and_return(nil)
258
+ end
259
+
260
+ it "adds warnings" do
261
+ validator.dry_run_validate!
262
+ expect(validator.warnings).to include("Order may be rejected during market hours")
263
+ expect(validator.warnings).to include("Price outside NBBO")
264
+ end
265
+ end
266
+
267
+ context "with insufficient buying power" do
268
+ before do
269
+ allow(buying_power_effect).to receive(:new_buying_power).and_return(BigDecimal("-100"))
270
+ allow(buying_power_effect).to receive(:current_buying_power).and_return(BigDecimal("1000"))
271
+ allow(buying_power_effect).to receive(:buying_power_change_amount).and_return(BigDecimal("1100"))
272
+ allow(buying_power_effect).to receive(:buying_power_usage_percentage).and_return(BigDecimal("110"))
273
+ allow(buying_power_effect).to receive(:change_in_margin_requirement).and_return(nil)
274
+ end
275
+
276
+ it "adds insufficient buying power error" do
277
+ validator.dry_run_validate!
278
+ expect(validator.errors.first).to match(/Insufficient buying power/)
279
+ end
280
+ end
281
+
282
+ context "with high buying power usage" do
283
+ before do
284
+ allow(buying_power_effect).to receive(:new_buying_power).and_return(BigDecimal("400"))
285
+ allow(buying_power_effect).to receive(:current_buying_power).and_return(BigDecimal("1000"))
286
+ allow(buying_power_effect).to receive(:buying_power_usage_percentage).and_return(BigDecimal("60"))
287
+ allow(buying_power_effect).to receive(:change_in_margin_requirement).and_return(nil)
288
+ end
289
+
290
+ it "adds warning about high buying power usage" do
291
+ validator.dry_run_validate!
292
+ expect(validator.warnings.first).to match(/60\.0% of available buying power/)
293
+ end
294
+ end
295
+ end
296
+
297
+ describe "#round_to_tick_size" do
298
+ it "rounds to penny increments" do
299
+ expect(validator.send(:round_to_tick_size, BigDecimal("10.123"))).to eq(BigDecimal("10.12"))
300
+ expect(validator.send(:round_to_tick_size, BigDecimal("10.126"))).to eq(BigDecimal("10.13"))
301
+ expect(validator.send(:round_to_tick_size, BigDecimal("10.125"))).to eq(BigDecimal("10.13"))
302
+ end
303
+
304
+ it "handles nil values" do
305
+ expect(validator.send(:round_to_tick_size, nil)).to be_nil
306
+ end
307
+ end
308
+
309
+ describe "#regular_market_hours?" do
310
+ it "returns true during market hours" do
311
+ market_time = Time.parse("2024-01-10 10:30:00")
312
+ expect(validator.send(:regular_market_hours?, market_time)).to be true
313
+
314
+ market_time = Time.parse("2024-01-10 15:30:00")
315
+ expect(validator.send(:regular_market_hours?, market_time)).to be true
316
+ end
317
+
318
+ it "returns false outside market hours" do
319
+ pre_market = Time.parse("2024-01-10 08:00:00")
320
+ expect(validator.send(:regular_market_hours?, pre_market)).to be false
321
+
322
+ after_hours = Time.parse("2024-01-10 17:00:00")
323
+ expect(validator.send(:regular_market_hours?, after_hours)).to be false
324
+
325
+ early_morning = Time.parse("2024-01-10 09:15:00")
326
+ expect(validator.send(:regular_market_hours?, early_morning)).to be false
327
+ end
328
+ end
329
+
330
+ describe "#weekend?" do
331
+ it "returns true on weekends" do
332
+ saturday = Time.parse("2024-01-13 12:00:00")
333
+ expect(validator.send(:weekend?, saturday)).to be true
334
+
335
+ sunday = Time.parse("2024-01-14 12:00:00")
336
+ expect(validator.send(:weekend?, sunday)).to be true
337
+ end
338
+
339
+ it "returns false on weekdays" do
340
+ monday = Time.parse("2024-01-15 12:00:00")
341
+ expect(validator.send(:weekend?, monday)).to be false
342
+
343
+ friday = Time.parse("2024-01-12 12:00:00")
344
+ expect(validator.send(:weekend?, friday)).to be false
345
+ end
346
+ end
347
+ end