@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,92 @@
1
+ """`training-view browser ...` subcommands."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ app = typer.Typer(no_args_is_help=True, help="Browser automation (Playwright) for TradingView.")
12
+ console = Console()
13
+
14
+
15
+ @app.command("session-check")
16
+ def session_check() -> None:
17
+ """Verify the saved browser profile / session cookie is still valid."""
18
+ from ..browser.automation import session_check as do_check
19
+
20
+ r = do_check()
21
+ ok = "✅" if r["signed_in"] else "❌"
22
+
23
+ method_label = {
24
+ "env": "env var (TV_SESSIONID in .env)",
25
+ "browser_profile": "browser profile (saved by `make login`)",
26
+ "both": "env var + browser profile (both)",
27
+ "none": "none",
28
+ }[r["auth_method"]]
29
+
30
+ console.print(f" {ok} signed_in: [bold]{r['signed_in']}[/bold]")
31
+ console.print(f" auth method: {method_label}")
32
+ console.print(
33
+ f" via .env cookie: {'✓' if r['via_env_cookie'] else '✗'}"
34
+ f" (TV_SESSIONID set in .env)"
35
+ )
36
+ console.print(
37
+ f" via browser profile: {'✓' if r['via_browser_profile'] else '✗'}"
38
+ f" (sessionid cookie persisted in Playwright profile)"
39
+ )
40
+ console.print(f" profile_dir: {r['profile_dir']}")
41
+
42
+ if not r["signed_in"]:
43
+ console.print(
44
+ "\n [yellow]Hint:[/yellow] run [bold]make login[/bold] for an interactive sign-in,"
45
+ " or paste a fresh sessionid into .env."
46
+ )
47
+
48
+
49
+ @app.command()
50
+ def login(
51
+ timeout: int = typer.Option(300, "--timeout", help="Seconds to wait for manual login")
52
+ ) -> None:
53
+ """Open a visible browser so you can log in manually. Profile is saved."""
54
+ from ..browser.automation import interactive_login
55
+
56
+ r = interactive_login(timeout_seconds=timeout)
57
+ if r["signed_in"]:
58
+ console.print(f"[green]✅ {r['message']}[/green]")
59
+ else:
60
+ console.print(f"[red]❌ {r['message']}[/red]")
61
+ raise typer.Exit(1)
62
+
63
+
64
+ @app.command()
65
+ def push(
66
+ script: Path = typer.Argument(..., exists=True, help="Path to .pine file"),
67
+ apply: Optional[str] = typer.Option(
68
+ None, "--apply", help="Symbol to apply chart to, e.g. NASDAQ:AAPL"
69
+ ),
70
+ screenshot: bool = typer.Option(False, "--screenshot", "-s", help="Save a chart screenshot"),
71
+ name: Optional[str] = typer.Option(None, "--name", help="Script name in TradingView"),
72
+ ) -> None:
73
+ """Paste a .pine file into TV Pine Editor, save, optionally apply + screenshot."""
74
+ from ..browser.automation import push_script
75
+
76
+ r = push_script(script, apply_to=apply, screenshot=screenshot, script_name=name)
77
+ console.print_json(json.dumps(r, default=str))
78
+ if r["errors"]:
79
+ raise typer.Exit(1)
80
+
81
+
82
+ @app.command()
83
+ def screenshot(
84
+ symbol: Optional[str] = typer.Option(None, "--symbol", "-s"),
85
+ layout: Optional[str] = typer.Option(None, "--layout", help="Layout URL"),
86
+ out: Optional[Path] = typer.Option(None, "--out", "-o"),
87
+ ) -> None:
88
+ """Open a chart and save a screenshot."""
89
+ from ..browser.automation import screenshot_chart
90
+
91
+ path = screenshot_chart(symbol=symbol, layout_url=layout, out_path=out)
92
+ console.print(f"[green]Saved:[/green] {path}")
@@ -0,0 +1,178 @@
1
+ """`training-view data ...` subcommands."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ app = typer.Typer(no_args_is_help=True, help="Pull OHLCV, search symbols, run screener.")
12
+ console = Console()
13
+
14
+
15
+ @app.command()
16
+ def ohlcv(
17
+ symbol: str = typer.Argument(..., help="Symbol, e.g. NASDAQ:AAPL or AAPL"),
18
+ interval: str = typer.Option("1D", "--interval", "-i", help="1, 5, 15, 60, 240, 1D, 1W, 1M"),
19
+ bars: int = typer.Option(500, "--bars", "-n", help="Number of bars to fetch"),
20
+ exchange: Optional[str] = typer.Option(None, "--exchange", "-e"),
21
+ csv: Optional[Path] = typer.Option(None, "--csv", help="Write to CSV instead of printing"),
22
+ tail: int = typer.Option(10, "--tail", help="Rows to print when not writing CSV"),
23
+ ) -> None:
24
+ """Fetch OHLCV candles for SYMBOL."""
25
+ from ..data.client import get_ohlcv
26
+
27
+ try:
28
+ df = get_ohlcv(symbol, interval=interval, n_bars=bars, exchange=exchange)
29
+ except Exception as e:
30
+ console.print(f"[red]Error:[/red] {e}")
31
+ raise typer.Exit(1)
32
+
33
+ if df.empty:
34
+ console.print(f"[yellow]No data returned for {symbol}[/yellow]")
35
+ raise typer.Exit(2)
36
+
37
+ if csv:
38
+ csv.parent.mkdir(parents=True, exist_ok=True)
39
+ df.to_csv(csv)
40
+ console.print(f"[green]Wrote {len(df)} rows → {csv}[/green]")
41
+ return
42
+
43
+ table = Table(title=f"{symbol} · {interval} · last {min(tail, len(df))} of {len(df)} bars")
44
+ table.add_column("time")
45
+ for c in df.columns:
46
+ table.add_column(c, justify="right")
47
+ for ts, row in df.tail(tail).iterrows():
48
+ table.add_row(str(ts), *[f"{v:.4f}" if isinstance(v, float) else str(v) for v in row])
49
+ console.print(table)
50
+
51
+
52
+ @app.command()
53
+ def search(
54
+ query: str = typer.Argument(..., help="Ticker or name fragment"),
55
+ limit: int = typer.Option(20, "--limit", "-n"),
56
+ ) -> None:
57
+ """Search symbols by name/ticker."""
58
+ from ..data.screener import search_symbol
59
+
60
+ try:
61
+ results = search_symbol(query, limit=limit)
62
+ except Exception as e:
63
+ console.print(f"[red]Error:[/red] {e}")
64
+ raise typer.Exit(1)
65
+
66
+ if not results:
67
+ console.print(f"[yellow]No matches for '{query}'[/yellow]")
68
+ return
69
+
70
+ table = Table(title=f"Search: {query}")
71
+ for k in results[0].keys():
72
+ table.add_column(str(k))
73
+ for row in results:
74
+ table.add_row(*[str(row.get(k, "")) for k in results[0].keys()])
75
+ console.print(table)
76
+
77
+
78
+ @app.command()
79
+ def screener(
80
+ market: str = typer.Option("america", "--market", "-m"),
81
+ filter_: list[str] = typer.Option(
82
+ [], "--filter", "-f",
83
+ help="Filter expression COL OP VAL (e.g. 'RSI < 30'). Repeatable.",
84
+ ),
85
+ columns: list[str] = typer.Option(
86
+ [], "--col", "-c",
87
+ help="Columns to include. Repeatable. Default: name, close, RSI, volume, change",
88
+ ),
89
+ order_by: Optional[str] = typer.Option(
90
+ None, "--order-by", help="Column to sort by (prefix '-' for descending)"
91
+ ),
92
+ limit: int = typer.Option(50, "--limit", "-n"),
93
+ ) -> None:
94
+ """Run a TradingView screener.
95
+
96
+ Example:
97
+
98
+ training-view data screener -m america -f "RSI < 30" -f "volume > 1000000" -c name -c close -c RSI
99
+ """
100
+ from ..data.screener import screener as run_screener
101
+
102
+ # Parse filters
103
+ parsed: list[tuple[str, str, object]] = []
104
+ for f in filter_:
105
+ parts = f.split(maxsplit=2)
106
+ if len(parts) != 3:
107
+ console.print(f"[red]Bad filter:[/red] '{f}' — expected 'COL OP VAL'")
108
+ raise typer.Exit(1)
109
+ col, op, val = parts
110
+ try:
111
+ v: object = float(val)
112
+ except ValueError:
113
+ v = val
114
+ parsed.append((col, op, v))
115
+
116
+ sort = None
117
+ if order_by:
118
+ ascending = not order_by.startswith("-")
119
+ sort = (order_by.lstrip("-"), ascending)
120
+
121
+ try:
122
+ df = run_screener(
123
+ market=market,
124
+ columns=columns or None,
125
+ filters=parsed or None,
126
+ order_by=sort,
127
+ limit=limit,
128
+ )
129
+ except Exception as e:
130
+ console.print(f"[red]Error:[/red] {e}")
131
+ raise typer.Exit(1)
132
+
133
+ if df.empty:
134
+ console.print("[yellow]No rows.[/yellow]")
135
+ return
136
+
137
+ table = Table(title=f"Screener · {market} · {len(df)} rows")
138
+ for c in df.columns:
139
+ table.add_column(str(c))
140
+ for _, row in df.iterrows():
141
+ table.add_row(*[f"{v:.4f}" if isinstance(v, float) else str(v) for v in row])
142
+ console.print(table)
143
+
144
+
145
+ @app.command()
146
+ def rating(
147
+ symbol: str = typer.Argument(..., help="Symbol, e.g. AAPL or NASDAQ:AAPL"),
148
+ interval: str = typer.Option("1D", "--interval", "-i"),
149
+ ) -> None:
150
+ """Get TradingView's Strong Buy/Buy/Neutral/Sell/Strong Sell rating."""
151
+ from ..data.screener import technical_rating
152
+
153
+ try:
154
+ r = technical_rating(symbol, interval=interval)
155
+ except Exception as e:
156
+ console.print(f"[red]Error:[/red] {e}")
157
+ raise typer.Exit(1)
158
+
159
+ if r.get("rating") is None:
160
+ console.print(f"[yellow]⚠ {r.get('error', 'no data')}[/yellow]")
161
+ if r.get("market_tried"):
162
+ console.print(f"[dim] market tried: {r['market_tried']}[/dim]")
163
+ return
164
+
165
+ colors = {
166
+ "Strong Buy": "bold green",
167
+ "Buy": "green",
168
+ "Neutral": "yellow",
169
+ "Sell": "red",
170
+ "Strong Sell": "bold red",
171
+ }
172
+ c = colors.get(r["rating"], "white")
173
+ console.print(f"\n [bold]{r['symbol']}[/bold] → [{c}]{r['rating']}[/{c}]")
174
+ console.print(f" score: {r.get('score')}")
175
+ console.print(f" rsi: {r.get('rsi')}")
176
+ console.print(f" close: {r.get('close')}")
177
+ console.print(f" MA score: {r.get('ma_score')}")
178
+ console.print(f" Osc score: {r.get('oscillator_score')}\n")
@@ -0,0 +1,56 @@
1
+ """`training-view` — TradingView integration toolkit CLI.
2
+
3
+ Run `training-view --help` for all commands.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from .data_cmds import app as data_app
11
+ from .browser_cmds import app as browser_app
12
+ from .pine_cmds import app as pine_app
13
+ from .model_cmds import app as model_app
14
+
15
+ app = typer.Typer(
16
+ name="training-view",
17
+ help="TradingView toolkit: data, browser automation, and PineScript scaffolding.",
18
+ no_args_is_help=True,
19
+ add_completion=False,
20
+ )
21
+ console = Console()
22
+
23
+ app.add_typer(data_app, name="data", help="Pull OHLCV, search symbols, run screener.")
24
+ app.add_typer(browser_app, name="browser", help="Push Pine scripts, screenshot charts.")
25
+ app.add_typer(pine_app, name="pine", help="Scaffold Pine indicators / strategies.")
26
+ app.add_typer(model_app, name="model", help="Fetch server-side stock/ETF/index model forecasts.")
27
+
28
+
29
+ @app.command()
30
+ def version() -> None:
31
+ """Print version."""
32
+ from importlib.metadata import version as v
33
+
34
+ try:
35
+ console.print(f"TradingView toolkit [bold]{v('tv-indicator')}[/bold]")
36
+ except Exception:
37
+ console.print("TradingView toolkit (dev)")
38
+
39
+
40
+ @app.command()
41
+ def status() -> None:
42
+ """Show config + auth status."""
43
+ from ..config import settings
44
+
45
+ console.print("[bold]TradingView integration toolkit[/bold]")
46
+ console.print(f" Session cookie set: {'✅' if settings.has_session else '❌'}")
47
+ console.print(f" Username: {settings.tv_username or '—'}")
48
+ console.print(f" Browser profile: {settings.browser_profile}")
49
+ console.print(f" Headless: {settings.browser_headless}")
50
+ console.print(f" Default chart URL: {settings.default_chart_url or '—'}")
51
+ console.print(f" Screenshot dir: {settings.screenshot_dir}")
52
+ console.print(f" Scripts dir: {settings.scripts_dir}")
53
+
54
+
55
+ if __name__ == "__main__":
56
+ app()
@@ -0,0 +1,255 @@
1
+ """`training-view model ...` subcommands for server-side stock forecast integration."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from ..model.forecast import dumps_json, forecast_for_symbol, model_status, setup_model_env
12
+
13
+ app = typer.Typer(no_args_is_help=True, help="Server-side stock/ETF/index forecast API.")
14
+ console = Console()
15
+
16
+
17
+ @app.command("status")
18
+ def status(
19
+ repo: Optional[Path] = typer.Option(
20
+ None,
21
+ "--repo",
22
+ help="Deprecated; ignored because the model now runs server-side.",
23
+ ),
24
+ csv_path: Optional[Path] = typer.Option(
25
+ None,
26
+ "--csv",
27
+ help="Deprecated; ignored because forecasts now come from the API.",
28
+ ),
29
+ conda_env: Optional[str] = typer.Option(
30
+ None,
31
+ "--conda-env",
32
+ help="Deprecated; ignored because no local ML runtime is used.",
33
+ ),
34
+ api_url: Optional[str] = typer.Option(
35
+ None,
36
+ "--api-url",
37
+ help="Forecast endpoint. Defaults to TV_MODEL_API_URL or TV_MODEL_API_BASE_URL + /forecast.",
38
+ ),
39
+ status_url: Optional[str] = typer.Option(
40
+ None,
41
+ "--status-url",
42
+ help="Optional API health endpoint. Defaults to TV_MODEL_API_STATUS_URL.",
43
+ ),
44
+ json_output: bool = typer.Option(False, "--json", help="Print raw JSON payload."),
45
+ ) -> None:
46
+ """Show server-side model API configuration/status."""
47
+ payload = model_status(
48
+ repo=repo,
49
+ csv_path=csv_path,
50
+ conda_env=conda_env or None,
51
+ api_url=api_url,
52
+ status_url=status_url,
53
+ )
54
+ if json_output:
55
+ print(dumps_json(payload))
56
+ return
57
+
58
+ console.print("[bold]Server-side stock/ETF/index model forecast API[/bold]")
59
+ console.print(f" forecast API: {_mark(payload['model_api_configured'])} {payload.get('model_api_url') or 'not configured'}")
60
+ console.print(f" status API: {_status_mark(payload.get('model_api_reachable'))} {payload.get('model_api_status_url') or 'not configured'}")
61
+ if payload.get("model_api_error"):
62
+ console.print(f" API note: [yellow]{payload['model_api_error']}[/yellow]")
63
+ console.print(f" temp dir: {_mark(payload['temp_dir_exists'])} {payload['temp_dir']}")
64
+ console.print(" auth: " + ("Bearer token set" if _api_token_set() else "no token"))
65
+ console.print(" commands:")
66
+ for value in payload["commands"].values():
67
+ console.print(f" {value}")
68
+ if payload.get("next_actions"):
69
+ console.print(" next actions:")
70
+ for action in payload["next_actions"]:
71
+ console.print(f" - {action}")
72
+
73
+
74
+ @app.command("setup")
75
+ def setup(
76
+ repo: Optional[Path] = typer.Option(
77
+ None,
78
+ "--repo",
79
+ help="Deprecated; ignored because the model now runs server-side.",
80
+ ),
81
+ conda_env: Optional[str] = typer.Option(
82
+ None,
83
+ "--conda-env",
84
+ help="Deprecated; ignored because no local ML runtime is used.",
85
+ ),
86
+ no_extra_deps: bool = typer.Option(
87
+ False,
88
+ "--no-extra-deps",
89
+ help="Deprecated; no local model dependencies are installed.",
90
+ ),
91
+ api_url: Optional[str] = typer.Option(
92
+ None,
93
+ "--api-url",
94
+ help="Forecast endpoint. Defaults to TV_MODEL_API_URL or TV_MODEL_API_BASE_URL + /forecast.",
95
+ ),
96
+ status_url: Optional[str] = typer.Option(
97
+ None,
98
+ "--status-url",
99
+ help="Optional API health endpoint. Defaults to TV_MODEL_API_STATUS_URL.",
100
+ ),
101
+ json_output: bool = typer.Option(False, "--json", help="Print raw JSON payload."),
102
+ ) -> None:
103
+ """Validate server-side model API configuration."""
104
+ payload = setup_model_env(
105
+ repo=repo,
106
+ conda_env=conda_env,
107
+ install_extra_deps=not no_extra_deps,
108
+ api_url=api_url,
109
+ status_url=status_url,
110
+ )
111
+ if json_output:
112
+ print(dumps_json(payload))
113
+ return
114
+
115
+ ok = "✓" if payload["ok"] else "✗"
116
+ console.print(f"[bold]Model API setup[/bold] {ok}")
117
+ if payload["stdout"]:
118
+ console.print(payload["stdout"])
119
+ if payload["stderr"]:
120
+ console.print(f"[yellow]{payload['stderr']}[/yellow]")
121
+ if not payload["ok"]:
122
+ raise typer.Exit(1)
123
+
124
+
125
+ @app.command("forecast")
126
+ def forecast(
127
+ symbol: str = typer.Argument(..., help="Ticker or TradingView symbol, e.g. TSLA or NASDAQ:TSLA"),
128
+ repo: Optional[Path] = typer.Option(
129
+ None,
130
+ "--repo",
131
+ help="Deprecated; ignored because forecasts now come from the API.",
132
+ ),
133
+ csv_path: Optional[Path] = typer.Option(
134
+ None,
135
+ "--csv",
136
+ help="Deprecated; ignored because forecasts now come from the API.",
137
+ ),
138
+ refresh: bool = typer.Option(
139
+ False,
140
+ "--refresh",
141
+ help="Ask the server-side model API to refresh/recompute before returning a forecast.",
142
+ ),
143
+ conda_env: Optional[str] = typer.Option(
144
+ None,
145
+ "--conda-env",
146
+ help="Deprecated; ignored because no local ML runtime is used.",
147
+ ),
148
+ period: str = typer.Option("10y", "--period", help="Historical period hint sent to the API."),
149
+ api_url: Optional[str] = typer.Option(
150
+ None,
151
+ "--api-url",
152
+ help="Forecast endpoint. Defaults to TV_MODEL_API_URL or TV_MODEL_API_BASE_URL + /forecast.",
153
+ ),
154
+ json_output: bool = typer.Option(False, "--json", help="Print raw JSON payload."),
155
+ ) -> None:
156
+ """Fetch the xs_range 20-day model forecast for a ticker from the API.
157
+
158
+ Crypto, forex, and futures symbols are reported as not applicable without an
159
+ API request.
160
+ """
161
+ payload = forecast_for_symbol(
162
+ symbol,
163
+ repo=repo,
164
+ csv_path=csv_path,
165
+ refresh=refresh,
166
+ conda_env=conda_env or None,
167
+ period=period,
168
+ api_url=api_url,
169
+ )
170
+
171
+ if json_output:
172
+ # Plain stdout so agents can parse JSON without Rich line wrapping.
173
+ print(dumps_json(payload))
174
+ else:
175
+ _print_human(payload)
176
+
177
+ # Forecast unavailability is a handled analysis state. Keep exit code 0 so
178
+ # agents can continue with TradingView/news/fundamentals and inspect
179
+ # payload["ok"] / payload["error"] instead of aborting the full report.
180
+
181
+
182
+ def _mark(ok: bool) -> str:
183
+ return "✓" if ok else "✗"
184
+
185
+
186
+ def _status_mark(value: object) -> str:
187
+ if value is True:
188
+ return "✓"
189
+ if value is False:
190
+ return "✗"
191
+ return "-"
192
+
193
+
194
+ def _api_token_set() -> bool:
195
+ import os
196
+
197
+ return bool(os.getenv("TV_MODEL_API_KEY") or os.getenv("TV_MODEL_API_TOKEN"))
198
+
199
+
200
+ def _print_human(payload: dict) -> None:
201
+ row = payload.get("row")
202
+ console.print(f"[bold]Model ticker:[/bold] {payload['model_ticker']} covered={payload['covered']}")
203
+ if not payload.get("model_applicable", True):
204
+ console.print(f"[bold]Asset class:[/bold] {payload.get('asset_class')}")
205
+ console.print(f"[yellow]Model forecast not applicable:[/yellow] {payload.get('unsupported_reason')}")
206
+ console.print("[yellow]No API request was made.[/yellow]")
207
+ return
208
+ console.print(f"[bold]Forecast API:[/bold] {payload.get('model_api_url') or 'not configured'}")
209
+ if payload.get("refresh_requested"):
210
+ console.print("[bold]Refresh requested:[/bold] yes")
211
+
212
+ if not row:
213
+ console.print(f"[yellow]Model forecast unavailable:[/yellow] {payload.get('error')}")
214
+ return
215
+
216
+ table = Table(title="20-day model forecast")
217
+ table.add_column("Field")
218
+ table.add_column("Value", justify="right")
219
+ fields = [
220
+ "ticker",
221
+ "class",
222
+ "position",
223
+ "rank",
224
+ "close",
225
+ "low_20d",
226
+ "high_20d",
227
+ "fair_value",
228
+ "value_gap",
229
+ "signal",
230
+ "mean_iou",
231
+ "iou60",
232
+ "dir_win",
233
+ "pred_low_pct",
234
+ "pred_high_pct",
235
+ "pred_mid_pct",
236
+ "range_width_pct",
237
+ "direction",
238
+ "directional_win_rate",
239
+ "range_overlap_rate",
240
+ "close_in_range_rate",
241
+ "range_coverage_rate",
242
+ ]
243
+ for field in fields:
244
+ if field in row and row[field] is not None:
245
+ table.add_row(field, _fmt(row[field]))
246
+ console.print(table)
247
+ if payload.get("model_output"):
248
+ console.print("[bold]model_output:[/bold]")
249
+ console.print(dumps_json(payload["model_output"]))
250
+
251
+
252
+ def _fmt(value: object) -> str:
253
+ if isinstance(value, float):
254
+ return f"{value:.4f}"
255
+ return str(value)