File size: 11,336 Bytes
56692f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b82f8e8
56692f6
b82f8e8
56692f6
 
 
 
4c93cbb
 
 
 
 
 
 
56692f6
4c93cbb
 
 
 
 
56692f6
 
 
 
4c93cbb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56692f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b82f8e8
56692f6
 
 
 
 
 
 
 
 
 
 
 
b82f8e8
 
56692f6
 
 
 
 
 
 
b82f8e8
 
56692f6
 
 
 
 
 
 
b82f8e8
 
 
 
 
 
 
 
 
 
 
 
 
56692f6
08d6b3c
 
 
 
 
b82f8e8
08d6b3c
 
 
 
b82f8e8
 
 
 
 
 
56692f6
 
 
b82f8e8
 
 
 
56692f6
08d6b3c
56692f6
08d6b3c
56692f6
b82f8e8
56692f6
b82f8e8
 
56692f6
b82f8e8
 
 
 
 
 
56692f6
08d6b3c
 
56692f6
b82f8e8
 
56692f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b82f8e8
 
56692f6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
"""
Kronos Monte-Carlo 預測 —— Gradio + ZeroGPU 版。

原本是 Docker + Flask 跑在 HF 免費 CPU 上,一次 6-15 分鐘。
ZeroGPU 只支援 Gradio SDK,所以外殼改寫成 Gradio;預測的數學一行都沒動。

三個 ZeroGPU 的硬性要求,對應到下面的寫法:
  1. Gradio SDK          → README.md 的 sdk: gradio,本檔用 gr.Interface
  2. 模型 module level 載入 → 下面的 tokenizer / model / predictor 都在模組層
  3. 推論包 @spaces.GPU   → _monte_carlo()

上游的 Kronos 套件(github.com/shiyu-coder/Kronos)沒有 setup.py 也沒有
pyproject.toml,所以 pip 裝不了。原本的 Dockerfile 是 build 時 git clone 進去,
Gradio Space 沒有 Dockerfile,改成啟動時把 model/ 那三個檔抓下來。
"""
import json
import os
import sys
import time
import urllib.request
from datetime import timedelta

# ─── 先把上游的 Kronos 套件放進 sys.path ─────────────────────────────────────
# 上游 repo 沒有 setup.py / pyproject.toml,pip 裝不了,所以直接抓 model/ 那三個檔
# (__init__.py / kronos.py / module.py,合計約 54KB)。
#
# ⚠️ shiyu-coder/Kronos 的預設分支是 **master**,不是 main。填錯會在開機時 404。
# KRONOS_REF 沒設的話會依序試 master、main;正式用建議直接釘成 commit SHA,
# 免得上游一改動你的 Space 就跟著壞。
KRONOS_FILES = ("__init__.py", "kronos.py", "module.py")
KRONOS_DIR = os.getenv("KRONOS_DIR", "/tmp/kronos_src")
_ref_env = os.getenv("KRONOS_REF", "").strip()
KRONOS_REFS = [_ref_env] if _ref_env else ["master", "main"]

def _raw_url(ref: str, fname: str) -> str:
    return f"https://raw.githubusercontent.com/shiyu-coder/Kronos/{ref}/model/{fname}"

def _bootstrap_kronos() -> None:
    pkg = os.path.join(KRONOS_DIR, "model")
    os.makedirs(pkg, exist_ok=True)

    missing = [f for f in KRONOS_FILES
               if not (os.path.exists(os.path.join(pkg, f))
                       and os.path.getsize(os.path.join(pkg, f)) > 100)]
    if not missing:
        if KRONOS_DIR not in sys.path:
            sys.path.insert(0, KRONOS_DIR)
        return

    errors = []
    for ref in KRONOS_REFS:
        try:
            for fname in missing:
                url = _raw_url(ref, fname)
                print(f"[bootstrap] fetching {fname} @ {ref}")
                with urllib.request.urlopen(url, timeout=30) as resp:
                    body = resp.read()
                # raw.githubusercontent 對不存在的檔案是回 404,但保險起見也擋一下
                # 「拿到 HTML 錯誤頁卻當成 Python」這種更難查的情況。
                if len(body) < 100 or body.lstrip()[:1] == b"<":
                    raise RuntimeError(f"{url} 回傳的內容不像 Python({len(body)} bytes)")
                with open(os.path.join(pkg, fname), "wb") as fh:
                    fh.write(body)
            print(f"[bootstrap] ok @ {ref}")
            if KRONOS_DIR not in sys.path:
                sys.path.insert(0, KRONOS_DIR)
            return
        except Exception as exc:  # noqa: BLE001 —— 換下一個 ref 再試
            print(f"[bootstrap] ref={ref} 失敗:{exc}")
            errors.append(f"{ref}: {exc}")

    raise RuntimeError(
        "抓不到 Kronos 原始碼。試過的 ref:" + "、".join(KRONOS_REFS) + "\n"
        + "\n".join(errors)
        + "\n請確認 https://github.com/shiyu-coder/Kronos 的分支名稱,"
          "或用環境變數 KRONOS_REF 指定分支/commit SHA。"
    )

