schwab_rb 0.9.2 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04505f39521ec9ea419c4a0a0a90000758ecd2b2ee112fb49f0a3557004b3d21
4
- data.tar.gz: 8fbdaf07886c79942573a3e1b3249a6be66ae7c3fdc6b9be23161d50c450f0df
3
+ metadata.gz: 33c611ab09700a163e183723beb5d4434d12b1af49a12949b161f7319a56bceb
4
+ data.tar.gz: e900ce468adadc78a6c065cc2198684658cf9519cfc2afd35fe8818929f4171c
5
5
  SHA512:
6
- metadata.gz: 21580fbeb51a9d336dd0913eeb51048fb718c02c79f63dbd509fa3a02262e531ad012cd6ac0a9f05f0eacf7ac3c7f34d6f4404ddbf24b52c4bb5fcfdb8eef4bf
7
- data.tar.gz: 30277381c65d9ffaf180d3ef6e579c47f0b53fc12356652a0a982ea6a42acc8f26c5238ef819fe5b720fb079ca574254861bd5ac7a6c47aaafc7ed46245f6311
6
+ metadata.gz: 4f8ba13ee3acd298b08c7e0ac0f0a2af2c0c8a2f22815e075a837fe4b5109f33bc4e6b526b5fc0c85c7017c5a0ca63957a8b51458b2db5dd1baf2533a765e201
7
+ data.tar.gz: 3953d141ecc0440d829d12820259d59d8253bf8377f05a499d859f5a5368d2b4454489a8cc9b9003b09048351232543dd2ed2a6f3674191d6a911a720e4baa46
@@ -1,7 +1,22 @@
1
1
  {
2
2
  "permissions": {
3
3
  "allow": [
4
- "Bash(gh pr edit:*)"
4
+ "Bash(gh pr edit:*)",
5
+ "WebSearch",
6
+ "WebFetch(domain:github.com)",
7
+ "Bash(bundle exec:*)",
8
+ "Bash(bundle install:*)",
9
+ "Bash(git checkout:*)",
10
+ "Bash(git add:*)",
11
+ "Bash(git rm:*)",
12
+ "Bash(git commit:*)",
13
+ "Bash(git push:*)",
14
+ "Bash(gh pr:*)",
15
+ "Bash(git reset:*)",
16
+ "Bash(find:*)",
17
+ "Bash(git pull:*)",
18
+ "Bash(git fetch:*)",
19
+ "Bash(git merge:*)"
5
20
  ],
6
21
  "deny": [],
7
22
  "ask": []
data/CHANGELOG.md CHANGED
@@ -1,16 +1,32 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.0.0] - 2026-09-05
4
+
5
+ ### Breaking Changes
6
+ - **Token storage**: Tokens are now persisted in SQLite (`~/.schwab_rb/schwab.db`) instead of a JSON file. `init_client_easy` and `init_client_login` no longer accept a `token_path` argument. Use `init_client_from_database` instead of `init_client_token_file`.
7
+ - **Account management**: `AccountHashManager`, `account_names.json`, and `account_hashes.json` are removed. Set `SCHWAB_ACCOUNT_NUMBER` in your environment instead. Account hashes are fetched from the API automatically and cached in SQLite.
8
+ - **Client method signatures**: All account-specific methods (`get_account`, `place_order`, `get_order`, etc.) now use `account_hash:` as a keyword argument instead of a positional argument. The `account_name:` keyword argument is removed.
9
+
10
+ ### Added
11
+ - **WebSocket streaming client**: Real-time market data streaming via `SchwabRb::Stream::Client`
12
+ - Event-based API: `stream.on(:nyse_book, symbols: ["AAPL"], fields: :all) { |event| ... }`
13
+ - Supported services: `NYSE_BOOK`, `NASDAQ_BOOK`, `OPTIONS_BOOK`, `LEVELONE_EQUITIES`, `LEVELONE_OPTIONS`, `LEVELONE_FUTURES`, `LEVELONE_FUTURES_OPTIONS`, `LEVELONE_FOREX`, `CHART_EQUITY`, `CHART_FUTURES`, `SCREENER_EQUITY`, `SCREENER_OPTION`, `ACCT_ACTIVITY`
14
+ - Ruby constant field definitions for all services (e.g. `Fields::Book::BIDS`, `Fields::LevelOneEquity::BID_PRICE`)
15
+ - Exponential backoff reconnection (2s initial, doubles, 120s cap)
16
+ - Sync (`Stream::Client`) and async (`Stream::AsyncClient`) wrappers
17
+ - **SQLite storage** (`SchwabRb::Storage::Database`): Single database at `~/.schwab_rb/schwab.db` (configurable via `SCHWAB_DATABASE_PATH`) storing tokens and account hashes
18
+ - **`SCHWAB_ACCOUNT_NUMBER` env var**: Configure your account number once; the gem auto-fetches and caches the account hash on first use
19
+ - **`init_client_from_database`**: New initializer that loads tokens from SQLite (replaces `init_client_token_file`)
20
+
3
21
  ### Fixed
4
22
  - CLI `login` no longer reads from `ARGF` at the browser prompt, which prevented `schwab_rb login` from opening the browser when command arguments were present
5
23
  - `OptionExpirationChain` now accepts both string-keyed and symbol-keyed hashes, matching `BaseClient#get_option_expiration_chain`
6
24
 
7
- ### Added
8
- - CLI `sample` command for saving single-expiration option chain snapshots as CSV or JSON
9
- - `--root` option for filtering sampled contracts by option root and using that root in output filenames
10
-
11
25
  ### Changed
12
26
  - CLI history downloads now default to `~/.schwab_rb/data/history`
13
27
  - CLI option samples now default to `~/.schwab_rb/data/options`
28
+ - CLI `login` success message updated to reflect database storage
29
+ - `PathSupport.expand_path` error message generalized from `"token_path is nil or empty"` to `"path is nil or empty"`
14
30
 
15
31
  ## [0.6.0] - 2025-12-11
16
32
 
@@ -19,9 +19,6 @@ Dotenv.load
19
19
  # config.log_level = "DEBUG"
20
20
  # end
21
21
 
22
- CURRENT_ACCT = "TRADING_BROKERAGE_ACCOUNT"
23
- acct_manager = SchwabRb::AccountHashManager.new
24
-
25
22
  client = SchwabRb::Auth.init_client_easy(
26
23
  ENV['SCHWAB_API_KEY'],
27
24
  ENV['SCHWAB_APP_SECRET'],
@@ -72,6 +69,6 @@ built_order = oco_order.build
72
69
 
73
70
  binding.pry
74
71
 
75
- # response = client.place_order(built_order, account_name: CURRENT_ACCT)
72
+ # response = client.place_order(built_order)
76
73
 
77
74
  binding.pry
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Stream Level 1 equity quotes
5
+ #
6
+ # Usage: ruby examples/stream_level_one.rb
7
+ #
8
+ # Requires SCHWAB_API_KEY, SCHWAB_APP_SECRET, and APP_CALLBACK_URL env vars.
9
+ # Token must already exist in the database (run login first).
10
+
11
+ require "schwab_rb"
12
+ require "dotenv/load"
13
+
14
+ Fields = SchwabRb::Stream::Fields
15
+
16
+ client = SchwabRb::Auth.init_client_easy(
17
+ ENV.fetch("SCHWAB_API_KEY"),
18
+ ENV.fetch("SCHWAB_APP_SECRET"),
19
+ ENV.fetch("APP_CALLBACK_URL")
20
+ )
21
+
22
+ stream = SchwabRb::Stream::Client.new(client)
23
+
24
+ stream.on(:level_one_equities,
25
+ symbols: ["AAPL", "MSFT", "GOOGL", "AMZN"],
26
+ fields: [
27
+ Fields::LevelOneEquity::SYMBOL,
28
+ Fields::LevelOneEquity::BID_PRICE,
29
+ Fields::LevelOneEquity::ASK_PRICE,
30
+ Fields::LevelOneEquity::LAST_PRICE,
31
+ Fields::LevelOneEquity::TOTAL_VOLUME,
32
+ Fields::LevelOneEquity::NET_CHANGE,
33
+ Fields::LevelOneEquity::NET_CHANGE_PERCENT
34
+ ]
35
+ ) do |event|
36
+ event["content"]&.each do |entry|
37
+ symbol = entry["key"]
38
+ bid = entry["1"]
39
+ ask = entry["2"]
40
+ last = entry["3"]
41
+ volume = entry["8"]
42
+ change = entry["18"]
43
+ change_pct = entry["42"]
44
+
45
+ parts = ["#{symbol}:"]
46
+ parts << "Last=#{last}" if last
47
+ parts << "Bid=#{bid}" if bid
48
+ parts << "Ask=#{ask}" if ask
49
+ parts << "Vol=#{volume}" if volume
50
+ parts << "Chg=#{change}" if change
51
+ parts << "Chg%=#{change_pct}" if change_pct
52
+
53
+ puts parts.join(" ")
54
+ end
55
+ end
56
+
57
+ puts "Starting Level 1 equity stream... Press Ctrl+C to stop."
58
+
59
+ trap("INT") do
60
+ puts "\nStopping stream..."
61
+ stream.stop
62
+ exit
63
+ end
64
+
65
+ stream.start
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Stream order book (Level 2) data for NYSE, NASDAQ, and Options
5
+ #
6
+ # Usage: ruby examples/stream_order_book.rb
7
+ #
8
+ # Requires SCHWAB_API_KEY, SCHWAB_APP_SECRET, and APP_CALLBACK_URL env vars.
9
+ # Token must already exist in the database (run login first).
10
+
11
+ require "schwab_rb"
12
+ require "dotenv/load"
13
+
14
+ Fields = SchwabRb::Stream::Fields
15
+
16
+ client = SchwabRb::Auth.init_client_easy(
17
+ ENV.fetch("SCHWAB_API_KEY"),
18
+ ENV.fetch("SCHWAB_APP_SECRET"),
19
+ ENV.fetch("APP_CALLBACK_URL")
20
+ )
21
+
22
+ stream = SchwabRb::Stream::Client.new(client)
23
+
24
+ stream.on(:nyse_book, symbols: ["AAPL", "MSFT"], fields: Fields::Book::ALL) do |event|
25
+ puts "=== NYSE BOOK ==="
26
+ event["content"]&.each do |entry|
27
+ puts " Symbol: #{entry["key"]}"
28
+ puts " Bids: #{entry["2"]&.length || 0} levels"
29
+ puts " Asks: #{entry["3"]&.length || 0} levels"
30
+ end
31
+ end
32
+
33
+ stream.on(:nasdaq_book, symbols: ["AMD", "NVDA"], fields: Fields::Book::ALL) do |event|
34
+ puts "=== NASDAQ BOOK ==="
35
+ event["content"]&.each do |entry|
36
+ puts " Symbol: #{entry["key"]}"
37
+ puts " Bids: #{entry["2"]&.length || 0} levels"
38
+ puts " Asks: #{entry["3"]&.length || 0} levels"
39
+ end
40
+ end
41
+
42
+ stream.on(:options_book, symbols: ["AAPL 261218C00200000"], fields: Fields::Book::ALL) do |event|
43
+ puts "=== OPTIONS BOOK ==="
44
+ event["content"]&.each do |entry|
45
+ puts " Symbol: #{entry["key"]}"
46
+ end
47
+ end
48
+
49
+ puts "Starting order book stream... Press Ctrl+C to stop."
50
+
51
+ trap("INT") do
52
+ puts "\nStopping stream..."
53
+ stream.stop
54
+ exit
55
+ end
56
+
57
+ stream.start
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "oauth2"
4
- require_relative "init_client_token_file"
4
+ require_relative "init_client_from_database"
5
5
  require_relative "init_client_login"
6
6
  require_relative "../path_support"
7
7
 
@@ -11,38 +11,39 @@ module SchwabRb
11
11
  api_key,
12
12
  app_secret,
13
13
  callback_url,
14
- token_path,
15
14
  asyncio: false,
16
15
  enforce_enums: false,
17
16
  callback_timeout: 300.0,
18
17
  interactive: true,
19
- requested_browser: nil
18
+ requested_browser: nil,
19
+ database: nil
20
20
  )
21
- token_path = SchwabRb::PathSupport.expand_path(token_path)
21
+ client = begin
22
+ SchwabRb::Auth.init_client_from_database(
23
+ api_key,
24
+ app_secret,
25
+ enforce_enums: enforce_enums,
26
+ database: database
27
+ )
28
+ rescue StandardError
29
+ nil
30
+ end
22
31
 
23
- raise OAuth2::Error, "No token found" unless File.exist?(token_path)
32
+ if client
33
+ client.refresh! if client.session.expired?
34
+ return client unless client.session.expired?
35
+ end
24
36
 
25
- client = SchwabRb::Auth.init_client_token_file(
26
- api_key,
27
- app_secret,
28
- token_path,
29
- enforce_enums: enforce_enums
30
- )
31
- client.refresh! if client.session.expired?
32
- raise OAuth2::Error, "Token expired" if client.session.expired?
33
-
34
- client
35
- rescue StandardError
36
37
  SchwabRb::Auth.init_client_login(
37
38
  api_key,
38
39
  app_secret,
39
40
  callback_url,
40
- token_path,
41
41
  asyncio: asyncio,
42
42
  enforce_enums: enforce_enums,
43
43
  callback_timeout: callback_timeout,
44
44
  interactive: interactive,
45
- requested_browser: requested_browser
45
+ requested_browser: requested_browser,
46
+ database: database
46
47
  )
47
48
  end
48
49
  end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "oauth2"
4
- require_relative "../path_support"
5
4
 
6
5
  module SchwabRb
7
6
  module Auth
8
- def self.init_client_token_file(api_key, app_secret, token_path, enforce_enums: true)
9
- token_path = SchwabRb::PathSupport.expand_path(token_path)
7
+ def self.init_client_from_database(api_key, app_secret, enforce_enums: true, database: nil)
8
+ metadata_manager = SchwabRb::Auth::TokenManager.from_database(api_key, database: database)
9
+ return nil unless metadata_manager
10
+
11
+ token = metadata_manager.token
10
12
 
11
13
  oauth = OAuth2::Client.new(
12
14
  api_key,
@@ -15,9 +17,6 @@ module SchwabRb
15
17
  token_url: "/v1/oauth/token"
16
18
  )
17
19
 
18
- metadata_manager = SchwabRb::Auth::TokenManager.from_file(token_path)
19
- token = metadata_manager.token
20
-
21
20
  session = OAuth2::AccessToken.new(
22
21
  oauth,
23
22
  token.token,
@@ -65,16 +65,14 @@ module SchwabRb
65
65
  api_key,
66
66
  app_secret,
67
67
  callback_url,
68
- token_path,
69
68
  asyncio: false,
70
69
  enforce_enums: false,
71
70
  callback_timeout: 300.0,
72
71
  interactive: true,
73
72
  input: $stdin,
74
- requested_browser: nil
73
+ requested_browser: nil,
74
+ database: nil
75
75
  )
76
- token_path = SchwabRb::PathSupport.expand_path(token_path)
77
-
78
76
  callback_timeout = if !callback_timeout
79
77
  0
80
78
  elsif callback_timeout.negative?
@@ -154,7 +152,7 @@ module SchwabRb
154
152
  app_secret,
155
153
  auth_context,
156
154
  received_url,
157
- token_path
155
+ database: database
158
156
  )
