schwab_rb 0.8.1 → 0.9.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: e92f8e69c61f92f34a54eb5ae7b5b07a8734dbe71a221006972a0988aedc5fd5
4
- data.tar.gz: a0635b9f44605a22ee31140f8b8601d7f0cf64d28dcdf41e5f711d680efa7dfd
3
+ metadata.gz: 4886933bb9c99f66735ff9503b0904ac55a605e653550d413713c8d33e412a79
4
+ data.tar.gz: d4f9476eeb6f97b00d890295e1edb6002c0780f4c34eb1b321d2355d55853e6a
5
5
  SHA512:
6
- metadata.gz: c30b898de7b8e933960f38c62b82364210ec89abe603b773bf7e0b012e74ce8e3230f6f28c015fc100a46a2a1a7596e6896758f3a695dd9707c63ef92eb1df8d
7
- data.tar.gz: 8f0e880a9a0227ad9b02c1d8ed95f9b66d49e2303ac5ad955f8cd9cc7fa0cabd893e72a5091f6990c352eacf5939e9a75eee292cf0f592dbe7c74b53baff897c
6
+ metadata.gz: 7998b7bbbfac20de7e62b3dc2ccc6472be8a19b2956f2a2f93c1fbd441c0ff0e5713004f27cc96b6b27286aead9457a16aca4d92a62411ea1a662a6f67d9f49f
7
+ data.tar.gz: e911c3bf6958b4dc0858669e0f38795466a4abf6d202805f6f12fa19ec6a991b1819b8743c682b89e12908595cb856ca8a15bea4b1011a4d6549f9a4a40feb6b
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: schwab-rb-cli
3
- description: Use the installed schwab_rb CLI for authentication and price history downloads. Activate when a user wants to run the schwab_rb command, log in to Schwab, fetch market data, troubleshoot CLI installation or environment variables, or save price history from the command line.
3
+ description: Use the installed schwab_rb CLI for authentication, option-chain samples, and price history downloads. Activate when a user wants to run the schwab_rb command, log in to Schwab, fetch market data, troubleshoot CLI installation or environment variables, or save CLI output from the command line.
4
4
  ---
5
5
 
6
6
  # Schwab RB CLI Skill
@@ -9,8 +9,8 @@ Use this skill when the task should be completed through the installed `schwab_r
9
9
 
10
10
  ## When to use
11
11
 
12
- - The user wants to run `schwab_rb help`, `schwab_rb login`, or `schwab_rb price-history`
13
- - The user wants to download price history data to JSON or CSV
12
+ - The user wants to run `schwab_rb help`, `schwab_rb login`, `schwab_rb sample`, or `schwab_rb price-history`
13
+ - The user wants to download an option-chain sample or price history data to JSON or CSV
14
14
  - The user needs help with CLI installation, `asdf` shims, or missing `SCHWAB_*` environment variables
15
15
  - The user wants the agent to verify the CLI works on the local machine
16
16
 
@@ -39,7 +39,13 @@ The wrapper only attempts to execute the globally installed `schwab_rb` command
39
39
  skills/schwab-rb-cli/scripts/run_schwab_rb.sh login
40
40
  ```
41
41
 
42
- 5. For data downloads, run:
42
+ 5. For option-chain samples, run:
43
+
44
+ ```bash
45
+ skills/schwab-rb-cli/scripts/run_schwab_rb.sh sample --symbol SPX --expiration-date 2026-04-17
46
+ ```
47
+
48
+ 6. For price history downloads, run:
43
49
 
44
50
  ```bash
45
51
  skills/schwab-rb-cli/scripts/run_schwab_rb.sh price-history --symbol AAPL --start-date 2026-03-01
@@ -5,6 +5,7 @@
5
5
  ```bash
6
6
  skills/schwab-rb-cli/scripts/run_schwab_rb.sh help
7
7
  skills/schwab-rb-cli/scripts/run_schwab_rb.sh help price-history
8
+ skills/schwab-rb-cli/scripts/run_schwab_rb.sh help sample
8
9
  ```
9
10
 
10
11
  ## Authentication
@@ -49,6 +50,25 @@ skills/schwab-rb-cli/scripts/run_schwab_rb.sh price-history \
49
50
  --dir /tmp/schwab_rb_data
50
51
  ```
