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.
- checksums.yaml +4 -4
- data/.claude/settings.local.json +9 -0
- data/.codex/skills/schwab-rb-cli/SKILL.md +65 -0
- data/.codex/skills/schwab-rb-cli/agents/openai.yaml +3 -0
- data/.codex/skills/schwab-rb-cli/references/cli_examples.md +120 -0
- data/.codex/skills/schwab-rb-cli/scripts/run_schwab_rb.sh +9 -0
- data/AGENTS.md +137 -0
- data/CHANGELOG.md +8 -0
- data/README.md +41 -3
- data/Rakefile +26 -0
- data/doc/LOGGING.md +141 -0
- data/doc/QUICK_START.md +15 -0
- data/examples/fetch_account_numbers.rb +16 -19
- data/examples/fetch_user_preferences.rb +16 -19
- data/examples/place_oco_order.rb +13 -6
- data/exe/schwab_rb +7 -0
- data/lib/schwab_rb/auth/init_client_easy.rb +2 -0
- data/lib/schwab_rb/auth/init_client_login.rb +2 -0
- data/lib/schwab_rb/auth/init_client_token_file.rb +3 -0
- data/lib/schwab_rb/auth/token_manager.rb +5 -1
- data/lib/schwab_rb/cli/app.rb +579 -0
- data/lib/schwab_rb/cli.rb +8 -0
- data/lib/schwab_rb/configuration.rb +7 -1
- data/lib/schwab_rb/constants.rb +4 -4
- data/lib/schwab_rb/path_support.rb +19 -0
- data/lib/schwab_rb/price_history/downloader.rb +246 -0
- data/lib/schwab_rb/utils/logger.rb +1 -1
- data/lib/schwab_rb/version.rb +1 -1
- data/lib/schwab_rb.rb +3 -0
- metadata +16 -3
|
@@ -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
|
|
@@ -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 ||
|
|
33
|
+
@log_file ||
|
|
34
|
+
(ENV["LOGFILE"] if ENV["LOGFILE"] && !ENV["LOGFILE"].empty?) ||
|
|
35
|
+
default_log_file
|
|
30
36
|
end
|
|
31
37
|
end
|
|
32
38
|
|
data/lib/schwab_rb/constants.rb
CHANGED
|
@@ -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="
|
|
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
|