@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,693 @@
1
+ """Server-side xs_range forecast API integration.
2
+
3
+ Indicator no longer trains or runs the stock forecast model locally. This module
4
+ keeps the public forecast payload stable while delegating inference/refresh to a
5
+ configured HTTP API.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from urllib.parse import urljoin
14
+
15
+ import requests
16
+
17
+ from ..config import PROJECT_ROOT
18
+
19
+ DEFAULT_MODEL_ROOT = PROJECT_ROOT / "model_data"
20
+ # Backwards-compatible alias for older imports; local model execution is removed.
21
+ DEFAULT_MODEL_REPO = DEFAULT_MODEL_ROOT
22
+ MODEL_UNIVERSE: tuple[str, ...] = ()
23
+
24
+ _CRYPTO_EXCHANGES = {
25
+ "BINANCE",
26
+ "COINBASE",
27
+ "KRAKEN",
28
+ "BITSTAMP",
29
+ "BITFINEX",
30
+ "BYBIT",
31
+ "OKX",
32
+ "KUCOIN",
33
+ "GEMINI",
34
+ "MEXC",
35
+ "HUOBI",
36
+ }
37
+ _FOREX_EXCHANGES = {
38
+ "FX",
39
+ "FX_IDC",
40
+ "OANDA",
41
+ "FOREXCOM",
42
+ "SAXO",
43
+ "PEPPERSTONE",
44
+ "CAPITALCOM",
45
+ "ICMARKETS",
46
+ "EIGHTCAP",
47
+ }
48
+ _FUTURES_EXCHANGES = {
49
+ "CME",
50
+ "CME_MINI",
51
+ "CBOT",
52
+ "NYMEX",
53
+ "COMEX",
54
+ "ICEUS",
55
+ "ICEEUR",
56
+ "EUREX",
57
+ }
58
+ _COMMON_CRYPTO_BASES = {
59
+ "BTC",
60
+ "ETH",
61
+ "SOL",
62
+ "BNB",
63
+ "XRP",
64
+ "ADA",
65
+ "DOGE",
66
+ "AVAX",
67
+ "DOT",
68
+ "TRX",
69
+ "TON",
70
+ "LINK",
71
+ "LTC",
72
+ "BCH",
73
+ "UNI",
74
+ "MATIC",
75
+ "POL",
76
+ "SHIB",
77
+ "PEPE",
78
+ "APT",
79
+ "ARB",
80
+ "OP",
81
+ "ATOM",
82
+ "ETC",
83
+ "XLM",
84
+ "HBAR",
85
+ "ICP",
86
+ "NEAR",
87
+ "FIL",
88
+ "SUI",
89
+ }
90
+ _CRYPTO_QUOTES = ("USDT", "USDC", "BUSD", "FDUSD", "USD", "EUR", "BTC", "ETH")
91
+
92
+ NUMERIC_FIELDS = {
93
+ "close",
94
+ "close_today",
95
+ "low_20d",
96
+ "pred_low_20d",
97
+ "high_20d",
98
+ "pred_high_20d",
99
+ "fair_value",
100
+ "pred_fair_value_20d",
101
+ "value_gap",
102
+ "value_gap_pct",
103
+ "pred_low_pct",
104
+ "pred_high_pct",
105
+ "pred_mid_pct",
106
+ "range_width_pct",
107
+ "downside_to_pred_low_pct",
108
+ "upside_to_pred_high_pct",
109
+ "risk_reward_ratio",
110
+ "position_score",
111
+ "mean_iou",
112
+ "iou60",
113
+ "dir_win",
114
+ "directional_win_rate",
115
+ "iou_ge_60_rate",
116
+ "iou_ge_70_rate",
117
+ "iou_ge_80_rate",
118
+ "iou_ge_90_rate",
119
+ "range_overlap_rate",
120
+ "close_in_range_rate",
121
+ "range_coverage_rate",
122
+ }
123
+ INT_FIELDS = {"rank", "position_rank", "eval_n_rows", "run_universe_size"}
124
+
125
+
126
+ class ModelApiError(RuntimeError):
127
+ """Raised when the server-side model API cannot return a usable response."""
128
+
129
+
130
+ def normalize_model_ticker(symbol: str) -> str:
131
+ """Map TradingView/Yahoo-ish symbols to the model ticker id."""
132
+ s = symbol.strip().upper()
133
+ if ":" in s:
134
+ s = s.split(":", 1)[1]
135
+ if s in {"^GSPC", "GSPC", "SPX500USD"}:
136
+ return "SPX"
137
+ if s.endswith(".TW"):
138
+ return s[:-3]
139
+ if s.endswith(".TWO"):
140
+ return s[:-4]
141
+ return s
142
+
143
+
144
+ def _split_exchange(symbol: str) -> tuple[str | None, str]:
145
+ s = symbol.strip().upper()
146
+ if ":" in s:
147
+ exchange, ticker = s.split(":", 1)
148
+ return exchange, ticker
149
+ return None, s
150
+
151
+
152
+ def _looks_like_crypto_pair(ticker: str) -> bool:
153
+ t = ticker.strip().upper()
154
+ if t.endswith(".P"):
155
+ t = t[:-2]
156
+ if t.endswith("PERP"):
157
+ t = t[:-4]
158
+ t = t.replace("-", "").replace("/", "")
159
+ for quote in _CRYPTO_QUOTES:
160
+ if not t.endswith(quote) or len(t) <= len(quote):
161
+ continue
162
+ base = t[: -len(quote)]
163
+ if base in _COMMON_CRYPTO_BASES:
164
+ return True
165
+ return False
166
+
167
+
168
+ def model_applicability(symbol: str) -> dict[str, Any]:
169
+ """Return whether the stock model should handle this symbol by default."""
170
+ exchange, ticker = _split_exchange(symbol)
171
+ if exchange in _CRYPTO_EXCHANGES or _looks_like_crypto_pair(ticker):
172
+ return {
173
+ "model_applicable": False,
174
+ "asset_class": "crypto",
175
+ "reason": "xs_range model is stock/ETF/index-only; crypto pairs are not supported",
176
+ }
177
+ if exchange in _FOREX_EXCHANGES:
178
+ return {
179
+ "model_applicable": False,
180
+ "asset_class": "forex",
181
+ "reason": "xs_range model is stock/ETF/index-only; forex pairs are not supported",
182
+ }
183
+ if exchange in _FUTURES_EXCHANGES:
184
+ return {
185
+ "model_applicable": False,
186
+ "asset_class": "futures",
187
+ "reason": "xs_range model is stock/ETF/index-only; futures/contracts are not supported",
188
+ }
189
+ return {
190
+ "model_applicable": True,
191
+ "asset_class": "stock_like",
192
+ "reason": None,
193
+ }
194
+
195
+
196
+ def _clean_url(value: str | None) -> str | None:
197
+ if value is None:
198
+ return None
199
+ stripped = value.strip()
200
+ return stripped or None
201
+
202
+
203
+ def _join_url(base: str, path: str) -> str:
204
+ return urljoin(base.rstrip("/") + "/", path.lstrip("/"))
205
+
206
+
207
+ def model_api_forecast_url(api_url: str | None = None) -> str | None:
208
+ """Return the configured forecast endpoint URL."""
209
+ explicit = _clean_url(
210
+ api_url or os.getenv("TV_MODEL_API_URL") or os.getenv("TV_MODEL_API_FORECAST_URL")
211
+ )
212
+ if explicit:
213
+ return explicit
214
+
215
+ base = _clean_url(os.getenv("TV_MODEL_API_BASE_URL"))
216
+ if not base:
217
+ return None
218
+ path = os.getenv("TV_MODEL_API_FORECAST_PATH", "/forecast")
219
+ return _join_url(base, path)
220
+
221
+
222
+ def model_api_status_url(status_url: str | None = None) -> str | None:
223
+ """Return the configured status endpoint URL, when one is available."""
224
+ explicit = _clean_url(status_url or os.getenv("TV_MODEL_API_STATUS_URL"))
225
+ if explicit:
226
+ return explicit
227
+
228
+ base = _clean_url(os.getenv("TV_MODEL_API_BASE_URL"))
229
+ if not base:
230
+ return None
231
+ path = os.getenv("TV_MODEL_API_STATUS_PATH", "/status")
232
+ return _join_url(base, path)
233
+
234
+
235
+ def _api_timeout() -> float:
236
+ raw = os.getenv("TV_MODEL_API_TIMEOUT", "60")
237
+ try:
238
+ return float(raw)
239
+ except ValueError:
240
+ return 60.0
241
+
242
+
243
+ def _api_headers() -> dict[str, str]:
244
+ headers = {
245
+ "Accept": "application/json",
246
+ "Content-Type": "application/json",
247
+ }
248
+ token = os.getenv("TV_MODEL_API_KEY") or os.getenv("TV_MODEL_API_TOKEN")
249
+ if token:
250
+ headers["Authorization"] = f"Bearer {token}"
251
+ return headers
252
+
253
+
254
+ def _decode_json_response(response: requests.Response) -> dict[str, Any]:
255
+ if response.status_code >= 400:
256
+ body = response.text.strip().replace("\n", " ")[:300]
257
+ detail = f": {body}" if body else ""
258
+ raise ModelApiError(f"model API returned HTTP {response.status_code}{detail}")
259
+
260
+ try:
261
+ payload = response.json()
262
+ except ValueError as exc:
263
+ raise ModelApiError("model API did not return JSON") from exc
264
+ if not isinstance(payload, dict):
265
+ raise ModelApiError("model API JSON response must be an object")
266
+ return payload
267
+
268
+
269
+ def _request_json(method: str, url: str, **kwargs: Any) -> dict[str, Any]:
270
+ try:
271
+ response = requests.request(
272
+ method,
273
+ url,
274
+ headers=_api_headers(),
275
+ timeout=_api_timeout(),
276
+ **kwargs,
277
+ )
278
+ except requests.RequestException as exc:
279
+ raise ModelApiError(f"model API request failed: {exc}") from exc
280
+ return _decode_json_response(response)
281
+
282
+
283
+ def fetch_api_forecast(
284
+ symbol: str,
285
+ *,
286
+ api_url: str | None = None,
287
+ refresh: bool = False,
288
+ period: str = "10y",
289
+ ) -> dict[str, Any]:
290
+ """Fetch a forecast payload from the server-side model API."""
291
+ endpoint = model_api_forecast_url(api_url)
292
+ if not endpoint:
293
+ raise ModelApiError("model API is not configured; set TV_MODEL_API_URL")
294
+
295
+ body = {
296
+ "symbol": symbol,
297
+ "model_ticker": normalize_model_ticker(symbol),
298
+ "refresh": bool(refresh),
299
+ "period": period,
300
+ }
301
+ return _request_json("POST", endpoint, json=body)
302
+
303
+
304
+ def _coerce_value(key: str, value: Any) -> Any:
305
+ if value is None:
306
+ return None
307
+ if isinstance(value, str):
308
+ value = value.strip()
309
+ if value == "":
310
+ return None
311
+ if key in NUMERIC_FIELDS:
312
+ try:
313
+ return float(value)
314
+ except (TypeError, ValueError):
315
+ return value
316
+ if key in INT_FIELDS:
317
+ try:
318
+ return int(float(value))
319
+ except (TypeError, ValueError):
320
+ return value
321
+ return value
322
+
323
+
324
+ def _coerce_row(row: dict[str, Any]) -> dict[str, Any]:
325
+ out = {key: _coerce_value(key, value) for key, value in row.items()}
326
+ _add_forecast_aliases(out)
327
+ return out
328
+
329
+
330
+ def _copy_alias(row: dict[str, Any], source: str, target: str) -> None:
331
+ if target not in row and source in row:
332
+ row[target] = row[source]
333
+
334
+
335
+ def _add_forecast_aliases(row: dict[str, Any]) -> None:
336
+ """Normalize compact fields and legacy forecast fields in-place."""
337
+ _copy_alias(row, "close", "close_today")
338
+ _copy_alias(row, "close_today", "close")
339
+ _copy_alias(row, "low_20d", "pred_low_20d")
340
+ _copy_alias(row, "pred_low_20d", "low_20d")
341
+ _copy_alias(row, "high_20d", "pred_high_20d")
342
+ _copy_alias(row, "pred_high_20d", "high_20d")
343
+ _copy_alias(row, "fair_value", "pred_fair_value_20d")
344
+ _copy_alias(row, "pred_fair_value_20d", "fair_value")
345
+ _copy_alias(row, "value_gap", "value_gap_pct")
346
+ _copy_alias(row, "value_gap_pct", "value_gap")
347
+ _copy_alias(row, "signal", "valuation_signal")
348
+ _copy_alias(row, "valuation_signal", "signal")
349
+ _copy_alias(row, "class", "model_class")
350
+ _copy_alias(row, "asset_class", "class")
351
+ _copy_alias(row, "iou60", "iou_ge_60_rate")
352
+ _copy_alias(row, "iou_ge_60_rate", "iou60")
353
+ _copy_alias(row, "dir_win", "directional_win_rate")
354
+ _copy_alias(row, "directional_win_rate", "dir_win")
355
+
356
+ close = row.get("close_today")
357
+ low = row.get("pred_low_20d")
358
+ high = row.get("pred_high_20d")
359
+ if isinstance(close, (int, float)) and close:
360
+ if isinstance(low, (int, float)) and "pred_low_pct" not in row:
361
+ row["pred_low_pct"] = (low / close - 1.0) * 100.0
362
+ if isinstance(high, (int, float)) and "pred_high_pct" not in row:
363
+ row["pred_high_pct"] = (high / close - 1.0) * 100.0
364
+ if isinstance(low, (int, float)) and isinstance(high, (int, float)):
365
+ if "pred_mid_pct" not in row:
366
+ row["pred_mid_pct"] = (((low / close) + (high / close)) / 2.0 - 1.0) * 100.0
367
+ if "range_width_pct" not in row:
368
+ row["range_width_pct"] = (high / close - low / close) * 100.0
369
+ if "direction" not in row and isinstance(row.get("pred_mid_pct"), (int, float)):
370
+ row["direction"] = "UP" if row["pred_mid_pct"] >= 0 else "DOWN"
371
+
372
+
373
+ def _looks_like_forecast_row(row: dict[str, Any]) -> bool:
374
+ fields = {
375
+ "low_20d",
376
+ "high_20d",
377
+ "pred_low_20d",
378
+ "pred_high_20d",
379
+ "fair_value",
380
+ "pred_fair_value_20d",
381
+ }
382
+ return any(field in row for field in fields)
383
+
384
+
385
+ def _collect_forecast_rows(value: Any, rows: list[dict[str, Any]]) -> None:
386
+ if isinstance(value, dict):
387
+ if _looks_like_forecast_row(value):
388
+ rows.append(value)
389
+ for key in (
390
+ "row",
391
+ "rows",
392
+ "model_output",
393
+ "forecast",
394
+ "forecasts",
395
+ "data",
396
+ "result",
397
+ "results",
398
+ ):
399
+ child = value.get(key)
400
+ if child is not None:
401
+ _collect_forecast_rows(child, rows)
402
+ elif isinstance(value, list):
403
+ for item in value:
404
+ _collect_forecast_rows(item, rows)
405
+
406
+
407
+ def extract_forecast_row(payload: dict[str, Any], ticker: str) -> dict[str, Any] | None:
408
+ rows: list[dict[str, Any]] = []
409
+ _collect_forecast_rows(payload, rows)
410
+ if not rows:
411
+ return None
412
+
413
+ normalized = normalize_model_ticker(ticker)
414
+ for row in rows:
415
+ row_ticker = row.get("ticker") or row.get("symbol") or row.get("model_ticker")
416
+ if row_ticker and normalize_model_ticker(str(row_ticker)) == normalized:
417
+ return _coerce_row(row)
418
+ return _coerce_row(rows[0])
419
+
420
+
421
+ def compact_model_output(row: dict[str, Any] | None) -> dict[str, Any] | None:
422
+ if not row:
423
+ return None
424
+ return {
425
+ "ticker": row.get("ticker"),
426
+ "class": row.get("class") or row.get("model_class") or row.get("asset_class"),
427
+ "position": row.get("position"),
428
+ "rank": row.get("rank"),
429
+ "close": row.get("close"),
430
+ "low_20d": row.get("low_20d"),
431
+ "high_20d": row.get("high_20d"),
432
+ "fair_value": row.get("fair_value"),
433
+ "value_gap": row.get("value_gap"),
434
+ "signal": row.get("signal"),
435
+ "mean_iou": row.get("mean_iou"),
436
+ "iou60": row.get("iou60"),
437
+ "dir_win": row.get("dir_win"),
438
+ }
439
+
440
+
441
+ def company_value_forecast(row: dict[str, Any] | None) -> dict[str, Any] | None:
442
+ if not row:
443
+ return None
444
+ keys = [
445
+ "date",
446
+ "ticker",
447
+ "class",
448
+ "position",
449
+ "position_rank",
450
+ "rank",
451
+ "close",
452
+ "fair_value",
453
+ "value_gap",
454
+ "signal",
455
+ "low_20d",
456
+ "high_20d",
457
+ "downside_to_pred_low_pct",
458
+ "upside_to_pred_high_pct",
459
+ "range_width_pct",
460
+ "mean_iou",
461
+ "dir_win",
462
+ "iou60",
463
+ "range_overlap_rate",
464
+ "close_in_range_rate",
465
+ "range_coverage_rate",
466
+ ]
467
+ return {key: row.get(key) for key in keys if key in row}
468
+
469
+
470
+ def model_status(
471
+ *,
472
+ repo: str | Path | None = None,
473
+ csv_path: str | Path | None = None,
474
+ conda_env: str | None = None,
475
+ api_url: str | None = None,
476
+ status_url: str | None = None,
477
+ ) -> dict[str, Any]:
478
+ """Show server-side model API configuration and optional health status."""
479
+ _ = repo
480
+ _ = csv_path
481
+ _ = conda_env
482
+ forecast_url = model_api_forecast_url(api_url)
483
+ health_url = model_api_status_url(status_url)
484
+ api_status: dict[str, Any] | None = None
485
+ api_error: str | None = None
486
+ api_reachable: bool | None = None
487
+
488
+ if health_url:
489
+ try:
490
+ api_status = _request_json("GET", health_url)
491
+ api_reachable = True
492
+ except ModelApiError as exc:
493
+ api_error = str(exc)
494
+ api_reachable = False
495
+
496
+ temp_dir = Path.cwd() / "temp"
497
+ commands = {
498
+ "status": "make model-status",
499
+ "configure_api": "set TV_MODEL_API_URL in .env or environment",
500
+ "forecast": "make model-forecast SYMBOL=NASDAQ:TSLA",
501
+ "refresh_forecast": "make model-forecast SYMBOL=NASDAQ:TSLA REFRESH=1",
502
+ }
503
+ next_actions: list[str] = []
504
+ if not forecast_url:
505
+ next_actions.append("Set TV_MODEL_API_URL to the server-side model forecast endpoint.")
506
+ if health_url and api_reachable is False:
507
+ next_actions.append("Check TV_MODEL_API_STATUS_URL / server health and network access.")
508
+ if not temp_dir.exists():
509
+ next_actions.append("Run `mkdir -p temp` or rerun `make install` to create the analysis artifact directory.")
510
+
511
+ return {
512
+ "model_api_url": forecast_url,
513
+ "model_api_configured": bool(forecast_url),
514
+ "model_api_status_url": health_url,
515
+ "model_api_reachable": api_reachable,
516
+ "model_api_status": api_status,
517
+ "model_api_error": api_error,
518
+ "model_backend": "server_api",
519
+ "model_root": None,
520
+ "model_root_exists": False,
521
+ "model_repo": None,
522
+ "model_repo_exists": False,
523
+ "model_docs": str(PROJECT_ROOT / "skills/stock-model-forecast/SKILL.md"),
524
+ "model_docs_exists": (PROJECT_ROOT / "skills/stock-model-forecast/SKILL.md").exists(),
525
+ "forecast_csv": None,
526
+ "forecast_csv_exists": False,
527
+ "covered_tickers": list(MODEL_UNIVERSE),
528
+ "refresh_conda_env": None,
529
+ "refresh_dependencies": {},
530
+ "temp_dir": str(temp_dir),
531
+ "temp_dir_exists": temp_dir.exists(),
532
+ "commands": commands,
533
+ "next_actions": next_actions,
534
+ }
535
+
536
+
537
+ def setup_model_env(
538
+ *,
539
+ repo: str | Path | None = None,
540
+ conda_env: str | None = None,
541
+ install_extra_deps: bool = True,
542
+ timeout: int = 3600,
543
+ api_url: str | None = None,
544
+ status_url: str | None = None,
545
+ ) -> dict[str, Any]:
546
+ """Validate server-side model API configuration.
547
+
548
+ The name is kept for the existing `training-view model setup` command; it no
549
+ longer installs local ML dependencies.
550
+ """
551
+ _ = repo
552
+ _ = conda_env
553
+ _ = install_extra_deps
554
+ _ = timeout
555
+ status = model_status(api_url=api_url, status_url=status_url)
556
+ ok = bool(status["model_api_configured"]) and status["model_api_reachable"] is not False
557
+ if ok:
558
+ stdout = "Server-side model API is configured."
559
+ else:
560
+ stdout = "Set TV_MODEL_API_URL to the server-side model forecast endpoint."
561
+ return {
562
+ "ok": ok,
563
+ "command": ["configure", "TV_MODEL_API_URL"],
564
+ "returncode": 0 if ok else 1,
565
+ "stdout": stdout,
566
+ "stderr": status.get("model_api_error") or "",
567
+ "status": status,
568
+ }
569
+
570
+
571
+ def forecast_for_symbol(
572
+ symbol: str,
573
+ *,
574
+ repo: str | Path | None = None,
575
+ csv_path: str | Path | None = None,
576
+ refresh: bool = False,
577
+ conda_env: str | None = None,
578
+ period: str = "10y",
579
+ api_url: str | None = None,
580
+ ) -> dict[str, Any]:
581
+ _ = repo
582
+ _ = csv_path
583
+ _ = conda_env
584
+ ticker = normalize_model_ticker(symbol)
585
+ applicability = model_applicability(symbol)
586
+ forecast_url = model_api_forecast_url(api_url)
587
+
588
+ if not applicability["model_applicable"]:
589
+ return {
590
+ "ok": False,
591
+ "symbol": symbol,
592
+ "model_ticker": ticker,
593
+ "covered": False,
594
+ "seed_covered": False,
595
+ "model_applicable": False,
596
+ "asset_class": applicability["asset_class"],
597
+ "unsupported_reason": applicability["reason"],
598
+ "model_api_url": forecast_url,
599
+ "model_root": None,
600
+ "model_repo": None,
601
+ "forecast_csv": None,
602
+ "forecast_csv_exists": False,
603
+ "model_output": None,
604
+ "company_value_forecast": None,
605
+ "row": None,
606
+ "run_universe_size": None,
607
+ "refresh_requested": refresh,
608
+ "refresh_results": [],
609
+ "error": f"model forecast not applicable: {applicability['reason']}",
610
+ }
611
+
612
+ if not forecast_url:
613
+ return {
614
+ "ok": False,
615
+ "symbol": symbol,
616
+ "model_ticker": ticker,
617
+ "covered": False,
618
+ "seed_covered": False,
619
+ "model_applicable": True,
620
+ "asset_class": applicability["asset_class"],
621
+ "unsupported_reason": None,
622
+ "model_api_url": None,
623
+ "model_root": None,
624
+ "model_repo": None,
625
+ "forecast_csv": None,
626
+ "forecast_csv_exists": False,
627
+ "model_output": None,
628
+ "company_value_forecast": None,
629
+ "row": None,
630
+ "run_universe_size": None,
631
+ "refresh_requested": refresh,
632
+ "refresh_results": [],
633
+ "error": "model API is not configured; set TV_MODEL_API_URL",
634
+ }
635
+
636
+ api_payload: dict[str, Any] | None = None
637
+ api_error: str | None = None
638
+ try:
639
+ api_payload = fetch_api_forecast(symbol, api_url=forecast_url, refresh=refresh, period=period)
640
+ except ModelApiError as exc:
641
+ api_error = str(exc)
642
+
643
+ row = extract_forecast_row(api_payload, ticker) if api_payload else None
644
+
645
+ # Some gateways only accept the model ticker in the `symbol` field even
646
+ # though Indicator also sends `model_ticker`. Keep TradingView symbols as
647
+ # the first attempt, then retry with the normalized ticker when needed.
648
+ if row is None and ticker != symbol:
649
+ retry_payload: dict[str, Any] | None = None
650
+ retry_error: str | None = None
651
+ try:
652
+ retry_payload = fetch_api_forecast(ticker, api_url=forecast_url, refresh=refresh, period=period)
653
+ except ModelApiError as exc:
654
+ retry_error = str(exc)
655
+
656
+ retry_row = extract_forecast_row(retry_payload, ticker) if retry_payload else None
657
+ if retry_row is not None:
658
+ api_payload = retry_payload
659
+ api_error = None
660
+ row = retry_row
661
+ elif api_error is None:
662
+ api_error = retry_error
663
+
664
+ error = api_error
665
+ if row is None and error is None:
666
+ error = str(api_payload.get("error") or "model API did not return a forecast row")
667
+
668
+ return {
669
+ "ok": row is not None,
670
+ "symbol": symbol,
671
+ "model_ticker": ticker,
672
+ "covered": bool(row) or bool((api_payload or {}).get("covered")),
673
+ "seed_covered": bool((api_payload or {}).get("seed_covered", False)),
674
+ "model_applicable": True,
675
+ "asset_class": applicability["asset_class"],
676
+ "unsupported_reason": None,
677
+ "model_api_url": forecast_url,
678
+ "model_root": None,
679
+ "model_repo": None,
680
+ "forecast_csv": None,
681
+ "forecast_csv_exists": False,
682
+ "model_output": compact_model_output(row),
683
+ "company_value_forecast": company_value_forecast(row),
684
+ "row": row,
685
+ "run_universe_size": (api_payload or {}).get("run_universe_size"),
686
+ "refresh_requested": refresh,
687
+ "refresh_results": [],
688
+ "error": error,
689
+ }
690
+
691
+
692
+ def dumps_json(payload: dict[str, Any]) -> str:
693
+ return json.dumps(payload, indent=2, sort_keys=False, default=str)