51
52
 
53
+ ## Option-chain samples
54
+
55
+ Default CSV output:
56
+
57
+ ```bash
58
+ skills/schwab-rb-cli/scripts/run_schwab_rb.sh sample --symbol SPX --expiration-date 2026-04-17
59
+ ```
60
+
61
+ Filter by root and write JSON to a custom directory:
62
+
63
+ ```bash
64
+ skills/schwab-rb-cli/scripts/run_schwab_rb.sh sample \
65
+ --symbol SPX \
66
+ --root SPXW \
67
+ --expiration-date 2026-04-17 \
68
+ --format json \
69
+ --dir /tmp/schwab_rb_options
70
+ ```
71
+
52
72
  ## Installation troubleshooting
53
73
 
54
74
  Install the gem from the repo:
data/.rubocop.yml CHANGED
@@ -41,3 +41,4 @@ Metrics/ClassLength:
41
41
  Metrics/ModuleLength:
42
42
  Exclude:
43
43
  - 'spec/**/*'
44
+ - 'lib/schwab_rb/option_sample/downloader.rb'
data/README.md CHANGED
@@ -83,7 +83,7 @@ Before using the gem, ensure you have the following environment variables set:
83
83
  - `APP_CALLBACK_URL`: The callback URL for your application.
84
84
  - `TOKEN_PATH`: Path to store the authentication token.
85
85
  - `SCHWAB_ACCOUNT_NUMBER`: Your Schwab account number.
86
- - `SCHWAB_LOGFILE`: (Optional) Path to the log file. Defaults to `STDOUT`.
86
+ - `SCHWAB_LOGFILE`: (Optional) Path to the log file. Defaults to `~/.schwab_rb/schwab_rb.log`.
87
87
  - `SCHWAB_LOG_LEVEL`: (Optional) Log level for the logger. Defaults to `WARN`. Possible values: `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`.
88
88
  - `SCHWAB_SILENCE_OUTPUT`: (Optional) Set to `true` to disable logging output. Defaults to `false`.
89
89
 
@@ -91,7 +91,7 @@ You can also configure logging programmatically:
91
91
 
