@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,140 @@
1
+ """`training-view pine ...` subcommands — local Pine scaffolding / validation."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from datetime import date
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from ..config import settings, PROJECT_ROOT
12
+
13
+ app = typer.Typer(no_args_is_help=True, help="Scaffold and validate PineScript locally.")
14
+ console = Console()
15
+
16
+ _TEMPLATE_DIR = PROJECT_ROOT / "skills" / "pinescript-v6" / "reference" / "templates"
17
+
18
+
19
+ @app.command()
20
+ def new(
21
+ kind: str = typer.Argument(..., help="indicator | strategy | webhook"),
22
+ name: str = typer.Argument(..., help="snake_case file name (no .pine)"),
23
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite if exists"),
24
+ ) -> None:
25
+ """Scaffold a new .pine file from the v6 template."""
26
+ kind = kind.lower()
27
+ template_map = {
28
+ "indicator": ("indicator.pine", "indicators"),
29
+ "strategy": ("strategy.pine", "strategies"),
30
+ "webhook": ("alert-webhook.pine", "strategies"),
31
+ }
32
+ if kind not in template_map:
33
+ console.print(f"[red]Unknown kind '{kind}'. Use: indicator | strategy | webhook[/red]")
34
+ raise typer.Exit(1)
35
+
36
+ tpl_name, subdir = template_map[kind]
37
+ tpl_path = _TEMPLATE_DIR / tpl_name
38
+ if not tpl_path.exists():
39
+ console.print(f"[red]Template not found:[/red] {tpl_path}")
40
+ raise typer.Exit(1)
41
+
42
+ out_dir = settings.scripts_dir / subdir
43
+ out_dir.mkdir(parents=True, exist_ok=True)
44
+ out_path = out_dir / f"{name}.pine"
45
+ if out_path.exists() and not force:
46
+ console.print(f"[yellow]Exists:[/yellow] {out_path} (use --force to overwrite)")
47
+ raise typer.Exit(1)
48
+
49
+ content = tpl_path.read_text(encoding="utf-8")
50
+ # Substitute placeholders
51
+ title = name.replace("_", " ").title()
52
+ content = content.replace("<Indicator Name>", title)
53
+ content = content.replace("<Strategy Name>", title)
54
+ content = content.replace("<short>", name[:10])
55
+ content = content.replace("<user>", settings.tv_username or "me")
56
+ content = content.replace("<date>", date.today().isoformat())
57
+ content = content.replace("<one-paragraph description>", "TODO: describe.")
58
+ content = content.replace("<one-paragraph description of entry/exit logic>", "TODO: describe.")
59
+
60
+ out_path.write_text(content, encoding="utf-8")
61
+ console.print(f"[green]Created:[/green] {out_path}")
62
+
63
+
64
+ @app.command()
65
+ def validate(
66
+ script: Path = typer.Argument(..., exists=True, help="Path to .pine file"),
67
+ ) -> None:
68
+ """Run lightweight local checks (real validation happens in TV's compiler)."""
69
+ code = script.read_text(encoding="utf-8")
70
+ issues: list[str] = []
71
+
72
+ if not re.search(r"^//@version=\d+", code, re.M):
73
+ issues.append("Missing `//@version=N` directive.")
74
+ else:
75
+ m = re.search(r"^//@version=(\d+)", code, re.M)
76
+ if m and m.group(1) not in {"5", "6"}:
77
+ issues.append(f"Version {m.group(1)} detected; prefer v6.")
78
+
79
+ decls = sum(
80
+ 1 for kw in ("indicator(", "strategy(", "library(")
81
+ if re.search(rf"^\s*{re.escape(kw)}", code, re.M)
82
+ )
83
+ if decls == 0:
84
+ issues.append("No `indicator()`, `strategy()`, or `library()` declaration found.")
85
+ elif decls > 1:
86
+ issues.append(f"{decls} declaration calls found; exactly one is required.")
87
+
88
+ # Check for unprefixed legacy calls
89
+ legacy_calls = ["sma(", "ema(", "rma(", "wma(", "rsi(", "atr(", "highest(", "lowest(",
90
+ "crossover(", "crossunder(", "tostring(", "abs("]
91
+ for fn in legacy_calls:
92
+ # Match call NOT preceded by '.' or letter
93
+ if re.search(rf"(?<![.\w]){re.escape(fn)}", code):
94
+ ns = "ta." if fn[:-1] in {"sma", "ema", "rma", "wma", "rsi", "atr",
95
+ "highest", "lowest", "crossover", "crossunder"} \
96
+ else "str." if fn[:-1] in {"tostring"} \
97
+ else "math." if fn[:-1] in {"abs"} \
98
+ else "?."
99
+ issues.append(f"Use `{ns}{fn}` instead of unprefixed `{fn}` (v5/v6 requires namespace).")
100
+
101
+ # Indicator must have at least one plot-like
102
+ if "indicator(" in code:
103
+ if not re.search(r"\b(plot|plotshape|plotchar|bgcolor|hline)\s*\(", code):
104
+ issues.append("indicator() has no plot*/bgcolor/hline output.")
105
+
106
+ # Strategy must have at least one entry
107
+ if "strategy(" in code and not re.search(r"\bstrategy\.(entry|order|close)\b", code):
108
+ issues.append("strategy() has no strategy.entry/order/close calls.")
109
+
110
+ if issues:
111
+ console.print(f"[yellow]Found {len(issues)} potential issue(s):[/yellow]")
112
+ for i in issues:
113
+ console.print(f" • {i}")
114
+ raise typer.Exit(2)
115
+ else:
116
+ console.print(f"[green]✅ {script} looks OK (basic checks).[/green]")
117
+ console.print("[dim] Real validation requires TradingView's Pine compiler.[/dim]")
118
+
119
+
120
+ @app.command(name="list")
121
+ def list_scripts() -> None:
122
+ """List all local .pine files."""
123
+ found = sorted(settings.scripts_dir.rglob("*.pine"))
124
+ if not found:
125
+ console.print(f"[yellow]No .pine files under {settings.scripts_dir}[/yellow]")
126
+ return
127
+ for p in found:
128
+ console.print(f" {p.relative_to(PROJECT_ROOT)}")
129
+
130
+
131
+ @app.command()
132
+ def show(
133
+ script: Path = typer.Argument(..., exists=True),
134
+ ) -> None:
135
+ """Print a Pine script with syntax-aware coloring (rough)."""
136
+ from rich.syntax import Syntax
137
+
138
+ code = script.read_text(encoding="utf-8")
139
+ # No Pine lexer in Pygments; JS is the closest visual approximation
140
+ console.print(Syntax(code, "javascript", line_numbers=True, theme="monokai"))
@@ -0,0 +1,98 @@
1
+ """Centralized configuration: env loading, paths, TV session cookie handling.
2
+
3
+ Both the CLI and the MCP servers import from here so behaviour stays consistent.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from dotenv import load_dotenv
12
+
13
+ # Walk up from this file until we find a .env, or land at project root
14
+ _HERE = Path(__file__).resolve()
15
+ PROJECT_ROOT = _HERE.parents[2] # src/tv_indicator/config.py -> project/
16
+ load_dotenv(PROJECT_ROOT / ".env", override=False)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Settings:
21
+ # TradingView authentication
22
+ tv_sessionid: str | None
23
+ tv_sessionid_sign: str | None
24
+ tv_username: str | None
25
+
26
+ # Browser automation
27
+ browser_profile: Path
28
+ browser_headless: bool
29
+ default_chart_url: str | None
30
+
31
+ # Output dirs
32
+ screenshot_dir: Path
33
+ scripts_dir: Path
34
+
35
+ @property
36
+ def has_session(self) -> bool:
37
+ return bool(self.tv_sessionid)
38
+
39
+ def session_cookies(self) -> list[dict]:
40
+ """Format cookies for Playwright's `context.add_cookies()`."""
41
+ cookies: list[dict] = []
42
+ if self.tv_sessionid:
43
+ cookies.append({
44
+ "name": "sessionid",
45
+ "value": self.tv_sessionid,
46
+ "domain": ".tradingview.com",
47
+ "path": "/",
48
+ "httpOnly": True,
49
+ "secure": True,
50
+ "sameSite": "Lax",
51
+ })
52
+ if self.tv_sessionid_sign:
53
+ cookies.append({
54
+ "name": "sessionid_sign",
55
+ "value": self.tv_sessionid_sign,
56
+ "domain": ".tradingview.com",
57
+ "path": "/",
58
+ "httpOnly": True,
59
+ "secure": True,
60
+ "sameSite": "Lax",
61
+ })
62
+ return cookies
63
+
64
+
65
+ def _bool_env(key: str, default: bool) -> bool:
66
+ raw = os.getenv(key)
67
+ if raw is None:
68
+ return default
69
+ return raw.strip().lower() in {"1", "true", "yes", "on", "y"}
70
+
71
+
72
+ def _path_env(key: str, default: Path) -> Path:
73
+ raw = os.getenv(key)
74
+ if raw is None or not raw.strip():
75
+ return default
76
+ p = Path(raw).expanduser()
77
+ if not p.is_absolute():
78
+ p = (PROJECT_ROOT / p).resolve()
79
+ return p
80
+
81
+
82
+ def load_settings() -> Settings:
83
+ return Settings(
84
+ tv_sessionid=os.getenv("TV_SESSIONID") or None,
85
+ tv_sessionid_sign=os.getenv("TV_SESSIONID_SIGN") or None,
86
+ tv_username=os.getenv("TV_USERNAME") or None,
87
+ browser_profile=_path_env(
88
+ "TV_BROWSER_PROFILE", PROJECT_ROOT / "tools/tv_browser/sessions/default"
89
+ ),
90
+ browser_headless=_bool_env("TV_BROWSER_HEADLESS", True),
91
+ default_chart_url=os.getenv("TV_DEFAULT_CHART_URL") or None,
92
+ screenshot_dir=_path_env("TV_SCREENSHOT_DIR", PROJECT_ROOT / "screenshots"),
93
+ scripts_dir=_path_env("TV_SCRIPTS_DIR", PROJECT_ROOT / "scripts"),
94
+ )
95
+
96
+
97
+ # Singleton for convenience
98
+ settings = load_settings()
File without changes
@@ -0,0 +1,187 @@
1
+ """OHLCV data via tvdatafeed (reverse-engineered TV WebSocket).
2
+
3
+ ⚠️ tvdatafeed is not on PyPI in a maintained form. Install via:
4
+ pip install --upgrade --force-reinstall git+https://github.com/rongardF/tvdatafeed.git
5
+
6
+ This wrapper hides the import error until the function is actually called so the
7
+ rest of the CLI still works without the optional dependency.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from dataclasses import dataclass
13
+ from enum import Enum
14
+ from typing import Optional
15
+
16
+ import pandas as pd
17
+
18
+ from ..config import settings
19
+
20
+
21
+ class Interval(str, Enum):
22
+ M_1 = "1"
23
+ M_3 = "3"
24
+ M_5 = "5"
25
+ M_15 = "15"
26
+ M_30 = "30"
27
+ M_45 = "45"
28
+ H_1 = "60"
29
+ H_2 = "120"
30
+ H_3 = "180"
31
+ H_4 = "240"
32
+ D = "1D"
33
+ W = "1W"
34
+ MN = "1M"
35
+
36
+
37
+ # Map our string interval to tvdatafeed's Interval enum at call time
38
+ _TV_INTERVAL_MAP = {
39
+ "1": "in_1_minute",
40
+ "3": "in_3_minute",
41
+ "5": "in_5_minute",
42
+ "15": "in_15_minute",
43
+ "30": "in_30_minute",
44
+ "45": "in_45_minute",
45
+ "60": "in_1_hour",
46
+ "120": "in_2_hour",
47
+ "180": "in_3_hour",
48
+ "240": "in_4_hour",
49
+ "1D": "in_daily",
50
+ "1W": "in_weekly",
51
+ "1M": "in_monthly",
52
+ }
53
+
54
+ # Friendly aliases -> canonical TV codes
55
+ _INTERVAL_ALIASES = {
56
+ "1m": "1", "3m": "3", "5m": "5", "15m": "15", "30m": "30", "45m": "45",
57
+ "1h": "60", "60m": "60",
58
+ "2h": "120", "3h": "180", "4h": "240",
59
+ "1d": "1D", "d": "1D", "day": "1D", "daily": "1D",
60
+ "1w": "1W", "w": "1W", "week": "1W", "weekly": "1W",
61
+ "1mo": "1M", "mo": "1M", "month": "1M", "monthly": "1M",
62
+ }
63
+
64
+
65
+ def _normalize_interval(interval: str) -> str:
66
+ """Map user-friendly intervals (e.g. '1H', '4h', 'daily') to TV codes ('60', '240', '1D')."""
67
+ if interval in _TV_INTERVAL_MAP:
68
+ return interval
69
+ alias = _INTERVAL_ALIASES.get(interval.lower())
70
+ if alias is not None:
71
+ return alias
72
+ return interval # let downstream raise a clear error
73
+
74
+
75
+ @dataclass
76
+ class OHLCVRequest:
77
+ symbol: str # e.g. "AAPL" or "NASDAQ:AAPL"
78
+ exchange: Optional[str] = None # if not encoded in symbol
79
+ interval: str = "1D"
80
+ n_bars: int = 500
81
+ extended_session: bool = False
82
+ fut_contract: Optional[int] = None # for futures, None for spot/stock
83
+
84
+
85
+ def _split_symbol(symbol: str, exchange: Optional[str]) -> tuple[str, str]:
86
+ """Accept 'NASDAQ:AAPL' or ('AAPL', 'NASDAQ'); return (symbol, exchange)."""
87
+ if ":" in symbol:
88
+ ex, sym = symbol.split(":", 1)
89
+ return sym, ex
90
+ if exchange:
91
+ return symbol, exchange
92
+ # Sensible default for stocks; users should always be explicit
93
+ return symbol, "NASDAQ"
94
+
95
+
96
+ class TVDataClient:
97
+ """Thin wrapper around tvdatafeed.TvDatafeed.
98
+
99
+ Lazily constructed so that import failures only surface when actually used.
100
+ """
101
+
102
+ _instance: Optional["TVDataClient"] = None
103
+
104
+ def __init__(self) -> None:
105
+ try:
106
+ # Both capitalisations are used in different forks
107
+ try:
108
+ from tvDatafeed import TvDatafeed, Interval as TVInterval # type: ignore
109
+ except ImportError:
110
+ from tvdatafeed import TvDatafeed, Interval as TVInterval # type: ignore
111
+ except ImportError as e:
112
+ raise RuntimeError(
113
+ "tvdatafeed is not installed.\n"
114
+ " pip install git+https://github.com/rongardF/tvdatafeed.git"
115
+ ) from e
116
+
117
+ self._TVInterval = TVInterval
118
+
119
+ if settings.tv_username and settings.tv_sessionid:
120
+ # The maintained fork takes a token/session — pattern varies.
121
+ # Default to anonymous mode; user can subclass or extend if needed.
122
+ self._tv = TvDatafeed()
123
+ else:
124
+ self._tv = TvDatafeed()
125
+
126
+ @classmethod
127
+ def get(cls) -> "TVDataClient":
128
+ if cls._instance is None:
129
+ cls._instance = cls()
130
+ return cls._instance
131
+
132
+ def get_ohlcv(self, req: OHLCVRequest, retries: int = 2) -> pd.DataFrame:
133
+ """Fetch OHLCV with auto-retry on transient WebSocket drops.
134
+
135
+ tvDatafeed occasionally returns empty results when its WebSocket connection
136
+ is lost on first contact ("Connection to remote host was lost"). We retry up
137
+ to `retries` times with a short backoff.
138
+ """
139
+ symbol, exchange = _split_symbol(req.symbol, req.exchange)
140
+ canonical = _normalize_interval(req.interval)
141
+ tv_interval_name = _TV_INTERVAL_MAP.get(canonical)
142
+ if tv_interval_name is None:
143
+ raise ValueError(
144
+ f"Unsupported interval '{req.interval}'. "
145
+ f"Valid: {list(_TV_INTERVAL_MAP.keys())}"
146
+ )
147
+ interval = getattr(self._TVInterval, tv_interval_name)
148
+
149
+ attempts = max(1, retries + 1)
150
+ last_df: Optional[pd.DataFrame] = None
151
+ for attempt in range(1, attempts + 1):
152
+ try:
153
+ df = self._tv.get_hist(
154
+ symbol=symbol,
155
+ exchange=exchange,
156
+ interval=interval,
157
+ n_bars=req.n_bars,
158
+ fut_contract=req.fut_contract,
159
+ extended_session=req.extended_session,
160
+ )
161
+ except Exception:
162
+ df = None
163
+ if df is not None and not df.empty:
164
+ return df
165
+ last_df = df
166
+ if attempt < attempts:
167
+ time.sleep(0.8 * attempt)
168
+ # All retries exhausted -> empty frame
169
+ return pd.DataFrame(columns=["open", "high", "low", "close", "volume"]) if last_df is None or last_df.empty else last_df
170
+
171
+
172
+ def get_ohlcv(
173
+ symbol: str,
174
+ interval: str = "1D",
175
+ n_bars: int = 500,
176
+ exchange: Optional[str] = None,
177
+ extended_session: bool = False,
178
+ ) -> pd.DataFrame:
179
+ """Functional wrapper used by CLI and MCP."""
180
+ req = OHLCVRequest(
181
+ symbol=symbol,
182
+ exchange=exchange,
183
+ interval=interval,
184
+ n_bars=n_bars,
185
+ extended_session=extended_session,
186
+ )
187
+ return TVDataClient.get().get_ohlcv(req)
@@ -0,0 +1,268 @@
1
+ """Symbol search + screener via `tradingview-screener` (most reliable TV endpoint).
2
+
3
+ This library uses TV's public scanner endpoint, which doesn't require auth.
4
+ Good for: searching tickers, running filter queries, getting TV's technical
5
+ ratings across many symbols at once.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any, Iterable, Optional
11
+
12
+ import pandas as pd
13
+
14
+
15
+ def _taiwan_direct_candidates(query: str) -> list[tuple[str, str]]:
16
+ """Return direct TWSE/TPEX candidates for Taiwan-style numeric tickers."""
17
+ q = query.strip().upper()
18
+ if q.endswith(".TW"):
19
+ ticker = q.removesuffix(".TW")
20
+ return [(ticker, "TWSE")] if ticker.isdigit() else []
21
+ if q.endswith(".TWO"):
22
+ ticker = q.removesuffix(".TWO")
23
+ return [(ticker, "TPEX")] if ticker.isdigit() else []
24
+ if q.isdigit() and 4 <= len(q) <= 6:
25
+ return [(q, "TWSE"), (q, "TPEX")]
26
+ return []
27
+
28
+
29
+ def _verified_taiwan_matches(query: str, limit: int) -> list[dict[str, Any]]:
30
+ """Fallback for symbols missing from scanner search, e.g. 0050 and 6584."""
31
+ candidates = _taiwan_direct_candidates(query)
32
+ if not candidates:
33
+ return []
34
+
35
+ try:
36
+ from .client import OHLCVRequest, TVDataClient
37
+ except Exception:
38
+ return []
39
+
40
+ matches: list[dict[str, Any]] = []
41
+ for ticker, exchange in candidates:
42
+ symbol = f"{exchange}:{ticker}"
43
+ tv_logger = logging.getLogger("tvDatafeed.main")
44
+ was_disabled = tv_logger.disabled
45
+ try:
46
+ tv_logger.disabled = True
47
+ df = TVDataClient.get().get_ohlcv(
48
+ OHLCVRequest(symbol=symbol, interval="1D", n_bars=1),
49
+ retries=0,
50
+ )
51
+ except Exception:
52
+ continue
53
+ finally:
54
+ tv_logger.disabled = was_disabled
55
+ if df is None or df.empty:
56
+ continue
57
+ matches.append(
58
+ {
59
+ "name": ticker,
60
+ "description": f"{ticker} Taiwan security",
61
+ "type": "stock",
62
+ "exchange": exchange,
63
+ "country": "Taiwan",
64
+ "symbol": symbol,
65
+ }
66
+ )
67
+ break
68
+ return matches
69
+
70
+
71
+ def search_symbol(query: str, limit: int = 20) -> list[dict[str, Any]]:
72
+ """Search for symbols by name or ticker.
73
+
74
+ Returns a list of {symbol, exchange, description, type, ...} dicts.
75
+ """
76
+ try:
77
+ from tradingview_screener import Query, col
78
+ except ImportError as e:
79
+ raise RuntimeError(
80
+ "tradingview-screener is not installed. `pip install tradingview-screener`"
81
+ ) from e
82
+
83
+ # tradingview-screener's `.like()` does substring matching directly
84
+ # (no SQL `%` wildcards needed) and is case-sensitive.
85
+ # We OR-match the uppercase ticker form against `name` and the
86
+ # title-cased / original form against `description`.
87
+ q_upper = query.upper()
88
+ q_title = query.title() if query.islower() else query
89
+
90
+ q = (
91
+ Query()
92
+ .select("name", "description", "type", "exchange", "country")
93
+ .where(
94
+ col("name").like(q_upper)
95
+ | col("description").like(q_title)
96
+ | col("description").like(query)
97
+ )
98
+ .limit(limit)
99
+ )
100
+ _, df = q.get_scanner_data()
101
+ if df is not None and not df.empty:
102
+ return df.to_dict(orient="records")
103
+ return _verified_taiwan_matches(query, limit)
104
+
105
+
106
+ def screener(
107
+ market: str = "america",
108
+ columns: Optional[Iterable[str]] = None,
109
+ filters: Optional[list[tuple[str, str, Any]]] = None,
110
+ order_by: Optional[tuple[str, bool]] = None, # (col, ascending)
111
+ limit: int = 50,
112
+ ) -> pd.DataFrame:
113
+ """Run a TradingView screener query.
114
+
115
+ Args:
116
+ market: "america", "crypto", "forex", "futures", "indonesia", ...
117
+ columns: which fields to fetch (e.g. ["name", "close", "RSI", "volume"])
118
+ filters: list of (column, op, value) tuples. Ops: "=", ">", "<", ">=", "<=", "!=", "in_range", "above_pct", ...
119
+ order_by: (column, ascending?)
120
+ limit: max rows
121
+ """
122
+ try:
123
+ from tradingview_screener import Query, col
124
+ except ImportError as e:
125
+ raise RuntimeError(
126
+ "tradingview-screener is not installed. `pip install tradingview-screener`"
127
+ ) from e
128
+
129
+ cols = list(columns) if columns else ["name", "close", "volume", "RSI", "change"]
130
+ q = Query().set_markets(market).select(*cols)
131
+
132
+ if filters:
133
+ for c, op, v in filters:
134
+ expr = col(c)
135
+ if op == ">":
136
+ q = q.where(expr > v)
137
+ elif op == "<":
138
+ q = q.where(expr < v)
139
+ elif op == ">=":
140
+ q = q.where(expr >= v)
141
+ elif op == "<=":
142
+ q = q.where(expr <= v)
143
+ elif op == "=" or op == "==":
144
+ q = q.where(expr == v)
145
+ elif op == "!=":
146
+ q = q.where(expr != v)
147
+ else:
148
+ raise ValueError(f"Unsupported op '{op}'. Use one of >, <, >=, <=, =, !=")
149
+
150
+ if order_by:
151
+ order_col, ascending = order_by
152
+ q = q.order_by(order_col, ascending=ascending)
153
+
154
+ q = q.limit(limit)
155
+ _, df = q.get_scanner_data()
156
+ return df if df is not None else pd.DataFrame()
157
+
158
+
159
+ # Known exchanges by asset class. Used to pick the right scanner market.
160
+ _CRYPTO_EXCHANGES = {
161
+ "BINANCE", "COINBASE", "KRAKEN", "BITSTAMP", "BITFINEX", "BYBIT",
162
+ "OKX", "KUCOIN", "HUOBI", "GEMINI", "BITTREX", "POLONIEX",
163
+ "CRYPTO", "CRYPTOCAP", "UNISWAP", "UNISWAP3ETH",
164
+ }
165
+ _FOREX_EXCHANGES = {"FX", "FX_IDC", "OANDA", "FOREXCOM", "SAXO", "PEPPERSTONE"}
166
+ _FUTURES_EXCHANGES = {"CME", "CME_MINI", "COMEX", "NYMEX", "CBOT", "ICE", "EUREX"}
167
+
168
+
169
+ def _market_for_symbol(symbol: str) -> str:
170
+ """Guess the right scanner market from a TV symbol like 'EXCHANGE:TICKER'."""
171
+ if ":" not in symbol:
172
+ return "america"
173
+ exchange = symbol.split(":", 1)[0].upper()
174
+ if exchange in _CRYPTO_EXCHANGES:
175
+ return "crypto"
176
+ if exchange in _FOREX_EXCHANGES:
177
+ return "forex"
178
+ if exchange in _FUTURES_EXCHANGES:
179
+ return "futures"
180
+ return "america"
181
+
182
+
183
+ def technical_rating(symbol: str, interval: str = "1D") -> dict[str, Any]:
184
+ """Get TradingView's "Strong Buy / Buy / Neutral / Sell / Strong Sell" rating.
185
+
186
+ Uses `tradingview-screener` to fetch the per-symbol Recommend.All field.
187
+
188
+ Note: TradingView's scanner API currently returns reliable data only for
189
+ equity markets (us, world stock exchanges). Crypto/forex/futures scanners
190
+ are present in the library but the upstream endpoints have been returning
191
+ empty results since early 2026. We attempt the right market based on the
192
+ exchange prefix and surface a clear error if no data comes back.
193
+ """
194
+ try:
195
+ from tradingview_screener import Query, col
196
+ except ImportError as e:
197
+ raise RuntimeError(
198
+ "tradingview-screener is not installed. `pip install tradingview-screener`"
199
+ ) from e
200
+
201
+ bare = symbol.split(":", 1)[-1]
202
+ market = _market_for_symbol(symbol)
203
+
204
+ q = (
205
+ Query()
206
+ .set_markets(market)
207
+ .select(
208
+ "name",
209
+ "Recommend.All",
210
+ "Recommend.MA",
211
+ "Recommend.Other",
212
+ "RSI",
213
+ "close",
214
+ )
215
+ .where(col("name") == bare)
216
+ .limit(1)
217
+ )
218
+ try:
219
+ _, df = q.get_scanner_data()
220
+ except Exception as e:
221
+ return {
222
+ "symbol": symbol,
223
+ "rating": None,
224
+ "market_tried": market,
225
+ "error": f"scanner query failed: {e}",
226
+ }
227
+
228
+ if df is None or df.empty:
229
+ msg = f"not found on '{market}' market"
230
+ if market != "america":
231
+ msg += (
232
+ " (TradingView's non-equity scanner endpoints are currently"
233
+ " returning empty results)"
234
+ )
235
+ return {
236
+ "symbol": symbol,
237
+ "rating": None,
238
+ "market_tried": market,
239
+ "error": msg,
240
+ }
241
+
242
+ row = df.iloc[0].to_dict()
243
+ score = row.get("Recommend.All")
244
+
245
+ def label(s: float | None) -> str:
246
+ if s is None:
247
+ return "Unknown"
248
+ if s >= 0.5:
249
+ return "Strong Buy"
250
+ if s >= 0.1:
251
+ return "Buy"
252
+ if s > -0.1:
253
+ return "Neutral"
254
+ if s > -0.5:
255
+ return "Sell"
256
+ return "Strong Sell"
257
+
258
+ return {
259
+ "symbol": symbol,
260
+ "interval": interval,
261
+ "market": market,
262
+ "rating": label(score),
263
+ "score": score,
264
+ "rsi": row.get("RSI"),
265
+ "close": row.get("close"),
266
+ "ma_score": row.get("Recommend.MA"),
267
+ "oscillator_score": row.get("Recommend.Other"),
268
+ }
File without changes