schwab_rb 0.6.0 → 0.8.2

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,579 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "date"
5
+ require "json"
6
+ require "optparse"
7
+ require "pathname"
8
+ require "fileutils"
9
+ require "time"
10
+
11
+ module SchwabRb
12
+ module CLI
13
+ class Error < StandardError; end
14
+
15
+ # Minimal command dispatcher for the gem's built-in CLI.
16
+ # rubocop:disable Metrics/ClassLength
17
+ class App
18
+ DEFAULT_HISTORY_DIR = "~/.schwab_rb/data/history"
19
+ DEFAULT_OPTIONS_DIR = "~/.schwab_rb/data/options"
20
+ SUPPORTED_FORMATS = %w[csv json].freeze
21
+ OPTION_SAMPLE_CSV_HEADERS = %w[
22
+ contract_type
23
+ symbol
24
+ description
25
+ strike
26
+ expiration_date
27
+ mark
28
+ bid
29
+ bid_size
30
+ ask
31
+ ask_size
32
+ last
33
+ last_size
34
+ open_interest
35
+ total_volume
36
+ delta
37
+ gamma
38
+ theta
39
+ vega
40
+ rho
41
+ volatility
42
+ theoretical_volatility
43
+ theoretical_option_value
44
+ intrinsic_value
45
+ extrinsic_value
46
+ underlying_price
47
+ ].freeze
48
+ PERIOD_TYPES = {
49
+ "day" => SchwabRb::PriceHistory::PeriodTypes::DAY,
50
+ "month" => SchwabRb::PriceHistory::PeriodTypes::MONTH,
51
+ "year" => SchwabRb::PriceHistory::PeriodTypes::YEAR,
52
+ "ytd" => SchwabRb::PriceHistory::PeriodTypes::YEAR_TO_DATE
53
+ }.freeze
54
+ FREQUENCY_ALIASES = SchwabRb::PriceHistory::Downloader::FREQUENCY_ALIASES
55
+
56
+ def initialize(env: ENV, stdout: $stdout, stderr: $stderr)
57
+ @env = env
58
+ @stdout = stdout
59
+ @stderr = stderr
60
+ end
61
+
62
+ def call(argv)
63
+ command = argv.shift
64
+
65
+ case command
66
+ when nil, "help", "--help", "-h"
67
+ handle_help(argv)
68
+ when "login"
69
+ handle_login(argv)
70
+ when "price-history"
71
+ handle_price_history(argv)
72
+ when "sample"
73
+ handle_option_sample(argv)
74
+ else
75
+ print_error("Unknown command `#{command}`.\n\n#{root_help}")
76
+ 1
77
+ end
78
+ rescue OptionParser::ParseError, ArgumentError, Error => e
79
+ print_error(e.message)
80
+ 1
81
+ end
82
+
83
+ private
84
+
85
+ attr_reader :env, :stdout, :stderr
86
+
87
+ def handle_help(argv)
88
+ topic = argv.first
89
+
90
+ case topic
91
+ when nil
92
+ stdout.puts(root_help)
93
+ when "login"
94
+ stdout.puts(login_help)
95
+ when "price-history"
96
+ stdout.puts(price_history_help)
97
+ when "sample"
98
+ stdout.puts(option_sample_help)
99
+ else
100
+ raise Error, "Unknown help topic `#{topic}`."
101
+ end
102
+
103
+ 0
104
+ end
105
+
106
+ # rubocop:disable Metrics/AbcSize
107
+ def handle_login(argv)
108
+ parser = OptionParser.new do |opts|
109
+ opts.banner = login_help
110
+ end
111
+
112
+ parser.parse!(argv)
113
+ raise Error, "Unexpected arguments: #{argv.join(' ')}" if argv.any?
114
+
115
+ credentials = load_credentials(require_callback_url: true)
116
+ token_path = resolved_token_path
117
+
118
+ SchwabRb::Auth.init_client_login(
119
+ credentials.fetch(:api_key),
120
+ credentials.fetch(:app_secret),
121
+ credentials.fetch(:callback_url),
122
+ token_path
123
+ )
124
+
125
+ stdout.puts("Authentication succeeded. Token saved to #{token_path}")
126
+ 0
127
+ rescue JSON::ParserError
128
+ raise Error,
129
+ "The token file at #{resolved_token_path} is not valid JSON. " \
130
+ "Delete it or run `schwab_rb login` to recreate it."
131
+ rescue OAuth2::Error, SchwabRb::Auth::RedirectTimeoutError, SchwabRb::Auth::RedirectServerExitedError => e
132
+ raise Error, "Authentication failed: #{e.message}"
133
+ end
134
+ # rubocop:enable Metrics/AbcSize
135
+
136
+ # rubocop:disable Metrics/AbcSize
137
+ def handle_price_history(argv)
138
+ options = {
139
+ dir: default_history_dir,
140
+ end_date: Date.today,
141
+ format: "json",
142
+ freq: "day",
143
+ need_extended_hours_data: false,
144
+ need_previous_close: false
145
+ }
146
+
147
+ # rubocop:disable Metrics/BlockLength
148
+ parser = OptionParser.new do |opts|
149
+ opts.banner = price_history_help
150
+ opts.on("-s", "--symbol SYMBOL", "Ticker symbol to download") { |value| options[:symbol] = value }
151
+ opts.on("--dir DIRECTORY", "Output directory (default: #{DEFAULT_HISTORY_DIR})") do |value|
152
+ options[:dir] = value
153
+ end
154
+ opts.on("--start-date DATE", "Start date in YYYY-MM-DD format") do |value|
155
+ options[:start_date] = parse_date(value, "start-date")
156
+ end
157
+ opts.on("--end-date DATE", "End date in YYYY-MM-DD format") do |value|
158
+ options[:end_date] = parse_date(value, "end-date")
159
+ end
160
+ opts.on("-p", "--period PERIOD", Integer, "Period value passed through to the API") do |value|
161
+ options[:period] = value
162
+ end
163
+ opts.on("--period-type TYPE", "Period type: #{PERIOD_TYPES.keys.join(', ')}") do |value|
164
+ options[:period_type] = normalize_period_type(value)
165
+ end
166
+ opts.on("--freq FREQ", "Frequency: #{FREQUENCY_ALIASES.keys.join(', ')}") do |value|
167
+ options[:freq] = normalize_frequency(value)
168
+ end
169
+ opts.on("--[no-]need-extended-hours-data", "Include extended hours data") do |value|
170
+ options[:need_extended_hours_data] = value
171
+ end
172
+ opts.on("--[no-]need-previous-close", "Include previous close data") do |value|
173
+ options[:need_previous_close] = value
174
+ end
175
+ opts.on("--format FORMAT", "Output format: #{SUPPORTED_FORMATS.join(', ')}") do |value|
176
+ options[:format] = normalize_format(value)
177
+ end
178
+ end
179
+ # rubocop:enable Metrics/BlockLength
180
+
181
+ parser.parse!(argv)
182
+ raise Error, "Unexpected arguments: #{argv.join(' ')}" if argv.any?
183
+
184
+ options[:end_date] = normalized_end_date(options.fetch(:end_date))
185
+ validate_price_history_options!(options)
186
+
187
+ _, output_path = resolve_price_history_response(options)
188
+
189
+ stdout.puts("Saved #{options[:symbol]} price history to #{output_path}")
190
+ 0
191
+ end
192
+ # rubocop:enable Metrics/AbcSize
193
+
194
+ # rubocop:disable Metrics/AbcSize
195
+ def handle_option_sample(argv)
196
+ options = {
197
+ dir: default_options_dir,
198
+ format: "csv",
199
+ timestamp: Time.now
200
+ }
201
+
202
+ parser = OptionParser.new do |opts|
203
+ opts.banner = option_sample_help
204
+ opts.on("-s", "--symbol SYMBOL", "Ticker symbol to sample") { |value| options[:symbol] = value }
205
+ opts.on("--root ROOT", "Option root to filter and use in the output filename") do |value|
206
+ options[:root] = value
207
+ end
208
+ opts.on("--expiration-date DATE", "Expiration date in YYYY-MM-DD format") do |value|
209
+ options[:expiration_date] = parse_date(value, "expiration-date")
210
+ end
211
+ opts.on("--dir DIRECTORY", "Output directory (default: #{DEFAULT_OPTIONS_DIR})") do |value|
212
+ options[:dir] = value
213
+ end
214
+ opts.on("--format FORMAT", "Output format: #{SUPPORTED_FORMATS.join(', ')}") do |value|
215
+ options[:format] = normalize_format(value)
216
+ end
217
+ end
218
+
219
+ parser.parse!(argv)
220
+ raise Error, "Unexpected arguments: #{argv.join(' ')}" if argv.any?
221
+
222
+ validate_option_sample_options!(options)
223
+
224
+ output_path = resolve_option_sample_response(options)
225
+
226
+ stdout.puts("Saved #{options[:symbol]} option sample to #{output_path}")
227
+ 0
228
+ end
229
+ # rubocop:enable Metrics/AbcSize
230
+
231
+ # rubocop:disable Metrics/AbcSize
232
+ def resolve_price_history_response(options)
233
+ directory = SchwabRb::PathSupport.expand_path(options.fetch(:dir))
234
+ SchwabRb::PriceHistory::Downloader.resolve(
235
+ client: build_non_interactive_client,
236
+ symbol: options.fetch(:symbol),
237
+ start_date: options.fetch(:start_date),
238
+ end_date: options.fetch(:end_date),
239
+ directory: directory,
240
+ frequency: options.fetch(:freq),
241
+ format: options.fetch(:format),
242
+ need_extended_hours_data: options.fetch(:need_extended_hours_data),
243
+ need_previous_close: options.fetch(:need_previous_close),
244
+ period_type: options[:period_type],
245
+ period: options[:period]
246
+ )
247
+ end
248
+ # rubocop:enable Metrics/AbcSize
249
+
250
+ def resolve_option_sample_response(options)
251
+ directory = SchwabRb::PathSupport.expand_path(options.fetch(:dir))
252
+ response = fetch_option_sample(build_non_interactive_client, options)
253
+ FileUtils.mkdir_p(directory)
254
+ output_path = option_sample_output_path(directory, options, response)
255
+ write_option_sample(output_path, response, options)
256
+ output_path
257
+ end
258
+
259
+ def write_option_sample(output_path, response, options)
260
+ File.write(output_path, serialized_option_sample(response, options))
261
+ end
262
+
263
+ def serialized_option_sample(response, options)
264
+ case options.fetch(:format)
265
+ when "json"
266
+ JSON.pretty_generate(response)
267
+ when "csv"
268
+ serialized_option_sample_csv(response, options)
269
+ else
270
+ raise Error, "Unsupported format `#{options[:format]}`."
271
+ end
272
+ end
273
+
274
+ def serialized_option_sample_csv(response, options)
275
+ sample_timestamp = options.fetch(:timestamp).utc.iso8601
276
+
277
+ CSV.generate do |csv|
278
+ csv << OPTION_SAMPLE_CSV_HEADERS
279
+ option_sample_rows(response).each do |option|
280
+ csv << option_sample_csv_row(response, option, sample_timestamp)
281
+ end
282
+ end
283
+ end
284
+
285
+ # rubocop:disable Metrics/AbcSize
286
+ def option_sample_csv_row(response, option, _sample_timestamp)
287
+ [
288
+ option[:putCall],
289
+ option[:symbol],
290
+ option[:description],
291
+ option[:strikePrice],
292
+ normalize_option_date(option[:expirationDate]),
293
+ option[:mark],
294
+ option[:bid],
295
+ option[:bidSize],
296
+ option[:ask],
297
+ option[:askSize],
298
+ option[:last],
299
+ option[:lastSize],
300
+ option[:openInterest],
301
+ option[:totalVolume],
302
+ option[:delta],
303
+ option[:gamma],
304
+ option[:theta],
305
+ option[:vega],
306
+ option[:rho],
307
+ option[:volatility],
308
+ option[:theoreticalVolatility],
309
+ option[:theoreticalOptionValue],
310
+ option[:intrinsicValue],
311
+ option[:extrinsicValue],
312
+ response[:underlyingPrice]
313
+ ]
314
+ end
315
+ # rubocop:enable Metrics/AbcSize
316
+
317
+ def fetch_option_sample(client, options)
318
+ expiration_date = options.fetch(:expiration_date)
319
+
320
+ response = client.get_option_chain(
321
+ SchwabRb::PriceHistory::Downloader.api_symbol(options.fetch(:symbol)),
322
+ contract_type: SchwabRb::Option::ContractTypes::ALL,
323
+ strike_range: SchwabRb::Option::StrikeRanges::ALL,
324
+ from_date: expiration_date,
325
+ to_date: expiration_date,
326
+ return_data_objects: false
327
+ )
328
+
329
+ filter_option_sample_response(response, options[:root])
330
+ end
331
+
332
+ def option_sample_rows(response)
333
+ rows = [response[:callExpDateMap], response[:putExpDateMap]].compact.flat_map do |date_map|
334
+ option_rows_from_date_map(date_map)
335
+ end
336
+
337
+ rows.sort_by do |option|
338
+ [
339
+ normalize_option_date(option[:expirationDate]).to_s,
340
+ option[:putCall].to_s,
341
+ option[:strikePrice].to_f
342
+ ]
343
+ end
344
+ end
345
+
346
+ def option_rows_from_date_map(date_map)
347
+ date_map.values.flat_map do |strikes|
348
+ strikes.values.flatten.map { |option| option.transform_keys(&:to_sym) }
349
+ end
350
+ end
351
+
352
+ def filter_option_sample_response(response, option_root)
353
+ return response if blank?(option_root)
354
+
355
+ normalized_root = option_root.to_s.strip.upcase
356
+ filtered_call_map = filter_option_date_map_by_root(response[:callExpDateMap], normalized_root)
357
+ filtered_put_map = filter_option_date_map_by_root(response[:putExpDateMap], normalized_root)
358
+
359
+ response.merge(
360
+ callExpDateMap: filtered_call_map,
361
+ putExpDateMap: filtered_put_map
362
+ )
363
+ end
364
+
365
+ def filter_option_date_map_by_root(date_map, option_root)
366
+ return {} unless date_map
367
+
368
+ date_map.each_with_object({}) do |(expiration_key, strikes), filtered_dates|
369
+ filtered_strikes = strikes.each_with_object({}) do |(strike, contracts), filtered_by_strike|
370
+ matching_contracts = contracts.select { |contract| contract[:optionRoot].to_s.upcase == option_root }
371
+ filtered_by_strike[strike] = matching_contracts if matching_contracts.any?
372
+ end
373
+
374
+ filtered_dates[expiration_key] = filtered_strikes if filtered_strikes.any?
375
+ end
376
+ end
377
+
378
+ def normalize_option_date(value)
379
+ return if value.nil?
380
+
381
+ Date.parse(value.to_s).iso8601
382
+ end
383
+
384
+ def normalize_option_timestamp(value)
385
+ return if value.nil?
386
+
387
+ Time.at(value / 1000.0).utc.iso8601
388
+ end
389
+
390
+ def build_non_interactive_client
391
+ credentials = load_credentials(require_callback_url: false)
392
+ token_path = resolved_token_path
393
+
394
+ client = SchwabRb::Auth.init_client_token_file(
395
+ credentials.fetch(:api_key),
396
+ credentials.fetch(:app_secret),
397
+ token_path
398
+ )
399
+ client.refresh!
400
+
401
+ return client unless client.session.expired?
402
+
403
+ raise Error, "No valid API token found at #{token_path}. Run `schwab_rb login` to authenticate."
404
+ rescue Errno::ENOENT
405
+ raise Error, "No valid API token found at #{token_path}. Run `schwab_rb login` to authenticate."
406
+ rescue JSON::ParserError
407
+ raise Error, "The token file at #{token_path} is not valid JSON. Run `schwab_rb login` to recreate it."
408
+ rescue OAuth2::Error => e
409
+ raise Error, "Unable to use the token at #{token_path}: #{e.message}. Run `schwab_rb login` to refresh it."
410
+ end
411
+
412
+ # rubocop:disable Metrics/AbcSize
413
+ def load_credentials(require_callback_url:)
414
+ api_key = env["SCHWAB_API_KEY"]
415
+ app_secret = env["SCHWAB_APP_SECRET"]
416
+ callback_url = env["SCHWAB_APP_CALLBACK_URL"] || env["APP_CALLBACK_URL"]
417
+
418
+ missing = []
419
+ missing << "SCHWAB_API_KEY" if blank?(api_key)
420
+ missing << "SCHWAB_APP_SECRET" if blank?(app_secret)
421
+ missing << "SCHWAB_APP_CALLBACK_URL" if require_callback_url && blank?(callback_url)
422
+
423
+ return { api_key: api_key, app_secret: app_secret, callback_url: callback_url } if missing.empty?
424
+
425
+ raise Error, "Missing required environment variables: #{missing.join(', ')}"
426
+ end
427
+ # rubocop:enable Metrics/AbcSize
428
+
429
+ def validate_price_history_options!(options)
430
+ raise Error, "The `--symbol` option is required." if blank?(options[:symbol])
431
+ raise Error, "The `--start-date` option is required." unless options[:start_date]
432
+ return unless options[:end_date] < options[:start_date]
433
+
434
+ raise Error, "`--end-date` must be on or after `--start-date`."
435
+ end
436
+
437
+ def validate_option_sample_options!(options)
438
+ raise Error, "The `--symbol` option is required." if blank?(options[:symbol])
439
+ raise Error, "The `--expiration-date` option is required." unless options[:expiration_date]
440
+ end
441
+
442
+ def resolved_token_path
443
+ token_path = env["SCHWAB_TOKEN_PATH"] || env["TOKEN_PATH"] || SchwabRb::Constants::DEFAULT_TOKEN_PATH
444
+ SchwabRb::PathSupport.expand_path(token_path)
445
+ end
446
+
447
+ def default_history_dir
448
+ SchwabRb::PathSupport.expand_path(DEFAULT_HISTORY_DIR)
449
+ end
450
+
451
+ def default_options_dir
452
+ SchwabRb::PathSupport.expand_path(DEFAULT_OPTIONS_DIR)
453
+ end
454
+
455
+ def normalize_format(value)
456
+ format = value.to_s.downcase
457
+ return format if SUPPORTED_FORMATS.include?(format)
458
+
459
+ raise Error, "Unsupported format `#{value}`. Use one of: #{SUPPORTED_FORMATS.join(', ')}"
460
+ end
461
+
462
+ def normalize_frequency(value)
463
+ normalized = value.to_s.downcase.delete(" ")
464
+ return normalized if FREQUENCY_ALIASES.key?(normalized)
465
+
466
+ raise Error, "Unsupported frequency `#{value}`. Use one of: #{FREQUENCY_ALIASES.keys.join(', ')}"
467
+ end
468
+
469
+ def normalize_period_type(value)
470
+ normalized = value.to_s.downcase
471
+ return PERIOD_TYPES.fetch(normalized) if PERIOD_TYPES.key?(normalized)
472
+
473
+ raise Error, "Unsupported period type `#{value}`. Use one of: #{PERIOD_TYPES.keys.join(', ')}"
474
+ end
475
+
476
+ def parse_date(value, option_name)
477
+ Date.iso8601(value)
478
+ rescue Date::Error
479
+ raise Error, "Invalid #{option_name} `#{value}`. Use YYYY-MM-DD."
480
+ end
481
+
482
+ def normalized_end_date(end_date)
483
+ return end_date unless end_date == Date.today
484
+
485
+ end_date - 1
486
+ end
487
+
488
+ def canonical_output_path(directory, options)
489
+ SchwabRb::PriceHistory::Downloader.canonical_output_path(
490
+ directory: directory,
491
+ symbol: options.fetch(:symbol),
492
+ frequency: options.fetch(:freq),
493
+ format: options.fetch(:format)
494
+ )
495
+ end
496
+
497
+ def option_sample_output_path(directory, options, response)
498
+ File.join(
499
+ directory,
500
+ [
501
+ SchwabRb::PriceHistory::Downloader.sanitize_symbol(
502
+ options[:root] || option_sample_root(response, options.fetch(:symbol))
503
+ ),
504
+ "exp#{options.fetch(:expiration_date).iso8601}",
505
+ options.fetch(:timestamp).strftime("%Y-%m-%d_%H-%M-%S")
506
+ ].join("_") + ".#{options.fetch(:format)}"
507
+ )
508
+ end
509
+
510
+ def option_sample_root(response, fallback_symbol)
511
+ first_option = option_sample_rows(response).find { |option| !blank?(option[:optionRoot]) }
512
+ first_option ? first_option[:optionRoot] : fallback_symbol
513
+ end
514
+
515
+ def blank?(value)
516
+ value.nil? || value.to_s.strip.empty?
517
+ end
518
+
519
+ def print_error(message)
520
+ stderr.puts(message)
521
+ end
522
+
523
+ def root_help
524
+ <<~HELP
525
+ Usage: schwab_rb COMMAND [options]
526
+
527
+ Commands:
528
+ help [command] Show general or command-specific help
529
+ login Authenticate and store a shared Schwab token
530
+ price-history Download price history data to disk
531
+ sample Download an option-chain sample for one expiration
532
+ HELP
533
+ end
534
+
535
+ def login_help
536
+ <<~HELP
537
+ Usage: schwab_rb login
538
+
539
+ Authenticates with Schwab in a browser and stores the token at #{resolved_token_path}.
540
+ Required environment variables: SCHWAB_API_KEY, SCHWAB_APP_SECRET, SCHWAB_APP_CALLBACK_URL
541
+ HELP
542
+ end
543
+
544
+ def price_history_help
545
+ <<~HELP
546
+ Usage: schwab_rb price-history --symbol SYMBOL --start-date YYYY-MM-DD [options]
547
+
548
+ Options:
549
+ -s, --symbol SYMBOL Ticker symbol to download
550
+ --dir DIRECTORY Output directory (default: #{DEFAULT_HISTORY_DIR})
551
+ --start-date DATE Start date in YYYY-MM-DD format
552
+ --end-date DATE End date in YYYY-MM-DD format (default: today)
553
+ -p, --period PERIOD Period value passed through to the API
554
+ --period-type TYPE Period type: #{PERIOD_TYPES.keys.join(', ')}
555
+ --freq FREQ Frequency: #{FREQUENCY_ALIASES.keys.join(', ')} (default: day)
556
+ --[no-]need-extended-hours-data Include extended hours data (default: false)
557
+ --[no-]need-previous-close Include previous close data (default: false)
558
+ --format FORMAT Output format: #{SUPPORTED_FORMATS.join(', ')} (default: json)
559
+ HELP
560
+ end
561
+
562
+ def option_sample_help
563
+ <<~HELP
564
+ Usage: schwab_rb sample --symbol SYMBOL --expiration-date YYYY-MM-DD [options]
565
+
566
+ Downloads the full option chain for a single expiration and writes a timestamped sample file.
567
+
568
+ Options:
569
+ -s, --symbol SYMBOL Underlying symbol to sample
570
+ --root ROOT Option root to filter and use in the output filename
571
+ --expiration-date DATE Expiration date in YYYY-MM-DD format
572
+ --dir DIRECTORY Output directory (default: #{DEFAULT_OPTIONS_DIR})
573
+ --format FORMAT Output format: #{SUPPORTED_FORMATS.join(', ')} (default: csv)
574
+ HELP
575
+ end
576
+ end
577
+ # rubocop:enable Metrics/ClassLength
578
+ end
579
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli/app"
4
+
5
+ module SchwabRb
6
+ module CLI
7
+ end
8
+ end
@@ -25,8 +25,14 @@ module SchwabRb
25
25
  !has_external_logger? && !@silence_output
26
26
  end
27
27
 
28
+ def default_log_file
29
+ File.join(@schwab_home, "schwab_rb.log")
30
+ end
31
+
28
32
  def effective_log_file
29
- @log_file || (ENV["LOGFILE"] if ENV["LOGFILE"] && !ENV["LOGFILE"].empty?)
33
+ @log_file ||
34
+ (ENV["LOGFILE"] if ENV["LOGFILE"] && !ENV["LOGFILE"].empty?) ||
35
+ default_log_file
30
36
  end
31
37
  end
32
38
 
@@ -2,8 +2,8 @@
2
2
 
3
3
  module SchwabRb
4
4
  module Constants
5
- SCHWAB_BASE_URL="https://api.schwabapi.com"
6
- TOKEN_ENDPOINT="https://api.schwabapi.com/v1/oauth/token"
7
- DEFAULT_TOKEN_PATH="./token.json"
5
+ SCHWAB_BASE_URL = "https://api.schwabapi.com"
6
+ TOKEN_ENDPOINT = "https://api.schwabapi.com/v1/oauth/token"
7
+ DEFAULT_TOKEN_PATH = "~/.schwab_rb/token.json"
8
8
  end
9
- end
9
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module SchwabRb
6
+ # Common path expansion and directory helpers for token and data files.
7
+ module PathSupport
8
+ module_function
9
+
10
+ def expand_path(path)
11
+ File.expand_path(path.to_s)
12
+ end
13
+
14
+ def ensure_parent_directory(path)
15
+ directory = File.dirname(expand_path(path))
16
+ FileUtils.mkdir_p(directory)
17
+ end
18
+ end
19
+ end