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.
@@ -49,15 +49,14 @@ module SchwabRb
49
49
  @token_manager.token_age
50
50
  end
51
51
 
52
- def get_account(account_hash = nil, account_name: nil, fields: nil, return_data_objects: true)
52
+ def get_account(account_hash: nil, fields: nil, return_data_objects: true)
53
53
  # Account balances, positions, and orders for a given account.
54
54
  #
55
- # @param account_hash [String] The account hash (optional if account_name provided)
56
- # @param account_name [String] The account name from account_names.json (takes priority)
55
+ # @param account_hash [String] The account hash. If nil, resolved from SCHWAB_ACCOUNT_NUMBER.
57
56
  # @param fields [Array] Balances displayed by default, additional fields can be
58
57
  # added here by adding values from Account.fields.
59
58
  # @param return_data_objects [Boolean] Whether to return data objects or Hash
60
- resolved_hash = resolve_account_hash(account_name: account_name, account_hash: account_hash)
59
+ resolved_hash = resolve_account_hash(account_hash: account_hash)
61
60
 
62
61
  with_account_hash_retry(resolved_hash) do
63
62
  refresh_token_if_needed
@@ -117,13 +116,6 @@ module SchwabRb
117
116
 
118
117
  account_numbers_data = JSON.parse(response.body, symbolize_names: true)
119
118
 
120
- begin
121
- hash_manager = SchwabRb::AccountHashManager.new
122
- hash_manager.update_hashes_from_api_response(account_numbers_data)
123
- rescue SchwabRb::AccountHashManager::AccountNamesFileNotFoundError
124
- # Silently skip if account names file doesn't exist - not all users will use this feature
125
- end
126
-
127
119
  if return_data_objects
128
120
  SchwabRb::DataObjects::AccountNumbers.build(account_numbers_data)
129
121
  else
@@ -131,23 +123,13 @@ module SchwabRb
131
123
  end
132
124
  end
133
125
 
134
- def available_account_names
135
- # Returns a list of available account names from account_names.json
136
- # Returns empty array if account_names.json doesn't exist
137
- #
138
- # @return [Array<String>] List of account names
139
- hash_manager = SchwabRb::AccountHashManager.new
140
- hash_manager.available_account_names
141
- end
142
-
143
- def get_order(order_id, account_hash = nil, account_name: nil, return_data_objects: true)
126
+ def get_order(order_id, account_hash: nil, return_data_objects: true)
144
127
  # Get a specific order for a specific account by its order ID.
145
128
  #
146
129
  # @param order_id [String] The order ID.
147
- # @param account_hash [String] The account hash (optional if account_name provided)
148
- # @param account_name [String] The account name from account_names.json (takes priority)
130
+ # @param account_hash [String] The account hash. If nil, resolved from SCHWAB_ACCOUNT_NUMBER.
149
131
  # @param return_data_objects [Boolean] Whether to return data objects or Hash
150
- resolved_hash = resolve_account_hash(account_name: account_name, account_hash: account_hash)
132
+ resolved_hash = resolve_account_hash(account_hash: account_hash)
151
133
 
152
134
  with_account_hash_retry(resolved_hash) do
153
135
  refresh_token_if_needed
@@ -164,13 +146,12 @@ module SchwabRb
164
146
  end
165
147
  end
166
148
 
167
- def cancel_order(order_id, account_hash = nil, account_name: nil)
149
+ def cancel_order(order_id, account_hash: nil)
168
150
  # Cancel a specific order for a specific account.
169
151
  #
170
152
  # @param order_id [String] The order ID.
171
- # @param account_hash [String] The account hash (optional if account_name provided)
172
- # @param account_name [String] The account name from account_names.json (takes priority)
173
- resolved_hash = resolve_account_hash(account_name: account_name, account_hash: account_hash)
153
+ # @param account_hash [String] The account hash. If nil, resolved from SCHWAB_ACCOUNT_NUMBER.
154
+ resolved_hash = resolve_account_hash(account_hash: account_hash)
174
155
 
175
156
  with_account_hash_retry(resolved_hash) do
176
157
  refresh_token_if_needed
@@ -181,8 +162,7 @@ module SchwabRb
181
162
  end
182
163
 
