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.
@@ -0,0 +1,343 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module TradingviewScreener
8
+ # Convert TradingView saved-screener `window.initData.screen_data`
9
+ # into scanner.tradingview.com scan payloads / Relations.
10
+ module ScreenData
11
+ module_function
12
+
13
+ def to_payload(screen_data)
14
+ Converter.new(screen_data).to_payload
15
+ end
16
+
17
+ def to_relation(screen_data, model: Stock)
18
+ model.from_payload(to_payload(screen_data))
19
+ end
20
+
21
+ def parse_html(html)
22
+ Parser.extract_screen_data(html)
23
+ end
24
+
25
+ def fetch(url, cookies: nil, proxy: nil, timeout: 20, headers: nil)
26
+ Fetcher.new(cookies: cookies, proxy: proxy, timeout: timeout, headers: headers).fetch(url)
27
+ end
28
+
29
+ class ParseError < Error; end
30
+ class FetchError < Error; end
31
+
32
+ class Parser
33
+ SCREEN_DATA_RE = /window\.initData\.screen_data\s*=\s*(\{.*?\})\s*;\s*\n\s*window\.initData\.onScreenerStandalonePage/m
34
+
35
+ def self.extract_screen_data(html)
36
+ text = html.to_s
37
+ match = text.match(SCREEN_DATA_RE)
38
+ raise ParseError, "screen_data not found in HTML" unless match
39
+
40
+ JSON.parse(match[1])
41
+ rescue JSON::ParserError => e
42
+ raise ParseError, "invalid screen_data JSON: #{e.message}"
43
+ end
44
+ end
45
+
46
+ class Fetcher
47
+ def initialize(cookies: nil, proxy: nil, timeout: 20, headers: nil)
48
+ @cookies = cookies
49
+ @proxy = proxy
50
+ @timeout = timeout
51
+ @headers = headers
52
+ end
53
+
54
+ def fetch(url)
55
+ client = Client.new(
56
+ timeout: @timeout,
57
+ cookies: @cookies,
58
+ proxy: @proxy,
59
+ headers: {
60
+ "accept" => "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
61
+ "user-agent" => "tradingview-screener-rb/#{VERSION}"
62
+ }.merge(@headers || {})
63
+ )
64
+ html = client.get(url)
65
+ data = Parser.extract_screen_data(html)
66
+ {
67
+ "url" => url,
68
+ "screen_data" => data,
69
+ "payload" => Converter.new(data).to_payload
70
+ }
71
+ end
72
+ end
73
+
74
+ class Converter
75
+ COLUMN_MAP = {
76
+ "Price" => "close",
77
+ "Change" => "change",
78
+ "Volume" => "volume",
79
+ "MarketCap" => "market_cap_basic",
80
+ "PriceToEarnings" => "price_earnings_ttm",
81
+ "EpsDiluted" => "earnings_per_share_diluted_ttm",
82
+ "EpsDilutedGrowth" => "earnings_per_share_diluted_yoy_growth_ttm",
83
+ "DividendsYield" => "dividends_yield_current",
84
+ "Sector" => "sector",
85
+ "AnalystRating" => "AnalystRating",
86
+ "PreMarketChange" => "premarket_change",
87
+ "PreMarketVolume" => "premarket_volume",
88
+ "PreMarketGap" => "premarket_gap",
89
+ "TickerUniversal" => "ticker-view",
90
+ "RelativeVolume" => "relative_volume_10d_calc",
91
+ "RelativeVolumeAtTime" => "relative_volume_intraday|5",
92
+ "AverageTrueRangePercent" => "ATRP",
93
+ "PriceAvgVolume" => "AvgValue.Traded_10d",
94
+ "AverageVolume" => "average_volume_10d_calc",
95
+ "Performance" => "Perf.W",
96
+ "RevenueGrowth" => "total_revenue_yoy_growth_ttm",
97
+ "PriceToEarningsToGrowth" => "price_earnings_growth_ttm",
98
+ "ReturnOnEquity" => "return_on_equity",
99
+ "Beta" => "beta_1_year",
100
+ "Ma" => "SMA"
101
+ }.freeze
102
+
103
+ SORT_MAP = {
104
+ "PreMarketVolume" => "premarket_volume",
105
+ "MarketCap" => "market_cap_basic",
106
+ "Volume" => "volume",
107
+ "Change" => "change",
108
+ "Price" => "close",
109
+ "PreMarketChange" => "premarket_change",
110
+ "RelativeVolume" => "relative_volume_10d_calc"
111
+ }.freeze
112
+
113
+ DEFAULT_COLUMNS = %w[
114
+ ticker-view close type typespecs pricescale minmov fractional minmove2 currency
115
+ change volume relative_volume_10d_calc market_cap_basic fundamental_currency_code
116
+ price_earnings_ttm earnings_per_share_diluted_ttm earnings_per_share_diluted_yoy_growth_ttm
117
+ dividends_yield_current sector.tr market sector AnalystRating AnalystRating.tr
118
+ premarket_change premarket_volume
119
+ ].freeze
120
+
121
+ def initialize(screen_data)
122
+ @data = deep_stringify(screen_data)
123
+ end
124
+
125
+ def to_payload
126
+ markets = Array(@data.dig("market_settings", "markets"))
127
+ markets = ["america"] if markets.empty?
128
+ filters = convert_filters(Array(@data["filters"]))
129
+ if @data.dig("market_settings", "is_primary_listing")
130
+ filters << { "left" => "is_primary", "operation" => "equal", "right" => true }
131
+ end
132
+
133
+ {
134
+ "markets" => markets,
135
+ "symbols" => {},
136
+ "options" => { "lang" => "en" },
137
+ "columns" => convert_columns(Array(@data["default_custom_column_set"])),
138
+ "filter" => filters,
139
+ "filter2" => deep_dup(Relation::STOCK_TYPE_FILTER2),
140
+ "sort" => convert_sort,
141
+ "range" => [0, 100],
142
+ "ignore_unknown_fields" => false,
143
+ "meta" => {
144
+ "id" => @data["id"],
145
+ "title" => @data["title"],
146
+ "version" => @data["version"],
147
+ "date_created" => @data["date_created"],
148
+ "date_updated" => @data["date_updated"],
149
+ "date_used" => @data["date_used"]
150
+ }.compact
151
+ }
152
+ end
153
+
154
+ private
155
+
156
+ # Keep screen-defined columns, but always include the fields used by trading-universe metadata.
157
+ REQUIRED_METADATA_COLUMNS = %w[
158
+ close
159
+ change
160
+ volume
161
+ relative_volume_10d_calc
162
+ sector
163
+ AnalystRating
164
+ AnalystRating.tr
165
+ premarket_change
166
+ ].freeze
167
+
168
+ def convert_columns(column_set)
169
+ cols = column_set.filter_map { |item| map_column_ref(item) }
170
+ cols = DEFAULT_COLUMNS.dup if cols.empty?
171
+ # Ensure type fields used by filter2 compatibility remain present,
172
+ # and always request the metadata columns consumers expect.
173
+ (%w[type typespecs] + cols + REQUIRED_METADATA_COLUMNS).uniq
174
+ end
175
+
176
+ def deep_dup(value)
177
+ case value
178
+ when Hash then value.each_with_object({}) { |(k, v), memo| memo[k] = deep_dup(v) }
179
+ when Array then value.map { |item| deep_dup(item) }
180
+ when String then value.dup
181
+ else value
182
+ end
183
+ end
184
+
185
+ def convert_sort
186
+ column = @data.dig("sort_column", "id").to_s
187
+ direction = @data["sort_direction"].to_s.downcase == "asc" ? "asc" : "desc"
188
+ sort_by = SORT_MAP[column] || map_column_id(column, @data.dig("sort_column", "params") || {}) || "market_cap_basic"
189
+ {
190
+ "sortBy" => sort_by,
191
+ "sortOrder" => direction
192
+ }
193
+ end
194
+
195
+ def convert_filters(filters)
196
+ filters.filter_map { |filter| convert_filter(filter) }
197
+ end
198
+
199
+ def convert_filter(filter)
200
+ type = filter["type"].to_s
201
+ case type
202
+ when "Condition"
203
+ convert_condition(filter)
204
+ when "CheckboxGroup"
205
+ convert_checkbox_group(filter)
206
+ when "Date"
207
+ nil # relative date filters with null are inactive placeholders
208
+ else
209
+ nil
210
+ end
211
+ end
212
+
213
+ def convert_condition(filter)
214
+ left = map_column_ref(filter.dig("left", "column"))
215
+ return nil if left.nil? || left == ""
216
+
217
+ operation = filter.dig("operation", "type").to_s
218
+ right = filter["right"]
219
+ target = filter["target"].to_s
220
+
221
+ if right.is_a?(Hash) && right.key?("column")
222
+ right_col = map_column_ref(right["column"])
223
+ return nil if right_col.nil? || right_col == ""
224
+
225
+ return {
226
+ "left" => left,
227
+ "operation" => operation_name(operation, equal_ok: true),
228
+ "right" => right_col
229
+ }
230
+ end
231
+
232
+ if right.is_a?(Hash) && (right.key?("left") || right.key?("right")) && operation == "between"
233
+ low = right["left"]
234
+ high = right["right"]
235
+ return nil if low.nil? && high.nil?
236
+
237
+ if !low.nil? && !high.nil?
238
+ return { "left" => left, "operation" => "in_range", "right" => [low, high] }
239
+ elsif !low.nil?
240
+ return { "left" => left, "operation" => "egreater", "right" => low }
241
+ else
242
+ return { "left" => left, "operation" => "eless", "right" => high }
243
+ end
244
+ end
245
+
246
+ value = right.is_a?(Hash) ? right["value"] : right
247
+ return nil if value.nil? && target == "value"
248
+
249
+ {
250
+ "left" => left,
251
+ "operation" => operation_name(operation, equal_ok: true),
252
+ "right" => value
253
+ }
254
+ end
255
+
256
+ def convert_checkbox_group(filter)
257
+ values = filter.dig("right", "values")
258
+ return nil if values.nil? || Array(values).empty?
259
+
260
+ left = map_column_ref(filter.dig("left", "column"))
261
+ return nil if left.nil? || left == ""
262
+
263
+ {
264
+ "left" => left,
265
+ "operation" => "in_range",
266
+ "right" => Array(values)
267
+ }
268
+ end
269
+
270
+ def operation_name(type, equal_ok: false)
271
+ case type.to_s
272
+ when "above", "greater" then "greater"
273
+ when "below", "less" then "less"
274
+ when "above_or_equal", "egreater" then "egreater"
275
+ when "below_or_equal", "eless" then "eless"
276
+ when "equal", "eq" then "equal"
277
+ when "not_equal", "nequal" then "nequal"
278
+ when "between" then "in_range"
279
+ else
280
+ equal_ok ? "equal" : type.to_s
281
+ end
282
+ end
283
+
284
+ def map_column_ref(column)
285
+ return nil if column.nil? || column == {} || column == ""
286
+
287
+ map_column_id(column["id"], column["params"] || {})
288
+ end
289
+
290
+ def map_column_id(id, params)
291
+ id = id.to_s
292
+ params = deep_stringify(params || {})
293
+
294
+ case id
295
+ when "Ma"
296
+ length = params["length"]
297
+ length = "20" if length.nil? || length == ""
298
+ "SMA#{length}"
299
+ when "AverageTrueRangePercent"
300
+ "ATRP"
301
+ when "RelativeVolumeAtTime"
302
+ # default TV intraday relative volume used by saved screens
303
+ "relative_volume_intraday|5"
304
+ when "RelativeVolume"
305
+ "relative_volume_10d_calc"
306
+ when "PriceAvgVolume"
307
+ "AvgValue.Traded_10d"
308
+ when "AverageVolume"
309
+ interval = params["interval"].to_s
310
+ case interval
311
+ when "Interval10D" then "average_volume_10d_calc"
312
+ when "Interval30D" then "average_volume_30d_calc"
313
+ else "average_volume_10d_calc"
314
+ end
315
+ when "Performance"
316
+ interval = params["interval"].to_s
317
+ case interval
318
+ when "Interval1W" then "Perf.W"
319
+ when "Interval1M" then "Perf.1M"
320
+ when "Interval3M" then "Perf.3M"
321
+ when "Interval6M" then "Perf.6M"
322
+ when "IntervalYTD" then "Perf.YTD"
323
+ when "Interval1Y" then "Perf.Y"
324
+ else "Perf.W"
325
+ end
326
+ else
327
+ COLUMN_MAP[id] || id
328
+ end
329
+ end
330
+
331
+ def deep_stringify(value)
332
+ case value
333
+ when Hash
334
+ value.each_with_object({}) { |(k, v), memo| memo[k.to_s] = deep_stringify(v) }
335
+ when Array
336
+ value.map { |item| deep_stringify(item) }
337
+ else
338
+ value
339
+ end
340
+ end
341
+ end
342
+ end
343
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TradingviewScreener
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tradingview_screener/version"
4
+ require_relative "tradingview_screener/predicate"
5
+ require_relative "tradingview_screener/client"
6
+ require_relative "tradingview_screener/relation"
7
+ require_relative "tradingview_screener/screen_data"
8
+ require_relative "tradingview_screener/base"
9
+
10
+ module TradingviewScreener
11
+ module_function
12
+
13
+ # Field helper: TradingviewScreener[:close]
14
+ def [](name)
15
+ Field.new(name)
16
+ end
17
+
18
+ # Include predicate helpers into Relation instances for scope blocks.
19
+ module QueryMethods
20
+ def gt(value) = Predicates.gt(value)
21
+ def gte(value) = Predicates.gte(value)
22
+ def lt(value) = Predicates.lt(value)
23
+ def lte(value) = Predicates.lte(value)
24
+ def not_eq(value) = Predicates.not_eq(value)
25
+ def has(value) = Predicates.has(value)
26
+ def has_none_of(value) = Predicates.has_none_of(value)
27
+ end
28
+
29
+ Relation.include(QueryMethods)
30
+ Base.extend(QueryMethods)
31
+ end
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tradingview-screener
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Victor Yang
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rspec
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.13'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.13'
26
+ - !ruby/object:Gem::Dependency
27
+ name: webmock
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.23'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.23'
40
+ - !ruby/object:Gem::Dependency
41
+ name: socksify
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.7'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.7'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ description: |
69
+ Chainable Query/Column API for TradingView's scanner endpoints.
70
+ Inspired by shner-elmo/TradingView-Screener, designed like Rails ORM relations.
71
+ email:
72
+ - ootyoungtoo@gmail.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - CHANGELOG.md
78
+ - LICENSE.txt
79
+ - README.md
80
+ - lib/tradingview/screener.rb
81
+ - lib/tradingview_screener.rb
82
+ - lib/tradingview_screener/base.rb
83
+ - lib/tradingview_screener/client.rb
84
+ - lib/tradingview_screener/predicate.rb
85
+ - lib/tradingview_screener/relation.rb
86
+ - lib/tradingview_screener/screen_data.rb
87
+ - lib/tradingview_screener/version.rb
88
+ homepage: https://rubygems.org/gems/tradingview-screener
89
+ licenses:
90
+ - MIT
91
+ metadata:
92
+ homepage_uri: https://rubygems.org/gems/tradingview-screener
93
+ source_code_uri: https://github.com/Youngv/tradingview-screener
94
+ changelog_uri: https://github.com/Youngv/tradingview-screener/blob/main/CHANGELOG.md
95
+ bug_tracker_uri: https://github.com/Youngv/tradingview-screener/issues
96
+ documentation_uri: https://www.rubydoc.info/gems/tradingview-screener
97
+ allowed_push_host: https://rubygems.org
98
+ rubygems_mfa_required: 'true'
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: 3.1.0
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubygems_version: 3.6.9
114
+ specification_version: 4
115
+ summary: ActiveRecord-style Ruby client for TradingView scanner/screener API
116
+ test_files: []