File size: 8,527 Bytes
3ad0b4b
9a1e8ca
3ad0b4b
9a1e8ca
 
 
 
29841a6
 
 
3ad0b4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a1e8ca
3ad0b4b
29841a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ad0b4b
 
 
 
 
 
 
9a1e8ca
3ad0b4b
 
 
9a1e8ca
 
 
 
 
 
3ad0b4b
 
9a1e8ca
 
 
 
 
 
 
3ad0b4b
 
 
9a1e8ca
3ad0b4b
 
 
 
 
 
 
 
9a1e8ca
 
3ad0b4b
 
 
 
 
 
 
 
 
 
9a1e8ca
3ad0b4b
 
 
 
 
 
 
 
 
 
9a1e8ca
3ad0b4b
 
 
 
 
 
 
 
 
 
9a1e8ca
 
 
 
 
3ad0b4b
 
 
 
 
 
 
 
 
 
 
9a1e8ca
3ad0b4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a1e8ca
3ad0b4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5234366
3ad0b4b
 
9a1e8ca
3ad0b4b
 
 
 
9a1e8ca
3ad0b4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a1e8ca
 
 
3ad0b4b
 
 
 
 
5347699
 
3ad0b4b
766b889
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
"""
Kronos Filter — HuggingFace Space (ZeroGPU)

Gradio app that wraps Kronos inference:
1. Visual UI (this page)
2. REST API at /call/predict (consumed by kronos-filter.vercel.app)

Runs on HF Spaces free CPU tier. @spaces.GPU decorator is kept as a no-op
so the code works on both CPU Spaces and (if upgraded to ZeroGPU via Pro)
GPU Spaces.
"""
from __future__ import annotations

import logging
import sys
from dataclasses import asdict
from datetime import datetime, timezone
from pathlib import Path

# Kronos model code lives at ./model/ in this Space
SPACE_ROOT = Path(__file__).resolve().parent
if str(SPACE_ROOT) not in sys.path:
    sys.path.insert(0, str(SPACE_ROOT))

import gradio as gr
import numpy as np
import torch

# @spaces.GPU decorator only useful on ZeroGPU (requires HF Pro).
# On free CPU tier we import it conditionally as a no-op.
try:
    import spaces
    SPACES_AVAILABLE = True
except ImportError:
    SPACES_AVAILABLE = False
    class _NoopGPU:
        def __call__(self, *a, **kw):
            def decorator(fn):
                return fn
            if len(a) == 1 and callable(a[0]):
                return a[0]
            return decorator
    class _NoopSpaces:
        GPU = _NoopGPU()
    spaces = _NoopSpaces()

from fetcher import fetch_aligned, build_future_timestamps
from predictor import KronosPathsPredictor
from probability_analyzer import analyze_ticker

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)

# Predictor is lazily initialized on first GPU call, then cached
PREDICTOR: KronosPathsPredictor | None = None


def _ensure_predictor_on_device(device: str) -> KronosPathsPredictor:
    """
    Lazy-init predictor and ensure it's on the target device.
    With ZeroGPU, GPU is only accessible inside @spaces.GPU blocks, so we
    initialize on CPU first then migrate to GPU inside the decorator.
    """
    global PREDICTOR
    if PREDICTOR is None:
        logger.info(f"Initializing Kronos-small on {device}...")
        PREDICTOR = KronosPathsPredictor(model_size="small", device=device)
    elif PREDICTOR.device != device:
        logger.info(f"Moving Kronos from {PREDICTOR.device} to {device}...")
        PREDICTOR.model = PREDICTOR.model.to(device)
        PREDICTOR.tokenizer = PREDICTOR.tokenizer.to(device)
        PREDICTOR.device = device
    return PREDICTOR