159
157
  ensure
160
158
  LoginFlowServer.stop
@@ -213,7 +211,7 @@ module SchwabRb
213
211
  end
214
212
 
215
213
  def self.client_from_received_url(
216
- api_key, app_secret, auth_context, received_url, token_path, enforce_enums: true
214
+ api_key, app_secret, auth_context, received_url, enforce_enums: true, database: nil
217
215
  )
218
216
  oauth = OAuth2::Client.new(
219
217
  api_key,
@@ -230,9 +228,10 @@ module SchwabRb
230
228
  metadata_manager = SchwabRb::Auth::TokenManager.from_oauth2_token(
231
229
  token,
232
230
  Time.now.to_i,
233
- token_path: token_path
231
+ api_key: api_key,
232
+ database: database
234
233
  )
235
- metadata_manager.to_file
234
+ metadata_manager.save
236
235
 
237
236
  session = OAuth2::AccessToken.new(
238
237
  oauth,
@@ -9,9 +9,11 @@ module SchwabRb
9
9
  module Auth
10
10
  class TokenManager
11
11
  class << self
12
- def from_file(token_path)
13
- token_path = SchwabRb::PathSupport.expand_path(token_path)
14
- token_data = JSON.parse(File.read(token_path))
12
+ def from_database(api_key, database: nil)
13
+ database ||= default_database
14
+ token_data = database.load_token(api_key)
15
+ return nil unless token_data
16
+
15
17
  token = SchwabRb::Auth::Token.new(
16
18
  token: token_data["token"]["access_token"],
17
19
  expires_in: token_data["token"]["expires_in"],
@@ -22,10 +24,10 @@ module SchwabRb
22
24
  expires_at: token_data["token"]["expires_at"]
23
25
  )
24
26
 
25
- TokenManager.new(token, token_data["timestamp"], token_path: token_path)
27
+ TokenManager.new(token, token_data["timestamp"], api_key: api_key, database: database)
26
28
  end
27
29
 
28
- def from_oauth2_token(oauth2_token, timestamp, token_path: SchwabRb::Constants::DEFAULT_TOKEN_PATH)
30
+ def from_oauth2_token(oauth2_token, timestamp, api_key:, database: nil)
29
31
  token = SchwabRb::Auth::Token.new(
30
32
  token: oauth2_token.token,
31
33
  expires_in: oauth2_token.expires_in,
@@ -36,17 +38,24 @@ module SchwabRb
36
38
  expires_at: oauth2_token.expires_at
37
39
  )
38
40
 
39
- TokenManager.new(token, timestamp, token_path: token_path)
41
+ TokenManager.new(token, timestamp, api_key: api_key, database: database)
42
+ end
43
+
44
+ private
45
+
46
+ def default_database
47
+ SchwabRb::Storage::Database.new
40
48
  end
41
49
  end
42
50
 
43
- def initialize(token, timestamp, token_path: SchwabRb::Constants::DEFAULT_TOKEN_PATH)
51
+ def initialize(token, timestamp, api_key: nil, database: nil)
44
52
  @token = token
45
53
  @timestamp = timestamp
46
- @token_path = SchwabRb::PathSupport.expand_path(token_path)
54
+ @api_key = api_key
55
+ @database = database
47
56
  end
48
57
 
49
- attr_reader :token, :timestamp, :token_path
58
+ attr_reader :token, :timestamp, :api_key, :database
50
59
 
51
60
  def refresh_token(client)
52
61
  new_token = client.session.refresh!
@@ -62,7 +71,7 @@ module SchwabRb
62
71
  )
63
72
  @timestamp = Time.now.to_i
64
73
 
65
- to_file
74
+ save
66
75
 
67
76
  oauth = OAuth2::Client.new(
68
77
  client.api_key,
@@ -79,9 +88,8 @@ module SchwabRb
79
88
  )
80
89
  end
81
90
 
82
- def to_file
83
- SchwabRb::PathSupport.ensure_parent_directory(token_path)
84
- File.write(token_path, to_json)
91
+ def save
92
+ effective_database.save_token(api_key, to_h)
85
93
  end
86
94
 
87
95
  def token_age
@@ -103,8 +111,10 @@ module SchwabRb
103
111
  }
