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,151 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "date"
|
4
|
+
require "time"
|
5
|
+
require "bigdecimal"
|
6
|
+
|
7
|
+
module Tastytrade
|
8
|
+
module Models
|
9
|
+
# Represents a transaction in a Tastytrade account
|
10
|
+
class Transaction < Base
|
11
|
+
attr_reader :id, :account_number, :symbol, :instrument_type, :underlying_symbol,
|
12
|
+
:transaction_type, :transaction_sub_type, :description, :action,
|
13
|
+
:quantity, :price, :executed_at, :transaction_date, :value,
|
14
|
+
:value_effect, :net_value, :net_value_effect, :is_estimated_fee,
|
15
|
+
:commission, :clearing_fees, :regulatory_fees, :proprietary_index_option_fees,
|
16
|
+
:order_id, :value_date, :reverses_id, :is_verified
|
17
|
+
|
18
|
+
TRANSACTION_TYPES = %w[
|
19
|
+
ACAT Assignment Balance\ Adjustment Cash\ Disbursement Cash\ Merger
|
20
|
+
Cash\ Settled\ Assignment Cash\ Settled\ Exercise Credit\ Interest
|
21
|
+
Debit\ Interest Deposit Dividend Exercise Expiration Fee Forward\ Split
|
22
|
+
Futures\ Settlement Journal\ Entry Mark\ to\ Market Maturity
|
23
|
+
Merger\ Acquisition Money\ Movement Name\ Change
|
24
|
+
Paid\ Premium\ Lending\ Income Receive\ Deliver Reverse\ Split
|
25
|
+
Special\ Dividend Stock\ Dividend Stock\ Loan\ Income Stock\ Merger
|
26
|
+
Symbol\ Change Transfer Withdrawal
|
27
|
+
].freeze
|
28
|
+
|
29
|
+
INSTRUMENT_TYPES = %w[
|
30
|
+
Bond Cryptocurrency Equity Equity\ Offering Equity\ Option Future
|
31
|
+
Future\ Option Index Unknown Warrant
|
32
|
+
].freeze
|
33
|
+
|
34
|
+
# Fetch transaction history for an account
|
35
|
+
# @param session [Tastytrade::Session] Active session
|
36
|
+
# @param account_number [String] Account number
|
37
|
+
# @param options [Hash] Optional filters
|
38
|
+
# @option options [Date, String] :start_date Start date for transactions
|
39
|
+
# @option options [Date, String] :end_date End date for transactions
|
40
|
+
# @option options [String] :symbol Filter by symbol
|
41
|
+
# @option options [String] :underlying_symbol Filter by underlying symbol
|
42
|
+
# @option options [String] :instrument_type Filter by instrument type
|
43
|
+
# @option options [Array<String>] :transaction_types Filter by transaction types
|
44
|
+
# @option options [Integer] :per_page Number of results per page (default: 250)
|
45
|
+
# @option options [Integer] :page_offset Page offset for pagination
|
46
|
+
# @return [Array<Transaction>] Array of transactions
|
47
|
+
def self.get_all(session, account_number, **options)
|
48
|
+
params = build_params(options)
|
49
|
+
transactions = []
|
50
|
+
page_offset = options[:page_offset] || 0
|
51
|
+
|
52
|
+
loop do
|
53
|
+
current_params = params.dup
|
54
|
+
current_params["page-offset"] = page_offset unless page_offset.zero?
|
55
|
+
response = session.get("/accounts/#{account_number}/transactions", current_params)
|
56
|
+
|
57
|
+
items = response.dig("data", "items") || []
|
58
|
+
break if items.empty?
|
59
|
+
|
60
|
+
transactions.concat(items.map { |item| new(item) })
|
61
|
+
|
62
|
+
# Break if we've reached the requested limit or if pagination is manual
|
63
|
+
break if options[:page_offset] || transactions.size >= (options[:per_page] || 250)
|
64
|
+
|
65
|
+
page_offset += 1
|
66
|
+
end
|
67
|
+
|
68
|
+
transactions
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def parse_attributes
|
74
|
+
parse_data(@data)
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.build_params(options)
|
78
|
+
{}.tap do |params|
|
79
|
+
params["start-date"] = format_date(options[:start_date]) if options[:start_date]
|
80
|
+
params["end-date"] = format_date(options[:end_date]) if options[:end_date]
|
81
|
+
params["symbol"] = options[:symbol] if options[:symbol]
|
82
|
+
params["underlying-symbol"] = options[:underlying_symbol] if options[:underlying_symbol]
|
83
|
+
params["instrument-type"] = options[:instrument_type] if options[:instrument_type]
|
84
|
+
params["type[]"] = Array(options[:transaction_types]) if options[:transaction_types]
|
85
|
+
params["per-page"] = options[:per_page] if options[:per_page]
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def self.format_date(date)
|
90
|
+
case date
|
91
|
+
when String
|
92
|
+
date
|
93
|
+
when Date, DateTime, Time
|
94
|
+
date.strftime("%Y-%m-%d")
|
95
|
+
else
|
96
|
+
date.to_s
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def parse_data(data)
|
101
|
+
@id = data["id"]
|
102
|
+
@account_number = data["account-number"]
|
103
|
+
@symbol = data["symbol"]
|
104
|
+
@instrument_type = data["instrument-type"]
|
105
|
+
@underlying_symbol = data["underlying-symbol"]
|
106
|
+
@transaction_type = data["transaction-type"]
|
107
|
+
@transaction_sub_type = data["transaction-sub-type"]
|
108
|
+
@description = data["description"]
|
109
|
+
@action = data["action"]
|
110
|
+
@quantity = parse_decimal(data["quantity"])
|
111
|
+
@price = parse_decimal(data["price"])
|
112
|
+
@executed_at = parse_datetime(data["executed-at"])
|
113
|
+
@transaction_date = parse_date(data["transaction-date"])
|
114
|
+
@value = parse_decimal(data["value"])
|
115
|
+
@value_effect = data["value-effect"]
|
116
|
+
@net_value = parse_decimal(data["net-value"])
|
117
|
+
@net_value_effect = data["net-value-effect"]
|
118
|
+
@is_estimated_fee = data["is-estimated-fee"]
|
119
|
+
@commission = parse_decimal(data["commission"])
|
120
|
+
@clearing_fees = parse_decimal(data["clearing-fees"])
|
121
|
+
@regulatory_fees = parse_decimal(data["regulatory-fees"])
|
122
|
+
@proprietary_index_option_fees = parse_decimal(data["proprietary-index-option-fees"])
|
123
|
+
@order_id = data["order-id"]
|
124
|
+
@value_date = parse_date(data["value-date"])
|
125
|
+
@reverses_id = data["reverses-id"]
|
126
|
+
@is_verified = data["is-verified"]
|
127
|
+
end
|
128
|
+
|
129
|
+
def parse_decimal(value)
|
130
|
+
return nil if value.nil? || value.to_s.empty?
|
131
|
+
BigDecimal(value.to_s)
|
132
|
+
rescue ArgumentError
|
133
|
+
nil
|
134
|
+
end
|
135
|
+
|
136
|
+
def parse_datetime(value)
|
137
|
+
return nil if value.nil? || value.to_s.empty?
|
138
|
+
Time.parse(value.to_s)
|
139
|
+
rescue ArgumentError
|
140
|
+
nil
|
141
|
+
end
|
142
|
+
|
143
|
+
def parse_date(value)
|
144
|
+
return nil if value.nil? || value.to_s.empty?
|
145
|
+
Date.parse(value.to_s)
|
146
|
+
rescue ArgumentError
|
147
|
+
nil
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
data/lib/tastytrade/models.rb
CHANGED
@@ -5,3 +5,9 @@ require_relative "models/user"
|
|
5
5
|
require_relative "models/account"
|
6
6
|
require_relative "models/account_balance"
|
7
7
|
require_relative "models/current_position"
|
8
|
+
require_relative "models/order_response"
|
9
|
+
require_relative "models/live_order"
|
10
|
+
require_relative "models/order_status"
|
11
|
+
require_relative "models/transaction"
|
12
|
+
require_relative "models/buying_power_effect"
|
13
|
+
require_relative "models/trading_status"
|
@@ -0,0 +1,191 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bigdecimal"
|
4
|
+
|
5
|
+
module Tastytrade
|
6
|
+
# Order action constants
|
7
|
+
module OrderAction
|
8
|
+
BUY_TO_OPEN = "Buy to Open"
|
9
|
+
SELL_TO_CLOSE = "Sell to Close"
|
10
|
+
SELL_TO_OPEN = "Sell to Open"
|
11
|
+
BUY_TO_CLOSE = "Buy to Close"
|
12
|
+
end
|
13
|
+
|
14
|
+
# Order type constants
|
15
|
+
module OrderType
|
16
|
+
MARKET = "Market"
|
17
|
+
LIMIT = "Limit"
|
18
|
+
STOP = "Stop"
|
19
|
+
end
|
20
|
+
|
21
|
+
# Order time in force constants
|
22
|
+
module OrderTimeInForce
|
23
|
+
DAY = "Day"
|
24
|
+
GTC = "GTC"
|
25
|
+
end
|
26
|
+
|
27
|
+
# Price effect constants
|
28
|
+
module PriceEffect
|
29
|
+
DEBIT = "Debit"
|
30
|
+
CREDIT = "Credit"
|
31
|
+
end
|
32
|
+
|
33
|
+
# Represents a single leg of an order
|
34
|
+
class OrderLeg
|
35
|
+
attr_reader :action, :symbol, :quantity, :instrument_type
|
36
|
+
|
37
|
+
def initialize(action:, symbol:, quantity:, instrument_type: "Equity")
|
38
|
+
validate_action!(action)
|
39
|
+
|
40
|
+
@action = action
|
41
|
+
@symbol = symbol
|
42
|
+
@quantity = quantity.to_i
|
43
|
+
@instrument_type = instrument_type
|
44
|
+
end
|
45
|
+
|
46
|
+
def to_api_params
|
47
|
+
{
|
48
|
+
"action" => @action,
|
49
|
+
"symbol" => @symbol,
|
50
|
+
"quantity" => @quantity,
|
51
|
+
"instrument-type" => @instrument_type
|
52
|
+
}
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def validate_action!(action)
|
58
|
+
valid_actions = [
|
59
|
+
OrderAction::BUY_TO_OPEN,
|
60
|
+
OrderAction::SELL_TO_CLOSE,
|
61
|
+
OrderAction::SELL_TO_OPEN,
|
62
|
+
OrderAction::BUY_TO_CLOSE
|
63
|
+
]
|
64
|
+
|
65
|
+
unless valid_actions.include?(action)
|
66
|
+
raise ArgumentError, "Invalid action: #{action}. Must be one of: #{valid_actions.join(", ")}"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# Represents an order to be placed
|
72
|
+
class Order
|
73
|
+
attr_reader :type, :time_in_force, :legs, :price
|
74
|
+
|
75
|
+
def initialize(type:, time_in_force: OrderTimeInForce::DAY, legs:, price: nil)
|
76
|
+
validate_type!(type)
|
77
|
+
validate_time_in_force!(time_in_force)
|
78
|
+
validate_price!(type, price)
|
79
|
+
|
80
|
+
@type = type
|
81
|
+
@time_in_force = time_in_force
|
82
|
+
@legs = Array(legs)
|
83
|
+
@price = price ? BigDecimal(price.to_s) : nil
|
84
|
+
end
|
85
|
+
|
86
|
+
def market?
|
87
|
+
@type == OrderType::MARKET
|
88
|
+
end
|
89
|
+
|
90
|
+
def limit?
|
91
|
+
@type == OrderType::LIMIT
|
92
|
+
end
|
93
|
+
|
94
|
+
def stop?
|
95
|
+
@type == OrderType::STOP
|
96
|
+
end
|
97
|
+
|
98
|
+
# Validates this order for a specific account using the OrderValidator.
|
99
|
+
# Performs comprehensive checks including symbol existence, quantity constraints,
|
100
|
+
# price validation, account permissions, and optionally buying power.
|
101
|
+
#
|
102
|
+
# @param session [Tastytrade::Session] Active session
|
103
|
+
# @param account [Tastytrade::Models::Account] Account to validate against
|
104
|
+
# @param skip_dry_run [Boolean] Skip dry-run validation for performance
|
105
|
+
# @return [Boolean] true if valid
|
106
|
+
# @raise [OrderValidationError] if validation fails with detailed error messages
|
107
|
+
#
|
108
|
+
# @example
|
109
|
+
# order.validate!(session, account) # Full validation including dry-run
|
110
|
+
# order.validate!(session, account, skip_dry_run: true) # Skip buying power check
|
111
|
+
def validate!(session, account, skip_dry_run: false)
|
112
|
+
validator = OrderValidator.new(session, account, self)
|
113
|
+
validator.validate!(skip_dry_run: skip_dry_run)
|
114
|
+
end
|
115
|
+
|
116
|
+
# Performs a dry-run validation of the order. This submits the order to the API
|
117
|
+
# in dry-run mode to check buying power, margin requirements, and get warnings
|
118
|
+
# without actually placing the order.
|
119
|
+
#
|
120
|
+
# @param session [Tastytrade::Session] Active session
|
121
|
+
# @param account [Tastytrade::Models::Account] Account to validate against
|
122
|
+
# @return [Tastytrade::Models::OrderResponse] Dry-run response with buying power effect
|
123
|
+
#
|
124
|
+
# @example
|
125
|
+
# response = order.dry_run(session, account)
|
126
|
+
# puts response.buying_power_effect
|
127
|
+
# puts response.warnings
|
128
|
+
def dry_run(session, account)
|
129
|
+
account.place_order(session, self, dry_run: true)
|
130
|
+
end
|
131
|
+
|
132
|
+
def to_api_params
|
133
|
+
params = {
|
134
|
+
"order-type" => @type,
|
135
|
+
"time-in-force" => @time_in_force,
|
136
|
+
"legs" => @legs.map(&:to_api_params)
|
137
|
+
}
|
138
|
+
|
139
|
+
# Add price for limit orders
|
140
|
+
# API expects string representation without negative sign
|
141
|
+
if limit? && @price
|
142
|
+
params["price"] = @price.to_s("F")
|
143
|
+
params["price-effect"] = determine_price_effect
|
144
|
+
end
|
145
|
+
|
146
|
+
params
|
147
|
+
end
|
148
|
+
|
149
|
+
private
|
150
|
+
|
151
|
+
def determine_price_effect
|
152
|
+
# Determine price effect based on the first leg's action
|
153
|
+
# Buy actions result in debit, sell actions result in credit
|
154
|
+
first_leg = @legs.first
|
155
|
+
return PriceEffect::DEBIT unless first_leg
|
156
|
+
|
157
|
+
case first_leg.action
|
158
|
+
when OrderAction::BUY_TO_OPEN, OrderAction::BUY_TO_CLOSE
|
159
|
+
PriceEffect::DEBIT
|
160
|
+
when OrderAction::SELL_TO_OPEN, OrderAction::SELL_TO_CLOSE
|
161
|
+
PriceEffect::CREDIT
|
162
|
+
else
|
163
|
+
PriceEffect::DEBIT # Default to debit
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def validate_type!(type)
|
168
|
+
valid_types = [OrderType::MARKET, OrderType::LIMIT, OrderType::STOP]
|
169
|
+
unless valid_types.include?(type)
|
170
|
+
raise ArgumentError, "Invalid order type: #{type}. Must be one of: #{valid_types.join(", ")}"
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
def validate_time_in_force!(time_in_force)
|
175
|
+
valid_tifs = [OrderTimeInForce::DAY, OrderTimeInForce::GTC]
|
176
|
+
unless valid_tifs.include?(time_in_force)
|
177
|
+
raise ArgumentError, "Invalid time in force: #{time_in_force}. Must be one of: #{valid_tifs.join(", ")}"
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def validate_price!(type, price)
|
182
|
+
if type == OrderType::LIMIT && price.nil?
|
183
|
+
raise ArgumentError, "Price is required for limit orders"
|
184
|
+
end
|
185
|
+
|
186
|
+
if price && price.to_f <= 0
|
187
|
+
raise ArgumentError, "Price must be greater than 0"
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,355 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bigdecimal"
|
4
|
+
require "time"
|
5
|
+
|
6
|
+
module Tastytrade
|
7
|
+
# Validates orders before submission to ensure they meet all requirements.
|
8
|
+
# Performs comprehensive checks including symbol validation, quantity constraints,
|
9
|
+
# price validation, account permissions, buying power, and market hours.
|
10
|
+
#
|
11
|
+
# @example Basic usage
|
12
|
+
# validator = OrderValidator.new(session, account, order)
|
13
|
+
# validator.validate! # Raises OrderValidationError if invalid
|
14
|
+
#
|
15
|
+
# @example With dry-run validation
|
16
|
+
# validator = OrderValidator.new(session, account, order)
|
17
|
+
# response = validator.dry_run_validate!
|
18
|
+
# puts validator.warnings if validator.warnings.any?
|
19
|
+
class OrderValidator
|
20
|
+
# @return [Array<String>] List of validation errors
|
21
|
+
attr_reader :errors
|
22
|
+
|
23
|
+
# @return [Array<String>] List of validation warnings
|
24
|
+
attr_reader :warnings
|
25
|
+
|
26
|
+
# Common tick sizes for different price ranges
|
27
|
+
TICK_SIZES = {
|
28
|
+
penny: BigDecimal("0.01"),
|
29
|
+
nickel: BigDecimal("0.05"),
|
30
|
+
dime: BigDecimal("0.10")
|
31
|
+
}.freeze
|
32
|
+
|
33
|
+
# Minimum quantity constraints
|
34
|
+
MIN_QUANTITY = 1
|
35
|
+
MAX_QUANTITY = 999_999
|
36
|
+
|
37
|
+
# Creates a new OrderValidator instance
|
38
|
+
#
|
39
|
+
# @param session [Tastytrade::Session] Active trading session
|
40
|
+
# @param account [Tastytrade::Models::Account] Account to validate against
|
41
|
+
# @param order [Tastytrade::Order] Order to validate
|
42
|
+
def initialize(session, account, order)
|
43
|
+
@session = session
|
44
|
+
@account = account
|
45
|
+
@order = order
|
46
|
+
@errors = []
|
47
|
+
@warnings = []
|
48
|
+
@trading_status = nil
|
49
|
+
@dry_run_response = nil
|
50
|
+
end
|
51
|
+
|
52
|
+
# Performs comprehensive order validation including structure, symbols,
|
53
|
+
# quantities, prices, account permissions, market hours, and optionally
|
54
|
+
# buying power via dry-run.
|
55
|
+
#
|
56
|
+
# @param skip_dry_run [Boolean] Skip the dry-run validation (for performance)
|
57
|
+
# @return [Boolean] true if validation passes
|
58
|
+
# @raise [OrderValidationError] if validation fails with detailed error messages
|
59
|
+
def validate!(skip_dry_run: false)
|
60
|
+
# Reset errors and warnings
|
61
|
+
@errors = []
|
62
|
+
@warnings = []
|
63
|
+
|
64
|
+
# Run all validations
|
65
|
+
validate_order_structure!
|
66
|
+
validate_symbols!
|
67
|
+
validate_quantities!
|
68
|
+
validate_prices!
|
69
|
+
validate_account_permissions!
|
70
|
+
validate_market_hours!
|
71
|
+
validate_buying_power! unless skip_dry_run
|
72
|
+
|
73
|
+
# Raise error if any validation failed
|
74
|
+
raise OrderValidationError, @errors if @errors.any?
|
75
|
+
|
76
|
+
true
|
77
|
+
end
|
78
|
+
|
79
|
+
# Performs pre-flight validation via dry-run API call. This checks buying power,
|
80
|
+
# margin requirements, and API-level validation rules without placing the order.
|
81
|
+
#
|
82
|
+
# @return [Tastytrade::Models::OrderResponse, nil] Dry-run response if successful, nil if failed
|
83
|
+
def dry_run_validate!
|
84
|
+
@dry_run_response = @account.place_order(@session, @order, dry_run: true)
|
85
|
+
|
86
|
+
# Check for API-level errors
|
87
|
+
if @dry_run_response.errors.any?
|
88
|
+
@errors.concat(@dry_run_response.errors.map { |e| format_api_error(e) })
|
89
|
+
end
|
90
|
+
|
91
|
+
# Check for warnings
|
92
|
+
if @dry_run_response.warnings.any?
|
93
|
+
@warnings.concat(@dry_run_response.warnings)
|
94
|
+
end
|
95
|
+
|
96
|
+
# Check buying power effect
|
97
|
+
if @dry_run_response.buying_power_effect
|
98
|
+
validate_buying_power_effect!(@dry_run_response.buying_power_effect)
|
99
|
+
end
|
100
|
+
|
101
|
+
@dry_run_response
|
102
|
+
rescue StandardError => e
|
103
|
+
@errors << "Dry-run validation failed: #{e.message}"
|
104
|
+
nil
|
105
|
+
end
|
106
|
+
|
107
|
+
private
|
108
|
+
|
109
|
+
# Validate basic order structure
|
110
|
+
def validate_order_structure!
|
111
|
+
# Check for at least one leg
|
112
|
+
if @order.legs.nil? || @order.legs.empty?
|
113
|
+
@errors << "Order must have at least one leg"
|
114
|
+
end
|
115
|
+
|
116
|
+
# Validate order type and price consistency
|
117
|
+
if @order.limit? && @order.price.nil?
|
118
|
+
@errors << "Limit orders require a price"
|
119
|
+
end
|
120
|
+
|
121
|
+
# Validate time in force
|
122
|
+
if @order.time_in_force.nil?
|
123
|
+
@errors << "Time in force is required"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# Validate symbols exist and are tradeable
|
128
|
+
def validate_symbols!
|
129
|
+
return if @order.legs.nil?
|
130
|
+
|
131
|
+
@order.legs.each do |leg|
|
132
|
+
validate_symbol!(leg.symbol, leg.instrument_type)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
# Validate a single symbol
|
137
|
+
def validate_symbol!(symbol, instrument_type)
|
138
|
+
return if symbol.nil? || symbol.empty?
|
139
|
+
|
140
|
+
case instrument_type
|
141
|
+
when "Equity"
|
142
|
+
validate_equity_symbol!(symbol)
|
143
|
+
when "Option"
|
144
|
+
# TODO: Implement option symbol validation
|
145
|
+
@warnings << "Option symbol validation not yet implemented for #{symbol}"
|
146
|
+
when "Future"
|
147
|
+
# TODO: Implement futures symbol validation
|
148
|
+
@warnings << "Futures symbol validation not yet implemented for #{symbol}"
|
149
|
+
else
|
150
|
+
@errors << "Unknown instrument type: #{instrument_type}"
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# Validate equity symbol exists
|
155
|
+
def validate_equity_symbol!(symbol)
|
156
|
+
# Try to fetch the equity to validate it exists
|
157
|
+
Instruments::Equity.get(@session, symbol)
|
158
|
+
rescue StandardError => e
|
159
|
+
@errors << "Invalid equity symbol '#{symbol}': #{e.message}"
|
160
|
+
end
|
161
|
+
|
162
|
+
# Validate order quantities
|
163
|
+
def validate_quantities!
|
164
|
+
return if @order.legs.nil?
|
165
|
+
|
166
|
+
@order.legs.each do |leg|
|
167
|
+
validate_quantity!(leg.quantity, leg.symbol)
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
# Validate a single quantity
|
172
|
+
def validate_quantity!(quantity, symbol)
|
173
|
+
# Check minimum quantity
|
174
|
+
if quantity.nil? || quantity < MIN_QUANTITY
|
175
|
+
@errors << "Quantity for #{symbol} must be at least #{MIN_QUANTITY}"
|
176
|
+
end
|
177
|
+
|
178
|
+
# Check maximum quantity
|
179
|
+
if quantity && quantity > MAX_QUANTITY
|
180
|
+
@errors << "Quantity for #{symbol} exceeds maximum of #{MAX_QUANTITY}"
|
181
|
+
end
|
182
|
+
|
183
|
+
# Check for whole shares (no fractional shares for now)
|
184
|
+
if quantity && quantity != quantity.to_i
|
185
|
+
@errors << "Fractional shares not supported for #{symbol}"
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
# Validate order prices
|
190
|
+
def validate_prices!
|
191
|
+
return unless @order.limit?
|
192
|
+
|
193
|
+
validate_price!(@order.price)
|
194
|
+
end
|
195
|
+
|
196
|
+
# Validate a single price
|
197
|
+
def validate_price!(price)
|
198
|
+
return if price.nil?
|
199
|
+
|
200
|
+
# Check for positive price
|
201
|
+
if price <= 0
|
202
|
+
@errors << "Price must be greater than 0"
|
203
|
+
end
|
204
|
+
|
205
|
+
# Check for reasonable price (not too high)
|
206
|
+
if price > BigDecimal("999999")
|
207
|
+
@errors << "Price exceeds reasonable limits"
|
208
|
+
end
|
209
|
+
|
210
|
+
# Round to appropriate tick size
|
211
|
+
rounded_price = round_to_tick_size(price)
|
212
|
+
if rounded_price != price
|
213
|
+
@warnings << "Price #{price} will be rounded to #{rounded_price}"
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
# Round price to appropriate tick size
|
218
|
+
def round_to_tick_size(price)
|
219
|
+
return price if price.nil?
|
220
|
+
|
221
|
+
# Simple tick size rules (can be enhanced based on instrument/exchange)
|
222
|
+
tick = if price < BigDecimal("1")
|
223
|
+
TICK_SIZES[:penny]
|
224
|
+
elsif price < BigDecimal("10")
|
225
|
+
TICK_SIZES[:penny]
|
226
|
+
else
|
227
|
+
TICK_SIZES[:penny]
|
228
|
+
end
|
229
|
+
|
230
|
+
(price / tick).round * tick
|
231
|
+
end
|
232
|
+
|
233
|
+
# Validate account permissions for the order
|
234
|
+
def validate_account_permissions!
|
235
|
+
@trading_status ||= @account.get_trading_status(@session)
|
236
|
+
|
237
|
+
# Check if account is restricted
|
238
|
+
if @trading_status.restricted?
|
239
|
+
restrictions = @trading_status.active_restrictions
|
240
|
+
@errors << "Account has active restrictions: #{restrictions.join(", ")}"
|
241
|
+
end
|
242
|
+
|
243
|
+
# Check specific permissions based on order type
|
244
|
+
@order.legs.each do |leg|
|
245
|
+
validate_leg_permissions!(leg)
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
# Validate permissions for a specific leg
|
250
|
+
def validate_leg_permissions!(leg)
|
251
|
+
@trading_status ||= @account.get_trading_status(@session)
|
252
|
+
|
253
|
+
case leg.instrument_type
|
254
|
+
when "Option"
|
255
|
+
unless @trading_status.can_trade_options?
|
256
|
+
@errors << "Account does not have options trading permissions"
|
257
|
+
end
|
258
|
+
when "Future"
|
259
|
+
unless @trading_status.can_trade_futures?
|
260
|
+
@errors << "Account does not have futures trading permissions"
|
261
|
+
end
|
262
|
+
when "Cryptocurrency"
|
263
|
+
unless @trading_status.can_trade_cryptocurrency?
|
264
|
+
@errors << "Account does not have cryptocurrency trading permissions"
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
# Check for closing-only restrictions
|
269
|
+
if opening_order?(leg.action) && @trading_status.is_closing_only
|
270
|
+
@errors << "Account is restricted to closing orders only"
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
# Check if this is an opening order
|
275
|
+
def opening_order?(action)
|
276
|
+
[OrderAction::BUY_TO_OPEN, OrderAction::SELL_TO_OPEN].include?(action)
|
277
|
+
end
|
278
|
+
|
279
|
+
# Validate market hours
|
280
|
+
def validate_market_hours!
|
281
|
+
# Get current time
|
282
|
+
now = Time.now
|
283
|
+
|
284
|
+
# Check if it's a market order during extended hours
|
285
|
+
if @order.market? && !regular_market_hours?(now)
|
286
|
+
@warnings << "Market orders may not be accepted outside regular trading hours"
|
287
|
+
end
|
288
|
+
|
289
|
+
# Basic weekday/weekend check
|
290
|
+
if weekend?(now)
|
291
|
+
@warnings << "Markets are closed on weekends"
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
# Check if current time is during regular market hours (9:30 AM - 4:00 PM ET)
|
296
|
+
def regular_market_hours?(time)
|
297
|
+
# Convert to Eastern Time (simplified - should use proper timezone library)
|
298
|
+
hour = time.hour
|
299
|
+
minute = time.min
|
300
|
+
|
301
|
+
# Rough check for market hours (9:30 AM - 4:00 PM ET)
|
302
|
+
# Note: This is simplified and doesn't account for timezones properly
|
303
|
+
return false if hour < 9 || hour >= 16
|
304
|
+
return false if hour == 9 && minute < 30
|
305
|
+
|
306
|
+
true
|
307
|
+
end
|
308
|
+
|
309
|
+
# Check if it's a weekend
|
310
|
+
def weekend?(time)
|
311
|
+
time.saturday? || time.sunday?
|
312
|
+
end
|
313
|
+
|
314
|
+
# Validate buying power via dry-run
|
315
|
+
def validate_buying_power!
|
316
|
+
# If we haven't run dry-run yet, do it now
|
317
|
+
@dry_run_response ||= dry_run_validate!
|
318
|
+
return if @dry_run_response.nil?
|
319
|
+
|
320
|
+
effect = @dry_run_response.buying_power_effect
|
321
|
+
return if effect.nil?
|
322
|
+
|
323
|
+
validate_buying_power_effect!(effect)
|
324
|
+
end
|
325
|
+
|
326
|
+
# Validate buying power effect from dry-run
|
327
|
+
def validate_buying_power_effect!(effect)
|
328
|
+
# Check if order would result in negative buying power
|
329
|
+
if effect.new_buying_power && effect.new_buying_power < 0
|
330
|
+
@errors << "Insufficient buying power. Order requires #{effect.buying_power_change_amount}, " \
|
331
|
+
"but only #{effect.current_buying_power} available"
|
332
|
+
end
|
333
|
+
|
334
|
+
# Warn if using significant portion of buying power
|
335
|
+
if effect.buying_power_usage_percentage > BigDecimal("50")
|
336
|
+
percentage = effect.buying_power_usage_percentage.to_f.round(1)
|
337
|
+
@warnings << "Order will use #{percentage}% of available buying power"
|
338
|
+
end
|
339
|
+
|
340
|
+
# Check for margin requirements
|
341
|
+
if effect.change_in_margin_requirement && effect.change_in_margin_requirement.abs > effect.current_buying_power
|
342
|
+
@errors << "Margin requirement of #{effect.change_in_margin_requirement.abs} exceeds available buying power"
|
343
|
+
end
|
344
|
+
end
|
345
|
+
|
346
|
+
# Format API error for display
|
347
|
+
def format_api_error(error)
|
348
|
+
if error.is_a?(Hash)
|
349
|
+
"#{error["domain"]}: #{error["reason"]}"
|
350
|
+
else
|
351
|
+
error.to_s
|
352
|
+
end
|
353
|
+
end
|
354
|
+
end
|
355
|
+
end
|