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