104
112
  end
105
113
 
106
- def to_json(*_args)
107
- to_h.to_json
114
+ private
115
+
116
+ def effective_database
117
+ @database || self.class.send(:default_database)
108
118
  end
109
119
  end
110
120
  end
@@ -83,21 +83,15 @@ module SchwabRb
83
83
  raise Error, "Unexpected arguments: #{argv.join(' ')}" if argv.any?
84
84
 
85
85
  credentials = load_credentials(require_callback_url: true)
86
- token_path = resolved_token_path
87
86
 
88
87
  SchwabRb::Auth.init_client_login(
89
88
  credentials.fetch(:api_key),
90
89
  credentials.fetch(:app_secret),
91
- credentials.fetch(:callback_url),
92
- token_path
90
+ credentials.fetch(:callback_url)
93
91
  )
94
92
 
95
- stdout.puts("Authentication succeeded. Token saved to #{token_path}")
93
+ stdout.puts("Authentication succeeded. Token saved to database.")
96
94
  0
97
- rescue JSON::ParserError
98
- raise Error,
99
- "The token file at #{resolved_token_path} is not valid JSON. " \
100
- "Delete it or run `schwab_rb login` to recreate it."
101
95
  rescue OAuth2::Error, SchwabRb::Auth::RedirectTimeoutError, SchwabRb::Auth::RedirectServerExitedError => e
