schwab_rb 0.3.11 → 0.4.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.
@@ -0,0 +1,239 @@
1
+ # Quick Start Guide
2
+
3
+ Get started with schwab_rb in 5 minutes.
4
+
5
+ ## 1. Install
6
+
7
+ ```bash
8
+ gem install schwab_rb
9
+ ```
10
+
11
+ Or add to your Gemfile:
12
+
13
+ ```ruby
14
+ gem 'schwab_rb'
15
+ ```
16
+
17
+ ## 2. Get Your Schwab API Credentials
18
+
19
+ 1. Go to [Schwab Developer Portal](https://developer.schwab.com/)
20
+ 2. Create an app to get:
21
+ - **API Key** (App Key)
22
+ - **App Secret** (Secret)
23
+ - **Callback URL** (use `https://127.0.0.1:8182` for local development)
24
+
25
+ ## 3. Set Up Environment Variables
26
+
27
+ Create a `.env` file:
28
+
29
+ ```bash
30
+ SCHWAB_API_KEY=your_api_key_here
31
+ SCHWAB_APP_SECRET=your_app_secret_here
32
+ SCHWAB_APP_CALLBACK_URL=https://127.0.0.1:8182
33
+ SCHWAB_TOKEN_PATH=~/.schwab_rb/token.json
34
+ ```
35
+
36
+ ## 4. Initialize Client (First Time)
37
+
38
+ ```ruby
39
+ require 'schwab_rb'
40
+ require 'dotenv/load'
41
+
42
+ # This will open your browser for authentication
43
+ client = SchwabRb::Auth.init_client_easy(
44
+ ENV['SCHWAB_API_KEY'],
45
+ ENV['SCHWAB_APP_SECRET'],
46
+ ENV['SCHWAB_APP_CALLBACK_URL'],
47
+ ENV['SCHWAB_TOKEN_PATH']
48
+ )
49
+
50
+ puts "✓ Authenticated successfully!"
51
+ ```
52
+
53
+ **What happens:**
54
+ - Browser opens to Schwab login
55
+ - You log in and authorize
56
+ - Token saved to `~/.schwab_rb/token.json`
57
+ - Client ready to use!
58
+
59
+ ## 5. Make Your First API Call
60
+
61
+ ```ruby
62
+ # Get your accounts
63
+ accounts = client.get_accounts
64
+
65
+ accounts.each do |account|
66
+ puts "Account: #{account.account_number}"
67
+ puts "Type: #{account.type}"
68
+ puts "Value: $#{account.balance.total_value}"
69
+ puts "---"
70
+ end
71
+ ```
72
+
73
+ ## 6. Set Up Account Names (Optional but Recommended)
74
+
75
+ Create `~/.schwab_rb/account_names.json`:
76
+
77
+ ```json
78
+ {
79
+ "my_trading": "12345678",
80
+ "my_ira": "87654321"
81
+ }
82
+ ```
83
+
84
+ Populate account hashes:
85
+
86
+ ```ruby
87
+ client.get_account_numbers
88
+ ```
89
+
90
+ Now use friendly names:
91
+
92
+ ```ruby
93
+ # Instead of:
94
+ # account = client.get_account("ABC123HASH")
95
+
96
+ # Do this:
97
+ account = client.get_account(account_name: "my_trading")
98
+ ```
99
+
100
+ ## Common Operations
101
+
102
+ ### Get Account Info
103
+
104
+ ```ruby
105
+ account = client.get_account(account_name: "my_trading")
106
+ puts "Buying Power: $#{account.balance.buying_power}"
107
+ ```
108
+
109
+ ### Get Quotes
110
+
111
+ ```ruby
112
+ quote = client.get_quote("AAPL")
113
+ puts "#{quote.symbol}: $#{quote.last_price}"
114
+ ```
115
+
116
+ ### Get Price History
117
+
118
+ ```ruby
119
+ history = client.get_price_history_every_day(
120
+ "AAPL",
121
+ start_datetime: DateTime.now - 30
122
+ )
123
+
124
+ puts "#{history.candles.size} days of data"
125
+ history.candles.last(5).each do |candle|
126
+ puts "#{candle.date}: $#{candle.close}"
127
+ end
128
+ ```
129
+
130
+ ### Place Order (Market Buy)
131
+
132
+ ```ruby
133
+ order = SchwabRb::Orders::Builder.new
134
+ .set_session(:normal)
135
+ .set_duration(:day)
136
+ .set_order_type(:market)
137
+ .add_equity_leg(:buy, 'AAPL', 10)
138
+ .build
139
+
140
+ response = client.place_order(order, account_name: "my_trading")
141
+ puts "Order placed!"
142
+ ```
143
+
144
+ ### Get Orders
145
+
146
+ ```ruby
147
+ orders = client.get_account_orders(account_name: "my_trading")
148
+
149
+ orders.each do |order|
150
+ puts "#{order.status}: #{order.order_leg_collection.first.quantity} shares of #{order.order_leg_collection.first.instrument.symbol}"
151
+ end
152
+ ```
153
+
154
+ ### Get Transactions
155
+
156
+ ```ruby
157
+ transactions = client.get_transactions(
158
+ account_name: "my_trading",
159
+ start_date: DateTime.now - 7
160
+ )
161
+
162
+ puts "#{transactions.size} transactions in the last week"
163
+ ```
164
+
165
+ ## Configuration (Optional)
166
+
167
+ ```ruby
168
+ SchwabRb.configure do |config|
169
+ # Logging
170
+ config.log_level = "INFO"
171
+ config.log_file = "schwab.log"
172
+
173
+ # Account management
174
+ config.schwab_home = "~/.schwab_rb"
175
+ config.account_names_path = "~/.schwab_rb/account_names.json"
176
+ config.account_hashes_path = "~/.schwab_rb/account_hashes.json"
177
+ end
178
+ ```
179
+
180
+ ## Next Steps
181
+
182
+ - **Trading**: See [examples/](../examples/) for order placement examples
183
+ - **Account Management**: Read [ACCOUNT_MANAGEMENT.md](./ACCOUNT_MANAGEMENT.md) for multi-account setup
184
+ - **API Reference**: Check the [main README](../README.md) for complete API documentation
185
+
186
+ ## Troubleshooting
187
+
188
+ **Authentication fails?**
189
+ - Check your API credentials
190
+ - Make sure callback URL matches exactly
191
+ - Try deleting `~/.schwab_rb/token.json` and re-authenticating
192
+
193
+ **Token expired?**
194
+ - The client automatically refreshes tokens
195
+ - If issues persist, re-authenticate with `init_client_easy`
196
+
197
+ **Can't find account?**
198
+ - Run `client.get_account_numbers` to refresh account hashes
199
+ - Check `account_names.json` for typos
200
+ - Use `client.available_account_names` to see configured accounts
201
+
202
+ ## Example Script
203
+
204
+ ```ruby
205
+ #!/usr/bin/env ruby
206
+ require 'schwab_rb'
207
+ require 'dotenv/load'
208
+
209
+ # Initialize
210
+ client = SchwabRb::Auth.init_client_easy(
211
+ ENV['SCHWAB_API_KEY'],
212
+ ENV['SCHWAB_APP_SECRET'],
213
+ ENV['SCHWAB_APP_CALLBACK_URL'],
214
+ ENV['SCHWAB_TOKEN_PATH']
215
+ )
216
+
217
+ # Get available accounts
218
+ names = client.available_account_names
219
+ puts "Available accounts: #{names.join(', ')}"
220
+
221
+ # Check account balance
222
+ account = client.get_account(account_name: names.first)
223
+ puts "\n#{names.first}:"
224
+ puts " Total Value: $#{account.balance.total_value}"
225
+ puts " Cash: $#{account.balance.cash_balance}"
226
+ puts " Buying Power: $#{account.balance.buying_power}"
227
+
228
+ # Get a quote
229
+ quote = client.get_quote("SPY")
230
+ puts "\nSPY: $#{quote.last_price}"
231
+
232
+ puts "\n✓ All done!"
233
+ ```
234
+
235
+ ---
236
+
237
+ **Need Help?**
238
+ - Check the [main README](../README.md)
239
+ - Review [examples/](../examples/)
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'schwab_rb'
6
+ require 'dotenv'
7
+ require 'pry'
8
+
9
+ Dotenv.load
10
+
11
+ # Example: Place an OCO (One Cancels Another) order
12
+ #
13
+ # This example demonstrates how to create an OCO order where two orders
14
+ # are submitted simultaneously, and if one fills, the other is automatically cancelled.
15
+ #
16
+
17
+ # SchwabRb::Configuration.configure do |config|
18
+ # end
19
+
20
+ CURRENT_ACCT = "TRADING_BROKERAGE_ACCOUNT"
21
+ acct_manager = SchwabRb::AccountHashManager.new
22
+
23
+ client = SchwabRb::Auth.init_client_easy(
24
+ ENV['SCHWAB_API_KEY'],
25
+ ENV['SCHWAB_APP_SECRET'],
26
+ ENV['SCHWAB_APP_CALLBACK_URL'],
27
+ ENV['SCHWAB_TOKEN_PATH']
28
+ )
29
+
30
+ puts "Example 1: OCO order with take profit and stop loss"
31
+ puts "=" * 60
32
+
33
+
34
+ symbols = [
35
+ "SPXW 251020P06510000", # long put
36
+ "SPXW 251020P06530000", # short put
37
+ "SPXW 251020C06790000", # long call
38
+ "SPXW 251020C06770000", # short call
39
+ ]
40
+
41
+ oco_order = SchwabRb::Orders::OrderFactory.build(
42
+ strategy_type: SchwabRb::Order::OrderStrategyTypes::OCO,
43
+ child_order_specs: [
44
+ {
45
+ strategy_type: SchwabRb::Order::ComplexOrderStrategyTypes::VERTICAL,
46
+ short_leg_symbol: "SPXW 251020P06530000",
47
+ long_leg_symbol: "SPXW 251020P06510000",
48
+ order_type: SchwabRb::Order::Types::STOP_LIMIT,
49
+ price: 2.1,
50
+ stop_price: 2.0,
51
+ order_instruction: :close,
52
+ credit_debit: :debit,
53
+ quantity: 2
54
+ },
55
+ {
56
+ strategy_type: SchwabRb::Order::ComplexOrderStrategyTypes::VERTICAL,
57
+ short_leg_symbol: "SPXW 251020C06770000",
58
+ long_leg_symbol: "SPXW 251020C06790000",
59
+ order_type: SchwabRb::Order::Types::STOP_LIMIT,
60
+ price: 2.1,
61
+ stop_price: 2.0,
62
+ order_instruction: :close,
63
+ credit_debit: :debit,
64
+ quantity: 2
65
+ }
66
+ ]
67
+ )
68
+
69
+ built_order = oco_order.build
70
+
71
+ binding.pry
72
+
73
+ response = client.place_order(built_order, account_name: CURRENT_ACCT)
74
+
75
+ binding.pry
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module SchwabRb
7
+ class AccountHashManager
8
+ class AccountNamesFileNotFoundError < StandardError; end
9
+ class InvalidAccountNamesFileError < StandardError; end
10
+
11
+ attr_reader :account_names_path, :account_hashes_path, :account_names
12
+
13
+ def initialize(account_names_path = nil, account_hashes_path = nil)
14
+ @account_names_path = account_names_path || SchwabRb.configuration.account_names_path
15
+ @account_hashes_path = account_hashes_path || SchwabRb.configuration.account_hashes_path
16
+ @account_names_path = File.expand_path(@account_names_path)
17
+ @account_hashes_path = File.expand_path(@account_hashes_path)
18
+ @account_names = []
19
+ end
20
+
21
+ def update_hashes_from_api_response(account_numbers_response)
22
+ account_names = load_account_names
23
+ current_hashes = load_account_hashes
24
+
25
+ number_to_hash = {}
26
+ account_numbers_response.each do |account_data|
27
+ account_number = account_data[:accountNumber]
28
+ hash_value = account_data[:hashValue]
29
+ number_to_hash[account_number] = hash_value
30
+ end
31
+
32
+ updated_hashes = {}
33
+ missing_accounts = []
34
+
35
+ account_names.each do |name, account_number|
36
+ if number_to_hash.key?(account_number)
37
+ updated_hashes[name] = number_to_hash[account_number]
38
+ elsif current_hashes.key?(name)
39
+ # Keep existing hash but warn that account wasn't in API response
40
+ updated_hashes[name] = current_hashes[name]
41
+ missing_accounts << { name: name, number: account_number }
42
+ else
43
+ # Account name exists but no hash found (new or invalid account)
44
+ missing_accounts << { name: name, number: account_number }
45
+ end
46
+ end
47
+
48
+ # Log warnings for accounts that weren't found in API response
49
+ if missing_accounts.any?
50
+ missing_accounts.each do |account|
51
+ SchwabRb::Logger.logger.warn(
52
+ "Account '#{account[:name]}' (#{account[:number]}) not found in API response. " \
53
+ "This may indicate a closed account or incorrect account number in account_names.json"
54
+ )
55
+ end
56
+ end
57
+
58
+ save_account_hashes(updated_hashes)
59
+ updated_hashes
60
+ end
61
+
62
+ def get_hash_by_name(account_name)
63
+ hashes = load_account_hashes
64
+ hashes[account_name]
65
+ end
66
+
67
+ def get_all_hashes
68
+ load_account_hashes
69
+ end
70
+
71
+ def available_account_names
72
+ begin
73
+ load_account_names.keys
74
+ rescue AccountNamesFileNotFoundError
75
+ []
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def load_account_hashes
82
+ return {} unless File.exist?(@account_hashes_path)
83
+
84
+ begin
85
+ json_content = File.read(@account_hashes_path)
86
+ JSON.parse(json_content)
87
+ rescue JSON::ParserError
88
+ {}
89
+ end
90
+ end
91
+
92
+ def save_account_hashes(hashes_map)
93
+ FileUtils.mkdir_p(File.dirname(@account_hashes_path))
94
+
95
+ File.write(@account_hashes_path, JSON.pretty_generate(hashes_map))
96
+ end
97
+
98
+ def load_account_names
99
+ unless File.exist?(@account_names_path)
100
+ raise AccountNamesFileNotFoundError,
101
+ "Account names file not found at #{@account_names_path}. " \
102
+ "Please create a JSON file mapping account names to account numbers. " \
103
+ "Example: {\"my_trading_account\": \"12345678\", \"my_ira\": \"87654321\"}"
104
+ end
105
+
106
+ begin
107
+ json_content = File.read(@account_names_path)
108
+ return {} if json_content.strip.empty?
109
+
110
+ account_names_hash = JSON.parse(json_content)
111
+ @account_names = account_names_hash.keys
112
+ account_names_hash
113
+ rescue JSON::ParserError => e
114
+ raise InvalidAccountNamesFileError,
115
+ "Invalid JSON in account names file at #{@account_names_path}: #{e.message}"
116
+ end
117
+ end
118
+ end
119
+ end
@@ -17,6 +17,37 @@ module SchwabRb
17
17
 
18
18
  class RedirectServerExitedError < StandardError; end
19
19
 
20
+ class OS
21
+ def self.windows?
22
+ (/cygwin|mswin|mingw|bccwin|wince|emx/ =~ RUBY_PLATFORM) != nil
23
+ end
24
+
25
+ def self.mac?
26
+ (/darwin/ =~ RUBY_PLATFORM) != nil
27
+ end
28
+
29
+ def self.unix?
30
+ !windows?
31
+ end
32
+
33
+ def self.linux?
34
+ unix? && !mac?
35
+ end
36
+
37
+ def self.open_cmd
38
+ return "open" if mac?
39
+ return %w[start msedge] if windows?
40
+
41
+ "xdg-open"
42
+ end
43
+ end
44
+
45
+ class BrowserLauncher
46
+ def self.open(command)
47
+ `#{command.join(" ")}`
48
+ end
49
+ end
50
+
20
51
  # class TokenExchangeError < StandardError
21
52
  # def initialize(msg)
22
53
  # super(msg)
@@ -100,7 +131,7 @@ module SchwabRb
100
131
  gets
101
132
  end
102
133
 
103
- `open "#{auth_context.authorization_url}}"`
134
+ open_browser(requested_browser, auth_context.authorization_url)
104
135
 
105
136
  timeout_time = Time.now + callback_timeout
106
137
  received_url = nil
@@ -127,6 +158,17 @@ module SchwabRb
127
158
  end
128
159
  end
129
160
 
161
+ def self.open_browser(browser, url, browser_launcher: BrowserLauncher)
162
+ open_args = Array(OS.open_cmd)
163
+ if !browser.nil? && !browser.strip.empty? && OS.mac?
164
+ open_args << "-a"
165
+ open_args << browser.gsub(" ", "\\ ")
166
+ end
167
+ open_args << %("#{url}")
168
+
169
+ browser_launcher.open open_args
170
+ end
171
+
130
172
  def self.create_ssl_certificate
131
173
  key = OpenSSL::PKey::RSA.new(2048)
132
174
  cert = OpenSSL::X509::Certificate.new