@spaces.GPU(duration=180)
def run_prediction(
    tickers_raw: str,
    lookback: int,
    pred_len: int,
    samples: int,
    seed_raw: str,
) -> dict:
    """
    Main prediction pipeline — runs on ZeroGPU (A10G when available).
    Duration budget: 180s max per call.
    """
    # Parse tickers (comma or newline separated)
    tickers = []
    for chunk in tickers_raw.replace("\n", ",").split(","):
        t = chunk.strip().upper()
        if t and t not in tickers:
            tickers.append(t)
    if not tickers:
        raise gr.Error("Please provide at least one ticker.")
    if len(tickers) > 30:
        raise gr.Error("Max 30 tickers per request.")

    lookback = int(lookback)
    pred_len = int(pred_len)
    samples = int(samples)
    seed = None
    if seed_raw and seed_raw.strip().isdigit():
        seed = int(seed_raw.strip())

    logger.info(f"Request: tickers={tickers}, lookback={lookback}, pred_len={pred_len}, samples={samples}, seed={seed}")

    # 1. Fetch OHLCV (on CPU, no GPU needed)
    ok_list, failed = fetch_aligned(tickers, lookback=lookback, interval="1d")
    if not ok_list:
        raise gr.Error(f"Could not fetch any valid ticker data. Failed: {failed}")

    # 2. Future timestamps
    future_ts_list = [
        build_future_timestamps(td.timestamps.iloc[-1], pred_len, "1d")
        for td in ok_list
    ]

    # 3. Ensure model is on GPU (ZeroGPU allocates cuda:0 inside this decorator)
    device = "cuda:0" if torch.cuda.is_available() else "cpu"
    logger.info(f"Inference device: {device} (cuda available: {torch.cuda.is_available()})")
    predictor = _ensure_predictor_on_device(device)

    paths_list = predictor.predict_paths(
        df_list=[td.df for td in ok_list],
        x_timestamp_list=[td.timestamps for td in ok_list],
        y_timestamp_list=future_ts_list,
        pred_len=pred_len,
        T=1.0, top_p=0.9,
        sample_count=samples,
        verbose=False,
        seed=seed,
    )

    # 4. Compute metrics (CPU, fast)
    metrics_list = []
    for td, paths in zip(ok_list, paths_list):
        try:
            m = analyze_ticker(td.ticker, paths, initial_close=float(td.df["close"].iloc[-1]))
            metrics_list.append(m)
        except Exception as exc:
            logger.warning(f"[{td.ticker}] analysis failed: {exc}")

    # 5. Build JSON report (same shape as local CLI's latest.json)
    report = {
        "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        "config": {
            "lookback": lookback,
            "pred_len": pred_len,
            "samples": samples,
            "model_size": "small",
            "interval": "1d",
            "T": 1.0,
            "top_p": 0.9,
            "seed": seed,
            "device": device,
        },
        "failed_tickers": failed,
        "data_last_date": ok_list[0].timestamps.iloc[-1].isoformat() if ok_list else None,
        "tickers": sorted(
            [asdict(m) for m in metrics_list],
            key=lambda x: x["prob_up"],
            reverse=True,
        ),
    }

    # Path summaries for fan chart
    path_summaries = []
    for td, paths, ts_future in zip(ok_list, paths_list, future_ts_list):
        close_paths = paths[:, :, 3]
        path_summaries.append({
            "ticker": td.ticker,
            "history_dates": [d.isoformat() for d in td.timestamps.iloc[-60:]],
            "history_close": td.df["close"].iloc[-60:].tolist(),
            "future_dates": [d.isoformat() for d in ts_future],
            "median_path": np.median(close_paths, axis=0).tolist(),
            "p5_path": np.percentile(close_paths, 5, axis=0).tolist(),
            "p25_path": np.percentile(close_paths, 25, axis=0).tolist(),
            "p75_path": np.percentile(close_paths, 75, axis=0).tolist(),
            "p95_path": np.percentile(close_paths, 95, axis=0).tolist(),
        })
    report["path_summaries"] = path_summaries

    return report


# ----------------------------------------------------------------- UI

with gr.Blocks(title="Kronos Filter API") as demo:
    gr.Markdown(
        """
        # 📈 Kronos Filter — Probability API (ZeroGPU)

        Compute backend for [kronos-filter.vercel.app](https://kronos-filter.vercel.app).
        Fill in tickers to get probabilistic forecasts, or call programmatically via `/call/predict`.

        > ⚙️ **Hardware**: ZeroGPU (A10G, dynamically allocated).
        > ⚠️ Not financial advice. Results are **model-subjective probabilities**.
        """
    )
    with gr.Row():
        with gr.Column(scale=2):
            tickers_in = gr.Textbox(
                label="Tickers (comma or newline separated)",
                placeholder="MSFT, NVDA, AAPL",
                lines=3,
                value="MSFT, NVDA, AAPL",
            )
            with gr.Row():
                lookback_in = gr.Number(label="Lookback", value=400, minimum=50, maximum=512, precision=0)
                pred_len_in = gr.Number(label="Pred len", value=30, minimum=1, maximum=120, precision=0)
                samples_in = gr.Number(label="Samples", value=30, minimum=5, maximum=60, precision=0)
                seed_in = gr.Textbox(label="Seed (blank = random)", value="42")
            run_btn = gr.Button("🚀 Predict", variant="primary")
        with gr.Column(scale=3):
            output_json = gr.JSON(label="Probability report", open=False)

    run_btn.click(
        fn=run_prediction,
        inputs=[tickers_in, lookback_in, pred_len_in, samples_in, seed_in],
        outputs=output_json,
        api_name="predict",
    )

    gr.Markdown(
        """
        ---
        **Model**: Kronos-small (24.7M params, 512 context) · ZeroGPU A10G ·
        First request cold-start ~15s (model load onto GPU), subsequent calls ~5-15s.
        Daily GPU quota for free HF accounts: ~5-10 min.
        Source: [github.com/az30667176-sudo/kronos-filter](https://github.com/az30667176-sudo/kronos-filter)
        """
    )


demo.queue(default_concurrency_limit=1)

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