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.
- checksums.yaml +4 -4
- data/.claude/commands/plan.md +13 -0
- data/.claude/commands/release-pr.md +12 -0
- data/CHANGELOG.md +180 -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/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
- metadata +34 -18
- data/lib/tastytrade/keyring_store.rb +0 -72
- 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
|