@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.
Files changed (80) hide show
  1. package/README.md +2 -7
  2. package/package.json +4 -1
  3. package/project/.cursor/rules/agent-docs.mdc +16 -0
  4. package/project/.cursor/rules/api-credential-storage.mdc +16 -0
  5. package/project/.cursor/rules/pinescript-v6.mdc +16 -0
  6. package/project/.cursor/rules/stock-model-forecast.mdc +16 -0
  7. package/project/.cursor/rules/system-prompt-injection.mdc +16 -0
  8. package/project/.cursor/rules/system-prompt-updater.mdc +16 -0
  9. package/project/.cursor/rules/tradingview-stock-data.mdc +16 -0
  10. package/project/.env.example +44 -0
  11. package/project/.npm-packaged-project +1 -0
  12. package/project/.pi/APPEND_SYSTEM.md +338 -0
  13. package/project/.pi/settings.json +8 -0
  14. package/project/AGENTS.md +538 -0
  15. package/project/CLAUDE.md +538 -0
  16. package/project/GEMINI.md +538 -0
  17. package/project/Makefile +488 -0
  18. package/project/README.md +419 -0
  19. package/project/conda-env-active.sh +98 -0
  20. package/project/conda-env-deactive.sh +42 -0
  21. package/project/docs/agent-install.md +446 -0
  22. package/project/docs/agent-skill-directory.md +222 -0
  23. package/project/docs/integration.html +271 -0
  24. package/project/packages/indicator/README.md +39 -0
  25. package/project/packages/indicator/package.json +40 -0
  26. package/project/packages/indicator/scripts/build-project-snapshot.js +57 -0
  27. package/project/packages/indicator/src/cli.js +368 -0
  28. package/project/packages/tradingview-stock-data-skill/README.md +112 -0
  29. package/project/packages/tradingview-stock-data-skill/extensions/stock-prompt-injector.ts +121 -0
  30. package/project/packages/tradingview-stock-data-skill/package.json +35 -0
  31. package/project/packages/tradingview-stock-data-skill/scripts/postinstall.sh +73 -0
  32. package/project/packages/tradingview-stock-data-skill/skills/tradingview-stock-data/SKILL.md +241 -0
  33. package/project/pyproject.toml +68 -0
  34. package/project/screenshots/.gitkeep +0 -0
  35. package/project/scripts/indicators/example_rsi_bands.pine +27 -0
  36. package/project/scripts/indicators/tsla_levels.pine +57 -0
  37. package/project/skills/agent-docs/SKILL.md +56 -0
  38. package/project/skills/api-credential-storage/SKILL.md +83 -0
  39. package/project/skills/api-credential-storage/scripts/upsert_env.py +151 -0
  40. package/project/skills/pinescript-v6/SKILL.md +129 -0
  41. package/project/skills/pinescript-v6/reference/built-ins.md +219 -0
  42. package/project/skills/pinescript-v6/reference/templates/alert-webhook.pine +76 -0
  43. package/project/skills/pinescript-v6/reference/templates/indicator.pine +48 -0
  44. package/project/skills/pinescript-v6/reference/templates/strategy.pine +50 -0
  45. package/project/skills/pinescript-v6/reference/v5-to-v6-migration.md +102 -0
  46. package/project/skills/pinescript-v6/reference/v6-language.md +202 -0
  47. package/project/skills/stock-model-forecast/SKILL.md +192 -0
  48. package/project/skills/system-prompt-injection/CUSTOM_SYSTEM_PROMPT.md +333 -0
  49. package/project/skills/system-prompt-injection/DEFAULT_SYSTEM_PROMPT.md +327 -0
  50. package/project/skills/system-prompt-injection/SKILL.md +90 -0
  51. package/project/skills/system-prompt-injection/SYSTEM_PROMPT.md +23 -0
  52. package/project/skills/system-prompt-updater/SKILL.md +82 -0
  53. package/project/skills/system-prompt-updater/scripts/system_prompt_update.sh +106 -0
  54. package/project/skills/tradingview-stock-data/SKILL.md +272 -0
  55. package/project/src/tv_indicator/__init__.py +0 -0
  56. package/project/src/tv_indicator/browser/__init__.py +0 -0
  57. package/project/src/tv_indicator/browser/automation.py +541 -0
  58. package/project/src/tv_indicator/browser/selectors.py +70 -0
  59. package/project/src/tv_indicator/cli/__init__.py +0 -0
  60. package/project/src/tv_indicator/cli/browser_cmds.py +92 -0
  61. package/project/src/tv_indicator/cli/data_cmds.py +178 -0
  62. package/project/src/tv_indicator/cli/main.py +56 -0
  63. package/project/src/tv_indicator/cli/model_cmds.py +255 -0
  64. package/project/src/tv_indicator/cli/pine_cmds.py +140 -0
  65. package/project/src/tv_indicator/config.py +98 -0
  66. package/project/src/tv_indicator/data/__init__.py +0 -0
  67. package/project/src/tv_indicator/data/client.py +187 -0
  68. package/project/src/tv_indicator/data/screener.py +268 -0
  69. package/project/src/tv_indicator/mcp/__init__.py +0 -0
  70. package/project/src/tv_indicator/mcp/agent_server.py +398 -0
  71. package/project/src/tv_indicator/mcp/browser_server.py +133 -0
  72. package/project/src/tv_indicator/mcp/data_server.py +239 -0
  73. package/project/src/tv_indicator/model/__init__.py +19 -0
  74. package/project/src/tv_indicator/model/forecast.py +693 -0
  75. package/project/tools/import_agent_tools.sh +503 -0
  76. package/project/tools/install_skills.sh +673 -0
  77. package/project/tools/interactive_install.sh +917 -0
  78. package/project/tools/progress.sh +114 -0
  79. package/project/tools/uninstall_agent_tools.sh +373 -0
  80. 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