92
92
  ```ruby
93
93
  SchwabRb.configure do |config|
94
- config.logger = Logger.new(STDOUT)
94
+ config.logger = Logger.new($stdout)
95
95
  config.log_level = 'INFO'
96
96
  config.silence_output = false
97
97
  end
@@ -4,19 +4,19 @@
4
4
  # Script to fetch account numbers data and save as fixture
5
5
  # Usage: ruby examples/fetch_account_numbers.rb
6
6
 
7
- require_relative '../lib/schwab_rb'
8
- require 'dotenv'
9
- require 'json'
10
- require 'fileutils'
7
+ require_relative "../lib/schwab_rb"
8
+ require "dotenv"
9
+ require "json"
10
+ require "fileutils"
11
11
 
12
12
  Dotenv.load
13
13
 
14
14
  def create_client
15
- token_path = ENV['TOKEN_PATH'] || 'schwab_token.json'
15
+ token_path = ENV["TOKEN_PATH"] || "schwab_token.json"
16
16
  SchwabRb::Auth.init_client_easy(
17
- ENV['SCHWAB_API_KEY'],
18
- ENV['SCHWAB_APP_SECRET'],
19
- ENV['APP_CALLBACK_URL'],
17
+ ENV.fetch("SCHWAB_API_KEY", nil),
18
+ ENV.fetch("SCHWAB_APP_SECRET", nil),
19
+ ENV.fetch("APP_CALLBACK_URL", nil),
20
20
  token_path
21
21
  )
22
22
  end
@@ -24,26 +24,23 @@ end
24
24
  def fetch_account_numbers
25
25
  client = create_client
26
26
  puts "Fetching account numbers..."
27
-
27
+
28
28
  response = client.get_account_numbers
29
29
  parsed_data = JSON.parse(response.body, symbolize_names: true)
30
-
30
+
31
31
  # Create fixtures directory if it doesn't exist
32
- fixtures_dir = File.join(__dir__, '..', 'spec', 'fixtures')
32
+ fixtures_dir = File.join(__dir__, "..", "spec", "fixtures")
33
33
  FileUtils.mkdir_p(fixtures_dir)
34
-
34
+
35
35
  # Save the raw response
36
- fixture_file = File.join(fixtures_dir, 'account_numbers.json')
36
+ fixture_file = File.join(fixtures_dir, "account_numbers.json")
37
37
  File.write(fixture_file, JSON.pretty_generate(parsed_data))
38
-
38
+
39
39
  puts "Account numbers data saved to: #{fixture_file}"
40
40
  puts "Sample data: #{parsed_data.first(2)}"
41
-
42
- rescue => e
41
+ rescue StandardError => e
43
42
  puts "Error fetching account numbers: #{e.message}"
44
43
  puts e.backtrace.first(3)
45
44
  end
46
45
 
47
- if __FILE__ == $0
48
- fetch_account_numbers
49
- end
46
+ fetch_account_numbers if __FILE__ == $PROGRAM_NAME
@@ -4,19 +4,19 @@
4
4
  # Script to fetch user preferences data and save as fixture
5
5
  # Usage: ruby examples/fetch_user_preferences.rb
6
6
 
7
- require_relative '../lib/schwab_rb'
8
- require 'dotenv'
9
- require 'json'
10
- require 'fileutils'
7
+ require_relative "../lib/schwab_rb"
8
+ require "dotenv"
9
+ require "json"
10
+ require "fileutils"
11
11
 
12
12
  Dotenv.load
13
13
 
14
14
  def create_client
15
- token_path = ENV['TOKEN_PATH'] || 'schwab_token.json'
15
+ token_path = ENV["TOKEN_PATH"] || "schwab_token.json"
16
16
  SchwabRb::Auth.init_client_easy(
17
- ENV['SCHWAB_API_KEY'],
18
- ENV['SCHWAB_APP_SECRET'],
19
- ENV['APP_CALLBACK_URL'],
17
+ ENV.fetch("SCHWAB_API_KEY", nil),
18
+ ENV.fetch("SCHWAB_APP_SECRET", nil),
19
+ ENV.fetch("APP_CALLBACK_URL", nil),
20
20
  token_path
21
21
  )
22
22
  end
@@ -24,26 +24,23 @@ end
24
24
  def fetch_user_preferences
25
25
  client = create_client
26
26
  puts "Fetching user preferences..."
27
-
27
+
28
28
  response = client.get_user_preferences
29
29
  parsed_data = JSON.parse(response.body, symbolize_names: true)
30
-
30
+
31
31
  # Create fixtures directory if it doesn't exist
32
- fixtures_dir = File.join(__dir__, '..', 'spec', 'fixtures')
32
+ fixtures_dir = File.join(__dir__, "..", "spec", "fixtures")
33
33
  FileUtils.mkdir_p(fixtures_dir)
34
-
34
+
35
35
  # Save the raw response
36
- fixture_file = File.join(fixtures_dir, 'user_preferences.json')
36
+ fixture_file = File.join(fixtures_dir, "user_preferences.json")
37
37
  File.write(fixture_file, JSON.pretty_generate(parsed_data))
38
-
38
+
39
39
  puts "User preferences data saved to: #{fixture_file}"
40
40
  puts "Sample data keys: #{parsed_data.keys.first(5)}"
41
-
42
- rescue => e
41
+ rescue StandardError => e
43
42
  puts "Error fetching user preferences: #{e.message}"
44
43
  puts e.backtrace.first(3)
45
44
  end
46
45
 
47
- if __FILE__ == $0
48
- fetch_user_preferences
49
- end
46
+ fetch_user_preferences if __FILE__ == $PROGRAM_NAME
@@ -15,6 +15,8 @@ Dotenv.load
15
15
  #
16
16
 
17
17
  # SchwabRb::Configuration.configure do |config|
18
+ # config.schwab_home = "/path/to/your/schwab_rb_home"
19
+ # config.log_level = "DEBUG"
18
20
  # end
19
21
 
20
22
  CURRENT_ACCT = "TRADING_BROKERAGE_ACCOUNT"
@@ -46,25 +48,30 @@ oco_order = SchwabRb::Orders::OrderFactory.build(
46
48
  short_leg_symbol: "SPXW 251020P06530000",
47
49
  long_leg_symbol: "SPXW 251020P06510000",
48
50
  order_type: SchwabRb::Order::Types::STOP_LIMIT,
49
- stop:
50
- price: 0.3,
51
+ price: 2.1,
52
+ stop_price: 2.0,
51
53
  order_instruction: :close,
52
54
  credit_debit: :debit,
53
- quantity: 1
55
+ quantity: 2
54
56
  },
55
57
  {
56
58
  strategy_type: SchwabRb::Order::ComplexOrderStrategyTypes::VERTICAL,
57
59
  short_leg_symbol: "SPXW 251020C06770000",
58
60
  long_leg_symbol: "SPXW 251020C06790000",
59
61
  order_type: SchwabRb::Order::Types::STOP_LIMIT,
60
- price: 0.3,
62
+ price: 2.1,
63
+ stop_price: 2.0,
61
64
  order_instruction: :close,
62
65
  credit_debit: :debit,
63
- quantity: 1
66
+ quantity: 2
64
67
  }
65
68
  ]
66
69
  )
67
70
 
68
71
  built_order = oco_order.build
69
72
 
70
- response = client.place_order(built_order, account_name: CURRENT_ACCT)
73
+ binding.pry
74
+
75
+ # response = client.place_order(built_order, account_name: CURRENT_ACCT)
76
+
77
+ binding.pry
@@ -1,12 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "csv"
4
3
  require "date"
5
4
  require "json"
6
5
  require "optparse"
7
6
  require "pathname"
8
- require "fileutils"
9
- require "time"
10
7
 
11
8
  module SchwabRb
12
9
  module CLI
@@ -18,33 +15,6 @@ module SchwabRb
18
15
  DEFAULT_HISTORY_DIR = "~/.schwab_rb/data/history"
19
16
  DEFAULT_OPTIONS_DIR = "~/.schwab_rb/data/options"
20
17
  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
18
  PERIOD_TYPES = {
49
19
  "day" => SchwabRb::PriceHistory::PeriodTypes::DAY,
50
20
  "month" => SchwabRb::PriceHistory::PeriodTypes::MONTH,
@@ -221,14 +191,13 @@ module SchwabRb
221
191
 
222
192
  validate_option_sample_options!(options)
223
193
 
224
- output_path = resolve_option_sample_response(options)
194
+ _, output_path = resolve_option_sample_response(options)
225
195
 
226
196
  stdout.puts("Saved #{options[:symbol]} option sample to #{output_path}")
227
197
  0
228
198
  end
229
199
  # rubocop:enable Metrics/AbcSize
230
200
 
231
- # rubocop:disable Metrics/AbcSize
232
201
  def resolve_price_history_response(options)
233
202
  directory = SchwabRb::PathSupport.expand_path(options.fetch(:dir))
234
203
  SchwabRb::PriceHistory::Downloader.resolve(
@@ -245,148 +214,20 @@ module SchwabRb
245
214
  period: options[:period]
246
215
  )
247
216
  end
248
- # rubocop:enable Metrics/AbcSize
249
217
 
250
218
  def resolve_option_sample_response(options)
251
219
  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
220
+ SchwabRb::OptionSample::Downloader.resolve(
221
+ client: build_non_interactive_client,
222
+ symbol: options.fetch(:symbol),
223
+ root: options[:root],
224
+ expiration_date: options.fetch(:expiration_date),
225
+ directory: directory,
226
+ format: options.fetch(:format),
227
+ timestamp: options.fetch(:timestamp)
362
228
  )
363
229
  end
364
230
 
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
231
  def build_non_interactive_client
391
232
  credentials = load_credentials(require_callback_url: false)
392
233
  token_path = resolved_token_path
@@ -494,24 +335,6 @@ module SchwabRb
494
335
  )
495
336
  end
496
337
 
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
338
  def blank?(value)
516
339
  value.nil? || value.to_s.strip.empty?
517
340
  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
 
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "json"
5
+ require "time"
6
+ require "fileutils"
7
+
8
+ module SchwabRb
9
+ class OptionSample
10
+ # Public file-oriented downloader for one-expiration option chain samples.
11
+ module Downloader
12
+ SUPPORTED_FORMATS = %w[csv json].freeze
13
+ CSV_HEADERS = %w[
14
+ contract_type
15
+ symbol
16
+ description
17
+ strike
18
+ expiration_date
19
+ mark
20
+ bid
21
+ bid_size
22
+ ask
23
+ ask_size
24
+ last
25
+ last_size
26
+ open_interest
27
+ total_volume
28
+ delta
29
+ gamma
30
+ theta
31
+ vega
32
+ rho
33
+ volatility
34
+ theoretical_volatility
35
+ theoretical_option_value
36
+ intrinsic_value
37
+ extrinsic_value
38
+ underlying_price
39
+ ].freeze
40
+
41
+ module_function
42
+
43
+ # rubocop:disable Metrics/ParameterLists
44
+ def resolve(client:, symbol:, expiration_date:, directory:, format:, timestamp:, root: nil)
45
+ response = fetch(
46
+ client: client,
47
+ symbol: symbol,
48
+ expiration_date: expiration_date,
49
+ root: root
50
+ )
51
+
52
+ FileUtils.mkdir_p(directory)
53
+ path = output_path(
54
+ directory: directory,
55
+ symbol: symbol,
56
+ expiration_date: expiration_date,
57
+ format: format,
58
+ timestamp: timestamp,
59
+ root: root,
60
+ response: response
61
+ )
62
+ File.write(path, serialize(response: response, format: format, timestamp: timestamp))
63
+
64
+ [response, path]
65
+ end
66
+ # rubocop:enable Metrics/ParameterLists
67
+
68
+ def fetch(client:, symbol:, expiration_date:, root: nil)
69
+ response = client.get_option_chain(
70
+ SchwabRb::PriceHistory::Downloader.api_symbol(symbol),
71
+ contract_type: SchwabRb::Option::ContractTypes::ALL,
72
+ strike_range: SchwabRb::Option::StrikeRanges::ALL,
73
+ from_date: expiration_date,
74
+ to_date: expiration_date,
75
+ return_data_objects: false
76
+ )
77
+
78
+ filter_response_by_root(response, root)
79
+ end
80
+
81
+ def filter_response_by_root(response, option_root)
82
+ return response if blank?(option_root)
83
+
84
+ normalized_root = option_root.to_s.strip.upcase
85
+
86
+ response.merge(
87
+ callExpDateMap: filter_date_map_by_root(response[:callExpDateMap], normalized_root),
88
+ putExpDateMap: filter_date_map_by_root(response[:putExpDateMap], normalized_root)
89
+ )
90
+ end
91
+
92
+ def filter_date_map_by_root(date_map, option_root)
93
+ return {} unless date_map
94
+
95
+ date_map.each_with_object({}) do |(expiration_key, strikes), filtered_dates|
96
+ filtered_strikes = strikes.each_with_object({}) do |(strike, contracts), filtered_by_strike|
97
+ matching_contracts = contracts.select { |contract| contract[:optionRoot].to_s.upcase == option_root }
98
+ filtered_by_strike[strike] = matching_contracts if matching_contracts.any?
99
+ end
100
+
101
+ filtered_dates[expiration_key] = filtered_strikes if filtered_strikes.any?
102
+ end
103
+ end
104
+
105
+ # rubocop:disable Metrics/ParameterLists
106
+ def output_path(directory:, symbol:, expiration_date:, format:, timestamp:, root: nil, response: nil)
107
+ selected_root = root || option_root(response, symbol)
108
+
109
+ File.join(
110
+ directory,
111
+ [
112
+ SchwabRb::PriceHistory::Downloader.sanitize_symbol(selected_root),
113
+ "exp#{expiration_date.iso8601}",
114
+ timestamp.strftime("%Y-%m-%d_%H-%M-%S")
115
+ ].join("_") + ".#{format}"
116
+ )
117
+ end
118
+ # rubocop:enable Metrics/ParameterLists
119
+
120
+ def serialize(response:, format:, timestamp:)
121
+ case format
122
+ when "json"
123
+ JSON.pretty_generate(response)
124
+ when "csv"
125
+ serialize_csv(response: response, timestamp: timestamp)
126
+ else
127
+ raise ArgumentError, "Unsupported format `#{format}`."
128
+ end
129
+ end
130
+
131
+ def serialize_csv(response:, timestamp:)
132
+ sample_timestamp = timestamp.utc.iso8601
133
+
134
+ CSV.generate do |csv|
135
+ csv << CSV_HEADERS
136
+ rows(response).each do |option|
137
+ csv << csv_row(response, option, sample_timestamp)
138
+ end
139
+ end
140
+ end
141
+
142
+ # rubocop:disable Metrics/AbcSize
143
+ def csv_row(response, option, _sample_timestamp)
144
+ [
145
+ option[:putCall],
146
+ option[:symbol],
147
+ option[:description],
148
+ option[:strikePrice],
149
+ normalize_option_date(option[:expirationDate]),
150
+ option[:mark],
151
+ option[:bid],
152
+ option[:bidSize],
153
+ option[:ask],
154
+ option[:askSize],
155
+ option[:last],
156
+ option[:lastSize],
157
+ option[:openInterest],
158
+ option[:totalVolume],
159
+ option[:delta],
160
+ option[:gamma],
161
+ option[:theta],
162
+ option[:vega],
163
+ option[:rho],
164
+ option[:volatility],
165
+ option[:theoreticalVolatility],
166
+ option[:theoreticalOptionValue],
167
+ option[:intrinsicValue],
168
+ option[:extrinsicValue],
169
+ response[:underlyingPrice]
170
+ ]
171
+ end
172
+ # rubocop:enable Metrics/AbcSize
173
+
174
+ def rows(response)
175
+ extracted_rows = [response[:callExpDateMap], response[:putExpDateMap]].compact.flat_map do |date_map|
176
+ rows_from_date_map(date_map)
177
+ end
178
+
179
+ extracted_rows.sort_by do |option|
180
+ [
181
+ normalize_option_date(option[:expirationDate]).to_s,
182
+ option[:putCall].to_s,
183
+ option[:strikePrice].to_f
184
+ ]
185
+ end
186
+ end
187
+
188
+ def rows_from_date_map(date_map)
189
+ date_map.values.flat_map do |strikes|
190
+ strikes.values.flatten.map { |option| option.transform_keys(&:to_sym) }
191
+ end
192
+ end
193
+
194
+ def option_root(response, fallback_symbol)
195
+ first_option = rows(response).find { |option| !blank?(option[:optionRoot]) }
196
+ first_option ? first_option[:optionRoot] : fallback_symbol
197
+ end
198
+
199
+ def normalize_option_date(value)
200
+ return if value.nil?
201
+
202
+ Date.parse(value.to_s).iso8601
203
+ end
204
+
205
+ def blank?(value)
206
+ value.nil? || value.to_s.strip.empty?
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchwabRb
4
+ class OptionSample
5
+ end
6
+ end
@@ -27,7 +27,7 @@ module SchwabRb
27
27
  return null_logger if config.silence_output
