tradingview-screener 0.2.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 +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +179 -0
- data/lib/tradingview/screener.rb +4 -0
- data/lib/tradingview_screener/base.rb +291 -0
- data/lib/tradingview_screener/client.rb +206 -0
- data/lib/tradingview_screener/predicate.rb +107 -0
- data/lib/tradingview_screener/relation.rb +581 -0
- data/lib/tradingview_screener/screen_data.rb +343 -0
- data/lib/tradingview_screener/version.rb +5 -0
- data/lib/tradingview_screener.rb +31 -0
- metadata +116 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module TradingviewScreener
|
|
6
|
+
# ActiveRecord-style immutable relation for TradingView scanner.
|
|
7
|
+
#
|
|
8
|
+
# Stock.where(is_primary: true)
|
|
9
|
+
# .where(close: 10..300)
|
|
10
|
+
# .where(close: gt(:SMA20))
|
|
11
|
+
# .order(premarket_volume: :desc)
|
|
12
|
+
# .limit(100)
|
|
13
|
+
# .load
|
|
14
|
+
class Relation
|
|
15
|
+
include Enumerable
|
|
16
|
+
|
|
17
|
+
SCAN_URL = "https://scanner.tradingview.com/%<market>s/scan"
|
|
18
|
+
DEFAULT_RANGE = [0, 50].freeze
|
|
19
|
+
|
|
20
|
+
STOCK_TYPE_FILTER2 = {
|
|
21
|
+
"operator" => "and",
|
|
22
|
+
"operands" => [
|
|
23
|
+
{
|
|
24
|
+
"operation" => {
|
|
25
|
+
"operator" => "or",
|
|
26
|
+
"operands" => [
|
|
27
|
+
{
|
|
28
|
+
"operation" => {
|
|
29
|
+
"operator" => "and",
|
|
30
|
+
"operands" => [
|
|
31
|
+
{ "expression" => { "left" => "type", "operation" => "equal", "right" => "stock" } },
|
|
32
|
+
{ "expression" => { "left" => "typespecs", "operation" => "has", "right" => ["common"] } }
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"operation" => {
|
|
38
|
+
"operator" => "and",
|
|
39
|
+
"operands" => [
|
|
40
|
+
{ "expression" => { "left" => "type", "operation" => "equal", "right" => "stock" } },
|
|
41
|
+
{ "expression" => { "left" => "typespecs", "operation" => "has", "right" => ["preferred"] } }
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"operation" => {
|
|
47
|
+
"operator" => "and",
|
|
48
|
+
"operands" => [
|
|
49
|
+
{ "expression" => { "left" => "type", "operation" => "equal", "right" => "dr" } }
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"operation" => {
|
|
55
|
+
"operator" => "and",
|
|
56
|
+
"operands" => [
|
|
57
|
+
{ "expression" => { "left" => "type", "operation" => "equal", "right" => "fund" } },
|
|
58
|
+
{ "expression" => { "left" => "typespecs", "operation" => "has_none_of", "right" => %w[etf mutual closedend] } }
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{ "expression" => { "left" => "typespecs", "operation" => "has_none_of", "right" => ["pre-ipo"] } }
|
|
66
|
+
]
|
|
67
|
+
}.freeze
|
|
68
|
+
|
|
69
|
+
attr_reader :klass
|
|
70
|
+
|
|
71
|
+
def initialize(klass = nil, values: nil)
|
|
72
|
+
@klass = klass
|
|
73
|
+
@values = values || default_values(klass)
|
|
74
|
+
@records = nil
|
|
75
|
+
@loaded = false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.from_payload(klass, payload)
|
|
79
|
+
payload = payload.to_h.transform_keys(&:to_s)
|
|
80
|
+
filters = Array(payload["filter"]).map { |item| item.to_h.transform_keys(&:to_s) }
|
|
81
|
+
sort = payload["sort"].to_h.transform_keys(&:to_s)
|
|
82
|
+
range = Array(payload["range"])
|
|
83
|
+
offset = range[0] ? Integer(range[0]) : 0
|
|
84
|
+
limit =
|
|
85
|
+
if range[1]
|
|
86
|
+
Integer(range[1]) - offset
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
markets = Array(payload["markets"]).map(&:to_s)
|
|
90
|
+
markets = ["america"] if markets.empty?
|
|
91
|
+
overrides = {
|
|
92
|
+
"where_values" => filters,
|
|
93
|
+
"markets" => markets,
|
|
94
|
+
"tickers" => Array(payload.dig("symbols", "tickers")),
|
|
95
|
+
"filter2" => payload["filter2"] || deep_dup_static(STOCK_TYPE_FILTER2),
|
|
96
|
+
"none_value" => false,
|
|
97
|
+
"offset_value" => offset
|
|
98
|
+
}
|
|
99
|
+
overrides["select_values"] = Array(payload["columns"]).map(&:to_s) if Array(payload["columns"]).any?
|
|
100
|
+
if sort["sortBy"] && !sort["sortBy"].to_s.empty?
|
|
101
|
+
overrides["order_values"] = [{
|
|
102
|
+
"sortBy" => sort["sortBy"].to_s,
|
|
103
|
+
"sortOrder" => (sort["sortOrder"] || "desc").to_s
|
|
104
|
+
}]
|
|
105
|
+
end
|
|
106
|
+
overrides["limit_value"] = limit if limit && limit.positive?
|
|
107
|
+
|
|
108
|
+
base = new(klass)
|
|
109
|
+
merged = base.send(:deep_dup, base.values)
|
|
110
|
+
overrides.each { |key, value| merged[key.to_sym] = value }
|
|
111
|
+
new(klass, values: merged)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.deep_dup_static(value)
|
|
115
|
+
case value
|
|
116
|
+
when Hash then value.each_with_object({}) { |(k, v), memo| memo[k] = deep_dup_static(v) }
|
|
117
|
+
when Array then value.map { |item| deep_dup_static(item) }
|
|
118
|
+
when String then value.dup
|
|
119
|
+
else value
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def select(*columns)
|
|
124
|
+
spawn(select_values: columns.flatten.map { |c| field_name(c) })
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# AR-like where:
|
|
128
|
+
# where(is_primary: true)
|
|
129
|
+
# where(close: 10..300)
|
|
130
|
+
# where(type: %w[stock fund])
|
|
131
|
+
# where(close: gt(:SMA20))
|
|
132
|
+
# where(Screener[:volume].gt(1_000_000))
|
|
133
|
+
# where(Screener[:close].gt(Screener[:SMA20]), is_primary: true)
|
|
134
|
+
def where(*predicates, **conditions)
|
|
135
|
+
filters = predicates.map { |item| normalize_predicate(item) }
|
|
136
|
+
conditions.each { |key, value| filters.concat(build_condition(key, value)) }
|
|
137
|
+
spawn(where_values: where_values + filters)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def rewhere(**conditions)
|
|
141
|
+
keys = conditions.keys.map { |key| field_name(key) }
|
|
142
|
+
kept = where_values.reject { |item| keys.include?(item["left"]) }
|
|
143
|
+
replacement = conditions.flat_map { |key, value| build_condition(key, value) }
|
|
144
|
+
spawn(where_values: kept + replacement)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def order(*args, **kwargs)
|
|
148
|
+
sorts = []
|
|
149
|
+
args.each do |arg|
|
|
150
|
+
case arg
|
|
151
|
+
when Hash
|
|
152
|
+
arg.each { |key, dir| sorts << sort_entry(key, dir) }
|
|
153
|
+
when String, Symbol
|
|
154
|
+
sorts << sort_entry(arg, :asc)
|
|
155
|
+
else
|
|
156
|
+
raise ArgumentError, "unsupported order value: #{arg.inspect}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
kwargs.each { |key, dir| sorts << sort_entry(key, dir) }
|
|
160
|
+
return self if sorts.empty?
|
|
161
|
+
|
|
162
|
+
spawn(order_values: sorts)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def limit(value)
|
|
166
|
+
spawn(limit_value: Integer(value))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def offset(value)
|
|
170
|
+
spawn(offset_value: Integer(value))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def market(*markets)
|
|
174
|
+
list = markets.flatten.compact.map(&:to_s)
|
|
175
|
+
spawn(markets: list)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def tickers(*tickers)
|
|
179
|
+
spawn(tickers: tickers.flatten.map(&:to_s), markets: [])
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def except(*keys)
|
|
183
|
+
next_values = deep_dup(@values)
|
|
184
|
+
keys.map(&:to_sym).each do |key|
|
|
185
|
+
case key
|
|
186
|
+
when :where then next_values[:where_values] = []
|
|
187
|
+
when :order then next_values[:order_values] = []
|
|
188
|
+
when :select then next_values[:select_values] = default_select
|
|
189
|
+
when :limit then next_values[:limit_value] = DEFAULT_RANGE[1]
|
|
190
|
+
when :offset then next_values[:offset_value] = 0
|
|
191
|
+
else
|
|
192
|
+
raise ArgumentError, "unknown except key: #{key}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
self.class.new(klass, values: next_values)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def merge(other)
|
|
199
|
+
raise ArgumentError, "merge expects Relation" unless other.is_a?(Relation)
|
|
200
|
+
|
|
201
|
+
spawn(
|
|
202
|
+
select_values: other.values[:select_values] || select_values,
|
|
203
|
+
where_values: where_values + other.where_values,
|
|
204
|
+
order_values: (other.order_values.empty? ? order_values : other.order_values),
|
|
205
|
+
limit_value: other.values[:limit_value] || limit_value,
|
|
206
|
+
offset_value: other.values[:offset_value] || offset_value,
|
|
207
|
+
markets: other.values[:markets] || markets,
|
|
208
|
+
tickers: other.values[:tickers] || tickers_value,
|
|
209
|
+
filter2: other.values[:filter2] || filter2
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def unscope(*args)
|
|
214
|
+
except(*args)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def none
|
|
218
|
+
spawn(none_value: true)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def load(client: nil, cookies: nil, headers: nil, timeout: 20, label_product: "screener-stock", proxy: nil)
|
|
222
|
+
return self if loaded?
|
|
223
|
+
|
|
224
|
+
if none_value?
|
|
225
|
+
@records = []
|
|
226
|
+
@total_count = 0
|
|
227
|
+
@raw = { "totalCount" => 0, "data" => [] }
|
|
228
|
+
else
|
|
229
|
+
raw = execute(
|
|
230
|
+
client: client,
|
|
231
|
+
cookies: cookies,
|
|
232
|
+
headers: headers,
|
|
233
|
+
timeout: timeout,
|
|
234
|
+
label_product: label_product,
|
|
235
|
+
proxy: proxy
|
|
236
|
+
)
|
|
237
|
+
@raw = raw
|
|
238
|
+
@total_count = raw["totalCount"].to_i
|
|
239
|
+
@records = map_rows(raw)
|
|
240
|
+
end
|
|
241
|
+
@loaded = true
|
|
242
|
+
self
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def reload(**opts)
|
|
246
|
+
reset
|
|
247
|
+
load(**opts)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def reset
|
|
251
|
+
@loaded = false
|
|
252
|
+
@records = nil
|
|
253
|
+
@total_count = nil
|
|
254
|
+
@raw = nil
|
|
255
|
+
self
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def loaded?
|
|
259
|
+
@loaded
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def to_a(**opts)
|
|
263
|
+
load(**opts) unless loaded?
|
|
264
|
+
records.dup
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def records
|
|
268
|
+
load unless loaded?
|
|
269
|
+
@records
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def each(...)
|
|
273
|
+
records.each(...)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def size
|
|
277
|
+
records.size
|
|
278
|
+
end
|
|
279
|
+
alias length size
|
|
280
|
+
|
|
281
|
+
def empty?
|
|
282
|
+
size.zero?
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def exists?(**opts)
|
|
286
|
+
if loaded?
|
|
287
|
+
!records.empty?
|
|
288
|
+
else
|
|
289
|
+
limit(1).load(**opts).size.positive?
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def count(**opts)
|
|
294
|
+
if loaded?
|
|
295
|
+
total_count
|
|
296
|
+
else
|
|
297
|
+
load(**opts).total_count
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def total_count
|
|
302
|
+
load unless loaded?
|
|
303
|
+
@total_count
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def first(n = nil, **opts)
|
|
307
|
+
rel = loaded? ? self : limit(n || 1).load(**opts)
|
|
308
|
+
n ? rel.records.first(n) : rel.records.first
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def last(n = nil, **opts)
|
|
312
|
+
rows = to_a(**opts)
|
|
313
|
+
n ? rows.last(n) : rows.last
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def pluck(*columns, **opts)
|
|
317
|
+
cols = columns.flatten.map { |c| field_name(c) }
|
|
318
|
+
rel = select_values == cols ? self : select(*cols)
|
|
319
|
+
rel.to_a(**opts).map do |row|
|
|
320
|
+
if cols.size == 1
|
|
321
|
+
row[cols.first]
|
|
322
|
+
else
|
|
323
|
+
cols.map { |col| row[col] }
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def pick(*columns, **opts)
|
|
329
|
+
limit(1).pluck(*columns, **opts).first
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def ids(**opts)
|
|
333
|
+
load(**opts) unless loaded?
|
|
334
|
+
records.map { |row| row["ticker"] }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def tickers_list(**opts)
|
|
338
|
+
ids(**opts)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def symbols(**opts)
|
|
342
|
+
ids(**opts).map do |ticker|
|
|
343
|
+
exchange, symbol = ticker.to_s.split(":", 2)
|
|
344
|
+
{
|
|
345
|
+
"exchange" => exchange,
|
|
346
|
+
"symbol" => symbol,
|
|
347
|
+
"full_symbol" => ticker
|
|
348
|
+
}
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def raw
|
|
353
|
+
load unless loaded?
|
|
354
|
+
@raw
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def to_sql
|
|
358
|
+
# scanner has no SQL; show the request payload for debugging (AR-ish inspect helper)
|
|
359
|
+
JSON.pretty_generate(to_query)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def to_query
|
|
363
|
+
{
|
|
364
|
+
"markets" => markets,
|
|
365
|
+
"symbols" => symbols_payload,
|
|
366
|
+
"options" => { "lang" => "en" },
|
|
367
|
+
"columns" => select_values,
|
|
368
|
+
"filter" => where_values,
|
|
369
|
+
"filter2" => filter2,
|
|
370
|
+
"sort" => order_payload,
|
|
371
|
+
"range" => [offset_value, offset_value + limit_value],
|
|
372
|
+
"ignore_unknown_fields" => false
|
|
373
|
+
}.compact
|
|
374
|
+
end
|
|
375
|
+
alias to_h to_query
|
|
376
|
+
|
|
377
|
+
def scanner_url
|
|
378
|
+
market = markets.size == 1 ? markets.first : "global"
|
|
379
|
+
market = "global" if market.nil? || market.empty?
|
|
380
|
+
format(SCAN_URL, market: market)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def selected_columns
|
|
384
|
+
select_values
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def inspect
|
|
388
|
+
if loaded?
|
|
389
|
+
entries = records.first(3).map { |row| row["ticker"] || row["name"] }
|
|
390
|
+
entries << "..." if records.size > 3
|
|
391
|
+
"#<#{self.class.name} [#{entries.join(', ')}] size=#{records.size} total=#{total_count}>"
|
|
392
|
+
else
|
|
393
|
+
"#<#{self.class.name} market=#{markets.inspect} where=#{where_values.size} order=#{order_values.inspect} limit=#{limit_value}>"
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def ==(other)
|
|
398
|
+
other.is_a?(self.class) && other.to_query == to_query
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def values
|
|
402
|
+
@values
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def where_values
|
|
406
|
+
Array(@values[:where_values])
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def order_values
|
|
410
|
+
Array(@values[:order_values])
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def select_values
|
|
414
|
+
Array(@values[:select_values])
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def limit_value
|
|
418
|
+
@values[:limit_value] || DEFAULT_RANGE[1]
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def offset_value
|
|
422
|
+
@values[:offset_value] || 0
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def markets
|
|
426
|
+
Array(@values[:markets])
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def tickers_value
|
|
430
|
+
Array(@values[:tickers])
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def filter2
|
|
434
|
+
@values[:filter2]
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def none_value?
|
|
438
|
+
@values[:none_value] == true
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
private
|
|
442
|
+
|
|
443
|
+
def default_values(klass)
|
|
444
|
+
{
|
|
445
|
+
select_values: klass&.default_columns || default_select,
|
|
446
|
+
where_values: klass&.default_where || [],
|
|
447
|
+
order_values: klass&.default_order || [{ "sortBy" => "market_cap_basic", "sortOrder" => "desc" }],
|
|
448
|
+
limit_value: klass&.default_limit || DEFAULT_RANGE[1],
|
|
449
|
+
offset_value: 0,
|
|
450
|
+
markets: Array(klass&.default_market || "america"),
|
|
451
|
+
tickers: [],
|
|
452
|
+
filter2: deep_dup(klass&.default_filter2 || STOCK_TYPE_FILTER2),
|
|
453
|
+
none_value: false
|
|
454
|
+
}
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def default_select
|
|
458
|
+
%w[
|
|
459
|
+
name close type typespecs pricescale minmov fractional minmove2 currency
|
|
460
|
+
change volume relative_volume_10d_calc market_cap_basic fundamental_currency_code
|
|
461
|
+
price_earnings_ttm earnings_per_share_diluted_ttm earnings_per_share_diluted_yoy_growth_ttm
|
|
462
|
+
dividends_yield_current sector.tr market sector AnalystRating AnalystRating.tr
|
|
463
|
+
]
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def spawn(**overrides)
|
|
467
|
+
self.class.new(klass, values: deep_dup(@values).merge(overrides))
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def field_name(value)
|
|
471
|
+
case value
|
|
472
|
+
when Field then value.name
|
|
473
|
+
when Predicate then value.left
|
|
474
|
+
else value.to_s
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def normalize_predicate(value)
|
|
479
|
+
case value
|
|
480
|
+
when Predicate then value.to_h
|
|
481
|
+
when Hash
|
|
482
|
+
if value.key?("left") || value.key?(:left)
|
|
483
|
+
stringify_predicate(value)
|
|
484
|
+
else
|
|
485
|
+
raise ArgumentError, "use where(key: value) for hash conditions, got #{value.inspect}"
|
|
486
|
+
end
|
|
487
|
+
else
|
|
488
|
+
raise ArgumentError, "unsupported where argument: #{value.inspect}"
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def stringify_predicate(hash)
|
|
493
|
+
{
|
|
494
|
+
"left" => hash[:left] || hash["left"],
|
|
495
|
+
"operation" => hash[:operation] || hash["operation"],
|
|
496
|
+
"right" => hash.key?(:right) ? hash[:right] : hash["right"]
|
|
497
|
+
}
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def build_condition(key, value)
|
|
501
|
+
name = field_name(key)
|
|
502
|
+
case value
|
|
503
|
+
when Predicates::Op
|
|
504
|
+
[{ "left" => name, "operation" => value.operation, "right" => unwrap_value(value.value) }]
|
|
505
|
+
when Predicate
|
|
506
|
+
[value.to_h.merge("left" => name)]
|
|
507
|
+
when Range
|
|
508
|
+
right = [value.begin, value.end]
|
|
509
|
+
right[1] = right[1].pred if value.exclude_end? && right[1].respond_to?(:pred)
|
|
510
|
+
[{ "left" => name, "operation" => "in_range", "right" => right }]
|
|
511
|
+
when Array
|
|
512
|
+
[{ "left" => name, "operation" => "in_range", "right" => value }]
|
|
513
|
+
when Field, Symbol
|
|
514
|
+
[{ "left" => name, "operation" => "equal", "right" => field_name(value) }]
|
|
515
|
+
else
|
|
516
|
+
[{ "left" => name, "operation" => "equal", "right" => value }]
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def unwrap_value(value)
|
|
521
|
+
case value
|
|
522
|
+
when Field then value.name
|
|
523
|
+
when Symbol then value.to_s
|
|
524
|
+
else value
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def sort_entry(key, dir)
|
|
529
|
+
direction =
|
|
530
|
+
case dir
|
|
531
|
+
when :asc, "asc", true then "asc"
|
|
532
|
+
when :desc, "desc", false then "desc"
|
|
533
|
+
else
|
|
534
|
+
raise ArgumentError, "order direction must be :asc or :desc, got #{dir.inspect}"
|
|
535
|
+
end
|
|
536
|
+
{ "sortBy" => field_name(key), "sortOrder" => direction }
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def order_payload
|
|
540
|
+
order_values.first || { "sortBy" => "market_cap_basic", "sortOrder" => "desc" }
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def symbols_payload
|
|
544
|
+
payload = {}
|
|
545
|
+
payload["tickers"] = tickers_value if tickers_value.any?
|
|
546
|
+
payload
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def execute(client:, cookies:, headers:, timeout:, label_product:, proxy: nil)
|
|
550
|
+
http = client || Client.new(
|
|
551
|
+
timeout: timeout,
|
|
552
|
+
cookies: cookies,
|
|
553
|
+
headers: headers,
|
|
554
|
+
label_product: label_product,
|
|
555
|
+
proxy: proxy
|
|
556
|
+
)
|
|
557
|
+
http.post(scanner_url, to_query)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def map_rows(raw)
|
|
561
|
+
columns = select_values
|
|
562
|
+
Array(raw["data"]).map do |item|
|
|
563
|
+
values = Array(item["d"])
|
|
564
|
+
row = {}
|
|
565
|
+
columns.each_with_index { |column, index| row[column] = values[index] }
|
|
566
|
+
# scanner primary key always comes from response key `s`
|
|
567
|
+
row["ticker"] = item["s"]
|
|
568
|
+
row
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def deep_dup(value)
|
|
573
|
+
case value
|
|
574
|
+
when Hash then value.each_with_object({}) { |(k, v), memo| memo[k] = deep_dup(v) }
|
|
575
|
+
when Array then value.map { |item| deep_dup(item) }
|
|
576
|
+
when String then value.dup
|
|
577
|
+
else value
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
end
|