183
164
  def get_account_orders(
184
- account_hash = nil,
185
- account_name: nil,
165
+ account_hash: nil,
186
166
  max_results: nil,
187
167
  from_entered_datetime: nil,
188
168
  to_entered_datetime: nil,
@@ -191,14 +171,13 @@ module SchwabRb
191
171
  )
192
172
  # Orders for a specific account. Optionally specify a single status on which to filter.
193
173
  #
194
- # @param account_hash [String] The account hash (optional if account_name provided)
195
- # @param account_name [String] The account name from account_names.json (takes priority)
174
+ # @param account_hash [String] The account hash. If nil, resolved from SCHWAB_ACCOUNT_NUMBER.
196
175
  # @param max_results [Integer] The maximum number of orders to retrieve.
197
176
  # @param from_entered_datetime [DateTime] Start of the query date range (default: 60 days ago).
198
177
  # @param to_entered_datetime [DateTime] End of the query date range (default: now).
199
178
  # @param status [String] Restrict query to orders with this status.
200
179
  # @param return_data_objects [Boolean] Whether to return data objects or Hash
201
- resolved_hash = resolve_account_hash(account_name: account_name, account_hash: account_hash)
180
+ resolved_hash = resolve_account_hash(account_hash: account_hash)
202
181
 
203
182
  with_account_hash_retry(resolved_hash) do
204
183
  refresh_token_if_needed
@@ -262,17 +241,16 @@ module SchwabRb
262
241
  end
263
242
  end
264
243
 
265
- def place_order(order_spec, account_hash = nil, account_name: nil)
244
+ def place_order(order_spec, account_hash: nil)
266
245
  # Place an order for a specific account. If order creation is successful,
267
246
  # the response will contain the ID of the generated order.
268
247
  #
269
- # @param account_hash [String] The account hash (optional if account_name provided)
270
248
  # @param order_spec [Hash, SchwabRb::Orders::Builder] The order specification
271
- # @param account_name [String] The account name from account_names.json (takes priority)
249
+ # @param account_hash [String] The account hash. If nil, resolved from SCHWAB_ACCOUNT_NUMBER.
272
250
  #
273
251
  # Note: Unlike most methods in this library, successful responses typically
274
252
  # do not contain JSON data, and attempting to extract it may raise an exception.
275
- resolved_hash = resolve_account_hash(account_name: account_name, account_hash: account_hash)
253
+ resolved_hash = resolve_account_hash(account_hash: account_hash)
276
254
 
277
255
  with_account_hash_retry(resolved_hash) do
278
256
  refresh_token_if_needed
@@ -284,16 +262,15 @@ module SchwabRb
284
262
  end
285
263
  end
286
264
 
287
- def replace_order(order_id, order_spec, account_hash = nil, account_name: nil)
265
+ def replace_order(order_id, order_spec, account_hash: nil)
288
266
  # Replace an existing order for an account.
289
267
  # The existing order will be replaced by the new order.
290
268
  # Once replaced, the old order will be canceled and a new order will be created.
291
269
  #
292
- # @param account_hash [String] The account hash (optional if account_name provided)
293
270
  # @param order_id [String] The order ID to replace
294
271
  # @param order_spec [Hash, SchwabRb::Orders::Builder] The new order specification
295
- # @param account_name [String] The account name from account_names.json (takes priority)
296
- resolved_hash = resolve_account_hash(account_name: account_name, account_hash: account_hash)
272
+ # @param account_hash [String] The account hash. If nil, resolved from SCHWAB_ACCOUNT_NUMBER.
273
+ resolved_hash = resolve_account_hash(account_hash: account_hash)
297
274
 
298
275
  with_account_hash_retry(resolved_hash) do
299
276
  refresh_token_if_needed
@@ -305,15 +282,14 @@ module SchwabRb
305
282
  end
306
283
  end
307
284
 
308
- def preview_order(order_spec, account_hash = nil, account_name: nil, return_data_objects: true)
285
+ def preview_order(order_spec, account_hash: nil, return_data_objects: true)
309
286
  # Preview an order, i.e., test whether an order would be accepted by the
310
287
  # API and see the structure it would result in.
311
288
  #
312
- # @param account_hash [String] The account hash (optional if account_name provided)
313
289
  # @param order_spec [Hash, SchwabRb::Orders::Builder] The order specification to preview
