@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,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
|