@kuandotdev/indicator 0.1.1 → 0.1.3
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.
- package/README.md +2 -7
- package/package.json +4 -1
- package/project/.cursor/rules/agent-docs.mdc +16 -0
- package/project/.cursor/rules/api-credential-storage.mdc +16 -0
- package/project/.cursor/rules/pinescript-v6.mdc +16 -0
- package/project/.cursor/rules/stock-model-forecast.mdc +16 -0
- package/project/.cursor/rules/system-prompt-injection.mdc +16 -0
- package/project/.cursor/rules/system-prompt-updater.mdc +16 -0
- package/project/.cursor/rules/tradingview-stock-data.mdc +16 -0
- package/project/.env.example +44 -0
- package/project/.npm-packaged-project +1 -0
- package/project/.pi/APPEND_SYSTEM.md +338 -0
- package/project/.pi/settings.json +8 -0
- package/project/AGENTS.md +538 -0
- package/project/CLAUDE.md +538 -0
- package/project/GEMINI.md +538 -0
- package/project/Makefile +488 -0
- package/project/README.md +419 -0
- package/project/conda-env-active.sh +98 -0
- package/project/conda-env-deactive.sh +42 -0
- package/project/docs/agent-install.md +446 -0
- package/project/docs/agent-skill-directory.md +222 -0
- package/project/docs/integration.html +271 -0
- package/project/packages/indicator/README.md +39 -0
- package/project/packages/indicator/package.json +40 -0
- package/project/packages/indicator/scripts/build-project-snapshot.js +57 -0
- package/project/packages/indicator/src/cli.js +368 -0
- package/project/packages/tradingview-stock-data-skill/README.md +112 -0
- package/project/packages/tradingview-stock-data-skill/extensions/stock-prompt-injector.ts +121 -0
- package/project/packages/tradingview-stock-data-skill/package.json +35 -0
- package/project/packages/tradingview-stock-data-skill/scripts/postinstall.sh +73 -0
- package/project/packages/tradingview-stock-data-skill/skills/tradingview-stock-data/SKILL.md +241 -0
- package/project/pyproject.toml +68 -0
- package/project/screenshots/.gitkeep +0 -0
- package/project/scripts/indicators/example_rsi_bands.pine +27 -0
- package/project/scripts/indicators/tsla_levels.pine +57 -0
- package/project/skills/agent-docs/SKILL.md +56 -0
- package/project/skills/api-credential-storage/SKILL.md +83 -0
- package/project/skills/api-credential-storage/scripts/upsert_env.py +151 -0
- package/project/skills/pinescript-v6/SKILL.md +129 -0
- package/project/skills/pinescript-v6/reference/built-ins.md +219 -0
- package/project/skills/pinescript-v6/reference/templates/alert-webhook.pine +76 -0
- package/project/skills/pinescript-v6/reference/templates/indicator.pine +48 -0
- package/project/skills/pinescript-v6/reference/templates/strategy.pine +50 -0
- package/project/skills/pinescript-v6/reference/v5-to-v6-migration.md +102 -0
- package/project/skills/pinescript-v6/reference/v6-language.md +202 -0
- package/project/skills/stock-model-forecast/SKILL.md +192 -0
- package/project/skills/system-prompt-injection/CUSTOM_SYSTEM_PROMPT.md +333 -0
- package/project/skills/system-prompt-injection/DEFAULT_SYSTEM_PROMPT.md +327 -0
- package/project/skills/system-prompt-injection/SKILL.md +90 -0
- package/project/skills/system-prompt-injection/SYSTEM_PROMPT.md +23 -0
- package/project/skills/system-prompt-updater/SKILL.md +82 -0
- package/project/skills/system-prompt-updater/scripts/system_prompt_update.sh +106 -0
- package/project/skills/tradingview-stock-data/SKILL.md +272 -0
- package/project/src/tv_indicator/__init__.py +0 -0
- package/project/src/tv_indicator/browser/__init__.py +0 -0
- package/project/src/tv_indicator/browser/automation.py +541 -0
- package/project/src/tv_indicator/browser/selectors.py +70 -0
- package/project/src/tv_indicator/cli/__init__.py +0 -0
- package/project/src/tv_indicator/cli/browser_cmds.py +92 -0
- package/project/src/tv_indicator/cli/data_cmds.py +178 -0
- package/project/src/tv_indicator/cli/main.py +56 -0
- package/project/src/tv_indicator/cli/model_cmds.py +255 -0
- package/project/src/tv_indicator/cli/pine_cmds.py +140 -0
- package/project/src/tv_indicator/config.py +98 -0
- package/project/src/tv_indicator/data/__init__.py +0 -0
- package/project/src/tv_indicator/data/client.py +187 -0
- package/project/src/tv_indicator/data/screener.py +268 -0
- package/project/src/tv_indicator/mcp/__init__.py +0 -0
- package/project/src/tv_indicator/mcp/agent_server.py +398 -0
- package/project/src/tv_indicator/mcp/browser_server.py +133 -0
- package/project/src/tv_indicator/mcp/data_server.py +239 -0
- package/project/src/tv_indicator/model/__init__.py +19 -0
- package/project/src/tv_indicator/model/forecast.py +693 -0
- package/project/tools/import_agent_tools.sh +503 -0
- package/project/tools/install_skills.sh +673 -0
- package/project/tools/interactive_install.sh +917 -0
- package/project/tools/progress.sh +114 -0
- package/project/tools/uninstall_agent_tools.sh +373 -0
- package/src/cli.js +22 -25
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""High-level Playwright flows for TradingView.
|
|
2
|
+
|
|
3
|
+
Public functions (used by CLI and MCP):
|
|
4
|
+
session_check() -> bool
|
|
5
|
+
interactive_login() -> bool (visible browser, user logs in once)
|
|
6
|
+
push_script(path, apply_to, screenshot, name) -> dict
|
|
7
|
+
screenshot_chart(symbol, layout_url, out_path) -> Path
|
|
8
|
+
|
|
9
|
+
All flows reuse a persistent Playwright profile dir so login state survives runs.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from contextlib import asynccontextmanager
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|
22
|
+
|
|
23
|
+
from playwright.async_api import (
|
|
24
|
+
BrowserContext,
|
|
25
|
+
Page,
|
|
26
|
+
async_playwright,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from ..config import settings
|
|
30
|
+
from . import selectors as sel
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ─── Context manager ────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _cleanup_stale_chromium_locks(profile_dir: Path) -> list[Path]:
|
|
37
|
+
"""Remove stale Chromium SingletonLock/Cookie/Socket symlinks.
|
|
38
|
+
|
|
39
|
+
Chromium leaves these when the previous process didn't shut down cleanly
|
|
40
|
+
(e.g. force-killed by `timeout`). On the next launch it refuses to open
|
|
41
|
+
the profile, complaining "already in use by another instance". The lock
|
|
42
|
+
files are symlinks pointing at <hostname>-<pid>; if no such process exists,
|
|
43
|
+
they're safe to remove.
|
|
44
|
+
"""
|
|
45
|
+
removed: list[Path] = []
|
|
46
|
+
for name in ("SingletonLock", "SingletonCookie", "SingletonSocket"):
|
|
47
|
+
p = profile_dir / name
|
|
48
|
+
if not p.exists() and not p.is_symlink():
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
if p.is_symlink():
|
|
52
|
+
target = os.readlink(str(p))
|
|
53
|
+
# Lock targets look like 'hostname-12345' (PID = last segment)
|
|
54
|
+
pid_part = target.rsplit("-", 1)[-1]
|
|
55
|
+
pid = int(pid_part) if pid_part.isdigit() else None
|
|
56
|
+
alive = False
|
|
57
|
+
if pid:
|
|
58
|
+
try:
|
|
59
|
+
os.kill(pid, 0) # signal 0 = liveness probe
|
|
60
|
+
alive = True
|
|
61
|
+
except (OSError, ProcessLookupError):
|
|
62
|
+
alive = False
|
|
63
|
+
if alive:
|
|
64
|
+
continue # real running instance; do not touch
|
|
65
|
+
p.unlink(missing_ok=True)
|
|
66
|
+
removed.append(p)
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
return removed
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@asynccontextmanager
|
|
73
|
+
async def tv_browser(headless: Optional[bool] = None):
|
|
74
|
+
"""Open a Playwright context with persistent profile + TV cookies (if any).
|
|
75
|
+
|
|
76
|
+
Usage:
|
|
77
|
+
async with tv_browser() as (ctx, page):
|
|
78
|
+
await page.goto(...)
|
|
79
|
+
"""
|
|
80
|
+
headless = settings.browser_headless if headless is None else headless
|
|
81
|
+
profile_dir = settings.browser_profile
|
|
82
|
+
profile_dir.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
stale = _cleanup_stale_chromium_locks(profile_dir)
|
|
84
|
+
if stale:
|
|
85
|
+
print(f" (cleaned {len(stale)} stale Chromium lock(s) from previous run)")
|
|
86
|
+
|
|
87
|
+
async with async_playwright() as pw:
|
|
88
|
+
ctx: BrowserContext = await pw.chromium.launch_persistent_context(
|
|
89
|
+
user_data_dir=str(profile_dir),
|
|
90
|
+
headless=headless,
|
|
91
|
+
viewport={"width": 1600, "height": 1000},
|
|
92
|
+
args=["--disable-blink-features=AutomationControlled"],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Inject session cookies from .env if present (skips real login)
|
|
96
|
+
cookies = settings.session_cookies()
|
|
97
|
+
if cookies:
|
|
98
|
+
await ctx.add_cookies(cookies)
|
|
99
|
+
|
|
100
|
+
# Reuse the default page if Chromium opened one, else open a new tab
|
|
101
|
+
page = ctx.pages[0] if ctx.pages else await ctx.new_page()
|
|
102
|
+
try:
|
|
103
|
+
yield ctx, page
|
|
104
|
+
finally:
|
|
105
|
+
await ctx.close()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ─── Helpers ────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _is_signed_in(page: Page, quick: bool = False) -> bool:
|
|
112
|
+
"""Robust login probe.
|
|
113
|
+
|
|
114
|
+
Priority order (cookie check is the source of truth):
|
|
115
|
+
1. Presence of a non-empty `sessionid` cookie on tradingview.com -> True.
|
|
116
|
+
2. DOM probe: any USER_MENU_SELECTORS matches -> True.
|
|
117
|
+
3. DOM probe: any SIGN_IN_SELECTORS matches -> False.
|
|
118
|
+
4. Otherwise -> False.
|
|
119
|
+
|
|
120
|
+
The cookie path is the most reliable because the chart page DOM doesn't have
|
|
121
|
+
the website header, so DOM-only checks fail there even when logged in.
|
|
122
|
+
"""
|
|
123
|
+
# 1. Cookie check (works on any page, doesn't need header DOM)
|
|
124
|
+
try:
|
|
125
|
+
ctx = page.context
|
|
126
|
+
cookies = await ctx.cookies("https://www.tradingview.com")
|
|
127
|
+
sessionid = next(
|
|
128
|
+
(c.get("value") for c in cookies if c.get("name") == "sessionid"),
|
|
129
|
+
None,
|
|
130
|
+
)
|
|
131
|
+
if sessionid:
|
|
132
|
+
return True
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
# 2. DOM positive signal
|
|
137
|
+
for selector in sel.USER_MENU_SELECTORS:
|
|
138
|
+
try:
|
|
139
|
+
if await page.locator(selector).count() > 0:
|
|
140
|
+
return True
|
|
141
|
+
except Exception:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
if quick:
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
# 3. DOM negative signal
|
|
148
|
+
for selector in sel.SIGN_IN_SELECTORS:
|
|
149
|
+
try:
|
|
150
|
+
if await page.locator(selector).count() > 0:
|
|
151
|
+
return False
|
|
152
|
+
except Exception:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def _open_pine_editor(page: Page) -> None:
|
|
159
|
+
"""Make sure the Pine Editor side panel is open and the Monaco editor is ready."""
|
|
160
|
+
# Is it already open? Probe for the dialog container.
|
|
161
|
+
try:
|
|
162
|
+
already_open = await page.locator(sel.PINE_DIALOG).is_visible(timeout=1500)
|
|
163
|
+
except Exception:
|
|
164
|
+
already_open = False
|
|
165
|
+
|
|
166
|
+
if not already_open:
|
|
167
|
+
# Click the toolbar Pine button to open the side panel
|
|
168
|
+
await page.click(sel.PINE_DIALOG_BUTTON, timeout=10_000)
|
|
169
|
+
|
|
170
|
+
# Wait for the Monaco editor inside the panel to be ready.
|
|
171
|
+
# First load is slow (~5-8 s) because TV lazy-loads the Monaco bundle.
|
|
172
|
+
await page.wait_for_selector(sel.PINE_MONACO, timeout=25_000, state="attached")
|
|
173
|
+
# Small extra grace period for the editor's content to be settled
|
|
174
|
+
await page.wait_for_timeout(800)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _replace_editor_contents(page: Page, code: str) -> None:
|
|
178
|
+
"""Clear the Monaco editor and write the new code in.
|
|
179
|
+
|
|
180
|
+
Strategy (in priority order):
|
|
181
|
+
1. Use Monaco's JS API directly via `editor.setValue(text)` if we can
|
|
182
|
+
reach the editor instance. This bypasses ALL keyboard handlers
|
|
183
|
+
(including Monaco's autoIndent, which silently re-indents pasted
|
|
184
|
+
code and corrupts multi-line `if`/`for` blocks).
|
|
185
|
+
2. Fall back to clipboard paste.
|
|
186
|
+
3. Fall back to typing.
|
|
187
|
+
"""
|
|
188
|
+
editor_el = page.locator(sel.PINE_MONACO).first
|
|
189
|
+
await editor_el.focus()
|
|
190
|
+
|
|
191
|
+
# Strategy 1: direct Monaco model write (no keyboard handlers run)
|
|
192
|
+
try:
|
|
193
|
+
ok = await page.evaluate(
|
|
194
|
+
"""text => {
|
|
195
|
+
// Find the Pine Editor's Monaco model. TV exposes monaco-editor
|
|
196
|
+
// globally on the chart page once it's loaded.
|
|
197
|
+
if (typeof monaco === 'undefined' || !monaco.editor) return false;
|
|
198
|
+
const models = monaco.editor.getModels();
|
|
199
|
+
if (!models || models.length === 0) return false;
|
|
200
|
+
// Pick the model that looks like Pine code (largest, or any)
|
|
201
|
+
const model = models[models.length - 1];
|
|
202
|
+
model.setValue(text);
|
|
203
|
+
// Notify any listeners that the content changed (TV's save
|
|
204
|
+
// dirty-tracking watches for this).
|
|
205
|
+
if (model.pushEditOperations) {
|
|
206
|
+
model.pushEditOperations([], [], () => null);
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
}""",
|
|
210
|
+
code,
|
|
211
|
+
)
|
|
212
|
+
if ok:
|
|
213
|
+
# Editor now has the new content. Click into it so the user-input
|
|
214
|
+
# focus is restored (some TV save flows need focus on the editor).
|
|
215
|
+
await editor_el.focus()
|
|
216
|
+
await page.wait_for_timeout(500)
|
|
217
|
+
return
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
# Strategy 2: clipboard paste (autoIndent may corrupt blocks but it's a fallback)
|
|
222
|
+
mod = "Meta" if _is_mac() else "Control"
|
|
223
|
+
await page.keyboard.press(f"{mod}+A")
|
|
224
|
+
await page.keyboard.press("Delete")
|
|
225
|
+
pasted = False
|
|
226
|
+
try:
|
|
227
|
+
await page.evaluate("text => navigator.clipboard.writeText(text)", code)
|
|
228
|
+
await editor_el.focus()
|
|
229
|
+
await page.keyboard.press(f"{mod}+V")
|
|
230
|
+
pasted = True
|
|
231
|
+
except Exception:
|
|
232
|
+
pasted = False
|
|
233
|
+
|
|
234
|
+
# Strategy 3: type it
|
|
235
|
+
if not pasted:
|
|
236
|
+
await editor_el.type(code, delay=0)
|
|
237
|
+
|
|
238
|
+
await page.wait_for_timeout(700)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _is_mac() -> bool:
|
|
242
|
+
import sys
|
|
243
|
+
return sys.platform == "darwin"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _with_symbol_param(url: str, symbol: str) -> str:
|
|
247
|
+
"""Return a TradingView chart URL with `symbol=EXCHANGE:TICKER` set.
|
|
248
|
+
|
|
249
|
+
TV accepts URLs like `/chart/?symbol=NASDAQ%3ATSLA` and `/chart/<id>/?symbol=...`.
|
|
250
|
+
Using the URL param makes standalone screenshots more deterministic than relying only
|
|
251
|
+
on the symbol-search modal, especially when a saved layout opens on an unrelated symbol.
|
|
252
|
+
"""
|
|
253
|
+
parsed = urlparse(url)
|
|
254
|
+
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
|
255
|
+
query["symbol"] = symbol
|
|
256
|
+
return urlunparse(parsed._replace(query=urlencode(query)))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ─── Public flows ───────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def session_check_async() -> dict:
|
|
263
|
+
"""Diagnose login state across both auth paths.
|
|
264
|
+
|
|
265
|
+
Two ways the toolkit can be authenticated:
|
|
266
|
+
- `via_env_cookie`: `TV_SESSIONID` is set in .env (injected per-run)
|
|
267
|
+
- `via_browser_profile`: `sessionid` cookie present in the Playwright profile
|
|
268
|
+
(persisted by `make login`)
|
|
269
|
+
|
|
270
|
+
Either one is enough; `signed_in` is True if any path produces a valid cookie.
|
|
271
|
+
"""
|
|
272
|
+
async with tv_browser(headless=True) as (ctx, page):
|
|
273
|
+
await page.goto(sel.CHART_URL, wait_until="domcontentloaded")
|
|
274
|
+
signed_in = await _is_signed_in(page)
|
|
275
|
+
|
|
276
|
+
# Inspect actual cookies on tradingview.com to see which path is providing auth
|
|
277
|
+
try:
|
|
278
|
+
cookies = await ctx.cookies("https://www.tradingview.com")
|
|
279
|
+
except Exception:
|
|
280
|
+
cookies = []
|
|
281
|
+
profile_sessionid = next(
|
|
282
|
+
(c.get("value") for c in cookies if c.get("name") == "sessionid"),
|
|
283
|
+
None,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
env_set = bool(settings.tv_sessionid)
|
|
287
|
+
profile_set = bool(profile_sessionid)
|
|
288
|
+
|
|
289
|
+
# Which path actually authenticated us?
|
|
290
|
+
if env_set and not profile_set:
|
|
291
|
+
method = "env"
|
|
292
|
+
elif profile_set and not env_set:
|
|
293
|
+
method = "browser_profile"
|
|
294
|
+
elif env_set and profile_set:
|
|
295
|
+
method = "both"
|
|
296
|
+
else:
|
|
297
|
+
method = "none"
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
"signed_in": signed_in,
|
|
301
|
+
"auth_method": method,
|
|
302
|
+
"via_env_cookie": env_set,
|
|
303
|
+
"via_browser_profile": profile_set,
|
|
304
|
+
"profile_dir": str(settings.browser_profile),
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def session_check() -> dict:
|
|
309
|
+
return asyncio.run(session_check_async())
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
async def interactive_login_async(timeout_seconds: int = 300) -> dict:
|
|
313
|
+
"""Open a visible browser; user logs in manually. Profile is saved.
|
|
314
|
+
|
|
315
|
+
Polls every second for the logged-in signal. As soon as login is detected,
|
|
316
|
+
the browser is closed automatically (via the `tv_browser` context manager).
|
|
317
|
+
"""
|
|
318
|
+
async with tv_browser(headless=False) as (_ctx, page):
|
|
319
|
+
await page.goto(sel.LOGIN_URL, wait_until="domcontentloaded")
|
|
320
|
+
# Pull the window to the front. Playwright's bring_to_front handles
|
|
321
|
+
# the tab; on macOS we additionally activate Chromium so it isn't
|
|
322
|
+
# hidden behind the terminal that launched it.
|
|
323
|
+
try:
|
|
324
|
+
await page.bring_to_front()
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
if sys.platform == "darwin":
|
|
328
|
+
try:
|
|
329
|
+
subprocess.run(
|
|
330
|
+
["osascript", "-e", 'tell application "Chromium" to activate'],
|
|
331
|
+
check=False, timeout=2, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
332
|
+
)
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
print("👉 A Chromium window has opened. Log into TradingView in that window.")
|
|
336
|
+
print(" If you don't see it, look behind other apps (or use Mission Control).")
|
|
337
|
+
print(" The window closes automatically once login is detected.")
|
|
338
|
+
deadline = time.time() + timeout_seconds
|
|
339
|
+
while time.time() < deadline:
|
|
340
|
+
if await _is_signed_in(page, quick=True):
|
|
341
|
+
# Give the page a brief moment to finish writing session cookies
|
|
342
|
+
# to disk before we tear down the context.
|
|
343
|
+
print("✓ Login detected. Closing browser…")
|
|
344
|
+
await asyncio.sleep(1.5)
|
|
345
|
+
return {"signed_in": True, "message": "Login captured and persisted."}
|
|
346
|
+
await asyncio.sleep(1)
|
|
347
|
+
return {"signed_in": False, "message": "Timed out waiting for login."}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def interactive_login(timeout_seconds: int = 300) -> dict:
|
|
351
|
+
return asyncio.run(interactive_login_async(timeout_seconds))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
async def push_script_async(
|
|
355
|
+
script_path: Path,
|
|
356
|
+
apply_to: Optional[str] = None,
|
|
357
|
+
screenshot: bool = False,
|
|
358
|
+
script_name: Optional[str] = None,
|
|
359
|
+
) -> dict:
|
|
360
|
+
"""Paste a .pine file into TV's Pine Editor, save it, optionally apply + screenshot."""
|
|
361
|
+
script_path = Path(script_path).resolve()
|
|
362
|
+
if not script_path.exists():
|
|
363
|
+
raise FileNotFoundError(script_path)
|
|
364
|
+
code = script_path.read_text(encoding="utf-8")
|
|
365
|
+
script_name = script_name or script_path.stem
|
|
366
|
+
|
|
367
|
+
result: dict = {
|
|
368
|
+
"script": str(script_path),
|
|
369
|
+
"name": script_name,
|
|
370
|
+
"saved": False,
|
|
371
|
+
"applied": False,
|
|
372
|
+
"screenshot": None,
|
|
373
|
+
"errors": [],
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async with tv_browser() as (_ctx, page):
|
|
377
|
+
target_url = settings.default_chart_url or sel.CHART_URL
|
|
378
|
+
await page.goto(target_url, wait_until="domcontentloaded")
|
|
379
|
+
|
|
380
|
+
if not await _is_signed_in(page):
|
|
381
|
+
result["errors"].append(
|
|
382
|
+
"Not signed in. Run `training-view browser login` first or set TV_SESSIONID in .env."
|
|
383
|
+
)
|
|
384
|
+
return result
|
|
385
|
+
|
|
386
|
+
# Optionally change symbol first (so the script applies to the right chart)
|
|
387
|
+
if apply_to:
|
|
388
|
+
try:
|
|
389
|
+
await _switch_symbol(page, apply_to)
|
|
390
|
+
except Exception as e:
|
|
391
|
+
result["errors"].append(f"Symbol change failed: {e}")
|
|
392
|
+
|
|
393
|
+
await _open_pine_editor(page)
|
|
394
|
+
await _replace_editor_contents(page, code)
|
|
395
|
+
|
|
396
|
+
# Save via Cmd/Ctrl+S — TV shows a "Save as" dialog for unnamed scripts
|
|
397
|
+
mod = "Meta" if _is_mac() else "Control"
|
|
398
|
+
await page.keyboard.press(f"{mod}+S")
|
|
399
|
+
# First-save dialog handling
|
|
400
|
+
try:
|
|
401
|
+
dialog = page.locator(sel.PINE_SCRIPT_NAME_DIALOG).first
|
|
402
|
+
await dialog.wait_for(timeout=4_000)
|
|
403
|
+
await page.locator(sel.PINE_SCRIPT_NAME_INPUT).fill(script_name)
|
|
404
|
+
await page.locator(sel.PINE_DIALOG_SUBMIT).first.click()
|
|
405
|
+
except Exception:
|
|
406
|
+
pass # already-saved scripts skip the dialog
|
|
407
|
+
|
|
408
|
+
await page.wait_for_timeout(2500)
|
|
409
|
+
result["saved"] = True
|
|
410
|
+
|
|
411
|
+
# Apply to chart
|
|
412
|
+
try:
|
|
413
|
+
await page.click(sel.PINE_ADD_TO_CHART_BUTTON, timeout=8_000)
|
|
414
|
+
await page.wait_for_timeout(2500)
|
|
415
|
+
result["applied"] = True
|
|
416
|
+
except Exception as e:
|
|
417
|
+
result["errors"].append(f"Add-to-chart failed: {e}")
|
|
418
|
+
|
|
419
|
+
# Check for Pine compile errors
|
|
420
|
+
try:
|
|
421
|
+
err = await page.query_selector(sel.PINE_ERROR_BADGE)
|
|
422
|
+
if err:
|
|
423
|
+
txt = await err.inner_text()
|
|
424
|
+
result["errors"].append(f"Pine compile error: {txt}")
|
|
425
|
+
except Exception:
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
# Screenshot
|
|
429
|
+
if screenshot:
|
|
430
|
+
settings.screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
431
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
432
|
+
tag = (apply_to or "chart").replace(":", "_")
|
|
433
|
+
out = settings.screenshot_dir / f"{script_name}__{tag}__{stamp}.png"
|
|
434
|
+
await page.screenshot(path=str(out), full_page=False)
|
|
435
|
+
result["screenshot"] = str(out)
|
|
436
|
+
|
|
437
|
+
return result
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def push_script(
|
|
441
|
+
script_path: Path | str,
|
|
442
|
+
apply_to: Optional[str] = None,
|
|
443
|
+
screenshot: bool = False,
|
|
444
|
+
script_name: Optional[str] = None,
|
|
445
|
+
) -> dict:
|
|
446
|
+
return asyncio.run(
|
|
447
|
+
push_script_async(Path(script_path), apply_to, screenshot, script_name)
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
async def _switch_symbol(page: Page, symbol: str) -> None:
|
|
452
|
+
"""Open TV's symbol-search dialog and select `symbol` robustly."""
|
|
453
|
+
exchange, _, ticker = symbol.partition(":")
|
|
454
|
+
ticker = ticker or symbol
|
|
455
|
+
|
|
456
|
+
await page.click(sel.SYMBOL_SEARCH_BUTTON, timeout=8_000)
|
|
457
|
+
|
|
458
|
+
input_loc = page.locator(sel.SYMBOL_SEARCH_INPUT).first
|
|
459
|
+
await input_loc.wait_for(timeout=5_000)
|
|
460
|
+
await input_loc.click()
|
|
461
|
+
mod = "Meta" if _is_mac() else "Control"
|
|
462
|
+
await page.keyboard.press(f"{mod}+A")
|
|
463
|
+
await input_loc.fill(symbol)
|
|
464
|
+
|
|
465
|
+
rows = page.locator(sel.SYMBOL_SEARCH_RESULT_FIRST)
|
|
466
|
+
await rows.first.wait_for(timeout=5_000)
|
|
467
|
+
|
|
468
|
+
# Prefer a row that contains both ticker and exchange. If TV changes row
|
|
469
|
+
# markup, fall back to ticker-only, then the first row.
|
|
470
|
+
clicked = False
|
|
471
|
+
for candidate in (
|
|
472
|
+
rows.filter(has_text=ticker).filter(has_text=exchange).first,
|
|
473
|
+
rows.filter(has_text=ticker).first,
|
|
474
|
+
rows.first,
|
|
475
|
+
):
|
|
476
|
+
try:
|
|
477
|
+
if await candidate.count() > 0:
|
|
478
|
+
await candidate.click(timeout=3_000)
|
|
479
|
+
clicked = True
|
|
480
|
+
break
|
|
481
|
+
except Exception:
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
if not clicked:
|
|
485
|
+
await page.keyboard.press("Enter")
|
|
486
|
+
|
|
487
|
+
# Wait for the chart to re-render and the toolbar to reflect the target.
|
|
488
|
+
await page.wait_for_timeout(2500)
|
|
489
|
+
try:
|
|
490
|
+
await page.wait_for_function(
|
|
491
|
+
"([selector, ticker]) => (document.querySelector(selector)?.innerText || '').toUpperCase().includes(ticker.toUpperCase())",
|
|
492
|
+
[sel.SYMBOL_SEARCH_BUTTON, ticker],
|
|
493
|
+
timeout=8_000,
|
|
494
|
+
)
|
|
495
|
+
except Exception:
|
|
496
|
+
current = ""
|
|
497
|
+
try:
|
|
498
|
+
current = (await page.locator(sel.SYMBOL_SEARCH_BUTTON).inner_text(timeout=1_000)).strip()
|
|
499
|
+
except Exception:
|
|
500
|
+
pass
|
|
501
|
+
raise RuntimeError(f"symbol switch did not reach {symbol!r}; toolbar shows {current!r}")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
async def screenshot_chart_async(
|
|
505
|
+
symbol: Optional[str] = None,
|
|
506
|
+
layout_url: Optional[str] = None,
|
|
507
|
+
out_path: Optional[Path] = None,
|
|
508
|
+
) -> Path:
|
|
509
|
+
async with tv_browser() as (_ctx, page):
|
|
510
|
+
url = layout_url or settings.default_chart_url or sel.CHART_URL
|
|
511
|
+
if symbol:
|
|
512
|
+
url = _with_symbol_param(url, symbol)
|
|
513
|
+
await page.goto(url, wait_until="domcontentloaded")
|
|
514
|
+
# Give the chart UI a moment to settle before interacting with the toolbar
|
|
515
|
+
await page.wait_for_timeout(4000)
|
|
516
|
+
if symbol:
|
|
517
|
+
try:
|
|
518
|
+
# The URL param usually switches the symbol. If a saved layout ignores it,
|
|
519
|
+
# force the switch via the toolbar and fail loudly if verification fails.
|
|
520
|
+
ticker = symbol.partition(":")[2] or symbol
|
|
521
|
+
toolbar = (await page.locator(sel.SYMBOL_SEARCH_BUTTON).inner_text(timeout=2_000)).upper()
|
|
522
|
+
if ticker.upper() not in toolbar:
|
|
523
|
+
await _switch_symbol(page, symbol)
|
|
524
|
+
except Exception as e:
|
|
525
|
+
raise RuntimeError(f"symbol switch failed for {symbol!r}: {e}") from e
|
|
526
|
+
|
|
527
|
+
settings.screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
528
|
+
if out_path is None:
|
|
529
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
530
|
+
tag = (symbol or "chart").replace(":", "_")
|
|
531
|
+
out_path = settings.screenshot_dir / f"{tag}__{stamp}.png"
|
|
532
|
+
await page.screenshot(path=str(out_path), full_page=False)
|
|
533
|
+
return Path(out_path)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def screenshot_chart(
|
|
537
|
+
symbol: Optional[str] = None,
|
|
538
|
+
layout_url: Optional[str] = None,
|
|
539
|
+
out_path: Optional[Path] = None,
|
|
540
|
+
) -> Path:
|
|
541
|
+
return asyncio.run(screenshot_chart_async(symbol, layout_url, out_path))
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""All TradingView DOM selectors in one place.
|
|
2
|
+
|
|
3
|
+
TV's DOM changes occasionally. When automation breaks, fix selectors here only.
|
|
4
|
+
Prefer role-based / text-based / data-name locators (resilient) over CSS class
|
|
5
|
+
names (brittle — TV uses hashed class names like `button-abc123`).
|
|
6
|
+
|
|
7
|
+
Last refreshed: May 2026.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# ─── URLs ────────────────────────────────────────────────────────────────
|
|
11
|
+
CHART_URL = "https://www.tradingview.com/chart/"
|
|
12
|
+
LOGIN_URL = "https://www.tradingview.com/accounts/signin/"
|
|
13
|
+
|
|
14
|
+
# ─── Login / auth state ──────────────────────────────────────────────────
|
|
15
|
+
USER_MENU_SELECTORS = [
|
|
16
|
+
'button[aria-label="Open user menu"]',
|
|
17
|
+
'button[aria-label*="user menu" i]',
|
|
18
|
+
'[data-name="header-user-menu-button"]', # legacy fallback
|
|
19
|
+
]
|
|
20
|
+
SIGN_IN_SELECTORS = [
|
|
21
|
+
'button[data-name="header-user-menu-sign-in"]',
|
|
22
|
+
'a[href*="/accounts/signin"]',
|
|
23
|
+
'[data-name="header-toolbar-sign-in"]',
|
|
24
|
+
]
|
|
25
|
+
SIGN_IN_BUTTON_TEXT = "Sign in"
|
|
26
|
+
USER_MENU = USER_MENU_SELECTORS[0] # back-compat alias
|
|
27
|
+
|
|
28
|
+
# ─── Symbol search (top-left ticker) ─────────────────────────────────────
|
|
29
|
+
# Click this to open the symbol-search dialog
|
|
30
|
+
SYMBOL_SEARCH_BUTTON = "#header-toolbar-symbol-search"
|
|
31
|
+
# The input that receives keystrokes inside the open dialog
|
|
32
|
+
SYMBOL_SEARCH_INPUT = '[role="dialog"] input'
|
|
33
|
+
# Each result row in the symbol-search results list
|
|
34
|
+
SYMBOL_SEARCH_RESULT_FIRST = '[data-role="list-item"]'
|
|
35
|
+
|
|
36
|
+
# ─── Pine Editor (slide-in panel from the right) ─────────────────────────
|
|
37
|
+
# Click this to open / close the Pine Editor panel
|
|
38
|
+
PINE_DIALOG_BUTTON = '[data-name="pine-dialog-button"]'
|
|
39
|
+
# The container that wraps the whole Pine Editor panel (visible when open)
|
|
40
|
+
PINE_DIALOG = '[data-name="pine-dialog"]'
|
|
41
|
+
# Monaco-based code editor (inside PINE_DIALOG)
|
|
42
|
+
PINE_MONACO = '[data-name="pine-dialog"] .monaco-editor textarea.inputarea'
|
|
43
|
+
# Scoped buttons inside the Pine panel
|
|
44
|
+
PINE_SAVE_BUTTON = '[data-name="pine-dialog"] button:has-text("Save")'
|
|
45
|
+
PINE_ADD_TO_CHART_BUTTON = '[data-name="pine-dialog"] button:has-text("Add to chart")'
|
|
46
|
+
PINE_PUBLISH_BUTTON = '[data-name="pine-dialog"] button:has-text("Publish script")'
|
|
47
|
+
# Script title element (shows "Untitled script" / your script's name)
|
|
48
|
+
PINE_TITLE = '[data-name="pine-dialog"] [class*="titleSlot"]'
|
|
49
|
+
|
|
50
|
+
# Save-as / script-name dialog (appears on first save of an unnamed script)
|
|
51
|
+
PINE_SCRIPT_NAME_DIALOG = '[role="dialog"]:has-text("Save as")'
|
|
52
|
+
PINE_SCRIPT_NAME_INPUT = '[role="dialog"] input[type="text"]'
|
|
53
|
+
PINE_DIALOG_SUBMIT = '[role="dialog"] button:has-text("Save")'
|
|
54
|
+
|
|
55
|
+
# ─── Chart canvas / area (for screenshots) ───────────────────────────────
|
|
56
|
+
CHART_CANVAS = ".chart-markup-table"
|
|
57
|
+
CHART_CONTAINER = ".chart-container"
|
|
58
|
+
|
|
59
|
+
# ─── Status indicators ───────────────────────────────────────────────────
|
|
60
|
+
# Pine compilation status: badge / toast that appears on error
|
|
61
|
+
PINE_ERROR_BADGE = '[data-name="pane-status-error"]'
|
|
62
|
+
TOAST_NOTIFICATION = '[data-name="toast-container"]'
|
|
63
|
+
|
|
64
|
+
# ─── Legacy selectors (kept for reference, no longer used) ───────────────
|
|
65
|
+
# These were valid in earlier TV builds:
|
|
66
|
+
# - [data-name="legend-source-title"] (was the symbol search button)
|
|
67
|
+
# - [data-role="search"] input (was the search input)
|
|
68
|
+
# - button:has-text("Pine Editor") (was the editor tab)
|
|
69
|
+
# - button[data-name="save"] (was the save button)
|
|
70
|
+
# - button[data-name="add-script-to-chart"] (was the add-to-chart button)
|
|
File without changes
|