314
- # @param account_name [String] The account name from account_names.json (takes priority)
290
+ # @param account_hash [String] The account hash. If nil, resolved from SCHWAB_ACCOUNT_NUMBER.
315
291
  # @param return_data_objects [Boolean] Whether to return data objects or Hash
316
- resolved_hash = resolve_account_hash(account_name: account_name, account_hash: account_hash)
292
+ resolved_hash = resolve_account_hash(account_hash: account_hash)
317
293
 
318
294
  with_account_hash_retry(resolved_hash) do
319
295
  refresh_token_if_needed
@@ -333,8 +309,7 @@ module SchwabRb
333
309
  end
334
310
 
335
311
  def get_transactions(
336
- account_hash = nil,
337
- account_name: nil,
312
+ account_hash: nil,
338
313
  start_date: nil,
339
314
  end_date: nil,
340
315
  transaction_types: nil,
@@ -343,14 +318,13 @@ module SchwabRb
343
318
  )
344
319
  # Transactions for a specific account.
345
320
  #
346
- # @param account_hash [String] The account hash (optional if account_name provided)
347
- # @param account_name [String] The account name from account_names.json (takes priority)
321
+ # @param account_hash [String] The account hash. If nil, resolved from SCHWAB_ACCOUNT_NUMBER.
348
322
  # @param start_date [Date, DateTime] Start date for transactions (default: 60 days ago).
349
323
  # @param end_date [Date, DateTime] End date for transactions (default: now).
350
324
  # @param transaction_types [Array] List of transaction types to filter by.
351
325
  # @param symbol [String] Filter transactions by the specified symbol.
352
326
  # @param return_data_objects [Boolean] Whether to return data objects or Hash
353
- resolved_hash = resolve_account_hash(account_name: account_name, account_hash: account_hash)
327
+ resolved_hash = resolve_account_hash(account_hash: account_hash)
354
328
 
355
329
  with_account_hash_retry(resolved_hash) do
356
330
  refresh_token_if_needed
@@ -394,14 +368,13 @@ module SchwabRb
394
368
  end
395
369
  end
396
370
 
397
- def get_transaction(activity_id, account_hash = nil, account_name: nil, return_data_objects: true)
371
+ def get_transaction(activity_id, account_hash: nil, return_data_objects: true)
398
372
  # Transaction for a specific account.
399
373
  #
400
- # @param account_hash [String] The account hash (optional if account_name provided)
401
374
  # @param activity_id [String] ID of the transaction to retrieve
402
- # @param account_name [String] The account name from account_names.json (takes priority)
375
+ # @param account_hash [String] The account hash. If nil, resolved from SCHWAB_ACCOUNT_NUMBER.
403
376
  # @param return_data_objects [Boolean] Whether to return data objects or Hash
404
- resolved_hash = resolve_account_hash(account_name: account_name, account_hash: account_hash)
377
+ resolved_hash = resolve_account_hash(account_hash: account_hash)
405
378
 
406
379
  with_account_hash_retry(resolved_hash) do
407
380
  refresh_token_if_needed
@@ -930,31 +903,34 @@ module SchwabRb
930
903
 
931
904
  private
932
905
 
933
- # Resolves account identifier to actual account hash
934
- # Accepts either account name (looked up in account_hashes.json) or direct hash
935
- # Priority: account_name > account_hash
936
- def resolve_account_hash(account_name: nil, account_hash: nil)
937
- # If account_name is provided, look it up
938
- if account_name
939
- hash_manager = SchwabRb::AccountHashManager.new
940
- resolved_hash = hash_manager.get_hash_by_name(account_name)
941
-
942
- unless resolved_hash
943
- raise ArgumentError,
944
- "Account name '#{account_name}' not found in account hashes. " \
945
- "Make sure get_account_numbers has been called to populate hashes."
946
- end
906
+ # Resolves account hash. Uses the provided hash directly, or auto-resolves
907
+ # from SCHWAB_ACCOUNT_NUMBER via the database (fetching from API if not cached).
908
+ def resolve_account_hash(account_hash: nil)
909
+ return account_hash if account_hash
947
910
 
948
- return resolved_hash
911
+ account_number = SchwabRb.configuration.account_number
912
+ unless account_number
913
+ raise ArgumentError,
914
+ "No account_hash provided and SCHWAB_ACCOUNT_NUMBER is not configured"
949
915
  end
950
916
 