_bootstrap_kronos()

# spaces 要在 torch 之前 import(ZeroGPU 會去接管 CUDA 的初始化)
import spaces  # noqa: E402
import numpy as np  # noqa: E402
import pandas as pd  # noqa: E402
import torch  # noqa: E402
import gradio as gr  # noqa: E402

from model import Kronos, KronosTokenizer, KronosPredictor  # noqa: E402

# ─── 設定 ────────────────────────────────────────────────────────────────────
# 模型尺寸做成環境變數,不寫死。三種選擇(參數量 / context / 搭配的 tokenizer):
#   NeoQuasar/Kronos-mini    4.1M   2048   NeoQuasar/Kronos-Tokenizer-2k
#   NeoQuasar/Kronos-small  24.7M    512   NeoQuasar/Kronos-Tokenizer-base
#   NeoQuasar/Kronos-base  102.3M    512   NeoQuasar/Kronos-Tokenizer-base   ← 預設
# 換 mini 的話 tokenizer 一定要跟著換,只改 MODEL_REPO 會對不起來。
MODEL_REPO = os.getenv("KRONOS_MODEL", "NeoQuasar/Kronos-base")
TOKENIZER_REPO = os.getenv("KRONOS_TOKENIZER", "NeoQuasar/Kronos-Tokenizer-base")
MAX_CONTEXT = int(os.getenv("KRONOS_MAX_CONTEXT", "512"))

# ZeroGPU 上,module level 的時候 GPU 還沒配給你,torch.cuda.is_available() 會回
# False —— 用它判斷會誤選 cpu,GPU 就白開了。所以看 HF 注入的旗標,不看 torch。
ZERO_GPU = os.getenv("SPACES_ZERO_GPU", "").lower() in ("1", "true")
if ZERO_GPU:
    DEVICE = "cuda"
else:
    DEVICE = os.getenv("KRONOS_DEVICE") or ("cuda" if torch.cuda.is_available() else "cpu")

# @spaces.GPU 的額度是按**宣告值**整包扣的(先扣不退),不是按實際跑多久。
# 免費帳號一天 300 秒:宣告 60 秒 → 一天 5 次;宣告 30 秒 → 10 次。
# 所以這個值要盡量貼近實測時間,不要留一大堆保險。
GPU_DURATION = int(os.getenv("KRONOS_GPU_DURATION", "60"))

print(f"[init] model={MODEL_REPO} tokenizer={TOKENIZER_REPO} "
      f"device={DEVICE} zero_gpu={ZERO_GPU} max_context={MAX_CONTEXT}")

tokenizer = KronosTokenizer.from_pretrained(TOKENIZER_REPO)
model = Kronos.from_pretrained(MODEL_REPO)
predictor = KronosPredictor(
    model=model, tokenizer=tokenizer, device=DEVICE, max_context=MAX_CONTEXT
)
print("[init] predictor ready")


@spaces.GPU(duration=GPU_DURATION)
def _monte_carlo(df, ts, y_timestamp, pred_len, sample_count, T, top_p):
    """實際吃 GPU 的那一段。跑 sample_count 條各自獨立的軌跡。

    這裡刻意維持「跑 N 次、每次 sample_count=1」的寫法 —— 扇形圖需要 N 條
    各自獨立的路徑,predict() 的 sample_count 參數是內部合成後只回一條。
    """
    t0 = time.time()
    paths_close = []
    paths_ohlc = []
    for _ in range(sample_count):
        pred_df = predictor.predict(
            df=df[["open", "high", "low", "close", "volume", "amount"]],
            x_timestamp=pd.Series(ts),
            y_timestamp=y_timestamp,
            pred_len=pred_len,
            T=T,
            top_p=top_p,
            sample_count=1,
            verbose=False,
        )
        paths_close.append(pred_df["close"].astype(float).tolist())
        paths_ohlc.append([
            {
                "ts": int(y_timestamp.iloc[i].timestamp() * 1000),
                "open":  float(pred_df["open"].iloc[i]),
                "high":  float(pred_df["high"].iloc[i]),
                "low":   float(pred_df["low"].iloc[i]),
                "close": float(pred_df["close"].iloc[i]),
            }
            for i in range(pred_len)
        ])
    return paths_close, paths_ohlc, time.time() - t0