102
96
  raise Error, "Authentication failed: #{e.message}"
103
97
  end
@@ -230,24 +224,23 @@ module SchwabRb
230
224
 
231
225
  def build_non_interactive_client
232
226
  credentials = load_credentials(require_callback_url: false)
233
- token_path = resolved_token_path
234
227
 
235
- client = SchwabRb::Auth.init_client_token_file(
228
+ client = SchwabRb::Auth.init_client_from_database(
236
229
  credentials.fetch(:api_key),
237
- credentials.fetch(:app_secret),
238
- token_path
230
+ credentials.fetch(:app_secret)
239
231
  )
232
+
233
+ raise Error, "No valid API token found. Run `schwab_rb login` to authenticate." unless client
234
+
240
235
  client.refresh!
241
236
 
242
237
  return client unless client.session.expired?
243
238
 
244
- raise Error, "No valid API token found at #{token_path}. Run `schwab_rb login` to authenticate."
239
+ raise Error, "Token expired. Run `schwab_rb login` to authenticate."
245
240
  rescue Errno::ENOENT
246
- raise Error, "No valid API token found at #{token_path}. Run `schwab_rb login` to authenticate."
247
- rescue JSON::ParserError
248
- raise Error, "The token file at #{token_path} is not valid JSON. Run `schwab_rb login` to recreate it."
241
+ raise Error, "No valid API token found. Run `schwab_rb login` to authenticate."
249
242
  rescue OAuth2::Error => e
250
- raise Error, "Unable to use the token at #{token_path}: #{e.message}. Run `schwab_rb login` to refresh it."
243
+ raise Error, "Unable to use stored token: #{e.message}. Run `schwab_rb login` to refresh it."
251
244
  end
252
245
 
253
246
  # rubocop:disable Metrics/AbcSize
@@ -280,11 +273,6 @@ module SchwabRb
280
273
  raise Error, "The `--expiration-date` option is required." unless options[:expiration_date]
281
274
  end
282
275
 
283
- def resolved_token_path
284
- token_path = env["SCHWAB_TOKEN_PATH"] || env["TOKEN_PATH"] || SchwabRb::Constants::DEFAULT_TOKEN_PATH
285
- SchwabRb::PathSupport.expand_path(token_path)
286
- end
287
-
288
276
  def default_history_dir
289
277
  SchwabRb::PathSupport.expand_path(DEFAULT_HISTORY_DIR)
290
278
  end
@@ -359,7 +347,7 @@ module SchwabRb
359
347
  <<~HELP
360
348
  Usage: schwab_rb login
361
349
 
362
- Authenticates with Schwab in a browser and stores the token at #{resolved_token_path}.
350
+ Authenticates with Schwab in a browser and stores the token in the database.
363
351
  Required environment variables: SCHWAB_API_KEY, SCHWAB_APP_SECRET, SCHWAB_APP_CALLBACK_URL
364
352
  HELP
365
353
  end