951
- # Fall back to account_hash if provided
952
- if account_hash
953
- return account_hash
954
- end
917
+ db = SchwabRb::Storage::Database.new
918
+ cached = db.load_account_hash(account_number)
919
+ return cached if cached
920
+
921
+ fetch_and_cache_account_hash(account_number, db)
922
+ end
923
+
924
+ def fetch_and_cache_account_hash(account_number, db)
925
+ refresh_token_if_needed
926
+ response = get("/trader/v1/accounts/accountNumbers", {})
927
+ data = JSON.parse(response.body, symbolize_names: true)
928
+ entry = data.find { |a| a[:accountNumber] == account_number }
929
+ raise ArgumentError, "Account #{account_number} not found in Schwab API response" unless entry
955
930
 
956
- # Neither was provided
957
- raise ArgumentError, "Either account_name or account_hash must be provided"
931
+ hash = entry[:hashValue]
932
+ db.save_account(account_number, hash)
933
+ hash
958
934
  end
959
935
 
960
936
  # Wraps API calls that use account_hash with retry logic for stale hashes
@@ -3,7 +3,7 @@
3
3
  module SchwabRb
4
4
  class Configuration
5
5
  attr_accessor :logger, :log_file, :log_level, :silence_output,
6
- :schwab_home, :account_hashes_path, :account_names_path
6
+ :schwab_home, :account_number, :database_path
7
7
 
8
8
  def initialize
9
9
  @logger = nil
@@ -13,8 +13,8 @@ module SchwabRb
13
13
 
14
14
  default_home = File.expand_path("~/.schwab_rb")
15
15
  @schwab_home = ENV.fetch("SCHWAB_HOME", default_home)
16
- @account_hashes_path = ENV.fetch("SCHWAB_ACCOUNT_HASHES_PATH", File.join(@schwab_home, "account_hashes.json"))
17
- @account_names_path = ENV.fetch("SCHWAB_ACCOUNT_NAMES_PATH", File.join(@schwab_home, "account_names.json"))
16
+ @account_number = ENV.fetch("SCHWAB_ACCOUNT_NUMBER", nil)
17
+ @database_path = ENV.fetch("SCHWAB_DATABASE_PATH", File.join(@schwab_home, "schwab.db"))
18
18
  end
19
19
 
20
20
  def has_external_logger?
@@ -4,6 +4,5 @@ module SchwabRb
4
4
  module Constants
5
5
  SCHWAB_BASE_URL = "https://api.schwabapi.com"
6
6
  TOKEN_ENDPOINT = "https://api.schwabapi.com/v1/oauth/token"
7
- DEFAULT_TOKEN_PATH = "~/.schwab_rb/token.json"
8
7
  end
9
8
  end
@@ -3,11 +3,12 @@
3
3
  require "fileutils"
4
4
 
5
5
  module SchwabRb
6
- # Common path expansion and directory helpers for token and data files.
7
6
  module PathSupport
8
7
  module_function
9
8
 
10
9
  def expand_path(path)
10
+ raise ArgumentError, "path is nil or empty" if path.nil? || path.to_s.strip.empty?
11
+
11
12
  File.expand_path(path.to_s)
12
13
  end