def predict(k_lines_json, pred_len=24, sample_count=10, interval_ms=3600000,
            T=1.0, top_p=0.9):
    """Gradio 的對外函式。回傳的 JSON 欄位跟舊版 Flask 完全一致。"""
    try:
        k_lines = json.loads(k_lines_json) if isinstance(k_lines_json, str) else k_lines_json
    except (TypeError, ValueError) as exc:
        return {"error": f"k_lines 不是合法的 JSON:{exc}"}

    if not k_lines or len(k_lines) < 50:
        return {"error": "need at least 50 k_lines"}

    pred_len = int(pred_len)
    sample_count = int(sample_count)
    interval_ms = int(interval_ms)
    T = float(T)
    top_p = float(top_p)

    # k_lines: [[ts_ms, open, high, low, close, volume], ...]
    arr = np.array(k_lines, dtype=float)
    ts = pd.to_datetime(arr[:, 0], unit="ms")
    df = pd.DataFrame({
        "open":   arr[:, 1],
        "high":   arr[:, 2],
        "low":    arr[:, 3],
        "close":  arr[:, 4],
        "volume": arr[:, 5] if arr.shape[1] > 5 else np.zeros(len(arr)),
        "amount": np.zeros(len(arr)),  # some versions expect this col
    })
    last_close = float(df["close"].iloc[-1])

    # Baseline volatility: realized std over a window of the SAME length as
    # the forecast horizon (pred_len). Comparing a 360-bar std to a 24-bar
    # forecast std is unfair and systematically makes vol_amp_prob ~= 0.
    tail_closes = df["close"].values[-(pred_len + 1):]
    hist_returns = np.diff(np.log(tail_closes))
    hist_std = float(np.std(hist_returns))
    # Guard — if history was perfectly flat, fall back to full-series std
    if hist_std == 0.0:
        full_rets = np.diff(np.log(df["close"].values))
        hist_std = float(np.std(full_rets)) if len(full_rets) else 1e-9

    last_ts = ts[-1]
    y_timestamp = pd.Series(
        [last_ts + timedelta(milliseconds=interval_ms * (i + 1)) for i in range(pred_len)]
    )

    paths_close, paths_ohlc, elapsed = _monte_carlo(
        df, ts, y_timestamp, pred_len, sample_count, T, top_p
    )

    finals = np.array([p[-1] for p in paths_close])
    upside_prob = float(np.mean(finals > last_close))

    path_stds = np.array([float(np.std(np.diff(np.log(np.array(p))))) for p in paths_close])
    # Ratio form: mean predicted vol / baseline vol. >1 means amplified, <1 dampened.
    vol_amp_ratio = float(np.mean(path_stds) / hist_std) if hist_std > 0 else 0.0
    # Probability form: fraction of paths more volatile than baseline (kept for reference).
    vol_amp_prob = float(np.mean(path_stds > hist_std))

    return {
        "ok": True,
        "model": MODEL_REPO,
        "device": DEVICE,
        "sample_count": sample_count,
        "pred_len": pred_len,
        "elapsed_s": round(elapsed, 2),
        "last_close": last_close,
        "hist_std": hist_std,
        "upside_prob": upside_prob,
        "vol_amp": vol_amp_ratio,        # legacy alias
        "vol_amp_ratio": vol_amp_ratio,
        "vol_amp_prob": vol_amp_prob,
        "mean_path_close": np.mean(np.array(paths_close), axis=0).tolist(),
        "paths": paths_ohlc,
        "future_ts_ms": [int(t.timestamp() * 1000) for t in y_timestamp],
    }


demo = gr.Interface(
    fn=predict,
    inputs=[
        gr.Textbox(label="k_lines (JSON)", lines=3,
                   info="[[ts_ms, open, high, low, close, volume], ...],至少 50 根"),
        gr.Number(label="pred_len", value=24, precision=0),
        gr.Number(label="sample_count", value=10, precision=0),
        gr.Number(label="interval_ms", value=3600000, precision=0),
        gr.Number(label="T", value=1.0),
        gr.Number(label="top_p", value=0.9),
    ],
    outputs=gr.JSON(label="prediction"),
    api_name="predict",
    title="Kronos Monte-Carlo",
    description=(f"model={MODEL_REPO} · device={DEVICE} · "
                 f"gpu_duration={GPU_DURATION}s\n\n"
                 "API: POST /gradio_api/call/predict → 取 event_id → "
                 "GET /gradio_api/call/predict/{event_id}(SSE)"),
)

if __name__ == "__main__":
    demo.queue().launch(server_name="0.0.0.0", server_port=7860)