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,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "json"
5
+ require "time"
6
+ require "fileutils"
7
+
8
+ module SchwabRb
9
+ class PriceHistory
10
+ module Downloader
11
+ INDEX_API_SYMBOLS = %w[
12
+ COMPX DJX MID NDX OEX RUT SPX VIX VIX9D VIX1D XSP
13
+ ].freeze
14
+ SUPPORTED_FORMATS = %w[csv json].freeze
15
+ FREQUENCY_ALIASES = {
16
+ "1min" => {
17
+ label: "1min",
18
+ frequency_type: SchwabRb::PriceHistory::FrequencyTypes::MINUTE,
19
+ frequency: SchwabRb::PriceHistory::Frequencies::EVERY_MINUTE,
20
+ period_type: SchwabRb::PriceHistory::PeriodTypes::DAY,
21
+ period: SchwabRb::PriceHistory::Periods::ONE_DAY
22
+ },
23
+ "5min" => {
24
+ label: "5min",
25
+ frequency_type: SchwabRb::PriceHistory::FrequencyTypes::MINUTE,
26
+ frequency: SchwabRb::PriceHistory::Frequencies::EVERY_FIVE_MINUTES,
27
+ period_type: SchwabRb::PriceHistory::PeriodTypes::DAY,
28
+ period: SchwabRb::PriceHistory::Periods::ONE_DAY
29
+ },
30
+ "10min" => {
31
+ label: "10min",
32
+ frequency_type: SchwabRb::PriceHistory::FrequencyTypes::MINUTE,
33
+ frequency: SchwabRb::PriceHistory::Frequencies::EVERY_TEN_MINUTES,
34
+ period_type: SchwabRb::PriceHistory::PeriodTypes::DAY,
35
+ period: SchwabRb::PriceHistory::Periods::ONE_DAY
36
+ },
37
+ "15min" => {
38
+ label: "15min",
39
+ frequency_type: SchwabRb::PriceHistory::FrequencyTypes::MINUTE,
40
+ frequency: SchwabRb::PriceHistory::Frequencies::EVERY_FIFTEEN_MINUTES,
41
+ period_type: SchwabRb::PriceHistory::PeriodTypes::DAY,
42
+ period: SchwabRb::PriceHistory::Periods::ONE_DAY
43
+ },
44
+ "30min" => {
45
+ label: "30min",
46
+ frequency_type: SchwabRb::PriceHistory::FrequencyTypes::MINUTE,
47
+ frequency: SchwabRb::PriceHistory::Frequencies::EVERY_THIRTY_MINUTES,
48
+ period_type: SchwabRb::PriceHistory::PeriodTypes::DAY,
49
+ period: SchwabRb::PriceHistory::Periods::ONE_DAY
50
+ },
51
+ "day" => {
52
+ label: "day",
53
+ frequency_type: SchwabRb::PriceHistory::FrequencyTypes::DAILY,
54
+ frequency: SchwabRb::PriceHistory::Frequencies::DAILY,
55
+ period_type: SchwabRb::PriceHistory::PeriodTypes::YEAR,
56
+ period: SchwabRb::PriceHistory::Periods::TWENTY_YEARS
57
+ },
58
+ "week" => {
59
+ label: "week",
60
+ frequency_type: SchwabRb::PriceHistory::FrequencyTypes::WEEKLY,
61
+ frequency: SchwabRb::PriceHistory::Frequencies::WEEKLY,
62
+ period_type: SchwabRb::PriceHistory::PeriodTypes::YEAR,
63
+ period: SchwabRb::PriceHistory::Periods::TWENTY_YEARS
64
+ },
65
+ "month" => {
66
+ label: "month",
67
+ frequency_type: SchwabRb::PriceHistory::FrequencyTypes::MONTHLY,
68
+ frequency: SchwabRb::PriceHistory::Frequencies::MONTHLY,
69
+ period_type: SchwabRb::PriceHistory::PeriodTypes::YEAR,
70
+ period: SchwabRb::PriceHistory::Periods::TWENTY_YEARS
71
+ }
72
+ }.freeze
73
+
74
+ module_function
75
+
76
+ def resolve(client:, symbol:, start_date:, end_date:, directory:, frequency:, format:, need_extended_hours_data:, need_previous_close:, period_type: nil, period: nil)
77
+ config = FREQUENCY_ALIASES.fetch(frequency)
78
+ path = canonical_output_path(directory: directory, symbol: symbol, frequency: frequency, format: format)
79
+ existing_response = load_cached_price_history(path, format)
80
+ response = existing_response
81
+
82
+ unless cached_range_covers?(existing_response, start_date, end_date, frequency)
83
+ FileUtils.mkdir_p(directory)
84
+ downloaded = client.get_price_history(
85
+ api_symbol(symbol),
86
+ period_type: period_type || config.fetch(:period_type),
87
+ period: period || config.fetch(:period),
88
+ frequency_type: config.fetch(:frequency_type),
89
+ frequency: config.fetch(:frequency),
90
+ start_datetime: start_date,
91
+ end_datetime: end_date,
92
+ need_extended_hours_data: need_extended_hours_data,
93
+ need_previous_close: need_previous_close,
94
+ return_data_objects: false
95
+ )
96
+ response = merge_price_history_responses(response, downloaded, symbol)
97
+ end
98
+
99
+ File.write(path, serialized_payload(response, format))
100
+ [response, path]
101
+ end
102
+
103
+ def canonical_output_path(directory:, symbol:, frequency:, format:)
104
+ File.join(directory, "#{sanitize_symbol(symbol)}_#{frequency}.#{format}")
105
+ end
106
+
107
+ def sanitize_symbol(symbol)
108
+ sanitized_symbol = symbol.to_s.gsub(/[^a-zA-Z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
109
+ sanitized_symbol.empty? ? "symbol" : sanitized_symbol
110
+ end
111
+
112
+ def api_symbol(symbol)
113
+ raw_symbol = symbol.to_s.strip
114
+ return raw_symbol if raw_symbol.start_with?("$", "/")
115
+ return "$#{raw_symbol}" if INDEX_API_SYMBOLS.include?(raw_symbol.upcase)
116
+
117
+ raw_symbol
118
+ end
119
+
120
+ def serialized_payload(response, format)
121
+ case format
122
+ when "json"
123
+ JSON.pretty_generate(response)
124
+ when "csv"
125
+ CSV.generate do |csv|
126
+ csv << %w[datetime open high low close volume]
127
+ Array(response[:candles]).each do |candle|
128
+ csv << [
129
+ Time.at(candle.fetch(:datetime) / 1000.0).utc.iso8601,
130
+ candle[:open],
131
+ candle[:high],
132
+ candle[:low],
133
+ candle[:close],
134
+ candle[:volume]
135
+ ]
136
+ end
137
+ end
138
+ else
139
+ raise ArgumentError, "Unsupported format `#{format}`."
140
+ end
141
+ end
142
+
143
+ def load_cached_price_history(path, format)
144
+ return unless File.exist?(path)
145
+
146
+ parse_price_history_file(path, format)
147
+ end
148
+
149
+ def parse_price_history_file(path, format)
150
+ case format
151
+ when "json"
152
+ symbolize_price_history_payload(JSON.parse(File.read(path)))
153
+ when "csv"
154
+ parse_price_history_csv(path)
155
+ else
156
+ raise ArgumentError, "Unsupported format `#{format}`."
157
+ end
158
+ rescue JSON::ParserError => e
159
+ raise ArgumentError, "Unable to parse cached price history at #{path}: #{e.message}"
160
+ end
161
+
162
+ def parse_price_history_csv(path)
163
+ candles = CSV.read(path, headers: true).map do |row|
164
+ {
165
+ datetime: Time.iso8601(row.fetch("datetime")).to_i * 1000,
166
+ open: row.fetch("open").to_f,
167
+ high: row.fetch("high").to_f,
168
+ low: row.fetch("low").to_f,
169
+ close: row.fetch("close").to_f,
170
+ volume: row.fetch("volume").to_i
171
+ }
172
+ end
173
+
174
+ {
175
+ symbol: File.basename(path).split("_").first,
176
+ empty: candles.empty?,
177
+ candles: candles
178
+ }
179
+ end
180
+
181
+ def symbolize_price_history_payload(payload)
182
+ {
183
+ symbol: payload["symbol"] || payload[:symbol],
184
+ empty: payload["empty"].nil? ? payload[:empty] : payload["empty"],
185
+ candles: Array(payload["candles"] || payload[:candles]).map do |candle|
186
+ candle.transform_keys(&:to_sym)
187
+ end
188
+ }
189
+ end
190
+
191
+ def cached_range_covers?(response, start_date, end_date, frequency)
192
+ return false unless response
193
+
194
+ dates = requested_candle_dates(response, start_date, end_date)
195
+ return false if dates.empty?
196
+ return daily_range_covered?(dates, start_date, end_date) if frequency == "day"
197
+
198
+ start_date >= dates.first && end_date <= dates.last
199
+ end
200
+
201
+ def candle_dates(response)
202
+ Array(response[:candles]).map do |candle|
203
+ Time.at(candle.fetch(:datetime) / 1000.0).utc.to_date
204
+ end.sort
205
+ end
206
+
207
+ def requested_candle_dates(response, start_date, end_date)
208
+ candle_dates(response).select { |date| date >= start_date && date <= end_date }
209
+ end
210
+
211
+ def daily_range_covered?(cached_dates, start_date, end_date)
212
+ business_dates_in_range(start_date, end_date).all? { |date| cached_dates.include?(date) }
213
+ end
214
+
215
+ def business_dates_in_range(start_date, end_date)
216
+ (start_date..end_date).select { |date| (1..5).cover?(date.wday) }
217
+ end
218
+
219
+ def merge_price_history_responses(left, right, fallback_symbol)
220
+ return normalize_price_history_response(right, fallback_symbol) unless left
221
+ return normalize_price_history_response(left, fallback_symbol) unless right
222
+
223
+ merged_candles = Array(left[:candles]) + Array(right[:candles])
224
+ deduped_candles = merged_candles.each_with_object({}) do |candle, by_datetime|
225
+ by_datetime[candle.fetch(:datetime)] = candle.transform_keys(&:to_sym)
226
+ end
227
+
228
+ {
229
+ symbol: left[:symbol] || right[:symbol] || fallback_symbol,
230
+ empty: deduped_candles.empty?,
231
+ candles: deduped_candles.values.sort_by { |candle| candle.fetch(:datetime) }
232
+ }
233
+ end
234
+
235
+ def normalize_price_history_response(response, fallback_symbol)
236
+ {
237
+ symbol: response[:symbol] || fallback_symbol,
238
+ empty: Array(response[:candles]).empty?,
239
+ candles: Array(response[:candles]).map { |candle| candle.transform_keys(&:to_sym) }.sort_by do |candle|
240
+ candle.fetch(:datetime)
241
+ end
242
+ }
243
+ end
244
+ end
245
+ end
246
+ 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.6.0"
4
+ VERSION = "0.8.2"
5
5
  end
data/lib/schwab_rb.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "schwab_rb/version"
4
4
  require_relative "schwab_rb/configuration"
5
5
  require_relative "schwab_rb/account_hash_manager"
6
+ require_relative "schwab_rb/path_support"
6
7
  require_relative "schwab_rb/auth/token_manager"
7
8
  require_relative "schwab_rb/auth/token"
8
9
  require_relative "schwab_rb/auth/init_client_login"
@@ -21,6 +22,7 @@ require_relative "schwab_rb/option"
21
22
  require_relative "schwab_rb/orders/instruments"
22
23
  require_relative "schwab_rb/market_hours"
23
24
  require_relative "schwab_rb/price_history"
25
+ require_relative "schwab_rb/price_history/downloader"
24
26
  require_relative "schwab_rb/movers"
25
27
  require_relative "schwab_rb/orders/builder"
26
28
  require_relative "schwab_rb/orders/session"
@@ -50,6 +52,7 @@ require_relative "schwab_rb/orders/vertical_order"
50
52
  require_relative "schwab_rb/orders/vertical_roll_order"
51
53
  require_relative "schwab_rb/orders/single_order"
52
54
  require_relative "schwab_rb/orders/oco_order"
55
+ require_relative "schwab_rb/cli"
53
56
 
54
57
  module SchwabRb
55
58
  class Error < StandardError; end
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.6.0
4
+ version: 0.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joseph Platta
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-12-11 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
@@ -188,21 +188,29 @@ description: A comprehensive Ruby client for interacting with the Charles Schwab
188
188
  and more with both synchronous and asynchronous support.
189
189
  email:
190
190
  - jwplatta@gmail.com
191
- executables: []
191
+ executables:
192
+ - schwab_rb
192
193
  extensions: []
193
194
  extra_rdoc_files: []
194
195
  files:
195
196
  - ".claude/settings.json"
197
+ - ".claude/settings.local.json"
198
+ - ".codex/skills/schwab-rb-cli/SKILL.md"
199
+ - ".codex/skills/schwab-rb-cli/agents/openai.yaml"
200
+ - ".codex/skills/schwab-rb-cli/references/cli_examples.md"
201
+ - ".codex/skills/schwab-rb-cli/scripts/run_schwab_rb.sh"
196
202
  - ".copilotignore"
197
203
  - ".rspec"
198
204
  - ".rubocop.yml"
199
205
  - ".rubocop_todo.yml"
206
+ - AGENTS.md
200
207
  - CHANGELOG.md
201
208
  - CLAUDE.md
202
209
  - LICENSE.txt
203
210
  - README.md
204
211
  - Rakefile
205
212
  - doc/ACCOUNT_MANAGEMENT.md
213
+ - doc/LOGGING.md
206
214
  - doc/PLACE_ORDER_SAMPLES.md
207
215
  - doc/QUICK_START.md
208
216
  - doc/notes/data_objects_analysis.md
@@ -211,6 +219,7 @@ files:
211
219
  - examples/fetch_price_history.rb
212
220
  - examples/fetch_user_preferences.rb
213
221
  - examples/place_oco_order.rb
222
+ - exe/schwab_rb
214
223
  - lib/schwab_rb.rb
215
224
  - lib/schwab_rb/account.rb
216
225
  - lib/schwab_rb/account_hash_manager.rb
@@ -221,6 +230,8 @@ files:
221
230
  - lib/schwab_rb/auth/login_flow_server.rb
222
231
  - lib/schwab_rb/auth/token.rb
223
232
  - lib/schwab_rb/auth/token_manager.rb
233
+ - lib/schwab_rb/cli.rb
234
+ - lib/schwab_rb/cli/app.rb
224
235
  - lib/schwab_rb/clients/async_client.rb
225
236
  - lib/schwab_rb/clients/base_client.rb
226
237
  - lib/schwab_rb/clients/client.rb
@@ -267,7 +278,9 @@ files:
267
278
  - lib/schwab_rb/orders/tax_lot_method.rb
268
279
  - lib/schwab_rb/orders/vertical_order.rb
269
280
  - lib/schwab_rb/orders/vertical_roll_order.rb
281
+ - lib/schwab_rb/path_support.rb
270
282
  - lib/schwab_rb/price_history.rb
283
+ - lib/schwab_rb/price_history/downloader.rb
271
284
  - lib/schwab_rb/quote.rb
272
285
  - lib/schwab_rb/transaction.rb
273
286
  - lib/schwab_rb/utils/enum_enforcer.rb