13
14
 
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+
5
+ module SchwabRb
6
+ module Storage
7
+ class Database
8
+ attr_reader :path
9
+
10
+ def initialize(path = nil)
11
+ @path = path || SchwabRb.configuration.database_path
12
+ @mutex = Mutex.new
13
+ @db = nil
14
+ end
15
+
16
+ def save_token(api_key, token_hash)
17
+ synchronize do
18
+ db.execute("DELETE FROM tokens WHERE api_key = ?", [api_key])
19
+ db.execute(<<~SQL, bind_token_params(api_key, token_hash))
20
+ INSERT INTO tokens (api_key, access_token, refresh_token, expires_at, expires_in,
21
+ token_type, scope, id_token, timestamp)
22
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
23
+ SQL
24
+ end
25
+ end
26
+
27
+ def load_token(api_key)
28
+ row = synchronize do
29
+ db.get_first_row("SELECT * FROM tokens WHERE api_key = ?", [api_key])
30
+ end
31
+ return nil unless row
32
+
33
+ expires_at = row["expires_at"]
34
+ if expires_at && Time.now.to_i > expires_at
35
+ synchronize { db.execute("DELETE FROM tokens WHERE api_key = ?", [api_key]) }
36
+ return nil
37
+ end
38
+
39
+ {
40
+ "timestamp" => row["timestamp"],
41
+ "token" => {
42
+ "access_token" => row["access_token"],
43
+ "refresh_token" => row["refresh_token"],
44
+ "expires_at" => expires_at,
45
+ "expires_in" => row["expires_in"],
46
+ "token_type" => row["token_type"],
47
+ "scope" => row["scope"],
48
+ "id_token" => row["id_token"]
49
+ }
50
+ }
51
+ end
52
+
53
+ def save_account(account_number, account_hash)
54
+ synchronize do
55
+ db.execute(<<~SQL, [account_number, account_hash])
56
+ INSERT INTO accounts (account_number, account_hash)
57
+ VALUES (?, ?)
58
+ ON CONFLICT(account_number) DO UPDATE SET
59
+ account_hash = excluded.account_hash,
60
+ updated_at = CURRENT_TIMESTAMP
61
+ SQL
62
+ end
63
+ end
64
+
65
+ def load_account_hash(account_number)
66
+ row = synchronize do
67
+ db.get_first_row("SELECT account_hash FROM accounts WHERE account_number = ?", [account_number])
68
+ end
69
+ row&.fetch("account_hash", nil)
70
+ end
71
+
72
+ def close
73
+ synchronize do
74
+ @db&.close
75
+ @db = nil
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def db
82
+ @db ||= open_database
83
+ end
84
+
85
+ def open_database
86
+ is_memory = @path == ":memory:"
87
+ SchwabRb::PathSupport.ensure_parent_directory(@path) unless is_memory
88
+
89
+ database = SQLite3::Database.new(@path)
90
+ database.results_as_hash = true
91
+ database.execute("PRAGMA journal_mode=WAL") unless is_memory
92
+ database.execute("PRAGMA foreign_keys=ON")
93
+ create_tables(database)
94
+ database
95
+ end
96
+
97
+ def create_tables(database)
98
+ database.execute(<<~SQL)
99
+ CREATE TABLE IF NOT EXISTS accounts (
100
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
101
+ account_number TEXT NOT NULL UNIQUE,
102
+ account_hash TEXT NOT NULL,
103
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
104
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
105
+ )
106
+ SQL
107
+
108
+ database.execute(<<~SQL)
109
+ CREATE TABLE IF NOT EXISTS tokens (
110
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
111
+ api_key TEXT NOT NULL UNIQUE,
112
+ access_token TEXT,
113
+ refresh_token TEXT,
114
+ expires_at INTEGER,
115
+ expires_in INTEGER,
116
+ token_type TEXT DEFAULT 'Bearer',
117
+ scope TEXT,
118
+ id_token TEXT,
119
+ timestamp INTEGER,
120
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
121
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
122
+ )
123
+ SQL
124
+
125
+ end
126
+
127
+ def synchronize(&block)
128
+ @mutex.synchronize(&block)
129
+ end
130
+
131
+ def bind_token_params(api_key, token_hash)
132
+ token = token_hash["token"] || token_hash[:token] || {}
133
+ [
134
+ api_key,
135
+ token["access_token"] || token[:access_token],
136
+ token["refresh_token"] || token[:refresh_token],
137
+ token["expires_at"] || token[:expires_at],
138
+ token["expires_in"] || token[:expires_in],
139
+ token["token_type"] || token[:token_type] || "Bearer",
140
+ token["scope"] || token[:scope],
141
+ token["id_token"] || token[:id_token],
142
+ token_hash["timestamp"] || token_hash[:timestamp]
143
+ ]
144
+ end
145
+
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module SchwabRb
6
+ module Stream
7
+ class AsyncClient
8
+ def initialize(client)
9
+ @stream = Base.new(client)
10
+ end
11
+
12
+ def on(service_symbol, symbols: nil, fields: nil, &block)
13
+ @stream.on(service_symbol, symbols: symbols, fields: fields, &block)
14
+ self
15
+ end
16
+
17
+ def start
18
+ @stream.start
19
+ end
20
+
21
+ def stop
22
+ @stream.stop
23
+ end
24
+
25
+ def connected?
26
+ @stream.connected?
27
+ end
28
+ end
29
+ end
30
+ end