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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 98dcc60a6b081a91a83d869aa27e5bb214eebc94b12cf6646db98f1a35c4fede
4
+ data.tar.gz: d9edd2c6cfb433d5a66a41e49d76ff2add1c82e1d4657a63cedfcae7f2a67539
5
+ SHA512:
6
+ metadata.gz: d4c6d6ca81ff9c6aeaeba7935e64fa154924efae09a40b3e369ea4612b35f9939d7eafaec53a4dec3553e79e5279d653de26f8a13bb67b6931c34e90c3e11631
7
+ data.tar.gz: 636af5fc960959ee4a172ff7ef9547cebf72cfdf6778c73711795249c644b8930f74bac660c3af5aa1c6f2ca8ff529c49fc7f9796cdcb8b068e89f1f1c6305da
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ ### Changed
6
+ - Gem/package name is `tradingview-screener` (RubyGems hyphen convention); require remains `tradingview_screener`.
7
+ - Extracted into a standalone publishable gem.
8
+ - Removed implicit ActiveSupport dependency (`presence` / `blank?`).
9
+ - Pure Ruby runtime: only stdlib + optional `socksify` for SOCKS proxies.
10
+ - Added a tracked Ruby version for reproducible development with RVM.
11
+ - Added strict gem validation and a Ruby 3.1-3.4 CI test matrix.
12
+ - Clarified Ruby support, unofficial API status, and the release workflow.
13
+
14
+ ### Added
15
+ - ActiveRecord-style `Relation` / `Base` / scopes API.
16
+ - Built-in momentum trend, momentum scalping, and open-range breakout scopes.
17
+ - `ScreenData` parser/converter for saved TradingView screener URLs/HTML.
18
+ - HTTP and SOCKS proxy support on the scanner client.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Victor Yang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # tradingview-screener
2
+
3
+ ActiveRecord-style Ruby client for TradingView's scanner API.
4
+
5
+ Inspired by [shner-elmo/TradingView-Screener](https://github.com/shner-elmo/TradingView-Screener), but designed like Rails ORM relations — not a Python port.
6
+
7
+ This is an unofficial client and is not affiliated with or endorsed by TradingView. The scanner endpoints are not a guaranteed public API and may change without notice.
8
+
9
+ Gem name uses hyphens (`tradingview-screener`); the require path and module use underscores:
10
+
11
+ ```ruby
12
+ require "tradingview_screener"
13
+ # also works: require "tradingview/screener" # Bundler default for hyphen gems
14
+ TradingviewScreener::Stock
15
+ ```
16
+
17
+ If Bundler does not load it automatically, set:
18
+
19
+ ```ruby
20
+ gem "tradingview-screener", require: "tradingview_screener"
21
+ ```
22
+
23
+ ## Install
24
+
25
+ Ruby 3.1 or newer is required.
26
+
27
+ ### From a local path
28
+
29
+ ```ruby
30
+ # Gemfile
31
+ gem "tradingview-screener", path: "../tradingview-screener"
32
+ ```
33
+
34
+ ### From RubyGems
35
+
36
+ ```ruby
37
+ # Gemfile
38
+ gem "tradingview-screener", "~> 0.2"
39
+ ```
40
+
41
+ ```bash
42
+ gem install tradingview-screener
43
+ ```
44
+
45
+ SOCKS proxies are optional. If you need them:
46
+
47
+ ```ruby
48
+ gem "socksify", "~> 1.7"
49
+ ```
50
+
51
+ ## Quick start
52
+
53
+ ```ruby
54
+ require "tradingview_screener"
55
+
56
+ rows = TradingviewScreener::Stock
57
+ .where(close: 10..300)
58
+ .where(volume: TradingviewScreener::Predicates.gt(1_000_000))
59
+ .where(TradingviewScreener::Stock[:close].gt(TradingviewScreener::Stock[:SMA20]))
60
+ .order(volume: :desc)
61
+ .limit(20)
62
+ .to_a
63
+
64
+ rows.first["ticker"] # => "NASDAQ:NVDA"
65
+ ```
66
+
67
+ ## ORM-shaped API
68
+
69
+ | ActiveRecord | tradingview-screener |
70
+ |---|---|
71
+ | `Model.where(a: 1)` | `Stock.where(is_primary: true)` |
72
+ | `where(price: 10..20)` | `where(close: 10..300)` |
73
+ | `where(status: %w[a b])` | `where(type: %w[stock fund])` |
74
+ | `order(created_at: :desc)` | `order(premarket_volume: :desc)` |
75
+ | `limit` / `offset` | same |
76
+ | `select(:id, :name)` | `select("name", "close")` |
77
+ | `scope :active, -> { ... }` | same |
78
+ | `default_scope` | same |
79
+ | `all` / `to_a` / `count` / `pluck` / `first` | same |
80
+ | relation is immutable chain | same |
81
+
82
+ ### Operators
83
+
84
+ ```ruby
85
+ where(volume: TradingviewScreener::Predicates.gt(1_000_000))
86
+ where(market_cap_basic: TradingviewScreener::Predicates.gte(1_000_000_000))
87
+ where(ATRP: TradingviewScreener::Predicates.lt(6))
88
+
89
+ # Arel-like nodes
90
+ where(TradingviewScreener::Stock[:close].gt(TradingviewScreener::Stock[:SMA20]))
91
+ where(TradingviewScreener::Stock[:typespecs].has(["common"]))
92
+ ```
93
+
94
+ ### Scopes
95
+
96
+ ```ruby
97
+ class MyScreen < TradingviewScreener::Base
98
+ market "america"
99
+ default_scope { where(is_primary: true) }
100
+
101
+ scope :liquid, -> {
102
+ where("AvgValue.Traded_10d" => TradingviewScreener::Predicates.gt(50_000_000))
103
+ }
104
+ scope :wide_atr, -> {
105
+ where(ATRP: TradingviewScreener::Predicates.gt(1))
106
+ .where(ATRP: TradingviewScreener::Predicates.lt(6))
107
+ }
108
+ end
109
+
110
+ MyScreen.liquid.wide_atr.order(volume: :desc).limit(50).load
111
+ ```
112
+
113
+ ### Built-in momentum scopes
114
+
115
+ ```ruby
116
+ TradingviewScreener::Stock.momentum_trend_long.to_a
117
+ TradingviewScreener::Stock.momentum_trend_short.ids
118
+ TradingviewScreener::Stock.momentum_scalping_long.to_a
119
+ TradingviewScreener::Stock.open_range_breakout_short.ids
120
+ ```
121
+
122
+ ### Saved screener URL / screen_data
123
+
124
+ ```ruby
125
+ rel = TradingviewScreener::Stock.from_screener_url(
126
+ "https://www.tradingview.com/screener/YOUR_SCREEN_ID/",
127
+ cookies: {
128
+ "sessionid" => ENV.fetch("TRADINGVIEW_SESSIONID"),
129
+ "sessionid_sign" => ENV.fetch("TRADINGVIEW_SESSIONID_SIGN")
130
+ }
131
+ )
132
+ rel.to_a
133
+ ```
134
+
135
+ ### Cookies / proxy
136
+
137
+ ```ruby
138
+ TradingviewScreener::Stock.momentum_trend_long.load(
139
+ cookies: {
140
+ "sessionid" => ENV.fetch("TRADINGVIEW_SESSIONID"),
141
+ "sessionid_sign" => ENV.fetch("TRADINGVIEW_SESSIONID_SIGN")
142
+ },
143
+ proxy: "http://user:pass@127.0.0.1:7890"
144
+ )
145
+ ```
146
+
147
+ ## Debugging
148
+
149
+ ```ruby
150
+ rel = TradingviewScreener::Stock
151
+ .where(TradingviewScreener::Stock[:close].gt(TradingviewScreener::Stock[:SMA20]))
152
+ .limit(10)
153
+
154
+ puts rel.to_sql # JSON payload sent to scanner
155
+ puts rel.scanner_url
156
+ ```
157
+
158
+ ## Development
159
+
160
+ ```bash
161
+ bundle install
162
+ bundle exec rspec
163
+ bundle exec rake build # => pkg/tradingview-screener-0.2.0.gem
164
+ gem build tradingview-screener.gemspec --strict
165
+ ```
166
+
167
+ Before publishing, verify that the version is not already in use and inspect the package:
168
+
169
+ ```bash
170
+ gem build tradingview-screener.gemspec --strict
171
+ gem contents --show-install-dir tradingview-screener # after installing the built gem
172
+ gem push tradingview-screener-0.2.0.gem
173
+ ```
174
+
175
+ RubyGems MFA is required for releases. Source code and issue tracking are available on [GitHub](https://github.com/Youngv/tradingview-screener).
176
+
177
+ ## License
178
+
179
+ MIT
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler/RubyGems map gem "tradingview-screener" to require "tradingview/screener".
4
+ require_relative "../tradingview_screener"
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TradingviewScreener
4
+ # ActiveRecord-style model base.
5
+ #
6
+ # class Stock < TradingviewScreener::Base
7
+ # market "america"
8
+ # default_scope { where(is_primary: true) }
9
+ # scope :liquid, -> { where(volume: gt(1_000_000)) }
10
+ # end
11
+ #
12
+ # Stock.liquid.where(close: 10..300).order(volume: :desc).limit(20).load
13
+ class Base
14
+ class << self
15
+ def inherited(subclass)
16
+ super
17
+ subclass.instance_variable_set(:@scopes, {})
18
+ subclass.instance_variable_set(:@default_scopes, [])
19
+ end
20
+
21
+ def market(name = nil)
22
+ if name.nil?
23
+ @default_market
24
+ else
25
+ @default_market = name.to_s
26
+ end
27
+ end
28
+ alias default_market market
29
+
30
+ def columns(*names)
31
+ if names.empty?
32
+ @default_columns
33
+ else
34
+ @default_columns = names.flatten.map(&:to_s)
35
+ end
36
+ end
37
+ alias default_columns columns
38
+
39
+ def default_limit(value = nil)
40
+ if value.nil?
41
+ @default_limit
42
+ else
43
+ @default_limit = Integer(value)
44
+ end
45
+ end
46
+
47
+ def default_order_values
48
+ @default_order
49
+ end
50
+ alias default_order default_order_values
51
+
52
+ def order_by(column, direction = :desc)
53
+ @default_order = [{ "sortBy" => column.to_s, "sortOrder" => direction.to_s == "asc" ? "asc" : "desc" }]
54
+ end
55
+
56
+ def default_where
57
+ @default_where
58
+ end
59
+
60
+ def default_filter2
61
+ @default_filter2
62
+ end
63
+
64
+ def filter2(value)
65
+ @default_filter2 = value
66
+ end
67
+
68
+ def scope(name, body = nil, &block)
69
+ callable = body || block
70
+ raise ArgumentError, "scope needs a callable" unless callable
71
+
72
+ scopes[name.to_sym] = callable
73
+ define_singleton_method(name) do |*args, **kwargs|
74
+ rel = all
75
+ result =
76
+ if callable.arity.nonzero? || kwargs.any?
77
+ rel.instance_exec(*args, **kwargs, &callable)
78
+ else
79
+ rel.instance_exec(&callable)
80
+ end
81
+ result.nil? ? none : result
82
+ end
83
+ end
84
+
85
+ def default_scope(body = nil, &block)
86
+ callable = body || block
87
+ raise ArgumentError, "default_scope needs a callable" unless callable
88
+
89
+ default_scopes << callable
90
+ end
91
+
92
+ def all
93
+ rel = Relation.new(self)
94
+ default_scopes.reduce(rel) do |memo, callable|
95
+ result = memo.instance_exec(&callable)
96
+ result.nil? ? memo : result
97
+ end
98
+ end
99
+
100
+ def none
101
+ all.none
102
+ end
103
+
104
+ def where(...) = all.where(...)
105
+ def rewhere(...) = all.rewhere(...)
106
+ def select(...) = all.select(...)
107
+ def order(...) = all.order(...)
108
+ def limit(...) = all.limit(...)
109
+ def offset(...) = all.offset(...)
110
+ def market!(...) = all.market(...)
111
+ def tickers(...) = all.tickers(...)
112
+ def merge(...) = all.merge(...)
113
+ def except(...) = all.except(...)
114
+ def load(**kwargs) = all.load(**kwargs)
115
+ def to_a(**kwargs) = all.to_a(**kwargs)
116
+ def count(**kwargs) = all.count(**kwargs)
117
+ def first(...) = all.first(...)
118
+ def last(...) = all.last(...)
119
+ def pluck(...) = all.pluck(...)
120
+ def pick(...) = all.pick(...)
121
+ def ids(**kwargs) = all.ids(**kwargs)
122
+ def exists?(**kwargs) = all.exists?(**kwargs)
123
+ def find_each(**opts, &block) = all.to_a(**opts).each(&block)
124
+
125
+ def [](name)
126
+ Field.new(name)
127
+ end
128
+
129
+ def unscoped
130
+ Relation.new(self)
131
+ end
132
+
133
+ def from_payload(payload)
134
+ Relation.from_payload(self, payload)
135
+ end
136
+
137
+ def from_screen_data(screen_data)
138
+ from_payload(ScreenData.to_payload(screen_data))
139
+ end
140
+
141
+ def from_screener_url(url, cookies: nil, proxy: nil, timeout: 20)
142
+ fetched = ScreenData.fetch(url, cookies: cookies, proxy: proxy, timeout: timeout)
143
+ from_payload(fetched["payload"])
144
+ end
145
+
146
+ protected
147
+
148
+ def scopes
149
+ @scopes ||= {}
150
+ end
151
+
152
+ def default_scopes
153
+ @default_scopes ||= []
154
+ end
155
+ end
156
+ end
157
+
158
+ # Convenience root model for US equities.
159
+ class Stock < Base
160
+ market "america"
161
+ order_by "market_cap_basic", :desc
162
+ default_limit 50
163
+
164
+ default_scope do
165
+ where(is_primary: true)
166
+ end
167
+
168
+ scope :primary, -> { where(is_primary: true) }
169
+
170
+ scope :priced_between, lambda { |min, max|
171
+ where(close: min..max)
172
+ }
173
+
174
+ # Captured from live MT LONG / MT SHORT TradingView screeners.
175
+ scope :momentum_trend_long, lambda {
176
+ select(
177
+ "ticker-view", "close", "type", "typespecs", "pricescale", "minmov", "fractional", "minmove2",
178
+ "currency", "change", "volume", "relative_volume_10d_calc", "market_cap_basic",
179
+ "fundamental_currency_code", "price_earnings_ttm", "earnings_per_share_diluted_ttm",
180
+ "earnings_per_share_diluted_yoy_growth_ttm", "dividends_yield_current", "sector.tr",
181
+ "market", "sector", "AnalystRating", "AnalystRating.tr", "premarket_change", "premarket_volume"
182
+ ).where(
183
+ "AvgValue.Traded_10d" => gt(50_000_000),
184
+ ATRP: gt(1)
185
+ ).where(
186
+ market_cap_basic: gte(1_000_000_000),
187
+ premarket_volume: gt(30_000),
188
+ "relative_volume_intraday|5" => gt(1.1),
189
+ ATRP: lt(6),
190
+ close: 10..300
191
+ ).where(
192
+ TradingviewScreener::Stock[:close].gt(TradingviewScreener::Stock[:SMA20])
193
+ ).order(premarket_volume: :desc).limit(100)
194
+ }
195
+
196
+ scope :momentum_trend_short, lambda {
197
+ select(
198
+ "ticker-view", "close", "type", "typespecs", "pricescale", "minmov", "fractional", "minmove2",
199
+ "currency", "change", "volume", "relative_volume_10d_calc", "market_cap_basic",
200
+ "fundamental_currency_code", "price_earnings_ttm", "earnings_per_share_diluted_ttm",
201
+ "earnings_per_share_diluted_yoy_growth_ttm", "dividends_yield_current", "sector.tr",
202
+ "market", "sector", "AnalystRating", "AnalystRating.tr", "premarket_change", "premarket_volume"
203
+ ).where(
204
+ TradingviewScreener::Stock[:close].lt(TradingviewScreener::Stock[:SMA20]),
205
+ "relative_volume_intraday|5" => gt(1.1),
206
+ premarket_volume: gt(30_000),
207
+ ATRP: lt(6)
208
+ ).where(
209
+ ATRP: gt(1),
210
+ "AvgValue.Traded_10d" => gt(50_000_000),
211
+ market_cap_basic: gte(1_000_000_000),
212
+ close: 10..300
213
+ ).order(market_cap_basic: :desc).limit(100)
214
+ }
215
+
216
+ # Captured from live MS LONG / MS SHORT TradingView screeners.
217
+ scope :momentum_scalping_long, lambda {
218
+ select(
219
+ "ticker-view", "close", "type", "typespecs", "pricescale", "minmov", "fractional", "minmove2",
220
+ "currency", "change", "volume", "relative_volume_10d_calc", "market_cap_basic",
221
+ "fundamental_currency_code", "price_earnings_ttm", "earnings_per_share_diluted_ttm",
222
+ "earnings_per_share_diluted_yoy_growth_ttm", "dividends_yield_current", "sector.tr",
223
+ "market", "sector", "AnalystRating", "AnalystRating.tr", "premarket_change", "premarket_volume"
224
+ ).where(
225
+ close: 10..300,
226
+ "relative_volume_intraday|5" => gt(1.1),
227
+ ATRP: lt(6),
228
+ market_cap_basic: gte(1_000_000_000),
229
+ premarket_gap: gt(0.5),
230
+ premarket_volume: gt(30_000)
231
+ ).where(
232
+ premarket_gap: lt(8),
233
+ ATRP: gt(1)
234
+ ).order(premarket_volume: :desc).limit(100)
235
+ }
236
+
237
+ scope :momentum_scalping_short, lambda {
238
+ select(
239
+ "ticker-view", "close", "type", "typespecs", "pricescale", "minmov", "fractional", "minmove2",
240
+ "currency", "change", "volume", "relative_volume_10d_calc", "market_cap_basic",
241
+ "fundamental_currency_code", "price_earnings_ttm", "earnings_per_share_diluted_ttm",
242
+ "earnings_per_share_diluted_yoy_growth_ttm", "dividends_yield_current", "sector.tr",
243
+ "market", "sector", "AnalystRating", "AnalystRating.tr", "premarket_change", "premarket_volume"
244
+ ).where(
245
+ premarket_gap: gt(-8),
246
+ market_cap_basic: gte(1_000_000_000)
247
+ ).where(
248
+ ATRP: gt(1)
249
+ ).where(
250
+ ATRP: lt(6),
251
+ premarket_volume: gt(30_000),
252
+ "relative_volume_intraday|5" => gt(1.1),
253
+ premarket_gap: lt(-0.5),
254
+ close: 10..300
255
+ ).order(premarket_volume: :desc).limit(100)
256
+ }
257
+
258
+ # Captured from live ORB LONG / ORB SHORT TradingView screeners.
259
+ scope :open_range_breakout_long, lambda {
260
+ select(
261
+ "ticker-view", "close", "type", "typespecs", "pricescale", "minmov", "fractional", "minmove2",
262
+ "currency", "change", "volume", "relative_volume_10d_calc", "market_cap_basic",
263
+ "fundamental_currency_code", "price_earnings_ttm", "earnings_per_share_diluted_ttm",
264
+ "earnings_per_share_diluted_yoy_growth_ttm", "dividends_yield_current", "sector.tr",
265
+ "market", "sector", "AnalystRating", "AnalystRating.tr"
266
+ ).where(
267
+ market_cap_basic: gte(1_000_000_000),
268
+ average_volume_10d_calc: gt(3_000_000),
269
+ close: 10..300,
270
+ ATRP: gt(5),
271
+ premarket_gap: gt(2)
272
+ ).order(market_cap_basic: :desc).limit(100)
273
+ }
274
+
275
+ scope :open_range_breakout_short, lambda {
276
+ select(
277
+ "ticker-view", "close", "type", "typespecs", "pricescale", "minmov", "fractional", "minmove2",
278
+ "currency", "change", "volume", "relative_volume_10d_calc", "market_cap_basic",
279
+ "fundamental_currency_code", "price_earnings_ttm", "earnings_per_share_diluted_ttm",
280
+ "earnings_per_share_diluted_yoy_growth_ttm", "dividends_yield_current", "sector.tr",
281
+ "market", "sector", "AnalystRating", "AnalystRating.tr"
282
+ ).where(
283
+ close: 10..300,
284
+ premarket_gap: lt(-3),
285
+ average_volume_10d_calc: gt(3_000_000),
286
+ ATRP: gt(5),
287
+ market_cap_basic: gte(1_000_000_000)
288
+ ).order(market_cap_basic: :desc).limit(100)
289
+ }
290
+ end
291
+ end