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,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
|
|
30
|
+
log_destination = config.effective_log_file
|
|
31
31
|
|
|
32
32
|
return null_logger if [:null, "/dev/null"].include?(log_destination)
|
|
33
33
|
|
data/lib/schwab_rb/version.rb
CHANGED
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.
|
|
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:
|
|
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
|