28
28
  return null_logger unless config.should_create_logger?
29
29
 
30
- log_destination = config.effective_log_file || $stdout
30
+ log_destination = config.effective_log_file
31
31
 
32
32
  return null_logger if [:null, "/dev/null"].include?(log_destination)
33
33
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SchwabRb
4
- VERSION = "0.8.1"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/schwab_rb.rb CHANGED
@@ -23,6 +23,8 @@ require_relative "schwab_rb/orders/instruments"
23
23
  require_relative "schwab_rb/market_hours"
24
24
  require_relative "schwab_rb/price_history"
25
25
  require_relative "schwab_rb/price_history/downloader"
26
+ require_relative "schwab_rb/option_sample"
27
+ require_relative "schwab_rb/option_sample/downloader"
26
28
  require_relative "schwab_rb/movers"
27
29
  require_relative "schwab_rb/orders/builder"
28
30
  require_relative "schwab_rb/orders/session"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: schwab_rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joseph Platta
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-04-09 00:00:00.000000000 Z
10
+ date: 2026-04-10 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: async
@@ -256,6 +256,8 @@ files:
256
256
  - lib/schwab_rb/market_hours.rb
257
257
  - lib/schwab_rb/movers.rb
258
258
  - lib/schwab_rb/option.rb
259
+ - lib/schwab_rb/option_sample.rb
260
+ - lib/schwab_rb/option_sample/downloader.rb
259
261
  - lib/schwab_rb/orders/builder.rb
260
262
  - lib/schwab_rb/orders/destination.rb
261
263
  - lib/schwab_rb/orders/duration.rb